From 925be9cb5267ed3447668b7d1d722d2cdff505c9 Mon Sep 17 00:00:00 2001 From: legrab Date: Sat, 18 Jul 2026 20:27:33 +0200 Subject: [PATCH] chore(i18n): provide localization for english and hungarian --- apps/web/src/App.tsx | 16 +-- apps/web/src/components/ConnectionStatus.tsx | 21 ++- apps/web/src/components/ErrorBoundary.tsx | 6 +- apps/web/src/components/Shell.tsx | 68 ++++----- .../src/components/UnsavedChangesGuard.tsx | 13 +- apps/web/src/i18n/core.ts | 26 ++++ apps/web/src/i18n/en.json | 130 ++++++++++++++++++ apps/web/src/i18n/hu.json | 130 ++++++++++++++++++ apps/web/src/i18n/index.tsx | 76 ++++++++++ apps/web/src/main.tsx | 9 +- apps/web/src/pages/AdminPage.tsx | 54 +++++--- apps/web/src/pages/EntityListPage.tsx | 72 +++++----- apps/web/src/pages/LoginPage.tsx | 29 ++-- apps/web/src/pages/NotFoundPage.tsx | 9 +- apps/web/src/pages/TimetablePage.tsx | 11 +- apps/web/src/pages/TodayPage.tsx | 74 +++++----- tests/unit/i18n.test.ts | 23 ++++ 17 files changed, 606 insertions(+), 161 deletions(-) create mode 100644 apps/web/src/i18n/core.ts create mode 100644 apps/web/src/i18n/en.json create mode 100644 apps/web/src/i18n/hu.json create mode 100644 apps/web/src/i18n/index.tsx create mode 100644 tests/unit/i18n.test.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d1dba17..1f3cc8f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -27,14 +27,17 @@ import { OrganizationEditorPage } from "./pages/OrganizationEditorPage"; import { SetupPage } from "./pages/SetupPage"; import { AnnualPlanEditorPage } from "./pages/AnnualPlanEditorPage"; import { NotFoundPage } from "./pages/NotFoundPage"; +import { useI18n } from "./i18n"; function Protected() { const location = useLocation(); + const { t } = useI18n(); const me = useQuery({ queryKey: ["me"], queryFn: () => api("/api/auth/me"), retry: false, }); - if (me.isLoading) return
Fonat betöltése…
; + if (me.isLoading) + return
{t("app.loading")}
; if (me.error) { if (!(me.error instanceof ApiError)) throw me.error; const authenticationFailed = [401, 403].includes(me.error.status); @@ -42,14 +45,11 @@ function Protected() { return ; return (
- Kapcsolódás szünetel -

A Fonat most nem éri el a szervert

-

- A munkamenetedet nem tekintjük lejártnak hálózati vagy szerverhiba - miatt. Ellenőrizd a kapcsolatot, majd próbáld újra. -

+ {t("app.connectionEyebrow")} +

{t("app.connectionTitle")}

+

{t("app.connectionBody")}

); diff --git a/apps/web/src/components/ConnectionStatus.tsx b/apps/web/src/components/ConnectionStatus.tsx index 8066c62..de548b5 100644 --- a/apps/web/src/components/ConnectionStatus.tsx +++ b/apps/web/src/components/ConnectionStatus.tsx @@ -1,4 +1,5 @@ import { useSyncExternalStore } from "react"; +import { useI18n } from "../i18n"; function subscribeToConnectionStatus(onStoreChange: () => void) { if (typeof window === "undefined") return () => undefined; @@ -24,15 +25,13 @@ export function useOnlineStatus() { export function ConnectionStatus() { const online = useOnlineStatus(); + const { t } = useI18n(); if (online) return null; return (
- Nincs hálózati kapcsolat. - - A nem mentett változtatások a szerkesztőben maradnak. Mentéshez várd - meg, amíg helyreáll a kapcsolat. - + {t("connection.offlineTitle")} + {t("connection.offlineBody")}
); } @@ -49,17 +48,15 @@ export function EditorSaveStatus({ saved, }: EditorSaveStatusProps) { const online = useOnlineStatus(); + const { t } = useI18n(); const status = pending - ? { label: "Mentés folyamatban…", tone: "pending" } + ? { label: t("save.pending"), tone: "pending" } : dirty && !online - ? { - label: "Nincs kapcsolat — a módosítások még nincsenek mentve.", - tone: "warning", - } + ? { label: t("save.offlineDirty"), tone: "warning" } : dirty - ? { label: "Nem mentett módosítások", tone: "dirty" } + ? { label: t("save.dirty"), tone: "dirty" } : saved - ? { label: "Minden változtatás mentve", tone: "saved" } + ? { label: t("save.saved"), tone: "saved" } : null; if (!status) return null; diff --git a/apps/web/src/components/ErrorBoundary.tsx b/apps/web/src/components/ErrorBoundary.tsx index 05c7827..76f4811 100644 --- a/apps/web/src/components/ErrorBoundary.tsx +++ b/apps/web/src/components/ErrorBoundary.tsx @@ -1,4 +1,6 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; +import { translateCurrent } from "../i18n"; + export class ErrorBoundary extends Component< { children: ReactNode }, { error?: Error } @@ -14,10 +16,10 @@ export class ErrorBoundary extends Component< if (this.state.error) return (
-

Az oldal nem tölthető be

+

{translateCurrent("error.title")}

{this.state.error.message}

- Vissza a Today oldalra + {translateCurrent("error.back")}
); diff --git a/apps/web/src/components/Shell.tsx b/apps/web/src/components/Shell.tsx index 75643d0..2f38946 100644 --- a/apps/web/src/components/Shell.tsx +++ b/apps/web/src/components/Shell.tsx @@ -7,49 +7,55 @@ import { } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { post } from "../api"; +import { useI18n, type TranslationKey } from "../i18n"; import { ConnectionStatus } from "./ConnectionStatus"; import { Logo } from "./Logo"; + const nav = [ { - label: "Napi munka", + label: "shell.dailyWork", items: [ - ["/", "Ma"], - ["/timetable", "Órarend"], - ["/lessons", "Óratervek"], - ["/assignments", "Kiosztások"], - ["/assessments", "Felmérések"], - ["/insights", "Elemzések"], + ["/", "shell.today"], + ["/timetable", "shell.timetable"], + ["/lessons", "shell.lessons"], + ["/assignments", "shell.assignments"], + ["/assessments", "shell.assessments"], + ["/insights", "shell.insights"], ], }, { - label: "Tartalom", + label: "shell.content", items: [ - ["/library", "Könyvtár"], - ["/exercises", "Gyakorlófeladatok"], - ["/annual-plans", "Éves tervek"], - ["/projects", "Projektek"], + ["/library", "shell.library"], + ["/exercises", "shell.exercises"], + ["/annual-plans", "shell.annualPlans"], + ["/projects", "shell.projects"], ], }, { - label: "Munkatér", + label: "shell.workspace", items: [ - ["/courses", "Kurzusok"], - ["/groups", "Csoportok"], - ["/learners", "Tanulók"], - ["/locations", "Helyszínek"], - ["/admin", "Admin"], - ["/guide", "Útmutató"], + ["/courses", "shell.courses"], + ["/groups", "shell.groups"], + ["/learners", "shell.learners"], + ["/locations", "shell.locations"], + ["/admin", "shell.admin"], + ["/guide", "shell.guide"], ], }, -] satisfies Array<{ label: string; items: Array<[string, string]> }>; +] satisfies Array<{ + label: TranslationKey; + items: Array<[string, TranslationKey]>; +}>; -const routeLabels: Record = Object.fromEntries( - nav.flatMap((group) => group.items), -); export function Shell() { const location = useLocation(); const navigate = useNavigate(); const qc = useQueryClient(); + const { t } = useI18n(); + const routeLabels = Object.fromEntries( + nav.flatMap((group) => group.items.map(([route, key]) => [route, t(key)])), + ); const logout = async () => { await post("/api/auth/logout", {}); qc.clear(); @@ -64,7 +70,7 @@ export function Shell() {
- Tanulói csatlakozás + {t("shell.studentJoin")}
- Aktuális hely + {t("shell.currentLocation")} {location.pathname === "/" - ? "Ma" + ? t("shell.today") : routeLabels[ Object.keys(routeLabels).find( (route) => route !== "/" && location.pathname.startsWith(route), ) || "" - ] || "Szerkesztés"} + ] || t("shell.editing")}
- Bemutató indítása + {t("shell.startPresentation")}
diff --git a/apps/web/src/components/UnsavedChangesGuard.tsx b/apps/web/src/components/UnsavedChangesGuard.tsx index 4eef42b..cb5ac75 100644 --- a/apps/web/src/components/UnsavedChangesGuard.tsx +++ b/apps/web/src/components/UnsavedChangesGuard.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useBeforeUnload, useBlocker } from "react-router-dom"; +import { useI18n } from "../i18n"; export function useUnsavedChanges(when: boolean) { + const { t } = useI18n(); const allowNavigationRef = useRef(false); const stayButtonRef = useRef(null); const [pendingDiscard, setPendingDiscard] = useState<(() => void) | null>( @@ -77,17 +79,14 @@ export function useUnsavedChanges(when: boolean) { aria-labelledby="unsaved-dialog-title" aria-describedby="unsaved-dialog-description" > -

Nem mentett módosítások

-

- Ha elhagyod ezt az oldalt, a még el nem mentett változtatások - elvesznek. -

+

{t("unsaved.title")}

+

{t("unsaved.body")}

diff --git a/apps/web/src/i18n/core.ts b/apps/web/src/i18n/core.ts new file mode 100644 index 0000000..12df111 --- /dev/null +++ b/apps/web/src/i18n/core.ts @@ -0,0 +1,26 @@ +import hu from "./hu.json"; +import en from "./en.json"; + +export const supportedLocales = ["hu", "en"] as const; +export type Locale = (typeof supportedLocales)[number]; +export type TranslationKey = keyof typeof hu; +export type TranslationParams = Record; + +const catalogs: Record> = { hu, en }; + +export function isLocale(value: unknown): value is Locale { + return supportedLocales.includes(value as Locale); +} + +export function translate( + locale: Locale, + key: TranslationKey, + params: TranslationParams = {}, +): string { + const template = catalogs[locale][key] ?? catalogs.hu[key] ?? key; + return Object.entries(params).reduce( + (value, [name, replacement]) => + value.replaceAll(`{${name}}`, String(replacement)), + template, + ); +} diff --git a/apps/web/src/i18n/en.json b/apps/web/src/i18n/en.json new file mode 100644 index 0000000..df3e9fe --- /dev/null +++ b/apps/web/src/i18n/en.json @@ -0,0 +1,130 @@ +{ + "app.loading": "Loading Fonat…", + "app.connectionEyebrow": "Connection interrupted", + "app.connectionTitle": "Fonat cannot reach the server", + "app.connectionBody": "A network or server failure does not automatically mean your session has expired. Check the connection, then try again.", + "app.reconnecting": "Reconnecting…", + "app.reconnect": "Reconnect", + "shell.dailyWork": "Daily work", + "shell.content": "Content", + "shell.workspace": "Workspace", + "shell.today": "Today", + "shell.timetable": "Timetable", + "shell.lessons": "Lesson plans", + "shell.assignments": "Assignments", + "shell.assessments": "Assessments", + "shell.insights": "Insights", + "shell.library": "Library", + "shell.exercises": "Exercises", + "shell.annualPlans": "Annual plans", + "shell.projects": "Projects", + "shell.courses": "Courses", + "shell.groups": "Groups", + "shell.learners": "Learners", + "shell.locations": "Locations", + "shell.admin": "Admin", + "shell.guide": "Guide", + "shell.studentJoin": "Learner join", + "shell.logout": "Sign out", + "shell.currentLocation": "Current location", + "shell.editing": "Editing", + "shell.startPresentation": "Start presentation", + "connection.offlineTitle": "No network connection.", + "connection.offlineBody": "Unsaved changes remain in the editor. Wait for the connection to recover before saving.", + "save.pending": "Saving…", + "save.offlineDirty": "No connection: changes have not been saved yet.", + "save.dirty": "Unsaved changes", + "save.saved": "All changes saved", + "unsaved.title": "Unsaved changes", + "unsaved.body": "Leaving this page will discard changes that have not been saved.", + "unsaved.stay": "Stay and save", + "unsaved.discard": "Discard changes", + "error.title": "The page could not be loaded", + "error.back": "Back to Today", + "login.eyebrow": "Teaching workspace", + "login.bootstrapTitle": "Create the first admin", + "login.title": "Sign in to the workspace", + "login.tagline": "Every thread of teaching in one place.", + "login.name": "Name", + "login.email": "Email", + "login.password": "Password", + "login.createWorkspace": "Create workspace", + "login.signIn": "Sign in", + "login.failure": "Sign-in failed", + "login.demo": "Demo: admin@fonat.local / fonat-demo", + "login.defaultDisplayName": "First teacher", + "notFound.title": "This thread has broken.", + "notFound.body": "The route does not exist, but you have not been signed out of the workspace.", + "notFound.back": "Back to Today", + "admin.eyebrow": "Workspace administration", + "admin.title": "Admin", + "admin.users": "Users", + "admin.disabled": "disabled", + "admin.active": "active", + "admin.createTeacher": "Create teacher", + "admin.systemStatus": "System status", + "admin.projectFeature": "Projects feature", + "admin.interfaceLanguage": "Interface language", + "admin.languageHelp": "This preference is stored in this browser. It does not change the language of teacher or learner content.", + "admin.resetData": "Reset data", + "admin.resetBody": "Teacher accounts and the active session are preserved. Workspace content is rebuilt.", + "admin.blankWorkspace": "Blank workspace", + "admin.reloadDemo": "Reload demo", + "admin.jsonExport": "JSON export", + "admin.newTeacher": "New teacher", + "language.hu": "Magyar", + "language.en": "English", + "today.loading": "Loading…", + "today.title": "Today, the threads meet.", + "today.body": "Lessons, assignments, evidence, and next steps in one calm workspace.", + "today.newLesson": "New lesson plan", + "today.finishSetup": "Finish setup", + "today.quickStart": "Quick start", + "today.gettingStarted": "Getting started", + "today.content": "Content", + "today.newConcept": "New concept", + "today.conceptHelp": "Definition and relationships", + "today.practice": "Practice", + "today.newExercise": "New exercise", + "today.exerciseHelp": "Six supported exercise types", + "today.planning": "Planning", + "today.lessonHelp": "Material, exercise, and live checking", + "today.nextLessons": "Upcoming lessons", + "today.all": "View all", + "today.noDate": "No date", + "today.open": "Open", + "today.inProgress": "In progress", + "today.pausedLesson": "Paused lesson", + "today.activeLesson": "Active lesson", + "today.continue": "Continue", + "today.noActiveRun": "No active lesson run.", + "today.findings": "Findings", + "today.defaultFinding": "finding", + "today.assigned": "Assigned work", + "today.manage": "Manage", + "today.deadline": "Deadline: {value}", + "today.none": "none", + "timetable.eyebrow": "Weekly view", + "timetable.title": "Timetable", + "timetable.overlap": "Overlap", + "entity.nodes": "Library", + "entity.lessons": "Lesson plans", + "entity.courses": "Courses", + "entity.learnerGroups": "Learner groups", + "entity.learners": "Learners", + "entity.locations": "Locations", + "entity.annualPlans": "Annual plans", + "entity.exercises": "Exercises", + "entity.statusDraft": "Draft", + "entity.statusPublished": "Published", + "entity.statusArchived": "Archived", + "entity.statusActive": "Active", + "entity.name": "Name", + "entity.status": "Status", + "entity.action": "Action", + "entity.open": "Open", + "entity.eyebrow": "Editable collection", + "entity.new": "New item", + "entity.search": "Search", + "entity.results": "{count} results" +} diff --git a/apps/web/src/i18n/hu.json b/apps/web/src/i18n/hu.json new file mode 100644 index 0000000..e862031 --- /dev/null +++ b/apps/web/src/i18n/hu.json @@ -0,0 +1,130 @@ +{ + "app.loading": "Fonat betöltése…", + "app.connectionEyebrow": "Kapcsolódás szünetel", + "app.connectionTitle": "A Fonat most nem éri el a szervert", + "app.connectionBody": "A munkamenetedet nem tekintjük lejártnak hálózati vagy szerverhiba miatt. Ellenőrizd a kapcsolatot, majd próbáld újra.", + "app.reconnecting": "Újracsatlakozás…", + "app.reconnect": "Újracsatlakozás", + "shell.dailyWork": "Napi munka", + "shell.content": "Tartalom", + "shell.workspace": "Munkatér", + "shell.today": "Ma", + "shell.timetable": "Órarend", + "shell.lessons": "Óratervek", + "shell.assignments": "Kiosztások", + "shell.assessments": "Felmérések", + "shell.insights": "Elemzések", + "shell.library": "Könyvtár", + "shell.exercises": "Gyakorlófeladatok", + "shell.annualPlans": "Éves tervek", + "shell.projects": "Projektek", + "shell.courses": "Kurzusok", + "shell.groups": "Csoportok", + "shell.learners": "Tanulók", + "shell.locations": "Helyszínek", + "shell.admin": "Admin", + "shell.guide": "Útmutató", + "shell.studentJoin": "Tanulói csatlakozás", + "shell.logout": "Kilépés", + "shell.currentLocation": "Aktuális hely", + "shell.editing": "Szerkesztés", + "shell.startPresentation": "Bemutató indítása", + "connection.offlineTitle": "Nincs hálózati kapcsolat.", + "connection.offlineBody": "A nem mentett változtatások a szerkesztőben maradnak. Mentéshez várd meg, amíg helyreáll a kapcsolat.", + "save.pending": "Mentés folyamatban…", + "save.offlineDirty": "Nincs kapcsolat: a módosítások még nincsenek mentve.", + "save.dirty": "Nem mentett módosítások", + "save.saved": "Minden változtatás mentve", + "unsaved.title": "Nem mentett módosítások", + "unsaved.body": "Ha elhagyod ezt az oldalt, a még el nem mentett változtatások elvesznek.", + "unsaved.stay": "Maradok és mentek", + "unsaved.discard": "Módosítások elvetése", + "error.title": "Az oldal nem tölthető be", + "error.back": "Vissza a Ma oldalra", + "login.eyebrow": "Oktatási műhely", + "login.bootstrapTitle": "Első admin létrehozása", + "login.title": "Belépés a munkatérbe", + "login.tagline": "Az oktatás minden szála egy helyen.", + "login.name": "Név", + "login.email": "E-mail", + "login.password": "Jelszó", + "login.createWorkspace": "Munkatér létrehozása", + "login.signIn": "Belépés", + "login.failure": "Sikertelen belépés", + "login.demo": "Demo: admin@fonat.local / fonat-demo", + "login.defaultDisplayName": "Első tanár", + "notFound.title": "Ez a szál megszakadt.", + "notFound.body": "Az útvonal nem található, de a munkatérből nem záródtál ki.", + "notFound.back": "Vissza a Ma oldalra", + "admin.eyebrow": "Munkatér-felügyelet", + "admin.title": "Admin", + "admin.users": "Felhasználók", + "admin.disabled": "letiltva", + "admin.active": "aktív", + "admin.createTeacher": "Tanár létrehozása", + "admin.systemStatus": "Rendszerállapot", + "admin.projectFeature": "Projekt képesség", + "admin.interfaceLanguage": "Felület nyelve", + "admin.languageHelp": "A beállítás ezen a böngészőn marad meg. A tanári és tanulói tartalmak nyelve nem változik.", + "admin.resetData": "Adatok visszaállítása", + "admin.resetBody": "A tanári fiókok és az aktív munkamenet megmaradnak. A munkatér tartalma újraépül.", + "admin.blankWorkspace": "Üres munkatér", + "admin.reloadDemo": "Demo újratöltése", + "admin.jsonExport": "JSON export", + "admin.newTeacher": "Új tanár", + "language.hu": "Magyar", + "language.en": "English", + "today.loading": "Betöltés…", + "today.title": "Ma a szálak összeérnek.", + "today.body": "Órák, beadandók, bizonyítékok és következő lépések egy nyugodt munkafelületen.", + "today.newLesson": "Új óraterv", + "today.finishSetup": "Beállítás befejezése", + "today.quickStart": "Gyors kezdés", + "today.gettingStarted": "Első lépések", + "today.content": "Tartalom", + "today.newConcept": "Új fogalom", + "today.conceptHelp": "Definíció és kapcsolatok", + "today.practice": "Gyakorlás", + "today.newExercise": "Új gyakorlófeladat", + "today.exerciseHelp": "Hat támogatott feladattípus", + "today.planning": "Tervezés", + "today.lessonHelp": "Tananyag, feladat és élő ellenőrzés", + "today.nextLessons": "Következő órák", + "today.all": "Összes", + "today.noDate": "Nincs dátum", + "today.open": "Megnyitás", + "today.inProgress": "Folyamatban", + "today.pausedLesson": "Szüneteltetett óra", + "today.activeLesson": "Aktív óra", + "today.continue": "Folytatás", + "today.noActiveRun": "Nincs aktív órafuttatás.", + "today.findings": "Jelzések", + "today.defaultFinding": "jelzés", + "today.assigned": "Kiosztott feladatok", + "today.manage": "Kezelés", + "today.deadline": "Határidő: {value}", + "today.none": "nincs", + "timetable.eyebrow": "Heti nézet", + "timetable.title": "Órarend", + "timetable.overlap": "Átfedés", + "entity.nodes": "Könyvtár", + "entity.lessons": "Óratervek", + "entity.courses": "Kurzusok", + "entity.learnerGroups": "Tanulócsoportok", + "entity.learners": "Tanulók", + "entity.locations": "Helyszínek", + "entity.annualPlans": "Éves tervek", + "entity.exercises": "Gyakorlófeladatok", + "entity.statusDraft": "Piszkozat", + "entity.statusPublished": "Közzétett", + "entity.statusArchived": "Archivált", + "entity.statusActive": "Aktív", + "entity.name": "Megnevezés", + "entity.status": "Állapot", + "entity.action": "Művelet", + "entity.open": "Megnyitás", + "entity.eyebrow": "Szerkeszthető gyűjtemény", + "entity.new": "Új elem", + "entity.search": "Keresés", + "entity.results": "{count} találat" +} diff --git a/apps/web/src/i18n/index.tsx b/apps/web/src/i18n/index.tsx new file mode 100644 index 0000000..8a51165 --- /dev/null +++ b/apps/web/src/i18n/index.tsx @@ -0,0 +1,76 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { + isLocale, + translate, + type Locale, + type TranslationKey, + type TranslationParams, +} from "./core"; + +export { + isLocale, + supportedLocales, + translate, + type Locale, + type TranslationKey, + type TranslationParams, +} from "./core"; + +const storageKey = "fonat.locale"; + +function initialLocale(): Locale { + if (typeof window === "undefined") return "hu"; + const stored = window.localStorage.getItem(storageKey); + if (isLocale(stored)) return stored; + return document.documentElement.lang.toLowerCase().startsWith("en") + ? "en" + : "hu"; +} + +type I18nValue = { + locale: Locale; + setLocale: (locale: Locale) => void; + t: (key: TranslationKey, params?: TranslationParams) => string; +}; + +const I18nContext = createContext(null); + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(initialLocale); + + useEffect(() => { + document.documentElement.lang = locale; + window.localStorage.setItem(storageKey, locale); + }, [locale]); + + const value = useMemo( + () => ({ + locale, + setLocale: setLocaleState, + t: (key, params) => translate(locale, key, params), + }), + [locale], + ); + + return {children}; +} + +export function useI18n() { + const value = useContext(I18nContext); + if (!value) throw new Error("useI18n must be used inside I18nProvider"); + return value; +} + +export function translateCurrent( + key: TranslationKey, + params?: TranslationParams, +) { + return translate(initialLocale(), key, params); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 889b47c..556c632 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -10,6 +10,7 @@ import "@milkdown/crepe/theme/common/style.css"; import "@milkdown/crepe/theme/frame.css"; import "./styles.css"; import { App } from "./App"; +import { I18nProvider } from "./i18n"; const client = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 2_000 } }, }); @@ -22,9 +23,11 @@ ReactDOM.createRoot(document.getElementById("root")!).render( grayColor="mauve" radius="large" > - - - + + + + + , ); diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index 889b837..1f2fced 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -1,8 +1,11 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, patch, post } from "../api"; +import { supportedLocales, useI18n } from "../i18n"; + export function AdminPage() { const qc = useQueryClient(); + const { locale, setLocale, t } = useI18n(); const users = useQuery({ queryKey: ["users"], queryFn: () => api("/api/admin/users"), @@ -20,7 +23,7 @@ export function AdminPage() { mutationFn: () => post("/api/admin/users", { email, - displayName: "Új tanár", + displayName: t("admin.newTeacher"), password: "temporary123", roles: ["teacher"], }), @@ -42,13 +45,13 @@ export function AdminPage() { <>
- Munkatér-felügyelet -

Admin

+ {t("admin.eyebrow")} +

{t("admin.title")}

-

Felhasználók

+

{t("admin.users")}

{users.data?.map((u) => (
@@ -57,16 +60,20 @@ export function AdminPage() { {u.email} · {u.roles.join(", ")}
- {u.disabled ? "letiltva" : "aktív"} + + {u.disabled ? t("admin.disabled") : t("admin.active")} +
))}
setEmail(e.target.value)} /> - +
-
-

Rendszerállapot

+
+

{t("admin.systemStatus")}

{JSON.stringify(health.data, null, 2)}
+ + {t("admin.languageHelp")}
-

Adatok visszaállítása

-

- A tanári fiókok és az aktív munkamenet megmaradnak. A munkatér - tartalma újraépül. -

+

{t("admin.resetData")}

+

{t("admin.resetBody")}

- JSON export + {t("admin.jsonExport")}
diff --git a/apps/web/src/pages/EntityListPage.tsx b/apps/web/src/pages/EntityListPage.tsx index 653bda3..105a332 100644 --- a/apps/web/src/pages/EntityListPage.tsx +++ b/apps/web/src/pages/EntityListPage.tsx @@ -8,21 +8,23 @@ import { } from "@tanstack/react-table"; import { Link } from "react-router-dom"; import { api } from "../api"; -const titles: Record = { - nodes: "Könyvtár", - lessons: "Óratervek", - courses: "Kurzusok", - "learner-groups": "Tanulócsoportok", - learners: "Tanulók", - locations: "Helyszínek", - "annual-plans": "Éves tervek", - exercises: "Gyakorlófeladatok", +import { useI18n, type TranslationKey } from "../i18n"; + +const titleKeys: Record = { + nodes: "entity.nodes", + lessons: "entity.lessons", + courses: "entity.courses", + "learner-groups": "entity.learnerGroups", + learners: "entity.learners", + locations: "entity.locations", + "annual-plans": "entity.annualPlans", + exercises: "entity.exercises", }; -const statusLabels: Record = { - draft: "Piszkozat", - published: "Közzétett", - archived: "Archivált", - active: "Aktív", +const statusKeys: Record = { + draft: "entity.statusDraft", + published: "entity.statusPublished", + archived: "entity.statusArchived", + active: "entity.statusActive", }; const detailPath = (route: string, id: string) => { @@ -38,6 +40,7 @@ const createPath = (route: string) => { }; export function EntityListPage({ route }: { route: string }) { + const { t } = useI18n(); const [search, setSearch] = useState(""); const q = useQuery({ queryKey: [route, search], @@ -51,7 +54,7 @@ export function EntityListPage({ route }: { route: string }) { () => [ helper.accessor((row) => row.title || row.name || row.id, { id: "title", - header: "Megnevezés", + header: t("entity.name"), cell: (i) => ( {i.getValue()} @@ -59,24 +62,29 @@ export function EntityListPage({ route }: { route: string }) { ), }), helper.accessor("lifecycle", { - header: "Állapot", - cell: (i) => ( - - {statusLabels[ - String(i.getValue() || i.row.original.status || "active") - ] || String(i.getValue() || i.row.original.status || "Aktív")} - - ), + header: t("entity.status"), + cell: (i) => { + const status = String( + i.getValue() || i.row.original.status || "active", + ); + return ( + + {statusKeys[status] ? t(statusKeys[status]) : status} + + ); + }, }), helper.display({ id: "actions", - header: "Művelet", + header: t("entity.action"), cell: (i) => ( - Megnyitás + + {t("entity.open")} + ), }), ], - [route], + [route, t], ); const table = useReactTable({ data: q.data || [], @@ -87,21 +95,23 @@ export function EntityListPage({ route }: { route: string }) { <>
- Szerkeszthető gyűjtemény -

{titles[route] || route}

+ {t("entity.eyebrow")} +

{titleKeys[route] ? t(titleKeys[route]) : route}

- Új elem + {t("entity.new")}
setSearch(e.target.value)} /> - {q.data?.length || 0} találat + + {t("entity.results", { count: q.data?.length || 0 })} +
diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index ba2efbb..b0c8af4 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -3,16 +3,21 @@ import { useNavigate } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, post } from "../api"; import { Logo } from "../components/Logo"; +import { useI18n } from "../i18n"; + export function LoginPage() { const nav = useNavigate(); const qc = useQueryClient(); + const { t } = useI18n(); const status = useQuery({ queryKey: ["auth-status"], queryFn: () => api("/api/auth/status"), }); const [email, setEmail] = useState("admin@fonat.local"); const [password, setPassword] = useState("fonat-demo"); - const [displayName, setDisplayName] = useState("Első tanár"); + const [displayName, setDisplayName] = useState(() => + t("login.defaultDisplayName"), + ); const [error, setError] = useState(""); const submit = async (e: React.FormEvent) => { e.preventDefault(); @@ -31,7 +36,7 @@ export function LoginPage() { await qc.invalidateQueries(); nav("/"); } catch (err) { - setError(err instanceof Error ? err.message : "Sikertelen belépés"); + setError(err instanceof Error ? err.message : t("login.failure")); } }; return ( @@ -39,18 +44,18 @@ export function LoginPage() {
- Oktatási műhely + {t("login.eyebrow")}

{status.data?.requiresBootstrap - ? "Első admin létrehozása" - : "Belépés a munkatérbe"} + ? t("login.bootstrapTitle") + : t("login.title")}

-

Az oktatás minden szála egy helyen.

+

{t("login.tagline")}

{status.data?.requiresBootstrap && ( )}
} - Demo: admin@fonat.local / fonat-demo + {t("login.demo")} ); diff --git a/apps/web/src/pages/NotFoundPage.tsx b/apps/web/src/pages/NotFoundPage.tsx index 990488b..c8c042b 100644 --- a/apps/web/src/pages/NotFoundPage.tsx +++ b/apps/web/src/pages/NotFoundPage.tsx @@ -1,12 +1,15 @@ import { Link } from "react-router-dom"; +import { useI18n } from "../i18n"; + export function NotFoundPage() { + const { t } = useI18n(); return (
404 -

Ez a szál megszakadt.

-

Az útvonal nem található, de a munkatérből nem záródtál ki.

+

{t("notFound.title")}

+

{t("notFound.body")}

- Vissza a Today oldalra + {t("notFound.back")}
); diff --git a/apps/web/src/pages/TimetablePage.tsx b/apps/web/src/pages/TimetablePage.tsx index e1a53f0..34c5c63 100644 --- a/apps/web/src/pages/TimetablePage.tsx +++ b/apps/web/src/pages/TimetablePage.tsx @@ -1,6 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { api } from "../api"; +import { useI18n } from "../i18n"; + export function TimetablePage() { + const { t } = useI18n(); const q = useQuery({ queryKey: ["timetable"], queryFn: () => api("/api/timetable"), @@ -13,8 +16,8 @@ export function TimetablePage() { <>
- Heti nézet -

Órarend

+ {t("timetable.eyebrow")} +

{t("timetable.title")}

{Object.entries(byDate).map(([date, items]) => ( @@ -34,7 +37,9 @@ export function TimetablePage() { {x.courseId} · {x.locationId} - {x.overlap && Átfedés} + {x.overlap && ( + {t("timetable.overlap")} + )} ))} diff --git a/apps/web/src/pages/TodayPage.tsx b/apps/web/src/pages/TodayPage.tsx index 902f2dd..da5ca5f 100644 --- a/apps/web/src/pages/TodayPage.tsx +++ b/apps/web/src/pages/TodayPage.tsx @@ -1,7 +1,10 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "react-router-dom"; import { api } from "../api"; +import { useI18n } from "../i18n"; + export function TodayPage() { + const { t } = useI18n(); const q = useQuery({ queryKey: ["today"], queryFn: () => api("/api/today"), @@ -11,108 +14,115 @@ export function TodayPage() { queryKey: ["onboarding-status"], queryFn: () => api("/api/onboarding/status"), }); - if (q.isLoading) return
Betöltés…
; + if (q.isLoading) return
{t("today.loading")}
; const d = q.data; return ( <>
{d?.date} -

Ma a szálak összeérnek.

-

- Órák, beadandók, bizonyítékok és következő lépések egy nyugodt - munkafelületen. -

+

{t("today.title")}

+

{t("today.body")}

{onboarding.data?.complete ? ( - Új óraterv + {t("today.newLesson")} ) : ( - Beállítás befejezése + {t("today.finishSetup")} )}
-

Gyors kezdés

- Első lépések +

{t("today.quickStart")}

+ {t("today.gettingStarted")}
- Tartalom - Új fogalom - Definíció és kapcsolatok + {t("today.content")} + {t("today.newConcept")} + {t("today.conceptHelp")} - Gyakorlás - Új gyakorlófeladat - Hat támogatott feladattípus + {t("today.practice")} + {t("today.newExercise")} + {t("today.exerciseHelp")} - Tervezés - Új óraterv - Tananyag, feladat és élő ellenőrzés + {t("today.planning")} + {t("today.newLesson")} + {t("today.lessonHelp")}
-

Következő órák

- Összes +

{t("today.nextLessons")}

+ {t("today.all")}
{d?.lessons.map((x: any) => (
{x.title} - {x.scheduledDate || "Nincs dátum"} + {x.scheduledDate || t("today.noDate")}
- Megnyitás + {t("today.open")}
))}
-

Folyamatban

+

{t("today.inProgress")}

{d?.activeRuns.length ? ( d.activeRuns.map((r: any) => (
- {r.state === "paused" ? "Szüneteltetett óra" : "Aktív óra"} + {r.state === "paused" + ? t("today.pausedLesson") + : t("today.activeLesson")} {r.lessonId}
- Folytatás + + {t("today.continue")} +
)) ) : ( -

Nincs aktív órafuttatás.

+

{t("today.noActiveRun")}

)}
-

Jelzések

+

{t("today.findings")}

{d?.findings.map((f: any) => (
{f.title} - {f.severity || "jelzés"} + + {f.severity || t("today.defaultFinding")} +
))}
-

Kiosztott feladatok

- Kezelés +

{t("today.assigned")}

+ {t("today.manage")}
{d?.assignments.map((a: any) => (
{a.status} {a.title} - Határidő: {a.deadlineDate || "nincs"} + + {t("today.deadline", { + value: a.deadlineDate || t("today.none"), + })} +
))}
diff --git a/tests/unit/i18n.test.ts b/tests/unit/i18n.test.ts new file mode 100644 index 0000000..d8f2619 --- /dev/null +++ b/tests/unit/i18n.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import hu from "../../apps/web/src/i18n/hu.json"; +import en from "../../apps/web/src/i18n/en.json"; +import { translate } from "../../apps/web/src/i18n/core"; + +describe("interface translations", () => { + it("keeps locale catalogs in key parity", () => { + expect(Object.keys(en).sort()).toEqual(Object.keys(hu).sort()); + }); + + it("contains no empty translations", () => { + for (const catalog of [hu, en]) + for (const value of Object.values(catalog)) + expect(value.trim().length).toBeGreaterThan(0); + }); + + it("interpolates named values", () => { + expect(translate("en", "entity.results", { count: 3 })).toBe("3 results"); + expect(translate("hu", "today.deadline", { value: "2026-09-01" })).toBe( + "Határidő: 2026-09-01", + ); + }); +});