From 3749f87c1dfc49569052f05424f0e11173b39795 Mon Sep 17 00:00:00 2001 From: elpatron Date: Sun, 31 May 2026 15:53:43 +0200 Subject: [PATCH] Add Scandinavian i18n (da/sv/nb) via DeepL pipeline. Integrate new locale bundles, language cycling in the UI, SEO hreflang tags, and localized beta flyer HTML variants with scripts for batch translation and key validation. Co-authored-by: Cursor --- .env.example | 4 + client/index.html | 3 + client/package.json | 5 +- client/src/App.tsx | 4 +- client/src/components/AuthOnboarding.tsx | 6 +- client/src/components/DemoViewer.tsx | 6 +- .../src/components/InvitationAcceptance.tsx | 5 +- client/src/components/LogbookDashboard.tsx | 4 +- client/src/components/ReadOnlyViewer.tsx | 18 +- client/src/i18n/index.ts | 11 +- client/src/i18n/localeKeys.test.ts | 39 + client/src/i18n/locales/da.json | 735 ++++++++++++++++++ client/src/i18n/locales/de.json | 7 + client/src/i18n/locales/en.json | 7 + client/src/i18n/locales/nb.json | 735 ++++++++++++++++++ client/src/i18n/locales/sv.json | 735 ++++++++++++++++++ client/src/services/demoLogbookData.ts | 7 +- client/src/utils/dateTimeFormat.ts | 14 +- client/src/utils/i18nLanguages.test.ts | 21 + client/src/utils/i18nLanguages.ts | 22 + client/src/utils/locale.test.ts | 23 +- client/src/utils/seo.ts | 16 +- docs/marketing/beta-flyer.da.html | 381 +++++++++ docs/marketing/beta-flyer.html | 2 +- docs/marketing/beta-flyer.nb.html | 381 +++++++++ docs/marketing/beta-flyer.sv.html | 381 +++++++++ scripts/lib/deepl-translate.mjs | 184 +++++ scripts/translate-flyer.mjs | 110 +++ scripts/translate-locales.mjs | 99 +++ scripts/validate-i18n-keys.mjs | 52 ++ 30 files changed, 3975 insertions(+), 42 deletions(-) create mode 100644 client/src/i18n/localeKeys.test.ts create mode 100644 client/src/i18n/locales/da.json create mode 100644 client/src/i18n/locales/nb.json create mode 100644 client/src/i18n/locales/sv.json create mode 100644 client/src/utils/i18nLanguages.test.ts create mode 100644 client/src/utils/i18nLanguages.ts create mode 100644 docs/marketing/beta-flyer.da.html create mode 100644 docs/marketing/beta-flyer.nb.html create mode 100644 docs/marketing/beta-flyer.sv.html create mode 100644 scripts/lib/deepl-translate.mjs create mode 100644 scripts/translate-flyer.mjs create mode 100644 scripts/translate-locales.mjs create mode 100644 scripts/validate-i18n-keys.mjs diff --git a/.env.example b/.env.example index 26b84d3..9a2c33b 100755 --- a/.env.example +++ b/.env.example @@ -1,5 +1,9 @@ OpenWeatherMapAPIKey= +# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs) +# Free plan keys use api-free.deepl.com automatically (suffix :fx) +DeepLAPIKey= + # Passkey configuration (WebAuthn Relying Party ID and Origin) # For local dev: localhost and http://localhost # For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu diff --git a/client/index.html b/client/index.html index a94f889..ed14362 100644 --- a/client/index.html +++ b/client/index.html @@ -12,6 +12,9 @@ + + + diff --git a/client/package.json b/client/package.json index 7d0a6f3..287595e 100644 --- a/client/package.json +++ b/client/package.json @@ -11,7 +11,10 @@ "preview": "vite preview", "generate:flyer": "node ../scripts/generate-beta-flyer.mjs", "generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png", - "generate:flyer:setup": "playwright install chromium" + "generate:flyer:setup": "playwright install chromium", + "translate:locales": "node ../scripts/translate-locales.mjs", + "translate:flyer": "node ../scripts/translate-flyer.mjs", + "validate:i18n": "node ../scripts/validate-i18n-keys.mjs" }, "dependencies": { "@simplewebauthn/browser": "^13.3.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 30a6985..57dfbec 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -45,6 +45,7 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx' import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx' import { useTranslation } from 'react-i18next' +import { getNextLanguage } from './utils/i18nLanguages.js' import { resolveTourLogbookContext, seedDemoLogbookIfNeeded @@ -496,8 +497,7 @@ function App() { } const toggleLanguage = () => { - const nextLang = i18n.language.startsWith('de') ? 'en' : 'de' - i18n.changeLanguage(nextLang) + i18n.changeLanguage(getNextLanguage(i18n.language)) } const handleExitDemo = () => { diff --git a/client/src/components/AuthOnboarding.tsx b/client/src/components/AuthOnboarding.tsx index 686112e..9800efa 100644 --- a/client/src/components/AuthOnboarding.tsx +++ b/client/src/components/AuthOnboarding.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' +import { getNextLanguage } from '../utils/i18nLanguages.js' import { registerUser, loginUser, @@ -209,8 +210,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo } const toggleLanguage = () => { - const nextLang = i18n.language.startsWith('de') ? 'en' : 'de' - i18n.changeLanguage(nextLang) + i18n.changeLanguage(getNextLanguage(i18n.language)) } const copyToClipboard = () => { @@ -596,7 +596,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
diff --git a/client/src/components/InvitationAcceptance.tsx b/client/src/components/InvitationAcceptance.tsx index 8f24651..702ee7a 100644 --- a/client/src/components/InvitationAcceptance.tsx +++ b/client/src/components/InvitationAcceptance.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { getNextLanguage } from '../utils/i18nLanguages.js' import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react' import { getActiveMasterKey, @@ -308,7 +309,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio } const toggleLanguage = () => { - i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de') + i18n.changeLanguage(getNextLanguage(i18n.language)) } if (recoveryPhrase) { @@ -511,7 +512,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx index 4330b10..3324309 100644 --- a/client/src/components/LogbookDashboard.tsx +++ b/client/src/components/LogbookDashboard.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { getNextLanguage } from '../utils/i18nLanguages.js' import { useSyncIndicator } from '../hooks/useSyncIndicator.js' import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js' import LogbookRoleBadge from './LogbookRoleBadge.tsx' @@ -193,8 +194,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf } const toggleLanguage = () => { - const nextLang = i18n.language.startsWith('de') ? 'en' : 'de' - i18n.changeLanguage(nextLang) + i18n.changeLanguage(getNextLanguage(i18n.language)) } const ownedLogbooks = logbooks.filter((lb) => !lb.isShared) diff --git a/client/src/components/ReadOnlyViewer.tsx b/client/src/components/ReadOnlyViewer.tsx index 7420657..220b72f 100644 --- a/client/src/components/ReadOnlyViewer.tsx +++ b/client/src/components/ReadOnlyViewer.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js' import { decryptJson } from '../services/crypto.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import VesselForm from './VesselForm.tsx' @@ -48,9 +49,9 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) { const res = await fetch(`/api/collaboration/share-pull?token=${token}`) if (!res.ok) { if (res.status === 410) { - throw new Error(i18n.language.startsWith('de') ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.') + throw new Error(isGermanLocale(i18n.language) ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.') } - throw new Error(i18n.language.startsWith('de') ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.') + throw new Error(isGermanLocale(i18n.language) ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.') } const data = await res.json() @@ -136,15 +137,14 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) { } const toggleLanguage = () => { - const nextLang = i18n.language.startsWith('de') ? 'en' : 'de' - i18n.changeLanguage(nextLang) + i18n.changeLanguage(getNextLanguage(i18n.language)) } if (loading) { return (
-

{i18n.language.startsWith('de') ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}

+

{isGermanLocale(i18n.language) ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}

) } @@ -153,10 +153,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) { return (
-

{i18n.language.startsWith('de') ? 'Verbindungsfehler' : 'Access Error'}

+

{isGermanLocale(i18n.language) ? 'Verbindungsfehler' : 'Access Error'}

{error}

) @@ -173,7 +173,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {

{logbookTitle}

- {i18n.language.startsWith('de') ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'} + {isGermanLocale(i18n.language) ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}

@@ -181,7 +181,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 41a6aec..d6333d6 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -3,12 +3,19 @@ import { initReactI18next } from 'react-i18next' import LanguageDetector from 'i18next-browser-languagedetector' import enJson from './locales/en.json' import deJson from './locales/de.json' +import daJson from './locales/da.json' +import svJson from './locales/sv.json' +import nbJson from './locales/nb.json' import { initSeo } from '../utils/seo.js' +import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js' /** JSON files wrap strings in `translation` — register that namespace explicitly. */ const resources = { en: { translation: enJson.translation }, - de: { translation: deJson.translation } + de: { translation: deJson.translation }, + da: { translation: daJson.translation }, + sv: { translation: svJson.translation }, + nb: { translation: nbJson.translation } } i18n @@ -18,7 +25,7 @@ i18n resources, defaultNS: 'translation', fallbackLng: 'en', - supportedLngs: ['de', 'en'], + supportedLngs: [...SUPPORTED_LANGUAGES], nonExplicitSupportedLngs: true, load: 'languageOnly', interpolation: { diff --git a/client/src/i18n/localeKeys.test.ts b/client/src/i18n/localeKeys.test.ts new file mode 100644 index 0000000..ab316ad --- /dev/null +++ b/client/src/i18n/localeKeys.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import deJson from '../i18n/locales/de.json' +import enJson from '../i18n/locales/en.json' +import daJson from '../i18n/locales/da.json' +import svJson from '../i18n/locales/sv.json' +import nbJson from '../i18n/locales/nb.json' + +function collectKeys(obj: Record, prefix = ''): string[] { + const keys: string[] = [] + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key + if (value && typeof value === 'object' && !Array.isArray(value)) { + keys.push(...collectKeys(value as Record, path)) + } else { + keys.push(path) + } + } + return keys.sort() +} + +const bundles = { + de: deJson.translation, + en: enJson.translation, + da: daJson.translation, + sv: svJson.translation, + nb: nbJson.translation +} as const + +describe('i18n locale key parity', () => { + const masterKeys = collectKeys(bundles.de) + + it.each(Object.keys(bundles).filter((lang) => lang !== 'de'))( + '%s has the same keys as de', + (lang) => { + const keys = collectKeys(bundles[lang as keyof typeof bundles]) + expect(keys).toEqual(masterKeys) + } + ) +}) diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json new file mode 100644 index 0000000..10d9633 --- /dev/null +++ b/client/src/i18n/locales/da.json @@ -0,0 +1,735 @@ +{ + "translation": { + "app": { + "name": "Kapteins Daagbok", + "tagline": "Privat yacht-logbog", + "beta": "Beta", + "beta_hint": "Betaversion - funktioner kan stadig ændres" + }, + "languages": { + "de": "Deutsch", + "en": "English", + "da": "Dansk", + "sv": "Svenska", + "nb": "Norsk" + }, + "common": { + "unsaved_changes_title": "Ikke gemte ændringer", + "unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.", + "unsaved_changes_leave": "Forladelse", + "unsaved_changes_stay": "Bliv her" + }, + "nav": { + "dashboard": "Dashboard", + "vessel": "Skibsdata", + "crew": "Besætningsliste", + "deviation": "Tabel over distraktioner", + "logs": "Indlæg i logbogen", + "stats": "Statistik", + "settings": "Indstillinger" + }, + "auth": { + "welcome": "Velkommen til Kapteins Daagbok.", + "tagline": "Din sikre, E2E-krypterede maritime logbog.", + "register": "Registrer dig med Passkey.", + "login": "Log ind med Passkey.", + "login_as": "Log ind som {{name}}", + "quick_login": "Hurtigt login", + "forget_account": "Glemt konto på denne enhed", + "not_user": "Ikke {{name}}?", + "recovery_title": "Din genoprettelsesnøgle", + "recovery_warning": "VIGTIGT: Skriv disse 12 ord ned. Hvis du mister din Passkey og disse ord, kan dine data ikke gendannes.", + "confirm_recovery": "Jeg har skrevet ordene ned", + "status_logged_in": "Logget ind", + "status_logged_out": "Aflyst", + "copied": "Kopieret!", + "copy_phrase": "Kopieringstast", + "enter_recovery": "Indtast genoprettelsesnøgle", + "recovery_fallback_warning": "Din Passkey er blevet godkendt, men din enhed understøtter ikke hardwarebaseret nøgleafledning. Indtast din genoprettelsesnøgle på 12 ord for at dekryptere din logbog.", + "recovery_placeholder": "Indtast din genoprettelsesnøgle, som består af 12 ord adskilt af mellemrum...", + "back": "Tilbage", + "decrypting": "Dekryptering...", + "decrypt_logbook": "Afkodning af logbog", + "error_incorrect_recovery": "Forkert genoprettelsesnøgle. Dekryptering mislykkedes.", + "error_decryption_failed": "Dekryptering mislykkedes. Tjek venligst din genoprettelsesnøgle.", + "or_register": "eller registrer dig", + "explore_demo": "Udforsk demoen uden en konto", + "username_placeholder": "Brugernavn / skippernavn", + "processing": "Behandling...", + "help": "Hjælp", + "setup_pin_title": "Opsæt lokal PIN-kode (valgfrit)", + "setup_pin_warning": "Da din enhed ikke understøtter direkte Passkey-nøgleafledning, ville du ellers være nødt til at indtaste din 12-ordsnøgle, hver gang du logger ind på denne enhed. Opsæt en lokal PIN-kode for at undgå dette.", + "pin_placeholder": "E.G. 123456", + "pin_label": "Lokal PIN-kode (4-8 cifre)", + "save_pin": "Gem PIN-kode og fortsæt", + "skip_pin": "Spring over og brug gendannelse", + "enter_pin_title": "Afkodning med PIN-kode", + "enter_pin_warning": "Indtast din lokale PIN-kode for at låse op for dekrypteringsnøglen på denne enhed.", + "enter_pin_placeholder": "Indtast din pinkode...", + "decrypt_with_pin": "Afkodning", + "use_recovery_instead": "Brug genoprettelsesnøgler i stedet", + "error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes." + }, + "pwa": { + "title": "Installer app", + "generic_benefit": "Installer Kapteins Daagbok på din enhed for at få hurtigere adgang, offline-brug og permanent datalagring.", + "ios_instructions": "På iPad/iPhone: Føj appen til startskærmen, så dine logbogsdata forbliver beskyttet, og appen starter som en indbygget app.", + "ios_step_share": "Tryk på aktiesymbolet i Safari-linjen", + "ios_step_add": "Vælg \"Gå til startskærm\"", + "install_now": "Installer nu", + "installing": "Installation...", + "later": "Senere", + "never": "Vis ikke mere", + "platform_ios": "Installation via Safari.", + "platform_android": "Installation via browseren", + "platform_desktop": "Installation som desktop-app", + "settings_section": "Installation af app", + "update_title": "Opdatering tilgængelig", + "update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.", + "update_now": "Opdater nu", + "update_reloading": "Indlæser..." + }, + "sync": { + "status_synced": "Synkroniseret", + "status_syncing": "Synkroniser...", + "status_offline": "Offline-cache", + "status_unsynced": "Usynkroniserede ændringer" + }, + "vessel": { + "title": "Skibets stamdata", + "name": "Yacht-navn", + "type": "Yacht-type", + "type_unset": "- ikke specificeret -", + "type_sailing": "Sejlbåd", + "type_motor": "Motorbåd", + "length_m": "Længde (m)", + "draft_m": "Dybgang (m)", + "air_draft_m": "Højde (m)", + "invalid_metric": "Ugyldig numerisk værdi - indtast venligst meter som et decimaltal (f.eks. 12,5).", + "port": "Hjemmehavn", + "owner": "Ejer", + "charter": "Charterselskab", + "registration": "Nummerplade/registreringsnummer", + "callsign": "Radiokaldesignal", + "atis": "ATIS nr.", + "mmsi": "MMSI-nr.", + "save": "Gem skibsdata", + "saving": "Vil blive reddet...", + "saved": "Skibsdata er gemt med succes!", + "loading": "Skibsdata er indlæst...", + "sails_list": "Sejl (eksisterende sejl)", + "sails_help": "Indtast de sejl, der er tilgængelige på din båd her (f.eks. storsejl, genua, fok).", + "add_sail": "Tilføj sejl", + "sail_name_placeholder": "z. f.eks. storsejl", + "no_sails": "Ingen sejl opbevaret.", + "photo_add": "Tilføj foto", + "photo_change": "Skift foto", + "photo_delete": "Slet foto", + "tanks_section": "Tanke (kapacitet)", + "tanks_help": "Valgfrit i liter - muliggør slider i journalen for kendte tankstørrelser.", + "freshwater_capacity_l": "Drikkevand (liter)", + "fuel_capacity_l": "Brændstof (liter)", + "greywater_capacity_l": "Gråt vand (liter)", + "invalid_tank_liters": "Ugyldig numerisk værdi - indtast venligst liter som et tal (f.eks. 200)." + }, + "logs": { + "title": "Logbogsdagbog", + "new_entry": "Ny rejsedag", + "travel_details": "Detaljer om rejsen", + "add_event": "Tilføj ny logbogspost", + "add_event_btn": "Tilføj begivenhed", + "edit_event": "Rediger begivenhed", + "save_event_btn": "Gem ændring", + "cancel_event_edit": "Annuller", + "delete_event": "Slet begivenhed", + "sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet", + "sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.", + "date": "dato", + "day_of_travel": "Rejsedag / rejsedag", + "departure": "Starthavn (rejse fra)", + "destination": "Destinationsport (til)", + "route": "Rejse fra/til", + "freshwater": "Ferskvand (liter)", + "fuel": "Treibstoff / Brændstof (liter)", + "greywater": "Gråt vand (liter)", + "greywater_level": "Fyldningsniveau", + "tank_slider_of_max": "{{current}} / {{max}} L", + "tank_capacity_tooltip": "Hvis tankkapaciteten (liter) er gemt i skibsdataene, kan du indtaste fyldningsniveauerne her ved hjælp af skyderen.", + "morning": "Stå op om morgenen", + "refilled": "Genopfyldt", + "evening": "Stand om aftenen", + "consumption": "Dagligt forbrug", + "signatures": "Underskrifter / frigivelse", + "sign_skipper": "Skippers underskrift", + "sign_crew": "Crew-signatur", + "sign_hint": "Tegn med finger, pen eller mus", + "sign_clear": "Sletning", + "sign_export_image": "[Underskrift]", + "sign_with_passkey": "Frigør med Passkey.", + "sign_passkey_signing": "Der anmodes om Passkey...", + "sign_passkey_signed": "Udgivet af {{username}}", + "sign_passkey_export": "Passkey: {{username}} ({{date}})", + "sign_attribution_export": "{{username}} ({{date}})", + "sign_passkey_clear": "Fjern Passkey-frigivelse", + "sign_mode_passkey": "Passkey", + "sign_mode_classic": "Klassisk", + "sign_passkey_failed": "Passkey Frigivelse mislykkedes", + "sign_passkey_cancelled": "Passkey Frigivelse annulleret", + "sign_invalid": "Signatur ugyldig - indholdet er blevet ændret", + "sign_badge_skipper": "Skipper", + "sign_badge_skipper_invalid": "Ugyldig", + "sign_badge_skipper_title_valid": "Skipper har udgivet", + "sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret", + "sign_classic_or_passkey": "Valgfrit: klassisk underskrift eller Passkey-frigivelse ovenfor", + "sign_crew_passkey_hint": "Besætningsmedlemmer med skriveadgang kan frigive via Passkey.", + "sign_offline_hint": "Passkey-Godkendelse kræver internet - klassisk underskrift mulig offline", + "sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.", + "sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og besætningens underskrifter.", + "sign_lock_warning_title": "Bekræft underskrift", + "sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.\n\nVil du gerne fortsætte?", + "sign_proceed": "Tegn", + "sign_cancel": "Annuller", + "sign_cleared_re_sign_title": "Underskrifter fjernet", + "sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og besætningens underskrifter er blevet fjernet. Underskriv venligst igen.", + "no_entries": "Ingen logbogsposter fundet for denne yacht. Opret din første rejsedag!", + "back_to_list": "Tilbage til tidsskriftslisten", + "save": "Gem logbogsside", + "saving": "Vil blive reddet...", + "saved": "Logbogsside gemt med succes!", + "loading": "Dagbogen er ved at blive indlæst.", + "delete_entry": "Slet tag", + "delete_confirm": "Er du sikker på, at du vil slette denne rejsedag permanent?", + "carry_over_tanks_title": "Overføre data fra den foregående dag?", + "carry_over_tanks_confirm": "Overtage starthavn, ferskvand, brændstof og gråvand fra den sidste dag på turen?\n\nStarthavn: {{departure}}\nFerskvand: {{fw}} L\nBrændstof: {{fuel}} L\nGråt vand: {{greywater}} L", + "carry_over_tanks_yes": "Tag over", + "carry_over_tanks_no": "Start med 0", + "event_title": "Kronologisk hændelseslog", + "no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.", + "event_time": "Tidspunkt på dagen", + "event_mgk": "MgK-kursus", + "event_rwk": "RwK-kursus", + "event_course_section": "Kursus", + "course_dial_hint": "Drej ringen eller indtast grader", + "course_dial_step_label": "Trinstørrelse", + "course_step_fine": "1°", + "course_step_medium": "5°", + "course_step_coarse": "10°", + "course_tab_mgk": "MgK", + "course_tab_rwk": "rwK", + "course_invalid": "Ugyldigt kursus (0-360)", + "course_placeholder_degrees": "z. B. 180", + "course_placeholder_cardinal": "z. E.G. NW", + "compass_n": "N", + "compass_e": "O", + "compass_s": "S", + "compass_w": "W", + "wind_mode_cardinal": "Kardinal", + "wind_mode_degrees": "Som grad", + "event_wind_direction": "Vindretning", + "event_wind_strength": "Vindstyrke", + "event_sea_state": "Havets tilstand", + "event_weather": "Vejret", + "event_log": "Log (sm)", + "event_gps": "GPS-position", + "event_location": "Sted/havn", + "event_location_placeholder": "z. f.eks. Kiel", + "event_remarks": "Bemærkninger / hændelser", + "gps_btn": "Hent GPS-koordinater", + "weather_btn": "OpenWeatherMap Kald vejret op", + "event_wind_pressure": "Lufttryk (hPa)", + "event_heel": "Krængning (°)", + "event_sails": "Sejlhåndtering/motor", + "motor_propulsion": "Kørsel med maskine", + "sails_picker_show_more": "Vis alle sejl", + "sails_picker_show_less": "Vis mindre", + "motor_hours": "Maskintimer (i alt)", + "fuel_per_motor_hour": "Forbrug pr. maskintime", + "event_distance": "Afstand (nm)", + "export_csv": "Download CSV.", + "share_csv": "CSV andel", + "export_pdf": "Download PDF.", + "exporting_pdf": "PDF er genereret...", + "photos_title": "Vedhæftede billeder (E2E-krypteret)", + "photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)", + "photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen", + "photo_btn": "Tag foto / upload", + "photo_processing": "Er ved at blive behandlet...", + "no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.", + "photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?", + "confirm_yes": "Ja", + "confirm_no": "Nej", + "track_upload_title": "GPS-spor (fil)", + "track_upload_points": "Point", + "gps_tracking_btn_gpx": "Download sporfilen", + "gps_track_upload_help": "Træk en GPX-, KML- eller GeoJSON-fil hertil, eller klik for at vælge", + "gps_track_upload_btn": "Upload GPS-spor", + "gps_track_delete": "Slet sporfilen", + "gps_track_delete_confirm": "Er du sikker på, at du vil slette denne sporfil permanent?", + "track_distance": "GPS-rute (sm)", + "track_speed_max": "Maks. Hastighed (kn)", + "track_speed_avg": "Ø Hastighed (kn)", + "track_map_title": "GPS-spor på OpenSeaMap", + "track_map_start": "Start", + "track_map_end": "Mål", + "track_map_speed_slow": "langsomt", + "track_map_speed_fast": "hurtigt", + "track_map_error": "Kortet kunne ikke indlæses.", + "exporting": "Eksport...", + "share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.", + "invite_crew": "Inviter besætningen", + "invite_link_copied": "Invitationslink kopieret til udklipsholderen!", + "invite_link_desc": "Del dette link med besætningsmedlemmer for at give dem skriveadgang til denne logbog.", + "collaborators_list": "Medlemmer / besætning", + "revoke": "Fjerne", + "revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?", + "invite_role": "Rolle", + "invite_expires": "Linket er gyldigt i 48 timer" + }, + "dashboard": { + "title": "Dine logbøger", + "subtitle": "Vælg en logbog, eller opret en ny til at styre dine rejser.", + "create_btn": "Opret logbog", + "new_logbook_placeholder": "Navn på logbog eller yacht", + "logout": "Log ud", + "logged_in_as": "Logget ind som {{name}}", + "delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok.json) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.", + "no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!", + "loading": "Logbøgerne er fyldt op...", + "status_synced": "Synkroniseret", + "status_local": "Kun lokal cache", + "delete_btn": "Slet logbog", + "section_owned": "Mine logbøger", + "section_shared": "Fælles logbøger", + "section_shared_hint": "Du er blevet inviteret som besætningsmedlem. Skipperprofil og indstillinger tilhører ejeren.", + "role_owner": "Egen logbog", + "role_owner_hint": "Du er ejer og skipper af denne logbog", + "role_crew": "Adgang for besætning", + "role_crew_hint": "Inviteret logbog - du kan arbejde som besætning og underskrive den", + "role_read": "Læs kun", + "role_read_hint": "Opdelt logbog - kun visning, ingen redigering", + "open_profile": "Åben profil af {{name}}", + "edit_title": "Omdøb logbog", + "edit_placeholder": "Nyt navn på logbogen", + "edit_success": "Logbog omdøbt med succes", + "edit_btn": "Omdøb", + "filter_label": "Filtrer logbøger", + "filter_placeholder": "Navn, årstal eller dato ...", + "filter_clear": "Nulstil filter", + "filter_results": "{{count}} Hits", + "filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.", + "sort_label": "Sortere", + "sort_by_label": "Sorter efter", + "sort_by_name": "Navn", + "sort_by_date": "dato", + "sort_dir_label": "Sekvens", + "sort_asc": "Stigende", + "sort_desc": "Nedadgående", + "sort_name_asc": "Navn A til Z", + "sort_name_desc": "Navn Z til A", + "sort_date_asc": "Ældste først", + "sort_date_desc": "Nyeste først" + }, + "profile": { + "title": "Brugerprofil", + "subtitle": "Konto, Passkeys og statistik for {{name}}.", + "back": "Tilbage til instrumentbrættet", + "loading": "Profilen er ved at blive indlæst...", + "load_error": "Profilen kunne ikke indlæses.", + "copy_failed": "Kopiering mislykkedes.", + "processing": "Er ved at blive behandlet...", + "identity_title": "Konto-identitet", + "username": "Brugernavn", + "user_id": "Bruger-ID", + "copy_user_id": "Kopier bruger-ID", + "account_since": "Konto siden", + "prf_status": "Passkey nøgleafledning (PRF)", + "prf_active": "Aktiv", + "prf_inactive": "Ikke sat op", + "passkeys_title": "Passkeys", + "passkeys_desc": "Registrer en separat Passkey på hver enhed. På den måde kan du logge ind, selv når du har skiftet platform.", + "passkeys_empty": "Ingen Passkeys fundet.", + "add_passkey_btn": "Tilføj ny Passkey.", + "add_passkey_success": "Passkey tilføjet med succes.", + "add_passkey_failed": "Passkey kunne ikke tilføjes.", + "remove_passkey_btn": "Fjern Passkey", + "remove_passkey_last_title": "Sidste Passkey.", + "remove_passkey_last_desc": "Den eneste Passkey kan ikke fjernes uden at miste adgangen til din konto. Hvis du vil slette kontoen helt, skal du bruge farezonen nederst på denne side.", + "remove_passkey_failed": "Passkey kunne ikke fjernes.", + "remove_passkey_confirm_title": "Fjern Passkey?", + "remove_passkey_confirm_desc": "Denne enhed kan så ikke længere logge ind med denne Passkey.", + "remove_passkey_confirm_yes": "Fjerne", + "remove_passkey_confirm_no": "Annuller", + "pin_title": "Lokal PIN-kode", + "pin_status": "Status", + "pin_active": "Aktiv på denne enhed", + "pin_inactive": "Ikke sat op", + "pin_confirm_label": "Bekræft PIN-kode", + "pin_confirm_placeholder": "Indtast PIN-kode igen", + "pin_set_btn": "Opsæt PIN-kode", + "pin_change_btn": "Skift PIN-kode", + "pin_remove_btn": "Fjern PIN-kode", + "pin_saved": "PIN-kode gemt.", + "pin_save_failed": "PIN-koden kunne ikke gemmes.", + "pin_mismatch": "PIN-koderne stemmer ikke overens.", + "pin_length_error": "PIN-koden skal bestå af mindst 4 tegn.", + "pin_no_session": "Sessionen er udløbet - tilmeld dig venligst igen.", + "remove_pin_confirm_title": "Fjerne PIN-kode?", + "remove_pin_confirm_desc": "Du skal logge ind igen på denne enhed med Passkey eller genoprettelsesnøglen.", + "remove_pin_confirm_yes": "Fjern PIN-kode", + "remove_pin_confirm_no": "Annuller", + "security_title": "Tjekliste for sikkerhed", + "security_desc": "Oversigt over de vigtigste beskyttelsesmekanismer for din konto.", + "security_passkeys_ok": "Mindst én Passkey registreret", + "security_passkeys_missing": "Nej Passkey registreret", + "security_prf_ok": "PRF-nøgleafledning aktiv", + "security_prf_missing": "PRF ikke sat op", + "security_pin_ok": "Lokal PIN-kode på denne enhed", + "security_pin_missing": "Ingen lokal PIN-kode", + "security_recovery_ok": "Opsætning af genoprettelsesnøgle", + "security_recovery_hint": "De 12 ord blev vist under registreringen. Opbevar dem offline og adskilt fra enheden. Du kan oprette en ny nøgle nedenfor - den gamle bliver så ugyldig.", + "recovery_rotate_btn": "Opret en ny genoprettelsesnøgle", + "recovery_rotate_confirm_title": "Opret en ny genoprettelsesnøgle?", + "recovery_rotate_confirm_desc": "Den tidligere nøgle på 12 ord bliver ugyldig med det samme. Sørg for at opbevare den nye nøgle sikkert, før du fortsætter.", + "recovery_rotate_confirm_yes": "Opret ny nøgle", + "recovery_rotate_confirm_no": "Annuller", + "recovery_rotate_new_warning": "VIGTIGT: Skriv disse 12 ord ned, og opbevar dem offline. Den tidligere genoprettelsesnøgle er nu ugyldig.", + "recovery_rotate_failed": "Genoprettelsesnøglen kunne ikke oprettes.", + "recovery_rotate_no_session": "Krypteringssessionen er udløbet - log ud og log ind igen, og prøv så igen.", + "device_title": "Denne enhed", + "device_desc": "Lokal cache, synkroniseringsstatus og hurtig login i denne browser.", + "device_sync_pending": "{{count}} ventende synkroniseringsposter", + "device_sync_ok": "Alle lokale ændringer synkroniseres", + "device_remembered": "Konto til hurtigt login gemt på denne enhed", + "device_not_remembered": "Kontoen er ikke på listen over hurtige login", + "device_forget_btn": "Glemt konto på denne enhed", + "device_forget_confirm_title": "Fjerne hurtig login?", + "device_forget_confirm_desc": "Kontoen forsvinder fra listen over hurtige login på denne enhed. Din session og dine lokale logbøger bevares.", + "device_forget_confirm_yes": "Fjerne", + "device_forget_confirm_no": "Annuller", + "passkey_label": "Navn på ny Passkey (valgfrit)", + "passkey_label_placeholder": "z. f.eks. MacBook, iPhone.", + "passkey_rename_btn": "Gem navn", + "passkey_rename_success": "Passkey navn gemt.", + "passkey_rename_failed": "Passkey-Navnet kunne ikke gemmes.", + "passkey_unnamed": "Uden titel Passkey.", + "stats_title": "Statistik", + "stats_subtitle": "Om alle dine logbøger på denne enhed", + "stats_logbooks": "Logbøger", + "stats_account_since": "Konto siden", + "stats_shared_logbooks": "Fælles logbøger", + "appearance_title": "App og visualisering", + "appearance_desc": "Designet og farveskemaet gælder for hele appen på denne enhed.", + "theme_label": "Appens designstil", + "theme_auto": "Automatisk (OS-registrering)", + "theme_ocean": "Ocean (glasmorfisme)", + "theme_material": "Materiale (Android)", + "theme_cupertino": "Cupertino (iOS)", + "color_scheme_label": "Lys eller mørk tilstand", + "color_scheme_auto": "Automatisk (system)", + "color_scheme_light": "Lys", + "color_scheme_dark": "Mørk", + "integrations_title": "Integrationer", + "owm_key": "OpenWeatherMap API-nøgle", + "owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.", + "prefs_save": "Gemme", + "prefs_saving": "Vil blive reddet...", + "prefs_saved": "Gemt", + "tour_title": "App-tur", + "tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.", + "tour_restart": "Start turen igen", + "push_title": "Push-meddelelser", + "push_desc": "Som logbogsejer får du besked, når inviterede besætningsmedlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.", + "push_enable": "Giv os besked om ændringer i besætningen", + "push_active": "Push-meddelelser er aktive på denne enhed.", + "push_unsupported": "Push-meddelelser understøttes ikke i denne browser.", + "push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.", + "push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.", + "push_error": "Push-meddelelser kunne ikke aktiveres." + }, + "crew": { + "title": "Skipper- og besætningsprofiler", + "skipper_section": "Skipper-profil", + "skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.", + "crew_section": "Besætningsliste", + "add_crew": "Tilføj besætningsmedlem", + "edit_crew": "Rediger besætningsmedlem", + "no_crew": "Ingen besætningsmedlemmer tilføjet endnu.", + "max_crew": "Det maksimale antal på 5 besætningsmedlemmer er nået.", + "name": "Navn", + "address": "adresse", + "birthdate": "Fødselsdag", + "phone": "Telefonnummer", + "nationality": "Nationalitet", + "passport": "Pas/ID-nummer", + "bloodtype": "Blodgruppe", + "allergies": "Allergier", + "diseases": "Eksisterende tilstande/sygdomme", + "save": "Gem skipper-data", + "save_member": "Gem medlem", + "saved": "Skipperprofilen er blevet gemt!", + "loading": "Besætningsfilerne er indlæst.", + "delete_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlem?" + }, + "deviation": { + "title": "Tabel over kompasafvigelser", + "subtitle": "Indtast den magnetiske kompasafbøjning (afbøjning) for kurser (MgK) fra 000° til 360° i trin på 10°.", + "heading": "MgK", + "deviation": "Distraktion", + "save": "Gem kalibreringsgitter", + "saving": "Vil blive reddet...", + "saved": "Kalibreringsgitteret er gemt med succes!", + "loading": "Kalibreringstabellen er indlæst..." + }, + "settings": { + "title": "Indstillinger for logbog", + "subtitle": "Del, tag backup og samarbejd om denne logbog.", + "select_logbook_hint": "Vælg en logbog for at redigere dens indstillinger.", + "no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.", + "weather_success": "Vejrdata hentet med succes!", + "weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.", + "weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.", + "gps_error": "Indtast en placering, eller find GPS-koordinaterne.", + "share_title": "Del logbog (skrivebeskyttet)", + "share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og besætning. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).", + "share_privacy_warning": "Anbefaling: Del kun dette link privat (f.eks. via e-mail eller messenger), ikke på sociale medier.", + "share_enable": "Aktivér offentligt link", + "share_copied": "Link kopieret!", + "share_copy_btn": "Kopier link", + "danger_zone_title": "Farezone", + "danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.", + "delete_account_btn": "Slet konto uigenkaldeligt", + "delete_account_confirm_title": "Slette konto?", + "delete_account_confirm_desc": "Er du helt sikker på, at du vil slette din konto uigenkaldeligt og alle tilknyttede logbøger og E2E-krypterede data?", + "delete_account_confirm_yes": "Ja, slet konto og alle data", + "delete_account_confirm_no": "Annuller", + "delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.", + "delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.", + "deleting_account": "Kontoen vil blive slettet...", + "invite_push_prompt_title": "Aktivere push-meddelelser?", + "invite_push_prompt_message": "Så snart inviterede besætningsmedlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.", + "invite_push_prompt_ios_message": "Så snart besætningsmedlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.", + "invite_push_prompt_enable": "Aktiver nu", + "invite_push_prompt_later": "Senere", + "invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.", + "backup_title": "Sikkerhedskopiering og gendannelse", + "backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, besætning, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.", + "backup_export_title": "Opret backup", + "backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.", + "backup_restore_title": "Gendan sikkerhedskopi", + "backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.", + "backup_passphrase": "Backup-passphrase", + "backup_passphrase_placeholder": "Mindst 8 tegn", + "backup_passphrase_confirm": "Bekræft adgangssætning", + "backup_passphrase_short": "Backup-passphrasen skal være mindst 8 tegn lang.", + "backup_passphrase_mismatch": "Passphrases matcher ikke.", + "backup_wrong_passphrase": "Passphrase forkert eller backup beskadiget.", + "backup_export_btn": "Download backup", + "backup_exporting": "Sikkerhedskopien er oprettet...", + "backup_export_success": "Backup oprettet ({{count}} rejsedage).", + "backup_file_label": "Backup-fil (.daagbok.json)", + "backup_preview_btn": "Tjek indhold", + "backup_previewing": "Tjek...", + "backup_restore_btn": "Gendan", + "backup_restoring": "Vil blive restaureret...", + "backup_restore_success": "Logbog \"{{title}}\" er blevet gendannet.", + "backup_restore_cancelled": "Genopretning aflyst.", + "backup_invalid_json": "Filen er ikke en gyldig JSON-fil.", + "backup_invalid_format": "Ukendt eller forældet backup-format.", + "backup_not_owner": "Kun logbogens ejer kan oprette sikkerhedskopier.", + "backup_not_authenticated": "Log ind for at gendanne en sikkerhedskopi.", + "backup_id_conflict": "Der findes allerede en logbog med dette ID.", + "backup_overwrite_confirm": "Den eksisterende logbog med samme ID erstattes. Fortsætter du?", + "backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?", + "backup_stat_entries": "{{count}} Rejsedage", + "backup_stat_photos": "{{count}} Fotos", + "backup_stat_crew": "{{count}} Besætningens poster", + "backup_stat_tracks": "{{count}} GPS-spor", + "backup_exported_at": "Eksporteret: {{date}}" + }, + "disclaimer": { + "title": "Vigtige bemærkninger", + "intro": "Læs venligst følgende instruktioner, før du bruger Kapteins Daagbok.", + "e2e_title": "Ende-til-ende-kryptering", + "e2e_body": "Dine logbogsdata er krypteret fra ende til anden. Kun du - eller personer med din nøgle - kan læse indholdet. Kun krypterede data gemmes på serveren.", + "pwa_title": "Progressiv web-app (PWA)", + "pwa_body": "Kapteins Daagbok kører som en progressiv webapp i din browser og kan installeres på din enhed - på samme måde som en native app, men uden en app store.", + "storage_title": "Lokal lagring og synkronisering", + "storage_body": "Dine data gemmes lokalt på din enhed (IndexedDB). Ændringer synkroniseres med serveren, når en internetforbindelse er aktiv. Du kan arbejde videre uden forbindelse; synkroniseringen finder sted senere.", + "free_title": "Gratis og uden reklamer", + "free_body": "Kapteins Daagbok er gratis og indeholder ingen reklamer.", + "liability_title": "Ansvarsfraskrivelse", + "liability_body": "Brug af appen sker på egen risiko. Vi påtager os intet ansvar for skader, der opstår som følge af brugen af appen - herunder forkerte eller ufuldstændige logbogsindførsler, tab af data eller tekniske fejl.", + "warranty_title": "Ingen garanti", + "warranty_body": "Der gives ingen garanti for tjenestens funktion, korrekthed eller tilgængelighed. Driften kan til enhver tid blive afbrudt, begrænset eller annulleret.", + "copyright": "© 2026 KnorrLabs, Markus F.J. Busche", + "accept": "Accepter og fortsæt", + "close": "Luk", + "button_title": "Noter og ansvarsfraskrivelse" + }, + "feedback": { + "button_title": "Send feedback", + "title": "Feedback", + "intro": "Del fejl, ideer eller generel feedback. Din besked vil blive sendt til projektteamet via en sikker meddelelseskanal.", + "category_label": "Kategori", + "category_general": "Generelt", + "category_bug": "Rapporter fejl", + "category_feature": "Anmodning om funktion", + "contact_label": "E-mail (valgfrit)", + "contact_placeholder": "deine@email.beispiel", + "message_label": "Besked", + "message_placeholder": "Beskriv din feedback...", + "send": "Send", + "sending": "Vil blive sendt...", + "cancel": "Annuller", + "success": "Tusind tak skal du have! Din feedback er blevet sendt.", + "error_send": "Feedback kunne ikke sendes. Prøv venligst igen senere.", + "error_invalid_email": "Indtast venligst en gyldig e-mailadresse.", + "error_not_configured": "Feedback er ikke tilgængelig på denne server.", + "error_rate_limited": "For mange tilbagemeldinger på kort tid. Vent venligst et par minutter.", + "error_spam": "Denne besked kunne ikke sendes. Vær venlig at omformulere den." + }, + "demo": { + "logbook_title": "Demo-logbog Østersøen", + "badge": "Demo", + "public_banner": "Skrivebeskyttet demo-visning", + "cta_register": "Opret konto", + "back_to_login": "Til registreringen" + }, + "invitation": { + "error_invalid_key": "Invitationslinket er kryptografisk ugyldigt (nøglen er forkert).", + "error_missing_key": "Invitationslinket indeholder ikke en dekrypteringsnøgle (#key=...). Brug venligst det fulde link fra ejeren.", + "error_expired": "Denne invitation er udløbet (gyldig i 48 timer).", + "error_invalid_token": "Invitationstokenet er ugyldigt.", + "error_load_failed": "Invitationsoplysningerne kunne ikke indlæses.", + "error_incomplete_session": "Session ufuldstændig - log venligst ind igen (bruger-ID mangler).", + "error_accept_failed": "Tiltrædelse mislykkedes.", + "error_login_failed": "Passkey Login mislykkedes.", + "error_username_missing": "Brugernavnet kunne ikke bestemmes - log venligst ind igen.", + "error_register_failed": "Registrering mislykkedes.", + "loading_joining": "At slutte sig til...", + "loading_checking": "Invitation vil blive tjekket...", + "loading_unlocking": "Logbogen er låst op og synkroniseret...", + "loading_retrieving_key": "Download krypteringsnøgle...", + "error_title": "Fejl i invitation", + "back_to_start": "Tilbage til start", + "title": "Invitation til logbog", + "invited_by": "Invitation fra", + "vessel_logbook": "Skib / Logbog", + "signed_in_preparing": "Registreret som {{username}}. Tilslutning er ved at blive forberedt...", + "join_again": "Deltag igen", + "login_or_register_hint": "Log ind eller opret en konto for at deltage i logbogen.", + "or_sign_up": "ELLER REGISTRER DIG IGEN", + "register_crew_account": "Opret en ny crew-konto", + "username_label": "Brugernavn", + "create_passkey": "Opret Passkey.", + "switch_language_en": "Engelsk", + "switch_language_de": "Tysk" + }, + "stats": { + "title": "Statistik", + "subtitle": "Overblik over ruter, forbrug og kørselstype", + "scope_label": "Evalueringsområde", + "scope_logbook": "Denne logbog", + "scope_account": "Alle logbøger", + "loading": "Statistikkerne er beregnet...", + "no_data": "Ingen rejsedage tilgængelige endnu.", + "total_distance": "Samlet afstand", + "travel_days": "Rejsedage", + "sail_distance": "Under sejl", + "motor_distance": "Kørsel med maskine", + "motor_hours_total": "Samlet antal maskintimer", + "daily_motor_hours": "Maskintimer pr. rejsedag", + "avg_motor_hours": "Ø maskintimer pr. rejsedag", + "unknown_propulsion": "Ukendt", + "fuel_total": "Brændstof i alt", + "water_total": "Vand i alt", + "daily_etmal": "Daglige tider", + "daily_consumption": "Dagligt forbrug", + "route_overview": "Rute", + "route_map_title": "Oversigt over ruter", + "propulsion_title": "Sejl vs. maskine", + "propulsion_hint": "Opdelingen er baseret på logbogshændelser pr. rejsedag, ikke på GPS-segmenter.", + "avg_distance": "Ø pr. rejsedag", + "avg_fuel": "Ø Brændstof", + "avg_water": "Ø Vand", + "fuel_per_nm": "Brændstof pr. sm", + "fuel_per_motor_hour": "Brændstof pr. maskintime", + "daily_fuel_per_motor_hour": "Brændstofforbrug pr. maskintime pr. rejsedag", + "fuel_legend": "Brændstof", + "water_legend": "Vand", + "unit_nm": "sm", + "unit_h": "h", + "unit_l": "L", + "day_label": "Dag {{day}}", + "account_logbooks": "Et overblik over logbøger", + "col_logbook": "Logbog" + }, + "tour": { + "skip": "Spring turen over", + "back": "Tilbage", + "next": "Yderligere", + "finish": "Klar", + "progress": "Trin {{current}} fra {{total}}.", + "steps": { + "welcome": { + "title": "Velkommen om bord!", + "body": "Vi har lavet en demo-logbog med tre dages rejse i Kielerfjorden til dig. Du kan til enhver tid slette prøveposterne, hvis du vil starte din egen logbog. Denne korte rundvisning viser dig de vigtigste funktioner." + }, + "welcome_public": { + "title": "Velkommen om bord!", + "body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden - uden en konto. Denne korte tur viser dig skibsdata, besætning og logbogsposter." + }, + "nav_logs": { + "title": "Indlæg i logbogen", + "body": "Det er her, du styrer dine rejsedage - afgang, destination, vejr, brændstofniveau og GPS-spor." + }, + "entry_list": { + "title": "Dine rejsedage", + "body": "Hvert kort repræsenterer en rejsedag. Tryk på en post for at se eller redigere detaljer." + }, + "entry_open": { + "title": "Åben rejsedag", + "body": "Sådan ser et udfyldt logbogsnotat ud - med begivenheder, tankniveauer og meget mere." + }, + "entry_track": { + "title": "GPS-spor", + "body": "Upload GPX-filer, eller se allerede gemte ruter på kortet - inklusive afstand og hastighed." + }, + "nav_vessel": { + "title": "Skibsdata", + "body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage." + }, + "nav_crew": { + "title": "Besætningsliste", + "body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere." + }, + "nav_stats": { + "title": "Statistik-dashboard", + "body": "Her kan du se kørselsafstande, brændstofforbrug, rutekort og kørselsandele - automatisk beregnet ud fra dine logbogsnotater." + }, + "nav_feedback": { + "title": "Send feedback", + "body": "Du kan bruge denne formular til at sende fejl, ideer eller generel feedback direkte til projektteamet - også efter rundvisningen, når som helst ved hjælp af ikonet øverst til højre." + }, + "nav_profile": { + "title": "Din brugerprofil", + "body": "Du kan få adgang til din personlige profil via skipperknappen øverst - uanset den aktuelle logbog." + }, + "profile_preferences": { + "title": "Regnskab og præsentation", + "body": "Her kan du administrere din kontoidentitet, tema og lys/mørk tilstand. Du kan til enhver tid genstarte app-turen. Passkeys og sikkerhedsindstillinger findes længere nede i profilen." + }, + "finish": { + "title": "Okay!", + "body": "Du vil blive ført direkte til statistikdashboardet. Du kan til enhver tid genstarte turen i din brugerprofil. Hav en god tur!" + } + } + }, + "seo": { + "title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)", + "description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, besætnings- og skibsdata - også offline som PWA.", + "keywords": "Yachtlogbog, skibslogbog, logbog om bord, sejlads, Passkey, E2E-kryptering, GPS-spor, maritim logbog, gratis, reklamefri, gratis, uden reklame", + "ogImageAlt": "Kapteins Daagbok Logo" + } + } +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index f614da3..94fa38a 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -6,6 +6,13 @@ "beta": "Beta", "beta_hint": "Beta-Version — Funktionen können sich noch ändern" }, + "languages": { + "de": "Deutsch", + "en": "English", + "da": "Dansk", + "sv": "Svenska", + "nb": "Norsk" + }, "common": { "unsaved_changes_title": "Ungespeicherte Änderungen", "unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 68c710e..4455949 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -6,6 +6,13 @@ "beta": "Beta", "beta_hint": "Beta release — features may still change" }, + "languages": { + "de": "Deutsch", + "en": "English", + "da": "Dansk", + "sv": "Svenska", + "nb": "Norsk" + }, "common": { "unsaved_changes_title": "Unsaved changes", "unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json new file mode 100644 index 0000000..a25f6cf --- /dev/null +++ b/client/src/i18n/locales/nb.json @@ -0,0 +1,735 @@ +{ + "translation": { + "app": { + "name": "Kapteins Daagbok", + "tagline": "Loggbok for private båter", + "beta": "Beta", + "beta_hint": "Betaversjon - funksjoner kan fortsatt endres" + }, + "languages": { + "de": "Deutsch", + "en": "English", + "da": "Dansk", + "sv": "Svenska", + "nb": "Norsk" + }, + "common": { + "unsaved_changes_title": "Ikke-lagrede endringer", + "unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.", + "unsaved_changes_leave": "Oppgivelse", + "unsaved_changes_stay": "Bli" + }, + "nav": { + "dashboard": "Dashbord", + "vessel": "Skipsdata", + "crew": "Mannskapsliste", + "deviation": "Tabell over distraksjoner", + "logs": "Loggbokoppføringer", + "stats": "Statistikk", + "settings": "Innstillinger" + }, + "auth": { + "welcome": "Velkommen til Kapteins Daagbok", + "tagline": "Din sikre, E2E-krypterte maritime loggbok.", + "register": "Registrer deg med Passkey", + "login": "Logg inn med Passkey", + "login_as": "Logg inn som {{name}}", + "quick_login": "Rask innlogging", + "forget_account": "Glemt konto på denne enheten", + "not_user": "Ikke {{name}}?", + "recovery_title": "Gjenopprettingsnøkkelen din", + "recovery_warning": "VIKTIG: Skriv ned disse 12 ordene. Hvis du mister Passkey og disse ordene, kan du ikke gjenopprette dataene dine.", + "confirm_recovery": "Jeg har skrevet ned ordene", + "status_logged_in": "Innlogget", + "status_logged_out": "Avlyst", + "copied": "Oppfattet!", + "copy_phrase": "Kopieringstast", + "enter_recovery": "Skriv inn gjenopprettingsnøkkel", + "recovery_fallback_warning": "Din Passkey har blitt autentisert, men enheten din støtter ikke maskinvarebasert nøkkelderivering. Skriv inn gjenopprettingsnøkkelen på 12 ord for å dekryptere loggboken.", + "recovery_placeholder": "Skriv inn gjenopprettingsnøkkelen din, som består av 12 ord atskilt med mellomrom...", + "back": "Tilbake", + "decrypting": "Dekryptering...", + "decrypt_logbook": "Dekryptere loggbok", + "error_incorrect_recovery": "Feil gjenopprettingsnøkkel. Dekryptering mislyktes.", + "error_decryption_failed": "Dekryptering mislyktes. Vennligst sjekk gjenopprettingsnøkkelen din.", + "or_register": "eller registrer deg", + "explore_demo": "Utforsk demoen uten konto", + "username_placeholder": "Brukernavn / Skippernavn", + "processing": "Behandling...", + "help": "Hjelp", + "setup_pin_title": "Konfigurer lokal PIN-kode (valgfritt)", + "setup_pin_warning": "Siden enheten din ikke støtter direkte Passkey-nøkkelavledning, må du ellers skrive inn 12-ordsnøkkelen hver gang du logger deg på denne enheten. Konfigurer en lokal PIN-kode for å unngå dette.", + "pin_placeholder": "E.G. 123456", + "pin_label": "Lokal PIN-kode (4-8 sifre)", + "save_pin": "Lagre PIN-kode og fortsett", + "skip_pin": "Hopp over og bruk gjenoppretting", + "enter_pin_title": "Dekrypter med PIN-kode", + "enter_pin_warning": "Skriv inn din lokale PIN-kode for å låse opp dekrypteringsnøkkelen på denne enheten.", + "enter_pin_placeholder": "Tast inn PIN-koden din...", + "decrypt_with_pin": "Dekryptere", + "use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet", + "error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes." + }, + "pwa": { + "title": "Installer app", + "generic_benefit": "Installer Kapteins Daagbok på enheten din for raskere tilgang, frakoblet bruk og permanent lagring av data.", + "ios_instructions": "På iPad/iPhone: Legg til appen på startskjermen, slik at loggbokdataene dine forblir beskyttet og appen starter som en vanlig app.", + "ios_step_share": "Trykk på aksjesymbolet i Safari-linjen", + "ios_step_add": "Velg \"Gå til startskjermen\"", + "install_now": "Installer nå", + "installing": "Installasjon...", + "later": "Senere", + "never": "Ikke vis mer", + "platform_ios": "Installasjon via Safari", + "platform_android": "Installasjon via nettleseren", + "platform_desktop": "Installasjon som en desktop-app", + "settings_section": "Installasjon av app", + "update_title": "Oppdatering tilgjengelig", + "update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.", + "update_now": "Oppdater nå", + "update_reloading": "Laster..." + }, + "sync": { + "status_synced": "Synkronisert", + "status_syncing": "Synkroniser...", + "status_offline": "Frakoblet hurtigbuffer", + "status_unsynced": "Usynkroniserte endringer" + }, + "vessel": { + "title": "Stamdata for skip", + "name": "Båtens navn", + "type": "Båttype", + "type_unset": "- ikke spesifisert -", + "type_sailing": "Seilbåt", + "type_motor": "Motorbåt", + "length_m": "Lengde (m)", + "draft_m": "Trekkraft (m)", + "air_draft_m": "Høyde (m)", + "invalid_metric": "Ugyldig tallverdi - angi meter som desimaltall (f.eks. 12,5).", + "port": "Hjemmehavn", + "owner": "Eier", + "charter": "Charterselskap", + "registration": "Nummerskilt/registreringsnummer", + "callsign": "Radiokallesignal", + "atis": "ATIS nr.", + "mmsi": "MMSI-nr.", + "save": "Lagre skipsdata", + "saving": "...vil bli reddet...", + "saved": "Skipsdata vellykket lagret!", + "loading": "Skipsdata er lastet inn...", + "sails_list": "Seil (eksisterende seil)", + "sails_help": "Skriv inn seilene som er tilgjengelige på båten din her (f.eks. storseil, genua, fokk).", + "add_sail": "Legg til seil", + "sail_name_placeholder": "z. f.eks. storseil", + "no_sails": "Ingen seil lagret.", + "photo_add": "Legg til bilde", + "photo_change": "Endre bilde", + "photo_delete": "Slett bilde", + "tanks_section": "Tanker (kapasitet)", + "tanks_help": "Valgfritt i liter - muliggjør glidebryter i tidsskriftet for kjente tankstørrelser.", + "freshwater_capacity_l": "Drikkevann (liter)", + "fuel_capacity_l": "Drivstoff (liter)", + "greywater_capacity_l": "Gråvann (liter)", + "invalid_tank_liters": "Ugyldig tallverdi - skriv inn liter som et tall (f.eks. 200)." + }, + "logs": { + "title": "Loggbokdagbok", + "new_entry": "Ny reisedag", + "travel_details": "Detaljer om reisen", + "add_event": "Legg til ny loggbokoppføring", + "add_event_btn": "Legg til hendelse", + "edit_event": "Rediger hendelse", + "save_event_btn": "Lagre endring", + "cancel_event_edit": "Avbryt", + "delete_event": "Slett hendelse", + "sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet", + "sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.", + "date": "dato", + "day_of_travel": "Reisens dag / reisedag", + "departure": "Starthavn (reise fra)", + "destination": "Destinasjonsport (til)", + "route": "Reise fra/til", + "freshwater": "Ferskvann (liter)", + "fuel": "Drivstoff / Drivstoff (liter)", + "greywater": "Gråvann (liter)", + "greywater_level": "Fyllingsnivå", + "tank_slider_of_max": "{{current}} / {{max}} L", + "tank_capacity_tooltip": "Hvis tankkapasiteten (liter) er lagret i skipsdataene, kan du angi fyllingsnivåene her ved hjelp av glidebryteren.", + "morning": "Stå opp om morgenen", + "refilled": "Påfyllt", + "evening": "Kveldsstand", + "consumption": "Daglig forbruk", + "signatures": "Underskrifter / frigivelse", + "sign_skipper": "Skippers signatur", + "sign_crew": "Mannskapets signatur", + "sign_hint": "Signer med finger, penn eller mus", + "sign_clear": "Slett", + "sign_export_image": "[Signatur]", + "sign_with_passkey": "Utgivelse med Passkey", + "sign_passkey_signing": "Passkey er forespurt...", + "sign_passkey_signed": "Utgitt av {{username}}", + "sign_passkey_export": "Passkey: {{username}} ({{date}})", + "sign_attribution_export": "{{username}} ({{date}})", + "sign_passkey_clear": "Fjern Passkey utgivelse", + "sign_mode_passkey": "Passkey", + "sign_mode_classic": "Klassisk", + "sign_passkey_failed": "Passkey Utgivelsen mislyktes", + "sign_passkey_cancelled": "Passkey Utgivelse kansellert", + "sign_invalid": "Signaturen er ugyldig - innholdet har blitt endret", + "sign_badge_skipper": "Skipper", + "sign_badge_skipper_invalid": "Ugyldig", + "sign_badge_skipper_title_valid": "Skipper har gitt ut", + "sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret", + "sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor", + "sign_crew_passkey_hint": "Besetningsmedlemmer med skrivetilgang kan frigjøre via Passkey.", + "sign_offline_hint": "Passkey-Godkjenning krever Internett - klassisk signatur mulig offline", + "sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.", + "sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og mannskapets signaturer.", + "sign_lock_warning_title": "Bekreft signatur", + "sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.\n\nØnsker du å fortsette?", + "sign_proceed": "Skilt", + "sign_cancel": "Avbryt", + "sign_cleared_re_sign_title": "Signaturer fjernet", + "sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og mannskapets signaturer er fjernet. Vennligst signer på nytt.", + "no_entries": "Ingen loggbokoppføringer funnet for denne båten. Lag din første seilasdag!", + "back_to_list": "Tilbake til tidsskriftlisten", + "save": "Lagre loggbokside", + "saving": "...vil bli reddet...", + "saved": "Loggboksiden er vellykket lagret!", + "loading": "Tidsskriftet lastes inn...", + "delete_entry": "Slett tagg", + "delete_confirm": "Er du sikker på at du vil slette denne reisedagen permanent?", + "carry_over_tanks_title": "Overføre data fra dagen før?", + "carry_over_tanks_confirm": "Overta starthavn, ferskvann, drivstoff og gråvann fra startnivåene fra siste dag på turen?\n\nStart havn: {{departure}}\nFerskvann: {{fw}} L\nDrivstoff: {{fuel}} L\nGråvann: {{greywater}} L", + "carry_over_tanks_yes": "Ta over", + "carry_over_tanks_no": "Begynn med 0", + "event_title": "Kronologisk hendelseslogg", + "no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.", + "event_time": "Tid på døgnet", + "event_mgk": "MgK-kurs", + "event_rwk": "RwK-kurs", + "event_course_section": "Kurs", + "course_dial_hint": "Vri ringen eller angi grader", + "course_dial_step_label": "Trinnstørrelse", + "course_step_fine": "1°", + "course_step_medium": "5°", + "course_step_coarse": "10°", + "course_tab_mgk": "MgK", + "course_tab_rwk": "rwK", + "course_invalid": "Ugyldig kurs (0-360)", + "course_placeholder_degrees": "z. B. 180", + "course_placeholder_cardinal": "z. E.G. NW", + "compass_n": "N", + "compass_e": "O", + "compass_s": "S", + "compass_w": "W", + "wind_mode_cardinal": "Kardinal", + "wind_mode_degrees": "Som grad", + "event_wind_direction": "Vindretning", + "event_wind_strength": "Vindstyrke", + "event_sea_state": "Havets tilstand", + "event_weather": "Været", + "event_log": "Logg (sm)", + "event_gps": "GPS-posisjon", + "event_location": "Sted / havn", + "event_location_placeholder": "z. f.eks. Kiel", + "event_remarks": "Merknader / hendelser", + "gps_btn": "Hent GPS-koordinater", + "weather_btn": "OpenWeatherMap Ring opp været", + "event_wind_pressure": "Lufttrykk (hPa)", + "event_heel": "Helning (°)", + "event_sails": "Seilhåndtering / motor", + "motor_propulsion": "Maskinreise", + "sails_picker_show_more": "Vis alle seil", + "sails_picker_show_less": "Vis mindre", + "motor_hours": "Maskintimer (totalt)", + "fuel_per_motor_hour": "Forbruk per maskintime", + "event_distance": "Avstand (sm)", + "export_csv": "Last ned CSV", + "share_csv": "CSV andel", + "export_pdf": "Last ned PDF", + "exporting_pdf": "PDF genereres...", + "photos_title": "Bildevedlegg (E2E-kryptert)", + "photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)", + "photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen", + "photo_btn": "Ta bilde / last opp", + "photo_processing": "...blir behandlet...", + "no_photos": "Ingen bilder knyttet til denne reisedagen ennå.", + "photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?", + "confirm_yes": "Ja", + "confirm_no": "Nei", + "track_upload_title": "GPS-sporing (fil)", + "track_upload_points": "Poeng", + "gps_tracking_btn_gpx": "Last ned sporfil", + "gps_track_upload_help": "Dra en GPX-, KML- eller GeoJSON-fil hit, eller klikk for å velge", + "gps_track_upload_btn": "Last opp GPS-spor", + "gps_track_delete": "Slett sporfil", + "gps_track_delete_confirm": "Er du sikker på at du vil slette denne sporfilen permanent?", + "track_distance": "GPS-rute (sm)", + "track_speed_max": "Maks. Hastighet (kn)", + "track_speed_avg": "Ø Hastighet (kn)", + "track_map_title": "GPS-spor på OpenSeaMap", + "track_map_start": "Start", + "track_map_end": "Mål", + "track_map_speed_slow": "langsomt", + "track_map_speed_fast": "raskt", + "track_map_error": "Kartet kunne ikke lastes inn.", + "exporting": "Eksport...", + "share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.", + "invite_crew": "Inviter mannskapet", + "invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!", + "invite_link_desc": "Del denne lenken med besetningsmedlemmene for å gi dem skrivetilgang til loggboken.", + "collaborators_list": "Medlemmer / Besetning", + "revoke": "Fjern", + "revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?", + "invite_role": "Rolle", + "invite_expires": "Lenken er gyldig i 48 timer" + }, + "dashboard": { + "title": "Loggbøkene dine", + "subtitle": "Velg en loggbok eller opprett en ny for å administrere reisene dine.", + "create_btn": "Opprett loggbok", + "new_logbook_placeholder": "Navn på loggboken eller båten", + "logout": "Logg ut", + "logged_in_as": "Innlogget som {{name}}", + "delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok.json) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.", + "no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!", + "loading": "Loggbøker er lastet...", + "status_synced": "Synkronisert", + "status_local": "Kun lokal hurtigbuffer", + "delete_btn": "Slett loggbok", + "section_owned": "Loggbøkene mine", + "section_shared": "Felles loggbøker", + "section_shared_hint": "Du er invitert som besetningsmedlem. Skipperprofil og innstillinger tilhører eieren.", + "role_owner": "Egen loggbok", + "role_owner_hint": "Du er eier og skipper av denne loggboken", + "role_crew": "Tilgang for mannskapet", + "role_crew_hint": "Loggbok med invitasjon - du kan jobbe som mannskap og signere den", + "role_read": "Bare les", + "role_read_hint": "Delt loggbok - kun visning, ingen redigering", + "open_profile": "Åpne profilen til {{name}}", + "edit_title": "Endre navn på loggbok", + "edit_placeholder": "Nytt navn på loggboken", + "edit_success": "Loggboken har fått nytt navn", + "edit_btn": "Gi nytt navn", + "filter_label": "Filtrer loggbøker", + "filter_placeholder": "Navn, årstall eller dato ...", + "filter_clear": "Tilbakestill filter", + "filter_results": "{{count}} Treff", + "filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.", + "sort_label": "Sortere", + "sort_by_label": "Sorter etter", + "sort_by_name": "Navn", + "sort_by_date": "dato", + "sort_dir_label": "Sekvens", + "sort_asc": "Stigende", + "sort_desc": "Synkende", + "sort_name_asc": "Navn A til Å", + "sort_name_desc": "Navn Z til A", + "sort_date_asc": "Eldst først", + "sort_date_desc": "Nyeste først" + }, + "profile": { + "title": "Brukerprofil", + "subtitle": "Regnskap, Passkeys og statistikk for {{name}}.", + "back": "Tilbake til dashbordet", + "loading": "Profilen lastes inn...", + "load_error": "Profilen kunne ikke lastes inn.", + "copy_failed": "Kopien mislyktes.", + "processing": "Blir behandlet...", + "identity_title": "Kontoidentitet", + "username": "Brukernavn", + "user_id": "Bruker-ID", + "copy_user_id": "Kopier bruker-ID", + "account_since": "Konto siden", + "prf_status": "Passkey nøkkelavledning (PRF)", + "prf_active": "Aktiv", + "prf_inactive": "Ikke satt opp", + "passkeys_title": "Passkeys", + "passkeys_desc": "Registrer en separat Passkey på hver enhet. Dette gjør at du kan logge på selv etter at du har byttet plattform.", + "passkeys_empty": "Ingen Passkeyer funnet.", + "add_passkey_btn": "Legg til ny Passkey", + "add_passkey_success": "Passkey vellykket lagt til.", + "add_passkey_failed": "Passkey kunne ikke legges til.", + "remove_passkey_btn": "Fjern Passkey", + "remove_passkey_last_title": "Sist Passkey", + "remove_passkey_last_desc": "Den eneste Passkey kan ikke fjernes uten at du mister tilgangen til kontoen din. For å slette kontoen helt, bruk faresonen nederst på denne siden.", + "remove_passkey_failed": "Passkey kunne ikke fjernes.", + "remove_passkey_confirm_title": "Fjern Passkey?", + "remove_passkey_confirm_desc": "Denne enheten kan da ikke lenger logge inn med denne Passkey.", + "remove_passkey_confirm_yes": "Fjern", + "remove_passkey_confirm_no": "Avbryt", + "pin_title": "Lokal PIN-kode", + "pin_status": "Status", + "pin_active": "Aktiv på denne enheten", + "pin_inactive": "Ikke satt opp", + "pin_confirm_label": "Bekreft PIN-kode", + "pin_confirm_placeholder": "Tast inn PIN-koden på nytt", + "pin_set_btn": "Konfigurer PIN-kode", + "pin_change_btn": "Endre PIN-kode", + "pin_remove_btn": "Fjern PIN-kode", + "pin_saved": "PIN-kode lagret.", + "pin_save_failed": "PIN-koden kunne ikke lagres.", + "pin_mismatch": "PIN-kodene stemmer ikke overens.", + "pin_length_error": "PIN-koden må bestå av minst 4 tegn.", + "pin_no_session": "Økten er utløpt - vennligst registrer deg på nytt.", + "remove_pin_confirm_title": "Fjerne PIN-kode?", + "remove_pin_confirm_desc": "Du må logge på igjen på denne enheten med Passkey eller gjenopprettingsnøkkel.", + "remove_pin_confirm_yes": "Fjern PIN-kode", + "remove_pin_confirm_no": "Avbryt", + "security_title": "Sjekkliste for sikkerhet", + "security_desc": "Oversikt over de viktigste beskyttelsesmekanismene for kontoen din.", + "security_passkeys_ok": "Minst én Passkey registrert", + "security_passkeys_missing": "Nei Passkey registrert", + "security_prf_ok": "PRF-nøkkelavledning aktiv", + "security_prf_missing": "PRF ikke satt opp", + "security_pin_ok": "Lokal PIN-kode på denne enheten", + "security_pin_missing": "Ingen lokal PIN-kode", + "security_recovery_ok": "Oppsett av gjenopprettingsnøkkel", + "security_recovery_hint": "De 12 ordene ble vist under registreringen. Oppbevar dem frakoblet og adskilt fra enheten. Du kan opprette en ny nøkkel nedenfor - den gamle blir da ugyldig.", + "recovery_rotate_btn": "Opprett en ny gjenopprettingsnøkkel", + "recovery_rotate_confirm_title": "Opprette en ny gjenopprettingsnøkkel?", + "recovery_rotate_confirm_desc": "Den forrige 12-ordsnøkkelen blir ugyldig umiddelbart. Sørg for at du oppbevarer den nye nøkkelen trygt før du fortsetter.", + "recovery_rotate_confirm_yes": "Opprett ny nøkkel", + "recovery_rotate_confirm_no": "Avbryt", + "recovery_rotate_new_warning": "VIKTIG: Skriv ned disse 12 ordene og oppbevar dem offline. Den forrige gjenopprettingsnøkkelen er nå ugyldig.", + "recovery_rotate_failed": "Gjenopprettingsnøkkel kunne ikke opprettes.", + "recovery_rotate_no_session": "Krypteringsøkten er utløpt - logg ut og logg inn igjen, og prøv deretter på nytt.", + "device_title": "Denne enheten", + "device_desc": "Lokal hurtigbuffer, synkroniseringsstatus og hurtigpålogging i denne nettleseren.", + "device_sync_pending": "{{count}} ventende synkroniseringsoppføringer", + "device_sync_ok": "Alle lokale endringer synkroniseres", + "device_remembered": "Konto for hurtiginnlogging lagret på denne enheten", + "device_not_remembered": "Kontoen er ikke i hurtiginnloggingslisten", + "device_forget_btn": "Glemt konto på denne enheten", + "device_forget_confirm_title": "Fjerne hurtiginnlogging?", + "device_forget_confirm_desc": "Kontoen forsvinner fra hurtiginnloggingslisten på denne enheten. Økten og de lokale loggbøkene beholdes.", + "device_forget_confirm_yes": "Fjern", + "device_forget_confirm_no": "Avbryt", + "passkey_label": "Navn på ny Passkey (valgfritt)", + "passkey_label_placeholder": "z. f.eks. MacBook, iPhone", + "passkey_rename_btn": "Lagre navn", + "passkey_rename_success": "Passkey navn lagret.", + "passkey_rename_failed": "Passkey-Navn kunne ikke lagres.", + "passkey_unnamed": "Uten tittel Passkey", + "stats_title": "Statistikk", + "stats_subtitle": "Om alle loggbøkene dine på denne enheten", + "stats_logbooks": "Loggbøker", + "stats_account_since": "Konto siden", + "stats_shared_logbooks": "Felles loggbøker", + "appearance_title": "App og visualisering", + "appearance_desc": "Designet og fargevalget gjelder for hele appen på denne enheten.", + "theme_label": "Appens designstil", + "theme_auto": "Automatisk (OS-deteksjon)", + "theme_ocean": "Ocean (glassmorfisme)", + "theme_material": "Materiale (Android)", + "theme_cupertino": "Cupertino (iOS)", + "color_scheme_label": "Lys eller mørk modus", + "color_scheme_auto": "Automatisk (system)", + "color_scheme_light": "Lys", + "color_scheme_dark": "Mørk", + "integrations_title": "Integrasjoner", + "owm_key": "OpenWeatherMap API-nøkkel", + "owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.", + "prefs_save": "Spar", + "prefs_saving": "...vil bli reddet...", + "prefs_saved": "Reddet", + "tour_title": "App-tur", + "tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.", + "tour_restart": "Start turen på nytt", + "push_title": "Push-varsler", + "push_desc": "Som loggbokseier vil du bli varslet når inviterte besetningsmedlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.", + "push_enable": "Gi oss beskjed om endringer i mannskapet", + "push_active": "Push-varsler er aktive på denne enheten.", + "push_unsupported": "Push-varsler støttes ikke i denne nettleseren.", + "push_denied_hint": "Varsler er blokkert. Tillat dem i innstillingene i nettleseren eller på enheten.", + "push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.", + "push_error": "Push-varsler kunne ikke aktiveres." + }, + "crew": { + "title": "Skipper- og mannskapsprofiler", + "skipper_section": "Skipperprofil", + "skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.", + "crew_section": "Mannskapsliste", + "add_crew": "Legg til besetningsmedlem", + "edit_crew": "Rediger besetningsmedlem", + "no_crew": "Ingen besetningsmedlemmer er lagt til ennå.", + "max_crew": "Maksimalt antall på 5 besetningsmedlemmer er nådd.", + "name": "Navn", + "address": "adresse", + "birthdate": "Bursdag", + "phone": "Telefonnummer", + "nationality": "Nasjonalitet", + "passport": "Pass-/ID-nummer", + "bloodtype": "Blodgruppe", + "allergies": "Allergier", + "diseases": "Eksisterende tilstander/sykdommer", + "save": "Lagre skipperdata", + "save_member": "Lagre medlem", + "saved": "Skipperprofilen er vellykket lagret!", + "loading": "Mannskapsfilene er lastet inn...", + "delete_confirm": "Er du sikker på at du vil fjerne dette besetningsmedlemmet?" + }, + "deviation": { + "title": "Tabell over kompassavvik", + "subtitle": "Angi den magnetiske kompassavbøyningen (avbøyning) for kurser (MgK) fra 000° til 360° i trinn på 10°.", + "heading": "MgK", + "deviation": "Distraksjon", + "save": "Lagre kalibreringsrutenettet", + "saving": "...vil bli reddet...", + "saved": "Kalibreringsrutenettet er vellykket lagret!", + "loading": "Kalibreringstabellen er lastet inn..." + }, + "settings": { + "title": "Innstillinger for loggbok", + "subtitle": "Del, sikkerhetskopier og samarbeid for denne loggboken.", + "select_logbook_hint": "Velg en loggbok for å redigere innstillingene.", + "no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.", + "weather_success": "Værdata vellykket hentet!", + "weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.", + "weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.", + "gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.", + "share_title": "Del loggbok (skrivebeskyttet)", + "share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og mannskapet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).", + "share_privacy_warning": "Anbefaling: Del denne lenken kun privat (f.eks. via e-post eller messenger), ikke på sosiale medier.", + "share_enable": "Aktiver offentlig lenke", + "share_copied": "Linken er kopiert!", + "share_copy_btn": "Kopier lenke", + "danger_zone_title": "Faresone", + "danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.", + "delete_account_btn": "Slett konto ugjenkallelig", + "delete_account_confirm_title": "Slett konto?", + "delete_account_confirm_desc": "Er du helt sikker på at du vil slette kontoen din og alle tilknyttede loggbøker og E2E-krypterte data ugjenkallelig?", + "delete_account_confirm_yes": "Ja, slett konto og alle data", + "delete_account_confirm_no": "Avbryt", + "delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.", + "delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.", + "deleting_account": "Kontoen vil bli slettet...", + "invite_push_prompt_title": "Aktivere push-varsler?", + "invite_push_prompt_message": "Så snart inviterte besetningsmedlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.", + "invite_push_prompt_ios_message": "Så snart besetningsmedlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.", + "invite_push_prompt_enable": "Aktiver nå", + "invite_push_prompt_later": "Senere", + "invite_push_prompt_success": "Push-varsler er aktive på denne enheten.", + "backup_title": "Sikkerhetskopiering og gjenoppretting", + "backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, mannskap, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.", + "backup_export_title": "Opprett sikkerhetskopi", + "backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.", + "backup_restore_title": "Gjenopprett sikkerhetskopi", + "backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.", + "backup_passphrase": "Passord for sikkerhetskopiering", + "backup_passphrase_placeholder": "Minst 8 tegn", + "backup_passphrase_confirm": "Bekreft passordfrasen", + "backup_passphrase_short": "Passordfrasen for sikkerhetskopiering må være på minst 8 tegn.", + "backup_passphrase_mismatch": "Passordfraser stemmer ikke overens.", + "backup_wrong_passphrase": "Passordfrasen er feil eller sikkerhetskopien er ødelagt.", + "backup_export_btn": "Last ned sikkerhetskopi", + "backup_exporting": "Sikkerhetskopien er opprettet...", + "backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).", + "backup_file_label": "Sikkerhetskopifil (.daagbok.json)", + "backup_preview_btn": "Sjekk innhold", + "backup_previewing": "Sjekk...", + "backup_restore_btn": "Gjenopprett", + "backup_restoring": "Vil bli restaurert...", + "backup_restore_success": "Loggbok \"{{title}}\" er gjenopprettet.", + "backup_restore_cancelled": "Gjenoppretting avlyst.", + "backup_invalid_json": "Filen er ikke en gyldig JSON-fil.", + "backup_invalid_format": "Ukjent eller utdatert sikkerhetskopiformat.", + "backup_not_owner": "Det er bare eieren av loggboken som kan opprette sikkerhetskopier.", + "backup_not_authenticated": "Vennligst logg inn for å gjenopprette en sikkerhetskopi.", + "backup_id_conflict": "Det finnes allerede en loggbok med denne ID-en.", + "backup_overwrite_confirm": "Den eksisterende loggboken med samme ID erstattes. Fortsette?", + "backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?", + "backup_stat_entries": "{{count}} Reisedager", + "backup_stat_photos": "{{count}} Bilder", + "backup_stat_crew": "{{count}} Mannskapsposter", + "backup_stat_tracks": "{{count}} GPS-spor", + "backup_exported_at": "Eksportert: {{date}}" + }, + "disclaimer": { + "title": "Viktige merknader", + "intro": "Vennligst les følgende instruksjoner før du bruker Kapteins Daagbok.", + "e2e_title": "Ende-til-ende-kryptering", + "e2e_body": "Loggbokdataene dine er kryptert fra ende til ende. Bare du - eller personer med din nøkkel - kan lese innholdet. Kun krypterte data lagres på serveren.", + "pwa_title": "Progressiv webapp (PWA)", + "pwa_body": "Kapteins Daagbok kjører som en progressiv webapp i nettleseren din og kan installeres på enheten din - på samme måte som en native-app, men uten en appbutikk.", + "storage_title": "Lokal lagring og synkronisering", + "storage_body": "Dataene dine lagres lokalt på enheten din (IndexedDB). Endringer synkroniseres med serveren når en Internett-tilkobling er aktiv. Du kan fortsette å jobbe uten tilkobling, synkroniseringen skjer senere.", + "free_title": "Gratis og reklamefri", + "free_body": "Kapteins Daagbok er gratis og inneholder ingen reklame.", + "liability_title": "Ansvarsfraskrivelse", + "liability_body": "Bruk av appen skjer på eget ansvar. Vi fraskriver oss ethvert ansvar for skader som oppstår som følge av bruk av appen - inkludert feilaktige eller ufullstendige loggbokoppføringer, tap av data eller tekniske feil.", + "warranty_title": "Ingen garanti", + "warranty_body": "Det gis ingen garanti for tjenestens funksjon, korrekthet eller tilgjengelighet. Driften kan når som helst bli avbrutt, begrenset eller kansellert.", + "copyright": "© 2026 KnorrLabs, Markus F.J. Busche", + "accept": "Godta og fortsett", + "close": "Lukk", + "button_title": "Merknader og ansvarsfraskrivelse" + }, + "feedback": { + "button_title": "Send tilbakemelding", + "title": "Tilbakemeldinger", + "intro": "Del feil, ideer eller generelle tilbakemeldinger. Meldingen din vil bli sendt til prosjektteamet via en sikker varslingskanal.", + "category_label": "Kategori", + "category_general": "Generelt", + "category_bug": "Rapporter feil", + "category_feature": "Forespørsel om funksjonalitet", + "contact_label": "E-post (valgfritt)", + "contact_placeholder": "deine@email.beispiel", + "message_label": "Melding", + "message_placeholder": "Beskriv tilbakemeldingene dine...", + "send": "Send", + "sending": "Vil bli sendt...", + "cancel": "Avbryt", + "success": "Tusen takk skal du ha! Tilbakemeldingen din er sendt.", + "error_send": "Tilbakemelding kunne ikke sendes. Vennligst prøv igjen senere.", + "error_invalid_email": "Vennligst skriv inn en gyldig e-postadresse.", + "error_not_configured": "Tilbakemelding er ikke tilgjengelig på denne serveren.", + "error_rate_limited": "For mange tilbakemeldinger på kort tid. Vennligst vent noen minutter.", + "error_spam": "Denne meldingen kunne ikke sendes. Vennligst omformuler den." + }, + "demo": { + "logbook_title": "Demologgbok Østersjøen", + "badge": "Demo", + "public_banner": "Skrivebeskyttet demovisning", + "cta_register": "Opprett konto", + "back_to_login": "Til registreringen" + }, + "invitation": { + "error_invalid_key": "Invitasjonslenken er kryptografisk ugyldig (feil nøkkel).", + "error_missing_key": "Invitasjonslenken inneholder ikke en dekrypteringsnøkkel (#key=...). Vennligst bruk den fullstendige lenken fra eieren.", + "error_expired": "Denne invitasjonen har utløpt (gyldig i 48 timer).", + "error_invalid_token": "Invitasjonstokenet er ugyldig.", + "error_load_failed": "Invitasjonsdetaljer kunne ikke lastes inn.", + "error_incomplete_session": "Økten er ufullstendig - vennligst logg inn på nytt (bruker-ID mangler).", + "error_accept_failed": "Tiltredelse mislyktes.", + "error_login_failed": "Passkey Innlogging mislyktes.", + "error_username_missing": "Brukernavnet ble ikke funnet - vennligst logg inn på nytt.", + "error_register_failed": "Registrering mislyktes.", + "loading_joining": "Bli med...", + "loading_checking": "Invitasjonen vil bli sjekket...", + "loading_unlocking": "Loggboken er låst opp og synkronisert...", + "loading_retrieving_key": "Last ned krypteringsnøkkelen...", + "error_title": "Feil i invitasjonen", + "back_to_start": "Tilbake til start", + "title": "Invitasjon til loggbok", + "invited_by": "Invitasjon fra", + "vessel_logbook": "Skip / Loggbok", + "signed_in_preparing": "Registrert som {{username}}. Tilslutning er under forberedelse...", + "join_again": "Bli med igjen", + "login_or_register_hint": "Logg inn eller registrer en konto for å bli med i loggboken.", + "or_sign_up": "ELLER REGISTRER DEG PÅ NYTT", + "register_crew_account": "Opprett en ny crew-konto", + "username_label": "Brukernavn", + "create_passkey": "Opprett Passkey", + "switch_language_en": "Engelsk", + "switch_language_de": "Tysk" + }, + "stats": { + "title": "Statistikk", + "subtitle": "Oversikt over ruter, forbruk og kjøretype", + "scope_label": "Evalueringsområde", + "scope_logbook": "Denne loggboken", + "scope_account": "Alle loggbøker", + "loading": "Statistikken er beregnet...", + "no_data": "Ingen reisedager tilgjengelig ennå.", + "total_distance": "Total avstand", + "travel_days": "Reisedager", + "sail_distance": "Under seil", + "motor_distance": "Maskinreise", + "motor_hours_total": "Totalt antall maskintimer", + "daily_motor_hours": "Maskintimer per reisedøgn", + "avg_motor_hours": "Ø maskintimer per reisedøgn", + "unknown_propulsion": "Ukjent", + "fuel_total": "Totalt drivstoff", + "water_total": "Totalt vann", + "daily_etmal": "Daglige tider", + "daily_consumption": "Daglig forbruk", + "route_overview": "Rute", + "route_map_title": "Oversikt over ruten", + "propulsion_title": "Seil vs. maskin", + "propulsion_hint": "Fordelingen er basert på loggbokhendelser per reisedag, ikke på GPS-segmenter.", + "avg_distance": "Ø per reisedag", + "avg_fuel": "Ø Drivstoff", + "avg_water": "Ø Vann", + "fuel_per_nm": "Drivstoff per sm", + "fuel_per_motor_hour": "Drivstoff per maskintime", + "daily_fuel_per_motor_hour": "Drivstofforbruk per maskintime per kjøredag", + "fuel_legend": "Drivstoff", + "water_legend": "Vann", + "unit_nm": "sm", + "unit_h": "h", + "unit_l": "L", + "day_label": "Dag {{day}}", + "account_logbooks": "Oversikt over loggbøker", + "col_logbook": "Loggbok" + }, + "tour": { + "skip": "Hopp over turen", + "back": "Tilbake", + "next": "Videre", + "finish": "Ferdig", + "progress": "Trinn {{current}} fra {{total}}", + "steps": { + "welcome": { + "title": "Velkommen om bord!", + "body": "Vi har laget en demo-loggbok med tre dagers reise i Kielfjorden for deg. Du kan når som helst slette eksempeloppføringene hvis du vil starte din egen loggbok. Denne korte omvisningen viser deg de viktigste funksjonene." + }, + "welcome_public": { + "title": "Velkommen om bord!", + "body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden - uten konto. Denne korte omvisningen viser deg skipsdata, mannskap og loggbokoppføringer." + }, + "nav_logs": { + "title": "Loggbokoppføringer", + "body": "Her administrerer du reisedagene dine - avreise, destinasjon, vær, drivstoffnivå og GPS-spor." + }, + "entry_list": { + "title": "Dine reisedager", + "body": "Hvert kort representerer en reisedag. Trykk på en oppføring for å vise eller redigere detaljer." + }, + "entry_open": { + "title": "Åpen reisedag", + "body": "Slik ser en fullført loggbok ut - med hendelser, tanknivåer og mer." + }, + "entry_track": { + "title": "GPS-sporing", + "body": "Last opp GPX-filer eller se allerede lagrede ruter på kartet - inkludert avstand og hastighet." + }, + "nav_vessel": { + "title": "Skipsdata", + "body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager." + }, + "nav_crew": { + "title": "Mannskapsliste", + "body": "Administrer mannskapet og tilordne dem til reisedager senere." + }, + "nav_stats": { + "title": "Dashbord for statistikk", + "body": "Her kan du se kjørelengder, drivstofforbruk, rutekart og kjøreandeler - automatisk beregnet ut fra loggbokoppføringene dine." + }, + "nav_feedback": { + "title": "Send tilbakemelding", + "body": "Du kan bruke dette skjemaet til å sende feil, ideer eller generelle tilbakemeldinger direkte til prosjektteamet - også etter omvisningen, når som helst ved hjelp av ikonet øverst til høyre." + }, + "nav_profile": { + "title": "Din brukerprofil", + "body": "Du får tilgang til din personlige profil via skipperknappen øverst - uavhengig av hvilken loggbok du bruker." + }, + "profile_preferences": { + "title": "Regnskap og presentasjon", + "body": "Her kan du administrere kontoidentitet, tema og lys/mørk modus. Du kan når som helst starte appturen på nytt. Passkeys og sikkerhetsinnstillinger finner du lenger ned i profilen." + }, + "finish": { + "title": "Greit!", + "body": "Du kommer rett til statistikkoversikten. Du kan når som helst starte turen på nytt i brukerprofilen din. Ha en riktig god tur!" + } + } + }, + "seo": { + "title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)", + "description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, mannskaps- og skipsdata på en sikker måte - også offline som PWA.", + "keywords": "Yachtloggbok, skipsloggbok, loggbok om bord, seiling, Passkey, E2E-kryptering, GPS-sporing, maritim loggbok, gratis, reklamefri, gratis, uten reklame", + "ogImageAlt": "Kapteins Daagbok Logo" + } + } +} diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json new file mode 100644 index 0000000..c76a35c --- /dev/null +++ b/client/src/i18n/locales/sv.json @@ -0,0 +1,735 @@ +{ + "translation": { + "app": { + "name": "Kapteins Daagbok", + "tagline": "Loggbok för privat yacht", + "beta": "Beta", + "beta_hint": "Betaversion - funktioner kan fortfarande ändras" + }, + "languages": { + "de": "Deutsch", + "en": "English", + "da": "Dansk", + "sv": "Svenska", + "nb": "Norsk" + }, + "common": { + "unsaved_changes_title": "Osparade ändringar", + "unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.", + "unsaved_changes_leave": "Övergivande", + "unsaved_changes_stay": "Stanna kvar" + }, + "nav": { + "dashboard": "Instrumentpanel", + "vessel": "Fartygsdata", + "crew": "Besättningslista", + "deviation": "Distraktionsbord", + "logs": "Loggboksanteckningar", + "stats": "Statistik", + "settings": "Inställningar" + }, + "auth": { + "welcome": "Välkommen till Kapteins Daagbok", + "tagline": "Din säkra, E2Ekrypterade loggbok för sjöfarten.", + "register": "Registrera dig med Passkey", + "login": "Logga in med Passkey", + "login_as": "Logga in som {{name}}", + "quick_login": "Snabb inloggning", + "forget_account": "Glömt konto på den här enheten", + "not_user": "Inte {{name}}?", + "recovery_title": "Din återställningsnyckel", + "recovery_warning": "VIKTIGT: Skriv ner dessa 12 ord. Om du förlorar din Passkey och dessa ord kan dina data inte återställas.", + "confirm_recovery": "Jag har skrivit ner orden", + "status_logged_in": "Inloggad", + "status_logged_out": "Avbruten", + "copied": "Kopierat!", + "copy_phrase": "Kopiera tangent", + "enter_recovery": "Ange återställningsnyckel", + "recovery_fallback_warning": "Din Passkey har autentiserats, men din enhet stöder inte maskinvarubaserad nyckelavledning. Ange din återställningsnyckel på 12 ord för att dekryptera din loggbok.", + "recovery_placeholder": "Ange din återställningsnyckel som består av 12 ord åtskilda av mellanslag...", + "back": "Tillbaka", + "decrypting": "Dekryptering...", + "decrypt_logbook": "Dekryptera loggbok", + "error_incorrect_recovery": "Felaktig återställningsnyckel. Dekryptering misslyckades.", + "error_decryption_failed": "Dekrypteringen misslyckades. Vänligen kontrollera din återställningsnyckel.", + "or_register": "eller registrera dig", + "explore_demo": "Utforska demoversionen utan konto", + "username_placeholder": "Användarnamn / Skepparnamn", + "processing": "Bearbetning...", + "help": "Hjälp", + "setup_pin_title": "Ange lokal PIN-kod (tillval)", + "setup_pin_warning": "Eftersom din enhet inte stöder direkt härledning av Passkey-nycklar måste du annars ange din nyckel på 12 ord varje gång du loggar in på den här enheten. Konfigurera en lokal PIN-kod för att undvika detta.", + "pin_placeholder": "E.G. 123456", + "pin_label": "Lokal PIN-kod (4-8 siffror)", + "save_pin": "Spara PIN-kod och fortsätt", + "skip_pin": "Skip & använd återvinning", + "enter_pin_title": "Dekryptera med PIN-kod", + "enter_pin_warning": "Ange din lokala PIN-kod för att låsa upp dekrypteringsnyckeln på den här enheten.", + "enter_pin_placeholder": "Ange din PIN-kod...", + "decrypt_with_pin": "Dekryptera", + "use_recovery_instead": "Använd återställningsnycklar istället", + "error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades." + }, + "pwa": { + "title": "Installera app", + "generic_benefit": "Installera Kapteins Daagbok på din enhet för snabbare åtkomst, offline-användning och permanent datalagring.", + "ios_instructions": "På iPad/iPhone: Lägg till appen på startskärmen så att dina loggboksdata förblir skyddade och appen startar som en inbyggd app.", + "ios_step_share": "Tryck på aktiesymbolen i fältet Safari.", + "ios_step_add": "Välj \"Gå till startskärmen\"", + "install_now": "Installera nu", + "installing": "Installation...", + "later": "Senare", + "never": "Visa inte mer", + "platform_ios": "Installation via Safari.", + "platform_android": "Installation via webbläsaren", + "platform_desktop": "Installation som en skrivbordsapp", + "settings_section": "Installation av app", + "update_title": "Uppdatering tillgänglig", + "update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.", + "update_now": "Uppdatering nu", + "update_reloading": "Laddar..." + }, + "sync": { + "status_synced": "Synkroniserad", + "status_syncing": "Synkronisera...", + "status_offline": "Offline-cache", + "status_unsynced": "Osynkroniserade förändringar" + }, + "vessel": { + "title": "Masterdata för fartyg", + "name": "Yacht namn", + "type": "Typ av båt", + "type_unset": "- inte specificerad -", + "type_sailing": "Segelyacht", + "type_motor": "Motorbåt", + "length_m": "Längd (m)", + "draft_m": "Djupgående (m)", + "air_draft_m": "Höjd (m)", + "invalid_metric": "Ogiltigt numeriskt värde - ange meter som ett decimaltal (t.ex. 12,5).", + "port": "Hem hamn", + "owner": "Ägare", + "charter": "Charterbolag", + "registration": "Registreringsnummer/registreringsskylt", + "callsign": "Radioanropssignal", + "atis": "ATIS nr.", + "mmsi": "MMSI nr.", + "save": "Spara fartygsdata", + "saving": "Kommer att sparas...", + "saved": "Fartygsdata har sparats framgångsrikt!", + "loading": "Fartygsdata är inlästa...", + "sails_list": "Segel (befintliga segel)", + "sails_help": "Ange här de segel som finns tillgängliga på din båt (t.ex. storsegel, genua, fock).", + "add_sail": "Lägg till segel", + "sail_name_placeholder": "z. t.ex. storsegel", + "no_sails": "Inga segel lagrade.", + "photo_add": "Lägg till foto", + "photo_change": "Ändra foto", + "photo_delete": "Ta bort foto", + "tanks_section": "Tankar (kapacitet)", + "tanks_help": "Valfritt i liter - möjliggör slider i journalen för kända tankstorlekar.", + "freshwater_capacity_l": "Dricksvatten (liter)", + "fuel_capacity_l": "Bränsle (liter)", + "greywater_capacity_l": "Gråvatten (liter)", + "invalid_tank_liters": "Ogiltigt numeriskt värde - ange liter som ett tal (t.ex. 200)." + }, + "logs": { + "title": "Loggboksjournal", + "new_entry": "Ny resdag", + "travel_details": "Detaljer om resan", + "add_event": "Lägg till ny loggbokspost", + "add_event_btn": "Lägg till händelse", + "edit_event": "Redigera händelse", + "save_event_btn": "Spara ändring", + "cancel_event_edit": "Avbryt", + "delete_event": "Ta bort händelse", + "sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen", + "sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.", + "date": "datum", + "day_of_travel": "Resedag / resedag", + "departure": "Starthamn (resa från)", + "destination": "Destinationsport (till)", + "route": "Resa från/till", + "freshwater": "Färskvatten (liter)", + "fuel": "Treibstoff / Bränsle (liter)", + "greywater": "Gråvatten (liter)", + "greywater_level": "Fyllnadsnivå", + "tank_slider_of_max": "{{current}} / {{max}} L", + "tank_capacity_tooltip": "Om tankens kapacitet (liter) finns lagrad i fartygets data kan du ange fyllnadsnivåerna här med hjälp av skjutreglaget.", + "morning": "Stå på morgonen", + "refilled": "Påfylld", + "evening": "Kvällsställ", + "consumption": "Daglig konsumtion", + "signatures": "Underskrifter / frisläppande", + "sign_skipper": "Skepparens signatur", + "sign_crew": "Besättningens signatur", + "sign_hint": "Signera med finger, penna eller mus", + "sign_clear": "Radera", + "sign_export_image": "[Signatur]", + "sign_with_passkey": "Frigör med Passkey", + "sign_passkey_signing": "Passkey begärs...", + "sign_passkey_signed": "Utgiven av {{username}}", + "sign_passkey_export": "Passkey: {{username}} ({{date}})", + "sign_attribution_export": "{{username}} ({{date}})", + "sign_passkey_clear": "Ta bort Passkey release", + "sign_mode_passkey": "Passkey", + "sign_mode_classic": "Klassisk", + "sign_passkey_failed": "Passkey Frigöring misslyckades", + "sign_passkey_cancelled": "Passkey Frigörandet inställt", + "sign_invalid": "Signaturen är ogiltig - innehållet har ändrats", + "sign_badge_skipper": "Skeppare", + "sign_badge_skipper_invalid": "Ogiltig", + "sign_badge_skipper_title_valid": "Skepparen har släppt", + "sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats", + "sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan", + "sign_crew_passkey_hint": "Besättningsmedlemmar med skrivbehörighet kan frigöra via Passkey.", + "sign_offline_hint": "Passkey-Godkännande kräver Internet - klassisk signatur möjlig offline", + "sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.", + "sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och besättningens signaturer.", + "sign_lock_warning_title": "Bekräfta underskrift", + "sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.\n\nVill du fortsätta?", + "sign_proceed": "Teckna", + "sign_cancel": "Avbryt", + "sign_cleared_re_sign_title": "Underskrifter borttagna", + "sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och besättningens namnteckningar har tagits bort. Vänligen underteckna igen.", + "no_entries": "Inga loggboksposter hittade för denna yacht. Skapa din första resedag!", + "back_to_list": "Tillbaka till tidskriftslistan", + "save": "Spara loggbokssida", + "saving": "Kommer att sparas...", + "saved": "Loggbokssidan har sparats framgångsrikt!", + "loading": "Journalen laddas...", + "delete_entry": "Ta bort tagg", + "delete_confirm": "Är du säker på att du vill radera den här resedagen permanent?", + "carry_over_tanks_title": "Överföra data från föregående dag?", + "carry_over_tanks_confirm": "Ta över starthamn, färskvatten, bränsle och gråvatten från startnivåerna från resans sista dag?\n\nStarthamn: {{departure}}\nFärskvatten: {{fw}} L\nBränsle: {{fuel}} L\nGråvatten: {{greywater}} L", + "carry_over_tanks_yes": "Ta över", + "carry_over_tanks_no": "Börja med 0", + "event_title": "Kronologisk händelselogg", + "no_events": "Inga händelser inlagda för denna resdag ännu.", + "event_time": "Tid på dygnet", + "event_mgk": "MgK-kurs", + "event_rwk": "RwK-kurs", + "event_course_section": "Kurs", + "course_dial_hint": "Vrid ringen eller gå in i grader", + "course_dial_step_label": "Stegstorlek", + "course_step_fine": "1°", + "course_step_medium": "5°", + "course_step_coarse": "10°", + "course_tab_mgk": "MgK", + "course_tab_rwk": "rwK", + "course_invalid": "Ogiltig kurs (0-360)", + "course_placeholder_degrees": "z. B. 180", + "course_placeholder_cardinal": "z. E.G. NW", + "compass_n": "N", + "compass_e": "O", + "compass_s": "S", + "compass_w": "W", + "wind_mode_cardinal": "Kardinal", + "wind_mode_degrees": "Som examen", + "event_wind_direction": "Vindriktning", + "event_wind_strength": "Vindstyrka", + "event_sea_state": "Havets tillstånd", + "event_weather": "Väder", + "event_log": "Log (sm)", + "event_gps": "GPS-position", + "event_location": "Plats / hamn", + "event_location_placeholder": "z. t.ex. Kiel", + "event_remarks": "Anmärkningar / incidenter", + "gps_btn": "Hämta GPS-koordinater", + "weather_btn": "OpenWeatherMap Ring upp väder", + "event_wind_pressure": "Lufttryck (hPa)", + "event_heel": "Krängning (°)", + "event_sails": "Segelhantering / motor", + "motor_propulsion": "Maskinens resa", + "sails_picker_show_more": "Visa alla segel", + "sails_picker_show_less": "Visa mindre", + "motor_hours": "Maskintimmar (totalt)", + "fuel_per_motor_hour": "Förbrukning per maskintimme", + "event_distance": "Avstånd (sm)", + "export_csv": "Hämta CSV.", + "share_csv": "Aktie", + "export_pdf": "Hämta PDF.", + "exporting_pdf": "PDF genereras...", + "photos_title": "Fotobilagor (E2E-krypterade)", + "photo_caption_label": "Fotobeskrivning/etikett (valfritt)", + "photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet", + "photo_btn": "Ta foto / ladda upp", + "photo_processing": "Håller på att bearbetas...", + "no_photos": "Inga foton kopplade till denna resdag ännu.", + "photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?", + "confirm_yes": "Ja", + "confirm_no": "Nej", + "track_upload_title": "GPS-spårning (fil)", + "track_upload_points": "Poäng", + "gps_tracking_btn_gpx": "Ladda ner spårfil", + "gps_track_upload_help": "Dra en GPX-, KML- eller GeoJSON-fil hit eller klicka för att välja", + "gps_track_upload_btn": "Ladda upp GPS-spår", + "gps_track_delete": "Ta bort spårfil", + "gps_track_delete_confirm": "Är du säker på att du vill radera den här spårfilen permanent?", + "track_distance": "GPS-rutt (sm)", + "track_speed_max": "Max. hastighet Hastighet (kn)", + "track_speed_avg": "Ø Hastighet (kn)", + "track_map_title": "GPS-spår på OpenSeaMap", + "track_map_start": "Start", + "track_map_end": "Mål", + "track_map_speed_slow": "långsamt", + "track_map_speed_fast": "snabb", + "track_map_error": "Kartan kunde inte läsas in.", + "exporting": "Export...", + "share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.", + "invite_crew": "Bjud in besättningen", + "invite_link_copied": "Länk till inbjudan kopierad till urklipp!", + "invite_link_desc": "Dela den här länken med besättningsmedlemmar för att ge dem skrivrättigheter till loggboken.", + "collaborators_list": "Medlemmar / Besättning", + "revoke": "Ta bort", + "revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?", + "invite_role": "Roll", + "invite_expires": "Länken är giltig i 48 timmar" + }, + "dashboard": { + "title": "Dina loggböcker", + "subtitle": "Välj en loggbok eller skapa en ny för att hantera dina resor.", + "create_btn": "Skapa loggbok", + "new_logbook_placeholder": "Loggbokens eller båtens namn", + "logout": "Logga ut", + "logged_in_as": "Inloggad som {{name}}", + "delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok.json) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.", + "no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!", + "loading": "Loggböckerna är fulla...", + "status_synced": "Synkroniserad", + "status_local": "Endast lokal cache", + "delete_btn": "Radera loggbok", + "section_owned": "Mina loggböcker", + "section_shared": "Delade loggböcker", + "section_shared_hint": "Du har blivit inbjuden som besättningsmedlem. Skepparens profil och inställningar tillhör ägaren.", + "role_owner": "Egen loggbok", + "role_owner_hint": "Du är ägare och skeppare till denna loggbok", + "role_crew": "Tillträde för besättningen", + "role_crew_hint": "Inbjuden loggbok - du kan arbeta som besättning och underteckna den", + "role_read": "Endast läsning", + "role_read_hint": "Delad loggbok - endast visning, ingen redigering", + "open_profile": "Öppna profil för {{name}}", + "edit_title": "Byt namn på loggbok", + "edit_placeholder": "Nytt namn på loggboken", + "edit_success": "Loggboken har framgångsrikt bytt namn", + "edit_btn": "Byt namn på", + "filter_label": "Filtrera loggböcker", + "filter_placeholder": "Namn, årtal eller datum ...", + "filter_clear": "Återställ filter", + "filter_results": "{{count}} Träffar", + "filter_no_results": "Inga loggböcker matchar din sökning. Försök med ett annat namn eller ett annat år.", + "sort_label": "Sortera", + "sort_by_label": "Sortera efter", + "sort_by_name": "Namn", + "sort_by_date": "datum", + "sort_dir_label": "Sekvens", + "sort_asc": "Stigande", + "sort_desc": "Nedåtgående", + "sort_name_asc": "Namn A till Ö", + "sort_name_desc": "Namn Z till A", + "sort_date_asc": "Äldst först", + "sort_date_desc": "Nyast först" + }, + "profile": { + "title": "Användarprofil", + "subtitle": "Konto, Passkeys och statistik för {{name}}", + "back": "Tillbaka till instrumentpanelen", + "loading": "Profilen håller på att laddas...", + "load_error": "Profilen kunde inte laddas.", + "copy_failed": "Kopiering misslyckades.", + "processing": "Håller på att bearbetas...", + "identity_title": "Kontots identitet", + "username": "Användarens namn", + "user_id": "Användar-ID", + "copy_user_id": "Kopiera användar-ID", + "account_since": "Konto sedan", + "prf_status": "Passkey härledning av nyckel (PRF)", + "prf_active": "Aktiv", + "prf_inactive": "Inte konfigurerad", + "passkeys_title": "Passkeys", + "passkeys_desc": "Registrera en separat Passkey på varje enhet. Detta gör att du kan logga in även efter att du bytt plattform.", + "passkeys_empty": "Inga Passkeys hittades.", + "add_passkey_btn": "Lägg till ny Passkey", + "add_passkey_success": "Passkey har lagts till.", + "add_passkey_failed": "Passkey kunde inte läggas till.", + "remove_passkey_btn": "Ta bort Passkey.", + "remove_passkey_last_title": "Senaste Passkey.", + "remove_passkey_last_desc": "Den enda Passkey kan inte tas bort utan att du förlorar åtkomsten till ditt konto. Om du vill radera kontot helt använder du riskzonen längst ner på den här sidan.", + "remove_passkey_failed": "Passkey kunde inte tas bort.", + "remove_passkey_confirm_title": "Ta bort Passkey?", + "remove_passkey_confirm_desc": "Denna enhet kan sedan inte längre logga in med denna Passkey.", + "remove_passkey_confirm_yes": "Ta bort", + "remove_passkey_confirm_no": "Avbryt", + "pin_title": "Lokal PIN-kod", + "pin_status": "Status", + "pin_active": "Aktiv på den här enheten", + "pin_inactive": "Inte konfigurerad", + "pin_confirm_label": "Bekräfta PIN-kod", + "pin_confirm_placeholder": "Ange PIN-koden igen", + "pin_set_btn": "Ange PIN-kod", + "pin_change_btn": "Ändra PIN-kod", + "pin_remove_btn": "Ta bort PIN-koden", + "pin_saved": "PIN-koden sparad.", + "pin_save_failed": "PIN-koden kunde inte räddas.", + "pin_mismatch": "PIN-koderna stämmer inte överens.", + "pin_length_error": "PIN-koden måste innehålla minst 4 tecken.", + "pin_no_session": "Sessionen har löpt ut - vänligen registrera dig igen.", + "remove_pin_confirm_title": "Ta bort PIN-koden?", + "remove_pin_confirm_desc": "Du måste logga in igen på den här enheten med Passkey eller återställningsnyckel.", + "remove_pin_confirm_yes": "Ta bort PIN-koden", + "remove_pin_confirm_no": "Avbryt", + "security_title": "Checklista för säkerhet", + "security_desc": "Översikt över de viktigaste skyddsmekanismerna för ditt konto.", + "security_passkeys_ok": "Minst en Passkey registrerad", + "security_passkeys_missing": "Nej Passkey registrerad", + "security_prf_ok": "Avledning av PRF-nyckel aktiv", + "security_prf_missing": "PRF inte upprättad", + "security_pin_ok": "Lokal PIN-kod på den här enheten", + "security_pin_missing": "Ingen lokal PIN-kod", + "security_recovery_ok": "Uppsättning av återställningsnyckel", + "security_recovery_hint": "De 12 orden visades under registreringen. Håll dem offline och åtskilda från enheten. Du kan skapa en ny nyckel nedan - den gamla kommer då att bli ogiltig.", + "recovery_rotate_btn": "Skapa en ny återställningsnyckel", + "recovery_rotate_confirm_title": "Skapa en ny återställningsnyckel?", + "recovery_rotate_confirm_desc": "Den tidigare nyckeln på 12 ord blir ogiltig omedelbart. Se till att du förvarar den nya nyckeln säkert innan du fortsätter.", + "recovery_rotate_confirm_yes": "Skapa ny nyckel", + "recovery_rotate_confirm_no": "Avbryt", + "recovery_rotate_new_warning": "VIKTIGT: Skriv ner dessa 12 ord och förvara dem offline. Den tidigare återställningsnyckeln är nu ogiltig.", + "recovery_rotate_failed": "Återställningsnyckel kunde inte skapas.", + "recovery_rotate_no_session": "Krypteringssessionen har löpt ut - logga ut och logga in igen och försök sedan igen.", + "device_title": "Denna enhet", + "device_desc": "Lokal cache, synkroniseringsstatus och snabb inloggning i den här webbläsaren.", + "device_sync_pending": "{{count}} väntande synkroniseringsposter", + "device_sync_ok": "Alla lokala ändringar synkroniseras", + "device_remembered": "Konto för snabb inloggning sparat på den här enheten", + "device_not_remembered": "Kontot finns inte med i listan för snabb inloggning", + "device_forget_btn": "Glömt konto på den här enheten", + "device_forget_confirm_title": "Ta bort snabb inloggning?", + "device_forget_confirm_desc": "Kontot försvinner från snabbinloggningslistan på den här enheten. Din session och dina lokala loggböcker behålls.", + "device_forget_confirm_yes": "Ta bort", + "device_forget_confirm_no": "Avbryt", + "passkey_label": "Namn för ny Passkey (valfritt)", + "passkey_label_placeholder": "z. t.ex. MacBook, iPhone", + "passkey_rename_btn": "Spara namn", + "passkey_rename_success": "Passkey namn sparat.", + "passkey_rename_failed": "Passkey-Namnet kunde inte sparas.", + "passkey_unnamed": "Utan titel Passkey", + "stats_title": "Statistik", + "stats_subtitle": "Om alla dina loggböcker på den här enheten", + "stats_logbooks": "Loggböcker", + "stats_account_since": "Konto sedan", + "stats_shared_logbooks": "Delade loggböcker", + "appearance_title": "App & visualisering", + "appearance_desc": "Designen och färgschemat gäller för hela appen på den här enheten.", + "theme_label": "Appens designstil", + "theme_auto": "Automatisk (OS-detektering)", + "theme_ocean": "Ocean (glasmorfism)", + "theme_material": "Material (Android)", + "theme_cupertino": "Cupertino (iOS)", + "color_scheme_label": "Ljust eller mörkt läge", + "color_scheme_auto": "Automatisk (system)", + "color_scheme_light": "Ljus", + "color_scheme_dark": "Mörk", + "integrations_title": "Integrationer", + "owm_key": "OpenWeatherMap API-nyckel", + "owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.", + "prefs_save": "Spara", + "prefs_saving": "Kommer att sparas...", + "prefs_saved": "Sparade", + "tour_title": "App-turné", + "tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.", + "tour_restart": "Starta resan igen", + "push_title": "Push-meddelanden", + "push_desc": "Som loggboksägare får du ett meddelande när inbjudna besättningsmedlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.", + "push_enable": "Meddela oss om förändringar i besättningen", + "push_active": "Push-meddelanden är aktiva på den här enheten.", + "push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.", + "push_denied_hint": "Meddelanden är blockerade. Tillåt dem i webbläsarens eller enhetens inställningar.", + "push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.", + "push_error": "Push-meddelanden kunde inte aktiveras." + }, + "crew": { + "title": "Profiler för skeppare och besättning", + "skipper_section": "Skepparens profil", + "skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.", + "crew_section": "Besättningslista", + "add_crew": "Lägg till besättningsmedlem", + "edit_crew": "Redigera besättningsmedlem", + "no_crew": "Inga besättningsmedlemmar har lagts till ännu.", + "max_crew": "Maximalt antal på 5 besättningsmedlemmar uppnås.", + "name": "Namn", + "address": "adress", + "birthdate": "Födelsedag", + "phone": "Telefonnummer", + "nationality": "Nationalitet", + "passport": "Pass/ID-nummer", + "bloodtype": "Blodgrupp", + "allergies": "Allergier", + "diseases": "Redan existerande tillstånd/sjukdomar", + "save": "Spara skeppardata", + "save_member": "Spara medlem", + "saved": "Skepparens profil har sparats!", + "loading": "Besättningsfilerna är laddade...", + "delete_confirm": "Är du säker på att du vill ta bort den här besättningsmedlemmen?" + }, + "deviation": { + "title": "Tabell för kompassavvikelse", + "subtitle": "Ange den magnetiska kompassdeflektionen (deflektion) för kurser (MgK) från 000° till 360° i steg om 10°.", + "heading": "MgK", + "deviation": "Distraktion", + "save": "Spara kalibreringsrutan", + "saving": "Kommer att sparas...", + "saved": "Kalibreringsnätet har sparats framgångsrikt!", + "loading": "Kalibreringsbordet är laddat..." + }, + "settings": { + "title": "Inställningar för loggbok", + "subtitle": "Dela, säkerhetskopiera och samarbeta för den här loggboken.", + "select_logbook_hint": "Välj en loggbok för att redigera dess inställningar.", + "no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.", + "weather_success": "Väderdata har hämtats framgångsrikt!", + "weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.", + "weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.", + "gps_error": "Ange en plats eller bestäm GPS-koordinaterna.", + "share_title": "Aktieloggbok (skrivskyddad)", + "share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och besättning. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).", + "share_privacy_warning": "Rekommendation: Dela endast den här länken privat (t.ex. via e-post eller messenger), inte på sociala medier.", + "share_enable": "Aktivera offentlig länk", + "share_copied": "Länk kopierad!", + "share_copy_btn": "Kopiera länk", + "danger_zone_title": "Farlig zon", + "danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.", + "delete_account_btn": "Ta bort konto oåterkalleligt", + "delete_account_confirm_title": "Radera konto?", + "delete_account_confirm_desc": "Är du helt säker på att du oåterkalleligen vill radera ditt konto och alla tillhörande loggböcker och E2E-krypterade data?", + "delete_account_confirm_yes": "Ja, radera konto och all data", + "delete_account_confirm_no": "Avbryt", + "delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.", + "delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.", + "deleting_account": "Kontot kommer att raderas...", + "invite_push_prompt_title": "Aktivera push-meddelanden?", + "invite_push_prompt_message": "Så snart inbjudna besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.", + "invite_push_prompt_ios_message": "Så snart besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.", + "invite_push_prompt_enable": "Aktivera nu", + "invite_push_prompt_later": "Senare", + "invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.", + "backup_title": "Säkerhetskopiering och återställning", + "backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, besättning, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.", + "backup_export_title": "Skapa säkerhetskopia", + "backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.", + "backup_restore_title": "Återställ säkerhetskopian", + "backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.", + "backup_passphrase": "Lösenord för säkerhetskopiering", + "backup_passphrase_placeholder": "Minst 8 tecken", + "backup_passphrase_confirm": "Bekräfta lösenfras", + "backup_passphrase_short": "Säkerhetskopians lösenfras måste vara minst 8 tecken lång.", + "backup_passphrase_mismatch": "Lösenfraserna stämmer inte överens.", + "backup_wrong_passphrase": "Lösenordet är felaktigt eller säkerhetskopian är skadad.", + "backup_export_btn": "Ladda ner backup", + "backup_exporting": "Säkerhetskopian skapas...", + "backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).", + "backup_file_label": "Säkerhetskopieringsfil (.daagbok.json)", + "backup_preview_btn": "Kontrollera innehåll", + "backup_previewing": "Check...", + "backup_restore_btn": "Återställ", + "backup_restoring": "Kommer att återställas...", + "backup_restore_success": "Loggbok \"{{title}}\" har återställts.", + "backup_restore_cancelled": "Återhämtning avbruten.", + "backup_invalid_json": "Filen är inte en giltig JSON-fil.", + "backup_invalid_format": "Okänt eller föråldrat backupformat.", + "backup_not_owner": "Endast loggbokens ägare kan skapa säkerhetskopior.", + "backup_not_authenticated": "Logga in för att återställa en säkerhetskopia.", + "backup_id_conflict": "En loggbok med detta ID finns redan.", + "backup_overwrite_confirm": "Den befintliga loggboken med samma ID ersätts. Fortsätter du?", + "backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?", + "backup_stat_entries": "{{count}} Resdagar", + "backup_stat_photos": "{{count}} Foton", + "backup_stat_crew": "{{count}} Besättningens uppgifter", + "backup_stat_tracks": "{{count}} GPS-spår", + "backup_exported_at": "Exporterad: {{date}}" + }, + "disclaimer": { + "title": "Viktiga anmärkningar", + "intro": "Läs följande anvisningar innan du använder Kapteins Daagbok.", + "e2e_title": "End-to-end-kryptering", + "e2e_body": "Dina loggboksdata är krypterade från början till slut. Endast du - eller personer med din nyckel - kan läsa innehållet. Endast krypterade data lagras på servern.", + "pwa_title": "Progressiv webbapplikation (PWA)", + "pwa_body": "Kapteins Daagbok körs som en progressiv webbapp i din webbläsare och kan installeras på din enhet - på samma sätt som en native-app, utan en appbutik.", + "storage_title": "Lokal lagring och synkronisering", + "storage_body": "Dina data lagras lokalt på din enhet (IndexedDB). Ändringar synkroniseras med servern när en internetanslutning är aktiv. Du kan fortsätta att arbeta utan anslutning, synkroniseringen sker senare.", + "free_title": "Kostnadsfritt och reklamfritt", + "free_body": "Kapteins Daagbok är kostnadsfritt och innehåller ingen reklam.", + "liability_title": "Ansvarsfriskrivning", + "liability_body": "Användningen av appen sker på egen risk. Inget ansvar accepteras för skador som uppstår till följd av användningen av appen - inklusive felaktiga eller ofullständiga loggboksanteckningar, förlust av data eller tekniska fel.", + "warranty_title": "Ingen garanti", + "warranty_body": "Ingen garanti ges för tjänstens funktion, korrekthet eller tillgänglighet. Driften kan när som helst avbrytas, begränsas eller ställas in.", + "copyright": "© 2026 KnorrLabs, Markus F.J. Busche", + "accept": "Acceptera och fortsätt", + "close": "Nära", + "button_title": "Anmärkningar och ansvarsfriskrivning" + }, + "feedback": { + "button_title": "Skicka feedback", + "title": "Återkoppling", + "intro": "Dela med dig av buggar, idéer eller allmän feedback. Ditt meddelande kommer att skickas till projektgruppen via en säker meddelandekanal.", + "category_label": "Kategori", + "category_general": "Allmänt", + "category_bug": "Rapportera fel", + "category_feature": "Begäran om funktion", + "contact_label": "E-post (valfritt)", + "contact_placeholder": "deine@email.beispiel", + "message_label": "Meddelande", + "message_placeholder": "Beskriv din feedback...", + "send": "Skicka", + "sending": "Kommer att skickas...", + "cancel": "Avbryt", + "success": "Tack så mycket! Din feedback har skickats.", + "error_send": "Feedback kunde inte skickas. Vänligen försök igen senare.", + "error_invalid_email": "Vänligen ange en giltig e-postadress.", + "error_not_configured": "Feedback är inte tillgängligt på den här servern.", + "error_rate_limited": "För många feedbackmeddelanden på kort tid. Vänligen vänta några minuter.", + "error_spam": "Det här meddelandet kunde inte skickas. Vänligen omformulera det." + }, + "demo": { + "logbook_title": "Demo loggbok Östersjön", + "badge": "Demo", + "public_banner": "Skrivskyddad demovy", + "cta_register": "Skapa konto", + "back_to_login": "Till registreringen" + }, + "invitation": { + "error_invalid_key": "Länken till inbjudan är kryptografiskt ogiltig (nyckeln är felaktig).", + "error_missing_key": "Länken till inbjudan innehåller ingen dekrypteringsnyckel (#key=...). Vänligen använd den fullständiga länken från ägaren.", + "error_expired": "Denna inbjudan har löpt ut (giltig i 48 timmar).", + "error_invalid_token": "Inbjudan ogiltig.", + "error_load_failed": "Inbjudan kunde inte läsas in.", + "error_incomplete_session": "Sessionen är ofullständig - logga in igen (användar-ID saknas).", + "error_accept_failed": "Anslutningen misslyckades.", + "error_login_failed": "Passkey Inloggningen misslyckades.", + "error_username_missing": "Användarnamnet kunde inte fastställas - vänligen logga in igen.", + "error_register_failed": "Registreringen misslyckades.", + "loading_joining": "Ansluter sig...", + "loading_checking": "Inbjudan kommer att kontrolleras...", + "loading_unlocking": "Loggboken är upplåst och synkroniserad...", + "loading_retrieving_key": "Ladda ner krypteringsnyckel...", + "error_title": "Fel i inbjudan", + "back_to_start": "Tillbaka till början", + "title": "Inbjudan till loggbok", + "invited_by": "Inbjudan från", + "vessel_logbook": "Fartyg / Loggbok", + "signed_in_preparing": "Registrerad som {{username}}. Anslutning förbereds...", + "join_again": "Gå med igen", + "login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.", + "or_sign_up": "ELLER REGISTRERA DIG IGEN", + "register_crew_account": "Skapa ett nytt konto för besättningen", + "username_label": "Användarens namn", + "create_passkey": "Skapa Passkey.", + "switch_language_en": "Engelska", + "switch_language_de": "Tysk" + }, + "stats": { + "title": "Statistik", + "subtitle": "Översikt över rutter, förbrukning och typ av körning", + "scope_label": "Utvärderingsområde", + "scope_logbook": "Denna loggbok", + "scope_account": "Alla loggböcker", + "loading": "Statistiken är beräknad...", + "no_data": "Inga resdagar tillgängliga ännu.", + "total_distance": "Totalt avstånd", + "travel_days": "Resdagar", + "sail_distance": "Under segel", + "motor_distance": "Maskinens resa", + "motor_hours_total": "Totalt antal maskintimmar", + "daily_motor_hours": "Maskintimmar per resdag", + "avg_motor_hours": "Ø maskintimmar per resdag", + "unknown_propulsion": "Okänd", + "fuel_total": "Totalt bränsle", + "water_total": "Totalt vatten", + "daily_etmal": "Dagliga tider", + "daily_consumption": "Daglig konsumtion", + "route_overview": "Vägbeskrivning", + "route_map_title": "Översikt över rutten", + "propulsion_title": "Segel vs. maskin", + "propulsion_hint": "Fördelningen baseras på loggbokshändelser per resdag, inte på GPS-segment.", + "avg_distance": "Ø per resdag", + "avg_fuel": "Ø Bränsle", + "avg_water": "Ø Vatten", + "fuel_per_nm": "Bränsle per sm", + "fuel_per_motor_hour": "Bränsle per maskintimme", + "daily_fuel_per_motor_hour": "Bränsleförbrukning per maskintimme och resdag", + "fuel_legend": "Bränsle", + "water_legend": "Vatten", + "unit_nm": "sm", + "unit_h": "h", + "unit_l": "L", + "day_label": "Dag {{day}}__.", + "account_logbooks": "Loggböcker i en överblick", + "col_logbook": "Loggbok" + }, + "tour": { + "skip": "Hoppa över turen", + "back": "Tillbaka", + "next": "Ytterligare", + "finish": "Färdig", + "progress": "Steg {{current}} från {{total}}.", + "steps": { + "welcome": { + "title": "Välkommen ombord!", + "body": "Vi har skapat en demo-loggbok med tre dagars resa i Kielfjorden åt dig. Du kan när som helst radera exempelposterna om du vill starta din egen loggbok. Den här korta rundturen visar dig de viktigaste funktionerna." + }, + "welcome_public": { + "title": "Välkommen ombord!", + "body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden - utan konto. Den här korta rundturen visar dig fartygsdata, besättning och loggboksanteckningar." + }, + "nav_logs": { + "title": "Loggboksanteckningar", + "body": "Det är här du hanterar dina resdagar - avresa, destination, väder, bränslenivåer och GPS-spår." + }, + "entry_list": { + "title": "Dina resdagar", + "body": "Varje kort representerar en resdag. Tryck på en post för att visa eller redigera detaljer." + }, + "entry_open": { + "title": "Öppen resdag", + "body": "Så här ser en komplett loggboksanteckning ut - med händelser, tanknivåer och mycket mer." + }, + "entry_track": { + "title": "GPS-spårning", + "body": "Ladda upp GPX-filer eller visa redan sparade rutter på kartan - inklusive avstånd och hastighet." + }, + "nav_vessel": { + "title": "Fartygsdata", + "body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar." + }, + "nav_crew": { + "title": "Besättningslista", + "body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare." + }, + "nav_stats": { + "title": "Kontrollpanel för statistik", + "body": "Här kan du se körsträckor, bränsleförbrukning, ruttkartor och körandelar - automatiskt beräknade från dina loggboksanteckningar." + }, + "nav_feedback": { + "title": "Skicka feedback", + "body": "Du kan använda det här formuläret för att skicka fel, idéer eller allmän feedback direkt till projektgruppen - även efter rundturen när som helst med hjälp av ikonen längst upp till höger." + }, + "nav_profile": { + "title": "Din användarprofil", + "body": "Du kommer åt din personliga profil via skipperknappen högst upp - oavsett vilken loggbok som är aktuell." + }, + "profile_preferences": { + "title": "Redovisning & presentation", + "body": "Här kan du hantera din konto-identitet, ditt tema och ljus/mörker-läge. Du kan när som helst starta om appturen. Passkeys och säkerhetsinställningar hittar du längre ner i profilen." + }, + "finish": { + "title": "Okej!", + "body": "Du kommer direkt till instrumentpanelen för statistik. Du kan när som helst starta om turen i din användarprofil. Ha en trevlig resa!" + } + } + }, + "seo": { + "title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)", + "description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, besättnings- och fartygsdata på ett säkert sätt - även offline som PWA.", + "keywords": "Yachtloggbok, skeppsdagbok, ombordloggbok, segling, Passkey, E2E kryptering, GPS-spår, sjöfartsloggbok, gratis, reklamfri, gratis, utan reklam", + "ogImageAlt": "Kapteins Daagbok Logotyp" + } + } +} diff --git a/client/src/services/demoLogbookData.ts b/client/src/services/demoLogbookData.ts index 036c838..f811bcb 100644 --- a/client/src/services/demoLogbookData.ts +++ b/client/src/services/demoLogbookData.ts @@ -1,6 +1,7 @@ import { parseTrackFile } from './trackUpload.js' import { computeTrackStats } from '../utils/trackStats.js' import i18n from '../i18n/index.js' +import { isGermanLocale } from '../utils/i18nLanguages.js' import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw' import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw' @@ -59,7 +60,7 @@ export interface PublicDemoFixture { } export function buildDemoDays(): DemoDaySpec[] { - const isDe = i18n.language.startsWith('de') + const isDe = isGermanLocale(i18n.language) return [ { date: '2026-05-29', @@ -165,7 +166,7 @@ export function buildDemoDays(): DemoDaySpec[] { } export function buildDemoYachtData(): Record { - const isDe = i18n.language.startsWith('de') + const isDe = isGermanLocale(i18n.language) return { name: 'Seeadler', vesselType: isDe ? 'Segelyacht' : 'Sailing yacht', @@ -188,7 +189,7 @@ export function buildDemoYachtData(): Record { } export function buildDemoCrewRecords(): DemoCrewRecord[] { - const isDe = i18n.language.startsWith('de') + const isDe = isGermanLocale(i18n.language) return [ { payloadId: 'skipper', diff --git a/client/src/utils/dateTimeFormat.ts b/client/src/utils/dateTimeFormat.ts index cc5509f..ee61460 100644 --- a/client/src/utils/dateTimeFormat.ts +++ b/client/src/utils/dateTimeFormat.ts @@ -1,7 +1,17 @@ +import { normalizeAppLanguage } from './i18nLanguages.js' + +const INTL_LOCALES: Record = { + de: 'de-DE', + en: 'en-GB', + da: 'da-DK', + sv: 'sv-SE', + nb: 'nb-NO' +} + /** BCP 47 locales that use 24-hour clock for Intl formatting. */ export function resolveIntlLocale(language?: string): string { - const lng = (language ?? 'en').toLowerCase() - return lng.startsWith('de') ? 'de-DE' : 'en-GB' + const lng = normalizeAppLanguage(language) + return INTL_LOCALES[lng] ?? 'en-GB' } const APP_DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { diff --git a/client/src/utils/i18nLanguages.test.ts b/client/src/utils/i18nLanguages.test.ts new file mode 100644 index 0000000..cdf3050 --- /dev/null +++ b/client/src/utils/i18nLanguages.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { getNextLanguage, normalizeAppLanguage, SUPPORTED_LANGUAGES } from './i18nLanguages.js' + +describe('i18nLanguages', () => { + it('normalizes regional tags to supported base codes', () => { + expect(normalizeAppLanguage('de-DE')).toBe('de') + expect(normalizeAppLanguage('nb-NO')).toBe('nb') + expect(normalizeAppLanguage('xx')).toBe('en') + }) + + it('cycles through all supported languages', () => { + let current: string = 'de' + const seen = new Set() + for (let i = 0; i < SUPPORTED_LANGUAGES.length; i++) { + seen.add(current) + current = getNextLanguage(current) + } + expect(seen.size).toBe(SUPPORTED_LANGUAGES.length) + expect(current).toBe('de') + }) +}) diff --git a/client/src/utils/i18nLanguages.ts b/client/src/utils/i18nLanguages.ts new file mode 100644 index 0000000..f7f15ea --- /dev/null +++ b/client/src/utils/i18nLanguages.ts @@ -0,0 +1,22 @@ +/** Supported UI languages (ISO 639-1, language-only). */ +export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const + +export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number] + +export function normalizeAppLanguage(language?: string): AppLanguage { + const base = (language ?? 'en').split('-')[0].toLowerCase() + if ((SUPPORTED_LANGUAGES as readonly string[]).includes(base)) { + return base as AppLanguage + } + return 'en' +} + +export function getNextLanguage(current?: string): AppLanguage { + const active = normalizeAppLanguage(current) + const index = SUPPORTED_LANGUAGES.indexOf(active) + return SUPPORTED_LANGUAGES[(index + 1) % SUPPORTED_LANGUAGES.length] +} + +export function isGermanLocale(language?: string): boolean { + return normalizeAppLanguage(language) === 'de' +} diff --git a/client/src/utils/locale.test.ts b/client/src/utils/locale.test.ts index dd9d29a..4401e1d 100644 --- a/client/src/utils/locale.test.ts +++ b/client/src/utils/locale.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolveIntlLocale } from './dateTimeFormat.js' import { initSeo, normalizeSeoLang, updatePageSeo } from './seo.js' -const HTML_LANG = /^de|en$/ +const SUPPORTED_HTML_LANG = /^de|en|da|sv|nb$/ function createMockI18n(language: string): I18nInstance { return { @@ -20,7 +20,9 @@ describe('normalizeSeoLang', () => { ['de-DE', 'de'], ['en', 'en'], ['en-US', 'en'], - ['en-GB', 'en'] + ['da', 'da'], + ['sv-SE', 'sv'], + ['nb-NO', 'nb'] ] as const)('maps %s to short code %s', (input, expected) => { expect(normalizeSeoLang(input)).toBe(expected) }) @@ -35,13 +37,15 @@ describe('updatePageSeo html lang', () => { it.each([ ['de', 'de'], ['en', 'en'], - ['en-GB', 'en'] + ['da', 'da'], + ['sv', 'sv'], + ['nb', 'nb'] ] as const)('sets html lang to %s when i18n language is %s', (i18nLanguage, expectedLang) => { initSeo(createMockI18n(i18nLanguage)) updatePageSeo() expect(document.documentElement.lang).toBe(expectedLang) - expect(document.documentElement.lang).toMatch(HTML_LANG) + expect(document.documentElement.lang).toMatch(SUPPORTED_HTML_LANG) }) }) @@ -49,14 +53,17 @@ describe('resolveIntlLocale', () => { it('uses full BCP 47 tags for Intl formatting only', () => { expect(resolveIntlLocale('de')).toBe('de-DE') expect(resolveIntlLocale('en')).toBe('en-GB') + expect(resolveIntlLocale('da')).toBe('da-DK') + expect(resolveIntlLocale('sv')).toBe('sv-SE') + expect(resolveIntlLocale('nb')).toBe('nb-NO') }) it('does not reuse Intl locale tags for html lang', () => { - const intlLocale = resolveIntlLocale('en') - const htmlLang = normalizeSeoLang('en') + const intlLocale = resolveIntlLocale('nb') + const htmlLang = normalizeSeoLang('nb') - expect(intlLocale).toBe('en-GB') - expect(htmlLang).toBe('en') + expect(intlLocale).toBe('nb-NO') + expect(htmlLang).toBe('nb') expect(htmlLang).not.toBe(intlLocale) }) }) diff --git a/client/src/utils/seo.ts b/client/src/utils/seo.ts index 40f6d8d..e3e45d6 100644 --- a/client/src/utils/seo.ts +++ b/client/src/utils/seo.ts @@ -1,13 +1,22 @@ import type { i18n as I18nInstance } from 'i18next' +import { normalizeAppLanguage, type AppLanguage } from './i18nLanguages.js' const SITE_ORIGIN = 'https://kapteins-daagbok.eu' -export type SeoLang = 'de' | 'en' +export type SeoLang = AppLanguage + +const OG_LOCALES: Record = { + de: 'de_DE', + en: 'en_GB', + da: 'da_DK', + sv: 'sv_SE', + nb: 'nb_NO' +} let i18nRef: I18nInstance | null = null export function normalizeSeoLang(lng: string): SeoLang { - return lng.startsWith('de') ? 'de' : 'en' + return normalizeAppLanguage(lng) } function setMeta(attr: 'name' | 'property', key: string, content: string) { @@ -47,8 +56,7 @@ export function updatePageSeo(lng?: string) { setMeta('name', 'keywords', keywords) setMeta('property', 'og:title', title) setMeta('property', 'og:description', description) - setMeta('property', 'og:locale', lang === 'de' ? 'de_DE' : 'en_US') - setMeta('property', 'og:locale:alternate', lang === 'de' ? 'en_US' : 'de_DE') + setMeta('property', 'og:locale', OG_LOCALES[lang]) setMeta('name', 'twitter:title', title) setMeta('name', 'twitter:description', description) setMeta('property', 'og:image:alt', imageAlt) diff --git a/docs/marketing/beta-flyer.da.html b/docs/marketing/beta-flyer.da.html new file mode 100644 index 0000000..efee303 --- /dev/null +++ b/docs/marketing/beta-flyer.da.html @@ -0,0 +1,381 @@ + + + + + Kapteins Daagbok - Beta-flyer + + + +
+
+ +
+

Kapteins Daagbok

+

Digital yachtlogbog - gratis og reklamefri

+
+ Beta +
+ +

+ Opbevar din logbog om bord digitalt: rejsedage, GPS-spor, besætnings- og skibsdata + End-to-end-krypteretkan installeres som en app og + også offline kan bruges til søs. +

+ +
+
Rejsedage i nautisk logbogsformat (havn, vejr, sejl, besætning, brændstofniveauer)
+
Offline-kompatibel PWA - kører på enhver smartphone og tablet
+
Simpelt login uden adgangskode Passkey.
+
Ende-til-ende-kryptering
+
Upload af GPS-spor (GPX/KML) med kortvisning
+
Rute-statistik
+
Vedhæftede billeder pr. rejsedag
+
Fotoavatarbilleder til skipper og besætning
+
Inviter besætningen - arbejd sammen om logbogen
+
PDF- & CSV-Export
+
Krypteret sikkerhedskopiering og gendannelse
+
Del logbog med venner
+
Et vilkårligt antal skibe og logbøger
+
Dansk&Deutsch&Engelsk
+
3 temaer, hver med en lys og en mørk variant
+
Fremstillet i Kiel.Sailing.City..
+
+ +
+
+ Anmeldung mit Passkey und Demo +
Registrering & Passkey
+
+
+ Logbuch-Journal mit Reisetagen +
Logbogsdagbog
+
+
+ Schiffs-Stammdaten mit Yachtfoto +
Skibsdata
+
+
+ +
+

Betafase - din feedback tæller

+

+ Kapteins Daagbok er enPrivat hobbyprojekt uden fortjeneste for øje. + Som betatester er du med til at forbedre appen for skippere og besætninger i hverdagen - feedback er meget velkommen. + Feedback er yderst velkommen. +

+
+ +
+
+ QR-Code: kapteins-daagbok.eu +
+
+

kapteins-daagbok.eu

+

Åbn i browseren, eller tilføj som en app til startskærmen. Registrer dig hos Passkey - der kræves ingen app store.

+
+ Helt gratis + Gratis reklame + E2E-krypteret +
+
+
+ +
+ Påtryk
+ KnorrLabs - Markus F.J. Busche - Knorrstr. 16 · 24106 Kiel - elpatron+kd@mailbox.org. +
+
+ + diff --git a/docs/marketing/beta-flyer.html b/docs/marketing/beta-flyer.html index e5c5bf3..a3822c5 100644 --- a/docs/marketing/beta-flyer.html +++ b/docs/marketing/beta-flyer.html @@ -328,7 +328,7 @@
Verschlüsseltes Backup & Wiederherstellung
Logbuch mit Freunden teilen
Beliebig viele Schiffe und Logbücher
-
Deutsch&Englisch
+
Deutsch·Englisch·Dansk·Svenska·Norsk
3 Themes, jeweils mit heller und dunkler Variante
Crafted in Kiel.Sailing.City.
diff --git a/docs/marketing/beta-flyer.nb.html b/docs/marketing/beta-flyer.nb.html new file mode 100644 index 0000000..1379934 --- /dev/null +++ b/docs/marketing/beta-flyer.nb.html @@ -0,0 +1,381 @@ + + + + + Kapteins Daagbok - Beta-flygeblad + + + +
+
+ +
+

Kapteins Daagbok

+

Digital loggbok for fritidsbåter - gratis og reklamefri

+
+ Beta +
+ +

+ Før loggboken om bord digitalt: reisedager, GPS-spor, mannskaps- og skipsdata + Ende-til-ende-kryptertkan installeres som en app og + også offline kan brukes til sjøs. +

+ +
+
Reisedager i nautisk loggbokformat (havn, vær, seil, mannskap, drivstoffnivå)
+
Offline-kompatibel PWA - kjører på alle smarttelefoner og nettbrett
+
Enkel passordfri Passkey-pålogging
+
Ende-til-ende-kryptering
+
Opplasting av GPS-spor (GPX/KML) med kartvisning
+
Rutestatistikk
+
Fotobilag per reisedag
+
Avatarbilder for skipper og mannskap
+
Inviter mannskapet - samarbeid om loggboken
+
PDF- & CSV-Export
+
Kryptert sikkerhetskopiering og gjenoppretting
+
Del loggboken med venner
+
Et hvilket som helst antall skip og loggbøker
+
Norsk&Deutsch&Engelsk
+
3 temaer, hvert med en lys og en mørk variant
+
Laget i Kiel.Sailing.City..
+
+ +
+
+ Anmeldung mit Passkey und Demo +
Registrering & Passkey
+
+
+ Logbuch-Journal mit Reisetagen +
Loggbokdagbok
+
+
+ Schiffs-Stammdaten mit Yachtfoto +
Skipsdata
+
+
+ +
+

Betafasen - dine tilbakemeldinger teller

+

+ Kapteins Daagbok er enPrivat hobbyprosjekt uten profitthensikt. + Som betatester bidrar du til å forbedre appen for skippere og mannskap i hverdagen - tilbakemeldinger er hjertelig velkomne. + Tilbakemeldinger er hjertelig velkomne. +

+
+ +
+
+ QR-Code: kapteins-daagbok.eu +
+
+

kapteins-daagbok.eu

+

Åpne i nettleseren eller legg til som en app på startskjermen. Registrer deg med Passkey - ingen appbutikk er nødvendig.

+
+ Kostnadsfritt + Reklame gratis + E2E-kryptert +
+
+
+ +
+ Avtrykk
+ KnorrLabs - Markus F.J. Busche - Knorrstr. 16 · 24106 Kiel - elpatron+kd@mailbox.org - Knorrstr. 16 · 24106 Kiel - elpatron+kd@mailbox.org +
+
+ + diff --git a/docs/marketing/beta-flyer.sv.html b/docs/marketing/beta-flyer.sv.html new file mode 100644 index 0000000..ef13beb --- /dev/null +++ b/docs/marketing/beta-flyer.sv.html @@ -0,0 +1,381 @@ + + + + + Kapteins Daagbok - Beta-flygblad + + + +
+
+ +
+

Kapteins Daagbok

+

Digital loggbok för båtar - gratis & reklamfri

+
+ Beta +
+ +

+ Förvara din loggbok ombord digitalt: resdagar, GPS-spår, besättnings- och fartygsdata + End-to-end-krypteringkan installeras som en app och + också offline användbar till sjöss. +

+ +
+
Resdagar i nautiskt loggboksformat (hamn, väder, segel, besättning, bränslenivåer)
+
Offline-kompatibel PWA - körs på alla smartphones och surfplattor
+
Enkel lösenordsfri Passkey-inloggning
+
End-to-end-kryptering
+
Uppladdning av GPS-spår (GPX/KML) med kartvisning
+
Statistik över rutter
+
Fotobilagor per resdag
+
Fotoavatarbilder för skeppare och besättning
+
Bjud in besättningen - arbeta tillsammans med loggboken
+
PDF- & CSV-Export
+
Krypterad säkerhetskopiering och återställning
+
Dela loggbok med vänner
+
Valfritt antal fartyg och loggböcker
+
Svenska&Deutsch&Engelsk
+
3 teman, vart och ett med en ljus och en mörk variant
+
Tillverkad i Kiel.Sailing.City..
+
+ +
+
+ Anmeldung mit Passkey und Demo +
Registrering & Passkey
+
+
+ Logbuch-Journal mit Reisetagen +
Loggboksjournal
+
+
+ Schiffs-Stammdaten mit Yachtfoto +
Fartygsdata
+
+
+ +
+

Betafas - din feedback är viktig

+

+ Kapteins Daagbok är enPrivat hobbyprojekt utan vinstsyfte. + Som betatestare hjälper du till att förbättra appen för skeppare och besättning i vardagen - feedback är uttryckligen välkommen. + Feedback är uttryckligen välkommen. +

+
+ +
+
+ QR-Code: kapteins-daagbok.eu +
+
+

kapteins-daagbok.eu

+

Öppna i webbläsaren eller lägg till som en app på hemskärmen. Registrera dig med Passkey - ingen appbutik krävs.

+
+ Kostnadsfritt + Reklamfri + E2E-krypterad +
+
+
+ +
+ Avtryck
+ KnorrLabs - Markus F.J. Busche - Knorrstr. 16 · 24106 Kiel - elpatron+kd@mailbox.org +
+
+ + diff --git a/scripts/lib/deepl-translate.mjs b/scripts/lib/deepl-translate.mjs new file mode 100644 index 0000000..a744030 --- /dev/null +++ b/scripts/lib/deepl-translate.mjs @@ -0,0 +1,184 @@ +/** + * Shared DeepL API helpers for batch translation scripts. + * @see https://developers.deepl.com/docs/getting-started/quickstart + */ + +import { readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(__dirname, '../..') + +/** Terms that must not be translated (product, tech, brands). */ +export const NO_TRANSLATE_TERMS = [ + 'Kapteins Daagbok', + 'Kiel.Sailing.City.', + 'KnorrLabs', + 'Markus F.J. Busche', + 'kapteins-daagbok.eu', + 'elpatron+kd@mailbox.org', + 'GPX/KML', + 'GPX', + 'KML', + 'PDF', + 'CSV', + 'PWA', + 'E2E', + 'Passkey', + 'OpenWeatherMap', + 'Safari', + 'iPad', + 'iPhone', + 'Android', + 'Knorrstr. 16 · 24106 Kiel' +] + +const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g + +export function loadEnvKey() { + const fromEnv = process.env.DEEPL_API_KEY ?? process.env.DeepLAPIKey + if (fromEnv) return fromEnv.trim() + + try { + const envPath = resolve(repoRoot, '.env') + const content = readFileSync(envPath, 'utf8') + for (const line of content.split('\n')) { + const match = line.match(/^(?:DEEPL_API_KEY|DeepLAPIKey)\s*=\s*(.+)$/) + if (match) return match[1].trim() + } + } catch { + // .env optional when key is exported in shell + } + + throw new Error('DeepL API key missing. Set DEEPL_API_KEY or DeepLAPIKey in .env') +} + +export function resolveApiUrl(apiKey) { + if (process.env.DEEPL_API_URL) return process.env.DEEPL_API_URL + return apiKey.endsWith(':fx') + ? 'https://api-free.deepl.com/v2/translate' + : 'https://api.deepl.com/v2/translate' +} + +export function protectText(text) { + const segments = [] + let protectedText = text.replace(PLACEHOLDER_RE, (match) => { + const id = segments.length + segments.push(match) + return `__X${id}__` + }) + + for (const term of NO_TRANSLATE_TERMS) { + if (!protectedText.includes(term)) continue + const id = segments.length + segments.push(term) + protectedText = protectedText.split(term).join(`__X${id}__`) + } + + return { text: protectedText, segments } +} + +export function restoreText(text, segments) { + let restored = text + segments.forEach((value, id) => { + restored = restored.replaceAll(`__X${id}__`, value) + restored = restored.replaceAll(`__ X ${id} __`, value) + restored = restored.replaceAll(`__X ${id}__`, value) + }) + return restored +} + +function sleep(ms) { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)) +} + +export async function translateBatch(texts, targetLang, { sourceLang = 'DE', apiKey, retries = 3 } = {}) { + if (texts.length === 0) return [] + + const key = apiKey ?? loadEnvKey() + const url = resolveApiUrl(key) + const protectedEntries = texts.map((text) => protectText(text)) + + const body = { + text: protectedEntries.map((entry) => entry.text), + source_lang: sourceLang, + target_lang: targetLang + } + + for (let attempt = 0; attempt < retries; attempt++) { + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `DeepL-Auth-Key ${key}`, + 'Content-Type': 'application/json', + 'User-Agent': 'kapteins-daagbok-translate/1.0' + }, + body: JSON.stringify(body) + }) + + if (response.status === 429 && attempt < retries - 1) { + const waitMs = 1500 * (attempt + 1) + console.warn(`Rate limited — waiting ${waitMs}ms…`) + await sleep(waitMs) + continue + } + + if (!response.ok) { + const detail = await response.text() + throw new Error(`DeepL error ${response.status}: ${detail}`) + } + + const payload = await response.json() + return payload.translations.map((item, index) => + restoreText(item.text, protectedEntries[index].segments) + ) + } + + throw new Error('DeepL translation failed after retries') +} + +export async function translateTexts(texts, targetLang, options = {}) { + const batchSize = options.batchSize ?? 40 + const results = [] + + for (let i = 0; i < texts.length; i += batchSize) { + const batch = texts.slice(i, i + batchSize) + const translated = await translateBatch(batch, targetLang, options) + results.push(...translated) + if (i + batchSize < texts.length) { + process.stdout.write(` ${Math.min(i + batchSize, texts.length)}/${texts.length}\r`) + await sleep(300) + } + } + + process.stdout.write(` ${texts.length}/${texts.length}\n`) + return results +} + +export function flattenTranslation(obj, prefix = '') { + const entries = [] + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key + if (value && typeof value === 'object' && !Array.isArray(value)) { + entries.push(...flattenTranslation(value, path)) + } else if (typeof value === 'string') { + entries.push([path, value]) + } + } + return entries +} + +export function unflattenTranslation(entries) { + const root = {} + for (const [path, value] of entries) { + const parts = path.split('.') + let node = root + for (let i = 0; i < parts.length - 1; i++) { + node[parts[i]] ??= {} + node = node[parts[i]] + } + node[parts[parts.length - 1]] = value + } + return root +} diff --git a/scripts/translate-flyer.mjs b/scripts/translate-flyer.mjs new file mode 100644 index 0000000..85b51d9 --- /dev/null +++ b/scripts/translate-flyer.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * Generate localized beta flyer HTML files from the German master via DeepL. + * + * Usage: node scripts/translate-flyer.mjs [--lang da,sv,nb] + */ + +import { readFile, writeFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadEnvKey, translateTexts } from './lib/deepl-translate.mjs' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(__dirname, '..') +const sourcePath = resolve(repoRoot, 'docs/marketing/beta-flyer.html') + +const TARGETS = { + da: { code: 'DA', htmlLang: 'da', file: 'beta-flyer.da.html' }, + sv: { code: 'SV', htmlLang: 'sv', file: 'beta-flyer.sv.html' }, + nb: { code: 'NB', htmlLang: 'nb', file: 'beta-flyer.nb.html' } +} + +/** Extract translatable text segments from HTML (text nodes only). */ +function extractSegments(html) { + const segments = [] + const re = />([^<]+)${from}<`, `>${to}<`) + } + return result +} + +function patchLanguageFeature(html, lang) { + const langBlocks = { + da: `Dansk`, + sv: `Svenska`, + nb: `Norsk` + } + + const deEnBlock = + /[\s\S]*?<\/span><\/span><\/div>/ + const replacement = `${langBlocks[lang]}&Deutsch&Engelsk` + + return html.replace(deEnBlock, replacement) +} + +function parseArgs(argv) { + let langs = Object.keys(TARGETS) + for (let i = 2; i < argv.length; i++) { + if (argv[i] === '--lang' && argv[i + 1]) { + langs = argv[++i].split(',').map((l) => l.trim()) + } + } + return langs +} + +async function main() { + const langs = parseArgs(process.argv) + const apiKey = loadEnvKey() + const sourceHtml = await readFile(sourcePath, 'utf8') + const segments = extractSegments(sourceHtml) + const texts = segments.map((s) => s.text) + + for (const lang of langs) { + const target = TARGETS[lang] + if (!target) { + console.error(`Unknown language: ${lang}`) + process.exit(1) + } + + console.log(`\n→ ${target.file}`) + const translated = await translateTexts(texts, target.code, { + sourceLang: 'DE', + apiKey, + batchSize: 20 + }) + + let html = replaceSegments(sourceHtml, segments, translated) + html = html.replace(//, ``) + html = html.replace( + /Kapteins Daagbok — Beta-Flyer<\/title>/, + `<title>Kapteins Daagbok — Beta-Flyer (${target.htmlLang.toUpperCase()})` + ) + html = patchLanguageFeature(html, lang) + + const outPath = resolve(repoRoot, 'docs/marketing', target.file) + await writeFile(outPath, html, 'utf8') + console.log(`Wrote ${outPath}`) + } +} + +main().catch((err) => { + console.error(err.message ?? err) + process.exit(1) +}) diff --git a/scripts/translate-locales.mjs b/scripts/translate-locales.mjs new file mode 100644 index 0000000..24ff322 --- /dev/null +++ b/scripts/translate-locales.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +/** + * Translate i18n locale JSON from German master via DeepL. + * + * Usage: + * node scripts/translate-locales.mjs [--lang da,sv,nb] [--source client/src/i18n/locales/de.json] + */ + +import { readFile, writeFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + flattenTranslation, + loadEnvKey, + translateTexts, + unflattenTranslation +} from './lib/deepl-translate.mjs' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(__dirname, '..') +const defaultSource = resolve(repoRoot, 'client/src/i18n/locales/de.json') + +const TARGETS = { + da: 'DA', + sv: 'SV', + nb: 'NB' +} + +/** Keys whose values stay identical to source (language names, brand). */ +const COPY_AS_IS_PREFIXES = ['languages.', 'app.name'] + +function parseArgs(argv) { + let langs = Object.keys(TARGETS) + let sourcePath = defaultSource + + for (let i = 2; i < argv.length; i++) { + if (argv[i] === '--lang' && argv[i + 1]) { + langs = argv[++i].split(',').map((l) => l.trim()) + } else if (argv[i] === '--source' && argv[i + 1]) { + sourcePath = resolve(repoRoot, argv[++i]) + } + } + + return { langs, sourcePath } +} + +function shouldCopyAsIs(path) { + return COPY_AS_IS_PREFIXES.some((prefix) => path === prefix || path.startsWith(prefix)) +} + +async function translateLocale(sourceJson, langCode, apiKey) { + const entries = flattenTranslation(sourceJson.translation) + const toTranslate = [] + const indices = [] + + entries.forEach(([path, value], index) => { + if (shouldCopyAsIs(path)) return + toTranslate.push(value) + indices.push(index) + }) + + console.log(`Translating ${toTranslate.length} strings to ${langCode.toUpperCase()}…`) + const translated = await translateTexts(toTranslate, TARGETS[langCode], { + sourceLang: 'DE', + apiKey + }) + + const resultEntries = [...entries] + indices.forEach((entryIndex, i) => { + resultEntries[entryIndex][1] = translated[i] + }) + + return { translation: unflattenTranslation(resultEntries) } +} + +async function main() { + const { langs, sourcePath } = parseArgs(process.argv) + const apiKey = loadEnvKey() + const sourceRaw = await readFile(sourcePath, 'utf8') + const sourceJson = JSON.parse(sourceRaw) + + for (const lang of langs) { + if (!TARGETS[lang]) { + console.error(`Unknown language: ${lang}`) + process.exit(1) + } + + const outPath = resolve(repoRoot, `client/src/i18n/locales/${lang}.json`) + console.log(`\n→ ${lang}.json`) + const translated = await translateLocale(sourceJson, lang, apiKey) + await writeFile(outPath, `${JSON.stringify(translated, null, 2)}\n`, 'utf8') + console.log(`Wrote ${outPath}`) + } +} + +main().catch((err) => { + console.error(err.message ?? err) + process.exit(1) +}) diff --git a/scripts/validate-i18n-keys.mjs b/scripts/validate-i18n-keys.mjs new file mode 100644 index 0000000..48b2f64 --- /dev/null +++ b/scripts/validate-i18n-keys.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Verify all locale JSON files have identical key sets. + * Usage: node scripts/validate-i18n-keys.mjs + */ + +import { readFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { flattenTranslation } from './lib/deepl-translate.mjs' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const localesDir = resolve(__dirname, '../client/src/i18n/locales') +const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json'] + +async function loadKeys(filename) { + const raw = await readFile(resolve(localesDir, filename), 'utf8') + const json = JSON.parse(raw) + return flattenTranslation(json.translation).map(([path]) => path).sort() +} + +async function main() { + const keySets = {} + for (const file of localeFiles) { + keySets[file] = await loadKeys(file) + } + + const master = keySets['de.json'] + let failed = false + + for (const file of localeFiles) { + if (file === 'de.json') continue + const keys = keySets[file] + const missing = master.filter((k) => !keys.includes(k)) + const extra = keys.filter((k) => !master.includes(k)) + if (missing.length || extra.length) { + failed = true + console.error(`\n${file}:`) + if (missing.length) console.error(` missing (${missing.length}):`, missing.slice(0, 10).join(', ')) + if (extra.length) console.error(` extra (${extra.length}):`, extra.slice(0, 10).join(', ')) + } else { + console.log(`${file}: OK (${keys.length} keys)`) + } + } + + if (failed) process.exit(1) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +})