diff --git a/web/package.json b/web/package.json index 7c025e9..41b9f52 100644 --- a/web/package.json +++ b/web/package.json @@ -18,10 +18,12 @@ "@fontsource-variable/geist": "^5.2.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "i18next": "^26.3.0", "lucide-react": "^1.17.0", "openapi-fetch": "^0.17.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^17.0.8", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1b0ad01..13ac4da 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + i18next: + specifier: ^26.3.0 + version: 26.3.0(typescript@5.8.3) lucide-react: specifier: ^1.17.0 version: 1.17.0(react@19.2.7) @@ -32,6 +35,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.2.7(react@19.2.7) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -1760,6 +1766,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1780,6 +1789,14 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + i18next@26.3.0: + resolution: {integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2389,6 +2406,22 @@ packages: peerDependencies: react: ^19.2.7 + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -2837,6 +2870,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -4572,6 +4609,10 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -4598,6 +4639,10 @@ snapshots: human-signals@8.0.1: {} + i18next@26.3.0(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -5114,6 +5159,17 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 + react-i18next@17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.0(typescript@5.8.3) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + typescript: 5.8.3 + react-is@17.0.2: {} react-refresh@0.17.0: {} @@ -5595,6 +5651,8 @@ snapshots: - tsx - yaml + void-elements@3.1.0: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json new file mode 100644 index 0000000..7b36382 --- /dev/null +++ b/web/src/i18n/en.json @@ -0,0 +1,8 @@ +{ + "app": { "name": "Collection" }, + "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "soon": "Coming soon" }, + "auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" }, + "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of" }, + "fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" }, + "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" } +} diff --git a/web/src/i18n/i18n.test.tsx b/web/src/i18n/i18n.test.tsx new file mode 100644 index 0000000..f58bffd --- /dev/null +++ b/web/src/i18n/i18n.test.tsx @@ -0,0 +1,29 @@ +import { expect, test, beforeEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { useTranslation } from "react-i18next"; +import i18n from "./index"; +import { useLocale } from "./use-locale"; + +beforeEach(async () => { + await i18n.changeLanguage("en"); +}); + +function Probe() { + const { t } = useTranslation(); + const { setLocale } = useLocale(); + return ( +
+ {t("objects.title")} + +
+ ); +} + +test("switches language at runtime", async () => { + render(); + expect(screen.getByTestId("title")).toHaveTextContent("Objects"); + await act(async () => { + screen.getByRole("button", { name: "sv" }).click(); + }); + expect(screen.getByTestId("title")).toHaveTextContent("Föremål"); +}); diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts new file mode 100644 index 0000000..be89623 --- /dev/null +++ b/web/src/i18n/index.ts @@ -0,0 +1,18 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import en from "./en.json"; +import sv from "./sv.json"; + +export const LOCALE_KEY = "locale"; +const stored = + typeof localStorage !== "undefined" ? localStorage.getItem(LOCALE_KEY) : null; +const fallback = "en"; + +void i18n.use(initReactI18next).init({ + resources: { en: { translation: en }, sv: { translation: sv } }, + lng: stored ?? fallback, + fallbackLng: fallback, + interpolation: { escapeValue: false }, +}); + +export default i18n; diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json new file mode 100644 index 0000000..c05032b --- /dev/null +++ b/web/src/i18n/sv.json @@ -0,0 +1,8 @@ +{ + "app": { "name": "Samling" }, + "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "soon": "Kommer snart" }, + "auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" }, + "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av" }, + "fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" }, + "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" } +} diff --git a/web/src/i18n/use-locale.ts b/web/src/i18n/use-locale.ts new file mode 100644 index 0000000..c93c453 --- /dev/null +++ b/web/src/i18n/use-locale.ts @@ -0,0 +1,13 @@ +import { useTranslation } from "react-i18next"; +import i18n, { LOCALE_KEY } from "./index"; + +export function useLocale() { + const { i18n: instance } = useTranslation(); + + const setLocale = (lng: "en" | "sv") => { + localStorage.setItem(LOCALE_KEY, lng); + void i18n.changeLanguage(lng); + }; + + return { locale: instance.language, setLocale }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index eb1ef28..eed4988 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,4 +1,5 @@ import "./index.css"; +import "./i18n"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./app"; diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 1fdb76d..82707c8 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -3,6 +3,24 @@ import { afterAll, afterEach } from "vitest"; import { server } from "./server"; +// Node v26 does not expose localStorage as a global unless --localstorage-file +// is passed. Provide a minimal in-memory shim so i18n and other modules that +// call localStorage.getItem/setItem work in jsdom tests. +if (typeof globalThis.localStorage === "undefined") { + const store: Record = {}; + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { Object.keys(store).forEach((k) => { delete store[k]; }); }, + get length() { return Object.keys(store).length; }, + key: (i: number) => Object.keys(store)[i] ?? null, + }, + writable: true, + }); +} + // Start MSW at module level so its fetch patch is in place before any test // module captures globalThis.fetch via openapi-fetch's createClient(). server.listen({ onUnhandledRequest: "error" }); diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index 197eece..cc58243 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -8,6 +8,7 @@ "skipLibCheck": true, "moduleResolution": "bundler", + "resolveJsonModule": true, "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force",