diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 670b5b9..5354636 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "dependencies": { "@tanstack/react-query": "^5.90.21", + "i18next": "^26.3.6", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^17.0.9", "react-router-dom": "^6.28.2", "zod": "^4.3.6" }, @@ -264,6 +266,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2676,6 +2687,43 @@ "hermes-estree": "0.25.1" } }, + "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/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3121,6 +3169,33 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "17.0.9", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.9.tgz", + "integrity": "sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3388,7 +3463,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3470,6 +3545,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -3627,6 +3711,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/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 176e28c..f2758d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,8 +12,10 @@ }, "dependencies": { "@tanstack/react-query": "^5.90.21", + "i18next": "^26.3.6", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^17.0.9", "react-router-dom": "^6.28.2", "zod": "^4.3.6" }, diff --git a/frontend/src/components/i18n/language-switcher.tsx b/frontend/src/components/i18n/language-switcher.tsx new file mode 100644 index 0000000..c6ddde2 --- /dev/null +++ b/frontend/src/components/i18n/language-switcher.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from 'react-i18next' +import { persistLanguage, SUPPORTED_LANGUAGES, type SupportedLanguage } from '../../i18n' + +const languageLabels: Record = { + es: 'language.spanish', + en: 'language.english', +} + +export function LanguageSwitcher() { + const { i18n, t } = useTranslation() + const currentLanguage = (i18n.resolvedLanguage ?? i18n.language) as SupportedLanguage + + const handleLanguageChange = async (language: SupportedLanguage) => { + await i18n.changeLanguage(language) + persistLanguage(language) + } + + return ( + + ) +} diff --git a/frontend/src/components/layout/app-shell.tsx b/frontend/src/components/layout/app-shell.tsx index 8ba721c..7fbb551 100644 --- a/frontend/src/components/layout/app-shell.tsx +++ b/frontend/src/components/layout/app-shell.tsx @@ -1,50 +1,41 @@ -import { useState, type ReactNode } from 'react' +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' import { NavLink } from 'react-router-dom' import { useAuth } from '../../features/auth/auth-store' -import { applyTheme, getInitialTheme, type ThemeMode } from '../../lib/utils/theme' +import { LanguageSwitcher } from '../i18n/language-switcher' import { Button } from '../ui/button' import { Badge } from '../ui/badge' +import { ThemeToggle } from '../ui/theme-toggle' type AppShellProps = { children: ReactNode } export function AppShell({ children }: AppShellProps) { + const { t } = useTranslation() const { session, logout } = useAuth() - const [theme, setTheme] = useState(() => { - const initial = getInitialTheme() - applyTheme(initial) - return initial - }) - - const toggleTheme = () => { - const nextTheme: ThemeMode = theme === 'light' ? 'dark' : 'light' - setTheme(nextTheme) - applyTheme(nextTheme) - } return (
- Prompt Catalog -

Personal library for prompts that work

+ {t('app.name')} +

{t('app.tagline')}

- Catalog + {t('nav.catalog')} {session.role === 'admin' || session.role === 'god' ? ( - Admin + {t('nav.admin')} ) : null} {session.username} · {session.role} - + +
diff --git a/frontend/src/components/prompts/prompt-detail-dialog.tsx b/frontend/src/components/prompts/prompt-detail-dialog.tsx index 6c0dcab..ddf0b5c 100644 --- a/frontend/src/components/prompts/prompt-detail-dialog.tsx +++ b/frontend/src/components/prompts/prompt-detail-dialog.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import type { PromptRecord } from '../../features/prompts/prompts-types' import { Button } from '../ui/button' @@ -14,6 +15,8 @@ export function PromptDetailDialog({ onEdit, onDelete, }: PromptDetailDialogProps) { + const { t } = useTranslation() + if (!prompt) return null const handleEdit = () => { @@ -35,30 +38,36 @@ export function PromptDetailDialog({ aria-labelledby="prompt-detail-title" onMouseDown={(event) => event.stopPropagation()} > -

Prompt details

+

{t('prompts.detail.title')}

-
- Model -

{prompt.model_name}

+
+
+ {t('prompts.detail.model')} + {prompt.model_name} +
+
+ {t('prompts.detail.category')} + {prompt.category} +
+
+ {t('prompts.detail.rating')} + {t('common.rating', { value: prompt.rate })} +
- Prompt + {t('prompts.detail.prompt')}

{prompt.prompt_text}

-
- {prompt.category} - Rating {prompt.rate}/5 -
diff --git a/frontend/src/components/prompts/prompt-form.tsx b/frontend/src/components/prompts/prompt-form.tsx index e37b666..1d47f9c 100644 --- a/frontend/src/components/prompts/prompt-form.tsx +++ b/frontend/src/components/prompts/prompt-form.tsx @@ -1,12 +1,13 @@ import { useState, type FormEvent } from 'react' +import { useTranslation } from 'react-i18next' import type { SelectOption } from '../../features/options/options-types' import type { PromptInput, PromptRecord } from '../../features/prompts/prompts-types' import { promptSchema } from '../../lib/validation/prompt-schemas' import { Button } from '../ui/button' import { ComboboxInput } from '../ui/combobox-input' -import { Input } from '../ui/input' import { RatingInput } from '../ui/rating-input' import { Select } from '../ui/select' +import { Textarea } from '../ui/textarea' type PromptFormProps = { initialValue?: PromptRecord | null @@ -29,6 +30,7 @@ export function PromptForm({ onSubmit, onCancelEdit, }: PromptFormProps) { + const { t } = useTranslation() const [form, setForm] = useState({ model_name: initialValue?.model_name ?? '', prompt_text: initialValue?.prompt_text ?? '', @@ -56,13 +58,13 @@ export function PromptForm({ } } setFieldErrors(nextErrors) - setError(parsed.error.issues[0]?.message ?? 'Invalid form data') + setError(parsed.error.issues[0]?.message ?? t('prompts.form.invalid')) return } const selectedModel = modelOptions.find((option) => option.value === parsed.data.model_name) if (!selectedModel) { - const message = 'Select a known model from the list.' + const message = t('prompts.form.knownModel') setFieldErrors({ model_name: message }) setError(message) return @@ -79,29 +81,32 @@ export function PromptForm({ return (
handleChange('model_name', value)} error={fieldErrors.model_name} /> - handleChange('prompt_text', e.target.value)} error={fieldErrors.prompt_text} + placeholder={t('prompts.form.promptPlaceholder')} + rows={7} />