From 57167b979f3cb79c6a4925052ad89951bfb4a338 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:10:19 +0400 Subject: [PATCH] chore: copy --- AGENTS.md | 2 + src-tauri/src/lib.rs | 2 +- src/app/page.tsx | 2 +- src/components/app-update-toast.tsx | 12 +- src/components/create-profile-dialog.tsx | 2 +- src/components/custom-toast.tsx | 10 +- .../extension-group-assignment-dialog.tsx | 6 +- .../extension-management-dialog.tsx | 401 +++++------ src/components/group-badges.tsx | 2 +- src/components/group-management-dialog.tsx | 2 +- src/components/import-profile-dialog.tsx | 6 +- src/components/profile-data-table.tsx | 47 +- src/components/profile-selector-dialog.tsx | 18 +- src/components/profile-sync-dialog.tsx | 45 +- src/components/proxy-management-dialog.tsx | 634 +++++++++--------- src/components/settings-dialog.tsx | 98 +-- src/components/sync-config-dialog.tsx | 6 +- src/components/ui/copy-to-clipboard.tsx | 8 +- src/components/ui/dialog.tsx | 2 +- src/i18n/locales/en.json | 61 +- src/i18n/locales/es.json | 89 ++- src/i18n/locales/fr.json | 87 ++- src/i18n/locales/ja.json | 83 ++- src/i18n/locales/pt.json | 85 ++- src/i18n/locales/ru.json | 83 ++- src/i18n/locales/zh.json | 85 ++- 26 files changed, 1048 insertions(+), 830 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index afbc338..8409816 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,8 @@ donutbrowser/ - Adding a new string means adding the key to ALL seven locale files in `src/i18n/locales/` (en, es, fr, ja, pt, ru, zh) — not just `en.json`. The English version alone is incomplete work. - Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first. - Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it. +- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site. +- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix. ## Singletons diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 98d4675..32d8c33 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1232,7 +1232,7 @@ pub fn run() { #[allow(unused_variables)] let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) .title("Donut Browser") - .inner_size(800.0, 500.0) + .inner_size(840.0, 500.0) .resizable(false) .fullscreen(false) .center() diff --git a/src/app/page.tsx b/src/app/page.tsx index fb30c74..6c145c0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1070,7 +1070,7 @@ export default function Home() { return (
-
+
{ await onRestart(); }; @@ -43,10 +45,10 @@ export function AppUpdateToast({
{updateReady - ? "Update ready, restart to apply" + ? t("appUpdate.toast.updateReady") : updateInfo.repo_update ? "Update available via package manager" - : "Manual download required"} + : t("appUpdate.toast.manualDownloadRequired")}
{updateInfo.current_version} → {updateInfo.new_version} @@ -71,7 +73,7 @@ export function AppUpdateToast({ className="flex gap-2 items-center text-xs" > - Restart Now + {t("appUpdate.toast.restartNow")} ) : ( !updateInfo.repo_update && @@ -82,7 +84,7 @@ export function AppUpdateToast({ className="flex gap-2 items-center text-xs" > - View Release + {t("appUpdate.toast.viewRelease")} ) )} @@ -92,7 +94,7 @@ export function AppUpdateToast({ size="sm" className="text-xs" > - Later + {t("appUpdate.toast.later")}
diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 4a16b71..a85d129 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -953,7 +953,7 @@ export function CreateProfileDialog({

- Fetching available versions... + {t("createProfile.version.fetching")}

)} diff --git a/src/components/custom-toast.tsx b/src/components/custom-toast.tsx index b0f0a11..80f8990 100644 --- a/src/components/custom-toast.tsx +++ b/src/components/custom-toast.tsx @@ -294,7 +294,9 @@ export function UnifiedToast(props: ToastProps) { "completed_files" in progress && (

- {progress.phase === "uploading" ? "Uploading" : "Downloading"}{" "} + {progress.phase === "uploading" + ? t("appUpdate.toast.uploading") + : t("appUpdate.toast.downloading")}{" "} {progress.completed_files}/{progress.total_files} files {" \u2022 "} {formatBytesCompact(progress.completed_bytes)} /{" "} @@ -349,17 +351,17 @@ export function UnifiedToast(props: ToastProps) { <> {stage === "extracting" && (

- Extracting browser files... Please do not close the app. + {t("browserDownload.toast.extracting")}

)} {stage === "verifying" && (

- Verifying browser files... + {t("browserDownload.toast.verifying")}

)} {stage === "downloading (twilight rolling release)" && (

- Downloading rolling release build... + {t("browserDownload.toast.downloadingRolling")}

)} diff --git a/src/components/extension-group-assignment-dialog.tsx b/src/components/extension-group-assignment-dialog.tsx index e488770..b298811 100644 --- a/src/components/extension-group-assignment-dialog.tsx +++ b/src/components/extension-group-assignment-dialog.tsx @@ -55,12 +55,12 @@ export function ExtensionGroupAssignmentDialog({ } catch (err) { console.error("Failed to load extension groups:", err); setError( - err instanceof Error ? err.message : "Failed to load extension groups", + err instanceof Error ? err.message : t("extensions.loadGroupsFailed"), ); } finally { setIsLoading(false); } - }, []); + }, [t]); const handleAssign = useCallback(async () => { setIsAssigning(true); @@ -79,7 +79,7 @@ export function ExtensionGroupAssignmentDialog({ } catch (err) { console.error("Failed to assign extension group:", err); const errorMessage = - err instanceof Error ? err.message : "Failed to assign extension group"; + err instanceof Error ? err.message : t("extensions.assignGroupFailed"); setError(errorMessage); toast.error(errorMessage); } finally { diff --git a/src/components/extension-management-dialog.tsx b/src/components/extension-management-dialog.tsx index 91272ce..1ddf76e 100644 --- a/src/components/extension-management-dialog.tsx +++ b/src/components/extension-management-dialog.tsx @@ -50,36 +50,43 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting"; function getSyncStatusDot( item: { sync_enabled?: boolean; last_sync?: number }, liveStatus: SyncStatus | undefined, + t: (key: string, options?: Record) => string, ): { color: string; tooltip: string; animate: boolean } { const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled"); switch (status) { case "syncing": - return { color: "bg-warning", tooltip: "Syncing...", animate: true }; + return { + color: "bg-warning", + tooltip: t("profileTable.syncTooltipSyncing"), + animate: true, + }; case "synced": return { color: "bg-success", tooltip: item.last_sync - ? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}` - : "Synced", + ? t("profileTable.syncTooltipSyncedAt", { + time: new Date(item.last_sync * 1000).toLocaleString(), + }) + : t("profileTable.syncTooltipSynced"), animate: false, }; case "waiting": return { color: "bg-warning", - tooltip: "Waiting to sync", + tooltip: t("profileTable.syncTooltipWaiting"), animate: false, }; case "error": return { color: "bg-destructive", - tooltip: "Sync error", + tooltip: t("profileTable.syncTooltipError"), animate: false, }; default: return { color: "bg-muted-foreground", - tooltip: "Not synced", + tooltip: t("profileTable.syncTooltipNotSynced"), animate: false, }; } @@ -674,6 +681,7 @@ export function ExtensionManagementDialog({ const syncDot = getSyncStatusDot( ext, extSyncStatus[ext.id], + t, ); return (
- + {t("extensions.editGroup")} @@ -1003,87 +1012,89 @@ export function ExtensionManagementDialog({ -
-
- - { - setEditGroupName(e.target.value); - }} - placeholder={t("extensions.groupNamePlaceholder")} - /> -
- - {extensions.filter((e) => !editGroupExtensionIds.includes(e.id)) - .length > 0 && ( + +
- - { + setEditGroupName(e.target.value); }} - > - - - - - {extensions - .filter((e) => !editGroupExtensionIds.includes(e.id)) - .map((ext) => ( - -
- {renderExtensionIcon(ext, "sm")} - {ext.name} -
-
- ))} -
- + placeholder={t("extensions.groupNamePlaceholder")} + />
- )} -
- - {editGroupExtensionIds.length === 0 ? ( -
- {t("extensions.noExtensionsInGroup")} -
- ) : ( -
- {editGroupExtensionIds.map((extId) => { - const ext = extensions.find((e) => e.id === extId); - if (!ext) return null; - return ( -
- {renderExtensionIcon(ext, "sm")} - - {ext.name} - - {renderCompatIcons(ext.browser_compatibility)} - -
- ); - })} + {extensions.filter((e) => !editGroupExtensionIds.includes(e.id)) + .length > 0 && ( +
+ +
)} + +
+ + {editGroupExtensionIds.length === 0 ? ( +
+ {t("extensions.noExtensionsInGroup")} +
+ ) : ( +
+ {editGroupExtensionIds.map((extId) => { + const ext = extensions.find((e) => e.id === extId); + if (!ext) return null; + return ( +
+ {renderExtensionIcon(ext, "sm")} + + {ext.name} + + {renderCompatIcons(ext.browser_compatibility)} + +
+ ); + })} +
+ )} +
-
+
); diff --git a/src/components/group-management-dialog.tsx b/src/components/group-management-dialog.tsx index 6716431..88d67ec 100644 --- a/src/components/group-management-dialog.tsx +++ b/src/components/group-management-dialog.tsx @@ -283,7 +283,7 @@ export function GroupManagementDialog({ {/* Groups list */} {isLoading ? (
- {t("common.loading")} + {t("common.buttons.loading")}
) : groups.length === 0 ? (
diff --git a/src/components/import-profile-dialog.tsx b/src/components/import-profile-dialog.tsx index 8fe40f5..aaa0cf7 100644 --- a/src/components/import-profile-dialog.tsx +++ b/src/components/import-profile-dialog.tsx @@ -543,9 +543,9 @@ export function ImportProfileDialog({
- {t("importProfile.importedAsPrefix")}{" "} - {getBrowserDisplayName(currentMappedBrowser)}{" "} - {t("importProfile.importedAsSuffix")} + {t("importProfile.importedAs", { + browser: getBrowserDisplayName(currentMappedBrowser), + })} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index cc5266d..c8c9547 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -536,7 +536,11 @@ const TagsCell = React.memo<{ onChange={(opts) => void handleChange(opts)} creatable selectFirstItem={false} - placeholder={effectiveTags.length === 0 ? "Add tags" : ""} + placeholder={ + effectiveTags.length === 0 + ? translate("profileTable.addTagsPlaceholder") + : "" + } className={cn( "bg-transparent border-0! focus-within:ring-0!", "[&_div:first-child]:border-0! [&_div:first-child]:ring-0! [&_div:first-child]:focus-within:ring-0!", @@ -1846,6 +1850,7 @@ export function ProfilesDataTable({ }, { id: "actions", + size: 100, cell: ({ row, table }) => { const meta = table.options.meta as TableMeta; const profile = row.original; @@ -1964,7 +1969,7 @@ export function ProfilesDataTable({ size="sm" disabled={!canLaunch || isLaunching || isStopping} className={cn( - "min-w-[70px] h-7", + "min-w-[80px] h-7 px-3", !canLaunch && "opacity-50 cursor-not-allowed", canLaunch && "cursor-pointer", isFollower && "border-accent", @@ -1980,9 +1985,9 @@ export function ProfilesDataTable({
) : isRunning ? ( - "Stop" + meta.t("profiles.actions.stop") ) : ( - "Launch" + meta.t("profiles.actions.launch") )} @@ -1999,7 +2004,9 @@ export function ProfilesDataTable({ }, { accessorKey: "name", - header: ({ column }) => { + size: 130, + header: ({ column, table }) => { + const meta = table.options.meta as TableMeta; return (
+ + + ); + })} + +
)}
- +
@@ -684,169 +676,167 @@ export function ProxyManagementDialog({ {t("vpns.management.noneCreated")}
) : ( -
- - - - - {t("common.labels.name")} - - {t("common.labels.type")} - - - {t("proxies.management.usage")} - - - {t("proxies.management.syncCol")} - - - {t("common.labels.actions")} - - - - - {vpnConfigs.map((vpn) => { - const syncDot = getSyncStatusDot( - vpn, - vpnSyncStatus[vpn.id], - t, - vpnSyncErrors[vpn.id], - ); - return ( - - -
- - -
- - -

{syncDot.tooltip}

-
- - {vpn.name} -
- - - WG - - - - {vpnUsage[vpn.id] ?? 0} - - - +
+
+ + + {t("common.labels.name")} + + {t("common.labels.type")} + + + {t("proxies.management.usage")} + + + {t("proxies.management.syncCol")} + + + {t("common.labels.actions")} + + + + + {vpnConfigs.map((vpn) => { + const syncDot = getSyncStatusDot( + vpn, + vpnSyncStatus[vpn.id], + t, + vpnSyncErrors[vpn.id], + ); + return ( + + +
-
- - void handleToggleVpnSync(vpn) - } - disabled={ - isTogglingVpnSync[vpn.id] || - vpnInUse[vpn.id] - } - /> -
+
- {vpnInUse[vpn.id] ? ( -

- {t( - "vpns.management.syncCannotDisable", - )} -

- ) : ( -

- {vpn.sync_enabled - ? t( - "proxies.management.disableSync", - ) - : t( - "proxies.management.enableSync", - )} -

- )} +

{syncDot.tooltip}

- - -
- - - + {vpn.name} +
+
+ + WG + + + + {vpnUsage[vpn.id] ?? 0} + + + + + +
+ + void handleToggleVpnSync(vpn) + } + disabled={ + isTogglingVpnSync[vpn.id] || + vpnInUse[vpn.id] + } + /> +
+
+ + {vpnInUse[vpn.id] ? ( +

+ {t( + "vpns.management.syncCannotDisable", + )} +

+ ) : ( +

+ {vpn.sync_enabled + ? t( + "proxies.management.disableSync", + ) + : t( + "proxies.management.enableSync", + )} +

+ )} +
+
+
+ +
+ + + + + + +

{t("vpns.management.editVpn")}

+
+
+ + + - - -

{t("vpns.management.editVpn")}

-
-
- - - - - - - - {(vpnUsage[vpn.id] ?? 0) > 0 ? ( -

- {(vpnUsage[vpn.id] ?? 0) === 1 - ? t( - "vpns.management.cannotDelete_one", - { count: vpnUsage[vpn.id] }, - ) - : t( - "vpns.management.cannotDelete_other", - { count: vpnUsage[vpn.id] }, - )} -

- ) : ( -

- {t("vpns.management.deleteVpn")} -

- )} -
-
-
-
- - ); - })} - -
-
+ + + + {(vpnUsage[vpn.id] ?? 0) > 0 ? ( +

+ {(vpnUsage[vpn.id] ?? 0) === 1 + ? t( + "vpns.management.cannotDelete_one", + { count: vpnUsage[vpn.id] }, + ) + : t( + "vpns.management.cannotDelete_other", + { count: vpnUsage[vpn.id] }, + )} +

+ ) : ( +

+ {t("vpns.management.deleteVpn")} +

+ )} +
+ +
+ + + ); + })} + +
)}
diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 67b67a9..59db349 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -811,7 +811,7 @@ export function SettingsDialog({

- Choose your preferred language for the application interface. + {t("settings.language.description")}

@@ -820,10 +820,12 @@ export function SettingsDialog({
- {isDefaultBrowser ? "Active" : "Inactive"} + {isDefaultBrowser + ? t("common.status.active") + : t("common.status.inactive")}
@@ -839,13 +841,12 @@ export function SettingsDialog({ className="w-full" > {isDefaultBrowser - ? "Already Default Browser" - : "Set as Default Browser"} + ? t("settings.defaultBrowser.alreadyDefault") + : t("settings.defaultBrowser.setAsDefault")}

- When set as default, Donut Browser will handle web links and - allow you to choose which profile to use. + {t("settings.defaultBrowser.description")}

)} @@ -854,12 +855,12 @@ export function SettingsDialog({ {isMacOS && (
{isLoadingPermissions ? (
- Loading permissions... + {t("settings.permissions.loading")}
) : (
@@ -928,7 +929,7 @@ export function SettingsDialog({ className="w-full" onClick={onIntegrationsOpen} > - Open Integrations Settings + {t("integrations.openSettings")}
@@ -952,33 +953,24 @@ export function SettingsDialog({ {/* Sync Encryption Section */}

- {t( - "settings.encryption.description", - "Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.", - )} + {t("settings.encryption.description")}

{!canUseEncryption ? (

- {t( - "settings.encryption.requiresProOrOwner", - "Profile encryption is available for Pro users and team owners.", - )} + {t("settings.encryption.requiresProOrOwner")}

) : hasE2ePassword ? (
- {t("settings.encryption.passwordSet", "Active")} + {t("settings.encryption.passwordSet")} - {t( - "settings.encryption.passwordSetDescription", - "E2E encryption password is set", - )} + {t("settings.encryption.passwordSetDescription")}
@@ -992,10 +984,7 @@ export function SettingsDialog({ setE2eError(""); }} > - {t( - "settings.encryption.changePassword", - "Change Password", - )} + {t("settings.encryption.changePassword")}
@@ -1026,10 +1007,7 @@ export function SettingsDialog({
{ setE2ePassword(e.target.value); @@ -1038,10 +1016,7 @@ export function SettingsDialog({ /> { setE2ePasswordConfirm(e.target.value); @@ -1057,21 +1032,11 @@ export function SettingsDialog({ isLoading={isSavingE2e} onClick={async () => { if (e2ePassword.length < 8) { - setE2eError( - t( - "settings.encryption.passwordTooShort", - "Password must be at least 8 characters", - ), - ); + setE2eError(t("settings.encryption.passwordTooShort")); return; } if (e2ePassword !== e2ePasswordConfirm) { - setE2eError( - t( - "settings.encryption.passwordMismatch", - "Passwords do not match", - ), - ); + setE2eError(t("settings.encryption.passwordMismatch")); return; } setIsSavingE2e(true); @@ -1083,10 +1048,7 @@ export function SettingsDialog({ setE2ePassword(""); setE2ePasswordConfirm(""); showSuccessToast( - t( - "settings.encryption.passwordSaved", - "Encryption password set", - ), + t("settings.encryption.passwordSaved"), ); } catch (error) { showErrorToast(String(error)); @@ -1095,7 +1057,7 @@ export function SettingsDialog({ } }} > - {t("settings.encryption.setPassword", "Set Password")} + {t("settings.encryption.setPassword")}
)} @@ -1172,13 +1134,11 @@ export function SettingsDialog({ variant="outline" className="w-full" > - Clear All Version Cache + {t("settings.advanced.clearCache")}

- Clear all cached browser version data and refresh all browser - versions from their sources. This will force a fresh download of - version information for all browsers. + {t("settings.advanced.clearCacheDescription")}

@@ -1194,7 +1154,7 @@ export function SettingsDialog({ - Cancel + {t("common.buttons.cancel")} - Save Settings + {t("common.buttons.saveSettings")} diff --git a/src/components/sync-config-dialog.tsx b/src/components/sync-config-dialog.tsx index 06fbcda..c9b0ae0 100644 --- a/src/components/sync-config-dialog.tsx +++ b/src/components/sync-config-dialog.tsx @@ -452,7 +452,11 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { setShowToken(!showToken); }} className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent" - aria-label={showToken ? "Hide token" : "Show token"} + aria-label={ + showToken + ? t("common.aria.hideToken") + : t("common.aria.showToken") + } > {showToken ? ( diff --git a/src/components/ui/copy-to-clipboard.tsx b/src/components/ui/copy-to-clipboard.tsx index 482fb35..5b131da 100644 --- a/src/components/ui/copy-to-clipboard.tsx +++ b/src/components/ui/copy-to-clipboard.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; import { LuCheck, LuCopy } from "react-icons/lu"; import { Button } from "@/components/ui/button"; import { showSuccessToast } from "@/lib/toast-utils"; @@ -26,6 +27,7 @@ export function CopyToClipboard({ className, successMessage = "Copied to clipboard", }: CopyToClipboardProps) { + const { t } = useTranslation(); const [copied, setCopied] = useState(false); const copyToClipboard = useCallback(async () => { @@ -47,9 +49,11 @@ export function CopyToClipboard({ size={size} className={`relative ${className ?? ""}`} onClick={copyToClipboard} - aria-label={copied ? "Copied" : "Copy to clipboard"} + aria-label={copied ? t("common.aria.copied") : t("common.aria.copy")} > - {copied ? "Copied" : "Copy"} + + {copied ? t("common.srOnly.copied") : t("common.srOnly.copy")} +