diff --git a/.env.example b/.env.example index 223d7e8..d646421 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ SIGNUPS_ENABLED=true # Node Environment NODE_ENV=development + +# DeepL API Key (for AI Translation feature) +DEEPL_API_KEY=your_deepl_api_key_here diff --git a/package-lock.json b/package-lock.json index be2afe1..3228dcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -154,6 +154,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -453,7 +454,8 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", "devOptional": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", @@ -2300,6 +2302,7 @@ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.0.tgz", "integrity": "sha512-Sc+ncr7+ph1hMf1LQfn6UyEXDEamCd5pXMsx8Q3SBH0NGX+zjqs3eaABt9hXwbcK9l7f8UyK8ldxOWA2LyPynQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/client-runtime-utils": "7.4.0" }, @@ -4161,18 +4164,6 @@ } } }, - "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": { - "version": "0.22.4", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz", - "integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^8.3.0", - "node-gyp-build": "^4.8.4" - } - }, "node_modules/@swagger-api/apidom-reference": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.1.0.tgz", @@ -4667,6 +4658,7 @@ "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4692,6 +4684,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5177,6 +5170,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5596,6 +5590,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6637,6 +6632,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6759,6 +6755,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7005,6 +7002,7 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -7912,6 +7910,7 @@ "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -7970,6 +7969,7 @@ "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", "integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10077,6 +10077,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "7.4.0", "@prisma/dev": "0.20.0", @@ -10227,6 +10228,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -10286,6 +10288,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10295,6 +10298,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10461,7 +10465,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-immutable": { "version": "4.0.0", @@ -11526,7 +11531,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -11602,6 +11608,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11642,18 +11649,6 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, - "node_modules/tree-sitter": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", - "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" - } - }, "node_modules/tree-sitter-json": { "version": "0.24.8", "resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz", @@ -11843,6 +11838,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12290,6 +12286,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/app/(dashboard)/projects/[projectId]/translations/page.tsx b/src/app/(dashboard)/projects/[projectId]/translations/page.tsx index 8ae764b..2558ac8 100644 --- a/src/app/(dashboard)/projects/[projectId]/translations/page.tsx +++ b/src/app/(dashboard)/projects/[projectId]/translations/page.tsx @@ -14,6 +14,7 @@ import { Lock, Info, Copy, + Wand2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -103,7 +104,7 @@ export default function TranslationsPage() { const addLocaleMutation = useAddLocale(projectId); const deleteLocaleMutation = useDeleteLocale(projectId); const updateTranslationMutation = useUpdateTranslation(projectId); - + // Check permissions const permissions = useProjectPermissions(projectId); @@ -124,11 +125,12 @@ export default function TranslationsPage() { } | null>(null); const [selectedLabelIds, setSelectedLabelIds] = React.useState([]); const [isSavingLabels, setIsSavingLabels] = React.useState(false); - + // Pagination state const [currentPage, setCurrentPage] = React.useState(1); const [labelsDialogPage, setLabelsDialogPage] = React.useState(1); const [labelsSearchQuery, setLabelsSearchQuery] = React.useState(""); + const [aiTranslatingTermId, setAiTranslatingTermId] = React.useState(null); // Get translations for selected locale const { data: translations = [] } = useTranslations( @@ -251,7 +253,7 @@ export default function TranslationsPage() { if (selectedLocale === localeCode) { setSelectedLocale(""); } - + // Close the dialog after successful deletion setDeletingLocale(null); } catch (error) { @@ -333,7 +335,7 @@ export default function TranslationsPage() { // Refresh terms to show updated labels refetchTerms(); - + setIsLabelsOpen(false); setLabelingTerm(null); setSelectedLabelIds([]); @@ -352,6 +354,41 @@ export default function TranslationsPage() { ); }; + const handleAiTranslate = async (termId: string) => { + if (!selectedLocale) return; + + setAiTranslatingTermId(termId); + try { + const response = await fetch( + `/api/v1/projects/${projectId}/translations/ai-translate`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ sourceLocaleCode: selectedLocale, termId }), + } + ); + + const json = await response.json() as { data?: { translated: number; skipped: number }; error?: string }; + + if (!response.ok) { + throw new Error(json.error || 'AI translation failed'); + } + + const translated = json.data?.translated ?? 0; + const skipped = json.data?.skipped ?? 0; + if (translated === 0) { + toast.info("No missing translations to fill"); + } else { + toast.success(`Translated to ${translated} language${translated !== 1 ? 's' : ''}${skipped > 0 ? ` (${skipped} skipped)` : ''}`); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "AI translation failed"); + } finally { + setAiTranslatingTermId(null); + } + }; + // Filter labels based on search query const filteredLabels = React.useMemo(() => { if (!labelsSearchQuery.trim()) return labels; @@ -406,61 +443,61 @@ export default function TranslationsPage() { - - Add Locale - - Add a new language to your project - - -
-
- - -

- Choose from predefined locale codes with language and region -

+ + Add Locale + + Add a new language to your project + + +
+
+ + +

+ Choose from predefined locale codes with language and region +

+
-
- - - - - - + + + + + + )}
@@ -538,54 +575,54 @@ export default function TranslationsPage() { {permissions.canManageLocales && locales.length > 0 && ( - { - if (open) { - const locale = locales.find((l) => l.locale.code === selectedLocale); - if (locale && 'id' in locale) setDeletingLocale((locale as { id: string }).id); - } else if (!deleteLocaleMutation.isPending) { - setDeletingLocale(null); - } - }} - > - - - - - - Delete Locale - - Are you sure you want to delete this locale? All translations for this language will be permanently removed. - - - - Cancel - { - const locale = locales.find((l) => l.locale.code === selectedLocale); - if (locale && 'locale' in locale && 'code' in locale.locale) { - handleDeleteLocale(locale.locale.code); - } - }} - disabled={deleteLocaleMutation.isPending && deletingLocale !== null} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {deleteLocaleMutation.isPending && deletingLocale !== null ? ( - <> - - Deleting... - - ) : ( - "Delete" - )} - - - - + { + if (open) { + const locale = locales.find((l) => l.locale.code === selectedLocale); + if (locale && 'id' in locale) setDeletingLocale((locale as { id: string }).id); + } else if (!deleteLocaleMutation.isPending) { + setDeletingLocale(null); + } + }} + > + + + + + + Delete Locale + + Are you sure you want to delete this locale? All translations for this language will be permanently removed. + + + + Cancel + { + const locale = locales.find((l) => l.locale.code === selectedLocale); + if (locale && 'locale' in locale && 'code' in locale.locale) { + handleDeleteLocale(locale.locale.code); + } + }} + disabled={deleteLocaleMutation.isPending && deletingLocale !== null} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleteLocaleMutation.isPending && deletingLocale !== null ? ( + <> + + Deleting... + + ) : ( + "Delete" + )} + + + + )} @@ -618,202 +655,223 @@ export default function TranslationsPage() { const isTermLocked = term.isLocked || false; const canEditLockedTerm = permissions.isOwner || permissions.isAdmin; const isDisabled = isTermLocked && !canEditLockedTerm; - + return ( - - -
-
- {term.value} - {isTermLocked && ( - - - - )} -
- {term.labels && term.labels.length > 0 && ( - -
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {term.labels.map((label: any) => ( - label.value ? ( - - - + +
+
+ {term.value} + {isTermLocked && ( + + + + )} +
+ {term.labels && term.labels.length > 0 && ( + +
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {term.labels.map((label: any) => ( + label.value ? ( + + + + {label.name} + + + +

{label.value}

+
+
+ ) : ( + {label.name} - - -

{label.value}

-
- - ) : ( - - {label.name} - - ) - ))} -
-
- )} -
-
- -
-