diff --git a/package-lock.json b/package-lock.json index 1920e29..ac78d07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "1.0.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "1.0.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "bcrypt": "^5.1.1", diff --git a/package.json b/package.json index 26acd58..6760a40 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:calendar": "node --experimental-sqlite test-calendar.js", "test:ncb": "node --experimental-sqlite test-notes-contacts-budget.js", "test:ux-utils": "node test-ux-utils.js", - "test:modal-utils": "node test-modal-utils.js", + "test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js", "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils" }, "dependencies": { diff --git a/public/components/modal.js b/public/components/modal.js index 890d290..83e4ebd 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -4,12 +4,15 @@ * Focus-Restore, Scroll-Lock und aria-modal. * Auf Mobile: Bottom Sheet mit Swipe-to-Close und Slide-out-Animation. * Abhängigkeiten: CSS-Klassen aus layout.css (.modal-overlay, .modal-panel, etc.) + * i18n.js (t) * * API: * openModal({ title, content, onSave, onDelete, size }) → void * closeModal() → void */ +import { t } from '/i18n.js'; + let activeOverlay = null; let previouslyFocused = null; let focusTrapHandler = null; @@ -191,7 +194,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} aria-labelledby="shared-modal-title"> diff --git a/public/components/oikos-install-prompt.js b/public/components/oikos-install-prompt.js index 2a0d407..5b5a3ff 100644 --- a/public/components/oikos-install-prompt.js +++ b/public/components/oikos-install-prompt.js @@ -1,7 +1,7 @@ /** * Modul: Install-Prompt Web Component * Zweck: Dezentes Banner für PWA-Installation (Chrome/Android) und iOS-Anleitung - * Abhängigkeiten: Design Tokens aus tokens.css (via CSS custom properties) + * Abhängigkeiten: Design Tokens aus tokens.css (via CSS custom properties), i18n.js (t) * * Verhalten: * - Chrome/Android: Fängt beforeinstallprompt ab, zeigt Install-Banner @@ -11,6 +11,8 @@ * - Timing: Banner erst nach 2 Nutzer-Interaktionen anzeigen */ +import { t } from '/i18n.js'; + const DISMISS_KEY = 'oikos-install-dismissed'; const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage @@ -39,6 +41,14 @@ class OikosInstallPrompt extends HTMLElement { return; } + // locale-changed: Banner neu rendern wenn Sprache wechselt + this._onLocaleChanged = () => { + if (this._currentIsIOS !== undefined) { + this._showBanner(this._currentIsIOS); + } + }; + window.addEventListener('locale-changed', this._onLocaleChanged); + // Noch nicht genug Interaktionen const interactions = Number(localStorage.getItem(INTERACTION_KEY) || '0'); if (interactions < INTERACTION_THRESHOLD) { @@ -56,6 +66,9 @@ class OikosInstallPrompt extends HTMLElement { disconnectedCallback() { window.removeEventListener('beforeinstallprompt', this._onBeforeInstall); if (this._offInteraction) this._offInteraction(); + if (this._onLocaleChanged) { + window.removeEventListener('locale-changed', this._onLocaleChanged); + } } _waitForInteractions() { @@ -97,6 +110,7 @@ class OikosInstallPrompt extends HTMLElement { /** Banner rendern */ _showBanner(isIOS) { + this._currentIsIOS = isIOS; this._shadow.innerHTML = ''; const style = document.createElement('style'); @@ -242,7 +256,7 @@ class OikosInstallPrompt extends HTMLElement { const title = document.createElement('div'); title.className = 'title'; - title.textContent = 'Oikos installieren'; + title.textContent = t('install.title'); const subtitle = document.createElement('div'); subtitle.className = 'subtitle'; @@ -251,12 +265,12 @@ class OikosInstallPrompt extends HTMLElement { // iOS: Teilen-Icon als SVG inline subtitle.innerHTML = ''; subtitle.append( - document.createTextNode('Tippe auf '), + document.createTextNode(t('install.iosTip1')), this._createShareIcon(), - document.createTextNode(' → „Zum Home-Bildschirm"') + document.createTextNode(t('install.iosTip2')) ); } else { - subtitle.textContent = 'Zur App hinzufügen'; + subtitle.textContent = t('install.subtitle'); } text.appendChild(title); @@ -267,7 +281,7 @@ class OikosInstallPrompt extends HTMLElement { if (!isIOS) { const btn = document.createElement('button'); btn.className = 'btn-install'; - btn.textContent = 'Installieren'; + btn.textContent = t('install.installButton'); btn.addEventListener('click', () => this._onInstallClick()); banner.appendChild(btn); } @@ -275,7 +289,7 @@ class OikosInstallPrompt extends HTMLElement { // Dismiss-Button const dismiss = document.createElement('button'); dismiss.className = 'btn-dismiss'; - dismiss.setAttribute('aria-label', 'Schließen'); + dismiss.setAttribute('aria-label', t('install.dismissLabel')); dismiss.innerHTML = ``; dismiss.addEventListener('click', () => this._dismiss()); banner.appendChild(dismiss); diff --git a/public/components/oikos-locale-picker.js b/public/components/oikos-locale-picker.js new file mode 100644 index 0000000..bc9d411 --- /dev/null +++ b/public/components/oikos-locale-picker.js @@ -0,0 +1,80 @@ +/** + * oikos-locale-picker — Sprachauswahl-Web-Component + * Zeigt Radio-Buttons für System/Deutsch/English. + * Bei Auswahl: setLocale() oder localStorage-Eintrag löschen (System). + * Dependencies: i18n.js + */ + +import { t, setLocale, getLocale, getSupportedLocales } from '/i18n.js'; + +const LOCALE_LABELS = { + de: 'Deutsch', + en: 'English', +}; + +class OikosLocalePicker extends HTMLElement { + connectedCallback() { + this._render(); + this._onLocaleChanged = () => this._render(); + window.addEventListener('locale-changed', this._onLocaleChanged); + } + + disconnectedCallback() { + window.removeEventListener('locale-changed', this._onLocaleChanged); + } + + _render() { + this.textContent = ''; + + const stored = localStorage.getItem('oikos-locale'); + + const wrapper = document.createElement('div'); + wrapper.className = 'locale-picker'; + + // System-Option + const systemOption = this._createOption( + 'system', + t('settings.localeSystem'), + !stored, + () => { + localStorage.removeItem('oikos-locale'); + location.reload(); + } + ); + wrapper.appendChild(systemOption); + + // Sprach-Optionen + for (const locale of getSupportedLocales()) { + const option = this._createOption( + locale, + LOCALE_LABELS[locale] || locale, + stored === locale, + () => setLocale(locale) + ); + wrapper.appendChild(option); + } + + this.appendChild(wrapper); + } + + _createOption(value, label, checked, onChange) { + const item = document.createElement('label'); + item.className = 'locale-picker__option'; + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'locale'; + radio.value = value; + radio.checked = checked; + radio.addEventListener('change', onChange); + + const span = document.createElement('span'); + span.textContent = label; + + item.appendChild(radio); + item.appendChild(span); + return item; + } +} + +customElements.define('oikos-locale-picker', OikosLocalePicker); diff --git a/public/i18n.js b/public/i18n.js new file mode 100644 index 0000000..5c45e41 --- /dev/null +++ b/public/i18n.js @@ -0,0 +1,108 @@ +/** + * i18n — Internationalisierung / Übersetzungsmodul + * Bietet t(), initI18n(), setLocale(), getLocale(), getSupportedLocales(), + * formatDate(), formatTime() für die gesamte App. + * Dependencies: none (vanilla JS, Fetch API, Intl API) + */ + +const SUPPORTED_LOCALES = ['de', 'en']; +const DEFAULT_LOCALE = 'de'; +const STORAGE_KEY = 'oikos-locale'; + +let currentLocale = DEFAULT_LOCALE; +let translations = {}; +let fallbackTranslations = {}; + +/** Resolve locale: manual override > navigator.language > default */ +function resolveLocale() { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && SUPPORTED_LOCALES.includes(stored)) return stored; + + const browserLocales = navigator.languages || [navigator.language]; + for (const tag of browserLocales) { + const base = tag.split('-')[0].toLowerCase(); + if (SUPPORTED_LOCALES.includes(base)) return base; + } + return DEFAULT_LOCALE; +} + +/** Lade eine Locale-JSON-Datei */ +async function loadLocale(locale) { + const resp = await fetch(`/locales/${locale}.json`); + if (!resp.ok) throw new Error(`Failed to load locale: ${locale}`); + return resp.json(); +} + +/** Initialisierung — einmal beim App-Start aufrufen */ +export async function initI18n() { + currentLocale = resolveLocale(); + fallbackTranslations = await loadLocale(DEFAULT_LOCALE); + if (currentLocale !== DEFAULT_LOCALE) { + try { + translations = await loadLocale(currentLocale); + } catch { + translations = fallbackTranslations; + currentLocale = DEFAULT_LOCALE; + } + } else { + translations = fallbackTranslations; + } + document.documentElement.lang = currentLocale; +} + +/** Sprache wechseln — löst 'locale-changed' Event aus */ +export async function setLocale(locale) { + if (!SUPPORTED_LOCALES.includes(locale)) return; + localStorage.setItem(STORAGE_KEY, locale); + currentLocale = locale; + const loaded = locale === DEFAULT_LOCALE + ? fallbackTranslations + : await loadLocale(locale); + if (currentLocale !== locale) return; + translations = loaded; + document.documentElement.lang = locale; + window.dispatchEvent(new CustomEvent('locale-changed', { detail: { locale } })); +} + +/** Übersetzungsfunktion mit Platzhalter-Unterstützung {{variable}} */ +export function t(key, params = {}) { + let str = translations[key] ?? fallbackTranslations[key] ?? key; + for (const [k, v] of Object.entries(params)) { + str = str.replaceAll(`{{${k}}}`, String(v)); + } + return str; +} + +/** Aktuelle Locale abfragen */ +export function getLocale() { + return currentLocale; +} + +/** Liste der unterstützten Locales */ +export function getSupportedLocales() { + return [...SUPPORTED_LOCALES]; +} + +/** Datum locale-aware formatieren */ +export function formatDate(date) { + if (date == null) return ''; + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ''; + return new Intl.DateTimeFormat(currentLocale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }).format(d); +} + +/** Uhrzeit locale-aware formatieren */ +export function formatTime(date) { + if (date == null) return ''; + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ''; + return new Intl.DateTimeFormat(currentLocale, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(d); +} diff --git a/public/locales/.gitkeep b/public/locales/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/locales/de.json b/public/locales/de.json new file mode 100644 index 0000000..c80f640 --- /dev/null +++ b/public/locales/de.json @@ -0,0 +1,540 @@ +{ + "common": { + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "close": "Schließen", + "create": "Erstellen", + "add": "Hinzufügen", + "back": "Zurück", + "next": "Weiter", + "loading": "Lade…", + "saving": "Wird gespeichert…", + "required": "Dieses Feld ist erforderlich.", + "error": "Fehler", + "allFieldsRequired": "Bitte alle Felder ausfüllen.", + "today": "Heute", + "tomorrow": "Morgen", + "skipToContent": "Zum Inhalt springen", + "reload": "Neu laden", + "errorOccurred": "Etwas ist schiefgelaufen.", + "unexpectedError": "Ein unerwarteter Fehler ist aufgetreten.", + "errorGeneric": "Ein Fehler ist aufgetreten.", + "updateAvailable": "Update verfügbar — Seite neu laden für die neueste Version.", + "titleRequired": "Titel ist erforderlich", + "nameRequired": "Name ist erforderlich", + "contentRequired": "Inhalt ist erforderlich", + "all": "Alle", + "unknownError": "Unbekannter Fehler" + }, + + "nav": { + "dashboard": "Übersicht", + "tasks": "Aufgaben", + "calendar": "Kalender", + "meals": "Essen", + "shopping": "Einkauf", + "notes": "Pinnwand", + "contacts": "Kontakte", + "budget": "Budget", + "settings": "Einstellungen", + "main": "Hauptnavigation", + "navigation": "Navigation", + "quickActions": "Schnellaktionen" + }, + + "dashboard": { + "title": "Übersicht", + "greetingMorning": "Guten Morgen, {{name}}", + "greetingDay": "Guten Tag, {{name}}", + "greetingEvening": "Guten Abend, {{name}}", + "allDone": "Alles erledigt", + "noEvents": "Keine Termine", + "noPinnedNotes": "Keine angepinnten Notizen", + "todayMeals": "Heute essen", + "allLink": "Alle", + "weekLink": "Woche", + "urgentTasksChip": "{{count}} dring. Aufgabe", + "urgentTasksChipPlural": "{{count}} dring. Aufgaben", + "eventsChip": "{{count}} Termin heute", + "eventsChipPlural": "{{count}} Termine heute", + "todayMealChip": "Heute: {{title}}", + "loadError": "Dashboard konnte nicht vollständig geladen werden.", + "weatherRefresh": "Wetter aktualisieren", + "weatherRefreshTitle": "Aktualisieren", + "weatherFeelsLike": "Gefühlt {{temp}}° · {{humidity}}% · Wind {{wind}} km/h", + "fabTaskLabel": "Aufgabe hinzufügen", + "fabCalendarLabel": "Termin hinzufügen", + "fabShoppingLabel": "Einkauf hinzufügen", + "fabNoteLabel": "Notiz hinzufügen", + "fabTask": "Aufgabe", + "fabCalendar": "Termin", + "fabShopping": "Einkauf", + "fabNote": "Notiz", + "overdue": "Überfällig", + "dueSoon": "Heute fällig", + "dueTomorrow": "Morgen fällig", + "allDay": "Ganztägig" + }, + + "tasks": { + "title": "Aufgaben", + "newTask": "Neue Aufgabe", + "editTask": "Aufgabe bearbeiten", + "emptyTitle": "Keine Aufgaben — alles erledigt?", + "emptyDescription": "Neue Aufgaben über den + Button erstellen.", + "titleLabel": "Titel *", + "titlePlaceholder": "Was muss erledigt werden?", + "descriptionLabel": "Notiz", + "descriptionPlaceholder": "Optionale Details…", + "priorityLabel": "Priorität", + "categoryLabel": "Kategorie", + "dueDateLabel": "Fälligkeit", + "dueTimeLabel": "Uhrzeit", + "assignedLabel": "Zugewiesen an", + "assignedNobody": "— Niemand —", + "statusLabel": "Status", + "priorityUrgent": "Dringend", + "priorityHigh": "Hoch", + "priorityMedium": "Mittel", + "priorityLow": "Niedrig", + "statusOpen": "Offen", + "statusInProgress": "In Bearbeitung", + "statusDone": "Erledigt", + "categoryHousehold": "Haushalt", + "categorySchool": "Schule", + "categoryShopping": "Einkauf", + "categoryRepair": "Reparatur", + "categoryHealth": "Gesundheit", + "categoryFinance": "Finanzen", + "categoryLeisure": "Freizeit", + "categoryMisc": "Sonstiges", + "overdue": "Überfällig", + "overdueDay": "{{count}}d überfällig", + "dueToday": "Heute fällig", + "dueTomorrow": "Morgen fällig", + "groupOverdue": "Überfällig", + "groupToday": "Heute", + "groupThisWeek": "Diese Woche", + "groupNextWeek": "Nächste Woche", + "groupLater": "Später", + "groupNoDate": "Kein Datum", + "markDone": "{{title}} als erledigt markieren", + "editButton": "Aufgabe bearbeiten", + "swipeOpen": "Öffnen", + "swipeDone": "Erledigt", + "swipeEdit": "Bearbeiten", + "subtaskAdd": "+ Teilaufgabe hinzufügen", + "subtaskToggle": "Teilaufgaben anzeigen", + "subtaskMarkDone": "{{title}} als erledigt markieren", + "deleteConfirm": "Aufgabe und alle Teilaufgaben löschen?", + "savedToast": "Aufgabe gespeichert.", + "createdToast": "Aufgabe erstellt.", + "deletedToast": "Aufgabe gelöscht.", + "loadError": "Aufgabe konnte nicht geladen werden.", + "subtaskPrompt": "Teilaufgabe:", + "kanbanOpen": "Offen", + "kanbanInProgress": "In Bearbeitung", + "kanbanDone": "Erledigt", + "recurring": "Wiederkehrend", + "listView": "Listenansicht", + "kanbanView": "Kanban-Ansicht" + }, + + "shopping": { + "title": "Einkauf", + "noLists": "Keine Listen", + "noListsDescription": "Erstelle eine Liste mit dem + Button.", + "emptyList": "Die Liste ist leer", + "emptyListDescription": "Artikel über das Eingabefeld oben hinzufügen.", + "newListPrompt": "Name der neuen Liste:", + "newListButton": "Neue Liste erstellen", + "renameListPrompt": "Neuer Listen-Name:", + "deleteListConfirm": "Liste \"{{name}}\" und alle Artikel löschen?", + "deletedListToast": "Liste gelöscht.", + "itemsRemovedToast": "{{count}} Artikel entfernt.", + "clearChecked": "Abgehakt löschen ({{count}})", + "itemNamePlaceholder": "Artikel hinzufügen…", + "itemQtyPlaceholder": "Menge", + "itemNameLabel": "Artikelname", + "itemQtyLabel": "Menge", + "categoryLabel": "Kategorie", + "addItemLabel": "Artikel hinzufügen", + "renameListLabel": "Liste umbenennen", + "deleteListLabel": "Liste löschen", + "swipeBack": "Zurück", + "swipeCheck": "Abhaken", + "swipeDelete": "Löschen", + "markDoneLabel": "{{name}} abhaken", + "markUndoneLabel": "{{name}} als nicht erledigt markieren", + "deleteItemLabel": "{{name}} löschen", + "listsLoadError": "Listen konnten nicht geladen werden.", + "itemsLoadError": "Artikel konnten nicht geladen werden.", + "catFruitVeg": "Obst & Gemüse", + "catBakery": "Backwaren", + "catDairy": "Milchprodukte", + "catMeatFish": "Fleisch & Fisch", + "catFrozen": "Tiefkühl", + "catDrinks": "Getränke", + "catHousehold": "Haushalt", + "catDrugstore": "Drogerie", + "catMisc": "Sonstiges" + }, + + "meals": { + "title": "Essensplan", + "noMealPlanned": "Kein Essen geplant", + "addMeal": "{{type}} hinzufügen", + "editMeal": "Mahlzeit bearbeiten", + "addMealTitle": "Mahlzeit hinzufügen", + "deleteMeal": "Mahlzeit löschen", + "transferToShoppingList": "Zutaten auf Einkaufsliste", + "today": "Heute", + "prevWeek": "Vorherige Woche", + "nextWeek": "Nächste Woche", + "loadError": "Essensplan konnte nicht geladen werden.", + "typeBreakfast": "Frühstück", + "typeLunch": "Mittagessen", + "typeDinner": "Abendessen", + "typeSnack": "Snack", + "dayMo": "Mo", + "dayDi": "Di", + "dayMi": "Mi", + "dayDo": "Do", + "dayFr": "Fr", + "daySa": "Sa", + "daySo": "So", + "dateLabel": "Datum", + "mealTypeLabel": "Mahlzeit", + "titleLabel": "Titel *", + "titlePlaceholder": "z.B. Spaghetti Bolognese", + "notesLabel": "Notizen", + "notesPlaceholder": "Optional…", + "ingredientsLabel": "Zutaten", + "addIngredient": "Zutat hinzufügen", + "ingredientNamePlaceholder": "Zutat", + "ingredientQtyPlaceholder": "Menge", + "removeIngredient": "Zutat entfernen", + "transferLabel": "Zutaten auf Einkaufsliste übertragen", + "transferNow": "Jetzt übertragen", + "noShoppingLists": "Keine Einkaufslisten vorhanden", + "transferSuccess": "{{count}} Zutat übertragen", + "transferSuccessPlural": "{{count}} Zutaten übertragen", + "transferAlreadyDone": "Alle Zutaten bereits übertragen", + "ingredientCount": "{{count}} Zutat", + "ingredientCountPlural": "{{count}} Zutaten", + "titleRequired": "Titel ist erforderlich", + "loadingIndicator": "Lade…" + }, + + "calendar": { + "title": "Kalender", + "newEvent": "Neuer Termin", + "editEvent": "Termin bearbeiten", + "addEvent": "Termin hinzufügen", + "deleteEvent": "Termin löschen", + "noEvents": "Keine Termine im gewählten Zeitraum.", + "today": "Heute", + "back": "Zurück", + "forward": "Weiter", + "viewMonth": "Monat", + "viewWeek": "Woche", + "viewDay": "Tag", + "viewAgenda": "Agenda", + "allDay": "Ganztägig", + "allDayShort": "ganztg.", + "moreEvents": "+{{count}} weitere", + "weekNumberLabel": "KW {{week}} · {{month}} {{year}}", + "agendaFrom": "Ab {{date}}", + "titleLabel": "Titel *", + "titlePlaceholder": "z.B. Zahnarzt", + "allDayToggle": "Ganztägig", + "startDateLabel": "Startdatum", + "startTimeLabel": "Startzeit", + "endDateLabel": "Enddatum", + "endTimeLabel": "Endzeit", + "fromLabel": "Von", + "toLabel": "Bis", + "locationLabel": "Ort", + "locationPlaceholder": "Optional", + "assignedLabel": "Zugewiesen an", + "assignedNobody": "— Niemand —", + "colorLabel": "Farbe", + "descriptionLabel": "Beschreibung", + "descriptionPlaceholder": "Optional…", + "popupEdit": "Bearbeiten", + "deleteConfirm": "\"{{title}}\" wirklich löschen?", + "createdToast": "Termin erstellt", + "savedToast": "Termin gespeichert", + "deletedToast": "Termin gelöscht", + "loadError": "Termine konnten nicht geladen werden.", + "saveError": "Fehler beim Speichern", + "deleteError": "Fehler beim Löschen", + "titleRequired": "Titel ist erforderlich", + "monthJanuary": "Januar", + "monthFebruary": "Februar", + "monthMarch": "März", + "monthApril": "April", + "monthMay": "Mai", + "monthJune": "Juni", + "monthJuly": "Juli", + "monthAugust": "August", + "monthSeptember": "September", + "monthOctober": "Oktober", + "monthNovember": "November", + "monthDecember": "Dezember", + "dayShortSunday": "So", + "dayShortMonday": "Mo", + "dayShortTuesday": "Di", + "dayShortWednesday": "Mi", + "dayShortThursday": "Do", + "dayShortFriday": "Fr", + "dayShortSaturday": "Sa", + "dayLongSunday": "Sonntag", + "dayLongMonday": "Montag", + "dayLongTuesday": "Dienstag", + "dayLongWednesday": "Mittwoch", + "dayLongThursday": "Donnerstag", + "dayLongFriday": "Freitag", + "dayLongSaturday": "Samstag", + "timeSuffix": "Uhr", + "colorLabel": "Farbe {{color}}" + }, + + "notes": { + "title": "Pinnwand", + "newNote": "Neue Notiz", + "editNote": "Notiz bearbeiten", + "addNoteLabel": "Neue Notiz", + "searchPlaceholder": "Notizen durchsuchen…", + "emptyTitle": "Noch keine Notizen", + "emptyDescription": "Neue Notiz über den + Button erstellen.", + "noResultsTitle": "Keine Treffer", + "noResultsDescription": "Keine Notiz enthält \"{{query}}\".", + "titleLabel": "Titel (optional)", + "titlePlaceholder": "Kein Titel", + "contentLabel": "Inhalt", + "contentMarkdownHint": "(Markdown-Formatierung möglich)", + "contentPlaceholder": "Notiz eingeben…", + "colorLabel": "Farbe", + "pinnedLabel": "Anpinnen (erscheint auf Dashboard)", + "pinAction": "Anpinnen", + "unpinAction": "Anpinnen aufheben", + "deleteLabel": "Notiz löschen", + "deleteConfirm": "Notiz wirklich löschen?", + "createdToast": "Notiz erstellt", + "savedToast": "Notiz gespeichert", + "deletedToast": "Notiz gelöscht", + "loadError": "Notizen konnten nicht geladen werden.", + "formatBold": "Fett (Strg+B)", + "formatItalic": "Kursiv (Strg+I)", + "formatUnderline": "Unterstrichen (Strg+U)", + "formatStrikethrough": "Durchgestrichen", + "formatHeading": "Überschrift", + "formatList": "Aufzählung", + "formatOrderedList": "Nummerierte Liste", + "formatChecklist": "Checkliste", + "formatLink": "Link", + "formatCode": "Code", + "formatQuote": "Zitat", + "formatDivider": "Trennlinie" + }, + + "contacts": { + "title": "Kontakte", + "newContact": "Neuer Kontakt", + "editContact": "Kontakt bearbeiten", + "addButton": "Neu", + "newContactLabel": "Neuer Kontakt", + "searchPlaceholder": "Name, Telefon oder E-Mail suchen…", + "importButton": "Import", + "importLabel": "Kontakt aus vCard importieren", + "importTooltip": "vCard importieren", + "emptyTitle": "Noch keine Kontakte", + "emptyDescription": "Neue Kontakte über den + Button hinzufügen.", + "filterAll": "Alle", + "nameLabel": "Name *", + "namePlaceholder": "Vollständiger Name", + "categoryLabel": "Kategorie", + "phoneLabel": "Telefon", + "phonePlaceholder": "+49 …", + "emailLabel": "E-Mail", + "emailPlaceholder": "name@beispiel.de", + "addressLabel": "Adresse", + "addressPlaceholder": "Straße, PLZ Ort", + "notesLabel": "Notizen", + "notesPlaceholder": "Optional…", + "callLabel": "Anrufen", + "emailActionLabel": "E-Mail", + "mapsLabel": "In Maps öffnen", + "exportLabel": "Als vCard exportieren", + "exportTooltip": "vCard exportieren", + "deleteLabel": "Kontakt löschen", + "deleteConfirm": "Kontakt wirklich löschen?", + "deletePersonConfirm": "\"{{name}}\" wirklich löschen?", + "savedToast": "Kontakt gespeichert", + "updatedToast": "Kontakt aktualisiert", + "deletedToast": "Kontakt gelöscht", + "importedToast": "{{name}} importiert.", + "importError": "Import fehlgeschlagen: {{error}}", + "vcardNoName": "vCard enthält keinen Namen.", + "catDoctor": "Arzt", + "catSchool": "Schule/Kita", + "catAuthority": "Behörde", + "catInsurance": "Versicherung", + "catCraftsman": "Handwerker", + "catEmergency": "Notfall", + "catMisc": "Sonstiges", + "categoryDoctor": "Arzt", + "categorySchool": "Schule/Kita", + "categoryAuthority": "Behörde", + "categoryInsurance": "Versicherung", + "categoryCraftsman": "Handwerker", + "categoryEmergency": "Notfall", + "categoryOther": "Sonstiges" + }, + + "budget": { + "title": "Budget", + "newEntry": "Neuer Eintrag", + "editEntry": "Eintrag bearbeiten", + "addEntryLabel": "Eintrag hinzufügen", + "newEntryFabLabel": "Neuer Eintrag", + "currentMonth": "Aktuell", + "prevMonth": "Vorheriger Monat", + "nextMonth": "Nächster Monat", + "income": "Einnahmen", + "expenses": "Ausgaben", + "balance": "Saldo", + "byCategory": "Nach Kategorie", + "transactions": "Transaktionen", + "emptyTitle": "Keine Einträge diesen Monat", + "emptyDescription": "Budget-Einträge über den + Button hinzufügen.", + "csvExport": "CSV", + "typeExpense": "Ausgabe", + "typeIncome": "Einnahme", + "titleLabel": "Titel *", + "titlePlaceholder": "z.B. REWE Einkauf", + "amountLabel": "Betrag (€) *", + "amountPlaceholder": "0,00", + "categoryLabel": "Kategorie", + "dateLabel": "Datum *", + "recurringLabel": "Wiederkehrend", + "deleteLabel": "Eintrag löschen", + "deleteConfirm": "Eintrag wirklich löschen?", + "deletePersonConfirm": "\"{{title}}\" wirklich löschen?", + "addedToast": "Eintrag hinzugefügt", + "savedToast": "Eintrag gespeichert", + "deletedToast": "Eintrag gelöscht", + "loadError": "Budget konnte nicht geladen werden.", + "trendNeutral": "— wie {{month}}", + "validAmountRequired": "Gültigen Betrag eingeben", + "dateRequired": "Datum ist erforderlich", + "catFood": "Lebensmittel", + "catRent": "Miete", + "catInsurance": "Versicherung", + "catMobility": "Mobilität", + "catLeisure": "Freizeit", + "catClothing": "Kleidung", + "catHealth": "Gesundheit", + "catEducation": "Bildung", + "catMisc": "Sonstiges", + "loadingIndicator": "Lade…" + }, + + "settings": { + "title": "Einstellungen", + "sectionDesign": "Design", + "sectionAccount": "Mein Konto", + "sectionCalendarSync": "Kalender-Synchronisation", + "sectionFamily": "Familienmitglieder", + "cardAppearance": "Darstellung", + "themeSystem": "System", + "themeSysLabel": "System-Einstellung verwenden", + "themeLight": "Hell", + "themeLightLabel": "Helles Design", + "themeDark": "Dunkel", + "themeDarkLabel": "Dunkles Design", + "changePassword": "Passwort ändern", + "currentPasswordLabel": "Aktuelles Passwort", + "newPasswordLabel": "Neues Passwort", + "confirmPasswordLabel": "Neues Passwort bestätigen", + "savePassword": "Passwort speichern", + "passwordMismatch": "Passwörter stimmen nicht überein.", + "passwordSavedToast": "Passwort erfolgreich geändert.", + "googleCalendar": "Google Calendar", + "appleCalendar": "Apple Calendar (iCloud)", + "syncNow": "Jetzt synchronisieren", + "disconnect": "Verbindung trennen", + "connectGoogle": "Mit Google verbinden", + "connected": "Verbunden", + "connectedLastSync": "Verbunden · Zuletzt: {{date}}", + "notConnected": "Nicht verbunden", + "notConfigured": "Nicht konfiguriert (fehlende .env-Variablen)", + "configured": "Konfiguriert (via .env)", + "configuredLastSync": "Konfiguriert (via .env) · Zuletzt: {{date}}", + "syncSuccess": "{{provider}} synchronisiert.", + "disconnectedToast": "{{provider}} getrennt.", + "googleOnlyAdmin": "Nur Admin kann Google Calendar verbinden.", + "appleOnlyAdmin": "Nur Admin kann Apple Calendar verbinden.", + "caldavUrlLabel": "CalDAV-Server-URL", + "caldavUrlPlaceholder": "https://caldav.icloud.com", + "appleIdLabel": "Apple-ID (E-Mail)", + "applePasswordLabel": "App-spezifisches Passwort", + "applePasswordHint": "Passwort unter appleid.apple.com → Sicherheit erstellen.", + "appleConnectBtn": "Verbinden & testen", + "appleConnecting": "Verbinde…", + "appleConnectedToast": "Apple Calendar verbunden.", + "syncSuccessGoogle": "Kalender-Sync mit Google erfolgreich verbunden.", + "syncSuccessApple": "Kalender-Sync mit Apple erfolgreich verbunden.", + "syncErrorGoogle": "Verbindung mit Google fehlgeschlagen. Bitte erneut versuchen.", + "syncErrorApple": "Verbindung mit Apple fehlgeschlagen. Bitte erneut versuchen.", + "addMember": "+ Mitglied hinzufügen", + "newMemberTitle": "Neues Familienmitglied", + "usernameLabel": "Benutzername", + "displayNameLabel": "Anzeigename", + "memberPasswordLabel": "Passwort", + "colorLabel": "Farbe", + "roleLabel": "Rolle", + "roleMember": "Mitglied", + "roleAdmin": "Admin", + "createMember": "Erstellen", + "cancelAddMember": "Abbrechen", + "memberAddedToast": "{{name}} hinzugefügt.", + "deleteMemberConfirm": "{{name}} wirklich löschen?", + "memberDeletedToast": "{{name}} gelöscht.", + "deleteMemberLabel": "Löschen", + "logout": "Abmelden", + "synchronizing": "Synchronisiere…", + "googleDisconnectConfirm": "Google Calendar-Verbindung trennen?", + "appleDisconnectConfirm": "Apple Calendar-Verbindung trennen?", + "localeSystem": "System", + "languageTitle": "Sprache" + }, + + "login": { + "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", + "usernameLabel": "Benutzername", + "usernamePlaceholder": "benutzername", + "passwordLabel": "Passwort", + "passwordPlaceholder": "••••••••", + "loginButton": "Anmelden", + "loggingIn": "Wird angemeldet …", + "tooManyAttempts": "Zu viele Versuche. Bitte warte kurz.", + "invalidCredentials": "Ungültige Anmeldedaten." + }, + + "install": { + "title": "Oikos installieren", + "subtitle": "Zur App hinzufügen", + "iosTip1": "Tippe auf ", + "iosTip2": " \u2192 \"Zum Home-Bildschirm\"", + "installButton": "Installieren", + "dismissLabel": "Schließen" + }, + + "modal": { + "closeLabel": "Schließen" + } +} diff --git a/public/locales/en.json b/public/locales/en.json new file mode 100644 index 0000000..d2f94a0 --- /dev/null +++ b/public/locales/en.json @@ -0,0 +1,540 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "close": "Close", + "create": "Create", + "add": "Add", + "back": "Back", + "next": "Next", + "loading": "Loading…", + "saving": "Saving…", + "required": "This field is required.", + "error": "Error", + "allFieldsRequired": "Please fill in all fields.", + "today": "Today", + "tomorrow": "Tomorrow", + "skipToContent": "Skip to content", + "reload": "Reload", + "errorOccurred": "Something went wrong.", + "unexpectedError": "An unexpected error occurred.", + "errorGeneric": "An error occurred.", + "updateAvailable": "Update available — reload the page to get the latest version.", + "titleRequired": "Title is required", + "nameRequired": "Name is required", + "contentRequired": "Content is required", + "all": "All", + "unknownError": "Unknown error" + }, + + "nav": { + "dashboard": "Overview", + "tasks": "Tasks", + "calendar": "Calendar", + "meals": "Meals", + "shopping": "Shopping", + "notes": "Board", + "contacts": "Contacts", + "budget": "Budget", + "settings": "Settings", + "main": "Main navigation", + "navigation": "Navigation", + "quickActions": "Quick actions" + }, + + "dashboard": { + "title": "Overview", + "greetingMorning": "Good morning, {{name}}", + "greetingDay": "Good afternoon, {{name}}", + "greetingEvening": "Good evening, {{name}}", + "allDone": "All done", + "noEvents": "No events", + "noPinnedNotes": "No pinned notes", + "todayMeals": "Today's meals", + "allLink": "All", + "weekLink": "Week", + "urgentTasksChip": "{{count}} urgent task", + "urgentTasksChipPlural": "{{count}} urgent tasks", + "eventsChip": "{{count}} event today", + "eventsChipPlural": "{{count}} events today", + "todayMealChip": "Today: {{title}}", + "loadError": "Dashboard could not be fully loaded.", + "weatherRefresh": "Refresh weather", + "weatherRefreshTitle": "Refresh", + "weatherFeelsLike": "Feels like {{temp}}° · {{humidity}}% · Wind {{wind}} km/h", + "fabTaskLabel": "Add task", + "fabCalendarLabel": "Add event", + "fabShoppingLabel": "Add shopping", + "fabNoteLabel": "Add note", + "fabTask": "Task", + "fabCalendar": "Event", + "fabShopping": "Shopping", + "fabNote": "Note", + "overdue": "Overdue", + "dueSoon": "Due today", + "dueTomorrow": "Due tomorrow", + "allDay": "All day" + }, + + "tasks": { + "title": "Tasks", + "newTask": "New Task", + "editTask": "Edit Task", + "emptyTitle": "No tasks — all done?", + "emptyDescription": "Create new tasks with the + button.", + "titleLabel": "Title *", + "titlePlaceholder": "What needs to be done?", + "descriptionLabel": "Note", + "descriptionPlaceholder": "Optional details…", + "priorityLabel": "Priority", + "categoryLabel": "Category", + "dueDateLabel": "Due date", + "dueTimeLabel": "Time", + "assignedLabel": "Assigned to", + "assignedNobody": "— Nobody —", + "statusLabel": "Status", + "priorityUrgent": "Urgent", + "priorityHigh": "High", + "priorityMedium": "Medium", + "priorityLow": "Low", + "statusOpen": "Open", + "statusInProgress": "In Progress", + "statusDone": "Done", + "categoryHousehold": "Household", + "categorySchool": "School", + "categoryShopping": "Shopping", + "categoryRepair": "Repair", + "categoryHealth": "Health", + "categoryFinance": "Finance", + "categoryLeisure": "Leisure", + "categoryMisc": "Miscellaneous", + "overdue": "Overdue", + "overdueDay": "{{count}}d overdue", + "dueToday": "Due today", + "dueTomorrow": "Due tomorrow", + "groupOverdue": "Overdue", + "groupToday": "Today", + "groupThisWeek": "This week", + "groupNextWeek": "Next week", + "groupLater": "Later", + "groupNoDate": "No date", + "markDone": "Mark {{title}} as done", + "editButton": "Edit task", + "swipeOpen": "Reopen", + "swipeDone": "Done", + "swipeEdit": "Edit", + "subtaskAdd": "+ Add subtask", + "subtaskToggle": "Show subtasks", + "subtaskMarkDone": "Mark {{title}} as done", + "deleteConfirm": "Delete task and all subtasks?", + "savedToast": "Task saved.", + "createdToast": "Task created.", + "deletedToast": "Task deleted.", + "loadError": "Task could not be loaded.", + "subtaskPrompt": "Subtask:", + "kanbanOpen": "Open", + "kanbanInProgress": "In Progress", + "kanbanDone": "Done", + "recurring": "Recurring", + "listView": "List view", + "kanbanView": "Kanban view" + }, + + "shopping": { + "title": "Shopping", + "noLists": "No lists", + "noListsDescription": "Create a list with the + button.", + "emptyList": "The list is empty", + "emptyListDescription": "Add items using the input field above.", + "newListPrompt": "Name for the new list:", + "newListButton": "Create new list", + "renameListPrompt": "New list name:", + "deleteListConfirm": "Delete list \"{{name}}\" and all items?", + "deletedListToast": "List deleted.", + "itemsRemovedToast": "{{count}} items removed.", + "clearChecked": "Remove checked ({{count}})", + "itemNamePlaceholder": "Add item…", + "itemQtyPlaceholder": "Quantity", + "itemNameLabel": "Item name", + "itemQtyLabel": "Quantity", + "categoryLabel": "Category", + "addItemLabel": "Add item", + "renameListLabel": "Rename list", + "deleteListLabel": "Delete list", + "swipeBack": "Undo", + "swipeCheck": "Check off", + "swipeDelete": "Delete", + "markDoneLabel": "Check off {{name}}", + "markUndoneLabel": "Uncheck {{name}}", + "deleteItemLabel": "Delete {{name}}", + "listsLoadError": "Lists could not be loaded.", + "itemsLoadError": "Items could not be loaded.", + "catFruitVeg": "Fruit & Vegetables", + "catBakery": "Bakery", + "catDairy": "Dairy", + "catMeatFish": "Meat & Fish", + "catFrozen": "Frozen", + "catDrinks": "Drinks", + "catHousehold": "Household", + "catDrugstore": "Drugstore", + "catMisc": "Miscellaneous" + }, + + "meals": { + "title": "Meal Plan", + "noMealPlanned": "No meal planned", + "addMeal": "Add {{type}}", + "editMeal": "Edit meal", + "addMealTitle": "Add meal", + "deleteMeal": "Delete meal", + "transferToShoppingList": "Add ingredients to shopping list", + "today": "Today", + "prevWeek": "Previous week", + "nextWeek": "Next week", + "loadError": "Meal plan could not be loaded.", + "typeBreakfast": "Breakfast", + "typeLunch": "Lunch", + "typeDinner": "Dinner", + "typeSnack": "Snack", + "dayMo": "Mon", + "dayDi": "Tue", + "dayMi": "Wed", + "dayDo": "Thu", + "dayFr": "Fri", + "daySa": "Sat", + "daySo": "Sun", + "dateLabel": "Date", + "mealTypeLabel": "Meal", + "titleLabel": "Title *", + "titlePlaceholder": "e.g. Spaghetti Bolognese", + "notesLabel": "Notes", + "notesPlaceholder": "Optional…", + "ingredientsLabel": "Ingredients", + "addIngredient": "Add ingredient", + "ingredientNamePlaceholder": "Ingredient", + "ingredientQtyPlaceholder": "Quantity", + "removeIngredient": "Remove ingredient", + "transferLabel": "Transfer ingredients to shopping list", + "transferNow": "Transfer now", + "noShoppingLists": "No shopping lists available", + "transferSuccess": "{{count}} ingredient transferred", + "transferSuccessPlural": "{{count}} ingredients transferred", + "transferAlreadyDone": "All ingredients already transferred", + "ingredientCount": "{{count}} ingredient", + "ingredientCountPlural": "{{count}} ingredients", + "titleRequired": "Title is required", + "loadingIndicator": "Loading…" + }, + + "calendar": { + "title": "Calendar", + "newEvent": "New Event", + "editEvent": "Edit Event", + "addEvent": "Add event", + "deleteEvent": "Delete event", + "noEvents": "No events in the selected period.", + "today": "Today", + "back": "Back", + "forward": "Forward", + "viewMonth": "Month", + "viewWeek": "Week", + "viewDay": "Day", + "viewAgenda": "Agenda", + "allDay": "All day", + "allDayShort": "all day", + "moreEvents": "+{{count}} more", + "weekNumberLabel": "W{{week}} · {{month}} {{year}}", + "agendaFrom": "From {{date}}", + "titleLabel": "Title *", + "titlePlaceholder": "e.g. Dentist", + "allDayToggle": "All day", + "startDateLabel": "Start date", + "startTimeLabel": "Start time", + "endDateLabel": "End date", + "endTimeLabel": "End time", + "fromLabel": "From", + "toLabel": "To", + "locationLabel": "Location", + "locationPlaceholder": "Optional", + "assignedLabel": "Assigned to", + "assignedNobody": "— Nobody —", + "colorLabel": "Color", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Optional…", + "popupEdit": "Edit", + "deleteConfirm": "Really delete \"{{title}}\"?", + "createdToast": "Event created", + "savedToast": "Event saved", + "deletedToast": "Event deleted", + "loadError": "Events could not be loaded.", + "saveError": "Error saving", + "deleteError": "Error deleting", + "titleRequired": "Title is required", + "monthJanuary": "January", + "monthFebruary": "February", + "monthMarch": "March", + "monthApril": "April", + "monthMay": "May", + "monthJune": "June", + "monthJuly": "July", + "monthAugust": "August", + "monthSeptember": "September", + "monthOctober": "October", + "monthNovember": "November", + "monthDecember": "December", + "dayShortSunday": "Sun", + "dayShortMonday": "Mon", + "dayShortTuesday": "Tue", + "dayShortWednesday": "Wed", + "dayShortThursday": "Thu", + "dayShortFriday": "Fri", + "dayShortSaturday": "Sat", + "dayLongSunday": "Sunday", + "dayLongMonday": "Monday", + "dayLongTuesday": "Tuesday", + "dayLongWednesday": "Wednesday", + "dayLongThursday": "Thursday", + "dayLongFriday": "Friday", + "dayLongSaturday": "Saturday", + "timeSuffix": "", + "colorLabel": "Color {{color}}" + }, + + "notes": { + "title": "Board", + "newNote": "New Note", + "editNote": "Edit Note", + "addNoteLabel": "New Note", + "searchPlaceholder": "Search notes…", + "emptyTitle": "No notes yet", + "emptyDescription": "Create a new note with the + button.", + "noResultsTitle": "No results", + "noResultsDescription": "No note contains \"{{query}}\".", + "titleLabel": "Title (optional)", + "titlePlaceholder": "No title", + "contentLabel": "Content", + "contentMarkdownHint": "(Markdown formatting supported)", + "contentPlaceholder": "Enter note…", + "colorLabel": "Color", + "pinnedLabel": "Pin (appears on dashboard)", + "pinAction": "Pin", + "unpinAction": "Unpin", + "deleteLabel": "Delete note", + "deleteConfirm": "Really delete this note?", + "createdToast": "Note created", + "savedToast": "Note saved", + "deletedToast": "Note deleted", + "loadError": "Notes could not be loaded.", + "formatBold": "Bold (Ctrl+B)", + "formatItalic": "Italic (Ctrl+I)", + "formatUnderline": "Underline (Ctrl+U)", + "formatStrikethrough": "Strikethrough", + "formatHeading": "Heading", + "formatList": "Bullet list", + "formatOrderedList": "Numbered list", + "formatChecklist": "Checklist", + "formatLink": "Link", + "formatCode": "Code", + "formatQuote": "Quote", + "formatDivider": "Divider" + }, + + "contacts": { + "title": "Contacts", + "newContact": "New Contact", + "editContact": "Edit Contact", + "addButton": "New", + "newContactLabel": "New Contact", + "searchPlaceholder": "Search by name, phone or email…", + "importButton": "Import", + "importLabel": "Import contact from vCard", + "importTooltip": "Import vCard", + "emptyTitle": "No contacts yet", + "emptyDescription": "Add new contacts with the + button.", + "filterAll": "All", + "nameLabel": "Name *", + "namePlaceholder": "Full name", + "categoryLabel": "Category", + "phoneLabel": "Phone", + "phonePlaceholder": "+1 …", + "emailLabel": "Email", + "emailPlaceholder": "name@example.com", + "addressLabel": "Address", + "addressPlaceholder": "Street, ZIP City", + "notesLabel": "Notes", + "notesPlaceholder": "Optional…", + "callLabel": "Call", + "emailActionLabel": "Email", + "mapsLabel": "Open in Maps", + "exportLabel": "Export as vCard", + "exportTooltip": "Export vCard", + "deleteLabel": "Delete contact", + "deleteConfirm": "Really delete this contact?", + "deletePersonConfirm": "Really delete \"{{name}}\"?", + "savedToast": "Contact saved", + "updatedToast": "Contact updated", + "deletedToast": "Contact deleted", + "importedToast": "{{name}} imported.", + "importError": "Import failed: {{error}}", + "vcardNoName": "vCard does not contain a name.", + "catDoctor": "Doctor", + "catSchool": "School/Childcare", + "catAuthority": "Authority", + "catInsurance": "Insurance", + "catCraftsman": "Tradesperson", + "catEmergency": "Emergency", + "catMisc": "Miscellaneous", + "categoryDoctor": "Doctor", + "categorySchool": "School/Daycare", + "categoryAuthority": "Authority", + "categoryInsurance": "Insurance", + "categoryCraftsman": "Tradesperson", + "categoryEmergency": "Emergency", + "categoryOther": "Other" + }, + + "budget": { + "title": "Budget", + "newEntry": "New Entry", + "editEntry": "Edit Entry", + "addEntryLabel": "Add entry", + "newEntryFabLabel": "New Entry", + "currentMonth": "Current", + "prevMonth": "Previous month", + "nextMonth": "Next month", + "income": "Income", + "expenses": "Expenses", + "balance": "Balance", + "byCategory": "By category", + "transactions": "Transactions", + "emptyTitle": "No entries this month", + "emptyDescription": "Add budget entries with the + button.", + "csvExport": "CSV", + "typeExpense": "Expense", + "typeIncome": "Income", + "titleLabel": "Title *", + "titlePlaceholder": "e.g. Supermarket", + "amountLabel": "Amount (€) *", + "amountPlaceholder": "0.00", + "categoryLabel": "Category", + "dateLabel": "Date *", + "recurringLabel": "Recurring", + "deleteLabel": "Delete entry", + "deleteConfirm": "Really delete this entry?", + "deletePersonConfirm": "Really delete \"{{title}}\"?", + "addedToast": "Entry added", + "savedToast": "Entry saved", + "deletedToast": "Entry deleted", + "loadError": "Budget could not be loaded.", + "trendNeutral": "— same as {{month}}", + "validAmountRequired": "Please enter a valid amount", + "dateRequired": "Date is required", + "catFood": "Groceries", + "catRent": "Rent", + "catInsurance": "Insurance", + "catMobility": "Transport", + "catLeisure": "Leisure", + "catClothing": "Clothing", + "catHealth": "Health", + "catEducation": "Education", + "catMisc": "Miscellaneous", + "loadingIndicator": "Loading…" + }, + + "settings": { + "title": "Settings", + "sectionDesign": "Appearance", + "sectionAccount": "My Account", + "sectionCalendarSync": "Calendar Sync", + "sectionFamily": "Family Members", + "cardAppearance": "Display", + "themeSystem": "System", + "themeSysLabel": "Use system setting", + "themeLight": "Light", + "themeLightLabel": "Light mode", + "themeDark": "Dark", + "themeDarkLabel": "Dark mode", + "changePassword": "Change password", + "currentPasswordLabel": "Current password", + "newPasswordLabel": "New password", + "confirmPasswordLabel": "Confirm new password", + "savePassword": "Save password", + "passwordMismatch": "Passwords do not match.", + "passwordSavedToast": "Password changed successfully.", + "googleCalendar": "Google Calendar", + "appleCalendar": "Apple Calendar (iCloud)", + "syncNow": "Sync now", + "disconnect": "Disconnect", + "connectGoogle": "Connect with Google", + "connected": "Connected", + "connectedLastSync": "Connected · Last: {{date}}", + "notConnected": "Not connected", + "notConfigured": "Not configured (missing .env variables)", + "configured": "Configured (via .env)", + "configuredLastSync": "Configured (via .env) · Last: {{date}}", + "syncSuccess": "{{provider}} synced.", + "disconnectedToast": "{{provider}} disconnected.", + "googleOnlyAdmin": "Only admin can connect Google Calendar.", + "appleOnlyAdmin": "Only admin can connect Apple Calendar.", + "caldavUrlLabel": "CalDAV Server URL", + "caldavUrlPlaceholder": "https://caldav.icloud.com", + "appleIdLabel": "Apple ID (email)", + "applePasswordLabel": "App-specific password", + "applePasswordHint": "Create password at appleid.apple.com → Security.", + "appleConnectBtn": "Connect & test", + "appleConnecting": "Connecting…", + "appleConnectedToast": "Apple Calendar connected.", + "syncSuccessGoogle": "Calendar sync with Google connected successfully.", + "syncSuccessApple": "Calendar sync with Apple connected successfully.", + "syncErrorGoogle": "Connection to Google failed. Please try again.", + "syncErrorApple": "Connection to Apple failed. Please try again.", + "addMember": "+ Add member", + "newMemberTitle": "New Family Member", + "usernameLabel": "Username", + "displayNameLabel": "Display name", + "memberPasswordLabel": "Password", + "colorLabel": "Color", + "roleLabel": "Role", + "roleMember": "Member", + "roleAdmin": "Admin", + "createMember": "Create", + "cancelAddMember": "Cancel", + "memberAddedToast": "{{name}} added.", + "deleteMemberConfirm": "Really delete {{name}}?", + "memberDeletedToast": "{{name}} deleted.", + "deleteMemberLabel": "Delete", + "logout": "Log out", + "synchronizing": "Syncing…", + "googleDisconnectConfirm": "Disconnect Google Calendar?", + "appleDisconnectConfirm": "Disconnect Apple Calendar?", + "localeSystem": "System", + "languageTitle": "Language" + }, + + "login": { + "tagline": "Family planning. Secure. Privacy-friendly. Open source.", + "usernameLabel": "Username", + "usernamePlaceholder": "username", + "passwordLabel": "Password", + "passwordPlaceholder": "••••••••", + "loginButton": "Log in", + "loggingIn": "Logging in…", + "tooManyAttempts": "Too many attempts. Please wait a moment.", + "invalidCredentials": "Invalid credentials." + }, + + "install": { + "title": "Install Oikos", + "subtitle": "Add to home screen", + "iosTip1": "Tap ", + "iosTip2": " → \"Add to Home Screen\"", + "installButton": "Install", + "dismissLabel": "Close" + }, + + "modal": { + "closeLabel": "Close" + } +} diff --git a/public/pages/budget.js b/public/pages/budget.js index 301a209..9ee9a26 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -8,6 +8,7 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; +import { t, formatDate, getLocale } from '/i18n.js'; // -------------------------------------------------------- // Konstanten @@ -18,8 +19,23 @@ const CATEGORIES = [ 'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges', ]; -const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', - 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; +const CATEGORY_LABELS = () => ({ + 'Lebensmittel': t('budget.catFood'), + 'Miete': t('budget.catRent'), + 'Versicherung': t('budget.catInsurance'), + 'Mobilität': t('budget.catMobility'), + 'Freizeit': t('budget.catLeisure'), + 'Kleidung': t('budget.catClothing'), + 'Gesundheit': t('budget.catHealth'), + 'Bildung': t('budget.catEducation'), + 'Sonstiges': t('budget.catMisc'), +}); + +function getMonthName(monthIndex) { + // monthIndex: 0-based (0=Januar, 11=Dezember) + const date = new Date(2000, monthIndex, 1); + return new Intl.DateTimeFormat(getLocale(), { month: 'long' }).format(date); +} // -------------------------------------------------------- // State @@ -43,7 +59,7 @@ function formatAmount(n) { function formatMonthLabel(ym) { const [y, m] = ym.split('-'); - return `${MONTH_NAMES[parseInt(m, 10) - 1]} ${y}`; + return `${getMonthName(parseInt(m, 10) - 1)} ${y}`; } function addMonths(ym, n) { @@ -74,7 +90,7 @@ async function loadMonth(month) { state.entries = []; state.summary = { income: 0, expenses: 0, balance: 0, byCategory: [] }; state.prevSummary = null; - window.oikos?.showToast('Budget konnte nicht geladen werden.', 'danger'); + window.oikos?.showToast(t('budget.loadError'), 'danger'); } } @@ -89,24 +105,24 @@ export async function render(container, { user }) { container.innerHTML = `
-

Budget

+

${t('budget.title')}

- - + - -
-
Lade…
+
${t('budget.loadingIndicator')}
-
@@ -171,17 +187,17 @@ function renderBody() {
-
Einnahmen
+
${t('budget.income')}
${formatAmount(s.income)}
${p ? renderTrend(s.income, p.income, prevLabel) : ''}
-
Ausgaben
+
${t('budget.expenses')}
${formatAmount(Math.abs(s.expenses))}
${p ? renderTrend(s.expenses, p.expenses, prevLabel) : ''}
-
Saldo
+
${t('budget.balance')}
${formatAmount(s.balance)}
${p ? renderTrend(s.balance, p.balance, prevLabel) : ''}
@@ -190,7 +206,7 @@ function renderBody() { ${s.byCategory.length ? `
-
Nach Kategorie
+
${t('budget.byCategory')}
${renderCategoryBars(s.byCategory)}
@@ -199,7 +215,7 @@ function renderBody() {
${sign}${formatAmount(e.amount)}
-
@@ -298,7 +314,7 @@ function renderEntries() { function renderTrend(current, prev, prevLabel) { const delta = current - prev; if (Math.abs(delta) < 0.005) { - return `
— wie ${prevLabel}
`; + return `
${t('budget.trendNeutral', { month: prevLabel })}
`; } const positive = delta > 0; const arrow = positive ? '▲' : '▼'; @@ -308,8 +324,7 @@ function renderTrend(current, prev, prevLabel) { } function formatEntryDate(dateStr) { - const d = new Date(dateStr + 'T00:00:00'); - return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.`; + return formatDate(new Date(dateStr + 'T00:00:00')); } // -------------------------------------------------------- @@ -323,38 +338,39 @@ function openBudgetModal({ mode, entry = null }) { const isExpense = isEdit ? entry.amount < 0 : true; const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : ''; + const catLabels = CATEGORY_LABELS(); const catOpts = CATEGORIES.map((c) => - `` + `` ).join(''); const content = `
+ id="type-expense" type="button">${t('budget.typeExpense')} + id="type-income" type="button">${t('budget.typeIncome')}
- + + placeholder="${t('budget.titlePlaceholder')}" value="${escHtml(isEdit ? entry.title : '')}">
- +
- +
- +
@@ -362,22 +378,22 @@ function openBudgetModal({ mode, entry = null }) {
`; openSharedModal({ - title: isEdit ? 'Eintrag bearbeiten' : 'Neuer Eintrag', + title: isEdit ? t('budget.editEntry') : t('budget.newEntry'), content, size: 'sm', onSave(panel) { @@ -397,7 +413,7 @@ function openBudgetModal({ mode, entry = null }) { panel.querySelector('#bm-cancel').addEventListener('click', closeModal); panel.querySelector('#bm-delete')?.addEventListener('click', async () => { - if (!confirm(`"${entry.title}" wirklich löschen?`)) return; + if (!confirm(t('budget.deletePersonConfirm', { title: entry.title }))) return; closeModal(); await deleteEntry(entry.id); }); @@ -410,9 +426,9 @@ function openBudgetModal({ mode, entry = null }) { const date = panel.querySelector('#bm-date').value; const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0; - if (!title) { window.oikos?.showToast('Titel ist erforderlich', 'error'); return; } - if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast('Gültigen Betrag eingeben', 'error'); return; } - if (!date) { window.oikos?.showToast('Datum ist erforderlich', 'error'); return; } + if (!title) { window.oikos?.showToast(t('common.titleRequired'), 'error'); return; } + if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast(t('budget.validAmountRequired'), 'error'); return; } + if (!date) { window.oikos?.showToast(t('budget.dateRequired'), 'error'); return; } const amount = currentType === 'expense' ? -absVal : absVal; @@ -434,11 +450,11 @@ function openBudgetModal({ mode, entry = null }) { closeModal(); renderBody(); - window.oikos?.showToast(mode === 'create' ? 'Eintrag hinzugefügt' : 'Eintrag gespeichert', 'success'); + window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); saveBtn.disabled = false; - saveBtn.textContent = isEdit ? 'Speichern' : 'Hinzufügen'; + saveBtn.textContent = isEdit ? t('common.save') : t('common.add'); } }); }, @@ -450,7 +466,7 @@ function openBudgetModal({ mode, entry = null }) { // -------------------------------------------------------- async function deleteEntry(id) { - if (!confirm('Eintrag wirklich löschen?')) return; + if (!confirm(t('budget.deleteConfirm'))) return; try { await api.delete(`/budget/${id}`); state.entries = state.entries.filter((e) => e.id !== id); @@ -458,9 +474,9 @@ async function deleteEntry(id) { state.summary = sumRes.data; renderBody(); vibrate([30, 50, 30]); - window.oikos?.showToast('Eintrag gelöscht', 'success'); + window.oikos?.showToast(t('budget.deletedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 4174ee4..6f881c0 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -8,17 +8,35 @@ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; +import { t, formatTime } from '/i18n.js'; // -------------------------------------------------------- // Konstanten // -------------------------------------------------------- const VIEWS = ['month', 'week', 'day', 'agenda']; -const VIEW_LABELS = { month: 'Monat', week: 'Woche', day: 'Tag', agenda: 'Agenda' }; -const DAY_NAMES_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; -const DAY_NAMES_LONG = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; -const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', - 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; +const VIEW_LABELS = () => ({ + month: t('calendar.viewMonth'), + week: t('calendar.viewWeek'), + day: t('calendar.viewDay'), + agenda: t('calendar.viewAgenda'), +}); +const DAY_NAMES_SHORT = () => [ + t('calendar.dayShortSunday'), t('calendar.dayShortMonday'), t('calendar.dayShortTuesday'), + t('calendar.dayShortWednesday'), t('calendar.dayShortThursday'), t('calendar.dayShortFriday'), + t('calendar.dayShortSaturday'), +]; +const DAY_NAMES_LONG = () => [ + t('calendar.dayLongSunday'), t('calendar.dayLongMonday'), t('calendar.dayLongTuesday'), + t('calendar.dayLongWednesday'), t('calendar.dayLongThursday'), t('calendar.dayLongFriday'), + t('calendar.dayLongSaturday'), +]; +const MONTH_NAMES = () => [ + t('calendar.monthJanuary'), t('calendar.monthFebruary'), t('calendar.monthMarch'), + t('calendar.monthApril'), t('calendar.monthMay'), t('calendar.monthJune'), + t('calendar.monthJuly'), t('calendar.monthAugust'), t('calendar.monthSeptember'), + t('calendar.monthOctober'), t('calendar.monthNovember'), t('calendar.monthDecember'), +]; const EVENT_COLORS = [ '#007AFF', '#34C759', '#FF9500', '#FF3B30', @@ -73,25 +91,20 @@ function getMondayOf(dateStr) { function formatDate(dateStr, { long = false, weekday = false } = {}) { const d = new Date(dateStr + 'T00:00:00'); const day = d.getDate(); - const mon = MONTH_NAMES[d.getMonth()]; + const mon = MONTH_NAMES()[d.getMonth()]; if (weekday) { - const wd = long ? DAY_NAMES_LONG[d.getDay()] : DAY_NAMES_SHORT[d.getDay()]; + const wd = long ? DAY_NAMES_LONG()[d.getDay()] : DAY_NAMES_SHORT()[d.getDay()]; return `${wd}, ${day}. ${mon}`; } return `${day}. ${mon} ${d.getFullYear()}`; } -function formatTime(datetimeStr) { - if (!datetimeStr) return ''; - const t = datetimeStr.slice(11, 16); - return t || ''; -} - function formatDateTime(datetimeStr) { if (!datetimeStr) return ''; const date = datetimeStr.slice(0, 10); - const time = datetimeStr.slice(11, 16); - return time ? `${formatDate(date)} ${time} Uhr` : formatDate(date); + const hasTime = datetimeStr.length > 10 && datetimeStr.slice(11, 16).trim() !== ''; + const time = hasTime ? formatTime(datetimeStr) : ''; + return time ? `${formatDate(date)} ${time} ${t('calendar.timeSuffix')}`.trimEnd() : formatDate(date); } function getMonthRange(dateStr) { @@ -132,7 +145,7 @@ async function loadRange(from, to) { } catch (err) { console.error('[Calendar] loadRange Fehler:', err); state.events = []; - window.oikos?.showToast('Termine konnten nicht geladen werden.', 'danger'); + window.oikos?.showToast(t('calendar.loadError'), 'danger'); } state.rangeFrom = from; state.rangeTo = to; @@ -161,7 +174,7 @@ export async function render(container, { user }) {
-
@@ -185,26 +198,26 @@ function renderToolbar() { if (!bar) return; bar.innerHTML = ` -

Kalender

+

${t('calendar.title')}

-
- +
${VIEWS.map((v) => ` + data-view="${v}">${VIEW_LABELS()[v]} `).join('')}
-
-
@@ -237,12 +250,12 @@ function updateLabel() { if (!lbl) return; const d = new Date(state.cursor + 'T00:00:00'); const year = d.getFullYear(); - const mon = MONTH_NAMES[d.getMonth()]; + const mon = MONTH_NAMES()[d.getMonth()]; if (state.view === 'month') lbl.textContent = `${mon} ${year}`; - if (state.view === 'week') lbl.textContent = `KW ${getWeekNumber(state.cursor)} · ${mon} ${year}`; + if (state.view === 'week') lbl.textContent = t('calendar.weekNumberLabel', { week: getWeekNumber(state.cursor), month: mon, year }); if (state.view === 'day') lbl.textContent = formatDate(state.cursor, { weekday: true, long: true }); - if (state.view === 'agenda') lbl.textContent = `Ab ${formatDate(state.cursor)}`; + if (state.view === 'agenda') lbl.textContent = t('calendar.agendaFrom', { date: formatDate(state.cursor) }); } function getWeekNumber(dateStr) { @@ -328,7 +341,7 @@ function renderMonthView(container) { container.innerHTML = `
- ${['Mo','Di','Mi','Do','Fr','Sa','So'].map((n) => `
${n}
`).join('')} + ${[t('calendar.dayShortMonday'),t('calendar.dayShortTuesday'),t('calendar.dayShortWednesday'),t('calendar.dayShortThursday'),t('calendar.dayShortFriday'),t('calendar.dayShortSaturday'),t('calendar.dayShortSunday')].map((n) => `
${n}
`).join('')}
${days.map(({ date, inMonth }) => renderMonthDay(date, inMonth)).join('')} @@ -376,7 +389,7 @@ function renderMonthDay(date, inMonth) {
${new Date(date + 'T00:00:00').getDate()}
${evHtml} - ${extra > 0 ? `
+${extra} weitere
` : ''} + ${extra > 0 ? `
${t('calendar.moreEvents', { count: extra })}
` : ''}
`; } @@ -404,14 +417,14 @@ function renderWeekView(container) { ${days.map((d) => { const dt = new Date(d + 'T00:00:00'); return `
-
${DAY_NAMES_SHORT[(dt.getDay())]}
+
${DAY_NAMES_SHORT()[dt.getDay()]}
${dt.getDate()}
`; }).join('')}
-
ganztg.
+
${t('calendar.allDayShort')}
${days.map((d, i) => `
${alldayEvs[i].map((ev) => ` @@ -523,7 +536,7 @@ function renderDayView(container) {
${allday.length ? `
-
ganztg.
+
${t('calendar.allDayShort')}
${allday.map((ev) => `
@@ -584,12 +597,12 @@ function renderAgendaView(container) { container.innerHTML = `
${groups.length === 0 - ? `
Keine Termine im gewählten Zeitraum.
` + ? `
${t('calendar.noEvents')}
` : groups.map(({ date, events }) => `
${formatDate(date)} - ${DAY_NAMES_LONG[new Date(date + 'T00:00:00').getDay()]} + ${DAY_NAMES_LONG()[new Date(date + 'T00:00:00').getDay()]}
${events.map((ev) => renderAgendaEvent(ev)).join('')}
@@ -611,9 +624,9 @@ function renderAgendaView(container) { function renderAgendaEvent(ev) { const timeStr = ev.all_day - ? 'Ganztägig' + ? t('calendar.allDay') : formatTime(ev.start_datetime) - + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} Uhr` : ' Uhr'); + + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} ${t('calendar.timeSuffix')}`.trimEnd() : ` ${t('calendar.timeSuffix')}`.trimEnd()); const initials = ev.assigned_name ? ev.assigned_name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2) @@ -650,9 +663,9 @@ function showEventPopup(ev, anchor) { popup.className = 'event-popup'; const timeStr = ev.all_day - ? 'Ganztägig' + ? t('calendar.allDay') : formatDateTime(ev.start_datetime) - + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} Uhr` : ''); + + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)}${t('calendar.timeSuffix') ? ' ' + t('calendar.timeSuffix') : ''}`.trim() : ''); popup.innerHTML = `
@@ -664,7 +677,7 @@ function showEventPopup(ev, anchor) { ${ev.assigned_name ? `
👤 ${escHtml(ev.assigned_name)}
` : ''}
- + @@ -687,7 +700,7 @@ function showEventPopup(ev, anchor) { }); popup.querySelector('#popup-delete').addEventListener('click', async () => { - if (!confirm(`"${ev.title}" wirklich löschen?`)) return; + if (!confirm(t('calendar.deleteConfirm', { title: ev.title }))) return; popup.remove(); await deleteEvent(ev.id); }); @@ -712,7 +725,7 @@ function openEventModal({ mode, event = null, date = null }) { const content = buildEventModalContent({ mode, event, date }); openSharedModal({ - title: isEdit ? 'Termin bearbeiten' : 'Neuer Termin', + title: isEdit ? t('calendar.editEvent') : t('calendar.newEvent'), content, size: 'md', onSave(panel) { @@ -745,7 +758,7 @@ function openEventModal({ mode, event = null, date = null }) { panel.querySelector('#modal-cancel').addEventListener('click', closeModal); panel.querySelector('#modal-delete')?.addEventListener('click', async () => { - if (!confirm(`"${event.title}" wirklich löschen?`)) return; + if (!confirm(t('calendar.deleteConfirm', { title: event.title }))) return; closeModal(); await deleteEvent(event.id); }); @@ -767,7 +780,7 @@ function buildEventModalContent({ mode, event, date }) { ? event.end_datetime.slice(11, 16) : '10:00'; const userOpts = [ - '', + ``, ...state.users.map((u) => `` ), @@ -775,36 +788,36 @@ function buildEventModalContent({ mode, event, date }) { return `
- + + placeholder="${t('calendar.titlePlaceholder')}" value="${escHtml(isEdit ? event.title : '')}">
- +
- +
- +
- +
@@ -813,52 +826,52 @@ function buildEventModalContent({ mode, event, date }) {
- + + placeholder="${t('calendar.locationPlaceholder')}" value="${escHtml(isEdit && event.location ? event.location : '')}">
- +
- +
${EVENT_COLORS.map((c) => ` + role="radio" tabindex="0" aria-label="${t('calendar.colorLabel', { color: c })}">
`).join('')}
- + + placeholder="${t('calendar.descriptionPlaceholder')}">${escHtml(isEdit && event.description ? event.description : '')}
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)} `; } @@ -868,7 +881,7 @@ async function saveEvent(overlay, mode, eventId) { const title = overlay.querySelector('#modal-title').value.trim(); if (!title) { - window.oikos?.showToast('Titel ist erforderlich', 'error'); + window.oikos?.showToast(t('calendar.titleRequired'), 'error'); return; } @@ -918,11 +931,11 @@ async function saveEvent(overlay, mode, eventId) { closeModal(); renderView(); - window.oikos?.showToast(mode === 'create' ? 'Termin erstellt' : 'Termin gespeichert', 'success'); + window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler beim Speichern', 'error'); + window.oikos?.showToast(err.data?.error ?? t('calendar.saveError'), 'error'); saveBtn.disabled = false; - saveBtn.textContent = mode === 'edit' ? 'Speichern' : 'Erstellen'; + saveBtn.textContent = mode === 'edit' ? t('common.save') : t('common.create'); } } @@ -931,9 +944,9 @@ async function deleteEvent(id) { await api.delete(`/calendar/${id}`); state.events = state.events.filter((e) => e.id !== id); renderView(); - window.oikos?.showToast('Termin gelöscht', 'success'); + window.oikos?.showToast(t('calendar.deletedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler beim Löschen', 'error'); + window.oikos?.showToast(err.data?.error ?? t('calendar.deleteError'), 'error'); } } diff --git a/public/pages/contacts.js b/public/pages/contacts.js index a19b225..f64a326 100644 --- a/public/pages/contacts.js +++ b/public/pages/contacts.js @@ -7,6 +7,7 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; +import { t } from '/i18n.js'; // -------------------------------------------------------- // Konstanten @@ -25,6 +26,18 @@ const CATEGORY_ICONS = { 'Sonstiges': '📋', }; +function CATEGORY_LABELS() { + return { + 'Arzt': t('contacts.categoryDoctor'), + 'Schule/Kita': t('contacts.categorySchool'), + 'Behörde': t('contacts.categoryAuthority'), + 'Versicherung': t('contacts.categoryInsurance'), + 'Handwerker': t('contacts.categoryCraftsman'), + 'Notfall': t('contacts.categoryEmergency'), + 'Sonstiges': t('contacts.categoryOther'), + }; +} + // -------------------------------------------------------- // State // -------------------------------------------------------- @@ -44,32 +57,32 @@ export async function render(container, { user }) { _container = container; container.innerHTML = `
-

Kontakte

+

${t('contacts.title')}

-
- + ${CATEGORIES.map((c) => ` - + `).join('')}
-
@@ -115,13 +128,13 @@ export async function render(container, { user }) { try { const text = await file.text(); const contact = parseVCard(text); - if (!contact.name) { window.oikos?.showToast('vCard enthält keinen Namen.', 'warning'); return; } + if (!contact.name) { window.oikos?.showToast(t('contacts.vcardNoName'), 'warning'); return; } const res = await api.post('/contacts', contact); state.contacts.push(res.data); renderList(); - window.oikos?.showToast(`${res.data.name} importiert.`, 'success'); + window.oikos?.showToast(t('contacts.importedToast', { name: res.data.name }), 'success'); } catch (err) { - window.oikos?.showToast('Import fehlgeschlagen: ' + err.message, 'danger'); + window.oikos?.showToast(t('contacts.importError', { error: err.message }), 'danger'); } }); } @@ -164,8 +177,8 @@ function renderList() { -
Noch keine Kontakte
-
Neue Kontakte über den + Button hinzufügen.
+
${t('contacts.emptyTitle')}
+
${t('contacts.emptyDescription')}
`; if (window.lucide) lucide.createIcons(); @@ -183,7 +196,7 @@ function renderList() { .sort(([a], [b]) => CATEGORIES.indexOf(a) - CATEGORIES.indexOf(b)) .map(([cat, items]) => `
-
${CATEGORY_ICONS[cat] || ''} ${escHtml(cat)}
+
${CATEGORY_ICONS[cat] || ''} ${CATEGORY_LABELS()[cat] || escHtml(cat)}
${items.map((c) => renderContactItem(c)).join('')}
`).join(''); @@ -207,9 +220,9 @@ function renderList() { } function renderContactItem(c) { - const phone = c.phone ? `
` : ''; - const email = c.email ? `` : ''; - const maps = c.address ? `` : ''; + const phone = c.phone ? `` : ''; + const email = c.email ? `` : ''; + const maps = c.address ? `` : ''; const meta = [c.phone, c.email].filter(Boolean).join(' · '); return ` @@ -222,10 +235,10 @@ function renderContactItem(c) { @@ -241,55 +254,56 @@ function openContactModal({ mode, contact = null }) { const isEdit = mode === 'edit'; const v = (field) => escHtml(isEdit && contact[field] ? contact[field] : ''); + const catLabels = CATEGORY_LABELS(); const catOpts = CATEGORIES.map((c) => - `` + `` ).join(''); const content = `
- - + +
- +
- - + +
- - + +
- - + +
- - + +
`; openSharedModal({ - title: isEdit ? 'Kontakt bearbeiten' : 'Neuer Kontakt', + title: isEdit ? t('contacts.editContact') : t('contacts.newContact'), content, size: 'md', onSave(panel) { panel.querySelector('#cm-cancel').addEventListener('click', closeModal); panel.querySelector('#cm-delete')?.addEventListener('click', async () => { - if (!confirm(`"${contact.name}" wirklich löschen?`)) return; + if (!confirm(t('contacts.deletePersonConfirm', { name: contact.name }))) return; closeModal(); await deleteContact(contact.id); }); @@ -303,7 +317,7 @@ function openContactModal({ mode, contact = null }) { const address = panel.querySelector('#cm-address').value.trim() || null; const notes = panel.querySelector('#cm-notes').value.trim() || null; - if (!name) { window.oikos?.showToast('Name ist erforderlich', 'error'); return; } + if (!name) { window.oikos?.showToast(t('common.nameRequired'), 'error'); return; } saveBtn.disabled = true; saveBtn.textContent = '…'; @@ -324,11 +338,11 @@ function openContactModal({ mode, contact = null }) { } closeModal(); renderList(); - window.oikos?.showToast(mode === 'create' ? 'Kontakt gespeichert' : 'Kontakt aktualisiert', 'success'); + window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); saveBtn.disabled = false; - saveBtn.textContent = isEdit ? 'Speichern' : 'Erstellen'; + saveBtn.textContent = isEdit ? t('common.save') : t('common.create'); } }); }, @@ -336,15 +350,15 @@ function openContactModal({ mode, contact = null }) { } async function deleteContact(id) { - if (!confirm('Kontakt wirklich löschen?')) return; + if (!confirm(t('contacts.deleteConfirm'))) return; try { await api.delete(`/contacts/${id}`); state.contacts = state.contacts.filter((c) => c.id !== id); renderList(); vibrate([30, 50, 30]); - window.oikos?.showToast('Kontakt gelöscht', 'success'); + window.oikos?.showToast(t('contacts.deletedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index ba0aaae..e43655a 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -5,6 +5,7 @@ */ import { api } from '/api.js'; +import { t, formatDate, formatTime, getLocale } from '/i18n.js'; // Hält den AbortController des aktuellen FAB-Listeners — wird bei jedem render() erneuert. let _fabController = null; @@ -15,14 +16,9 @@ let _fabController = null; function greeting(displayName) { const h = new Date().getHours(); - const tageszeit = h < 12 ? 'Morgen' : h < 18 ? 'Tag' : 'Abend'; - return `Guten ${tageszeit}, ${displayName}`; -} - -function formatDate(date = new Date()) { - return date.toLocaleDateString('de-DE', { - weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', - }); + if (h < 12) return t('dashboard.greetingMorning', { name: displayName }); + if (h < 18) return t('dashboard.greetingDay', { name: displayName }); + return t('dashboard.greetingEvening', { name: displayName }); } function formatDateTime(isoString) { @@ -33,13 +29,14 @@ function formatDateTime(isoString) { tomorrow.setDate(today.getDate() + 1); const dateStr = d.toDateString() === today.toDateString() - ? 'Heute' + ? t('common.today') : d.toDateString() === tomorrow.toDateString() - ? 'Morgen' - : d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); + ? t('common.tomorrow') + : formatDate(d); - const timeStr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); - return `${dateStr}, ${timeStr} Uhr`; + const timeStr = formatTime(d); + const suffix = t('calendar.timeSuffix'); + return `${dateStr}, ${timeStr}${suffix ? ' ' + suffix : ''}`.trim(); } function formatDueDate(dateStr) { @@ -49,21 +46,21 @@ function formatDueDate(dateStr) { const diffMs = due - now; const diffH = diffMs / (1000 * 60 * 60); - if (diffMs < 0) return { text: 'Überfällig', overdue: true }; - if (diffH < 24) return { text: 'Heute fällig', overdue: false }; - if (diffH < 48) return { text: 'Morgen fällig', overdue: false }; + if (diffMs < 0) return { text: t('dashboard.overdue'), overdue: true }; + if (diffH < 24) return { text: t('dashboard.dueSoon'), overdue: false }; + if (diffH < 48) return { text: t('dashboard.dueTomorrow'), overdue: false }; return { - text: due.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }), + text: formatDate(due), overdue: false, }; } -const MEAL_LABELS = { - breakfast: 'Frühstück', - lunch: 'Mittagessen', - dinner: 'Abendessen', - snack: 'Snack', -}; +const MEAL_LABELS = () => ({ + breakfast: t('meals.typeBreakfast'), + lunch: t('meals.typeLunch'), + dinner: t('meals.typeDinner'), + snack: t('meals.typeSnack'), +}); const MEAL_ICONS = { breakfast: 'sunrise', @@ -76,7 +73,8 @@ function initials(name = '') { return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase(); } -function widgetHeader(icon, title, count, linkHref, linkLabel = 'Alle') { +function widgetHeader(icon, title, count, linkHref, linkLabel) { + linkLabel = linkLabel ?? t('dashboard.allLink'); const badge = count != null ? `${count}` : ''; @@ -122,24 +120,24 @@ function renderGreeting(user, stats = {}) { if (urgentCount > 0) statChips.push(` - ${urgentCount} dring. Aufgabe${urgentCount > 1 ? 'n' : ''} + ${urgentCount > 1 ? t('dashboard.urgentTasksChipPlural', { count: urgentCount }) : t('dashboard.urgentTasksChip', { count: urgentCount })} `); if (todayEventCount > 0) statChips.push(` - ${todayEventCount} Termin${todayEventCount > 1 ? 'e' : ''} heute + ${todayEventCount > 1 ? t('dashboard.eventsChipPlural', { count: todayEventCount }) : t('dashboard.eventsChip', { count: todayEventCount })} `); if (todayMealTitle) statChips.push(` - Heute: ${todayMealTitle} + ${t('dashboard.todayMealChip', { title: todayMealTitle })} `); return `
${greeting(user.display_name)}
-
${formatDate()}
+
${formatDate(new Date())}
${statChips.length ? `
${statChips.join('')}
` : ''}
@@ -149,10 +147,10 @@ function renderGreeting(user, stats = {}) { function renderUrgentTasks(tasks) { if (!tasks.length) { return `
- ${widgetHeader('check-square', 'Aufgaben', 0, '/tasks')} + ${widgetHeader('check-square', t('nav.tasks'), 0, '/tasks')}
-
Alles erledigt
+
${t('dashboard.allDone')}
`; } @@ -174,7 +172,7 @@ function renderUrgentTasks(tasks) { }).join(''); return `
- ${widgetHeader('check-square', 'Aufgaben', tasks.length, '/tasks')} + ${widgetHeader('check-square', t('nav.tasks'), tasks.length, '/tasks')}
${items}
`; } @@ -182,10 +180,10 @@ function renderUrgentTasks(tasks) { function renderUpcomingEvents(events) { if (!events.length) { return `
- ${widgetHeader('calendar', 'Termine', 0, '/calendar')} + ${widgetHeader('calendar', t('nav.calendar'), 0, '/calendar')}
-
Keine Termine
+
${t('dashboard.noEvents')}
`; } @@ -194,14 +192,15 @@ function renderUpcomingEvents(events) { const items = events.map((e) => { const d = new Date(e.start_datetime); const isToday = d.toDateString() === today; - const timeStr = e.all_day ? 'Ganztägig' : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr'; + const _suffix = t('calendar.timeSuffix'); + const timeStr = e.all_day ? t('dashboard.allDay') : `${formatTime(d)}${_suffix ? ' ' + _suffix : ''}`.trim(); return `
${e.title}
- ${isToday ? 'Heute' : formatDateTime(e.start_datetime).split(',')[0]} + ${isToday ? t('common.today') : formatDateTime(e.start_datetime).split(',')[0]} ${timeStr} ${e.location ? ` · ${e.location}` : ''}
@@ -211,7 +210,7 @@ function renderUpcomingEvents(events) { }).join(''); return `
- ${widgetHeader('calendar', 'Termine', events.length, '/calendar')} + ${widgetHeader('calendar', t('nav.calendar'), events.length, '/calendar')}
${items}
`; } @@ -219,19 +218,20 @@ function renderUpcomingEvents(events) { function renderTodayMeals(meals) { const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack']; + const mealLabels = MEAL_LABELS(); const slots = MEAL_ORDER.map((type) => { const meal = meals.find((m) => m.meal_type === type); return `
-
${MEAL_LABELS[type]}
+
${mealLabels[type]}
${meal ? meal.title : '—'}
`; }).join(''); return `
- ${widgetHeader('utensils', 'Heute essen', null, '/meals', 'Woche')} + ${widgetHeader('utensils', t('dashboard.todayMeals'), null, '/meals', t('dashboard.weekLink'))}
${slots}
`; } @@ -239,10 +239,10 @@ function renderTodayMeals(meals) { function renderPinnedNotes(notes) { if (!notes.length) { return `
- ${widgetHeader('pin', 'Pinnwand', 0, '/notes')} + ${widgetHeader('pin', t('nav.notes'), 0, '/notes')}
-
Keine angepinnten Notizen
+
${t('dashboard.noPinnedNotes')}
`; } @@ -256,7 +256,7 @@ function renderPinnedNotes(notes) { `).join(''); return `
- ${widgetHeader('pin', 'Pinnwand', notes.length, '/notes')} + ${widgetHeader('pin', t('nav.notes'), notes.length, '/notes')}
${items}
`; } @@ -274,7 +274,7 @@ function renderWeatherWidget(weather) { const forecastHtml = forecast.map((d, i) => { const date = new Date(d.date + 'T12:00:00'); - const label = date.toLocaleDateString('de-DE', { weekday: 'short' }); + const label = new Intl.DateTimeFormat(getLocale(), { weekday: 'short' }).format(date); const extraCls = i >= 3 ? ' weather-forecast__day--extended' : ''; return `
@@ -290,7 +290,7 @@ function renderWeatherWidget(weather) { return `
-
@@ -300,7 +300,7 @@ function renderWeatherWidget(weather) {
${current.desc}
${city}
- Gefühlt ${current.feels_like}° · ${current.humidity}% · Wind ${current.wind_speed} km/h + ${t('dashboard.weatherFeelsLike', { temp: current.feels_like, humidity: current.humidity, wind: current.wind_speed })}
[ + { route: '/tasks', label: t('dashboard.fabTask'), icon: 'check-square' }, + { route: '/calendar', label: t('dashboard.fabCalendar'), icon: 'calendar-plus' }, + { route: '/shopping', label: t('dashboard.fabShopping'), icon: 'shopping-cart' }, + { route: '/notes', label: t('dashboard.fabNote'), icon: 'sticky-note' }, ]; function renderFab() { - const actionsHtml = FAB_ACTIONS.map((a) => ` + const actionsHtml = FAB_ACTIONS().map((a) => `
+ aria-label="${a.label}"> ${a.label} -
@@ -315,58 +316,58 @@ function openNoteModal({ mode, note = null }) { const content = `
- + + placeholder="${t('notes.titlePlaceholder')}" value="${escHtml(isEdit && note.title ? note.title : '')}">
- +
- - - - - - - - - - - -
- +
${NOTE_COLORS.map((c) => `
`; openSharedModal({ - title: isEdit ? 'Notiz bearbeiten' : 'Neue Notiz', + title: isEdit ? t('notes.editNote') : t('notes.newNote'), content, size: 'md', onSave(panel) { @@ -427,7 +428,7 @@ function openNoteModal({ mode, note = null }) { const color = panel.querySelector('.note-color-swatch--active')?.dataset.color || NOTE_COLORS[0]; const pinned = panel.querySelector('#note-pinned').checked ? 1 : 0; - if (!cnt) { window.oikos?.showToast('Inhalt ist erforderlich', 'error'); return; } + if (!cnt) { window.oikos?.showToast(t('common.contentRequired'), 'error'); return; } saveBtn.disabled = true; saveBtn.textContent = '…'; @@ -444,12 +445,12 @@ function openNoteModal({ mode, note = null }) { } closeModal(); renderGrid(); - window.oikos?.showToast(mode === 'create' ? 'Notiz erstellt' : 'Notiz gespeichert', 'success'); + window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); btnError(saveBtn); saveBtn.disabled = false; - saveBtn.textContent = isEdit ? 'Speichern' : 'Erstellen'; + saveBtn.textContent = isEdit ? t('common.save') : t('common.create'); } }); }, @@ -468,20 +469,20 @@ async function togglePin(id) { state.notes.sort((a, b) => b.pinned - a.pinned); renderGrid(); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } async function deleteNote(id) { - if (!confirm('Notiz wirklich löschen?')) return; + if (!confirm(t('notes.deleteConfirm'))) return; try { await api.delete(`/notes/${id}`); state.notes = state.notes.filter((n) => n.id !== id); renderGrid(); vibrate([30, 50, 30]); - window.oikos?.showToast('Notiz gelöscht', 'success'); + window.oikos?.showToast(t('notes.deletedToast'), 'success'); } catch (err) { - window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error'); + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } diff --git a/public/pages/settings.js b/public/pages/settings.js index 29c5f4d..e47f380 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -5,6 +5,8 @@ */ import { api, auth } from '/api.js'; +import { t, formatDate, formatTime } from '/i18n.js'; +import '/components/oikos-locale-picker.js'; /** * @param {HTMLElement} container @@ -32,40 +34,58 @@ export async function render(container, { user }) { if (aStatus.status === 'fulfilled') appleStatus = aStatus.value; } catch (_) { /* non-critical */ } + const googleStatusText = googleStatus.connected + ? (googleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(googleStatus.lastSync) }) : t('settings.connected')) + : googleStatus.configured ? t('settings.notConnected') : t('settings.notConfigured'); + + const appleStatusText = appleStatus.connected + ? (appleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(appleStatus.lastSync) }) : t('settings.connected')) + : appleStatus.configured + ? (appleStatus.lastSync ? t('settings.configuredLastSync', { date: formatDateTime(appleStatus.lastSync) }) : t('settings.configured')) + : t('settings.notConnected'); + container.innerHTML = `
- ${syncOk ? `
Kalender-Sync mit ${syncOk === 'google' ? 'Google' : 'Apple'} erfolgreich verbunden.
` : ''} - ${syncErr ? `
Verbindung mit ${syncErr === 'google' ? 'Google' : 'Apple'} fehlgeschlagen. Bitte erneut versuchen.
` : ''} + ${syncOk ? `
${syncOk === 'google' ? t('settings.syncSuccessGoogle') : t('settings.syncSuccessApple')}
` : ''} + ${syncErr ? `
${syncErr === 'google' ? t('settings.syncErrorGoogle') : t('settings.syncErrorApple')}
` : ''}
-

Design

+

${t('settings.sectionDesign')}

-

Darstellung

+

${t('settings.cardAppearance')}

- - -
+ +
+

${t('settings.languageTitle')}

+
+ +
+
+
-

Mein Konto

+

${t('settings.sectionAccount')}

-

Passwort ändern

+

${t('settings.changePassword')}

- +
- +
- +
- +
-

Kalender-Synchronisation

+

${t('settings.sectionCalendarSync')}

@@ -116,21 +136,19 @@ export async function render(container, { user }) {
-
Google Calendar
+
${t('settings.googleCalendar')}
- ${googleStatus.connected - ? `Verbunden${googleStatus.lastSync ? ` · Zuletzt: ${formatDate(googleStatus.lastSync)}` : ''}` - : googleStatus.configured ? 'Nicht verbunden' : 'Nicht konfiguriert (fehlende .env-Variablen)'} + ${googleStatusText}
${googleStatus.configured ? `
${googleStatus.connected ? ` - - ${user?.role === 'admin' ? `` : ''} + + ${user?.role === 'admin' ? `` : ''} ` : ` - ${user?.role === 'admin' ? `Mit Google verbinden` : 'Nur Admin kann Google Calendar verbinden.'} + ${user?.role === 'admin' ? `${t('settings.connectGoogle')}` : `${t('settings.googleOnlyAdmin')}`} `}
` : ''} @@ -145,84 +163,80 @@ export async function render(container, { user }) {
-
Apple Calendar (iCloud)
+
${t('settings.appleCalendar')}
- ${appleStatus.connected - ? `Verbunden${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}` - : appleStatus.configured - ? `Konfiguriert (via .env)${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}` - : 'Nicht verbunden'} + ${appleStatusText}
${appleStatus.configured ? `
- - ${appleStatus.connected && user?.role === 'admin' ? `` : ''} + + ${appleStatus.connected && user?.role === 'admin' ? `` : ''}
` : user?.role === 'admin' ? `
- - + +
- +
- + - Passwort unter appleid.apple.com → Sicherheit erstellen. + ${t('settings.applePasswordHint')}
- +
- ` : 'Nur Admin kann Apple Calendar verbinden.'} + ` : `${t('settings.appleOnlyAdmin')}`}
${user?.role === 'admin' ? `
-

Familienmitglieder

+

${t('settings.sectionFamily')}

    ${users.map(memberHtml).join('')}
- +
-

Neues Familienmitglied

+

${t('settings.newMemberTitle')}

- +
- +
- +
- +
- +
- - + +
@@ -231,7 +245,7 @@ export async function render(container, { user }) {
- +
`; @@ -270,7 +284,7 @@ function bindEvents(container, user) { errorEl.hidden = true; if (newPw !== confirmPw) { - showError(errorEl, 'Passwörter stimmen nicht überein.'); + showError(errorEl, t('settings.passwordMismatch')); return; } @@ -279,7 +293,7 @@ function bindEvents(container, user) { try { await api.patch('/auth/me/password', { current_password: currentPw, new_password: newPw }); passwordForm.reset(); - window.oikos?.showToast('Passwort erfolgreich geändert.', 'success'); + window.oikos?.showToast(t('settings.passwordSavedToast'), 'success'); } catch (err) { showError(errorEl, err.message); } finally { @@ -293,15 +307,15 @@ function bindEvents(container, user) { if (googleSyncBtn) { googleSyncBtn.addEventListener('click', async () => { googleSyncBtn.disabled = true; - googleSyncBtn.textContent = 'Synchronisiere…'; + googleSyncBtn.textContent = t('settings.synchronizing'); try { await api.post('/calendar/google/sync', {}); - window.oikos?.showToast('Google Calendar synchronisiert.', 'success'); + window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Google Calendar' }), 'success'); } catch (err) { window.oikos?.showToast(err.message, 'danger'); } finally { googleSyncBtn.disabled = false; - googleSyncBtn.textContent = 'Jetzt synchronisieren'; + googleSyncBtn.textContent = t('settings.syncNow'); } }); } @@ -310,10 +324,10 @@ function bindEvents(container, user) { const googleDisconnectBtn = container.querySelector('#google-disconnect-btn'); if (googleDisconnectBtn) { googleDisconnectBtn.addEventListener('click', async () => { - if (!confirm('Google Calendar-Verbindung trennen?')) return; + if (!confirm(t('settings.googleDisconnectConfirm'))) return; try { await api.delete('/calendar/google/disconnect'); - window.oikos?.showToast('Google Calendar getrennt.', 'default'); + window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Google Calendar' }), 'default'); window.oikos?.navigate('/settings'); } catch (err) { window.oikos?.showToast(err.message, 'danger'); @@ -326,15 +340,15 @@ function bindEvents(container, user) { if (appleSyncBtn) { appleSyncBtn.addEventListener('click', async () => { appleSyncBtn.disabled = true; - appleSyncBtn.textContent = 'Synchronisiere…'; + appleSyncBtn.textContent = t('settings.synchronizing'); try { await api.post('/calendar/apple/sync', {}); - window.oikos?.showToast('Apple Calendar synchronisiert.', 'success'); + window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Apple Calendar' }), 'success'); } catch (err) { window.oikos?.showToast(err.message, 'danger'); } finally { appleSyncBtn.disabled = false; - appleSyncBtn.textContent = 'Jetzt synchronisieren'; + appleSyncBtn.textContent = t('settings.syncNow'); } }); } @@ -343,10 +357,10 @@ function bindEvents(container, user) { const appleDisconnectBtn = container.querySelector('#apple-disconnect-btn'); if (appleDisconnectBtn) { appleDisconnectBtn.addEventListener('click', async () => { - if (!confirm('Apple Calendar-Verbindung trennen?')) return; + if (!confirm(t('settings.appleDisconnectConfirm'))) return; try { await api.delete('/calendar/apple/disconnect'); - window.oikos?.showToast('Apple Calendar getrennt.', 'default'); + window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Apple Calendar' }), 'default'); window.oikos?.navigate('/settings'); } catch (err) { window.oikos?.showToast(err.message, 'danger'); @@ -368,16 +382,16 @@ function bindEvents(container, user) { const btn = container.querySelector('#apple-connect-btn'); btn.disabled = true; - btn.textContent = 'Verbinde…'; + btn.textContent = t('settings.appleConnecting'); try { await api.post('/calendar/apple/connect', { url, username, password }); - window.oikos?.showToast('Apple Calendar verbunden.', 'success'); + window.oikos?.showToast(t('settings.appleConnectedToast'), 'success'); window.oikos?.navigate('/settings'); } catch (err) { showError(errorEl, err.message); } finally { btn.disabled = false; - btn.textContent = 'Verbinden & testen'; + btn.textContent = t('settings.appleConnectBtn'); } }); } @@ -425,7 +439,7 @@ function bindEvents(container, user) { addMemberForm.reset(); container.querySelector('#add-member-form-card').classList.add('settings-card--hidden'); container.querySelector('#add-member-btn').hidden = false; - window.oikos?.showToast(`${res.user.display_name} hinzugefügt.`, 'success'); + window.oikos?.showToast(t('settings.memberAddedToast', { name: res.user.display_name }), 'success'); bindDeleteButtons(container, user); } catch (err) { showError(errorEl, err.message); @@ -458,11 +472,11 @@ function bindDeleteButtons(container, user) { btn.addEventListener('click', async () => { const id = parseInt(btn.dataset.deleteUser, 10); const name = btn.dataset.name; - if (!confirm(`${name} wirklich löschen?`)) return; + if (!confirm(t('settings.deleteMemberConfirm', { name }))) return; try { await auth.deleteUser(id); btn.closest('.settings-member').remove(); - window.oikos?.showToast(`${name} gelöscht.`, 'default'); + window.oikos?.showToast(t('settings.memberDeletedToast', { name }), 'default'); } catch (err) { window.oikos?.showToast(err.message, 'danger'); } @@ -480,9 +494,9 @@ function memberHtml(u) {
${initials(u.display_name)}
${u.display_name} - @${u.username} · ${u.role === 'admin' ? 'Admin' : 'Mitglied'} + @${u.username} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}
- @@ -494,9 +508,10 @@ function initials(name) { return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase(); } -function formatDate(iso) { +function formatDateTime(iso) { if (!iso) return ''; - return new Date(iso).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + const d = new Date(iso); + return `${formatDate(d)} ${formatTime(d)}`.trim(); } function currentTheme() { diff --git a/public/pages/shopping.js b/public/pages/shopping.js index cd15326..b6991dc 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -6,6 +6,7 @@ import { api } from '/api.js'; import { stagger, vibrate } from '/utils/ux.js'; +import { t } from '/i18n.js'; // -------------------------------------------------------- // Konstanten @@ -21,6 +22,18 @@ const ITEM_CATEGORIES = [ 'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges', ]; +const CATEGORY_LABELS = () => ({ + 'Obst & Gemüse': t('shopping.catFruitVeg'), + 'Backwaren': t('shopping.catBakery'), + 'Milchprodukte': t('shopping.catDairy'), + 'Fleisch & Fisch': t('shopping.catMeatFish'), + 'Tiefkühl': t('shopping.catFrozen'), + 'Getränke': t('shopping.catDrinks'), + 'Haushalt': t('shopping.catHousehold'), + 'Drogerie': t('shopping.catDrugstore'), + 'Sonstiges': t('shopping.catMisc'), +}); + const CATEGORY_ICONS = { 'Obst & Gemüse': 'apple', 'Backwaren': 'wheat', @@ -95,9 +108,9 @@ function renderListContent(container) { content.innerHTML = `
-
Keine Listen
+
${t('shopping.noLists')}
- Erstelle eine Liste mit dem + Button. + ${t('shopping.noListsDescription')}
`; if (window.lucide) window.lucide.createIcons(); @@ -110,7 +123,7 @@ function renderListContent(container) {
+ role="button" tabindex="0" aria-label="${t('shopping.renameListLabel')}"> ${state.activeList.name} @@ -119,10 +132,10 @@ function renderListContent(container) { ` : ''} @@ -134,17 +147,17 @@ function renderListContent(container) {
+ placeholder="${t('shopping.itemNamePlaceholder')}" aria-label="${t('shopping.itemNameLabel')}" autocomplete="off"> + placeholder="${t('shopping.itemQtyPlaceholder')}" aria-label="${t('shopping.itemQtyLabel')}" autocomplete="off">
- ${ITEM_CATEGORIES.map((c) => `` ).join('')} -
@@ -170,17 +183,18 @@ function renderItems() { -
Die Liste ist leer
-
Artikel über das Eingabefeld oben hinzufügen.
+
${t('shopping.emptyList')}
+
${t('shopping.emptyListDescription')}
`; } + const catLabels = CATEGORY_LABELS(); const groups = groupItemsByCategory(state.items); return groups.map(([cat, items]) => `
- ${cat} + ${catLabels[cat] || cat}
${items.map(renderItem).join('')}
`).join(''); @@ -192,17 +206,17 @@ function renderItem(item) {
@@ -210,7 +224,7 @@ function renderItem(item) { ${item.quantity ? `
${escHtml(item.quantity)}
` : ''}
@@ -474,7 +488,7 @@ function updateItemsList(container) { `); if (window.lucide) window.lucide.createIcons(); } else if (clearBtn) { @@ -483,7 +497,7 @@ function updateItemsList(container) { } else { clearBtn.innerHTML = ` - Abgehakt löschen (${checkedCount})`; + ${t('shopping.clearChecked', { count: checkedCount })}`; if (window.lucide) window.lucide.createIcons(); } } @@ -509,7 +523,7 @@ async function loadLists() { } catch (err) { console.error('[Shopping] loadLists Fehler:', err); state.lists = []; - window.oikos?.showToast('Listen konnten nicht geladen werden.', 'danger'); + window.oikos?.showToast(t('shopping.listsLoadError'), 'danger'); } } @@ -528,7 +542,7 @@ async function switchList(listId, container) { console.error('[Shopping] loadItems Fehler:', err); state.items = []; state.activeList = state.lists.find((l) => l.id === listId) ?? null; - window.oikos?.showToast('Artikel konnten nicht geladen werden.', 'danger'); + window.oikos?.showToast(t('shopping.itemsLoadError'), 'danger'); } renderListContent(container); wireListContentEvents(container); @@ -548,7 +562,7 @@ function wireTabBar(container) { } if (target.dataset.action === 'new-list') { - const name = prompt('Name der neuen Liste:'); + const name = prompt(t('shopping.newListPrompt')); if (!name?.trim()) return; try { const data = await api.post('/shopping', { name: name.trim() }); @@ -621,7 +635,7 @@ function wireListContentEvents(container) { updateItemsList(container); updateListCounter(state.activeListId, -count, -count); renderTabs(container); - window.oikos.showToast(`${count} Artikel entfernt.`); + window.oikos.showToast(t('shopping.itemsRemovedToast', { count })); } catch (err) { window.oikos.showToast(err.message, 'danger'); } @@ -629,7 +643,7 @@ function wireListContentEvents(container) { // ---- Liste umbenennen ---- if (action === 'rename-list') { - const newName = prompt('Neuer Listen-Name:', state.activeList?.name); + const newName = prompt(t('shopping.renameListPrompt'), state.activeList?.name); if (!newName?.trim() || newName.trim() === state.activeList?.name) return; try { const data = await api.put(`/shopping/${state.activeListId}`, { name: newName.trim() }); @@ -646,7 +660,7 @@ function wireListContentEvents(container) { // ---- Liste löschen ---- if (action === 'delete-list') { - if (!confirm(`Liste "${state.activeList?.name}" und alle Artikel löschen?`)) return; + if (!confirm(t('shopping.deleteListConfirm', { name: state.activeList?.name }))) return; try { await api.delete(`/shopping/${state.activeListId}`); state.lists = state.lists.filter((l) => l.id !== state.activeListId); @@ -659,7 +673,7 @@ function wireListContentEvents(container) { renderTabs(container); renderListContent(container); } - window.oikos.showToast('Liste gelöscht.'); + window.oikos.showToast(t('shopping.deletedListToast')); } catch (err) { window.oikos.showToast(err.message, 'danger'); } @@ -701,15 +715,15 @@ export async function render(container, { user }) { } } catch (err) { console.error('[Shopping] Ladefehler:', err.message); - window.oikos.showToast('Einkaufslisten konnten nicht geladen werden.', 'danger'); + window.oikos.showToast(t('shopping.listsLoadError'), 'danger'); } container.innerHTML = `
-

Einkaufslisten

+

${t('shopping.title')}

-
diff --git a/public/pages/tasks.js b/public/pages/tasks.js index da28ae2..4621be7 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -8,31 +8,43 @@ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; +import { t, formatDate } from '/i18n.js'; // -------------------------------------------------------- // Konstanten // -------------------------------------------------------- -const PRIORITIES = [ - { value: 'urgent', label: 'Dringend', color: 'var(--color-priority-urgent)' }, - { value: 'high', label: 'Hoch', color: 'var(--color-priority-high)' }, - { value: 'medium', label: 'Mittel', color: 'var(--color-priority-medium)' }, - { value: 'low', label: 'Niedrig', color: 'var(--color-priority-low)' }, +const PRIORITIES = () => [ + { value: 'urgent', label: t('tasks.priorityUrgent'), color: 'var(--color-priority-urgent)' }, + { value: 'high', label: t('tasks.priorityHigh'), color: 'var(--color-priority-high)' }, + { value: 'medium', label: t('tasks.priorityMedium'), color: 'var(--color-priority-medium)' }, + { value: 'low', label: t('tasks.priorityLow'), color: 'var(--color-priority-low)' }, ]; -const STATUSES = [ - { value: 'open', label: 'Offen' }, - { value: 'in_progress', label: 'In Bearbeitung'}, - { value: 'done', label: 'Erledigt' }, +const STATUSES = () => [ + { value: 'open', label: t('tasks.statusOpen') }, + { value: 'in_progress', label: t('tasks.statusInProgress') }, + { value: 'done', label: t('tasks.statusDone') }, ]; const CATEGORIES = [ - 'Haushalt','Schule','Einkauf','Reparatur', - 'Gesundheit','Finanzen','Freizeit','Sonstiges', + 'Haushalt', 'Schule', 'Einkauf', 'Reparatur', + 'Gesundheit', 'Finanzen', 'Freizeit', 'Sonstiges', ]; -const PRIORITY_LABELS = Object.fromEntries(PRIORITIES.map((p) => [p.value, p.label])); -const STATUS_LABELS = Object.fromEntries(STATUSES.map((s) => [s.value, s.label])); +const CATEGORY_LABELS = () => ({ + 'Haushalt': t('tasks.categoryHousehold'), + 'Schule': t('tasks.categorySchool'), + 'Einkauf': t('tasks.categoryShopping'), + 'Reparatur': t('tasks.categoryRepair'), + 'Gesundheit': t('tasks.categoryHealth'), + 'Finanzen': t('tasks.categoryFinance'), + 'Freizeit': t('tasks.categoryLeisure'), + 'Sonstiges': t('tasks.categoryMisc'), +}); + +const PRIORITY_LABELS = () => Object.fromEntries(PRIORITIES().map((p) => [p.value, p.label])); +const STATUS_LABELS = () => Object.fromEntries(STATUSES().map((s) => [s.value, s.label])); // -------------------------------------------------------- // Hilfsfunktionen @@ -49,10 +61,10 @@ function formatDueDate(dateStr) { now.setHours(0, 0, 0, 0); const diffDays = Math.round((due - now) / 86400000); - if (diffDays < 0) return { label: `${Math.abs(diffDays)}d überfällig`, cls: 'due-date--overdue' }; - if (diffDays === 0) return { label: 'Heute fällig', cls: 'due-date--today' }; - if (diffDays === 1) return { label: 'Morgen fällig', cls: '' }; - return { label: due.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }), cls: '' }; + if (diffDays < 0) return { label: t('tasks.overdueDay', { count: Math.abs(diffDays) }), cls: 'due-date--overdue' }; + if (diffDays === 0) return { label: t('tasks.dueToday'), cls: 'due-date--today' }; + if (diffDays === 1) return { label: t('tasks.dueTomorrow'), cls: '' }; + return { label: formatDate(due), cls: '' }; } function groupBy(tasks, mode) { @@ -67,21 +79,28 @@ function groupBy(tasks, mode) { } // mode === 'due' - for (const t of tasks) { + const groupOverdue = t('tasks.groupOverdue'); + const groupToday = t('tasks.groupToday'); + const groupThisWeek = t('tasks.groupThisWeek'); + const groupNextWeek = t('tasks.groupNextWeek'); + const groupLater = t('tasks.groupLater'); + const groupNoDate = t('tasks.groupNoDate'); + + for (const task of tasks) { let key; - if (!t.due_date) key = 'Kein Datum'; + if (!task.due_date) key = groupNoDate; else { - const diff = Math.round((new Date(t.due_date) - new Date().setHours(0,0,0,0)) / 86400000); - if (diff < 0) key = 'Überfällig'; - else if (diff === 0) key = 'Heute'; - else if (diff <= 3) key = 'Diese Woche'; - else if (diff <= 7) key = 'Nächste Woche'; - else key = 'Später'; + const diff = Math.round((new Date(task.due_date) - new Date().setHours(0,0,0,0)) / 86400000); + if (diff < 0) key = groupOverdue; + else if (diff === 0) key = groupToday; + else if (diff <= 3) key = groupThisWeek; + else if (diff <= 7) key = groupNextWeek; + else key = groupLater; } - (groups[key] = groups[key] || []).push(t); + (groups[key] = groups[key] || []).push(task); } - const order = ['Überfällig', 'Heute', 'Diese Woche', 'Nächste Woche', 'Später', 'Kein Datum']; + const order = [groupOverdue, groupToday, groupThisWeek, groupNextWeek, groupLater, groupNoDate]; return order.filter((k) => groups[k]).map((k) => [k, groups[k]]); } @@ -92,7 +111,7 @@ function groupBy(tasks, mode) { function renderPriorityBadge(priority) { return ` - ${PRIORITY_LABELS[priority] ?? priority} + ${PRIORITY_LABELS()[priority] ?? priority} `; } @@ -110,11 +129,11 @@ function renderSwipeRow(task, innerHtml) {
${innerHtml}
`; @@ -133,7 +152,7 @@ function renderTaskCard(task, opts = {}) { data-subtask-id="${s.id}"> ${s.title} @@ -145,7 +164,7 @@ function renderTaskCard(task, opts = {}) {
@@ -156,8 +175,8 @@ function renderTaskCard(task, opts = {}) {
${renderPriorityBadge(task.priority)} ${renderDueDate(task.due_date)} - ${task.is_recurring ? '' : ''} - ${task.category !== 'Sonstiges' ? `${task.category}` : ''} + ${task.is_recurring ? `` : ''} + ${task.category !== 'Sonstiges' ? `${CATEGORY_LABELS()[task.category] ?? task.category}` : ''}
@@ -168,14 +187,14 @@ function renderTaskCard(task, opts = {}) {
` : ''}
${progress !== null ? `
+ aria-label="${t('tasks.subtaskToggle')}">
@@ -187,7 +206,7 @@ function renderTaskCard(task, opts = {}) { id="subtasks-${task.id}"> ${subtasksHtml}
` : ''}
`; @@ -200,8 +219,8 @@ function renderTaskGroups(tasks, groupMode) { -
Keine Aufgaben — alles erledigt?
-
Neue Aufgaben über den + Button erstellen.
+
${t('tasks.emptyTitle')}
+
${t('tasks.emptyDescription')}
`; } @@ -227,11 +246,12 @@ function renderModalContent({ task = null, users = [] } = {}) { `` ).join(''); + const catLabels = CATEGORY_LABELS(); const categoryOptions = CATEGORIES.map((c) => - `` + `` ).join(''); - const priorityOptions = PRIORITIES.map((p) => + const priorityOptions = PRIORITIES().map((p) => `` ).join(''); @@ -241,36 +261,36 @@ function renderModalContent({ task = null, users = [] } = {}) {
- +
- Dieses Feld ist erforderlich. + ${t('common.required')}
- +
- +
- + @@ -279,30 +299,30 @@ function renderModalContent({ task = null, users = [] } = {}) {
- +
- +
- +
${isEdit ? `
- + @@ -315,9 +335,9 @@ function renderModalContent({ task = null, users = [] } = {}) { `; @@ -375,7 +395,7 @@ async function loadTaskForEdit(id) { function openTaskModal({ task = null, users = [] } = {}, container) { const isEdit = !!task; openSharedModal({ - title: isEdit ? 'Aufgabe bearbeiten' : 'Neue Aufgabe', + title: isEdit ? t('tasks.editTask') : t('tasks.newTask'), content: renderModalContent({ task, users }), size: 'lg', onSave(panel) { @@ -408,9 +428,9 @@ async function handleFormSubmit(e, container) { errorEl.hidden = true; submitBtn.disabled = true; - submitBtn.textContent = 'Wird gespeichert…'; + submitBtn.textContent = t('common.saving'); - const originalLabel = taskId ? 'Speichern' : 'Erstellen'; + const originalLabel = taskId ? t('common.save') : t('common.create'); const rrule = getRRuleValues(document, 'task'); const body = { @@ -429,10 +449,10 @@ async function handleFormSubmit(e, container) { try { if (taskId) { await api.put(`/tasks/${taskId}`, body); - window.oikos.showToast('Aufgabe gespeichert.', 'success'); + window.oikos.showToast(t('tasks.savedToast'), 'success'); } else { await api.post('/tasks', body); - window.oikos.showToast('Aufgabe erstellt.', 'success'); + window.oikos.showToast(t('tasks.createdToast'), 'success'); } btnSuccess(submitBtn, originalLabel); setTimeout(() => closeModal(), 700); @@ -447,11 +467,11 @@ async function handleFormSubmit(e, container) { } async function handleDeleteTask(id, container) { - if (!confirm('Aufgabe und alle Teilaufgaben löschen?')) return; + if (!confirm(t('tasks.deleteConfirm'))) return; try { await api.delete(`/tasks/${id}`); closeModal(); - window.oikos.showToast('Aufgabe gelöscht.', 'default'); + window.oikos.showToast(t('tasks.deletedToast'), 'default'); await loadTasks(container); } catch (err) { window.oikos.showToast(err.message, 'danger'); @@ -459,7 +479,7 @@ async function handleDeleteTask(id, container) { } async function handleAddSubtask(parentId, container) { - const title = prompt('Teilaufgabe:'); + const title = prompt(t('tasks.subtaskPrompt')); if (!title?.trim()) return; try { await api.post('/tasks', { title: title.trim(), parent_task_id: parentId }); @@ -473,10 +493,10 @@ async function handleAddSubtask(parentId, container) { // Kanban-Ansicht // -------------------------------------------------------- -const KANBAN_COLS = [ - { status: 'open', label: 'Offen', colorVar: '--color-text-secondary' }, - { status: 'in_progress', label: 'In Bearbeitung', colorVar: '--color-warning' }, - { status: 'done', label: 'Erledigt', colorVar: '--color-success' }, +const KANBAN_COLS = () => [ + { status: 'open', label: t('tasks.kanbanOpen'), colorVar: '--color-text-secondary' }, + { status: 'in_progress', label: t('tasks.kanbanInProgress'), colorVar: '--color-warning' }, + { status: 'done', label: t('tasks.kanbanDone'), colorVar: '--color-success' }, ]; function renderKanbanCard(task) { @@ -503,8 +523,9 @@ function renderKanban(container) { const listEl = container.querySelector('#task-list'); if (!listEl) return; + const cols = KANBAN_COLS(); const grouped = {}; - for (const col of KANBAN_COLS) grouped[col.status] = []; + for (const col of cols) grouped[col.status] = []; for (const t of state.tasks) { if (grouped[t.status]) grouped[t.status].push(t); else grouped['open'].push(t); @@ -512,7 +533,7 @@ function renderKanban(container) { listEl.innerHTML = `
- ${KANBAN_COLS.map((col) => ` + ${cols.map((col) => `
@@ -606,7 +627,7 @@ function wireKanbanDrag(container) { const task = await loadTaskForEdit(card.dataset.taskId); openTaskModal({ task, users: state.users }, container); } catch (err) { - window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger'); + window.oikos.showToast(t('tasks.loadError'), 'danger'); } } }); @@ -635,15 +656,17 @@ function renderFilters(container) { if (!bar) return; const chips = []; + const statusLabels = STATUS_LABELS(); + const priorityLabels = PRIORITY_LABELS(); if (state.filters.status) { chips.push(` - ${STATUS_LABELS[state.filters.status]} + ${statusLabels[state.filters.status]} `); } if (state.filters.priority) { chips.push(` - ${PRIORITY_LABELS[state.filters.priority]} + ${priorityLabels[state.filters.priority]} `); } @@ -657,12 +680,12 @@ function renderFilters(container) { // Inaktive Filter-Chips (zum Aktivieren) if (!state.filters.status) { - STATUSES.forEach((s) => { + STATUSES().forEach((s) => { chips.push(`${s.label}`); }); } if (!state.filters.priority) { - PRIORITIES.forEach((p) => { + PRIORITIES().forEach((p) => { chips.push(`${p.label}`); }); } @@ -802,7 +825,7 @@ function wireSwipeGestures(container) { const task = await loadTaskForEdit(taskId); openTaskModal({ task, users: state.users }, container); } catch (err) { - window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger'); + window.oikos.showToast(t('tasks.loadError'), 'danger'); } } else { @@ -912,7 +935,7 @@ function wireTaskList(container) { const task = await loadTaskForEdit(id); openTaskModal({ task, users: state.users }, container); } catch (err) { - window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger'); + window.oikos.showToast(t('tasks.loadError'), 'danger'); } } @@ -931,24 +954,24 @@ export async function render(container, { user }) { container.innerHTML = `
-

Aufgaben

+

${t('tasks.title')}

- - + +
@@ -963,7 +986,7 @@ export async function render(container, { user }) {
`).join('')}
-
@@ -981,7 +1004,7 @@ export async function render(container, { user }) { state.users = metaData.users ?? []; } catch (err) { console.error('[Tasks] Ladefehler:', err.message); - window.oikos.showToast('Aufgaben konnten nicht geladen werden.', 'danger'); + window.oikos.showToast(t('tasks.loadError'), 'danger'); state.tasks = []; state.users = []; } diff --git a/public/router.js b/public/router.js index e4e1745..5f21124 100644 --- a/public/router.js +++ b/public/router.js @@ -5,6 +5,7 @@ */ import { auth } from '/api.js'; +import { initI18n, getLocale, t } from '/i18n.js'; // -------------------------------------------------------- // Routen-Definitionen @@ -215,8 +216,8 @@ async function renderPage(route, previousPath = null) { */ function renderAppShell(container) { container.innerHTML = ` - Zum Inhalt springen -