Unverified 提交 b1c4aa28 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Add i18n internationalization support with language selector (#2450)

## Summary - Set up i18n infrastructure using i18next with locale files for English (chat, common, errors, home, settings namespaces) - Add LanguageSelector component to the settings page for users to switch languages - Add language preference field to the app schema and integrate i18next provider in the app layout and renderer ## Test plan - Verify the app builds and starts without errors - Navigate to Settings and confirm the Language Selector is visible and functional - Confirm English translations load correctly across all namespaces 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2450"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Broad UI text refactor plus new runtime language switching could surface missing keys, incorrect namespaces, or layout regressions, though changes are largely non-functional and localized to presentation. > > **Overview** > Adds app-wide internationalization via `i18next`/`react-i18next`, including a new `src/i18n` initialization with bundled namespaces and locale resources. > > Introduces a persisted `language` setting (validated by `LanguageSchema`) plus a new `LanguageSelector` UI, and syncs the active i18n language at startup in `RootLayout`. > > Migrates many user-facing strings across chat, settings, integrations, dialogs, banners, and preview panel components to use `t()` translation keys (with interpolation/plurals) instead of hardcoded English, and adds an i18n design doc. Dependencies are updated in `package.json`/lockfile. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a996131500b0f99ea766036084972f9863aca81d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Add app-wide internationalization with i18next and a language selector in Settings. The chosen language persists and updates the UI instantly; ships with English, Simplified Chinese (zh-CN), and Brazilian Portuguese (pt-BR), aligned with the Linear issue, and migrates UI text across the app. - **New Features** - Initialize i18next/react-i18next with namespaces (common, settings, chat, home, errors) before render; sync to UserSettings.language on startup. - Add LanguageSelector in Settings → General showing only completed locales; saves validated values and switches UI language. - Include complete en, zh-CN, pt-BR translations, Intl-based date/number/relative-time helpers, and docs/i18n.md. - **Refactors** - Replace hardcoded strings with t() across 50+ components. - Validate settings.language via LanguageSchema.safeParse; defer changeLanguage to layout sync to avoid duplicates; add error handling for updateSettings; extract constants in formatRelativeTime. <sup>Written for commit b670b0489415ca966db1f70f0a1ad3111a455538. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com> Co-authored-by: 's avatarclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
上级 a6baaef1
......@@ -26,6 +26,8 @@ Make sure you run this once after doing `npm install` because it will make sure
npm run init-precommit
```
**Note:** Running `npm install` may update `package-lock.json` with version changes or peer dependency flag removals. If rebasing or performing git operations, commit these changes first to avoid "unstaged changes" errors.
## Pre-commit checks
RUN THE FOLLOWING CHECKS before you do a commit.
......
差异被折叠。
......@@ -57,6 +57,7 @@
"geist": "^1.3.1",
"glob": "^11.0.2",
"html-to-image": "^1.11.13",
"i18next": "^25.8.0",
"isomorphic-git": "^1.30.1",
"jotai": "^2.12.2",
"jsonrepair": "^3.13.1",
......@@ -70,6 +71,7 @@
"posthog-js": "^1.236.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^16.5.4",
"react-konva": "^19.2.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
......@@ -14489,6 +14491,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
......@@ -14638,6 +14649,37 @@
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/i18next": {
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.0.tgz",
"integrity": "sha512-urrg4HMFFMQZ2bbKRK7IZ8/CTE7D8H4JRlAwqA2ZwDRFfdd0K/4cdbNNLgfn9mo+I/h9wJu61qJzH7jCFAhUZQ==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
......@@ -19710,6 +19752,33 @@
"react": ">=16.13.1"
}
},
"node_modules/react-i18next": {
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz",
"integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
......@@ -22758,7 +22827,7 @@
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
......@@ -23880,6 +23949,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/watchpack": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
......
......@@ -94,6 +94,7 @@
"geist": "^1.3.1",
"glob": "^11.0.2",
"html-to-image": "^1.11.13",
"i18next": "^25.8.0",
"isomorphic-git": "^1.30.1",
"jotai": "^2.12.2",
"jsonrepair": "^3.13.1",
......@@ -107,6 +108,7 @@
"posthog-js": "^1.236.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^16.5.4",
"react-konva": "^19.2.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
......
......@@ -36,3 +36,8 @@ Actions performed using the default `GITHUB_TOKEN` (including labels added by `g
```bash
gh api repos/dyad-sh/dyad/issues/{PR_NUMBER}/labels -f "labels[]=label-name"
```
## Rebase conflict resolution tips
- When resolving conflicts in i18n-related commits, watch for duplicate constant definitions that conflict with imports from `@/lib/schemas` (e.g., `DEFAULT_ZOOM_LEVEL`)
- If both sides of a conflict have valid imports/hooks, keep both and remove any duplicate constant redefinitions
......@@ -18,6 +18,8 @@ import { selectedComponentsPreviewAtom } from "@/atoms/previewAtoms";
import { chatInputValueAtom } from "@/atoms/chatAtoms";
import { usePlanEvents } from "@/hooks/usePlanEvents";
import { useZoomShortcuts } from "@/hooks/useZoomShortcuts";
import i18n from "@/i18n";
import { LanguageSchema } from "@/lib/schemas";
export default function RootLayout({ children }: { children: ReactNode }) {
const { refreshAppIframe } = useRunApp();
......@@ -62,6 +64,16 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return () => {};
}, [settings?.zoomLevel]);
// Sync i18n language with persisted user setting
useEffect(() => {
const parsed = LanguageSchema.safeParse(settings?.language);
const language = parsed.success ? parsed.data : "en";
if (i18n.language !== language) {
i18n.changeLanguage(language);
}
}, [settings?.language]);
// Global keyboard listener for refresh events
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
......
......@@ -2,6 +2,7 @@ import { useSettings } from "@/hooks/useSettings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { showInfo } from "@/lib/toast";
import { useTranslation } from "react-i18next";
export function AutoApproveSwitch({
showToast = true,
......@@ -9,6 +10,7 @@ export function AutoApproveSwitch({
showToast?: boolean;
}) {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
return (
<div className="flex items-center space-x-2">
<Switch
......@@ -22,7 +24,7 @@ export function AutoApproveSwitch({
}
}}
/>
<Label htmlFor="auto-approve">Auto-approve</Label>
<Label htmlFor="auto-approve">{t("workflow.autoApprove")}</Label>
</div>
);
}
import { useSettings } from "@/hooks/useSettings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslation } from "react-i18next";
import { showInfo } from "@/lib/toast";
......@@ -10,6 +11,7 @@ export function AutoFixProblemsSwitch({
showToast?: boolean;
}) {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
return (
<div className="flex items-center space-x-2">
<Switch
......@@ -25,7 +27,7 @@ export function AutoFixProblemsSwitch({
}
}}
/>
<Label htmlFor="auto-fix-problems">Auto-fix problems</Label>
<Label htmlFor="auto-fix-problems">{t("workflow.autoFixProblems")}</Label>
</div>
);
}
......@@ -3,9 +3,11 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
import { ipc } from "@/ipc/types";
import { useTranslation } from "react-i18next";
export function AutoUpdateSwitch() {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
if (!settings) {
return null;
......@@ -31,7 +33,7 @@ export function AutoUpdateSwitch() {
});
}}
/>
<Label htmlFor="enable-auto-update">Auto-update</Label>
<Label htmlFor="enable-auto-update">{t("general.autoUpdate")}</Label>
</div>
);
}
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
......@@ -34,6 +35,7 @@ import { ChatSearchDialog } from "./ChatSearchDialog";
import { useSelectChat } from "@/hooks/useSelectChat";
export function ChatList({ show }: { show?: boolean }) {
const { t } = useTranslation("chat");
const navigate = useNavigate();
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
const [selectedAppId] = useAtom(selectedAppIdAtom);
......@@ -115,7 +117,7 @@ export function ChatList({ show }: { show?: boolean }) {
await invalidateChats();
} catch (error) {
// DO A TOAST
showError(`Failed to create new chat: ${(error as any).toString()}`);
showError(t("failedCreateChat", { error: (error as any).toString() }));
}
} else {
// If no app is selected, navigate to home page
......@@ -126,7 +128,7 @@ export function ChatList({ show }: { show?: boolean }) {
const handleDeleteChat = async (chatId: number) => {
try {
await ipc.chat.deleteChat(chatId);
showSuccess("Chat deleted successfully");
showSuccess(t("chatDeleted"));
// If the deleted chat was selected, navigate to home
if (selectedChatId === chatId) {
......@@ -137,7 +139,7 @@ export function ChatList({ show }: { show?: boolean }) {
// Refresh the chat list
await invalidateChats();
} catch (error) {
showError(`Failed to delete chat: ${(error as any).toString()}`);
showError(t("failedDeleteChat", { error: (error as any).toString() }));
}
};
......@@ -176,7 +178,7 @@ export function ChatList({ show }: { show?: boolean }) {
className="overflow-y-auto h-[calc(100vh-112px)]"
data-testid="chat-list-container"
>
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<SidebarGroupLabel>{t("recentChats")}</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col space-y-4">
<Button
......@@ -185,7 +187,7 @@ export function ChatList({ show }: { show?: boolean }) {
className="flex items-center justify-start gap-2 mx-2 py-3"
>
<PlusCircle size={16} />
<span>New Chat</span>
<span>{t("newChat")}</span>
</Button>
<Button
onClick={() => setIsSearchDialogOpen(!isSearchDialogOpen)}
......@@ -194,16 +196,16 @@ export function ChatList({ show }: { show?: boolean }) {
data-testid="search-chats-button"
>
<Search size={16} />
<span>Search chats</span>
<span>{t("searchChats")}</span>
</Button>
{loading ? (
<div className="py-3 px-4 text-sm text-gray-500">
Loading chats...
{t("loadingChats")}
</div>
) : chats.length === 0 ? (
<div className="py-3 px-4 text-sm text-gray-500">
No chats found
{t("noChatsFound")}
</div>
) : (
<SidebarMenu className="space-y-1">
......@@ -226,7 +228,7 @@ export function ChatList({ show }: { show?: boolean }) {
>
<div className="flex flex-col w-full">
<span className="truncate">
{chat.title || "New Chat"}
{chat.title || t("newChat")}
</span>
<span className="text-xs text-gray-500">
{formatDistanceToNow(new Date(chat.createdAt), {
......@@ -262,19 +264,19 @@ export function ChatList({ show }: { show?: boolean }) {
className="px-3 py-2"
>
<Edit3 className="mr-2 h-4 w-4" />
<span>Rename Chat</span>
<span>{t("renameChat")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleDeleteChatClick(
chat.id,
chat.title || "New Chat",
chat.title || t("newChat"),
)
}
className="px-3 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/50 focus:bg-red-50 dark:focus:bg-red-950/50"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Chat</span>
<span>{t("deleteChat")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
......
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useAtomValue, useSetAtom } from "jotai";
import {
chatMessagesByIdAtom,
......@@ -35,6 +36,7 @@ export function ChatPanel({
isPreviewOpen,
onTogglePreview,
}: ChatPanelProps) {
const { t } = useTranslation("chat");
const messagesById = useAtomValue(chatMessagesByIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false);
......@@ -184,7 +186,7 @@ export function ChatPanel({
>
<ArrowDown className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent>Scroll to bottom</TooltipContent>
<TooltipContent>{t("scrollToBottom")}</TooltipContent>
</Tooltip>
</div>
)}
......
import { useTranslation } from "react-i18next";
import React from "react";
import {
AlertDialog,
......@@ -19,31 +20,25 @@ interface CommunityCodeConsentDialogProps {
export const CommunityCodeConsentDialog: React.FC<
CommunityCodeConsentDialogProps
> = ({ isOpen, onAccept, onCancel }) => {
const { t } = useTranslation(["home", "common"]);
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Community Code Notice</AlertDialogTitle>
<AlertDialogTitle>{t("home:communityCodeNotice")}</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
<p>
This code was created by a Dyad community member, not our core
team.
</p>
<p>
Community code can be very helpful, but since it's built
independently, it may have bugs, security risks, or could cause
issues with your system. We can't provide official support if
problems occur.
</p>
<p>
We recommend reviewing the code on GitHub first. Only proceed if
you're comfortable with these risks.
</p>
<p>{t("home:communityCodeWarning")}</p>
<p>{t("home:communityCodeRisk")}</p>
<p>{t("home:communityCodeReview")}</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onAccept}>Accept</AlertDialogAction>
<AlertDialogCancel onClick={onCancel}>
{t("common:cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={onAccept}>
{t("common:accept")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
......
import { useTranslation } from "react-i18next";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
......@@ -33,6 +34,7 @@ export function CreateAppDialog({
onOpenChange,
template,
}: CreateAppDialogProps) {
const { t } = useTranslation(["home", "common"]);
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const [appName, setAppName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
......@@ -84,27 +86,27 @@ export function CreateAppDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create New App</DialogTitle>
<DialogTitle>{t("home:createNewApp")}</DialogTitle>
<DialogDescription>
{`Create a new app using the ${template?.title} template.`}
{t("home:createAppUsingTemplate", { template: template?.title })}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="appName">App Name</Label>
<Label htmlFor="appName">{t("home:appName")}</Label>
<Input
id="appName"
value={appName}
onChange={(e) => setAppName(e.target.value)}
placeholder="Enter app name..."
placeholder={t("home:enterAppName")}
className={nameExists ? "border-red-500" : ""}
disabled={isSubmitting}
/>
{nameExists && (
<p className="text-sm text-red-500">
An app with this name already exists
{t("home:appNameAlreadyExists")}
</p>
)}
</div>
......@@ -117,7 +119,7 @@ export function CreateAppDialog({
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
{t("common:cancel")}
</Button>
<Button
type="submit"
......@@ -127,7 +129,7 @@ export function CreateAppDialog({
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isSubmitting ? "Creating..." : "Create App"}
{isSubmitting ? t("common:creating") : t("home:createApp")}
</Button>
</DialogFooter>
</form>
......
......@@ -9,10 +9,12 @@ import {
} from "@/components/ui/select";
import type { ChatMode } from "@/lib/schemas";
import { isDyadProEnabled, getEffectiveDefaultChatMode } from "@/lib/schemas";
import { useTranslation } from "react-i18next";
export function DefaultChatModeSelector() {
const { settings, updateSettings, envVars } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
const { t } = useTranslation("settings");
if (!settings) {
return null;
......@@ -53,7 +55,7 @@ export function DefaultChatModeSelector() {
htmlFor="default-chat-mode"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Default Chat Mode
{t("workflow.defaultChatMode")}
</label>
<Select
value={effectiveDefault}
......@@ -89,7 +91,7 @@ export function DefaultChatModeSelector() {
</Select>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
The chat mode used when creating new chats.
{t("workflow.defaultChatModeDescription")}
</div>
</div>
);
......
import { useTranslation } from "react-i18next";
import React from "react";
import { Trash2, Loader2 } from "lucide-react";
import {
......@@ -28,6 +29,7 @@ export function DeleteConfirmationDialog({
trigger,
isDeleting = false,
}: DeleteConfirmationDialogProps) {
const { t } = useTranslation(["home", "common"]);
return (
<AlertDialog>
{trigger ? (
......@@ -37,29 +39,32 @@ export function DeleteConfirmationDialog({
className={buttonVariants({ variant: "ghost", size: "icon" })}
data-testid="delete-prompt-button"
disabled={isDeleting}
title={`Delete ${itemType.toLowerCase()}`}
title={`${t("common:delete")} ${itemType.toLowerCase()}`}
>
<Trash2 className="h-4 w-4" />
</AlertDialogTrigger>
)}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {itemType}</AlertDialogTitle>
<AlertDialogTitle>
{t("home:deleteItemTitle", { itemType })}
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{itemName}"? This action cannot be
undone.
{t("home:deleteItemConfirmation", { itemName })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isDeleting}>
{t("common:cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={onDelete} disabled={isDeleting}>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
{t("common:deleting")}
</>
) : (
"Delete"
t("common:delete")
)}
</AlertDialogAction>
</AlertDialogFooter>
......
import { useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogAction,
......@@ -27,6 +28,7 @@ export function ForceCloseDialog({
onClose,
performanceData,
}: ForceCloseDialogProps) {
const { t } = useTranslation(["home", "common"]);
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
......@@ -37,19 +39,16 @@ export function ForceCloseDialog({
<AlertDialogHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
<AlertDialogTitle>Force Close Detected</AlertDialogTitle>
<AlertDialogTitle>{t("home:forceCloseDetected")}</AlertDialogTitle>
</div>
<AlertDialogDescription render={<div />}>
<div className="space-y-4 pt-2 text-muted-foreground">
<div className="text-base">
The app was not closed properly the last time it was running.
This could indicate a crash or unexpected termination.
</div>
<div className="text-base">{t("home:forceCloseDescription")}</div>
{performanceData && (
<div className="rounded-lg border bg-muted/50 p-4 space-y-3">
<div className="font-semibold text-sm text-foreground">
Last Known State:{" "}
{t("home:lastKnownState")}{" "}
<span className="font-normal text-muted-foreground">
{formatTimestamp(performanceData.timestamp)}
</span>
......@@ -59,18 +58,22 @@ export function ForceCloseDialog({
{/* Process Metrics */}
<div className="space-y-2">
<div className="font-medium text-foreground">
Process Metrics
{t("home:processMetrics")}
</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Memory:</span>
<span className="text-muted-foreground">
{t("home:memory")}
</span>
<span className="font-mono">
{performanceData.memoryUsageMB} MB
</span>
</div>
{performanceData.cpuUsagePercent !== undefined && (
<div className="flex justify-between">
<span className="text-muted-foreground">CPU:</span>
<span className="text-muted-foreground">
{t("home:cpu")}
</span>
<span className="font-mono">
{performanceData.cpuUsagePercent}%
</span>
......@@ -84,7 +87,7 @@ export function ForceCloseDialog({
performanceData.systemCpuPercent !== undefined) && (
<div className="space-y-2">
<div className="font-medium text-foreground">
System Metrics
{t("home:systemMetrics")}
</div>
<div className="space-y-1">
{performanceData.systemMemoryUsageMB !== undefined &&
......@@ -92,7 +95,7 @@ export function ForceCloseDialog({
undefined && (
<div className="flex justify-between">
<span className="text-muted-foreground">
Memory:
{t("home:memory")}
</span>
<span className="font-mono">
{performanceData.systemMemoryUsageMB} /{" "}
......@@ -103,7 +106,7 @@ export function ForceCloseDialog({
{performanceData.systemCpuPercent !== undefined && (
<div className="flex justify-between">
<span className="text-muted-foreground">
CPU:
{t("home:cpu")}
</span>
<span className="font-mono">
{performanceData.systemCpuPercent}%
......@@ -120,7 +123,9 @@ export function ForceCloseDialog({
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={onClose}>OK</AlertDialogAction>
<AlertDialogAction onClick={onClose}>
{t("common:ok")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
......
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Github,
......@@ -75,6 +76,7 @@ function ConnectedGitHubConnector({
triggerAutoSync,
onAutoSyncComplete,
}: ConnectedGitHubConnectorProps) {
const { t } = useTranslation(["home", "common"]);
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null);
const [syncSuccess, setSyncSuccess] = useState<boolean>(false);
......@@ -98,7 +100,9 @@ function ConnectedGitHubConnector({
await ipc.github.disconnect({ appId });
refreshApp();
} catch (err: any) {
setDisconnectError(err.message || "Failed to disconnect repository.");
setDisconnectError(
err.message || t("integrations.github.failedDisconnectRepo"),
);
} finally {
setIsDisconnecting(false);
}
......
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Github } from "lucide-react";
import { useSettings } from "@/hooks/useSettings";
import { showSuccess, showError } from "@/lib/toast";
export function GitHubIntegration() {
const { t } = useTranslation(["home", "common"]);
const { settings, updateSettings } = useSettings();
const [isDisconnecting, setIsDisconnecting] = useState(false);
......@@ -16,14 +18,12 @@ export function GitHubIntegration() {
githubUser: undefined,
});
if (result) {
showSuccess("Successfully disconnected from GitHub");
showSuccess(t("integrations.github.disconnected"));
} else {
showError("Failed to disconnect from GitHub");
showError(t("integrations.github.failedDisconnect"));
}
} catch (err: any) {
showError(
err.message || "An error occurred while disconnecting from GitHub",
);
showError(err.message || t("integrations.github.errorDisconnect"));
} finally {
setIsDisconnecting(false);
}
......@@ -39,10 +39,10 @@ export function GitHubIntegration() {
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
GitHub Integration
{t("integrations.github.title")}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your account is connected to GitHub.
{t("integrations.github.connected")}
</p>
</div>
......@@ -53,7 +53,9 @@ export function GitHubIntegration() {
disabled={isDisconnecting}
className="flex items-center gap-2"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from GitHub"}
{isDisconnecting
? t("common:disconnecting")
: t("integrations.github.disconnect")}
<Github className="h-4 w-4" />
</Button>
</div>
......
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSettings } from "@/hooks/useSettings";
import { Language, LanguageSchema } from "@/lib/schemas";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const DEFAULT_LANGUAGE: Language = "en";
/**
* Language labels shown in their native script so users can always
* find their language regardless of the current UI language.
* Only languages with completed translations are listed here.
*/
const LANGUAGE_OPTIONS: { value: Language; nativeLabel: string }[] = [
{ value: "en", nativeLabel: "English" },
{ value: "zh-CN", nativeLabel: "简体中文" },
{ value: "pt-BR", nativeLabel: "Português (Brasil)" },
// Additional languages will be added as translations are completed:
// { value: "ja", nativeLabel: "日本語" },
// { value: "ko", nativeLabel: "한국어" },
// { value: "es", nativeLabel: "Español" },
// { value: "fr", nativeLabel: "Français" },
// { value: "de", nativeLabel: "Deutsch" },
];
export function LanguageSelector() {
const { t } = useTranslation("settings");
const { settings, updateSettings } = useSettings();
const currentLanguage: Language = useMemo(() => {
const parsed = LanguageSchema.safeParse(settings?.language);
return parsed.success ? parsed.data : DEFAULT_LANGUAGE;
}, [settings?.language]);
const handleChange = async (value: Language | null) => {
if (!value) return;
try {
await updateSettings({ language: value });
// Language change is handled by the useEffect in layout.tsx
// after settings are successfully persisted
} catch (error) {
console.error("Failed to update language setting:", error);
// Settings update failed, so no language change will occur
}
};
return (
<div className="space-y-2">
<div className="flex flex-col gap-1">
<Label htmlFor="language">{t("general.language")}</Label>
<p className="text-sm text-muted-foreground">
{t("general.languageDescription")}
</p>
</div>
<Select value={currentLanguage} onValueChange={handleChange}>
<SelectTrigger id="language" className="w-[220px]">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.nativeLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
......@@ -8,6 +8,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { MAX_CHAT_TURNS_IN_CONTEXT } from "@/constants/settings_constants";
import { useTranslation } from "react-i18next";
interface OptionInfo {
value: string;
......@@ -49,6 +50,7 @@ const options: OptionInfo[] = [
export const MaxChatTurnsSelector: React.FC = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
const handleValueChange = (value: string) => {
if (value === "default") {
......@@ -74,14 +76,14 @@ export const MaxChatTurnsSelector: React.FC = () => {
htmlFor="max-chat-turns"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Maximum number of chat turns used in context
{t("ai.maxChatTurns")}
</label>
<Select
value={currentValue}
onValueChange={(v) => v && handleValueChange(v)}
>
<SelectTrigger className="w-[180px]" id="max-chat-turns">
<SelectValue placeholder="Select turns" />
<SelectValue placeholder={t("ai.selectMaxChatTurns")} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
......
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ipc } from "@/ipc/types";
import { toast } from "sonner";
......@@ -10,6 +11,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
export function NeonConnector() {
const { t } = useTranslation("home");
const { settings, refreshSettings } = useSettings();
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
const { isDarkMode } = useTheme();
......@@ -18,7 +20,7 @@ export function NeonConnector() {
const handleDeepLink = async () => {
if (lastDeepLink?.type === "neon-oauth-return") {
await refreshSettings();
toast.success("Successfully connected to Neon!");
toast.success(t("integrations.neon.connectedSuccess"));
clearLastDeepLink();
}
};
......@@ -30,7 +32,9 @@ export function NeonConnector() {
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
<div className="flex flex-col items-start justify-between">
<div className="flex items-center justify-between w-full">
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
<h2 className="text-lg font-medium pb-1">
{t("integrations.neon.database")}
</h2>
<Button
variant="outline"
onClick={() => {
......@@ -43,7 +47,7 @@ export function NeonConnector() {
</Button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
You are connected to Neon Database
{t("integrations.neon.connectedToNeon")}
</p>
<NeonDisconnectButton />
</div>
......@@ -54,9 +58,11 @@ export function NeonConnector() {
return (
<div className="flex flex-col space-y-4 p-4 border bg-white dark:bg-gray-800 max-w-100 rounded-md">
<div className="flex flex-col items-start justify-between">
<h2 className="text-lg font-medium pb-1">Neon Database</h2>
<h2 className="text-lg font-medium pb-1">
{t("integrations.neon.database")}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 pb-3">
Neon Database has a good free tier with backups and up to 10 projects.
{t("integrations.neon.freeTier")}
</p>
<div
onClick={async () => {
......@@ -71,7 +77,7 @@ export function NeonConnector() {
className="w-auto h-10 cursor-pointer flex items-center justify-center px-4 py-2 rounded-md border-2 transition-colors font-medium text-sm dark:bg-gray-900 dark:border-gray-700"
data-testid="connect-neon-button"
>
<span className="mr-2">Connect to</span>
<span className="mr-2">{t("integrations.neon.connectTo")}</span>
<NeonSvg isDarkMode={isDarkMode} />
</div>
</div>
......
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useSettings } from "@/hooks/useSettings";
......@@ -7,6 +8,7 @@ interface NeonDisconnectButtonProps {
}
export function NeonDisconnectButton({ className }: NeonDisconnectButtonProps) {
const { t } = useTranslation("home");
const { updateSettings, settings } = useSettings();
const handleDisconnect = async () => {
......@@ -14,10 +16,10 @@ export function NeonDisconnectButton({ className }: NeonDisconnectButtonProps) {
await updateSettings({
neon: undefined,
});
toast.success("Disconnected from Neon successfully");
toast.success(t("integrations.neon.disconnected"));
} catch (error) {
console.error("Failed to disconnect from Neon:", error);
toast.error("Failed to disconnect from Neon");
toast.error(t("integrations.neon.failedDisconnect"));
}
};
......@@ -32,7 +34,7 @@ export function NeonDisconnectButton({ className }: NeonDisconnectButtonProps) {
className={className}
size="sm"
>
Disconnect from Neon
{t("integrations.neon.disconnect")}
</Button>
);
}
import { useTranslation } from "react-i18next";
import { useSettings } from "@/hooks/useSettings";
import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
export function NeonIntegration() {
const { t } = useTranslation("home");
const { settings } = useSettings();
const isConnected = !!settings?.neon?.accessToken;
......@@ -14,10 +16,10 @@ export function NeonIntegration() {
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Neon Integration
{t("integrations.neon.title")}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your account is connected to Neon.
{t("integrations.neon.connected")}
</p>
</div>
......
......@@ -5,9 +5,11 @@ import { useSettings } from "@/hooks/useSettings";
import { showError, showSuccess } from "@/lib/toast";
import { ipc } from "@/ipc/types";
import { FolderOpen, RotateCcw, CheckCircle, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
export function NodePathSelector() {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
const [isSelectingPath, setIsSelectingPath] = useState(false);
const [nodeStatus, setNodeStatus] = useState<{
version: string | null;
......@@ -103,9 +105,7 @@ export function NodePathSelector() {
<div className="space-y-4">
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-sm font-medium">
Node.js Path Configuration
</Label>
<Label className="text-sm font-medium">{t("general.nodePath")}</Label>
<Button
onClick={handleSelectNodePath}
......@@ -115,7 +115,9 @@ export function NodePathSelector() {
className="flex items-center gap-2"
>
<FolderOpen className="w-4 h-4" />
{isSelectingPath ? "Selecting..." : "Browse for Node.js"}
{isSelectingPath
? t("general.selecting")
: t("general.browseForNode")}
</Button>
{isCustomPath && (
......@@ -126,7 +128,7 @@ export function NodePathSelector() {
className="flex items-center gap-2"
>
<RotateCcw className="w-4 h-4" />
Reset to Default
{t("general.resetToDefault")}
</Button>
)}
</div>
......@@ -135,7 +137,9 @@ export function NodePathSelector() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
{isCustomPath ? "Custom Path:" : "System PATH:"}
{isCustomPath
? t("general.customPath")
: t("general.systemPath")}
</span>
{isCustomPath && (
<span className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
......@@ -160,7 +164,7 @@ export function NodePathSelector() {
) : (
<div className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<AlertCircle className="w-4 h-4" />
<span className="text-xs">Not found</span>
<span className="text-xs">{t("general.notFound")}</span>
</div>
)}
</div>
......@@ -170,13 +174,10 @@ export function NodePathSelector() {
{/* Help Text */}
<div className="text-sm text-gray-500 dark:text-gray-400">
{nodeStatus.isValid ? (
<p>Node.js is properly configured and ready to use.</p>
<p>{t("general.nodeConfigured")}</p>
) : (
<>
<p>
Select the folder where Node.js is installed if it's not in your
system PATH.
</p>
<p>{t("general.nodeSelectFolder")}</p>
</>
)}
</div>
......
import { useTranslation } from "react-i18next";
// @ts-ignore
import openAiLogo from "../../assets/ai-logos/openai-logo.svg";
// @ts-ignore
......@@ -39,6 +40,7 @@ export function ProBanner() {
}
export function ManageDyadProButton({ className }: { className?: string }) {
const { t } = useTranslation("home");
return (
<Button
variant="outline"
......@@ -52,13 +54,14 @@ export function ManageDyadProButton({ className }: { className?: string }) {
}}
>
<Wallet aria-hidden="true" className="w-5 h-5" />
Manage Dyad Pro
{t("proBanner.manageDyadPro")}
<ArrowUpRight aria-hidden="true" className="w-5 h-5" />
</Button>
);
}
export function SetupDyadProButton() {
const { t } = useTranslation("home");
return (
<Button
variant="outline"
......@@ -69,12 +72,13 @@ export function SetupDyadProButton() {
}}
>
<KeyRound aria-hidden="true" />
Already have Dyad Pro? Add your key
{t("proBanner.alreadyHavePro")}
</Button>
);
}
export function AiAccessBanner() {
const { t } = useTranslation("home");
return (
<div
className="w-full py-2 sm:py-2.5 md:py-3 rounded-lg bg-gradient-to-br from-white via-indigo-50 to-sky-100 dark:from-indigo-700 dark:via-indigo-700 dark:to-indigo-900 flex items-center justify-center relative overflow-hidden ring-1 ring-inset ring-black/5 dark:ring-white/10 shadow-sm cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-[1px]"
......@@ -95,14 +99,14 @@ export function AiAccessBanner() {
<div className="relative z-10 text-center flex flex-col items-center gap-0.5 sm:gap-1 md:gap-1.5 px-4 md:px-6 pr-6 md:pr-8">
<div className="mt-0.5 sm:mt-1 flex items-center gap-2 sm:gap-3 justify-center">
<div className="text-xl font-semibold tracking-tight text-indigo-900 dark:text-indigo-100">
Access leading AI models with one plan
{t("proBanner.accessLeadingModels")}
</div>
<button
type="button"
aria-label="Subscribe to Dyad Pro"
className="inline-flex items-center rounded-md bg-white/90 text-indigo-800 hover:bg-white shadow px-3 py-1.5 text-xs sm:text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-white/50"
>
Get Dyad Pro
{t("proBanner.getDyadPro")}
</button>
</div>
......@@ -141,6 +145,7 @@ export function AiAccessBanner() {
}
export function SmartContextBanner() {
const { t } = useTranslation("home");
return (
<div
className="w-full py-2 sm:py-2.5 md:py-3 rounded-lg bg-gradient-to-br from-emerald-50 via-emerald-100 to-emerald-200 dark:from-emerald-700 dark:via-emerald-700 dark:to-emerald-900 flex items-center justify-center relative overflow-hidden ring-1 ring-inset ring-emerald-900/10 dark:ring-white/10 shadow-sm cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-[1px]"
......@@ -162,10 +167,10 @@ export function SmartContextBanner() {
<div className="mt-0.5 sm:mt-1 flex items-center gap-2 sm:gap-3 justify-center">
<div className="flex flex-col items-center text-center">
<div className="text-xl font-semibold tracking-tight text-emerald-900 dark:text-emerald-100">
Up to 3x cheaper
{t("proBanner.upTo3xCheaper")}
</div>
<div className="text-sm sm:text-base mt-1 text-emerald-700 dark:text-emerald-200/80">
by using Smart Context
{t("proBanner.byUsingSmartContext")}
</div>
</div>
<button
......@@ -173,7 +178,7 @@ export function SmartContextBanner() {
aria-label="Get Dyad Pro"
className="inline-flex items-center rounded-md bg-white/90 text-emerald-800 hover:bg-white shadow px-3 py-1.5 text-xs sm:text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-white/50"
>
Get Dyad Pro
{t("proBanner.getDyadPro")}
</button>
</div>
</div>
......@@ -182,6 +187,7 @@ export function SmartContextBanner() {
}
export function TurboBanner() {
const { t } = useTranslation("home");
return (
<div
className="w-full py-2 sm:py-2.5 md:py-3 rounded-lg bg-gradient-to-br from-rose-50 via-rose-100 to-rose-200 dark:from-rose-800 dark:via-fuchsia-800 dark:to-rose-800 flex items-center justify-center relative overflow-hidden ring-1 ring-inset ring-rose-900/10 dark:ring-white/5 shadow-sm cursor-pointer transition-all duration-200 hover:shadow-md hover:-translate-y-[1px]"
......@@ -203,10 +209,10 @@ export function TurboBanner() {
<div className="mt-0.5 sm:mt-1 flex items-center gap-2 sm:gap-3 justify-center">
<div className="flex flex-col items-center text-center">
<div className="text-xl font-semibold tracking-tight text-rose-900 dark:text-rose-100">
Generate code 4–10x faster
{t("proBanner.generateCode4x")}
</div>
<div className="text-sm sm:text-base mt-1 text-rose-700 dark:text-rose-200/80">
with Turbo Models & Turbo Edits
{t("proBanner.withTurboModels")}
</div>
</div>
<button
......@@ -214,7 +220,7 @@ export function TurboBanner() {
aria-label="Get Dyad Pro"
className="inline-flex items-center rounded-md bg-white/90 text-rose-800 hover:bg-white shadow px-3 py-1.5 text-xs sm:text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-white/50"
>
Get Dyad Pro
{t("proBanner.getDyadPro")}
</button>
</div>
</div>
......
......@@ -15,6 +15,7 @@ import { Skeleton } from "./ui/skeleton";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { AlertTriangle } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
......@@ -37,6 +38,7 @@ import { CreateCustomProviderDialog } from "./CreateCustomProviderDialog";
export function ProviderSettingsGrid() {
const navigate = useNavigate();
const { t } = useTranslation(["settings", "common"]);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] =
useState<LanguageModelProvider | null>(null);
......@@ -75,7 +77,9 @@ export function ProviderSettingsGrid() {
if (isLoading) {
return (
<div className="p-6">
<h2 className="text-lg font-medium mb-6">AI Providers</h2>
<h2 className="text-lg font-medium mb-6">
{t("settings:ai.providers")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<Card key={i} className="border-border">
......@@ -93,12 +97,14 @@ export function ProviderSettingsGrid() {
if (error) {
return (
<div className="p-6">
<h2 className="text-lg font-medium mb-6">AI Providers</h2>
<h2 className="text-lg font-medium mb-6">
{t("settings:ai.providers")}
</h2>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertTitle>{t("common:error")}</AlertTitle>
<AlertDescription>
Failed to load AI providers: {error.message}
{t("settings:ai.failedToLoadProviders", { message: error.message })}
</AlertDescription>
</Alert>
</div>
......@@ -107,7 +113,7 @@ export function ProviderSettingsGrid() {
return (
<div className="p-6">
<h2 className="text-lg font-medium mb-6">AI Providers</h2>
<h2 className="text-lg font-medium mb-6">{t("settings:ai.providers")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{providers
?.filter((p) => p.type !== "local")
......@@ -142,7 +148,9 @@ export function ProviderSettingsGrid() {
>
<Edit className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent>Edit Provider</TooltipContent>
<TooltipContent>
{t("settings:ai.editProvider")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
......@@ -158,7 +166,9 @@ export function ProviderSettingsGrid() {
>
<Trash2 className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent>Delete Provider</TooltipContent>
<TooltipContent>
{t("settings:ai.deleteProvider")}
</TooltipContent>
</Tooltip>
</div>
)}
......@@ -166,11 +176,11 @@ export function ProviderSettingsGrid() {
{provider.name}
{isProviderSetup(provider.id) ? (
<span className="ml-3 text-sm font-medium text-green-500 bg-green-50 dark:bg-green-900/30 border border-green-500/50 dark:border-green-500/50 px-2 py-1 rounded-full">
Ready
{t("common:ready")}
</span>
) : (
<span className="text-sm text-gray-500 bg-gray-50 dark:bg-gray-900 dark:text-gray-300 px-2 py-1 rounded-full">
Needs Setup
{t("common:needsSetup")}
</span>
)}
</CardTitle>
......@@ -178,7 +188,7 @@ export function ProviderSettingsGrid() {
{provider.hasFreeTier && (
<span className="text-blue-600 mt-2 dark:text-blue-400 text-sm font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded-full inline-flex items-center">
<GiftIcon className="w-4 h-4 mr-1" />
Free tier available
{t("settings:ai.freeTierAvailable")}
</span>
)}
</CardDescription>
......@@ -195,10 +205,10 @@ export function ProviderSettingsGrid() {
<CardHeader className="p-4 flex flex-col items-center justify-center h-full">
<PlusIcon className="h-8 w-8 text-muted-foreground mb-2" />
<CardTitle className="text-lg font-medium text-center">
Add custom provider
{t("settings:ai.addCustomProvider")}
</CardTitle>
<CardDescription className="text-center">
Connect to a custom LLM API endpoint
{t("settings:ai.connectCustomEndpoint")}
</CardDescription>
</CardHeader>
</Card>
......@@ -224,19 +234,24 @@ export function ProviderSettingsGrid() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Custom Provider</AlertDialogTitle>
<AlertDialogTitle>
{t("settings:ai.deleteCustomProvider")}
</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this custom provider and all its
associated models. This action cannot be undone.
{t("settings:ai.deleteProviderConfirmation")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isDeleting}>
{t("common:cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteProvider}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete Provider"}
{isDeleting
? t("common:deleting")
: t("settings:ai.deleteProviderAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
......
......@@ -10,9 +10,11 @@ import {
import { toast } from "sonner";
import { ipc } from "@/ipc/types";
import type { ReleaseChannel } from "@/lib/schemas";
import { useTranslation } from "react-i18next";
export function ReleaseChannelSelector() {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
if (!settings) {
return null;
......@@ -52,7 +54,7 @@ export function ReleaseChannelSelector() {
htmlFor="release-channel"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Release Channel
{t("general.releaseChannel")}
</label>
<Select
value={settings.releaseChannel}
......@@ -62,14 +64,13 @@ export function ReleaseChannelSelector() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stable">Stable</SelectItem>
<SelectItem value="beta">Beta</SelectItem>
<SelectItem value="stable">{t("general.stable")}</SelectItem>
<SelectItem value="beta">{t("general.beta")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
<p>Stable is recommended for most users. </p>
<p>Beta receives more frequent updates but may have more bugs.</p>
<p>{t("general.releaseChannelDescription")}</p>
</div>
</div>
);
......
......@@ -9,9 +9,11 @@ import {
import { useSettings } from "@/hooks/useSettings";
import { showError } from "@/lib/toast";
import { ipc } from "@/ipc/types";
import { useTranslation } from "react-i18next";
export function RuntimeModeSelector() {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
if (!settings) {
return null;
......@@ -32,7 +34,7 @@ export function RuntimeModeSelector() {
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Label className="text-sm font-medium" htmlFor="runtime-mode">
Runtime Mode
{t("general.runtimeMode")}
</Label>
<Select
value={settings.runtimeMode2 ?? "host"}
......@@ -48,8 +50,7 @@ export function RuntimeModeSelector() {
</Select>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Choose whether to run apps directly on the local machine or in Docker
containers
{t("general.runtimeModeDescription")}
</div>
</div>
{isDockerMode && (
......
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
import {
ChevronRight,
......@@ -45,6 +46,7 @@ type NodeInstallStep =
| "finished-checking";
export function SetupBanner() {
const { t } = useTranslation("home");
const posthog = usePostHog();
const navigate = useNavigate();
const [isOnboardingVisible, setIsOnboardingVisible] = useState(true);
......@@ -157,7 +159,7 @@ export function SetupBanner() {
if (itemsNeedAction.length === 0) {
return (
<h1 className="text-center text-5xl font-bold mb-8 bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-gray-100 dark:to-gray-400 tracking-tight">
Build a new app
{t("setup.buildNewApp")}
</h1>
);
}
......@@ -181,7 +183,7 @@ export function SetupBanner() {
return (
<>
<p className="text-xl font-medium text-zinc-700 dark:text-zinc-300 p-4 pt-6">
Setup Dyad
{t("setup.setupDyad")}
</p>
<OnboardingBanner
isVisible={isOnboardingVisible}
......@@ -204,7 +206,7 @@ export function SetupBanner() {
<div className="flex items-center gap-3">
{getStatusIcon(isNodeSetupComplete, nodeCheckError)}
<span className="font-medium text-sm">
1. Install Node.js (App Runtime)
{t("setup.installNodeJs")}
</span>
</div>
</div>
......@@ -212,26 +214,33 @@ export function SetupBanner() {
<AccordionContent className="px-4 pt-2 pb-4 bg-white dark:bg-zinc-900 border-t border-inherit">
{nodeCheckError && (
<p className="text-sm text-red-600 dark:text-red-400">
Error checking Node.js status. Try installing Node.js.
{t("setup.errorCheckingNode")}
</p>
)}
{isNodeSetupComplete ? (
<p className="text-sm">
Node.js ({nodeSystemInfo!.nodeVersion}) installed.{" "}
{t("setup.nodeInstalled", {
version: nodeSystemInfo!.nodeVersion,
})}{" "}
{nodeSystemInfo!.pnpmVersion && (
<span className="text-xs text-gray-500">
{" "}
(optional) pnpm ({nodeSystemInfo!.pnpmVersion}) installed.
{t("setup.pnpmInstalled", {
version: nodeSystemInfo!.pnpmVersion,
})}
</span>
)}
</p>
) : (
<div className="text-sm">
<p>Node.js is required to run apps locally.</p>
<p>{t("setup.nodeRequired")}</p>
{nodeInstallStep === "waiting-for-continue" && (
<p className="mt-1">
After you have installed Node.js, click "Continue". If the
installer didn't work, try{" "}
{
t("setup.afterInstallNode").split(
t("setup.moreDownloadOptions"),
)[0]
}
<a
className="text-blue-500 dark:text-blue-400 hover:underline"
onClick={() => {
......@@ -240,7 +249,7 @@ export function SetupBanner() {
);
}}
>
more download options
{t("setup.moreDownloadOptions")}
</a>
.
</p>
......@@ -256,7 +265,7 @@ export function SetupBanner() {
onClick={() => setShowManualConfig(!showManualConfig)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Node.js already installed? Configure path manually →
{t("setup.nodeAlreadyInstalled")}
</button>
{showManualConfig && (
......
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
......@@ -16,6 +17,7 @@ import { showSuccess, showError } from "@/lib/toast";
import { isSupabaseConnected } from "@/lib/schemas";
export function SupabaseIntegration() {
const { t } = useTranslation(["home", "common"]);
const { settings, updateSettings } = useSettings();
const [isDisconnecting, setIsDisconnecting] = useState(false);
......@@ -35,10 +37,10 @@ export function SupabaseIntegration() {
enableSupabaseWriteSqlMigration: false,
});
if (result) {
showSuccess("Successfully disconnected all Supabase organizations");
showSuccess(t("integrations.supabase.disconnectedAll"));
await refetchOrganizations();
} else {
showError("Failed to disconnect from Supabase");
showError(t("integrations.supabase.failedDisconnect"));
}
} catch (err: any) {
showError(
......@@ -52,9 +54,9 @@ export function SupabaseIntegration() {
const handleDeleteOrganization = async (organizationSlug: string) => {
try {
await deleteOrganization({ organizationSlug });
showSuccess("Organization disconnected successfully");
showSuccess(t("integrations.supabase.orgDisconnected"));
} catch (err: any) {
showError(err.message || "Failed to disconnect organization");
showError(err.message || t("integrations.supabase.failedDisconnect"));
}
};
......@@ -63,7 +65,7 @@ export function SupabaseIntegration() {
await updateSettings({
enableSupabaseWriteSqlMigration: enabled,
});
showSuccess("Setting updated");
showSuccess(t("integrations.supabase.settingUpdated"));
} catch (err: any) {
showError(err.message || "Failed to update setting");
}
......@@ -89,11 +91,12 @@ export function SupabaseIntegration() {
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Supabase Integration
{t("integrations.supabase.title")}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{organizations.length} organization
{organizations.length !== 1 ? "s" : ""} connected to Supabase.
{t("integrations.supabase.organizationsConnected", {
count: organizations.length,
})}
</p>
</div>
<Button
......@@ -103,7 +106,9 @@ export function SupabaseIntegration() {
disabled={isDisconnecting}
className="flex items-center gap-2"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect All"}
{isDisconnecting
? t("common:disconnecting")
: t("integrations.supabase.disconnectAll")}
<DatabaseZap className="h-4 w-4" />
</Button>
</div>
......@@ -141,7 +146,9 @@ export function SupabaseIntegration() {
<Trash2 className="h-3.5 w-3.5 mr-1" />
<span className="text-xs">Disconnect</span>
</TooltipTrigger>
<TooltipContent>Disconnect organization</TooltipContent>
<TooltipContent>
{t("integrations.supabase.disconnectOrganization")}
</TooltipContent>
</Tooltip>
</div>
))}
......@@ -160,13 +167,10 @@ export function SupabaseIntegration() {
htmlFor="supabase-migrations"
className="text-sm font-medium"
>
Write SQL migration files
{t("integrations.supabase.writeSqlMigrations")}
</Label>
<p className="text-xs text-gray-500 dark:text-gray-400">
Generate SQL migration files when modifying your Supabase schema.
This helps you track database changes in version control, though
these files aren't used for chat context, which uses the live
schema.
{t("integrations.supabase.writeSqlDescription")}
</p>
</div>
</div>
......
import { useTranslation } from "react-i18next";
import { ipc } from "@/ipc/types";
import React from "react";
import { Button } from "./ui/button";
......@@ -9,6 +10,7 @@ const hideBannerAtom = atom(false);
export function PrivacyBanner() {
const [hideBanner, setHideBanner] = useAtom(hideBannerAtom);
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
// TODO: Implement state management for banner visibility and user choice
// TODO: Implement functionality for Accept, Reject, Ask me later buttons
// TODO: Add state to hide/show banner based on user choice
......@@ -26,10 +28,8 @@ export function PrivacyBanner() {
Share anonymous data?
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Help improve Dyad with anonymous usage data.
<em className="block italic mt-0.5">
Note: this does not log your code or messages.
</em>
{t("telemetry.privacyNotice")}
<br />
<a
onClick={() => {
ipc.system.openExternalUrl(
......@@ -50,7 +50,7 @@ export function PrivacyBanner() {
}}
data-testid="telemetry-accept-button"
>
Accept
{t("telemetry.acceptAndContinue")}
</Button>
<Button
variant="secondary"
......
import { useSettings } from "@/hooks/useSettings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslation } from "react-i18next";
export function TelemetrySwitch() {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
return (
<div className="flex items-center space-x-2">
<Switch
......@@ -19,7 +21,7 @@ export function TelemetrySwitch() {
});
}}
/>
<Label htmlFor="telemetry-switch">Telemetry</Label>
<Label htmlFor="telemetry-switch">{t("telemetry.enable")}</Label>
</div>
);
}
......@@ -7,6 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslation } from "react-i18next";
interface OptionInfo {
value: string;
......@@ -38,6 +39,7 @@ const options: OptionInfo[] = [
export const ThinkingBudgetSelector: React.FC = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
const handleValueChange = (value: string) => {
updateSettings({ thinkingBudget: value as "low" | "medium" | "high" });
......@@ -57,14 +59,14 @@ export const ThinkingBudgetSelector: React.FC = () => {
htmlFor="thinking-budget"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Thinking Budget
{t("ai.thinkingBudget")}
</label>
<Select
value={currentValue}
onValueChange={(v) => v && handleValueChange(v)}
>
<SelectTrigger className="w-[180px]" id="thinking-budget">
<SelectValue placeholder="Select budget" />
<SelectValue placeholder={t("ai.selectThinkingBudget")} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
......
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { useSettings } from "@/hooks/useSettings";
import { showSuccess, showError } from "@/lib/toast";
export function VercelIntegration() {
const { t } = useTranslation(["home", "common"]);
const { settings, updateSettings } = useSettings();
const [isDisconnecting, setIsDisconnecting] = useState(false);
......@@ -14,14 +16,12 @@ export function VercelIntegration() {
vercelAccessToken: undefined,
});
if (result) {
showSuccess("Successfully disconnected from Vercel");
showSuccess(t("integrations.vercel.disconnected"));
} else {
showError("Failed to disconnect from Vercel");
showError(t("integrations.vercel.failedDisconnect"));
}
} catch (err: any) {
showError(
err.message || "An error occurred while disconnecting from Vercel",
);
showError(err.message || t("integrations.vercel.errorDisconnect"));
} finally {
setIsDisconnecting(false);
}
......@@ -37,10 +37,10 @@ export function VercelIntegration() {
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Vercel Integration
{t("integrations.vercel.title")}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your account is connected to Vercel.
{t("integrations.vercel.connected")}
</p>
</div>
......@@ -51,7 +51,9 @@ export function VercelIntegration() {
disabled={isDisconnecting}
className="flex items-center gap-2"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from Vercel"}
{isDisconnecting
? t("common:disconnecting")
: t("integrations.vercel.disconnect")}
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 22.525H0l12-21.05 12 21.05z" />
</svg>
......
......@@ -9,6 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslation } from "react-i18next";
const ZOOM_LEVEL_LABELS: Record<ZoomLevel, string> = {
"90": "90%",
......@@ -28,6 +29,7 @@ const ZOOM_LEVEL_DESCRIPTIONS: Record<ZoomLevel, string> = {
export function ZoomSelector() {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings");
const currentZoomLevel: ZoomLevel = useMemo(() => {
const value = settings?.zoomLevel ?? DEFAULT_ZOOM_LEVEL;
return ZoomLevelSchema.safeParse(value).success
......@@ -38,9 +40,9 @@ export function ZoomSelector() {
return (
<div className="space-y-2">
<div className="flex flex-col gap-1">
<Label htmlFor="zoom-level">Zoom level</Label>
<Label htmlFor="zoom-level">{t("general.zoom")}</Label>
<p className="text-sm text-muted-foreground">
Adjusts the zoom level to make content easier to read.
{t("general.zoomDescription")}
</p>
</div>
<Select
......@@ -50,7 +52,7 @@ export function ZoomSelector() {
}
>
<SelectTrigger id="zoom-level" className="w-[220px]">
<SelectValue placeholder="Select zoom level" />
<SelectValue placeholder={t("general.selectZoom")} />
</SelectTrigger>
<SelectContent>
{Object.entries(ZOOM_LEVEL_LABELS).map(([value, label]) => (
......
import { FileText, X, MessageSquare, Upload } from "lucide-react";
import type { FileAttachment } from "@/ipc/types";
import { useTranslation } from "react-i18next";
interface AttachmentsListProps {
attachments: FileAttachment[];
......@@ -10,6 +11,8 @@ export function AttachmentsList({
attachments,
onRemove,
}: AttachmentsListProps) {
const { t } = useTranslation("chat");
if (attachments.length === 0) return null;
return (
......@@ -61,7 +64,7 @@ export function AttachmentsList({
<button
onClick={() => onRemove(index)}
className="hover:bg-muted-foreground/20 rounded-full p-0.5"
aria-label="Remove attachment"
aria-label={t("removeAttachment")}
>
<X size={12} />
</button>
......
import { XCircle, AlertTriangle } from "lucide-react"; // Assuming lucide-react is used
import { useTranslation } from "react-i18next";
interface ChatErrorProps {
error: string | null;
......@@ -6,6 +7,8 @@ interface ChatErrorProps {
}
export function ChatError({ error, onDismiss }: ChatErrorProps) {
const { t } = useTranslation("chat");
if (!error) {
return null;
}
......@@ -23,7 +26,7 @@ export function ChatError({ error, onDismiss }: ChatErrorProps) {
<button
onClick={onDismiss}
className="absolute top-1 right-1 p-1 rounded-full hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
aria-label="Dismiss error"
aria-label={t("dismissError")}
>
<XCircle className="h-4 w-4 text-red-500 hover:text-red-700" />
</button>
......
......@@ -6,6 +6,7 @@ import {
Info,
} from "lucide-react";
import { PanelRightClose } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAtom, useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useVersions } from "@/hooks/useVersions";
......@@ -43,6 +44,7 @@ export function ChatHeader({
onTogglePreview,
onVersionClick,
}: ChatHeaderProps) {
const { t } = useTranslation("chat");
const appId = useAtomValue(selectedAppIdAtom);
const { versions, loading: versionsLoading } = useVersions(appId);
const { navigate } = useRouter();
......@@ -78,7 +80,7 @@ export function ChatHeader({
// If this throws, it will automatically show an error toast
await renameBranch({ oldBranchName: "master", newBranchName: "main" });
showSuccess("Master branch renamed to main");
showSuccess(t("header.masterRenamed"));
};
const handleNewChat = async () => {
......@@ -92,7 +94,7 @@ export function ChatHeader({
});
await invalidateChats();
} catch (error) {
showError(`Failed to create new chat: ${(error as any).toString()}`);
showError(t("failedCreateChat", { error: (error as any).toString() }));
}
} else {
navigate({ to: "/" });
......@@ -123,14 +125,14 @@ export function ChatHeader({
<span className="flex items-center gap-1">
{isAnyCheckoutVersionInProgress ? (
<>
<span>
Please wait, switching back to latest version...
</span>
<span>{t("header.switchingToLatest")}</span>
</>
) : (
<>
<strong>Warning:</strong>
<span>You are not on a branch</span>
<strong>
{t("header.warningNotOnBranch").split(":")[0]}:
</strong>
<span>{t("header.notOnBranch")}</span>
<Info size={14} />
</>
)}
......@@ -139,8 +141,8 @@ export function ChatHeader({
<TooltipContent>
<p>
{isAnyCheckoutVersionInProgress
? "Version checkout is currently in progress"
: "Checkout main branch, otherwise changes will not be saved properly"}
? t("header.checkoutInProgress")
: t("header.checkoutMainBranch")}
</p>
</TooltipContent>
</Tooltip>
......@@ -148,11 +150,9 @@ export function ChatHeader({
</>
)}
{currentBranchName && currentBranchName !== "<no-branch>" && (
<span>
You are on branch: <strong>{currentBranchName}</strong>.
</span>
<span>{t("header.onBranch", { name: currentBranchName })}</span>
)}
{branchInfoLoading && <span>Checking branch...</span>}
{branchInfoLoading && <span>{t("header.checkingBranch")}</span>}
</span>
</div>
{currentBranchName === "master" ? (
......@@ -162,7 +162,9 @@ export function ChatHeader({
onClick={handleRenameMasterToMain}
disabled={isRenamingBranch || branchInfoLoading}
>
{isRenamingBranch ? "Renaming..." : "Rename master to main"}
{isRenamingBranch
? t("header.renaming")
: t("header.renameMasterToMain")}
</Button>
) : isAnyCheckoutVersionInProgress && !isCheckingOutVersion ? null : (
<Button
......@@ -172,8 +174,8 @@ export function ChatHeader({
disabled={isCheckingOutVersion || branchInfoLoading}
>
{isCheckingOutVersion
? "Checking out..."
: "Switch to main branch"}
? t("header.checkingOut")
: t("header.switchToMainBranch")}
</Button>
)}
</div>
......@@ -194,7 +196,7 @@ export function ChatHeader({
className="hidden @2xs:flex items-center justify-start gap-2 mx-2 py-3"
>
<PlusCircle size={16} />
<span>New Chat</span>
<span>{t("newChat")}</span>
</Button>
<Button
onClick={onVersionClick}
......@@ -204,7 +206,7 @@ export function ChatHeader({
<History size={16} />
{versionsLoading
? "..."
: `Version ${versions.length}${versionPostfix}`}
: `${t("header.versionCount", { count: versions.length })}${versionPostfix}`}
</Button>
</div>
......
......@@ -8,6 +8,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useTranslation } from "react-i18next";
interface DeleteChatDialogProps {
isOpen: boolean;
......@@ -22,28 +23,28 @@ export function DeleteChatDialog({
onConfirmDelete,
chatTitle,
}: DeleteChatDialogProps) {
const { t } = useTranslation("chat");
const { t: tc } = useTranslation("common");
return (
<AlertDialog open={isOpen} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chat</AlertDialogTitle>
<AlertDialogTitle>{t("deleteChat")}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{chatTitle || "this chat"}"? This
action cannot be undone and all messages in this chat will be
permanently lost.
{t("deleteChatConfirmation", { title: chatTitle || "this chat" })}
<br />
<br />
<strong>Note:</strong> Any code changes that have already been
accepted will be kept.
<strong>{t("deleteChatNote")}</strong>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{tc("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirmDelete}
className="bg-red-600 text-white hover:bg-red-700 dark:bg-red-600 dark:text-white dark:hover:bg-red-700"
>
Delete Chat
{t("deleteChat")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
......
import { Paperclip } from "lucide-react";
import { useTranslation } from "react-i18next";
interface DragDropOverlayProps {
isDraggingOver: boolean;
}
export function DragDropOverlay({ isDraggingOver }: DragDropOverlayProps) {
const { t } = useTranslation("chat");
if (!isDraggingOver) return null;
return (
<div className="absolute inset-0 bg-blue-100/30 dark:bg-blue-900/30 flex items-center justify-center rounded-lg z-10 pointer-events-none">
<div className="bg-background p-4 rounded-lg shadow-lg text-center">
<Paperclip className="mx-auto mb-2 text-blue-500" />
<p className="text-sm font-medium">Drop files to attach</p>
<p className="text-sm font-medium">{t("dropFilesToAttach")}</p>
</div>
</div>
);
......
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ipc } from "@/ipc/types";
import { showError, showSuccess } from "@/lib/toast";
import {
......@@ -28,6 +29,8 @@ export function RenameChatDialog({
onOpenChange,
onRename,
}: RenameChatDialogProps) {
const { t } = useTranslation("chat");
const { t: tc } = useTranslation("common");
const [newTitle, setNewTitle] = useState("");
// Reset title when dialog opens
......@@ -50,7 +53,7 @@ export function RenameChatDialog({
chatId,
title: newTitle.trim(),
});
showSuccess("Chat renamed successfully");
showSuccess(t("chatRenamed"));
// Call the parent's onRename callback to refresh the chat list
onRename();
......@@ -58,7 +61,7 @@ export function RenameChatDialog({
// Close the dialog
handleOpenChange(false);
} catch (error) {
showError(`Failed to rename chat: ${(error as any).toString()}`);
showError(t("failedRenameChat", { error: (error as any).toString() }));
}
};
......@@ -70,20 +73,20 @@ export function RenameChatDialog({
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Chat</DialogTitle>
<DialogDescription>Enter a new name for this chat.</DialogDescription>
<DialogTitle>{t("renameChat")}</DialogTitle>
<DialogDescription>{t("renameChatDescription")}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="chat-title" className="text-right">
Title
{t("chatTitle")}
</Label>
<Input
id="chat-title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="col-span-3"
placeholder="Enter chat title..."
placeholder={t("enterChatTitle")}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSave();
......@@ -94,10 +97,10 @@ export function RenameChatDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancel
{tc("cancel")}
</Button>
<Button onClick={handleSave} disabled={!newTitle.trim()}>
Save
{tc("save")}
</Button>
</DialogFooter>
</DialogContent>
......
......@@ -33,6 +33,7 @@ import { showError, showSuccess } from "@/lib/toast";
import { useMutation } from "@tanstack/react-query";
import { useCheckProblems } from "@/hooks/useCheckProblems";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
import { useTranslation } from "react-i18next";
export type PreviewMode =
| "preview"
......@@ -44,6 +45,7 @@ export type PreviewMode =
// Preview Header component with preview mode toggle
export const ActionHeader = () => {
const { t } = useTranslation("home");
const [previewMode, setPreviewMode] = useAtom(previewModeAtom);
const [isPreviewOpen, setIsPreviewOpen] = useAtom(isPreviewOpenAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom);
......@@ -218,14 +220,14 @@ export const ActionHeader = () => {
"preview",
previewRef,
<Eye size={iconSize} />,
"Preview",
t("preview.title"),
"preview-mode-button",
)}
{renderButton(
"problems",
problemsRef,
<AlertTriangle size={iconSize} />,
"Problems",
t("preview.problems"),
"problems-mode-button",
displayCount && (
<span className="ml-0.5 px-1 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-full min-w-[16px] text-center">
......@@ -237,28 +239,28 @@ export const ActionHeader = () => {
"code",
codeRef,
<Code size={iconSize} />,
"Code",
t("preview.code"),
"code-mode-button",
)}
{renderButton(
"configure",
configureRef,
<Wrench size={iconSize} />,
"Configure",
t("preview.configure"),
"configure-mode-button",
)}
{renderButton(
"security",
securityRef,
<Shield size={iconSize} />,
"Security",
t("preview.security"),
"security-mode-button",
)}
{renderButton(
"publish",
publishRef,
<Globe size={iconSize} />,
"Publish",
t("preview.publish"),
"publish-mode-button",
)}
</div>
......@@ -277,24 +279,24 @@ export const ActionHeader = () => {
>
<MoreVertical size={16} />
</TooltipTrigger>
<TooltipContent>More options</TooltipContent>
<TooltipContent>{t("preview.moreOptions")}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuItem onClick={onCleanRestart}>
<Cog size={16} />
<div className="flex flex-col">
<span>Rebuild</span>
<span>{t("preview.rebuild")}</span>
<span className="text-xs text-muted-foreground">
Re-installs node_modules and restarts
{t("preview.rebuildDescription")}
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={onClearSessionData}>
<Trash2 size={16} />
<div className="flex flex-col">
<span>Clear Cache</span>
<span>{t("preview.clearCache")}</span>
<span className="text-xs text-muted-foreground">
Clears cookies and local storage and other app cache
{t("preview.clearCacheDescription")}
</span>
</div>
</DropdownMenuItem>
......
......@@ -10,6 +10,7 @@ import {
} from "@/components/ui/tooltip";
import { useAtomValue } from "jotai";
import { selectedFileAtom } from "@/atoms/viewAtoms";
import { useTranslation } from "react-i18next";
interface App {
id?: number;
......@@ -23,6 +24,7 @@ export interface CodeViewProps {
// Code view component that displays app files or status messages
export const CodeView = ({ loading, app }: CodeViewProps) => {
const { t } = useTranslation("home");
const selectedFile = useAtomValue(selectedFileAtom);
const { refreshApp } = useLoadApp(app?.id ?? null);
const [isFullscreen, setIsFullscreen] = useState(false);
......@@ -48,12 +50,14 @@ export const CodeView = ({ loading, app }: CodeViewProps) => {
}, [isFullscreen]);
if (loading) {
return <div className="text-center py-4">Loading files...</div>;
return <div className="text-center py-4">{t("preview.loadingFiles")}</div>;
}
if (!app) {
return (
<div className="text-center py-4 text-gray-500">No app selected</div>
<div className="text-center py-4 text-gray-500">
{t("preview.noAppSelected")}
</div>
);
}
......@@ -76,9 +80,11 @@ export const CodeView = ({ loading, app }: CodeViewProps) => {
>
<RefreshCw size={16} />
</TooltipTrigger>
<TooltipContent>Refresh Files</TooltipContent>
<TooltipContent>{t("preview.refreshFiles")}</TooltipContent>
</Tooltip>
<div className="text-sm text-gray-500">{app.files.length} files</div>
<div className="text-sm text-gray-500">
{app.files.length} {t("preview.files")}
</div>
<div className="flex-1" />
<Tooltip>
<TooltipTrigger
......@@ -92,7 +98,9 @@ export const CodeView = ({ loading, app }: CodeViewProps) => {
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
</TooltipTrigger>
<TooltipContent>
{isFullscreen ? "Exit full screen" : "Enter full screen"}
{isFullscreen
? t("preview.exitFullScreen")
: t("preview.enterFullScreen")}
</TooltipContent>
</Tooltip>
</div>
......@@ -111,7 +119,7 @@ export const CodeView = ({ loading, app }: CodeViewProps) => {
/>
) : (
<div className="text-center py-4 text-gray-500">
Select a file to view
{t("preview.selectFileToView")}
</div>
)}
</div>
......@@ -120,5 +128,9 @@ export const CodeView = ({ loading, app }: CodeViewProps) => {
);
}
return <div className="text-center py-4 text-gray-500">No files found</div>;
return (
<div className="text-center py-4 text-gray-500">
{t("preview.noFilesFound")}
</div>
);
};
......@@ -12,6 +12,7 @@ import {
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { useTranslation } from "react-i18next";
interface ConsoleEntryProps {
type: "server" | "client" | "edge-function" | "network-requests";
......@@ -42,6 +43,7 @@ export const ConsoleEntryComponent = (props: ConsoleEntryProps) => {
isExpanded = false,
onToggleExpand,
} = props;
const { t } = useTranslation(["home", "common"]);
const setChatInput = useSetAtom(chatInputValueAtom);
const isTruncated = message.length > MAX_MESSAGE_LENGTH;
......@@ -114,11 +116,11 @@ export const ConsoleEntryComponent = (props: ConsoleEntryProps) => {
>
{isExpanded ? (
<>
Show less <ChevronUp size={12} />
{t("common:showLess")} <ChevronUp size={12} />
</>
) : (
<>
Show more <ChevronDown size={12} />
{t("common:showMore")} <ChevronDown size={12} />
</>
)}
</button>
......@@ -137,7 +139,7 @@ export const ConsoleEntryComponent = (props: ConsoleEntryProps) => {
>
<MessageSquare size={12} className="text-gray-500" />
</TooltipTrigger>
<TooltipContent>Send to chat</TooltipContent>
<TooltipContent>{t("home:preview.sendToChat")}</TooltipContent>
</Tooltip>
</div>
);
......
......@@ -4,6 +4,7 @@ import {
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { useTranslation } from "react-i18next";
interface ConsoleFiltersProps {
levelFilter: "all" | "info" | "warn" | "error";
......@@ -39,6 +40,7 @@ export const ConsoleFilters = ({
totalLogs,
showFilters,
}: ConsoleFiltersProps) => {
const { t } = useTranslation("home");
const hasActiveFilters =
levelFilter !== "all" || typeFilter !== "all" || sourceFilter !== "";
......@@ -58,10 +60,10 @@ export const ConsoleFilters = ({
}
className="text-xs px-2 py-1 border border-border rounded bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<option value="all">All Levels</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
<option value="all">{t("preview.consoleFilters.allLevels")}</option>
<option value="info">{t("preview.consoleFilters.info")}</option>
<option value="warn">{t("preview.consoleFilters.warn")}</option>
<option value="error">{t("preview.consoleFilters.error")}</option>
</select>
{/* Type filter */}
......@@ -79,11 +81,15 @@ export const ConsoleFilters = ({
}
className="text-xs px-2 py-1 border border-border rounded bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<option value="all">All Types</option>
<option value="server">Server</option>
<option value="client">Client</option>
<option value="edge-function">Edge Function</option>
<option value="network-requests">Network Requests</option>
<option value="all">{t("preview.consoleFilters.allTypes")}</option>
<option value="server">{t("preview.consoleFilters.server")}</option>
<option value="client">{t("preview.consoleFilters.client")}</option>
<option value="edge-function">
{t("preview.consoleFilters.edgeFunction")}
</option>
<option value="network-requests">
{t("preview.consoleFilters.networkRequests")}
</option>
</select>
{/* Source filter */}
......@@ -93,7 +99,7 @@ export const ConsoleFilters = ({
onChange={(e) => onSourceFilterChange(e.target.value)}
className="text-xs px-2 py-1 border border-border rounded bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<option value="">All Sources</option>
<option value="">{t("preview.consoleFilters.allSources")}</option>
{uniqueSources.map((source) => (
<option key={source} value={source}>
{source}
......@@ -109,7 +115,7 @@ export const ConsoleFilters = ({
className="text-xs px-2 py-1 flex items-center gap-1 border border-border rounded bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<X size={12} />
Clear Filters
{t("preview.consoleFilters.clearFilters")}
</button>
)}
......@@ -126,10 +132,12 @@ export const ConsoleFilters = ({
>
<Trash2 size={14} />
</TooltipTrigger>
<TooltipContent>Clear logs</TooltipContent>
<TooltipContent>{t("preview.consoleFilters.clearLogs")}</TooltipContent>
</Tooltip>
<div className="ml-auto text-xs text-gray-500">{totalLogs} logs</div>
<div className="ml-auto text-xs text-gray-500">
{totalLogs} {t("preview.consoleFilters.logs")}
</div>
</div>
);
};
......@@ -17,6 +17,7 @@ import {
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { useTranslation } from "react-i18next";
interface FileEditorProps {
appId: number | null;
......@@ -37,6 +38,7 @@ const Breadcrumb: React.FC<BreadcrumbProps> = ({
onSave,
isSaving,
}) => {
const { t } = useTranslation("home");
const segments = path.split("/").filter(Boolean);
return (
......@@ -74,7 +76,9 @@ const Breadcrumb: React.FC<BreadcrumbProps> = ({
<Save size={12} />
</TooltipTrigger>
<TooltipContent>
{hasUnsavedChanges ? "Save changes" : "No unsaved changes"}
{hasUnsavedChanges
? t("preview.saveChanges")
: t("preview.noUnsavedChanges")}
</TooltipContent>
</Tooltip>
{hasUnsavedChanges && (
......@@ -95,6 +99,7 @@ export const FileEditor = ({
filePath,
initialLine = null,
}: FileEditorProps) => {
const { t } = useTranslation("home");
const { content, loading, error } = useLoadAppFile(appId, filePath);
const { theme } = useTheme();
const [value, setValue] = useState<string | undefined>(undefined);
......@@ -206,7 +211,7 @@ export const FileEditor = ({
if (warning) {
showWarning(warning);
} else {
showSuccess("File saved");
showSuccess(t("preview.fileSaved"));
}
originalValueRef.current = currentValueRef.current;
......@@ -231,7 +236,7 @@ export const FileEditor = ({
}, [initialLine, filePath, content, navigateToLine]);
if (loading) {
return <div className="p-4">Loading file content...</div>;
return <div className="p-4">{t("preview.loadingFileContent")}</div>;
}
if (error) {
......@@ -239,7 +244,9 @@ export const FileEditor = ({
}
if (!content) {
return <div className="p-4 text-gray-500">No content available</div>;
return (
<div className="p-4 text-gray-500">{t("preview.noContentAvailable")}</div>
);
}
return (
......
......@@ -13,6 +13,7 @@ import { useSetAtom } from "jotai";
import { Input } from "@/components/ui/input";
import type { AppFileSearchResult } from "@/ipc/types";
import { useSearchAppFiles } from "@/hooks/useSearchAppFiles";
import { useTranslation } from "react-i18next";
interface FileTreeProps {
appId: number | null;
......@@ -100,6 +101,7 @@ const buildFileTree = (files: string[]): TreeNode[] => {
// File tree component
export const FileTree = ({ appId, files }: FileTreeProps) => {
const { t } = useTranslation("home");
const [searchValue, setSearchValue] = useState("");
const prevAppIdRef = useRef<number | null>(appId);
......@@ -168,7 +170,7 @@ export const FileTree = ({ appId, files }: FileTreeProps) => {
<Input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Search file contents"
placeholder={t("preview.searchFileContents")}
className="h-8 pl-7 pr-16 text-sm"
data-testid="file-tree-search"
disabled={!appId}
......@@ -177,7 +179,7 @@ export const FileTree = ({ appId, files }: FileTreeProps) => {
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchValue("")}
aria-label="Clear search"
aria-label={t("preview.clearSearch")}
>
<X size={14} />
</button>
......@@ -193,8 +195,8 @@ export const FileTree = ({ appId, files }: FileTreeProps) => {
<div className="mt-1 flex items-center justify-between text-[11px] text-muted-foreground">
<span>
{searchLoading
? "Searching files..."
: `${matchesByPath.size} match${matchesByPath.size === 1 ? "" : "es"}`}
? t("preview.searchingFiles")
: t("preview.match", { count: matchesByPath.size })}
</span>
</div>
)}
......@@ -211,7 +213,7 @@ export const FileTree = ({ appId, files }: FileTreeProps) => {
!searchError &&
matchesByPath.size === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
No files matched your search.
{t("preview.noFilesMatchedSearch")}
</div>
) : isSearchMode ? (
<div className="px-2 py-1">
......
......@@ -19,6 +19,7 @@ import { PublishPanel } from "./PublishPanel";
import { SecurityPanel } from "./SecurityPanel";
import { PlanPanel } from "./PlanPanel";
import { useSupabase } from "@/hooks/useSupabase";
import { useTranslation } from "react-i18next";
interface ConsoleHeaderProps {
isOpen: boolean;
......@@ -31,14 +32,18 @@ const ConsoleHeader = ({
isOpen,
onToggle,
latestMessage,
}: ConsoleHeaderProps) => (
}: ConsoleHeaderProps) => {
const { t } = useTranslation("home");
return (
<div
onClick={onToggle}
className="flex items-start gap-2 px-4 py-1.5 border-t border-border cursor-pointer hover:bg-[var(--background-darkest)] transition-colors"
>
<Logs size={16} className="mt-0.5" />
<div className="flex flex-col">
<span className="text-sm font-medium">System Messages</span>
<span className="text-sm font-medium">
{t("preview.systemMessages")}
</span>
{!isOpen && latestMessage && (
<span className="text-xs text-gray-500 truncate max-w-[200px] md:max-w-[400px]">
{latestMessage}
......@@ -48,7 +53,8 @@ const ConsoleHeader = ({
<div className="flex-1" />
{isOpen ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</div>
);
);
};
// Main PreviewPanel component
export function PreviewPanel() {
......
......@@ -18,6 +18,7 @@ import { useStreamChat } from "@/hooks/useStreamChat";
import { useCheckProblems } from "@/hooks/useCheckProblems";
import { createProblemFixPrompt } from "@/shared/problem_prompt";
import { showError } from "@/lib/toast";
import { useTranslation } from "react-i18next";
interface ProblemItemProps {
problem: Problem;
......@@ -26,6 +27,7 @@ interface ProblemItemProps {
}
const ProblemItem = ({ problem, checked, onToggle }: ProblemItemProps) => {
const { t } = useTranslation(["home", "common"]);
return (
<div
role="checkbox"
......@@ -39,7 +41,7 @@ const ProblemItem = ({ problem, checked, onToggle }: ProblemItemProps) => {
onCheckedChange={onToggle}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
aria-label="Select problem"
aria-label={t("home:preview.problems_panel.selectProblem")}
/>
<div className="flex-shrink-0 mt-0.5">
<XCircle size={16} className="text-red-500" />
......@@ -82,6 +84,7 @@ const RecheckButton = ({
className = "h-7 px-3 text-xs",
onBeforeRecheck,
}: RecheckButtonProps) => {
const { t } = useTranslation(["home", "common"]);
const { checkProblems, isChecking } = useCheckProblems(appId);
const [showingFeedback, setShowingFeedback] = useState(false);
......@@ -115,7 +118,9 @@ const RecheckButton = ({
size={14}
className={`mr-1 ${isShowingChecking ? "animate-spin" : ""}`}
/>
{isShowingChecking ? "Checking..." : "Run checks"}
{isShowingChecking
? t("home:preview.problems_panel.checkingProblems")
: t("home:preview.problems_panel.runChecks")}
</Button>
);
};
......@@ -137,6 +142,7 @@ const ProblemsSummary = ({
onFixSelected,
onSelectAll,
}: ProblemsSummaryProps) => {
const { t } = useTranslation(["home", "common"]);
const { problems } = problemReport;
const totalErrors = problems.length;
......@@ -146,7 +152,7 @@ const ProblemsSummary = ({
return (
<div className="flex flex-col items-center justify-center h-32 text-center">
<p className="mt-6 text-sm font-medium text-muted-foreground mb-3">
No problems found
{t("home:preview.problems_panel.noProblemsFound")}
</p>
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center mb-3">
<Check size={20} className="text-green-600 dark:text-green-400" />
......@@ -164,7 +170,7 @@ const ProblemsSummary = ({
<div className="flex items-center gap-2">
<XCircle size={16} className="text-red-500" />
<span className="text-sm font-medium">
{totalErrors} {totalErrors === 1 ? "error" : "errors"}
{t("home:preview.problems_panel.error", { count: totalErrors })}
</span>
</div>
)}
......@@ -178,7 +184,7 @@ const ProblemsSummary = ({
onClick={onSelectAll}
className="h-7 px-3 text-xs"
>
Select all
{t("common:selectAll")}
</Button>
) : (
<Button
......@@ -187,7 +193,7 @@ const ProblemsSummary = ({
onClick={onClearAll}
className="h-7 px-3 text-xs"
>
Clear all
{t("common:clearAll")}
</Button>
)}
<Button
......@@ -199,7 +205,9 @@ const ProblemsSummary = ({
disabled={selectedCount === 0}
>
<Wrench size={14} className="mr-1" />
{`Fix ${selectedCount} ${selectedCount === 1 ? "problem" : "problems"}`}
{t("home:preview.problems_panel.fixProblems", {
count: selectedCount,
})}
</Button>
</div>
</div>
......@@ -215,6 +223,7 @@ export function Problems() {
}
export function _Problems() {
const { t } = useTranslation(["home", "common"]);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const { problemReport } = useCheckProblems(selectedAppId);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
......@@ -238,9 +247,11 @@ export function _Problems() {
<div className="w-16 h-16 rounded-full bg-[var(--background-darkest)] flex items-center justify-center mb-4">
<AlertTriangle size={24} className="text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No App Selected</h3>
<h3 className="text-lg font-medium mb-2">
{t("home:preview.problems_panel.noAppSelectedTitle")}
</h3>
<p className="text-sm text-muted-foreground max-w-md">
Select an app to view TypeScript problems and diagnostic information.
{t("home:preview.problems_panel.noAppSelectedDescription")}
</p>
</div>
);
......@@ -252,9 +263,11 @@ export function _Problems() {
<div className="w-16 h-16 rounded-full bg-[var(--background-darkest)] flex items-center justify-center mb-4">
<AlertTriangle size={24} className="text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No Problems Report</h3>
<h3 className="text-lg font-medium mb-2">
{t("home:preview.problems_panel.noProblemsReportTitle")}
</h3>
<p className="text-sm text-muted-foreground max-w-md mb-4">
Run checks to scan your app for TypeScript errors and other problems.
{t("home:preview.problems_panel.noProblemsReportDescription")}
</p>
<RecheckButton
appId={selectedAppId}
......
......@@ -14,9 +14,11 @@ import {
} from "@/hooks/useAgentTools";
import { Loader2, ChevronRight } from "lucide-react";
import { AgentToolConsent } from "@/lib/schemas";
import { useTranslation } from "react-i18next";
export function AgentToolsSettings() {
const { tools, isLoading, setConsent } = useAgentTools();
const { t } = useTranslation("settings");
const [showAutoApproved, setShowAutoApproved] = useState(false);
const handleConsentChange = (
......@@ -42,7 +44,7 @@ export function AgentToolsSettings() {
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Configure permissions for Agent built-in tools.
{t("agentPermissions.description")}
</p>
{/* Requires approval tools */}
......@@ -70,7 +72,11 @@ export function AgentToolsSettings() {
<ChevronRight
className={`size-4 transition-transform ${showAutoApproved ? "rotate-90" : ""}`}
/>
<span>Default allowed tools ({autoApprovedTools.length})</span>
<span>
{t("agentPermissions.defaultAllowedTools", {
count: autoApprovedTools.length,
})}
</span>
</button>
{showAutoApproved && (
<div className="space-y-2 pl-6">
......@@ -103,6 +109,7 @@ function ToolConsentRow({
consent: AgentToolConsent;
onConsentChange: (consent: AgentToolConsent) => void;
}) {
const { t } = useTranslation("settings");
return (
<div className="border rounded p-3">
<div className="flex items-center justify-between gap-4">
......@@ -120,9 +127,13 @@ function ToolConsentRow({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ask">Ask</SelectItem>
<SelectItem value="always">Always allow</SelectItem>
<SelectItem value="never">Never allow</SelectItem>
<SelectItem value="ask">{t("agentPermissions.ask")}</SelectItem>
<SelectItem value="always">
{t("agentPermissions.alwaysAllow")}
</SelectItem>
<SelectItem value="never">
{t("agentPermissions.neverAllow")}
</SelectItem>
</SelectContent>
</Select>
</div>
......
......@@ -15,6 +15,7 @@ import { showError, showInfo, showSuccess } from "@/lib/toast";
import { Edit2, Plus, Save, Trash2, X } from "lucide-react";
import { useDeepLink } from "@/contexts/DeepLinkContext";
import { AddMcpServerDeepLinkData } from "@/ipc/deep_link_data";
import { useTranslation } from "react-i18next";
type KeyValue = { key: string; value: string };
......@@ -60,6 +61,7 @@ function KeyValueEditor({
isSaving: boolean;
itemLabel?: string;
}) {
const { t } = useTranslation(["settings", "common"]);
const initial = useMemo(() => parseJsonToArray(json), [json]);
const [envVars, setEnvVars] = useState<KeyValue[]>(initial);
const [editingKey, setEditingKey] = useState<string | null>(null);
......@@ -80,11 +82,11 @@ function KeyValueEditor({
const handleAdd = async () => {
if (!newKey.trim() || !newValue.trim()) {
showError("Both key and value are required");
showError(t("toolsMcp.keyValueRequired"));
return;
}
if (envVars.some((e) => e.key === newKey.trim())) {
showError(`${itemLabel} with this key already exists`);
showError(t("settings:toolsMcp.duplicateKey"));
return;
}
const next = [...envVars, { key: newKey.trim(), value: newValue.trim() }];
......@@ -104,7 +106,7 @@ function KeyValueEditor({
const handleSaveEdit = async () => {
if (!editingKey) return;
if (!editingKeyValue.trim() || !editingValue.trim()) {
showError("Both key and value are required");
showError(t("toolsMcp.keyValueRequired"));
return;
}
if (
......@@ -112,7 +114,7 @@ function KeyValueEditor({
(e) => e.key === editingKeyValue.trim() && e.key !== editingKey,
)
) {
showError(`${itemLabel} with this key already exists`);
showError(t("settings:toolsMcp.duplicateKey"));
return;
}
const next = envVars.map((e) =>
......@@ -144,10 +146,16 @@ function KeyValueEditor({
{isAddingNew ? (
<div className="space-y-3 p-3 border rounded-md bg-muted/50">
<div className="space-y-2">
<Label htmlFor={`env-new-key-${id}`}>Key</Label>
<Label htmlFor={`env-new-key-${id}`}>
{t("settings:toolsMcp.key")}
</Label>
<Input
id={`env-new-key-${id}`}
placeholder={itemLabel === "Header" ? "Key" : "e.g., PATH"}
placeholder={
itemLabel === "Header"
? t("settings:toolsMcp.key")
: t("settings:toolsMcp.keyPlaceholder")
}
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
autoFocus
......@@ -155,11 +163,15 @@ function KeyValueEditor({
/>
</div>
<div className="space-y-2">
<Label htmlFor={`env-new-value-${id}`}>Value</Label>
<Label htmlFor={`env-new-value-${id}`}>
{t("settings:toolsMcp.value")}
</Label>
<Input
id={`env-new-value-${id}`}
placeholder={
itemLabel === "Header" ? "Value" : "e.g., /usr/local/bin"
itemLabel === "Header"
? t("settings:toolsMcp.value")
: t("settings:toolsMcp.valuePlaceholder")
}
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
......@@ -173,7 +185,7 @@ function KeyValueEditor({
disabled={disabled || isSaving}
>
<Save size={14} />
{isSaving ? "Saving..." : "Save"}
{isSaving ? t("common:saving") : t("common:save")}
</Button>
<Button
onClick={() => {
......@@ -185,7 +197,7 @@ function KeyValueEditor({
size="sm"
>
<X size={14} />
Cancel
{t("common:cancel")}
</Button>
</div>
</div>
......@@ -197,7 +209,7 @@ function KeyValueEditor({
disabled={disabled}
>
<Plus size={14} />
Add {itemLabel}
{t("settings:toolsMcp.addEnvVar")}
</Button>
)}
......
/**
* Locale-aware formatting utilities using the browser's Intl API.
* These are available in Electron's Chromium without additional libraries.
*/
export function formatDate(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export function formatNumber(value: number, locale: string): string {
return new Intl.NumberFormat(locale).format(value);
}
const ONE_MINUTE_IN_MS = 1000 * 60;
const ONE_HOUR_IN_MS = ONE_MINUTE_IN_MS * 60;
const ONE_DAY_IN_MS = ONE_HOUR_IN_MS * 24;
export function formatRelativeTime(date: Date, locale: string): string {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
const diffMs = date.getTime() - Date.now();
const absDiffMs = Math.abs(diffMs);
if (absDiffMs < ONE_HOUR_IN_MS) {
// Less than 1 hour — show minutes
const diffMinutes = Math.round(diffMs / ONE_MINUTE_IN_MS);
return rtf.format(diffMinutes, "minute");
}
if (absDiffMs < ONE_DAY_IN_MS) {
// Less than 1 day — show hours
const diffHours = Math.round(diffMs / ONE_HOUR_IN_MS);
return rtf.format(diffHours, "hour");
}
// Otherwise show days
const diffDays = Math.round(diffMs / ONE_DAY_IN_MS);
return rtf.format(diffDays, "day");
}
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
// Import all English locale bundles (bundled with the app)
import enCommon from "./locales/en/common.json";
import enSettings from "./locales/en/settings.json";
import enChat from "./locales/en/chat.json";
import enHome from "./locales/en/home.json";
import enErrors from "./locales/en/errors.json";
// Chinese Simplified
import zhCNCommon from "./locales/zh-CN/common.json";
import zhCNSettings from "./locales/zh-CN/settings.json";
import zhCNChat from "./locales/zh-CN/chat.json";
import zhCNHome from "./locales/zh-CN/home.json";
import zhCNErrors from "./locales/zh-CN/errors.json";
// Brazilian Portuguese
import ptBRCommon from "./locales/pt-BR/common.json";
import ptBRSettings from "./locales/pt-BR/settings.json";
import ptBRChat from "./locales/pt-BR/chat.json";
import ptBRHome from "./locales/pt-BR/home.json";
import ptBRErrors from "./locales/pt-BR/errors.json";
const resources = {
en: {
common: enCommon,
settings: enSettings,
chat: enChat,
home: enHome,
errors: enErrors,
},
"zh-CN": {
common: zhCNCommon,
settings: zhCNSettings,
chat: zhCNChat,
home: zhCNHome,
errors: zhCNErrors,
},
"pt-BR": {
common: ptBRCommon,
settings: ptBRSettings,
chat: ptBRChat,
home: ptBRHome,
errors: ptBRErrors,
},
};
i18n.use(initReactI18next).init({
resources,
lng: "en", // Default; overridden by user setting on startup
fallbackLng: "en",
defaultNS: "common",
ns: ["common", "settings", "chat", "home", "errors"],
interpolation: {
escapeValue: false, // React already escapes rendered output
},
});
export default i18n;
{
"newChat": "New Chat",
"recentChats": "Recent Chats",
"searchChats": "Search chats",
"loadingChats": "Loading chats...",
"noChatsFound": "No chats found",
"renameChat": "Rename Chat",
"deleteChat": "Delete Chat",
"renameChatDescription": "Enter a new name for this chat.",
"chatTitle": "Title",
"enterChatTitle": "Enter chat title...",
"chatRenamed": "Chat renamed successfully",
"failedRenameChat": "Failed to rename chat: {{error}}",
"failedCreateChat": "Failed to create new chat: {{error}}",
"chatDeleted": "Chat deleted successfully",
"failedDeleteChat": "Failed to delete chat: {{error}}",
"deleteChatConfirmation": "Are you sure you want to delete \"{{title}}\"? This action cannot be undone and all messages in this chat will be permanently lost.",
"deleteChatNote": "Note: Any code changes that have already been accepted will be kept.",
"scrollToBottom": "Scroll to bottom",
"dismissError": "Dismiss error",
"askDyadToBuild": "Ask Dyad to build...",
"cancelGeneration": "Cancel generation",
"sendMessage": "Send message",
"loadingProposal": "Loading proposal...",
"errorLoadingProposal": "Error loading proposal: {{message}}",
"visualEditor": "Visual editor (Pro)",
"visualEditorDescription": "Visual editing lets you make UI changes without AI and is a Pro-only feature",
"summarizeNewChatTip": "Creating a new chat makes the AI more focused and efficient",
"summarizeToNewChat": "Summarize to new chat",
"refactorFile": "Refactor {{path}} and make it more modular",
"refactorDescription": "Refactor the file to improve maintainability",
"writeCodeProperly": "Write code properly",
"writeCodeProperlyDescription": "Write code properly (useful when AI generates the code in the wrong format)",
"rebuildApp": "Rebuild app",
"rebuildAppDescription": "Rebuild the application",
"restartApp": "Restart app",
"restartAppDescription": "Restart the development server",
"refreshApp": "Refresh app",
"refreshAppDescription": "Refresh the application preview",
"keepGoing": "Keep going",
"tipProposal": "Tip proposal",
"securityRisks": "Security Risks",
"sqlQueries": "SQL Queries",
"packagesAdded": "Packages Added",
"serverFunctionsChanged": "Server Functions Changed",
"filesChanged": "Files Changed",
"securityRisksFound": "Security risks found",
"approve": "Approve",
"reject": "Reject",
"noChanges": "No changes",
"sqlQuery": "SQL Query",
"approved": "Approved",
"rejected": "Rejected",
"copy": "Copy",
"requestId": "Request ID",
"copyRequestId": "Copy Request ID",
"maxTokensUsed": "Max tokens used: {{count}}",
"allowToolToRun": "Allow {{toolName}} to run?",
"queueCount": "(1 of {{total}})",
"allowOnce": "Allow once",
"alwaysAllow": "Always allow",
"removeAttachment": "Remove attachment",
"recentChatActivity": "Recent chat activity",
"loadingActivity": "Loading activity...",
"noRecentChats": "No recent chats",
"uncommittedChanges": "You have {{count}} uncommitted change(s).",
"reviewAndCommit": "Review & commit",
"reviewCommitChanges": "Review & Commit Changes",
"reviewChangesDescription": "Review your changes and enter a commit message.",
"commitMessage": "Commit message",
"enterCommitMessage": "Enter commit message...",
"changedFiles": "Changed files ({{count}})",
"committing": "Committing...",
"commit": "Commit",
"added": "Added",
"modified": "Modified",
"deleted": "Deleted",
"renamed": "Renamed",
"updateFiles": "Update files",
"closeVersionPane": "Close version pane",
"versionHistory": "Version History",
"noVersionsAvailable": "No versions available",
"versionLabel": "Version {{number}} ({{hash}})",
"dbSnapshot": "DB",
"dbSnapshotExpired": "DB snapshot may have expired (older than 24 hours)",
"dbSnapshotAvailable": "Database snapshot available at timestamp {{timestamp}}",
"restoreToVersion": "Restore to this version",
"restoring": "Restoring...",
"restoringToVersion": "Restoring to this version...",
"revertedToVersion": "Reverted all changes back to version {{version}}",
"revertedToHash": "Reverted all changes back to version {{hash}}",
"contextUsed": "Used:",
"contextLimit": "Limit:",
"contextLimitWarning": "You're close to the context limit for this chat.",
"summarizeIntoNewChat": "Summarize into new chat",
"fixAllErrors": "Fix All Errors ({{count}})",
"todosCompleted": "{{completed}} of {{total}} To-dos Completed",
"allTasksCompleted": "All tasks completed",
"noTaskInProgress": "No task in progress",
"tokenUsage": "Tokens: {{count}}",
"tokenPercentUsed": "{{percent}}% of {{limit}}K",
"tokenUsageBreakdown": "Token Usage Breakdown",
"messageHistory": "Message History",
"codebase": "Codebase",
"mentionedApps": "Mentioned Apps",
"systemPrompt": "System Prompt",
"currentInput": "Current Input",
"total": "Total",
"failedCountTokens": "Failed to count tokens",
"optimizeTokens": "Optimize your tokens with",
"dyadProSmartContext": "Dyad Pro's Smart Context",
"attachFiles": "Attach files",
"themes": "Themes",
"noTheme": "No Theme",
"moreThemes": "More themes",
"newTheme": "New Theme",
"allCustomThemes": "All Custom Themes",
"showTokenUsage": "Show token usage",
"hideTokenUsage": "Hide token usage",
"attachFileContext": "Attach file as chat context",
"attachFileContextExample": "Example use case: screenshot of the app to point out a UI issue",
"uploadFileCodebase": "Upload file to codebase",
"uploadFileCodebaseExample": "Example use case: add an image to use for your app",
"dropFilesToAttach": "Drop files to attach",
"selectedComponents": "Selected Components ({{count}})",
"clearAllComponents": "Clear all selected components",
"deselectComponent": "Deselect component",
"chatMode": {
"openMenu": "Open mode menu",
"toggleShortcut": "{{shortcut}} to toggle",
"agentV2": "Agent v2",
"agentV2Description": "Better at bigger tasks and debugging",
"build": "Build",
"buildDescription": "Generate and edit code",
"ask": "Ask",
"askDescription": "Ask questions about the app",
"buildWithMcp": "Build with MCP",
"buildWithMcpDescription": "Like Build, but can use tools (MCP) to generate code"
},
"modelPicker": {
"modelLabel": "Model:",
"cloudModels": "Cloud Models",
"loadingModels": "Loading models...",
"noCloudModels": "No cloud models available",
"otherProviders": "Other AI providers",
"providerCount_one": "{{count}} provider",
"providerCount_other": "{{count}} providers",
"dyadTurbo": "Dyad Turbo",
"modelCount_one": "{{count}} model",
"modelCount_other": "{{count}} models",
"providerModels": "{{name}} Models",
"localModels": "Local models",
"localModelsDescription": "LM Studio, Ollama",
"ollama": "Ollama",
"ollamaModels": "Ollama Models",
"errorLoading": "Error loading",
"noneAvailable": "None available",
"isOllamaRunning": "Is Ollama running?",
"noLocalModels": "No local models found",
"ensureOllamaRunning": "Ensure Ollama is running and models are pulled.",
"lmStudio": "LM Studio",
"lmStudioModels": "LM Studio Models",
"noLoadedModels": "No loaded models found",
"ensureLMStudioRunning": "Ensure LM Studio is running and models are loaded."
},
"header": {
"switchingToLatest": "Please wait, switching back to latest version...",
"warningNotOnBranch": "Warning: You are not on a branch",
"notOnBranch": "You are not on a branch",
"checkoutInProgress": "Version checkout is currently in progress",
"checkoutMainBranch": "Checkout main branch, otherwise changes will not be saved properly",
"checkingBranch": "Checking branch...",
"onBranch": "You are on branch: {{name}}.",
"renameMasterToMain": "Rename master to main",
"renaming": "Renaming...",
"switchToMainBranch": "Switch to main branch",
"checkingOut": "Checking out...",
"masterRenamed": "Master branch renamed to main",
"versionCount": "Version {{count}}"
},
"errorBox": {
"accessWithDyadPro": "Access with Dyad Pro",
"orSwitchModel": "or switch to another model.",
"upgradeToDyadPro": "Upgrade to Dyad Pro",
"troubleshootingGuide": "Troubleshooting guide",
"invalidProKey": "Looks like you don't have a valid Dyad Pro key.",
"today": "today.",
"creditsUsed": "You have used all of your Dyad AI credits this month.",
"reloadOrUpgrade": "Reload or upgrade your subscription",
"getMoreCredits": "and get more AI credits",
"readDocs": "Read docs"
},
"promo": {
"tiredOfWaiting": "Tired of waiting on AI?",
"getDyadPro": "Get Dyad Pro",
"fasterEdits": "for faster edits with Turbo Edits.",
"saveOnCosts": "Save up to 3x on AI costs with",
"debuggingLoop": "Getting stuck in a debugging loop? Try a different model.",
"joinBuilders": "Join 600+ builders in the",
"dyadSubreddit": "Dyad subreddit",
"foundBug": "Found a bug? Click Help > Report a Bug",
"reportBadResponse": "Want to report a bad AI response? Upload the chat by clicking Help",
"watch": "Watch",
"creatorBuildApp": "the creator of Dyad build a Bible app step-by-step",
"gettingStuck": "Getting stuck? Read our",
"debuggingTips": "debugging tips",
"advancedTip": "Advanced tip: Customize your",
"aiRules": "AI rules",
"keepFocused": "Want to keep the AI focused? Start a new chat.",
"whatsNext": "Want to know what's next? Check out our",
"roadmap": "roadmap",
"likeDyad": "Like Dyad? Star it on",
"gitHub": "GitHub"
},
"consent": {
"toolWantsToRun": "Tool wants to run",
"requestsConsent": "requests your consent.",
"inputRequired": "Input Required"
},
"agentModeActivated": "Agent Mode Activated",
"agentModeTip": "Tip: Create a new chat to give the agent a clean context for better results.",
"neverShowAgain": "Never show again"
}
{
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"confirm": "Confirm",
"loading": "Loading...",
"copyToClipboard": "Copy to clipboard",
"copied": "Copied!",
"close": "Close",
"back": "Back",
"goBack": "Go Back",
"reset": "Reset",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"yes": "Yes",
"no": "No",
"retry": "Retry",
"ok": "OK",
"create": "Create",
"update": "Update",
"edit": "Edit",
"add": "Add",
"remove": "Remove",
"search": "Search",
"refresh": "Refresh",
"enabled": "Enabled",
"disabled": "Disabled",
"saving": "Saving...",
"deleting": "Deleting...",
"creating": "Creating...",
"updating": "Updating...",
"connecting": "Connecting...",
"disconnecting": "Disconnecting...",
"importing": "Importing...",
"uploading": "Uploading...",
"preparing": "Preparing...",
"checking": "Checking...",
"notSet": "Not Set",
"set": "Set",
"custom": "Custom",
"ready": "Ready",
"needsSetup": "Needs Setup",
"accept": "Accept",
"decline": "Decline",
"showMore": "Show more",
"showLess": "Show less",
"selectAll": "Select all",
"clearAll": "Clear all",
"noResults": "No results found",
"itemCount_one": "{{count}} item",
"itemCount_other": "{{count}} items",
"pro": "Pro",
"free": "Free",
"recommended": "Recommended",
"experimental": "experimental",
"new": "New",
"builtIn": "Built-in",
"advanced": "Advanced",
"required": "Required",
"optional": "Optional"
}
{
"unknown": "An unknown error occurred",
"failedToCreate": "Failed to create: {{error}}",
"failedToUpdate": "Failed to update: {{error}}",
"failedToDelete": "Failed to delete: {{error}}",
"failedToLoad": "Failed to load: {{error}}",
"networkError": "A network error occurred. Please check your connection.",
"copyError": "Copy error message",
"errorOccurred": "An error occurred"
}
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论