diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml new file mode 100644 index 00000000..71732157 --- /dev/null +++ b/.github/workflows/crowdin.yml @@ -0,0 +1,38 @@ +name: Crowdin Action + +permissions: + contents: write + pull-requests: write + +on: + push: + branches: [main] + +jobs: + synchronize-with-crowdin: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Crowdin Action + uses: crowdin/github-action@v2 + with: + upload_sources: true + upload_translations: false + download_translations: true + localization_branch_name: l10n_crowdin_translations + create_pull_request: true + pull_request_title: "New Crowdin Translations" + pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)" + pull_request_base_branch_name: "main" + env: + # A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository). + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # A numeric ID, found at https://crowdin.com/project//tools/api + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + + # Visit https://crowdin.com/settings#api-key to create this token + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/bun.lock b/bun.lock index 22a874a5..a75bca01 100644 --- a/bun.lock +++ b/bun.lock @@ -11,9 +11,12 @@ "electron-chrome-web-store": "npm:@iamevan/electron-chrome-web-store@0.11.2", "electron-context-menu": "^4.0.5", "electron-updater": "^6.6.2", + "i18next": "^25.1.2", + "i18next-resources-to-backend": "^1.2.1", "knex": "^3.1.0", "mime-types": "^3.0.1", "posthog-node": "^4.17.1", + "react-i18next": "^15.5.1", "sharp": "^0.34.1", "sharp-ico": "^0.1.5", "zod": "^3.24.4", @@ -1160,6 +1163,8 @@ "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], @@ -1174,6 +1179,10 @@ "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], + "i18next": ["i18next@25.1.2", "", { "dependencies": { "@babel/runtime": "^7.26.10" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-SP63m8LzdjkrAjruH7SCI3ndPSgjt4/wX7ouUUOzCW/eY+HzlIo19IQSfYA9X3qRiRP1SYtaTsg/Oz/PGsfD8w=="], + + "i18next-resources-to-backend": ["i18next-resources-to-backend@1.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw=="], + "ico-endec": ["ico-endec@0.1.6", "", {}, "sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ=="], "iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="], @@ -1574,6 +1583,8 @@ "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "react-i18next": ["react-i18next@15.5.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-C8RZ7N7H0L+flitiX6ASjq9p5puVJU1Z8VyL3OgM/QOMRf40BMZX+5TkpxzZVcTmOLPX5zlti4InEX5pFyiVeA=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], @@ -1884,6 +1895,8 @@ "vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 00000000..d7c5ce0e --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,8 @@ +"project_id_env": "CROWDIN_PROJECT_ID" +"api_token_env": "CROWDIN_PERSONAL_TOKEN" +"base_path": "." + +"preserve_hierarchy": true + +"files": + [{ "source": "src/shared/locales/en-US/*.json", "translation": "src/shared/locales/%locale%/%original_file_name%" }] diff --git a/package.json b/package.json index e2d96d75..d72c8cbc 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,12 @@ "electron-chrome-web-store": "npm:@iamevan/electron-chrome-web-store@0.11.2", "electron-context-menu": "^4.0.5", "electron-updater": "^6.6.2", + "i18next": "^25.1.2", + "i18next-resources-to-backend": "^1.2.1", "knex": "^3.1.0", "mime-types": "^3.0.1", "posthog-node": "^4.17.1", + "react-i18next": "^15.5.1", "sharp": "^0.34.1", "sharp-ico": "^0.1.5", "zod": "^3.24.4" diff --git a/src/main/ipc/app/app.ts b/src/main/ipc/app/app.ts index 77847904..f2dc8336 100644 --- a/src/main/ipc/app/app.ts +++ b/src/main/ipc/app/app.ts @@ -5,7 +5,8 @@ import { ipcMain } from "electron"; ipcMain.handle("app:get-info", async () => { return { version: app.getVersion(), - packaged: app.isPackaged + packaged: app.isPackaged, + locale: app.getLocale() }; }); diff --git a/src/main/modules/posthog.ts b/src/main/modules/posthog.ts index bf10c740..a7c9d35e 100644 --- a/src/main/modules/posthog.ts +++ b/src/main/modules/posthog.ts @@ -46,7 +46,8 @@ function getAppInfoForPosthog() { return { version: app.getVersion(), platform: process.platform, - environment: process.env.NODE_ENV + environment: process.env.NODE_ENV, + locale: app.getLocale() }; } diff --git a/src/preload/index.ts b/src/preload/index.ts index fc1aa834..3746957f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -404,6 +404,7 @@ const appAPI: FlowAppAPI = { const appInfo: { version: string; packaged: boolean; + locale: string; } = await ipcRenderer.invoke("app:get-info"); const appVersion = appInfo.version; const updateChannel: "Stable" | "Beta" | "Alpha" | "Development" = appInfo.packaged ? "Stable" : "Development"; @@ -416,7 +417,8 @@ const appAPI: FlowAppAPI = { chrome_version: process.versions.chrome, electron_version: process.versions.electron, os: os, - update_channel: updateChannel + update_channel: updateChannel, + locale: appInfo.locale }; }, writeTextToClipboard: (text: string) => { diff --git a/src/renderer/src/components/browser-ui/browser-action.tsx b/src/renderer/src/components/browser-ui/browser-action.tsx index a8b51135..f96d3339 100644 --- a/src/renderer/src/components/browser-ui/browser-action.tsx +++ b/src/renderer/src/components/browser-ui/browser-action.tsx @@ -10,6 +10,7 @@ import { cn } from "@/lib/utils"; import { CogIcon, LayersIcon, PackageXIcon, PinIcon, PinOffIcon, PuzzleIcon } from "lucide-react"; import { MouseEvent, useCallback, useMemo, useRef, useState } from "react"; import { PortalPopover } from "@/components/portal/popover"; +import { useBrowserUITranslations } from "@/lib/i18n"; interface BrowserActionListProps { partition?: string; @@ -144,6 +145,7 @@ function BrowserAction({ action, alignment, partition, activeTabId }: BrowserAct } export function BrowserActionList({ alignmentX = "left", alignmentY = "bottom" }: BrowserActionListProps) { + const { t: tBrowserUI } = useBrowserUITranslations(); const { isCurrentSpaceLight } = useSpaces(); const { actions, activeTabId, partition } = useBrowserAction(); const [open, setOpen] = useState(false); @@ -184,19 +186,19 @@ export function BrowserActionList({ alignmentX = "left", alignmentY = "bottom" } {noActiveTab && ( - No Active Tab + {tBrowserUI("No Active Tab")} )} {!noActiveTab && noActions && ( - No Extensions Available + {tBrowserUI("No Extensions Available")} )} - Manage Extensions + {tBrowserUI("Manage Extensions")} diff --git a/src/renderer/src/components/browser-ui/sidebar/content/new-tab-button.tsx b/src/renderer/src/components/browser-ui/sidebar/content/new-tab-button.tsx index 1fc7cef9..642a91cd 100644 --- a/src/renderer/src/components/browser-ui/sidebar/content/new-tab-button.tsx +++ b/src/renderer/src/components/browser-ui/sidebar/content/new-tab-button.tsx @@ -4,10 +4,12 @@ import { SidebarMenuButton } from "@/components/ui/resizable-sidebar"; import { PlusIcon } from "lucide-react"; import { SIDEBAR_HOVER_COLOR } from "@/components/browser-ui/browser-sidebar"; import { cn } from "@/lib/utils"; +import { useBrowserUITranslations } from "@/lib/i18n"; const MotionSidebarMenuButton = motion(SidebarMenuButton); export function NewTabButton() { + const { t: tBrowserUI } = useBrowserUITranslations(); const [isPressed, setIsPressed] = useState(false); const handleMouseDown = () => { @@ -32,7 +34,7 @@ export function NewTabButton() { className={cn(SIDEBAR_HOVER_COLOR, "text-black/50 dark:text-muted-foreground")} > - New Tab + {tBrowserUI("New Tab")} ); } diff --git a/src/renderer/src/components/browser-ui/sidebar/content/space-sidebar.tsx b/src/renderer/src/components/browser-ui/sidebar/content/space-sidebar.tsx index faacf7ae..6057b7ad 100644 --- a/src/renderer/src/components/browser-ui/sidebar/content/space-sidebar.tsx +++ b/src/renderer/src/components/browser-ui/sidebar/content/space-sidebar.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { SidebarGroup, SidebarMenu, useSidebar } from "@/components/ui/resizable-sidebar"; import { Space } from "~/flow/interfaces/sessions/spaces"; import { cn, hex_is_light } from "@/lib/utils"; +import { useBrowserUITranslations } from "@/lib/i18n"; import { AnimatePresence, motion } from "motion/react"; import { useCallback, useMemo, useRef } from "react"; import { DropIndicator as BaseDropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/list-item"; @@ -38,6 +39,7 @@ export function DropIndicator({ isSpaceLight }: { isSpaceLight: boolean }) { function SidebarSectionDivider({ hasTabs, handleCloseAllTabs }: { hasTabs: boolean; handleCloseAllTabs: () => void }) { const { open } = useSidebar(); + const { t: tBrowserUI } = useBrowserUITranslations(); if (!hasTabs) return null; @@ -62,7 +64,7 @@ function SidebarSectionDivider({ hasTabs, handleCloseAllTabs }: { hasTabs: boole size="sm" onClick={handleCloseAllTabs} > - Clear + {tBrowserUI("Clear")} )} diff --git a/src/renderer/src/components/browser-ui/sidebar/header/address-bar/address-bar.tsx b/src/renderer/src/components/browser-ui/sidebar/header/address-bar/address-bar.tsx index d7b7bdde..bc03e109 100644 --- a/src/renderer/src/components/browser-ui/sidebar/header/address-bar/address-bar.tsx +++ b/src/renderer/src/components/browser-ui/sidebar/header/address-bar/address-bar.tsx @@ -2,12 +2,14 @@ import { AddressBarCopyLinkButton } from "@/components/browser-ui/sidebar/header import { PinnedBrowserActions } from "@/components/browser-ui/sidebar/header/address-bar/pinned-browser-actions"; import { useTabs } from "@/components/providers/tabs-provider"; import { SidebarGroup, useSidebar } from "@/components/ui/resizable-sidebar"; +import { useBrowserUITranslations } from "@/lib/i18n"; import { simplifyUrl } from "@/lib/url"; import { cn } from "@/lib/utils"; import { SearchIcon } from "lucide-react"; import { useRef } from "react"; function FakeAddressBar({ className }: { className?: string }) { + const { t: tBrowserUI } = useBrowserUITranslations(); const inputRef = useRef(null); const { addressUrl, focusedTab } = useTabs(); @@ -34,7 +36,7 @@ function FakeAddressBar({ className }: { className?: string }) { const simplifiedUrl = simplifyUrl(addressUrl); const isPlaceholder = !simplifiedUrl; - const value = isPlaceholder ? "Search or type URL" : simplifiedUrl; + const value = isPlaceholder ? tBrowserUI("Search or type URL") : simplifiedUrl; return (
> | null>(null); const [isLoading, setIsLoading] = useState(true); @@ -40,28 +42,28 @@ export function BrowserInfoCard() { // Replaced Card with styled div
-

Browser Information

-

Details about your Flow Browser installation.

+

{tSettings("sections.about.info.title")}

+

{tSettings("sections.about.info.description")}

{isLoading ? (
- Loading browser details... + {tSettings("sections.about.info.loading")}
) : appInfo ? ( // Using a 3-column grid for label & value to better control alignment and wrapping
- - - - - - + + + + + +
) : (
- Could not load browser information. + {tSettings("sections.about.info.loading.failed")}
)}
diff --git a/src/renderer/src/components/settings/sections/about/section.tsx b/src/renderer/src/components/settings/sections/about/section.tsx index b60e50da..a98db2ed 100644 --- a/src/renderer/src/components/settings/sections/about/section.tsx +++ b/src/renderer/src/components/settings/sections/about/section.tsx @@ -1,13 +1,16 @@ import { BrowserInfoCard } from "./browser-info-card"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { useSettingsTranslations } from "@/lib/i18n"; export function AboutSettings() { + const { t: tSettings } = useSettingsTranslations(); + return (
-

About

-

Information about your browser

+

{tSettings("sections.about")}

+

{tSettings("sections.about.description")}

diff --git a/src/renderer/src/components/settings/sections/external-apps/section.tsx b/src/renderer/src/components/settings/sections/external-apps/section.tsx index b2796c48..ad90acc3 100644 --- a/src/renderer/src/components/settings/sections/external-apps/section.tsx +++ b/src/renderer/src/components/settings/sections/external-apps/section.tsx @@ -15,6 +15,8 @@ import { WebsiteFavicon } from "@/components/main/website-favicon"; import { ExternalAppPermission } from "~/flow/interfaces/settings/openExternal"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { useSettingsTranslations } from "@/lib/i18n"; +import { Trans } from "react-i18next"; function PermissionItem({ websiteUrl, @@ -25,6 +27,7 @@ function PermissionItem({ protocols: string[]; onRevoke: (url: string, protocol: string) => void; }) { + const { t: tSettings } = useSettingsTranslations(); const [expanded, setExpanded] = useState(false); const [confirmDialog, setConfirmDialog] = useState<{ isOpen: boolean; protocol: string }>({ isOpen: false, @@ -48,14 +51,14 @@ function PermissionItem({ expanded && "rotate-90" )} /> - +

{websiteUrl}

- {protocols.length} protocol{protocols.length > 1 ? "s" : ""} + {tSettings("settings.external-apps.protocol", { count: protocols.length })}
@@ -91,7 +94,7 @@ function PermissionItem({ setConfirmDialog({ isOpen: true, protocol }); }} > - Revoke + {tSettings("settings.external-apps.revoke")} ))} @@ -106,16 +109,22 @@ function PermissionItem({ > - Confirm Revocation + {tSettings("settings.external-apps.confirm-card.title")} - Revoke permission for {websiteUrl} to open{" "} - {confirmDialog.protocol}{" "} - links? + , + code: + }} + /> @@ -134,6 +143,7 @@ function PermissionItem({ } export function ExternalAppsSettings() { + const { t: tSettings } = useSettingsTranslations(); const [searchQuery, setSearchQuery] = useState(""); const [permissions, setPermissions] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -145,29 +155,29 @@ export function ExternalAppsSettings() { setPermissions(fetchedPermissions); } catch (error) { console.error("Failed to fetch permissions:", error); - toast.error("Could not load permissions."); + toast.error(tSettings("settings.external-apps.loading.failed")); setPermissions([]); } finally { setIsLoading(false); } - }, []); + }, [tSettings]); const revokePermission = useCallback( async (url: string, protocol: string) => { try { const success = await flow.openExternal.unsetAlwaysOpenExternal(url, protocol); if (success) { - toast.success("Permission revoked!"); + toast.success(tSettings("settings.external-apps.revoke-permission.success")); revalidatePermissions(); } else { - toast.error("Failed to revoke permission."); + toast.error(tSettings("settings.external-apps.revoke-permission.failed")); } } catch (error) { console.error("Failed to revoke permission:", error); - toast.error("An error occurred while revoking permission."); + toast.error(tSettings("settings.external-apps.revoke-permission.error")); } }, - [revalidatePermissions] + [revalidatePermissions, tSettings] ); useEffect(() => { @@ -191,25 +201,16 @@ export function ExternalAppsSettings() { return (
-

External Applications

-

- Manage websites and the protocols they are allowed to open automatically. -

+

{tSettings("settings.external-apps.title")}

+

{tSettings("settings.external-apps.description")}

-
-

Protocol Permissions

-

- Websites you have allowed to open external applications via specific protocols. -

-
-
setSearchQuery(e.target.value)} @@ -219,17 +220,19 @@ export function ExternalAppsSettings() { {isLoading ? (
-

Loading permissions...

+

{tSettings("settings.external-apps.loading")}

) : filteredWebsites.length === 0 ? (

- {searchQuery ? "No matching permissions found" : "No permissions configured"} + {searchQuery + ? tSettings("settings.external-apps.no-matching-permissions") + : tSettings("settings.external-apps.no-permissions-configured")}

{!searchQuery && (

- Websites will ask for permission to open external links. + {tSettings("settings.external-apps.no-permissions-found.description")}

)}
@@ -247,10 +250,7 @@ export function ExternalAppsSettings() { )}
-

- Note: When you revoke a permission, the website will need to ask for permission again the next time it tries - to open that protocol. -

+

{tSettings("settings.external-apps.note")}

diff --git a/src/renderer/src/components/settings/sections/general/section.tsx b/src/renderer/src/components/settings/sections/general/section.tsx index b9b9e8bf..49590a88 100644 --- a/src/renderer/src/components/settings/sections/general/section.tsx +++ b/src/renderer/src/components/settings/sections/general/section.tsx @@ -1,11 +1,14 @@ import { BasicSettingsCards } from "@/components/settings/sections/general/basic-settings-cards"; +import { useSettingsTranslations } from "@/lib/i18n"; export function GeneralSettings() { + const { t: tSettings } = useSettingsTranslations(); + return (
-

General

-

{"Manage your browser's general settings"}

+

{tSettings("sections.general")}

+

{tSettings("sections.general.description")}

diff --git a/src/renderer/src/components/settings/sections/icon/section.tsx b/src/renderer/src/components/settings/sections/icon/section.tsx index 7449aff1..f23af1a7 100644 --- a/src/renderer/src/components/settings/sections/icon/section.tsx +++ b/src/renderer/src/components/settings/sections/icon/section.tsx @@ -3,6 +3,7 @@ import { Check, Loader2 } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { motion } from "motion/react"; import { toast } from "sonner"; +import { useIconsTranslations, useSettingsTranslations } from "@/lib/i18n"; interface IconOption { id: string; @@ -13,6 +14,9 @@ interface IconOption { } export function IconSettings() { + const { t: tSettings } = useSettingsTranslations(); + const { t: tIcons } = useIconsTranslations(); + const [selectedIcon, setSelectedIcon] = useState(""); const [iconOptions, setIconOptions] = useState([]); const [isUpdating, setIsUpdating] = useState(false); @@ -89,17 +93,17 @@ export function IconSettings() {
- Browser Icon - Select an icon for your browser application + {tSettings("settings.icon.title")} + {tSettings("settings.icon.description")} {!isSupported ? (
-
Icon customization is not supported on this platform.
+
{tSettings("settings.icon.unsupported")}
) : isLoading ? (
-
Loading icons...
+
{tSettings("settings.icon.loading")}
) : (
@@ -131,7 +135,7 @@ export function IconSettings() {
-

{icon.name}

+

{tIcons(icon.name)}

{icon.author &&

by {icon.author}

}
@@ -159,7 +163,7 @@ export function IconSettings() { {icon.current && selectedIcon !== icon.id && !isUpdating && (
- CURRENT + {tSettings("settings.icon.current")}
)} diff --git a/src/renderer/src/components/settings/settings-layout.tsx b/src/renderer/src/components/settings/settings-layout.tsx index 1829285c..26676913 100644 --- a/src/renderer/src/components/settings/settings-layout.tsx +++ b/src/renderer/src/components/settings/settings-layout.tsx @@ -12,20 +12,23 @@ import { SettingsProvider } from "@/components/providers/settings-provider"; import { AppUpdatesProvider } from "@/components/providers/app-updates-provider"; import { Globe, DockIcon, UsersIcon, OrbitIcon, BlocksIcon, Info, KeyboardIcon } from "lucide-react"; import { ShortcutsProvider } from "@/components/providers/shortcuts-provider"; +import { useSettingsTranslations } from "@/lib/i18n"; export function SettingsLayout() { + const { t: tSettings } = useSettingsTranslations(); + const [activeSection, setActiveSection] = useState("general"); const [selectedProfileId, setSelectedProfileId] = useState(null); const [selectedSpaceId, setSelectedSpaceId] = useState(null); const sections = [ - { id: "general", label: "General", icon: }, - { id: "icons", label: "Icon", icon: }, - { id: "profiles", label: "Profiles", icon: }, - { id: "spaces", label: "Spaces", icon: }, - { id: "external-apps", label: "External Apps", icon: }, - { id: "shortcuts", label: "Shortcuts", icon: }, - { id: "about", label: "About", icon: } + { id: "general", label: tSettings("sections.general"), icon: }, + { id: "icons", label: tSettings("sections.icon"), icon: }, + { id: "profiles", label: tSettings("sections.profiles"), icon: }, + { id: "spaces", label: tSettings("sections.spaces"), icon: }, + { id: "external-apps", label: tSettings("sections.external-apps"), icon: }, + { id: "shortcuts", label: tSettings("sections.shortcuts"), icon: }, + { id: "about", label: tSettings("sections.about"), icon: } ]; const navigateToSpaces = (profileId: string) => { diff --git a/src/renderer/src/components/settings/settings-titlebar.tsx b/src/renderer/src/components/settings/settings-titlebar.tsx index 7564bc14..560a48e4 100644 --- a/src/renderer/src/components/settings/settings-titlebar.tsx +++ b/src/renderer/src/components/settings/settings-titlebar.tsx @@ -1,9 +1,13 @@ "use client"; +import { useSettingsTranslations } from "@/lib/i18n"; + export function SettingsTitlebar() { + const { t: tSettings } = useSettingsTranslations(); + return (
- Flow Settings + {tSettings("title")}
); } diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts new file mode 100644 index 00000000..f421706c --- /dev/null +++ b/src/renderer/src/lib/i18n.ts @@ -0,0 +1,67 @@ +import i18n from "i18next"; +import { initReactI18next, useTranslation } from "react-i18next"; +import resourcesToBackend from "i18next-resources-to-backend"; + +// Config +const I18N_DEBUG_ENABLED = false; + +// Tell vite to load all locale files +import.meta.glob("~/locales/*/*.json"); + +// Initialize i18n as quickly as possible +i18n + .use(resourcesToBackend((language: string, namespace: string) => import(`~/locales/${language}/${namespace}.json`))) + .use(initReactI18next) // passes i18n down to react-i18next + .init({ + lng: "en", // default language + fallbackLng: "en", + + ns: ["browser-ui", "settings", "icons"], + + keySeparator: false, // turn off nested key splitting + nsSeparator: false, // turn off namespace splitting + + interpolation: { + escapeValue: false // react already safes from xss + }, + postProcess: I18N_DEBUG_ENABLED ? ["debugger"] : [], + debug: I18N_DEBUG_ENABLED + }); + +// Custom post processor for debugging +const debugProcessor = { + type: "postProcessor" as const, // Add type assertion + name: "debugger", + // eslint-disable-next-line @typescript-eslint/no-unused-vars + process(value: string, _key: string, _options: unknown, _translator: unknown) { + if (I18N_DEBUG_ENABLED) { + return "a"; + } + return value; + } +}; + +i18n.use(debugProcessor); // Register the post processor + +// Set the language to the user's locale +flow.app.getAppInfo().then((appInfo) => { + i18n.changeLanguage(appInfo.locale); +}); + +export function useBrowserUITranslations() { + return useTranslation("browser-ui"); +} + +export function useSettingsTranslations() { + return useTranslation("settings"); +} + +export function useIconsTranslations() { + return useTranslation("icons"); +} + +export function usePagesTranslations() { + return useTranslation("pages"); +} + +export default i18n; diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 327304ff..04da8c28 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -2,6 +2,7 @@ import { Fragment, StrictMode as ReactStrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App"; +import "@/lib/i18n"; const STRICT_MODE_ENABLED = true; diff --git a/src/renderer/src/routes/about/page.tsx b/src/renderer/src/routes/about/page.tsx index 1feee2c1..6c4443bf 100644 --- a/src/renderer/src/routes/about/page.tsx +++ b/src/renderer/src/routes/about/page.tsx @@ -2,8 +2,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Button } from "@/components/ui/button"; import { motion } from "motion/react"; import { copyTextToClipboard } from "@/lib/utils"; +import { usePagesTranslations } from "@/lib/i18n"; function Page() { + const { t: tPages } = usePagesTranslations(); + const hostnames = ["about", "new-tab", "games", "omnibox", "error", "extensions"]; return ( @@ -16,8 +19,8 @@ function Page() { > - Flow URLs - A list of available Flow browser URLs + {tPages("about.title")} + {tPages("about.description")}
diff --git a/src/shared/flow/interfaces/app/app.ts b/src/shared/flow/interfaces/app/app.ts index 70f0d492..3f431911 100644 --- a/src/shared/flow/interfaces/app/app.ts +++ b/src/shared/flow/interfaces/app/app.ts @@ -11,6 +11,7 @@ export interface FlowAppAPI { electron_version: string; os: string; update_channel: "Stable" | "Beta" | "Alpha" | "Development"; + locale: string; }>; /** diff --git a/src/shared/locales/en-US/browser-ui.json b/src/shared/locales/en-US/browser-ui.json new file mode 100644 index 00000000..326184d0 --- /dev/null +++ b/src/shared/locales/en-US/browser-ui.json @@ -0,0 +1,8 @@ +{ + "Clear": "Clear", + "New Tab": "New Tab", + "Search or type URL": "Search or type URL", + "No Active Tab": "No Active Tab", + "No Extensions Available": "No Extensions Available", + "Manage Extensions": "Manage Extensions" +} diff --git a/src/shared/locales/en-US/icons.json b/src/shared/locales/en-US/icons.json new file mode 100644 index 00000000..5eca8bee --- /dev/null +++ b/src/shared/locales/en-US/icons.json @@ -0,0 +1,10 @@ +{ + "Default": "Default", + "Nature": "Nature", + "3D": "3D", + "Darkness": "Darkness", + "Glowy": "Glowy", + "Minimal Flat": "Minimal Flat", + "Retro": "Retro", + "Summer": "Summer" +} diff --git a/src/shared/locales/en-US/pages.json b/src/shared/locales/en-US/pages.json new file mode 100644 index 00000000..be3c2178 --- /dev/null +++ b/src/shared/locales/en-US/pages.json @@ -0,0 +1,4 @@ +{ + "about.title": "Flow URLs", + "about.description": "A list of available Flow browser URLs" +} diff --git a/src/shared/locales/en-US/settings.json b/src/shared/locales/en-US/settings.json new file mode 100644 index 00000000..a2a894fc --- /dev/null +++ b/src/shared/locales/en-US/settings.json @@ -0,0 +1,46 @@ +{ + "title": "Flow Settings", + "cancel": "Cancel", + "sections.general": "General", + "sections.icon": "Icon", + "sections.profiles": "Profiles", + "sections.spaces": "Spaces", + "sections.external-apps": "External Apps", + "sections.shortcuts": "Shortcuts", + "sections.about": "About", + "sections.general.description": "Manage your browser's general settings", + "settings.icon.title": "Browser Icon", + "settings.icon.description": "Select an icon for your browser application", + "settings.icon.unsupported": "Icon customization is not supported on this platform.", + "settings.icon.loading": "Loading icons...", + "settings.icon.current": "CURRENT", + "settings.external-apps.title": "External Applications", + "settings.external-apps.description": "Manage websites and the protocols they are allowed to open automatically.", + "settings.external-apps.note": "Note: When you revoke a permission, the website will need to ask for permission again the next time it tries to open that protocol.", + "settings.external-apps.confirm-card.title": "Confirm Revocation", + "settings.external-apps.confirm-card.description": "Revoke permission for {{websiteUrl}} to open {{protocol}} links?", + "settings.external-apps.search-placeholder": "Search by website or protocol...", + "settings.external-apps.revoke-permission.confirm": "Revoke Permission", + "settings.external-apps.revoke-permission.success": "Permission revoked!", + "settings.external-apps.revoke-permission.failed": "Failed to revoke permission.", + "settings.external-apps.revoke-permission.error": "An error occurred while revoking permission.", + "settings.external-apps.protocol_one": "{{count}} protocol", + "settings.external-apps.protocol_other": "{{count}} protocols", + "settings.external-apps.no-matching-permissions": "No matching permissions found", + "settings.external-apps.no-permissions-configured": "No permissions configured", + "settings.external-apps.no-permissions-found.description": "We couldn't find any matching permissions.", + "settings.external-apps.loading": "Loading permissions...", + "settings.external-apps.loading.failed": "Could not load permissions.", + "settings.external-apps.revoke": "Revoke", + "sections.about.description": "Information about your browser", + "sections.about.info.title": "Browser Information", + "sections.about.info.description": "Details about your browser", + "sections.about.info.browser-name": "Browser Name", + "sections.about.info.version": "Version", + "sections.about.info.build": "Build", + "sections.about.info.engine": "Engine", + "sections.about.info.os": "OS", + "sections.about.info.update-channel": "Update Channel", + "sections.about.info.loading": "Loading browser details...", + "sections.about.info.loading.failed": "Could not load browser information." +} diff --git a/src/shared/locales/pl-PL/browser-ui.json b/src/shared/locales/pl-PL/browser-ui.json new file mode 100644 index 00000000..15a690c9 --- /dev/null +++ b/src/shared/locales/pl-PL/browser-ui.json @@ -0,0 +1,8 @@ +{ + "Tabs": "Karty", + "New Tab": "Nowa karta", + "Search or type URL": "Wyszukaj lub wpisz URL", + "No Active Tab": "Brak aktywnej karty", + "No Extensions Available": "Brak dostępnych rozszerzeń", + "Manage Extensions": "Zarządzaj rozszerzeniami" +} diff --git a/src/shared/locales/pl-PL/icons.json b/src/shared/locales/pl-PL/icons.json new file mode 100644 index 00000000..5eca8bee --- /dev/null +++ b/src/shared/locales/pl-PL/icons.json @@ -0,0 +1,10 @@ +{ + "Default": "Default", + "Nature": "Nature", + "3D": "3D", + "Darkness": "Darkness", + "Glowy": "Glowy", + "Minimal Flat": "Minimal Flat", + "Retro": "Retro", + "Summer": "Summer" +} diff --git a/src/shared/locales/pl-PL/settings.json b/src/shared/locales/pl-PL/settings.json new file mode 100644 index 00000000..6517755f --- /dev/null +++ b/src/shared/locales/pl-PL/settings.json @@ -0,0 +1,12 @@ +{ + "Flow Settings": "Ustawienia Flow", + "General": "Ogólne", + "Icon": "Ikona", + "Profiles": "Profile", + "Spaces": "Obszary", + "External Apps": "Aplikacje zewnętrzne", + "About": "O Flow", + "Manage your browser's general settings": "Manage your browser's general settings", + "Browser Icon": "Browser Icon", + "Select an icon for your browser application": "Select an icon for your browser application" +} diff --git a/src/shared/locales/zh-CN/browser-ui.json b/src/shared/locales/zh-CN/browser-ui.json new file mode 100644 index 00000000..a44396fd --- /dev/null +++ b/src/shared/locales/zh-CN/browser-ui.json @@ -0,0 +1,8 @@ +{ + "Tabs": "标签页", + "New Tab": "新标签页", + "Search or type URL": "搜索或输入网址", + "No Active Tab": "没有活动的标签页", + "No Extensions Available": "没有可用的扩展程序", + "Manage Extensions": "管理扩展程序" +} diff --git a/src/shared/locales/zh-CN/icons.json b/src/shared/locales/zh-CN/icons.json new file mode 100644 index 00000000..5eca8bee --- /dev/null +++ b/src/shared/locales/zh-CN/icons.json @@ -0,0 +1,10 @@ +{ + "Default": "Default", + "Nature": "Nature", + "3D": "3D", + "Darkness": "Darkness", + "Glowy": "Glowy", + "Minimal Flat": "Minimal Flat", + "Retro": "Retro", + "Summer": "Summer" +} diff --git a/src/shared/locales/zh-CN/settings.json b/src/shared/locales/zh-CN/settings.json new file mode 100644 index 00000000..a5caf141 --- /dev/null +++ b/src/shared/locales/zh-CN/settings.json @@ -0,0 +1,12 @@ +{ + "Flow Settings": "Flow 设置", + "General": "一般", + "Icon": "图标", + "Profiles": "設定檔配置文件", + "Spaces": "空间", + "External Apps": "外部应用程序", + "About": "关于", + "Manage your browser's general settings": "Manage your browser's general settings", + "Browser Icon": "Browser Icon", + "Select an icon for your browser application": "Select an icon for your browser application" +} diff --git a/src/shared/locales/zh-TW/browser-ui.json b/src/shared/locales/zh-TW/browser-ui.json new file mode 100644 index 00000000..d86f31bc --- /dev/null +++ b/src/shared/locales/zh-TW/browser-ui.json @@ -0,0 +1,8 @@ +{ + "Tabs": "分頁", + "New Tab": "新分頁", + "Search or type URL": "搜尋或輸入網址", + "No Active Tab": "沒有使用中的分頁", + "No Extensions Available": "沒有可用的擴充功能", + "Manage Extensions": "管理擴充功能" +} diff --git a/src/shared/locales/zh-TW/icons.json b/src/shared/locales/zh-TW/icons.json new file mode 100644 index 00000000..5eca8bee --- /dev/null +++ b/src/shared/locales/zh-TW/icons.json @@ -0,0 +1,10 @@ +{ + "Default": "Default", + "Nature": "Nature", + "3D": "3D", + "Darkness": "Darkness", + "Glowy": "Glowy", + "Minimal Flat": "Minimal Flat", + "Retro": "Retro", + "Summer": "Summer" +} diff --git a/src/shared/locales/zh-TW/settings.json b/src/shared/locales/zh-TW/settings.json new file mode 100644 index 00000000..7467afa4 --- /dev/null +++ b/src/shared/locales/zh-TW/settings.json @@ -0,0 +1,12 @@ +{ + "Flow Settings": "Flow 設定", + "General": "一般", + "Icon": "圖示", + "Profiles": "設定檔", + "Spaces": "空間", + "External Apps": "外部應用程式", + "About": "關於", + "Manage your browser's general settings": "Manage your browser's general settings", + "Browser Icon": "Browser Icon", + "Select an icon for your browser application": "Select an icon for your browser application" +}