From eb4fb2c000d716633ff696610648e2ec611275ea Mon Sep 17 00:00:00 2001 From: luisfponce Date: Tue, 14 Jul 2026 13:09:20 -0600 Subject: [PATCH 1/2] make multi languaje front ui --- frontend/package-lock.json | 95 ++++++++++++++++++- frontend/package.json | 2 + .../src/components/i18n/language-switcher.tsx | 35 +++++++ frontend/src/components/layout/app-shell.tsx | 16 ++-- .../src/components/prompts/prompt-list.tsx | 10 +- frontend/src/i18n/index.ts | 53 +++++++++++ frontend/src/i18n/locales/en.ts | 86 +++++++++++++++++ frontend/src/i18n/locales/es.ts | 86 +++++++++++++++++ frontend/src/main.tsx | 1 + frontend/src/pages/landing/landing-page.tsx | 40 ++++---- frontend/src/pages/login/login-page.tsx | 22 +++-- frontend/src/pages/prompts/prompts-page.tsx | 34 +++---- frontend/src/pages/register/register-page.tsx | 26 ++--- frontend/src/styles/base.css | 10 ++ 14 files changed, 447 insertions(+), 69 deletions(-) create mode 100644 frontend/src/components/i18n/language-switcher.tsx create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/locales/en.ts create mode 100644 frontend/src/i18n/locales/es.ts 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..e599e72 100644 --- a/frontend/src/components/layout/app-shell.tsx +++ b/frontend/src/components/layout/app-shell.tsx @@ -1,7 +1,9 @@ import { useState, 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' @@ -10,6 +12,7 @@ type AppShellProps = { } export function AppShell({ children }: AppShellProps) { + const { t } = useTranslation() const { session, logout } = useAuth() const [theme, setTheme] = useState(() => { const initial = getInitialTheme() @@ -27,24 +30,25 @@ export function AppShell({ children }: AppShellProps) {
- 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-list.tsx b/frontend/src/components/prompts/prompt-list.tsx index ba7f2ab..940e5d3 100644 --- a/frontend/src/components/prompts/prompt-list.tsx +++ b/frontend/src/components/prompts/prompt-list.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useTranslation } from 'react-i18next' import type { PromptRecord } from '../../features/prompts/prompts-types' import { EmptyState } from '../ui/empty-state' import { PromptDetailDialog } from './prompt-detail-dialog' @@ -20,13 +21,14 @@ function summarizePromptText(text: string, maxLength = 140) { } export function PromptList({ prompts, onEdit, onDelete }: PromptListProps) { + const { t } = useTranslation() const [selectedPrompt, setSelectedPrompt] = useState(null) if (!prompts.length) { return ( ) } @@ -47,9 +49,9 @@ export function PromptList({ prompts, onEdit, onDelete }: PromptListProps) { {summary} {prompt.category} - Rating {prompt.rate}/5 + {t('common.rating', { value: prompt.rate })} - View prompt details + {t('prompts.viewDetails')} ) })} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..a6e2ccc --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,53 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import { en } from './locales/en' +import { es } from './locales/es' + +export const STORAGE_KEY = 'app.language' +export const DEFAULT_LANGUAGE = 'es' +export const SUPPORTED_LANGUAGES = ['es', 'en'] as const + +export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number] + +const resources = { + es: { translation: es }, + en: { translation: en }, +} + +function isSupportedLanguage(language: string | null): language is SupportedLanguage { + return SUPPORTED_LANGUAGES.includes(language as SupportedLanguage) +} + +export function getInitialLanguage(): SupportedLanguage { + const urlLanguage = new URLSearchParams(window.location.search).get('lng') + if (isSupportedLanguage(urlLanguage)) { + return urlLanguage + } + + const savedLanguage = localStorage.getItem(STORAGE_KEY) + if (isSupportedLanguage(savedLanguage)) { + return savedLanguage + } + + return DEFAULT_LANGUAGE +} + +export function persistLanguage(language: SupportedLanguage) { + localStorage.setItem(STORAGE_KEY, language) + document.documentElement.lang = language + document.documentElement.dir = 'ltr' +} + +i18n.use(initReactI18next).init({ + resources, + lng: getInitialLanguage(), + fallbackLng: DEFAULT_LANGUAGE, + supportedLngs: SUPPORTED_LANGUAGES, + interpolation: { + escapeValue: false, + }, +}) + +persistLanguage(i18n.resolvedLanguage as SupportedLanguage) + +export { i18n } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts new file mode 100644 index 0000000..1039475 --- /dev/null +++ b/frontend/src/i18n/locales/en.ts @@ -0,0 +1,86 @@ +export const en = { + app: { + name: 'Prompt Catalog', + tagline: 'Personal library for prompts that work', + }, + language: { + label: 'Select language', + spanish: 'Español', + english: 'English', + }, + nav: { + catalog: 'Catalog', + admin: 'Admin', + signIn: 'Sign in', + createAccount: 'Create account', + logout: 'Logout', + main: 'Main', + }, + theme: { + dark: 'Dark', + light: 'Light', + }, + common: { + loading: 'Loading...', + rating: 'Rating {{value}}/5', + }, + landing: { + badge: 'Your onw Prompt portfolio organized as your convinience', + title: 'Build your personal catalog of prompts that actually work.', + copy: 'Save, classify, rate, and reuse AI prompts with a focused catalog experience backed by FastAPI, JWT auth, MariaDB, Redis, and React Query.', + features: [ + 'Search prompts by content, model, category, and rating', + 'Reuse proven prompts with copy, edit, duplicate, and delete actions', + 'Admin-ready RBAC with prompt monitoring and future analytics', + ], + featureDescription: 'Designed to turn a basic CRUD into a product recruiters and users can understand quickly.', + showcaseTitle: 'Reusable Architecture Reviewer', + showcasePrompt: 'Review this API endpoint for authorization gaps, unsafe schemas, and missing regression tests. Return findings by severity.', + showcaseMeta: 'Category: production ready · Owner: Luis Felipe Ponce Navarro', + apiDocs: 'Local API docs', + }, + auth: { + loginTitle: 'Login', + loginDescription: 'Use your API account credentials.', + registered: 'Account created with the default user role. Sign in with your new credentials.', + username: 'Username', + password: 'Password', + signingIn: 'Signing in...', + forgotPassword: 'Forgot password?', + needAccount: 'Need an account? Create one', + registerTitle: 'Create account', + registerDescription: 'Start building a catalog of prompts you can trust and reuse.', + firstName: 'First name', + lastName: 'Last name', + email: 'Email', + creating: 'Creating...', + alreadyHaveAccount: 'Already have an account?', + invalidCredentials: 'Invalid credentials', + unableToLogin: 'Unable to login', + checkRegistration: 'Check your registration details', + unableToCreate: 'Unable to create account', + usernameTaken: 'Username is already taken', + }, + prompts: { + eyebrow: 'Catalog', + title: 'Your prompt library', + description: 'Save the prompts that worked, then find and reuse them faster.', + edit: 'Edit prompt', + create: 'Create prompt', + listTitle: 'Your prompts', + dropdownError: 'Unable to load dropdown options', + deleteTitle: 'Delete prompt?', + deleteDescription: 'This removes the prompt from your catalog. This action cannot be undone.', + deleteConfirm: 'Delete prompt', + emptyTitle: 'No prompts yet', + emptyDescription: 'Save your first proven prompt to start building your reusable catalog.', + viewDetails: 'View prompt details', + errors: { + sessionUnavailable: 'Session is not available', + selectedUnavailable: 'Session or selected prompt is not available', + create: 'Unable to create prompt', + update: 'Unable to update prompt', + delete: 'Unable to delete prompt', + }, + }, +} as const diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts new file mode 100644 index 0000000..d433f59 --- /dev/null +++ b/frontend/src/i18n/locales/es.ts @@ -0,0 +1,86 @@ +export const es = { + app: { + name: 'Catálogo de Prompts', + tagline: 'Biblioteca personal para prompts que funcionan', + }, + language: { + label: 'Seleccionar idioma', + spanish: 'Español', + english: 'English', + }, + nav: { + catalog: 'Catálogo', + admin: 'Admin', + signIn: 'Iniciar sesión', + createAccount: 'Crear cuenta', + logout: 'Cerrar sesión', + main: 'Principal', + }, + theme: { + dark: 'Oscuro', + light: 'Claro', + }, + common: { + loading: 'Cargando...', + rating: 'Calificación {{value}}/5', + }, + landing: { + badge: 'Tu portafolio de Prompt ordenados para tu conveniencia', + title: 'Construye tu catálogo personal de prompts que realmente funcionan.', + copy: 'Guarda, clasifica, califica y reutiliza prompts de IA con una experiencia enfocada respaldada por FastAPI, autenticación JWT, MariaDB, Redis y React Query.', + features: [ + 'Busca prompts por contenido, modelo, categoría y calificación', + 'Reutiliza prompts probados con acciones para copiar, editar, duplicar y eliminar', + 'RBAC listo para administración con monitoreo de prompts y analítica futura', + ], + featureDescription: 'Diseñado para convertir un CRUD básico en un producto que reclutadores y usuarios entienden rápido.', + showcaseTitle: 'Revisor de arquitectura reutilizable', + showcasePrompt: 'Revisa este endpoint de API para detectar brechas de autorización, esquemas inseguros y pruebas de regresión faltantes. Devuelve hallazgos por severidad.', + showcaseMeta: 'Categoría: Produccion · Propietario: Luis Felipe Ponce Navarro', + apiDocs: 'Docs locales de la API', + }, + auth: { + loginTitle: 'Iniciar sesión', + loginDescription: 'Usa las credenciales de tu cuenta de API.', + registered: 'Cuenta creada con el rol de usuario predeterminado. Inicia sesión con tus nuevas credenciales.', + username: 'Usuario', + password: 'Contraseña', + signingIn: 'Iniciando sesión...', + forgotPassword: '¿Olvidaste tu contraseña?', + needAccount: '¿Necesitas una cuenta? Crea una', + registerTitle: 'Crear cuenta', + registerDescription: 'Empieza a construir un catálogo de prompts en los que puedes confiar y reutilizar.', + firstName: 'Nombre', + lastName: 'Apellido', + email: 'Correo electrónico', + creating: 'Creando...', + alreadyHaveAccount: '¿Ya tienes una cuenta?', + invalidCredentials: 'Credenciales inválidas', + unableToLogin: 'No se pudo iniciar sesión', + checkRegistration: 'Revisa los datos de registro', + unableToCreate: 'No se pudo crear la cuenta', + usernameTaken: 'El usuario ya está en uso', + }, + prompts: { + eyebrow: 'Catálogo', + title: 'Tu biblioteca de prompts', + description: 'Guarda los prompts que funcionaron, luego encuéntralos y reutilízalos más rápido.', + edit: 'Editar prompt', + create: 'Crear prompt', + listTitle: 'Tus prompts', + dropdownError: 'No se pudieron cargar las opciones desplegables', + deleteTitle: '¿Eliminar prompt?', + deleteDescription: 'Esto elimina el prompt de tu catálogo. Esta acción no se puede deshacer.', + deleteConfirm: 'Eliminar prompt', + emptyTitle: 'Aún no hay prompts', + emptyDescription: 'Guarda tu primer prompt probado para empezar a construir tu catálogo reutilizable.', + viewDetails: 'Ver detalles del prompt', + errors: { + sessionUnavailable: 'La sesión no está disponible', + selectedUnavailable: 'La sesión o el prompt seleccionado no está disponible', + create: 'No se pudo crear el prompt', + update: 'No se pudo actualizar el prompt', + delete: 'No se pudo eliminar el prompt', + }, + }, +} as const diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3f9e9e4..f3c5ccc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import { RouterProvider } from 'react-router-dom' import { AppProviders } from './app/providers' import { router } from './app/router' +import './i18n' import './styles/tokens.css' import './styles/themes.css' import './styles/base.css' diff --git a/frontend/src/pages/landing/landing-page.tsx b/frontend/src/pages/landing/landing-page.tsx index bc598e7..e6f4151 100644 --- a/frontend/src/pages/landing/landing-page.tsx +++ b/frontend/src/pages/landing/landing-page.tsx @@ -1,52 +1,52 @@ +import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' +import { LanguageSwitcher } from '../../components/i18n/language-switcher' import { Badge } from '../../components/ui/badge' import { Card } from '../../components/ui/card' -const features = [ - 'Search prompts by content, model, category, and rating', - 'Reuse proven prompts with copy, edit, duplicate, and delete actions', - 'Admin-ready RBAC with prompt monitoring and future analytics', -] - export function LandingPage() { + const { t } = useTranslation() + const features = t('landing.features', { returnObjects: true }) as string[] + return (
- Portfolio-ready full-stack product -

Build your personal catalog of prompts that actually work.

+ {t('landing.badge')} +

{t('landing.title')}

- Save, classify, rate, and reuse AI prompts with a focused catalog experience backed by FastAPI, JWT auth, MariaDB, Redis, and React Query. + {t('landing.copy')}

- Create account - Sign in + {t('nav.createAccount')} + {t('nav.signIn')}
GPT-4 - Rating 5/5 + {t('common.rating', { value: 5 })}
-

Reusable Architecture Reviewer

+

{t('landing.showcaseTitle')}

- Review this API endpoint for authorization gaps, unsafe schemas, and missing regression tests. Return findings by severity. + {t('landing.showcasePrompt')}

-

Category: code-review · Owner: you

+

{t('landing.showcaseMeta')}

{features.map((feature) => (

{feature}

-

Designed to turn a basic CRUD into a product recruiters and users can understand quickly.

+

{t('landing.featureDescription')}

))}
@@ -54,7 +54,7 @@ export function LandingPage() { React + TypeScript + Vite FastAPI JWT APIs MariaDB + Redis - Local API docs + {t('landing.apiDocs')}
) diff --git a/frontend/src/pages/login/login-page.tsx b/frontend/src/pages/login/login-page.tsx index aaf982f..801274e 100644 --- a/frontend/src/pages/login/login-page.tsx +++ b/frontend/src/pages/login/login-page.tsx @@ -1,4 +1,5 @@ import { useState, type FormEvent } from 'react' +import { useTranslation } from 'react-i18next' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { Card } from '../../components/ui/card' import { InlineError } from '../../components/ui/inline-error' @@ -8,6 +9,7 @@ import { useAuth } from '../../features/auth/auth-store' import { loginSchema } from '../../lib/validation/auth-schemas' export function LoginPage() { + const { t } = useTranslation() const navigate = useNavigate() const [searchParams] = useSearchParams() const { login } = useAuth() @@ -21,7 +23,7 @@ export function LoginPage() { const parsed = loginSchema.safeParse({ username, password }) if (!parsed.success) { - setError(parsed.error.issues[0]?.message ?? 'Invalid credentials') + setError(parsed.error.issues[0]?.message ?? t('auth.invalidCredentials')) return } @@ -31,7 +33,7 @@ export function LoginPage() { await login(parsed.data.username, parsed.data.password) navigate('/app/prompts', { replace: true }) } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to login') + setError(err instanceof Error ? err.message : t('auth.unableToLogin')) } finally { setLoading(false) } @@ -40,20 +42,20 @@ export function LoginPage() { return (
-

Login

-

Use your API account credentials.

+

{t('auth.loginTitle')}

+

{t('auth.loginDescription')}

{searchParams.get('registered') ? ( -
Account created with the default user role. Sign in with your new credentials.
+
{t('auth.registered')}
) : null} setUsername(event.target.value)} autoComplete="username" /> setPassword(event.target.value)} @@ -61,13 +63,13 @@ export function LoginPage() { /> {error ? : null} - Forgot password? + {t('auth.forgotPassword')} - Need an account? Create one + {t('auth.needAccount')}
diff --git a/frontend/src/pages/prompts/prompts-page.tsx b/frontend/src/pages/prompts/prompts-page.tsx index 853521f..648d6b4 100644 --- a/frontend/src/pages/prompts/prompts-page.tsx +++ b/frontend/src/pages/prompts/prompts-page.tsx @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { Card } from '../../components/ui/card' import { ConfirmDialog } from '../../components/ui/confirm-dialog' import { InlineError } from '../../components/ui/inline-error' @@ -23,6 +24,7 @@ import type { } from '../../features/prompts/prompts-types' export function PromptsPage() { + const { t } = useTranslation() const { session } = useAuth() const queryClient = useQueryClient() const [editingPrompt, setEditingPrompt] = useState(null) @@ -68,7 +70,7 @@ export function PromptsPage() { const createMutation = useMutation({ mutationFn: async (value: PromptInput) => { if (!token) { - throw new Error('Session is not available') + throw new Error(t('prompts.errors.sessionUnavailable')) } return createPrompt(token, userId, value) }, @@ -77,14 +79,14 @@ export function PromptsPage() { setError(null) }, onError: (err) => { - setError(err instanceof Error ? err.message : 'Unable to create prompt') + setError(err instanceof Error ? err.message : t('prompts.errors.create')) }, }) const updateMutation = useMutation({ mutationFn: async (value: PromptInput) => { if (!token || userId === null || !editingPrompt) { - throw new Error('Session or selected prompt is not available') + throw new Error(t('prompts.errors.selectedUnavailable')) } return updatePrompt(token, editingPrompt.id, userId, value) }, @@ -94,14 +96,14 @@ export function PromptsPage() { setError(null) }, onError: (err) => { - setError(err instanceof Error ? err.message : 'Unable to update prompt') + setError(err instanceof Error ? err.message : t('prompts.errors.update')) }, }) const deleteMutation = useMutation({ mutationFn: async (prompt: PromptRecord) => { if (!token) { - throw new Error('Session is not available') + throw new Error(t('prompts.errors.sessionUnavailable')) } return deletePrompt(token, prompt.id) }, @@ -110,7 +112,7 @@ export function PromptsPage() { setError(null) }, onError: (err) => { - setError(err instanceof Error ? err.message : 'Unable to delete prompt') + setError(err instanceof Error ? err.message : t('prompts.errors.delete')) }, }) @@ -133,12 +135,12 @@ export function PromptsPage() { return (
-

{editingPrompt ? 'Edit prompt' : 'Create prompt'}

+

{editingPrompt ? t('prompts.edit') : t('prompts.create')}

setEditingPrompt(null)} /> {categoriesQuery.error || modelsQuery.error ? ( - + ) : null}
-

Your prompts

- {promptsQuery.isLoading ?

Loading...

: null} +

{t('prompts.listTitle')}

+ {promptsQuery.isLoading ?

{t('common.loading')}

: null} {error ? : null} {promptsQuery.data ? ( setPromptToDelete(null)} onConfirm={handleDelete} diff --git a/frontend/src/pages/register/register-page.tsx b/frontend/src/pages/register/register-page.tsx index 46ae9ad..df3170b 100644 --- a/frontend/src/pages/register/register-page.tsx +++ b/frontend/src/pages/register/register-page.tsx @@ -1,4 +1,5 @@ import { useState, type FormEvent } from 'react' +import { useTranslation } from 'react-i18next' import { Link, useNavigate } from 'react-router-dom' import { Button } from '../../components/ui/button' import { Card } from '../../components/ui/card' @@ -9,6 +10,7 @@ import { ApiError } from '../../lib/http/api-error' import { registerSchema } from '../../lib/validation/auth-schemas' export function RegisterPage() { + const { t } = useTranslation() const navigate = useNavigate() const [form, setForm] = useState({ name: '', last_name: '', email: '', username: '', password: '' }) const [error, setError] = useState(null) @@ -22,7 +24,7 @@ export function RegisterPage() { event.preventDefault() const parsed = registerSchema.safeParse(form) if (!parsed.success) { - setError(parsed.error.issues[0]?.message ?? 'Check your registration details') + setError(parsed.error.issues[0]?.message ?? t('auth.checkRegistration')) return } @@ -32,8 +34,8 @@ export function RegisterPage() { await signup(parsed.data) navigate('/login?registered=1', { replace: true }) } catch (err) { - const message = err instanceof Error ? err.message : 'Unable to create account' - setError(err instanceof ApiError && err.status === 400 ? 'Username is already taken' : message) + const message = err instanceof Error ? err.message : t('auth.unableToCreate') + setError(err instanceof ApiError && err.status === 400 ? t('auth.usernameTaken') : message) } finally { setLoading(false) } @@ -42,17 +44,17 @@ export function RegisterPage() { return (
-

Create account

-

Start building a catalog of prompts you can trust and reuse.

+

{t('auth.registerTitle')}

+

{t('auth.registerDescription')}

- updateField('name', event.target.value)} autoComplete="given-name" /> - updateField('last_name', event.target.value)} autoComplete="family-name" /> - updateField('email', event.target.value)} autoComplete="email" /> - updateField('username', event.target.value)} autoComplete="username" /> - updateField('password', event.target.value)} autoComplete="new-password" /> + updateField('name', event.target.value)} autoComplete="given-name" /> + updateField('last_name', event.target.value)} autoComplete="family-name" /> + updateField('email', event.target.value)} autoComplete="email" /> + updateField('username', event.target.value)} autoComplete="username" /> + updateField('password', event.target.value)} autoComplete="new-password" /> {error ? : null} - - Already have an account? + + {t('auth.alreadyHaveAccount')}
diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 4b6b452..bbb504e 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -220,6 +220,16 @@ h2 { outline-offset: 1px; } +.language-switcher { + display: inline-flex; +} + +.language-select { + min-width: 7.5rem; + padding-block: 0.45rem; + font-size: var(--font-sm); +} + .field-combobox { position: relative; } From 398c8f0e6121226c77e055328876c1a1e3b9e9fe Mon Sep 17 00:00:00 2001 From: luisfponce Date: Tue, 14 Jul 2026 14:21:23 -0600 Subject: [PATCH 2/2] feat(frontend): refresh landing and catalog UI --- frontend/src/components/layout/app-shell.tsx | 19 +- .../prompts/prompt-detail-dialog.tsx | 33 +- .../src/components/prompts/prompt-form.tsx | 25 +- .../src/components/prompts/prompt-list.tsx | 5 +- frontend/src/components/ui/confirm-dialog.tsx | 5 +- frontend/src/components/ui/textarea.tsx | 20 + frontend/src/components/ui/theme-toggle.tsx | 21 + frontend/src/i18n/locales/en.ts | 156 +++++- frontend/src/i18n/locales/es.ts | 156 +++++- frontend/src/main.tsx | 3 + .../src/pages/admin/admin-prompts-page.tsx | 66 ++- frontend/src/pages/landing/landing-page.tsx | 108 +++- frontend/src/pages/landing/landing-samples.ts | 53 ++ frontend/src/pages/login/login-page.tsx | 81 +-- frontend/src/pages/prompts/prompts-page.tsx | 36 +- frontend/src/pages/recovery/recovery-page.tsx | 106 ++-- frontend/src/pages/register/register-page.tsx | 45 +- frontend/src/styles/base.css | 511 +++++++++++++++--- frontend/src/styles/themes.css | 52 +- frontend/src/styles/tokens.css | 15 +- 20 files changed, 1208 insertions(+), 308 deletions(-) create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/components/ui/theme-toggle.tsx create mode 100644 frontend/src/pages/landing/landing-samples.ts diff --git a/frontend/src/components/layout/app-shell.tsx b/frontend/src/components/layout/app-shell.tsx index e599e72..7fbb551 100644 --- a/frontend/src/components/layout/app-shell.tsx +++ b/frontend/src/components/layout/app-shell.tsx @@ -1,11 +1,11 @@ -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 @@ -14,17 +14,6 @@ type AppShellProps = { 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 (
@@ -44,9 +33,7 @@ export function AppShell({ children }: AppShellProps) { ) : 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} />