feat: internationalization (i18n) — de + en, oikos-locale-picker, Intl date formatting
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "1.0.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "1.0.0",
|
"version": "0.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
"test:calendar": "node --experimental-sqlite test-calendar.js",
|
"test:calendar": "node --experimental-sqlite test-calendar.js",
|
||||||
"test:ncb": "node --experimental-sqlite test-notes-contacts-budget.js",
|
"test:ncb": "node --experimental-sqlite test-notes-contacts-budget.js",
|
||||||
"test:ux-utils": "node test-ux-utils.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"
|
"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": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
* Focus-Restore, Scroll-Lock und aria-modal.
|
* Focus-Restore, Scroll-Lock und aria-modal.
|
||||||
* Auf Mobile: Bottom Sheet mit Swipe-to-Close und Slide-out-Animation.
|
* Auf Mobile: Bottom Sheet mit Swipe-to-Close und Slide-out-Animation.
|
||||||
* Abhängigkeiten: CSS-Klassen aus layout.css (.modal-overlay, .modal-panel, etc.)
|
* Abhängigkeiten: CSS-Klassen aus layout.css (.modal-overlay, .modal-panel, etc.)
|
||||||
|
* i18n.js (t)
|
||||||
*
|
*
|
||||||
* API:
|
* API:
|
||||||
* openModal({ title, content, onSave, onDelete, size }) → void
|
* openModal({ title, content, onSave, onDelete, size }) → void
|
||||||
* closeModal() → void
|
* closeModal() → void
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { t } from '/i18n.js';
|
||||||
|
|
||||||
let activeOverlay = null;
|
let activeOverlay = null;
|
||||||
let previouslyFocused = null;
|
let previouslyFocused = null;
|
||||||
let focusTrapHandler = null;
|
let focusTrapHandler = null;
|
||||||
@@ -191,7 +194,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
|
|||||||
aria-labelledby="shared-modal-title">
|
aria-labelledby="shared-modal-title">
|
||||||
<div class="modal-panel__header">
|
<div class="modal-panel__header">
|
||||||
<h2 class="modal-panel__title" id="shared-modal-title">${title}</h2>
|
<h2 class="modal-panel__title" id="shared-modal-title">${title}</h2>
|
||||||
<button class="modal-panel__close" data-action="close-modal" aria-label="Schließen">
|
<button class="modal-panel__close" data-action="close-modal" aria-label="${t('modal.closeLabel')}">
|
||||||
<i data-lucide="x" style="width:18px;height:18px" aria-hidden="true"></i>
|
<i data-lucide="x" style="width:18px;height:18px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Install-Prompt Web Component
|
* Modul: Install-Prompt Web Component
|
||||||
* Zweck: Dezentes Banner für PWA-Installation (Chrome/Android) und iOS-Anleitung
|
* 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:
|
* Verhalten:
|
||||||
* - Chrome/Android: Fängt beforeinstallprompt ab, zeigt Install-Banner
|
* - Chrome/Android: Fängt beforeinstallprompt ab, zeigt Install-Banner
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
* - Timing: Banner erst nach 2 Nutzer-Interaktionen anzeigen
|
* - Timing: Banner erst nach 2 Nutzer-Interaktionen anzeigen
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { t } from '/i18n.js';
|
||||||
|
|
||||||
const DISMISS_KEY = 'oikos-install-dismissed';
|
const DISMISS_KEY = 'oikos-install-dismissed';
|
||||||
const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage
|
const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage
|
||||||
|
|
||||||
@@ -39,6 +41,14 @@ class OikosInstallPrompt extends HTMLElement {
|
|||||||
return;
|
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
|
// Noch nicht genug Interaktionen
|
||||||
const interactions = Number(localStorage.getItem(INTERACTION_KEY) || '0');
|
const interactions = Number(localStorage.getItem(INTERACTION_KEY) || '0');
|
||||||
if (interactions < INTERACTION_THRESHOLD) {
|
if (interactions < INTERACTION_THRESHOLD) {
|
||||||
@@ -56,6 +66,9 @@ class OikosInstallPrompt extends HTMLElement {
|
|||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
window.removeEventListener('beforeinstallprompt', this._onBeforeInstall);
|
window.removeEventListener('beforeinstallprompt', this._onBeforeInstall);
|
||||||
if (this._offInteraction) this._offInteraction();
|
if (this._offInteraction) this._offInteraction();
|
||||||
|
if (this._onLocaleChanged) {
|
||||||
|
window.removeEventListener('locale-changed', this._onLocaleChanged);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_waitForInteractions() {
|
_waitForInteractions() {
|
||||||
@@ -97,6 +110,7 @@ class OikosInstallPrompt extends HTMLElement {
|
|||||||
|
|
||||||
/** Banner rendern */
|
/** Banner rendern */
|
||||||
_showBanner(isIOS) {
|
_showBanner(isIOS) {
|
||||||
|
this._currentIsIOS = isIOS;
|
||||||
this._shadow.innerHTML = '';
|
this._shadow.innerHTML = '';
|
||||||
|
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
@@ -242,7 +256,7 @@ class OikosInstallPrompt extends HTMLElement {
|
|||||||
|
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.className = 'title';
|
title.className = 'title';
|
||||||
title.textContent = 'Oikos installieren';
|
title.textContent = t('install.title');
|
||||||
|
|
||||||
const subtitle = document.createElement('div');
|
const subtitle = document.createElement('div');
|
||||||
subtitle.className = 'subtitle';
|
subtitle.className = 'subtitle';
|
||||||
@@ -251,12 +265,12 @@ class OikosInstallPrompt extends HTMLElement {
|
|||||||
// iOS: Teilen-Icon als SVG inline
|
// iOS: Teilen-Icon als SVG inline
|
||||||
subtitle.innerHTML = '';
|
subtitle.innerHTML = '';
|
||||||
subtitle.append(
|
subtitle.append(
|
||||||
document.createTextNode('Tippe auf '),
|
document.createTextNode(t('install.iosTip1')),
|
||||||
this._createShareIcon(),
|
this._createShareIcon(),
|
||||||
document.createTextNode(' → „Zum Home-Bildschirm"')
|
document.createTextNode(t('install.iosTip2'))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
subtitle.textContent = 'Zur App hinzufügen';
|
subtitle.textContent = t('install.subtitle');
|
||||||
}
|
}
|
||||||
|
|
||||||
text.appendChild(title);
|
text.appendChild(title);
|
||||||
@@ -267,7 +281,7 @@ class OikosInstallPrompt extends HTMLElement {
|
|||||||
if (!isIOS) {
|
if (!isIOS) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'btn-install';
|
btn.className = 'btn-install';
|
||||||
btn.textContent = 'Installieren';
|
btn.textContent = t('install.installButton');
|
||||||
btn.addEventListener('click', () => this._onInstallClick());
|
btn.addEventListener('click', () => this._onInstallClick());
|
||||||
banner.appendChild(btn);
|
banner.appendChild(btn);
|
||||||
}
|
}
|
||||||
@@ -275,7 +289,7 @@ class OikosInstallPrompt extends HTMLElement {
|
|||||||
// Dismiss-Button
|
// Dismiss-Button
|
||||||
const dismiss = document.createElement('button');
|
const dismiss = document.createElement('button');
|
||||||
dismiss.className = 'btn-dismiss';
|
dismiss.className = 'btn-dismiss';
|
||||||
dismiss.setAttribute('aria-label', 'Schließen');
|
dismiss.setAttribute('aria-label', t('install.dismissLabel'));
|
||||||
dismiss.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
dismiss.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||||
dismiss.addEventListener('click', () => this._dismiss());
|
dismiss.addEventListener('click', () => this._dismiss());
|
||||||
banner.appendChild(dismiss);
|
banner.appendChild(dismiss);
|
||||||
|
|||||||
@@ -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);
|
||||||
+108
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 <strong>appleid.apple.com → Sicherheit</strong> 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <strong>appleid.apple.com → Security</strong>.",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-46
@@ -8,6 +8,7 @@
|
|||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||||
import { stagger, vibrate } from '/utils/ux.js';
|
import { stagger, vibrate } from '/utils/ux.js';
|
||||||
|
import { t, formatDate, getLocale } from '/i18n.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -18,8 +19,23 @@ const CATEGORIES = [
|
|||||||
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
|
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
|
||||||
];
|
];
|
||||||
|
|
||||||
const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
const CATEGORY_LABELS = () => ({
|
||||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
'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
|
// State
|
||||||
@@ -43,7 +59,7 @@ function formatAmount(n) {
|
|||||||
|
|
||||||
function formatMonthLabel(ym) {
|
function formatMonthLabel(ym) {
|
||||||
const [y, m] = ym.split('-');
|
const [y, m] = ym.split('-');
|
||||||
return `${MONTH_NAMES[parseInt(m, 10) - 1]} ${y}`;
|
return `${getMonthName(parseInt(m, 10) - 1)} ${y}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMonths(ym, n) {
|
function addMonths(ym, n) {
|
||||||
@@ -74,7 +90,7 @@ async function loadMonth(month) {
|
|||||||
state.entries = [];
|
state.entries = [];
|
||||||
state.summary = { income: 0, expenses: 0, balance: 0, byCategory: [] };
|
state.summary = { income: 0, expenses: 0, balance: 0, byCategory: [] };
|
||||||
state.prevSummary = null;
|
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 = `
|
container.innerHTML = `
|
||||||
<div class="budget-page">
|
<div class="budget-page">
|
||||||
<h1 class="sr-only">Budget</h1>
|
<h1 class="sr-only">${t('budget.title')}</h1>
|
||||||
<div class="budget-nav">
|
<div class="budget-nav">
|
||||||
<button class="btn btn--icon" id="budget-prev" aria-label="Vorheriger Monat">
|
<button class="btn btn--icon" id="budget-prev" aria-label="${t('budget.prevMonth')}">
|
||||||
<i data-lucide="chevron-left" aria-hidden="true"></i>
|
<i data-lucide="chevron-left" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="budget-nav__today" id="budget-today">Aktuell</button>
|
<button class="budget-nav__today" id="budget-today">${t('budget.currentMonth')}</button>
|
||||||
<span class="budget-nav__label" id="budget-label"></span>
|
<span class="budget-nav__label" id="budget-label"></span>
|
||||||
<button class="btn btn--primary btn--icon" id="budget-add" aria-label="Eintrag hinzufügen">
|
<button class="btn btn--primary btn--icon" id="budget-add" aria-label="${t('budget.addEntryLabel')}">
|
||||||
<i data-lucide="plus" aria-hidden="true"></i>
|
<i data-lucide="plus" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--icon" id="budget-next" aria-label="Nächster Monat">
|
<button class="btn btn--icon" id="budget-next" aria-label="${t('budget.nextMonth')}">
|
||||||
<i data-lucide="chevron-right" aria-hidden="true"></i>
|
<i data-lucide="chevron-right" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="budget-body" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
<div id="budget-body" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
||||||
<div style="padding:2rem;text-align:center;color:var(--color-text-disabled);">Lade…</div>
|
<div style="padding:2rem;text-align:center;color:var(--color-text-disabled);">${t('budget.loadingIndicator')}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="page-fab" id="fab-new-budget" aria-label="Neuer Eintrag">
|
<button class="page-fab" id="fab-new-budget" aria-label="${t('budget.newEntryFabLabel')}">
|
||||||
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,17 +187,17 @@ function renderBody() {
|
|||||||
<!-- Zusammenfassung -->
|
<!-- Zusammenfassung -->
|
||||||
<div class="budget-summary">
|
<div class="budget-summary">
|
||||||
<div class="budget-summary-card budget-summary-card--income">
|
<div class="budget-summary-card budget-summary-card--income">
|
||||||
<div class="budget-summary-card__label">Einnahmen</div>
|
<div class="budget-summary-card__label">${t('budget.income')}</div>
|
||||||
<div class="budget-summary-card__amount">${formatAmount(s.income)}</div>
|
<div class="budget-summary-card__amount">${formatAmount(s.income)}</div>
|
||||||
${p ? renderTrend(s.income, p.income, prevLabel) : ''}
|
${p ? renderTrend(s.income, p.income, prevLabel) : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="budget-summary-card budget-summary-card--expenses">
|
<div class="budget-summary-card budget-summary-card--expenses">
|
||||||
<div class="budget-summary-card__label">Ausgaben</div>
|
<div class="budget-summary-card__label">${t('budget.expenses')}</div>
|
||||||
<div class="budget-summary-card__amount">${formatAmount(Math.abs(s.expenses))}</div>
|
<div class="budget-summary-card__amount">${formatAmount(Math.abs(s.expenses))}</div>
|
||||||
${p ? renderTrend(s.expenses, p.expenses, prevLabel) : ''}
|
${p ? renderTrend(s.expenses, p.expenses, prevLabel) : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="budget-summary-card ${balanceClass}">
|
<div class="budget-summary-card ${balanceClass}">
|
||||||
<div class="budget-summary-card__label">Saldo</div>
|
<div class="budget-summary-card__label">${t('budget.balance')}</div>
|
||||||
<div class="budget-summary-card__amount">${formatAmount(s.balance)}</div>
|
<div class="budget-summary-card__amount">${formatAmount(s.balance)}</div>
|
||||||
${p ? renderTrend(s.balance, p.balance, prevLabel) : ''}
|
${p ? renderTrend(s.balance, p.balance, prevLabel) : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +206,7 @@ function renderBody() {
|
|||||||
<!-- Kategorie-Balken -->
|
<!-- Kategorie-Balken -->
|
||||||
${s.byCategory.length ? `
|
${s.byCategory.length ? `
|
||||||
<div class="budget-chart-section">
|
<div class="budget-chart-section">
|
||||||
<div class="budget-chart-section__title">Nach Kategorie</div>
|
<div class="budget-chart-section__title">${t('budget.byCategory')}</div>
|
||||||
<div class="budget-chart">
|
<div class="budget-chart">
|
||||||
${renderCategoryBars(s.byCategory)}
|
${renderCategoryBars(s.byCategory)}
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +215,7 @@ function renderBody() {
|
|||||||
<!-- Transaktionsliste -->
|
<!-- Transaktionsliste -->
|
||||||
<div class="budget-list-section">
|
<div class="budget-list-section">
|
||||||
<div class="budget-list-header">
|
<div class="budget-list-header">
|
||||||
<span class="budget-list-header__title">Transaktionen</span>
|
<span class="budget-list-header__title">${t('budget.transactions')}</span>
|
||||||
${state.entries.length ? `
|
${state.entries.length ? `
|
||||||
<a href="/api/v1/budget/export?month=${state.month}" class="btn btn--secondary"
|
<a href="/api/v1/budget/export?month=${state.month}" class="btn btn--secondary"
|
||||||
style="font-size:var(--text-sm);padding:var(--space-1) var(--space-3);">
|
style="font-size:var(--text-sm);padding:var(--space-1) var(--space-3);">
|
||||||
@@ -256,8 +272,8 @@ function renderEntries() {
|
|||||||
<line x1="12" y1="1" x2="12" y2="23"/>
|
<line x1="12" y1="1" x2="12" y2="23"/>
|
||||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="empty-state__title">Keine Einträge diesen Monat</div>
|
<div class="empty-state__title">${t('budget.emptyTitle')}</div>
|
||||||
<div class="empty-state__description">Budget-Einträge über den + Button hinzufügen.</div>
|
<div class="empty-state__description">${t('budget.emptyDescription')}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +293,7 @@ function renderEntries() {
|
|||||||
<div class="budget-entry__meta">${date} · ${escHtml(e.category)}${recurTag}</div>
|
<div class="budget-entry__meta">${date} · ${escHtml(e.category)}${recurTag}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div>
|
<div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div>
|
||||||
<button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="Eintrag löschen">
|
<button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="${t('budget.deleteLabel')}">
|
||||||
<i data-lucide="trash-2" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,7 +314,7 @@ function renderEntries() {
|
|||||||
function renderTrend(current, prev, prevLabel) {
|
function renderTrend(current, prev, prevLabel) {
|
||||||
const delta = current - prev;
|
const delta = current - prev;
|
||||||
if (Math.abs(delta) < 0.005) {
|
if (Math.abs(delta) < 0.005) {
|
||||||
return `<div class="budget-summary-card__trend budget-summary-card__trend--neutral">— wie ${prevLabel}</div>`;
|
return `<div class="budget-summary-card__trend budget-summary-card__trend--neutral">${t('budget.trendNeutral', { month: prevLabel })}</div>`;
|
||||||
}
|
}
|
||||||
const positive = delta > 0;
|
const positive = delta > 0;
|
||||||
const arrow = positive ? '▲' : '▼';
|
const arrow = positive ? '▲' : '▼';
|
||||||
@@ -308,8 +324,7 @@ function renderTrend(current, prev, prevLabel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatEntryDate(dateStr) {
|
function formatEntryDate(dateStr) {
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
return formatDate(new Date(dateStr + 'T00:00:00'));
|
||||||
return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -323,38 +338,39 @@ function openBudgetModal({ mode, entry = null }) {
|
|||||||
const isExpense = isEdit ? entry.amount < 0 : true;
|
const isExpense = isEdit ? entry.amount < 0 : true;
|
||||||
const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : '';
|
const absAmount = isEdit ? Math.abs(entry.amount).toFixed(2) : '';
|
||||||
|
|
||||||
|
const catLabels = CATEGORY_LABELS();
|
||||||
const catOpts = CATEGORIES.map((c) =>
|
const catOpts = CATEGORIES.map((c) =>
|
||||||
`<option value="${c}" ${isEdit && entry.category === c ? 'selected' : ''}>${c}</option>`
|
`<option value="${c}" ${isEdit && entry.category === c ? 'selected' : ''}>${catLabels[c] || c}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
<div class="amount-type-toggle">
|
<div class="amount-type-toggle">
|
||||||
<button class="amount-type-btn amount-type-btn--expenses ${isExpense ? 'amount-type-btn--active' : ''}"
|
<button class="amount-type-btn amount-type-btn--expenses ${isExpense ? 'amount-type-btn--active' : ''}"
|
||||||
id="type-expense" type="button">Ausgabe</button>
|
id="type-expense" type="button">${t('budget.typeExpense')}</button>
|
||||||
<button class="amount-type-btn amount-type-btn--income ${!isExpense ? 'amount-type-btn--active' : ''}"
|
<button class="amount-type-btn amount-type-btn--income ${!isExpense ? 'amount-type-btn--active' : ''}"
|
||||||
id="type-income" type="button">Einnahme</button>
|
id="type-income" type="button">${t('budget.typeIncome')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="bm-title">Titel *</label>
|
<label class="form-label" for="bm-title">${t('budget.titleLabel')}</label>
|
||||||
<input type="text" class="form-input" id="bm-title"
|
<input type="text" class="form-input" id="bm-title"
|
||||||
placeholder="z.B. REWE Einkauf" value="${escHtml(isEdit ? entry.title : '')}">
|
placeholder="${t('budget.titlePlaceholder')}" value="${escHtml(isEdit ? entry.title : '')}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="bm-amount">Betrag (€) *</label>
|
<label class="form-label" for="bm-amount">${t('budget.amountLabel')}</label>
|
||||||
<input type="number" class="form-input" id="bm-amount"
|
<input type="number" class="form-input" id="bm-amount"
|
||||||
placeholder="0,00" step="0.01" min="0"
|
placeholder="${t('budget.amountPlaceholder')}" step="0.01" min="0"
|
||||||
value="${absAmount}">
|
value="${absAmount}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="bm-category">Kategorie</label>
|
<label class="form-label" for="bm-category">${t('budget.categoryLabel')}</label>
|
||||||
<select class="form-input" id="bm-category">${catOpts}</select>
|
<select class="form-input" id="bm-category">${catOpts}</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="bm-date">Datum *</label>
|
<label class="form-label" for="bm-date">${t('budget.dateLabel')}</label>
|
||||||
<input type="date" class="form-input" id="bm-date"
|
<input type="date" class="form-input" id="bm-date"
|
||||||
value="${isEdit ? entry.date : today}">
|
value="${isEdit ? entry.date : today}">
|
||||||
</div>
|
</div>
|
||||||
@@ -362,22 +378,22 @@ function openBudgetModal({ mode, entry = null }) {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="allday-toggle">
|
<label class="allday-toggle">
|
||||||
<input type="checkbox" id="bm-recurring" ${isEdit && entry.is_recurring ? 'checked' : ''}>
|
<input type="checkbox" id="bm-recurring" ${isEdit && entry.is_recurring ? 'checked' : ''}>
|
||||||
<span class="allday-toggle__label">Wiederkehrend</span>
|
<span class="allday-toggle__label">${t('budget.recurringLabel')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||||
${isEdit ? `<button class="btn btn--danger btn--icon" id="bm-delete" aria-label="Eintrag löschen">
|
${isEdit ? `<button class="btn btn--danger btn--icon" id="bm-delete" aria-label="${t('budget.deleteLabel')}">
|
||||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
</button>` : '<div></div>'}
|
</button>` : '<div></div>'}
|
||||||
<div style="display:flex;gap:var(--space-3)">
|
<div style="display:flex;gap:var(--space-3)">
|
||||||
<button class="btn btn--secondary" id="bm-cancel">Abbrechen</button>
|
<button class="btn btn--secondary" id="bm-cancel">${t('common.cancel')}</button>
|
||||||
<button class="btn btn--primary" id="bm-save">${isEdit ? 'Speichern' : 'Hinzufügen'}</button>
|
<button class="btn btn--primary" id="bm-save">${isEdit ? t('common.save') : t('common.add')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
openSharedModal({
|
openSharedModal({
|
||||||
title: isEdit ? 'Eintrag bearbeiten' : 'Neuer Eintrag',
|
title: isEdit ? t('budget.editEntry') : t('budget.newEntry'),
|
||||||
content,
|
content,
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
onSave(panel) {
|
onSave(panel) {
|
||||||
@@ -397,7 +413,7 @@ function openBudgetModal({ mode, entry = null }) {
|
|||||||
panel.querySelector('#bm-cancel').addEventListener('click', closeModal);
|
panel.querySelector('#bm-cancel').addEventListener('click', closeModal);
|
||||||
|
|
||||||
panel.querySelector('#bm-delete')?.addEventListener('click', async () => {
|
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();
|
closeModal();
|
||||||
await deleteEntry(entry.id);
|
await deleteEntry(entry.id);
|
||||||
});
|
});
|
||||||
@@ -410,9 +426,9 @@ function openBudgetModal({ mode, entry = null }) {
|
|||||||
const date = panel.querySelector('#bm-date').value;
|
const date = panel.querySelector('#bm-date').value;
|
||||||
const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0;
|
const recurring = panel.querySelector('#bm-recurring').checked ? 1 : 0;
|
||||||
|
|
||||||
if (!title) { window.oikos?.showToast('Titel ist erforderlich', 'error'); return; }
|
if (!title) { window.oikos?.showToast(t('common.titleRequired'), 'error'); return; }
|
||||||
if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast('Gültigen Betrag eingeben', 'error'); return; }
|
if (isNaN(absVal) || absVal <= 0) { window.oikos?.showToast(t('budget.validAmountRequired'), 'error'); return; }
|
||||||
if (!date) { window.oikos?.showToast('Datum ist erforderlich', 'error'); return; }
|
if (!date) { window.oikos?.showToast(t('budget.dateRequired'), 'error'); return; }
|
||||||
|
|
||||||
const amount = currentType === 'expense' ? -absVal : absVal;
|
const amount = currentType === 'expense' ? -absVal : absVal;
|
||||||
|
|
||||||
@@ -434,11 +450,11 @@ function openBudgetModal({ mode, entry = null }) {
|
|||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
renderBody();
|
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) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||||
saveBtn.disabled = false;
|
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) {
|
async function deleteEntry(id) {
|
||||||
if (!confirm('Eintrag wirklich löschen?')) return;
|
if (!confirm(t('budget.deleteConfirm'))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/budget/${id}`);
|
await api.delete(`/budget/${id}`);
|
||||||
state.entries = state.entries.filter((e) => e.id !== id);
|
state.entries = state.entries.filter((e) => e.id !== id);
|
||||||
@@ -458,9 +474,9 @@ async function deleteEntry(id) {
|
|||||||
state.summary = sumRes.data;
|
state.summary = sumRes.data;
|
||||||
renderBody();
|
renderBody();
|
||||||
vibrate([30, 50, 30]);
|
vibrate([30, 50, 30]);
|
||||||
window.oikos?.showToast('Eintrag gelöscht', 'success');
|
window.oikos?.showToast(t('budget.deletedToast'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+80
-67
@@ -8,17 +8,35 @@ import { api } from '/api.js';
|
|||||||
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
||||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||||
import { stagger } from '/utils/ux.js';
|
import { stagger } from '/utils/ux.js';
|
||||||
|
import { t, formatTime } from '/i18n.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
const VIEWS = ['month', 'week', 'day', 'agenda'];
|
const VIEWS = ['month', 'week', 'day', 'agenda'];
|
||||||
const VIEW_LABELS = { month: 'Monat', week: 'Woche', day: 'Tag', agenda: 'Agenda' };
|
const VIEW_LABELS = () => ({
|
||||||
const DAY_NAMES_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
month: t('calendar.viewMonth'),
|
||||||
const DAY_NAMES_LONG = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
week: t('calendar.viewWeek'),
|
||||||
const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
day: t('calendar.viewDay'),
|
||||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
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 = [
|
const EVENT_COLORS = [
|
||||||
'#007AFF', '#34C759', '#FF9500', '#FF3B30',
|
'#007AFF', '#34C759', '#FF9500', '#FF3B30',
|
||||||
@@ -73,25 +91,20 @@ function getMondayOf(dateStr) {
|
|||||||
function formatDate(dateStr, { long = false, weekday = false } = {}) {
|
function formatDate(dateStr, { long = false, weekday = false } = {}) {
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
const day = d.getDate();
|
const day = d.getDate();
|
||||||
const mon = MONTH_NAMES[d.getMonth()];
|
const mon = MONTH_NAMES()[d.getMonth()];
|
||||||
if (weekday) {
|
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 `${wd}, ${day}. ${mon}`;
|
||||||
}
|
}
|
||||||
return `${day}. ${mon} ${d.getFullYear()}`;
|
return `${day}. ${mon} ${d.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(datetimeStr) {
|
|
||||||
if (!datetimeStr) return '';
|
|
||||||
const t = datetimeStr.slice(11, 16);
|
|
||||||
return t || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(datetimeStr) {
|
function formatDateTime(datetimeStr) {
|
||||||
if (!datetimeStr) return '';
|
if (!datetimeStr) return '';
|
||||||
const date = datetimeStr.slice(0, 10);
|
const date = datetimeStr.slice(0, 10);
|
||||||
const time = datetimeStr.slice(11, 16);
|
const hasTime = datetimeStr.length > 10 && datetimeStr.slice(11, 16).trim() !== '';
|
||||||
return time ? `${formatDate(date)} ${time} Uhr` : formatDate(date);
|
const time = hasTime ? formatTime(datetimeStr) : '';
|
||||||
|
return time ? `${formatDate(date)} ${time} ${t('calendar.timeSuffix')}`.trimEnd() : formatDate(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMonthRange(dateStr) {
|
function getMonthRange(dateStr) {
|
||||||
@@ -132,7 +145,7 @@ async function loadRange(from, to) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Calendar] loadRange Fehler:', err);
|
console.error('[Calendar] loadRange Fehler:', err);
|
||||||
state.events = [];
|
state.events = [];
|
||||||
window.oikos?.showToast('Termine konnten nicht geladen werden.', 'danger');
|
window.oikos?.showToast(t('calendar.loadError'), 'danger');
|
||||||
}
|
}
|
||||||
state.rangeFrom = from;
|
state.rangeFrom = from;
|
||||||
state.rangeTo = to;
|
state.rangeTo = to;
|
||||||
@@ -161,7 +174,7 @@ export async function render(container, { user }) {
|
|||||||
<div class="calendar-page" id="calendar-page">
|
<div class="calendar-page" id="calendar-page">
|
||||||
<div class="cal-toolbar" id="cal-toolbar"></div>
|
<div class="cal-toolbar" id="cal-toolbar"></div>
|
||||||
<div id="cal-body" style="flex:1;display:flex;flex-direction:column;overflow:hidden;"></div>
|
<div id="cal-body" style="flex:1;display:flex;flex-direction:column;overflow:hidden;"></div>
|
||||||
<button class="page-fab" id="fab-new-event" aria-label="Neuer Termin">
|
<button class="page-fab" id="fab-new-event" aria-label="${t('calendar.newEvent')}">
|
||||||
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,26 +198,26 @@ function renderToolbar() {
|
|||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
|
|
||||||
bar.innerHTML = `
|
bar.innerHTML = `
|
||||||
<h1 class="sr-only">Kalender</h1>
|
<h1 class="sr-only">${t('calendar.title')}</h1>
|
||||||
<div class="cal-toolbar__nav">
|
<div class="cal-toolbar__nav">
|
||||||
<button class="btn btn--icon" id="cal-prev" aria-label="Zurück">
|
<button class="btn btn--icon" id="cal-prev" aria-label="${t('calendar.back')}">
|
||||||
<i data-lucide="chevron-left" aria-hidden="true"></i>
|
<i data-lucide="chevron-left" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="cal-toolbar__today" id="cal-today">Heute</button>
|
<button class="cal-toolbar__today" id="cal-today">${t('calendar.today')}</button>
|
||||||
<span class="cal-toolbar__label" id="cal-label"></span>
|
<span class="cal-toolbar__label" id="cal-label"></span>
|
||||||
<div class="cal-toolbar__views">
|
<div class="cal-toolbar__views">
|
||||||
${VIEWS.map((v) => `
|
${VIEWS.map((v) => `
|
||||||
<button class="cal-toolbar__view-btn ${v === state.view ? 'cal-toolbar__view-btn--active' : ''}"
|
<button class="cal-toolbar__view-btn ${v === state.view ? 'cal-toolbar__view-btn--active' : ''}"
|
||||||
data-view="${v}">${VIEW_LABELS[v]}</button>
|
data-view="${v}">${VIEW_LABELS()[v]}</button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--primary btn--icon" id="cal-add" aria-label="Termin hinzufügen"
|
<button class="btn btn--primary btn--icon" id="cal-add" aria-label="${t('calendar.addEvent')}"
|
||||||
style="margin-left:auto;">
|
style="margin-left:auto;">
|
||||||
<i data-lucide="plus" aria-hidden="true"></i>
|
<i data-lucide="plus" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="cal-toolbar__nav">
|
<div class="cal-toolbar__nav">
|
||||||
<button class="btn btn--icon" id="cal-next" aria-label="Weiter">
|
<button class="btn btn--icon" id="cal-next" aria-label="${t('calendar.forward')}">
|
||||||
<i data-lucide="chevron-right" aria-hidden="true"></i>
|
<i data-lucide="chevron-right" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,12 +250,12 @@ function updateLabel() {
|
|||||||
if (!lbl) return;
|
if (!lbl) return;
|
||||||
const d = new Date(state.cursor + 'T00:00:00');
|
const d = new Date(state.cursor + 'T00:00:00');
|
||||||
const year = d.getFullYear();
|
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 === '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 === '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) {
|
function getWeekNumber(dateStr) {
|
||||||
@@ -328,7 +341,7 @@ function renderMonthView(container) {
|
|||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="month-view">
|
<div class="month-view">
|
||||||
<div class="month-weekdays">
|
<div class="month-weekdays">
|
||||||
${['Mo','Di','Mi','Do','Fr','Sa','So'].map((n) => `<div class="month-weekday">${n}</div>`).join('')}
|
${[t('calendar.dayShortMonday'),t('calendar.dayShortTuesday'),t('calendar.dayShortWednesday'),t('calendar.dayShortThursday'),t('calendar.dayShortFriday'),t('calendar.dayShortSaturday'),t('calendar.dayShortSunday')].map((n) => `<div class="month-weekday">${n}</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="month-grid" id="month-grid">
|
<div class="month-grid" id="month-grid">
|
||||||
${days.map(({ date, inMonth }) => renderMonthDay(date, inMonth)).join('')}
|
${days.map(({ date, inMonth }) => renderMonthDay(date, inMonth)).join('')}
|
||||||
@@ -376,7 +389,7 @@ function renderMonthDay(date, inMonth) {
|
|||||||
<div class="${classes}" data-date="${date}">
|
<div class="${classes}" data-date="${date}">
|
||||||
<div class="month-day__number">${new Date(date + 'T00:00:00').getDate()}</div>
|
<div class="month-day__number">${new Date(date + 'T00:00:00').getDate()}</div>
|
||||||
${evHtml}
|
${evHtml}
|
||||||
${extra > 0 ? `<div class="month-day__more">+${extra} weitere</div>` : ''}
|
${extra > 0 ? `<div class="month-day__more">${t('calendar.moreEvents', { count: extra })}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -404,14 +417,14 @@ function renderWeekView(container) {
|
|||||||
${days.map((d) => {
|
${days.map((d) => {
|
||||||
const dt = new Date(d + 'T00:00:00');
|
const dt = new Date(d + 'T00:00:00');
|
||||||
return `<div class="week-view__day-header">
|
return `<div class="week-view__day-header">
|
||||||
<div class="week-view__day-name">${DAY_NAMES_SHORT[(dt.getDay())]}</div>
|
<div class="week-view__day-name">${DAY_NAMES_SHORT()[dt.getDay()]}</div>
|
||||||
<div class="week-view__day-num ${d === state.today ? 'week-view__day-num--today' : ''}">${dt.getDate()}</div>
|
<div class="week-view__day-num ${d === state.today ? 'week-view__day-num--today' : ''}">${dt.getDate()}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
<!-- Ganztägige Ereignisse -->
|
<!-- Ganztägige Ereignisse -->
|
||||||
<div class="allday-row" style="display:grid;grid-template-columns:48px repeat(7,1fr);">
|
<div class="allday-row" style="display:grid;grid-template-columns:48px repeat(7,1fr);">
|
||||||
<div style="width:48px;padding:2px;font-size:10px;color:var(--color-text-disabled);text-align:right;padding-right:4px;line-height:24px;">ganztg.</div>
|
<div style="width:48px;padding:2px;font-size:10px;color:var(--color-text-disabled);text-align:right;padding-right:4px;line-height:24px;">${t('calendar.allDayShort')}</div>
|
||||||
${days.map((d, i) => `
|
${days.map((d, i) => `
|
||||||
<div class="allday-cell">
|
<div class="allday-cell">
|
||||||
${alldayEvs[i].map((ev) => `
|
${alldayEvs[i].map((ev) => `
|
||||||
@@ -523,7 +536,7 @@ function renderDayView(container) {
|
|||||||
</div>
|
</div>
|
||||||
${allday.length ? `
|
${allday.length ? `
|
||||||
<div class="allday-row" style="display:grid;grid-template-columns:48px 1fr;">
|
<div class="allday-row" style="display:grid;grid-template-columns:48px 1fr;">
|
||||||
<div style="padding:2px 4px 2px 0;font-size:10px;color:var(--color-text-disabled);text-align:right;line-height:24px;">ganztg.</div>
|
<div style="padding:2px 4px 2px 0;font-size:10px;color:var(--color-text-disabled);text-align:right;line-height:24px;">${t('calendar.allDayShort')}</div>
|
||||||
<div class="allday-cell">
|
<div class="allday-cell">
|
||||||
${allday.map((ev) => `
|
${allday.map((ev) => `
|
||||||
<div class="allday-event" data-id="${ev.id}" style="background-color:${escHtml(ev.color)};">
|
<div class="allday-event" data-id="${ev.id}" style="background-color:${escHtml(ev.color)};">
|
||||||
@@ -584,12 +597,12 @@ function renderAgendaView(container) {
|
|||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="agenda-view" id="agenda-view">
|
<div class="agenda-view" id="agenda-view">
|
||||||
${groups.length === 0
|
${groups.length === 0
|
||||||
? `<div class="agenda-empty">Keine Termine im gewählten Zeitraum.</div>`
|
? `<div class="agenda-empty">${t('calendar.noEvents')}</div>`
|
||||||
: groups.map(({ date, events }) => `
|
: groups.map(({ date, events }) => `
|
||||||
<div class="agenda-day">
|
<div class="agenda-day">
|
||||||
<div class="agenda-day__header ${date === state.today ? 'agenda-day__header--today' : ''}">
|
<div class="agenda-day__header ${date === state.today ? 'agenda-day__header--today' : ''}">
|
||||||
<span class="agenda-day__date">${formatDate(date)}</span>
|
<span class="agenda-day__date">${formatDate(date)}</span>
|
||||||
<span class="agenda-day__weekday">${DAY_NAMES_LONG[new Date(date + 'T00:00:00').getDay()]}</span>
|
<span class="agenda-day__weekday">${DAY_NAMES_LONG()[new Date(date + 'T00:00:00').getDay()]}</span>
|
||||||
</div>
|
</div>
|
||||||
${events.map((ev) => renderAgendaEvent(ev)).join('')}
|
${events.map((ev) => renderAgendaEvent(ev)).join('')}
|
||||||
</div>
|
</div>
|
||||||
@@ -611,9 +624,9 @@ function renderAgendaView(container) {
|
|||||||
|
|
||||||
function renderAgendaEvent(ev) {
|
function renderAgendaEvent(ev) {
|
||||||
const timeStr = ev.all_day
|
const timeStr = ev.all_day
|
||||||
? 'Ganztägig'
|
? t('calendar.allDay')
|
||||||
: formatTime(ev.start_datetime)
|
: 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
|
const initials = ev.assigned_name
|
||||||
? ev.assigned_name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2)
|
? 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';
|
popup.className = 'event-popup';
|
||||||
|
|
||||||
const timeStr = ev.all_day
|
const timeStr = ev.all_day
|
||||||
? 'Ganztägig'
|
? t('calendar.allDay')
|
||||||
: formatDateTime(ev.start_datetime)
|
: 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 = `
|
popup.innerHTML = `
|
||||||
<div class="event-popup__color-bar" style="background-color:${escHtml(ev.color)};"></div>
|
<div class="event-popup__color-bar" style="background-color:${escHtml(ev.color)};"></div>
|
||||||
@@ -664,7 +677,7 @@ function showEventPopup(ev, anchor) {
|
|||||||
${ev.assigned_name ? `<div>👤 ${escHtml(ev.assigned_name)}</div>` : ''}
|
${ev.assigned_name ? `<div>👤 ${escHtml(ev.assigned_name)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="event-popup__actions">
|
<div class="event-popup__actions">
|
||||||
<button class="btn btn--secondary" style="flex:1;" id="popup-edit">Bearbeiten</button>
|
<button class="btn btn--secondary" style="flex:1;" id="popup-edit">${t('calendar.popupEdit')}</button>
|
||||||
<button class="btn btn--danger" id="popup-delete">
|
<button class="btn btn--danger" id="popup-delete">
|
||||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -687,7 +700,7 @@ function showEventPopup(ev, anchor) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
popup.querySelector('#popup-delete').addEventListener('click', async () => {
|
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();
|
popup.remove();
|
||||||
await deleteEvent(ev.id);
|
await deleteEvent(ev.id);
|
||||||
});
|
});
|
||||||
@@ -712,7 +725,7 @@ function openEventModal({ mode, event = null, date = null }) {
|
|||||||
const content = buildEventModalContent({ mode, event, date });
|
const content = buildEventModalContent({ mode, event, date });
|
||||||
|
|
||||||
openSharedModal({
|
openSharedModal({
|
||||||
title: isEdit ? 'Termin bearbeiten' : 'Neuer Termin',
|
title: isEdit ? t('calendar.editEvent') : t('calendar.newEvent'),
|
||||||
content,
|
content,
|
||||||
size: 'md',
|
size: 'md',
|
||||||
onSave(panel) {
|
onSave(panel) {
|
||||||
@@ -745,7 +758,7 @@ function openEventModal({ mode, event = null, date = null }) {
|
|||||||
panel.querySelector('#modal-cancel').addEventListener('click', closeModal);
|
panel.querySelector('#modal-cancel').addEventListener('click', closeModal);
|
||||||
|
|
||||||
panel.querySelector('#modal-delete')?.addEventListener('click', async () => {
|
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();
|
closeModal();
|
||||||
await deleteEvent(event.id);
|
await deleteEvent(event.id);
|
||||||
});
|
});
|
||||||
@@ -767,7 +780,7 @@ function buildEventModalContent({ mode, event, date }) {
|
|||||||
? event.end_datetime.slice(11, 16) : '10:00';
|
? event.end_datetime.slice(11, 16) : '10:00';
|
||||||
|
|
||||||
const userOpts = [
|
const userOpts = [
|
||||||
'<option value="">— Niemand —</option>',
|
`<option value="">${t('calendar.assignedNobody')}</option>`,
|
||||||
...state.users.map((u) =>
|
...state.users.map((u) =>
|
||||||
`<option value="${u.id}" ${isEdit && event.assigned_to === u.id ? 'selected' : ''}>${escHtml(u.display_name)}</option>`
|
`<option value="${u.id}" ${isEdit && event.assigned_to === u.id ? 'selected' : ''}>${escHtml(u.display_name)}</option>`
|
||||||
),
|
),
|
||||||
@@ -775,36 +788,36 @@ function buildEventModalContent({ mode, event, date }) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-title">Titel *</label>
|
<label class="form-label" for="modal-title">${t('calendar.titleLabel')}</label>
|
||||||
<input type="text" class="form-input" id="modal-title"
|
<input type="text" class="form-input" id="modal-title"
|
||||||
placeholder="z.B. Zahnarzt" value="${escHtml(isEdit ? event.title : '')}">
|
placeholder="${t('calendar.titlePlaceholder')}" value="${escHtml(isEdit ? event.title : '')}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="allday-toggle">
|
<label class="allday-toggle">
|
||||||
<input type="checkbox" id="modal-allday" ${isEdit && event.all_day ? 'checked' : ''}>
|
<input type="checkbox" id="modal-allday" ${isEdit && event.all_day ? 'checked' : ''}>
|
||||||
<span class="allday-toggle__label">Ganztägig</span>
|
<span class="allday-toggle__label">${t('calendar.allDayToggle')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="time-fields">
|
<div id="time-fields">
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-start-date">Startdatum</label>
|
<label class="form-label" for="modal-start-date">${t('calendar.startDateLabel')}</label>
|
||||||
<input type="date" class="form-input" id="modal-start-date" value="${startDate}">
|
<input type="date" class="form-input" id="modal-start-date" value="${startDate}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-start-time">Startzeit</label>
|
<label class="form-label" for="modal-start-time">${t('calendar.startTimeLabel')}</label>
|
||||||
<input type="time" class="form-input" id="modal-start-time" value="${startTime}">
|
<input type="time" class="form-input" id="modal-start-time" value="${startTime}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-end-date">Enddatum</label>
|
<label class="form-label" for="modal-end-date">${t('calendar.endDateLabel')}</label>
|
||||||
<input type="date" class="form-input" id="modal-end-date" value="${endDate}">
|
<input type="date" class="form-input" id="modal-end-date" value="${endDate}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-end-time">Endzeit</label>
|
<label class="form-label" for="modal-end-time">${t('calendar.endTimeLabel')}</label>
|
||||||
<input type="time" class="form-input" id="modal-end-time" value="${endTime}">
|
<input type="time" class="form-input" id="modal-end-time" value="${endTime}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -813,52 +826,52 @@ function buildEventModalContent({ mode, event, date }) {
|
|||||||
<div id="allday-fields" style="display:none;">
|
<div id="allday-fields" style="display:none;">
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-allday-start">Von</label>
|
<label class="form-label" for="modal-allday-start">${t('calendar.fromLabel')}</label>
|
||||||
<input type="date" class="form-input" id="modal-allday-start" value="${startDate}">
|
<input type="date" class="form-input" id="modal-allday-start" value="${startDate}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-allday-end">Bis</label>
|
<label class="form-label" for="modal-allday-end">${t('calendar.toLabel')}</label>
|
||||||
<input type="date" class="form-input" id="modal-allday-end" value="${endDate}">
|
<input type="date" class="form-input" id="modal-allday-end" value="${endDate}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-location">Ort</label>
|
<label class="form-label" for="modal-location">${t('calendar.locationLabel')}</label>
|
||||||
<input type="text" class="form-input" id="modal-location"
|
<input type="text" class="form-input" id="modal-location"
|
||||||
placeholder="Optional" value="${escHtml(isEdit && event.location ? event.location : '')}">
|
placeholder="${t('calendar.locationPlaceholder')}" value="${escHtml(isEdit && event.location ? event.location : '')}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-assigned">Zugewiesen an</label>
|
<label class="form-label" for="modal-assigned">${t('calendar.assignedLabel')}</label>
|
||||||
<select class="form-input" id="modal-assigned">${userOpts}</select>
|
<select class="form-input" id="modal-assigned">${userOpts}</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Farbe</label>
|
<label class="form-label">${t('calendar.colorLabel')}</label>
|
||||||
<div class="color-picker">
|
<div class="color-picker">
|
||||||
${EVENT_COLORS.map((c) => `
|
${EVENT_COLORS.map((c) => `
|
||||||
<div class="color-swatch" data-color="${c}" style="background-color:${c};"
|
<div class="color-swatch" data-color="${c}" style="background-color:${c};"
|
||||||
role="radio" tabindex="0" aria-label="Farbe ${c}"></div>
|
role="radio" tabindex="0" aria-label="${t('calendar.colorLabel', { color: c })}"></div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-description">Beschreibung</label>
|
<label class="form-label" for="modal-description">${t('calendar.descriptionLabel')}</label>
|
||||||
<textarea class="form-input" id="modal-description" rows="2"
|
<textarea class="form-input" id="modal-description" rows="2"
|
||||||
placeholder="Optional…">${escHtml(isEdit && event.description ? event.description : '')}</textarea>
|
placeholder="${t('calendar.descriptionPlaceholder')}">${escHtml(isEdit && event.description ? event.description : '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
|
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
|
||||||
|
|
||||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||||
${isEdit ? `<button class="btn btn--danger btn--icon" id="modal-delete" aria-label="Termin löschen">
|
${isEdit ? `<button class="btn btn--danger btn--icon" id="modal-delete" aria-label="${t('calendar.deleteEvent')}">
|
||||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
</button>` : '<div></div>'}
|
</button>` : '<div></div>'}
|
||||||
<div style="display:flex;gap:var(--space-3)">
|
<div style="display:flex;gap:var(--space-3)">
|
||||||
<button class="btn btn--secondary" id="modal-cancel">Abbrechen</button>
|
<button class="btn btn--secondary" id="modal-cancel">${t('common.cancel')}</button>
|
||||||
<button class="btn btn--primary" id="modal-save">${isEdit ? 'Speichern' : 'Erstellen'}</button>
|
<button class="btn btn--primary" id="modal-save">${isEdit ? t('common.save') : t('common.create')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -868,7 +881,7 @@ async function saveEvent(overlay, mode, eventId) {
|
|||||||
const title = overlay.querySelector('#modal-title').value.trim();
|
const title = overlay.querySelector('#modal-title').value.trim();
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
window.oikos?.showToast('Titel ist erforderlich', 'error');
|
window.oikos?.showToast(t('calendar.titleRequired'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,11 +931,11 @@ async function saveEvent(overlay, mode, eventId) {
|
|||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
renderView();
|
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) {
|
} 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.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}`);
|
await api.delete(`/calendar/${id}`);
|
||||||
state.events = state.events.filter((e) => e.id !== id);
|
state.events = state.events.filter((e) => e.id !== id);
|
||||||
renderView();
|
renderView();
|
||||||
window.oikos?.showToast('Termin gelöscht', 'success');
|
window.oikos?.showToast(t('calendar.deletedToast'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler beim Löschen', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('calendar.deleteError'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-43
@@ -7,6 +7,7 @@
|
|||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||||
import { stagger, vibrate } from '/utils/ux.js';
|
import { stagger, vibrate } from '/utils/ux.js';
|
||||||
|
import { t } from '/i18n.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -25,6 +26,18 @@ const CATEGORY_ICONS = {
|
|||||||
'Sonstiges': '📋',
|
'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
|
// State
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -44,32 +57,32 @@ export async function render(container, { user }) {
|
|||||||
_container = container;
|
_container = container;
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="contacts-page">
|
<div class="contacts-page">
|
||||||
<h1 class="sr-only">Kontakte</h1>
|
<h1 class="sr-only">${t('contacts.title')}</h1>
|
||||||
<div class="contacts-toolbar">
|
<div class="contacts-toolbar">
|
||||||
<div class="contacts-toolbar__search">
|
<div class="contacts-toolbar__search">
|
||||||
<i data-lucide="search" class="contacts-toolbar__search-icon" aria-hidden="true"></i>
|
<i data-lucide="search" class="contacts-toolbar__search-icon" aria-hidden="true"></i>
|
||||||
<input type="search" class="contacts-toolbar__search-input"
|
<input type="search" class="contacts-toolbar__search-input"
|
||||||
id="contacts-search" placeholder="Name, Telefon oder E-Mail suchen…"
|
id="contacts-search" placeholder="${t('contacts.searchPlaceholder')}"
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<label class="btn btn--secondary" title="vCard importieren" aria-label="Kontakt aus vCard importieren">
|
<label class="btn btn--secondary" title="${t('contacts.importTooltip')}" aria-label="${t('contacts.importLabel')}">
|
||||||
<i data-lucide="upload" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
<i data-lucide="upload" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
||||||
Import
|
${t('contacts.importButton')}
|
||||||
<input type="file" id="contacts-import-input" accept=".vcf,text/vcard" style="display:none">
|
<input type="file" id="contacts-import-input" accept=".vcf,text/vcard" style="display:none">
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn--primary" id="contacts-add-btn">
|
<button class="btn btn--primary" id="contacts-add-btn">
|
||||||
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
||||||
Neu
|
${t('contacts.addButton')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="contacts-filters" id="contacts-filters">
|
<div class="contacts-filters" id="contacts-filters">
|
||||||
<button class="contact-filter-chip contact-filter-chip--active" data-cat="">Alle</button>
|
<button class="contact-filter-chip contact-filter-chip--active" data-cat="">${t('contacts.filterAll')}</button>
|
||||||
${CATEGORIES.map((c) => `
|
${CATEGORIES.map((c) => `
|
||||||
<button class="contact-filter-chip" data-cat="${escHtml(c)}">${CATEGORY_ICONS[c] || ''} ${escHtml(c)}</button>
|
<button class="contact-filter-chip" data-cat="${escHtml(c)}">${CATEGORY_ICONS[c] || ''} ${CATEGORY_LABELS()[c] || escHtml(c)}</button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div id="contacts-list" class="contacts-list"></div>
|
<div id="contacts-list" class="contacts-list"></div>
|
||||||
<button class="page-fab" id="fab-new-contact" aria-label="Neuer Kontakt">
|
<button class="page-fab" id="fab-new-contact" aria-label="${t('contacts.newContactLabel')}">
|
||||||
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,13 +128,13 @@ export async function render(container, { user }) {
|
|||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const contact = parseVCard(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);
|
const res = await api.post('/contacts', contact);
|
||||||
state.contacts.push(res.data);
|
state.contacts.push(res.data);
|
||||||
renderList();
|
renderList();
|
||||||
window.oikos?.showToast(`${res.data.name} importiert.`, 'success');
|
window.oikos?.showToast(t('contacts.importedToast', { name: res.data.name }), 'success');
|
||||||
} catch (err) {
|
} 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() {
|
|||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="empty-state__title">Noch keine Kontakte</div>
|
<div class="empty-state__title">${t('contacts.emptyTitle')}</div>
|
||||||
<div class="empty-state__description">Neue Kontakte über den + Button hinzufügen.</div>
|
<div class="empty-state__description">${t('contacts.emptyDescription')}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
@@ -183,7 +196,7 @@ function renderList() {
|
|||||||
.sort(([a], [b]) => CATEGORIES.indexOf(a) - CATEGORIES.indexOf(b))
|
.sort(([a], [b]) => CATEGORIES.indexOf(a) - CATEGORIES.indexOf(b))
|
||||||
.map(([cat, items]) => `
|
.map(([cat, items]) => `
|
||||||
<div class="contact-group">
|
<div class="contact-group">
|
||||||
<div class="contact-group__header">${CATEGORY_ICONS[cat] || ''} ${escHtml(cat)}</div>
|
<div class="contact-group__header">${CATEGORY_ICONS[cat] || ''} ${CATEGORY_LABELS()[cat] || escHtml(cat)}</div>
|
||||||
${items.map((c) => renderContactItem(c)).join('')}
|
${items.map((c) => renderContactItem(c)).join('')}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -207,9 +220,9 @@ function renderList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderContactItem(c) {
|
function renderContactItem(c) {
|
||||||
const phone = c.phone ? `<a href="tel:${escHtml(c.phone)}" class="contact-action-btn contact-action-btn--call" aria-label="Anrufen"><i data-lucide="phone" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
const phone = c.phone ? `<a href="tel:${escHtml(c.phone)}" class="contact-action-btn contact-action-btn--call" aria-label="${t('contacts.callLabel')}"><i data-lucide="phone" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
||||||
const email = c.email ? `<a href="mailto:${escHtml(c.email)}" class="contact-action-btn contact-action-btn--mail" aria-label="E-Mail"><i data-lucide="mail" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
const email = c.email ? `<a href="mailto:${escHtml(c.email)}" class="contact-action-btn contact-action-btn--mail" aria-label="${t('contacts.emailActionLabel')}"><i data-lucide="mail" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
||||||
const maps = c.address ? `<a href="https://maps.google.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener" class="contact-action-btn contact-action-btn--maps" aria-label="In Maps öffnen"><i data-lucide="map-pin" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
const maps = c.address ? `<a href="https://maps.google.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener" class="contact-action-btn contact-action-btn--maps" aria-label="${t('contacts.mapsLabel')}"><i data-lucide="map-pin" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
||||||
const meta = [c.phone, c.email].filter(Boolean).join(' · ');
|
const meta = [c.phone, c.email].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -222,10 +235,10 @@ function renderContactItem(c) {
|
|||||||
<div class="contact-item__actions">
|
<div class="contact-item__actions">
|
||||||
${phone}${email}${maps}
|
${phone}${email}${maps}
|
||||||
<a href="/api/v1/contacts/${c.id}/vcard" download="${escHtml(c.name)}.vcf"
|
<a href="/api/v1/contacts/${c.id}/vcard" download="${escHtml(c.name)}.vcf"
|
||||||
class="contact-action-btn" aria-label="Als vCard exportieren" title="vCard exportieren">
|
class="contact-action-btn" aria-label="${t('contacts.exportLabel')}" title="${t('contacts.exportTooltip')}">
|
||||||
<i data-lucide="download" style="width:16px;height:16px;" aria-hidden="true"></i>
|
<i data-lucide="download" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
<button class="contact-action-btn" data-action="delete" data-id="${c.id}" aria-label="Kontakt löschen">
|
<button class="contact-action-btn" data-action="delete" data-id="${c.id}" aria-label="${t('contacts.deleteLabel')}">
|
||||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,55 +254,56 @@ function openContactModal({ mode, contact = null }) {
|
|||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
const v = (field) => escHtml(isEdit && contact[field] ? contact[field] : '');
|
const v = (field) => escHtml(isEdit && contact[field] ? contact[field] : '');
|
||||||
|
|
||||||
|
const catLabels = CATEGORY_LABELS();
|
||||||
const catOpts = CATEGORIES.map((c) =>
|
const catOpts = CATEGORIES.map((c) =>
|
||||||
`<option value="${c}" ${isEdit && contact.category === c ? 'selected' : ''}>${c}</option>`
|
`<option value="${c}" ${isEdit && contact.category === c ? 'selected' : ''}>${catLabels[c] || escHtml(c)}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="cm-name">Name *</label>
|
<label class="form-label" for="cm-name">${t('contacts.nameLabel')}</label>
|
||||||
<input type="text" class="form-input" id="cm-name" placeholder="Vollständiger Name" value="${v('name')}">
|
<input type="text" class="form-input" id="cm-name" placeholder="${t('contacts.namePlaceholder')}" value="${v('name')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="cm-category">Kategorie</label>
|
<label class="form-label" for="cm-category">${t('contacts.categoryLabel')}</label>
|
||||||
<select class="form-input" id="cm-category">${catOpts}</select>
|
<select class="form-input" id="cm-category">${catOpts}</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="cm-phone">Telefon</label>
|
<label class="form-label" for="cm-phone">${t('contacts.phoneLabel')}</label>
|
||||||
<input type="tel" class="form-input" id="cm-phone" placeholder="+49 …" value="${v('phone')}">
|
<input type="tel" class="form-input" id="cm-phone" placeholder="${t('contacts.phonePlaceholder')}" value="${v('phone')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="cm-email">E-Mail</label>
|
<label class="form-label" for="cm-email">${t('contacts.emailLabel')}</label>
|
||||||
<input type="email" class="form-input" id="cm-email" placeholder="name@beispiel.de" value="${v('email')}">
|
<input type="email" class="form-input" id="cm-email" placeholder="${t('contacts.emailPlaceholder')}" value="${v('email')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="cm-address">Adresse</label>
|
<label class="form-label" for="cm-address">${t('contacts.addressLabel')}</label>
|
||||||
<input type="text" class="form-input" id="cm-address" placeholder="Straße, PLZ Ort" value="${v('address')}">
|
<input type="text" class="form-input" id="cm-address" placeholder="${t('contacts.addressPlaceholder')}" value="${v('address')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="cm-notes">Notizen</label>
|
<label class="form-label" for="cm-notes">${t('contacts.notesLabel')}</label>
|
||||||
<textarea class="form-input" id="cm-notes" rows="2" placeholder="Optional…">${v('notes')}</textarea>
|
<textarea class="form-input" id="cm-notes" rows="2" placeholder="${t('contacts.notesPlaceholder')}">${v('notes')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||||
${isEdit ? `<button class="btn btn--danger btn--icon" id="cm-delete" aria-label="Kontakt löschen">
|
${isEdit ? `<button class="btn btn--danger btn--icon" id="cm-delete" aria-label="${t('contacts.deleteLabel')}">
|
||||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
</button>` : '<div></div>'}
|
</button>` : '<div></div>'}
|
||||||
<div style="display:flex;gap:var(--space-3);">
|
<div style="display:flex;gap:var(--space-3);">
|
||||||
<button class="btn btn--secondary" id="cm-cancel">Abbrechen</button>
|
<button class="btn btn--secondary" id="cm-cancel">${t('common.cancel')}</button>
|
||||||
<button class="btn btn--primary" id="cm-save">${isEdit ? 'Speichern' : 'Erstellen'}</button>
|
<button class="btn btn--primary" id="cm-save">${isEdit ? t('common.save') : t('common.create')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
openSharedModal({
|
openSharedModal({
|
||||||
title: isEdit ? 'Kontakt bearbeiten' : 'Neuer Kontakt',
|
title: isEdit ? t('contacts.editContact') : t('contacts.newContact'),
|
||||||
content,
|
content,
|
||||||
size: 'md',
|
size: 'md',
|
||||||
onSave(panel) {
|
onSave(panel) {
|
||||||
panel.querySelector('#cm-cancel').addEventListener('click', closeModal);
|
panel.querySelector('#cm-cancel').addEventListener('click', closeModal);
|
||||||
|
|
||||||
panel.querySelector('#cm-delete')?.addEventListener('click', async () => {
|
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();
|
closeModal();
|
||||||
await deleteContact(contact.id);
|
await deleteContact(contact.id);
|
||||||
});
|
});
|
||||||
@@ -303,7 +317,7 @@ function openContactModal({ mode, contact = null }) {
|
|||||||
const address = panel.querySelector('#cm-address').value.trim() || null;
|
const address = panel.querySelector('#cm-address').value.trim() || null;
|
||||||
const notes = panel.querySelector('#cm-notes').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.disabled = true;
|
||||||
saveBtn.textContent = '…';
|
saveBtn.textContent = '…';
|
||||||
@@ -324,11 +338,11 @@ function openContactModal({ mode, contact = null }) {
|
|||||||
}
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
renderList();
|
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) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||||
saveBtn.disabled = false;
|
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) {
|
async function deleteContact(id) {
|
||||||
if (!confirm('Kontakt wirklich löschen?')) return;
|
if (!confirm(t('contacts.deleteConfirm'))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/contacts/${id}`);
|
await api.delete(`/contacts/${id}`);
|
||||||
state.contacts = state.contacts.filter((c) => c.id !== id);
|
state.contacts = state.contacts.filter((c) => c.id !== id);
|
||||||
renderList();
|
renderList();
|
||||||
vibrate([30, 50, 30]);
|
vibrate([30, 50, 30]);
|
||||||
window.oikos?.showToast('Kontakt gelöscht', 'success');
|
window.oikos?.showToast(t('contacts.deletedToast'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
-55
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '/api.js';
|
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.
|
// Hält den AbortController des aktuellen FAB-Listeners — wird bei jedem render() erneuert.
|
||||||
let _fabController = null;
|
let _fabController = null;
|
||||||
@@ -15,14 +16,9 @@ let _fabController = null;
|
|||||||
|
|
||||||
function greeting(displayName) {
|
function greeting(displayName) {
|
||||||
const h = new Date().getHours();
|
const h = new Date().getHours();
|
||||||
const tageszeit = h < 12 ? 'Morgen' : h < 18 ? 'Tag' : 'Abend';
|
if (h < 12) return t('dashboard.greetingMorning', { name: displayName });
|
||||||
return `Guten ${tageszeit}, ${displayName}`;
|
if (h < 18) return t('dashboard.greetingDay', { name: displayName });
|
||||||
}
|
return t('dashboard.greetingEvening', { name: displayName });
|
||||||
|
|
||||||
function formatDate(date = new Date()) {
|
|
||||||
return date.toLocaleDateString('de-DE', {
|
|
||||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(isoString) {
|
function formatDateTime(isoString) {
|
||||||
@@ -33,13 +29,14 @@ function formatDateTime(isoString) {
|
|||||||
tomorrow.setDate(today.getDate() + 1);
|
tomorrow.setDate(today.getDate() + 1);
|
||||||
|
|
||||||
const dateStr = d.toDateString() === today.toDateString()
|
const dateStr = d.toDateString() === today.toDateString()
|
||||||
? 'Heute'
|
? t('common.today')
|
||||||
: d.toDateString() === tomorrow.toDateString()
|
: d.toDateString() === tomorrow.toDateString()
|
||||||
? 'Morgen'
|
? t('common.tomorrow')
|
||||||
: d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
: formatDate(d);
|
||||||
|
|
||||||
const timeStr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
const timeStr = formatTime(d);
|
||||||
return `${dateStr}, ${timeStr} Uhr`;
|
const suffix = t('calendar.timeSuffix');
|
||||||
|
return `${dateStr}, ${timeStr}${suffix ? ' ' + suffix : ''}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDueDate(dateStr) {
|
function formatDueDate(dateStr) {
|
||||||
@@ -49,21 +46,21 @@ function formatDueDate(dateStr) {
|
|||||||
const diffMs = due - now;
|
const diffMs = due - now;
|
||||||
const diffH = diffMs / (1000 * 60 * 60);
|
const diffH = diffMs / (1000 * 60 * 60);
|
||||||
|
|
||||||
if (diffMs < 0) return { text: 'Überfällig', overdue: true };
|
if (diffMs < 0) return { text: t('dashboard.overdue'), overdue: true };
|
||||||
if (diffH < 24) return { text: 'Heute fällig', overdue: false };
|
if (diffH < 24) return { text: t('dashboard.dueSoon'), overdue: false };
|
||||||
if (diffH < 48) return { text: 'Morgen fällig', overdue: false };
|
if (diffH < 48) return { text: t('dashboard.dueTomorrow'), overdue: false };
|
||||||
return {
|
return {
|
||||||
text: due.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }),
|
text: formatDate(due),
|
||||||
overdue: false,
|
overdue: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEAL_LABELS = {
|
const MEAL_LABELS = () => ({
|
||||||
breakfast: 'Frühstück',
|
breakfast: t('meals.typeBreakfast'),
|
||||||
lunch: 'Mittagessen',
|
lunch: t('meals.typeLunch'),
|
||||||
dinner: 'Abendessen',
|
dinner: t('meals.typeDinner'),
|
||||||
snack: 'Snack',
|
snack: t('meals.typeSnack'),
|
||||||
};
|
});
|
||||||
|
|
||||||
const MEAL_ICONS = {
|
const MEAL_ICONS = {
|
||||||
breakfast: 'sunrise',
|
breakfast: 'sunrise',
|
||||||
@@ -76,7 +73,8 @@ function initials(name = '') {
|
|||||||
return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
|
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
|
const badge = count != null
|
||||||
? `<span class="widget__badge">${count}</span>`
|
? `<span class="widget__badge">${count}</span>`
|
||||||
: '';
|
: '';
|
||||||
@@ -122,24 +120,24 @@ function renderGreeting(user, stats = {}) {
|
|||||||
if (urgentCount > 0)
|
if (urgentCount > 0)
|
||||||
statChips.push(`<span class="greeting-chip greeting-chip--warn">
|
statChips.push(`<span class="greeting-chip greeting-chip--warn">
|
||||||
<i data-lucide="alert-circle" style="${chipIcon}" aria-hidden="true"></i>
|
<i data-lucide="alert-circle" style="${chipIcon}" aria-hidden="true"></i>
|
||||||
${urgentCount} dring. Aufgabe${urgentCount > 1 ? 'n' : ''}
|
${urgentCount > 1 ? t('dashboard.urgentTasksChipPlural', { count: urgentCount }) : t('dashboard.urgentTasksChip', { count: urgentCount })}
|
||||||
</span>`);
|
</span>`);
|
||||||
if (todayEventCount > 0)
|
if (todayEventCount > 0)
|
||||||
statChips.push(`<span class="greeting-chip">
|
statChips.push(`<span class="greeting-chip">
|
||||||
<i data-lucide="calendar" style="${chipIcon}" aria-hidden="true"></i>
|
<i data-lucide="calendar" style="${chipIcon}" aria-hidden="true"></i>
|
||||||
${todayEventCount} Termin${todayEventCount > 1 ? 'e' : ''} heute
|
${todayEventCount > 1 ? t('dashboard.eventsChipPlural', { count: todayEventCount }) : t('dashboard.eventsChip', { count: todayEventCount })}
|
||||||
</span>`);
|
</span>`);
|
||||||
if (todayMealTitle)
|
if (todayMealTitle)
|
||||||
statChips.push(`<span class="greeting-chip">
|
statChips.push(`<span class="greeting-chip">
|
||||||
<i data-lucide="utensils" style="${chipIcon}" aria-hidden="true"></i>
|
<i data-lucide="utensils" style="${chipIcon}" aria-hidden="true"></i>
|
||||||
Heute: ${todayMealTitle}
|
${t('dashboard.todayMealChip', { title: todayMealTitle })}
|
||||||
</span>`);
|
</span>`);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="widget-greeting">
|
<div class="widget-greeting">
|
||||||
<div class="widget-greeting__content">
|
<div class="widget-greeting__content">
|
||||||
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
|
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
|
||||||
<div class="widget-greeting__date">${formatDate()}</div>
|
<div class="widget-greeting__date">${formatDate(new Date())}</div>
|
||||||
${statChips.length ? `<div class="widget-greeting__chips">${statChips.join('')}</div>` : ''}
|
${statChips.length ? `<div class="widget-greeting__chips">${statChips.join('')}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,10 +147,10 @@ function renderGreeting(user, stats = {}) {
|
|||||||
function renderUrgentTasks(tasks) {
|
function renderUrgentTasks(tasks) {
|
||||||
if (!tasks.length) {
|
if (!tasks.length) {
|
||||||
return `<div class="widget">
|
return `<div class="widget">
|
||||||
${widgetHeader('check-square', 'Aufgaben', 0, '/tasks')}
|
${widgetHeader('check-square', t('nav.tasks'), 0, '/tasks')}
|
||||||
<div class="widget__empty">
|
<div class="widget__empty">
|
||||||
<i data-lucide="check-circle" class="empty-state__icon" style="color:var(--color-success)" aria-hidden="true"></i>
|
<i data-lucide="check-circle" class="empty-state__icon" style="color:var(--color-success)" aria-hidden="true"></i>
|
||||||
<div>Alles erledigt</div>
|
<div>${t('dashboard.allDone')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -174,7 +172,7 @@ function renderUrgentTasks(tasks) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `<div class="widget">
|
return `<div class="widget">
|
||||||
${widgetHeader('check-square', 'Aufgaben', tasks.length, '/tasks')}
|
${widgetHeader('check-square', t('nav.tasks'), tasks.length, '/tasks')}
|
||||||
<div class="widget__body">${items}</div>
|
<div class="widget__body">${items}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -182,10 +180,10 @@ function renderUrgentTasks(tasks) {
|
|||||||
function renderUpcomingEvents(events) {
|
function renderUpcomingEvents(events) {
|
||||||
if (!events.length) {
|
if (!events.length) {
|
||||||
return `<div class="widget">
|
return `<div class="widget">
|
||||||
${widgetHeader('calendar', 'Termine', 0, '/calendar')}
|
${widgetHeader('calendar', t('nav.calendar'), 0, '/calendar')}
|
||||||
<div class="widget__empty">
|
<div class="widget__empty">
|
||||||
<i data-lucide="calendar-check" class="empty-state__icon" aria-hidden="true"></i>
|
<i data-lucide="calendar-check" class="empty-state__icon" aria-hidden="true"></i>
|
||||||
<div>Keine Termine</div>
|
<div>${t('dashboard.noEvents')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -194,14 +192,15 @@ function renderUpcomingEvents(events) {
|
|||||||
const items = events.map((e) => {
|
const items = events.map((e) => {
|
||||||
const d = new Date(e.start_datetime);
|
const d = new Date(e.start_datetime);
|
||||||
const isToday = d.toDateString() === today;
|
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 `
|
return `
|
||||||
<div class="event-item" data-route="/calendar" role="button" tabindex="0">
|
<div class="event-item" data-route="/calendar" role="button" tabindex="0">
|
||||||
<div class="event-item__bar" style="background-color:${e.color || 'var(--color-accent)'}"></div>
|
<div class="event-item__bar" style="background-color:${e.color || 'var(--color-accent)'}"></div>
|
||||||
<div class="event-item__content">
|
<div class="event-item__content">
|
||||||
<div class="event-item__title">${e.title}</div>
|
<div class="event-item__title">${e.title}</div>
|
||||||
<div class="event-item__time">
|
<div class="event-item__time">
|
||||||
<span class="event-time-badge ${isToday ? 'event-time-badge--today' : ''}">${isToday ? 'Heute' : formatDateTime(e.start_datetime).split(',')[0]}</span>
|
<span class="event-time-badge ${isToday ? 'event-time-badge--today' : ''}">${isToday ? t('common.today') : formatDateTime(e.start_datetime).split(',')[0]}</span>
|
||||||
${timeStr}
|
${timeStr}
|
||||||
${e.location ? ` · ${e.location}` : ''}
|
${e.location ? ` · ${e.location}` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +210,7 @@ function renderUpcomingEvents(events) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `<div class="widget">
|
return `<div class="widget">
|
||||||
${widgetHeader('calendar', 'Termine', events.length, '/calendar')}
|
${widgetHeader('calendar', t('nav.calendar'), events.length, '/calendar')}
|
||||||
<div class="widget__body">${items}</div>
|
<div class="widget__body">${items}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -219,19 +218,20 @@ function renderUpcomingEvents(events) {
|
|||||||
function renderTodayMeals(meals) {
|
function renderTodayMeals(meals) {
|
||||||
const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack'];
|
const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||||
|
|
||||||
|
const mealLabels = MEAL_LABELS();
|
||||||
const slots = MEAL_ORDER.map((type) => {
|
const slots = MEAL_ORDER.map((type) => {
|
||||||
const meal = meals.find((m) => m.meal_type === type);
|
const meal = meals.find((m) => m.meal_type === type);
|
||||||
return `
|
return `
|
||||||
<div class="meal-slot ${meal ? 'meal-slot--filled' : ''}" data-route="/meals" role="button" tabindex="0">
|
<div class="meal-slot ${meal ? 'meal-slot--filled' : ''}" data-route="/meals" role="button" tabindex="0">
|
||||||
<i data-lucide="${MEAL_ICONS[type]}" class="meal-slot__icon" aria-hidden="true"></i>
|
<i data-lucide="${MEAL_ICONS[type]}" class="meal-slot__icon" aria-hidden="true"></i>
|
||||||
<div class="meal-slot__type">${MEAL_LABELS[type]}</div>
|
<div class="meal-slot__type">${mealLabels[type]}</div>
|
||||||
<div class="meal-slot__title">${meal ? meal.title : '—'}</div>
|
<div class="meal-slot__title">${meal ? meal.title : '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `<div class="widget widget--meals">
|
return `<div class="widget widget--meals">
|
||||||
${widgetHeader('utensils', 'Heute essen', null, '/meals', 'Woche')}
|
${widgetHeader('utensils', t('dashboard.todayMeals'), null, '/meals', t('dashboard.weekLink'))}
|
||||||
<div class="meal-slots">${slots}</div>
|
<div class="meal-slots">${slots}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -239,10 +239,10 @@ function renderTodayMeals(meals) {
|
|||||||
function renderPinnedNotes(notes) {
|
function renderPinnedNotes(notes) {
|
||||||
if (!notes.length) {
|
if (!notes.length) {
|
||||||
return `<div class="widget">
|
return `<div class="widget">
|
||||||
${widgetHeader('pin', 'Pinnwand', 0, '/notes')}
|
${widgetHeader('pin', t('nav.notes'), 0, '/notes')}
|
||||||
<div class="widget__empty">
|
<div class="widget__empty">
|
||||||
<i data-lucide="sticky-note" class="empty-state__icon" aria-hidden="true"></i>
|
<i data-lucide="sticky-note" class="empty-state__icon" aria-hidden="true"></i>
|
||||||
<div>Keine angepinnten Notizen</div>
|
<div>${t('dashboard.noPinnedNotes')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ function renderPinnedNotes(notes) {
|
|||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
return `<div class="widget widget--wide">
|
return `<div class="widget widget--wide">
|
||||||
${widgetHeader('pin', 'Pinnwand', notes.length, '/notes')}
|
${widgetHeader('pin', t('nav.notes'), notes.length, '/notes')}
|
||||||
<div class="notes-grid-widget">${items}</div>
|
<div class="notes-grid-widget">${items}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -274,7 +274,7 @@ function renderWeatherWidget(weather) {
|
|||||||
|
|
||||||
const forecastHtml = forecast.map((d, i) => {
|
const forecastHtml = forecast.map((d, i) => {
|
||||||
const date = new Date(d.date + 'T12:00:00');
|
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' : '';
|
const extraCls = i >= 3 ? ' weather-forecast__day--extended' : '';
|
||||||
return `
|
return `
|
||||||
<div class="weather-forecast__day${extraCls}">
|
<div class="weather-forecast__day${extraCls}">
|
||||||
@@ -290,7 +290,7 @@ function renderWeatherWidget(weather) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="widget weather-widget" id="weather-widget">
|
<div class="widget weather-widget" id="weather-widget">
|
||||||
<button class="weather-widget__refresh" id="weather-refresh-btn" aria-label="Wetter aktualisieren" title="Aktualisieren">
|
<button class="weather-widget__refresh" id="weather-refresh-btn" aria-label="${t('dashboard.weatherRefresh')}" title="${t('dashboard.weatherRefreshTitle')}">
|
||||||
<i data-lucide="refresh-cw" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="refresh-cw" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="weather-widget__inner">
|
<div class="weather-widget__inner">
|
||||||
@@ -300,7 +300,7 @@ function renderWeatherWidget(weather) {
|
|||||||
<div class="weather-widget__desc">${current.desc}</div>
|
<div class="weather-widget__desc">${current.desc}</div>
|
||||||
<div class="weather-widget__city">${city}</div>
|
<div class="weather-widget__city">${city}</div>
|
||||||
<div class="weather-widget__meta">
|
<div class="weather-widget__meta">
|
||||||
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 })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img class="weather-widget__icon" src="${WEATHER_ICON_BASE}${current.icon}@2x.png"
|
<img class="weather-widget__icon" src="${WEATHER_ICON_BASE}${current.icon}@2x.png"
|
||||||
@@ -315,17 +315,17 @@ function renderWeatherWidget(weather) {
|
|||||||
// FAB Speed-Dial
|
// FAB Speed-Dial
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
const FAB_ACTIONS = [
|
const FAB_ACTIONS = () => [
|
||||||
{ route: '/tasks', label: 'Aufgabe', icon: 'check-square' },
|
{ route: '/tasks', label: t('dashboard.fabTask'), icon: 'check-square' },
|
||||||
{ route: '/calendar', label: 'Termin', icon: 'calendar-plus' },
|
{ route: '/calendar', label: t('dashboard.fabCalendar'), icon: 'calendar-plus' },
|
||||||
{ route: '/shopping', label: 'Einkauf', icon: 'shopping-cart' },
|
{ route: '/shopping', label: t('dashboard.fabShopping'), icon: 'shopping-cart' },
|
||||||
{ route: '/notes', label: 'Notiz', icon: 'sticky-note' },
|
{ route: '/notes', label: t('dashboard.fabNote'), icon: 'sticky-note' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function renderFab() {
|
function renderFab() {
|
||||||
const actionsHtml = FAB_ACTIONS.map((a) => `
|
const actionsHtml = FAB_ACTIONS().map((a) => `
|
||||||
<div class="fab-action" data-route="${a.route}" role="button" tabindex="-1"
|
<div class="fab-action" data-route="${a.route}" role="button" tabindex="-1"
|
||||||
aria-label="${a.label} hinzufügen">
|
aria-label="${a.label}">
|
||||||
<span class="fab-action__label">${a.label}</span>
|
<span class="fab-action__label">${a.label}</span>
|
||||||
<button class="fab-action__btn" tabindex="-1" aria-hidden="true">
|
<button class="fab-action__btn" tabindex="-1" aria-hidden="true">
|
||||||
<i data-lucide="${a.icon}" aria-hidden="true"></i>
|
<i data-lucide="${a.icon}" aria-hidden="true"></i>
|
||||||
@@ -335,7 +335,7 @@ function renderFab() {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="fab-container" id="fab-container">
|
<div class="fab-container" id="fab-container">
|
||||||
<button class="fab-main" id="fab-main" aria-label="Schnellaktionen" aria-expanded="false">
|
<button class="fab-main" id="fab-main" aria-label="${t('nav.quickActions')}" aria-expanded="false">
|
||||||
<i data-lucide="plus" aria-hidden="true"></i>
|
<i data-lucide="plus" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="fab-actions" id="fab-actions" aria-hidden="true">
|
<div class="fab-actions" id="fab-actions" aria-hidden="true">
|
||||||
@@ -410,7 +410,7 @@ export async function render(container, { user }) {
|
|||||||
<div class="widget-greeting" style="grid-column:1/-1">
|
<div class="widget-greeting" style="grid-column:1/-1">
|
||||||
<div class="widget-greeting__content">
|
<div class="widget-greeting__content">
|
||||||
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
|
<div class="widget-greeting__title">${greeting(user.display_name)}</div>
|
||||||
<div class="widget-greeting__date">${formatDate()}</div>
|
<div class="widget-greeting__date">${formatDate(new Date())}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${skeletonWidget(3)}
|
${skeletonWidget(3)}
|
||||||
@@ -433,7 +433,7 @@ export async function render(container, { user }) {
|
|||||||
weather = weatherRes.data ?? null;
|
weather = weatherRes.data ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Dashboard] Ladefehler:', err.message);
|
console.error('[Dashboard] Ladefehler:', err.message);
|
||||||
window.oikos?.showToast('Dashboard konnte nicht vollständig geladen werden.', 'warning');
|
window.oikos?.showToast(t('dashboard.loadError'), 'warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toDateString();
|
const today = new Date().toDateString();
|
||||||
@@ -449,7 +449,7 @@ export async function render(container, { user }) {
|
|||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<h1 class="sr-only">Übersicht</h1>
|
<h1 class="sr-only">${t('dashboard.title')}</h1>
|
||||||
<div class="dashboard__grid">
|
<div class="dashboard__grid">
|
||||||
${renderGreeting(user, stats)}
|
${renderGreeting(user, stats)}
|
||||||
${renderWeatherWidget(weather)}
|
${renderWeatherWidget(weather)}
|
||||||
|
|||||||
+12
-11
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { auth } from '/api.js';
|
import { auth } from '/api.js';
|
||||||
|
import { t } from '/i18n.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rendert die Login-Seite in den gegebenen Container.
|
* Rendert die Login-Seite in den gegebenen Container.
|
||||||
@@ -15,13 +16,13 @@ export async function render(container) {
|
|||||||
<main class="login-page" id="main-content">
|
<main class="login-page" id="main-content">
|
||||||
<div class="login-hero">
|
<div class="login-hero">
|
||||||
<h1 class="login-hero__title">Oikos</h1>
|
<h1 class="login-hero__title">Oikos</h1>
|
||||||
<p class="login-hero__tagline">Familienplanung. Sicher. Datenschutzfreundlich. Open Source.</p>
|
<p class="login-hero__tagline">${t('login.tagline')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="login-card card card--padded">
|
<div class="login-card card card--padded">
|
||||||
|
|
||||||
<form class="login-form" id="login-form" novalidate>
|
<form class="login-form" id="login-form" novalidate>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="username">Benutzername</label>
|
<label class="label" for="username">${t('login.usernameLabel')}</label>
|
||||||
<input
|
<input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -30,20 +31,20 @@ export async function render(container) {
|
|||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
autocapitalize="none"
|
autocapitalize="none"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
placeholder="benutzername"
|
placeholder="${t('login.usernamePlaceholder')}"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="password">Passwort</label>
|
<label class="label" for="password">${t('login.passwordLabel')}</label>
|
||||||
<input
|
<input
|
||||||
class="input"
|
class="input"
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
placeholder="••••••••"
|
placeholder="${t('login.passwordPlaceholder')}"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +52,7 @@ export async function render(container) {
|
|||||||
<div class="login-error" id="login-error" role="alert" aria-live="polite" hidden></div>
|
<div class="login-error" id="login-error" role="alert" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn--primary login-form__submit" id="login-btn">
|
<button type="submit" class="btn btn--primary login-form__submit" id="login-btn">
|
||||||
Anmelden
|
${t('login.loginButton')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,24 +71,24 @@ export async function render(container) {
|
|||||||
const password = form.password.value;
|
const password = form.password.value;
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
showError(errorEl, 'Bitte alle Felder ausfüllen.');
|
showError(errorEl, t('common.allFieldsRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Wird angemeldet …';
|
submitBtn.textContent = t('login.loggingIn');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await auth.login(username, password);
|
const result = await auth.login(username, password);
|
||||||
window.oikos.navigate('/', result.user);
|
window.oikos.navigate('/', result.user);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(errorEl, err.status === 429
|
showError(errorEl, err.status === 429
|
||||||
? 'Zu viele Versuche. Bitte warte kurz.'
|
? t('login.tooManyAttempts')
|
||||||
: 'Ungültige Anmeldedaten.'
|
: t('login.invalidCredentials')
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = 'Anmelden';
|
submitBtn.textContent = t('login.loginButton');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-59
@@ -7,19 +7,23 @@
|
|||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js';
|
||||||
import { stagger } from '/utils/ux.js';
|
import { stagger } from '/utils/ux.js';
|
||||||
|
import { t, formatDate } from '/i18n.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
const MEAL_TYPES = [
|
const MEAL_TYPES = () => [
|
||||||
{ key: 'breakfast', label: 'Frühstück', icon: 'sunrise' },
|
{ key: 'breakfast', label: t('meals.typeBreakfast'), icon: 'sunrise' },
|
||||||
{ key: 'lunch', label: 'Mittagessen', icon: 'sun' },
|
{ key: 'lunch', label: t('meals.typeLunch'), icon: 'sun' },
|
||||||
{ key: 'dinner', label: 'Abendessen', icon: 'moon' },
|
{ key: 'dinner', label: t('meals.typeDinner'), icon: 'moon' },
|
||||||
{ key: 'snack', label: 'Snack', icon: 'cookie' },
|
{ key: 'snack', label: t('meals.typeSnack'), icon: 'cookie' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DAY_NAMES = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
const DAY_NAMES = () => [
|
||||||
|
t('meals.dayMo'), t('meals.dayDi'), t('meals.dayMi'), t('meals.dayDo'),
|
||||||
|
t('meals.dayFr'), t('meals.daySa'), t('meals.daySo'),
|
||||||
|
];
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
@@ -55,11 +59,7 @@ function addDays(dateStr, n) {
|
|||||||
|
|
||||||
function formatWeekLabel(monday) {
|
function formatWeekLabel(monday) {
|
||||||
const sunday = addDays(monday, 6);
|
const sunday = addDays(monday, 6);
|
||||||
const fmt = (s) => {
|
return `${formatDate(monday)} – ${formatDate(sunday)}`;
|
||||||
const d = new Date(s + 'T00:00:00Z');
|
|
||||||
return `${d.getUTCDate().toString().padStart(2, '0')}.${(d.getUTCMonth() + 1).toString().padStart(2, '0')}.${d.getUTCFullYear()}`;
|
|
||||||
};
|
|
||||||
return `${fmt(monday)} – ${fmt(sunday)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isToday(dateStr) {
|
function isToday(dateStr) {
|
||||||
@@ -67,8 +67,7 @@ function isToday(dateStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDayDate(dateStr) {
|
function formatDayDate(dateStr) {
|
||||||
const d = new Date(dateStr + 'T00:00:00Z');
|
return formatDate(dateStr);
|
||||||
return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -84,7 +83,7 @@ async function loadWeek(week) {
|
|||||||
console.error('[Meals] loadWeek Fehler:', err);
|
console.error('[Meals] loadWeek Fehler:', err);
|
||||||
state.meals = [];
|
state.meals = [];
|
||||||
state.currentWeek = getMondayOf(week);
|
state.currentWeek = getMondayOf(week);
|
||||||
window.oikos?.showToast('Essensplan konnte nicht geladen werden.', 'danger');
|
window.oikos?.showToast(t('meals.loadError'), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,19 +104,19 @@ export async function render(container, { user }) {
|
|||||||
_container = container;
|
_container = container;
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="meals-page">
|
<div class="meals-page">
|
||||||
<h1 class="sr-only">Essensplan</h1>
|
<h1 class="sr-only">${t('meals.title')}</h1>
|
||||||
<div class="week-nav">
|
<div class="week-nav">
|
||||||
<button class="btn btn--icon" id="week-prev" aria-label="Vorherige Woche">
|
<button class="btn btn--icon" id="week-prev" aria-label="${t('meals.prevWeek')}">
|
||||||
<i data-lucide="chevron-left" aria-hidden="true"></i>
|
<i data-lucide="chevron-left" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<span class="week-nav__label" id="week-label"></span>
|
<span class="week-nav__label" id="week-label"></span>
|
||||||
<button class="week-nav__today" id="week-today">Heute</button>
|
<button class="week-nav__today" id="week-today">${t('meals.today')}</button>
|
||||||
<button class="btn btn--icon" id="week-next" aria-label="Nächste Woche">
|
<button class="btn btn--icon" id="week-next" aria-label="${t('meals.nextWeek')}">
|
||||||
<i data-lucide="chevron-right" aria-hidden="true"></i>
|
<i data-lucide="chevron-right" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="week-grid" id="week-grid">
|
<div class="week-grid" id="week-grid">
|
||||||
<div style="margin:auto;padding:2rem;text-align:center;color:var(--color-text-disabled)">Lade…</div>
|
<div style="margin:auto;padding:2rem;text-align:center;color:var(--color-text-disabled)">${t('meals.loadingIndicator')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -144,6 +143,7 @@ function renderWeekGrid() {
|
|||||||
formatWeekLabel(state.currentWeek);
|
formatWeekLabel(state.currentWeek);
|
||||||
|
|
||||||
const days = Array.from({ length: 7 }, (_, i) => addDays(state.currentWeek, i));
|
const days = Array.from({ length: 7 }, (_, i) => addDays(state.currentWeek, i));
|
||||||
|
const dayNames = DAY_NAMES();
|
||||||
|
|
||||||
grid.innerHTML = days.map((date, idx) => {
|
grid.innerHTML = days.map((date, idx) => {
|
||||||
const mealsForDay = state.meals.filter((m) => m.date === date);
|
const mealsForDay = state.meals.filter((m) => m.date === date);
|
||||||
@@ -152,11 +152,11 @@ function renderWeekGrid() {
|
|||||||
return `
|
return `
|
||||||
<div class="day-column">
|
<div class="day-column">
|
||||||
<div class="day-header ${todayClass}">
|
<div class="day-header ${todayClass}">
|
||||||
<span class="day-header__name">${DAY_NAMES[idx]}</span>
|
<span class="day-header__name">${dayNames[idx]}</span>
|
||||||
<span class="day-header__date">${formatDayDate(date)}</span>
|
<span class="day-header__date">${formatDayDate(date)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="day-slots">
|
<div class="day-slots">
|
||||||
${MEAL_TYPES.map((type) => renderSlot(date, type, mealsForDay)).join('')}
|
${MEAL_TYPES().map((type) => renderSlot(date, type, mealsForDay)).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -175,14 +175,14 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
<div class="meal-slot meal-slot--empty" data-date="${date}" data-type="${type.key}">
|
<div class="meal-slot meal-slot--empty" data-date="${date}" data-type="${type.key}">
|
||||||
<div class="meal-slot__type-label">${type.label}</div>
|
<div class="meal-slot__type-label">${type.label}</div>
|
||||||
<div class="empty-state empty-state--compact">
|
<div class="empty-state empty-state--compact">
|
||||||
<div class="empty-state__description">Kein Essen geplant</div>
|
<div class="empty-state__description">${t('meals.noMealPlanned')}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="meal-slot__add-btn"
|
class="meal-slot__add-btn"
|
||||||
data-action="add-meal"
|
data-action="add-meal"
|
||||||
data-date="${date}"
|
data-date="${date}"
|
||||||
data-type="${type.key}"
|
data-type="${type.key}"
|
||||||
aria-label="${type.label} hinzufügen"
|
aria-label="${t('meals.addMeal', { type: type.label })}"
|
||||||
>
|
>
|
||||||
<i data-lucide="plus" style="width:16px;height:16px;" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -192,7 +192,7 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
|
|
||||||
const ingCount = meal.ingredients?.length ?? 0;
|
const ingCount = meal.ingredients?.length ?? 0;
|
||||||
const ingDone = meal.ingredients?.filter((i) => i.on_shopping_list).length ?? 0;
|
const ingDone = meal.ingredients?.filter((i) => i.on_shopping_list).length ?? 0;
|
||||||
const ingLabel = ingCount > 0 ? `${ingCount} Zutat${ingCount !== 1 ? 'en' : ''}` : '';
|
const ingLabel = ingCount > 0 ? (ingCount !== 1 ? t('meals.ingredientCountPlural', { count: ingCount }) : t('meals.ingredientCount', { count: ingCount })) : '';
|
||||||
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
const ingDoneLabel = ingCount > 0 && ingDone === ingCount ? ' ✓' : '';
|
||||||
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
const canTransfer = ingCount > 0 && ingDone < ingCount;
|
||||||
|
|
||||||
@@ -211,12 +211,12 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
${canTransfer ? `<button class="meal-card__action-btn meal-card__action-btn--shopping"
|
${canTransfer ? `<button class="meal-card__action-btn meal-card__action-btn--shopping"
|
||||||
data-action="transfer-meal"
|
data-action="transfer-meal"
|
||||||
data-meal-id="${meal.id}"
|
data-meal-id="${meal.id}"
|
||||||
aria-label="Zutaten auf Einkaufsliste"
|
aria-label="${t('meals.transferToShoppingList')}"
|
||||||
><i data-lucide="shopping-cart" style="width:14px;height:14px;" aria-hidden="true"></i></button>` : ''}
|
><i data-lucide="shopping-cart" style="width:14px;height:14px;" aria-hidden="true"></i></button>` : ''}
|
||||||
<button class="meal-card__action-btn"
|
<button class="meal-card__action-btn"
|
||||||
data-action="delete-meal"
|
data-action="delete-meal"
|
||||||
data-meal-id="${meal.id}"
|
data-meal-id="${meal.id}"
|
||||||
aria-label="Mahlzeit löschen"
|
aria-label="${t('meals.deleteMeal')}"
|
||||||
><i data-lucide="trash-2" style="width:14px;height:14px;" aria-hidden="true"></i></button>
|
><i data-lucide="trash-2" style="width:14px;height:14px;" aria-hidden="true"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,7 +429,7 @@ function openMealModal(opts) {
|
|||||||
const content = buildModalContent(opts);
|
const content = buildModalContent(opts);
|
||||||
|
|
||||||
openSharedModal({
|
openSharedModal({
|
||||||
title: isEdit ? 'Mahlzeit bearbeiten' : 'Mahlzeit hinzufügen',
|
title: isEdit ? t('meals.editMeal') : t('meals.addMealTitle'),
|
||||||
content,
|
content,
|
||||||
size: 'md',
|
size: 'md',
|
||||||
onSave(panel) {
|
onSave(panel) {
|
||||||
@@ -498,16 +498,16 @@ function openMealModal(opts) {
|
|||||||
try {
|
try {
|
||||||
const res = await api.post(`/meals/${state.modal.meal.id}/to-shopping-list`, { listId });
|
const res = await api.post(`/meals/${state.modal.meal.id}/to-shopping-list`, { listId });
|
||||||
if (res.data.transferred > 0) {
|
if (res.data.transferred > 0) {
|
||||||
window.oikos?.showToast(`${res.data.transferred} Zutat${res.data.transferred !== 1 ? 'en' : ''} übertragen`, 'success');
|
window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success');
|
||||||
await loadWeek(state.currentWeek);
|
await loadWeek(state.currentWeek);
|
||||||
closeModal();
|
closeModal();
|
||||||
renderWeekGrid();
|
renderWeekGrid();
|
||||||
} else {
|
} else {
|
||||||
window.oikos?.showToast('Alle Zutaten bereits übertragen', 'info');
|
window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -520,13 +520,13 @@ function openMealModal(opts) {
|
|||||||
|
|
||||||
function buildModalContent({ mode, date, mealType, meal }) {
|
function buildModalContent({ mode, date, mealType, meal }) {
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
const typeOpts = MEAL_TYPES.map((t) =>
|
const typeOpts = MEAL_TYPES().map((mt) =>
|
||||||
`<option value="${t.key}" ${t.key === mealType ? 'selected' : ''}>${t.label}</option>`
|
`<option value="${mt.key}" ${mt.key === mealType ? 'selected' : ''}>${mt.label}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const listOpts = state.lists.length
|
const listOpts = state.lists.length
|
||||||
? state.lists.map((l) => `<option value="${l.id}">${escHtml(l.name)}</option>`).join('')
|
? state.lists.map((l) => `<option value="${l.id}">${escHtml(l.name)}</option>`).join('')
|
||||||
: '<option value="" disabled>Keine Einkaufslisten vorhanden</option>';
|
: `<option value="" disabled>${t('meals.noShoppingLists')}</option>`;
|
||||||
|
|
||||||
const ingRows = isEdit && meal.ingredients?.length
|
const ingRows = isEdit && meal.ingredients?.length
|
||||||
? meal.ingredients.map((ing) => ingredientRowHTML(ing.name, ing.quantity ?? '', ing.id)).join('')
|
? meal.ingredients.map((ing) => ingredientRowHTML(ing.name, ing.quantity ?? '', ing.id)).join('')
|
||||||
@@ -537,36 +537,36 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
return `
|
return `
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label class="form-label" for="modal-date">Datum</label>
|
<label class="form-label" for="modal-date">${t('meals.dateLabel')}</label>
|
||||||
<input type="date" class="form-input" id="modal-date" value="${date}">
|
<input type="date" class="form-input" id="modal-date" value="${date}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label class="form-label" for="modal-type">Mahlzeit</label>
|
<label class="form-label" for="modal-type">${t('meals.mealTypeLabel')}</label>
|
||||||
<select class="form-input" id="modal-type">${typeOpts}</select>
|
<select class="form-input" id="modal-type">${typeOpts}</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="position:relative;">
|
<div class="form-group" style="position:relative;">
|
||||||
<label class="form-label" for="modal-title">Titel *</label>
|
<label class="form-label" for="modal-title">${t('meals.titleLabel')}</label>
|
||||||
<input type="text" class="form-input" id="modal-title"
|
<input type="text" class="form-input" id="modal-title"
|
||||||
placeholder="z.B. Spaghetti Bolognese"
|
placeholder="${t('meals.titlePlaceholder')}"
|
||||||
value="${escHtml(isEdit ? meal.title : '')}"
|
value="${escHtml(isEdit ? meal.title : '')}"
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
<div id="modal-autocomplete" class="meal-modal__autocomplete" hidden></div>
|
<div id="modal-autocomplete" class="meal-modal__autocomplete" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-notes">Notizen</label>
|
<label class="form-label" for="modal-notes">${t('meals.notesLabel')}</label>
|
||||||
<textarea class="form-input" id="modal-notes" rows="2"
|
<textarea class="form-input" id="modal-notes" rows="2"
|
||||||
placeholder="Optional…">${escHtml(isEdit && meal.notes ? meal.notes : '')}</textarea>
|
placeholder="${t('meals.notesPlaceholder')}">${escHtml(isEdit && meal.notes ? meal.notes : '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Zutaten</label>
|
<label class="form-label">${t('meals.ingredientsLabel')}</label>
|
||||||
<div class="ingredient-list" id="ingredient-list">${ingRows}</div>
|
<div class="ingredient-list" id="ingredient-list">${ingRows}</div>
|
||||||
<button class="add-ingredient-btn" id="add-ingredient-btn" type="button">
|
<button class="add-ingredient-btn" id="add-ingredient-btn" type="button">
|
||||||
<i data-lucide="plus" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
Zutat hinzufügen
|
${t('meals.addIngredient')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -574,26 +574,26 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
<div class="shopping-transfer">
|
<div class="shopping-transfer">
|
||||||
<div class="shopping-transfer__label">
|
<div class="shopping-transfer__label">
|
||||||
<i data-lucide="shopping-cart" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="shopping-cart" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
Zutaten auf Einkaufsliste übertragen
|
${t('meals.transferLabel')}
|
||||||
</div>
|
</div>
|
||||||
<select class="shopping-transfer__select" id="transfer-list-select">${listOpts}</select>
|
<select class="shopping-transfer__select" id="transfer-list-select">${listOpts}</select>
|
||||||
<button class="btn btn--secondary shopping-transfer__btn" id="transfer-btn" type="button">
|
<button class="btn btn--secondary shopping-transfer__btn" id="transfer-btn" type="button">
|
||||||
Jetzt übertragen
|
${t('meals.transferNow')}
|
||||||
</button>
|
</button>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
||||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||||
<button class="btn btn--secondary" id="modal-cancel">Abbrechen</button>
|
<button class="btn btn--secondary" id="modal-cancel">${t('common.cancel')}</button>
|
||||||
<button class="btn btn--primary" id="modal-save">${isEdit ? 'Speichern' : 'Hinzufügen'}</button>
|
<button class="btn btn--primary" id="modal-save">${isEdit ? t('common.save') : t('common.add')}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ingredientRowHTML(name, qty, id) {
|
function ingredientRowHTML(name, qty, id) {
|
||||||
return `
|
return `
|
||||||
<div class="ingredient-row" data-ing-id="${id ?? ''}">
|
<div class="ingredient-row" data-ing-id="${id ?? ''}">
|
||||||
<input type="text" class="form-input ingredient-row__name" placeholder="Zutat" value="${escHtml(name)}">
|
<input type="text" class="form-input ingredient-row__name" placeholder="${t('meals.ingredientNamePlaceholder')}" value="${escHtml(name)}">
|
||||||
<input type="text" class="form-input ingredient-row__qty" placeholder="Menge" value="${escHtml(qty)}">
|
<input type="text" class="form-input ingredient-row__qty" placeholder="${t('meals.ingredientQtyPlaceholder')}" value="${escHtml(qty)}">
|
||||||
<button class="ingredient-row__remove" data-action="remove-ingredient" type="button" aria-label="Zutat entfernen">
|
<button class="ingredient-row__remove" data-action="remove-ingredient" type="button" aria-label="${t('meals.removeIngredient')}">
|
||||||
<i data-lucide="x" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="x" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -613,7 +613,7 @@ async function saveModal(overlay) {
|
|||||||
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
const notes = overlay.querySelector('#modal-notes').value.trim() || null;
|
||||||
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
window.oikos?.showToast('Titel ist erforderlich', 'error');
|
window.oikos?.showToast(t('meals.titleRequired'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,11 +656,11 @@ async function saveModal(overlay) {
|
|||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
renderWeekGrid();
|
renderWeekGrid();
|
||||||
window.oikos?.showToast(mode === 'create' ? 'Mahlzeit hinzugefügt' : 'Mahlzeit gespeichert', 'success');
|
window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler beim Speichern', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
saveBtn.textContent = state.modal?.mode === 'edit' ? 'Speichern' : 'Hinzufügen';
|
saveBtn.textContent = state.modal?.mode === 'edit' ? t('common.save') : t('common.add');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,14 +669,14 @@ async function saveModal(overlay) {
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
async function deleteMeal(mealId) {
|
async function deleteMeal(mealId) {
|
||||||
if (!confirm('Mahlzeit wirklich löschen?')) return;
|
if (!confirm(t('meals.deleteMeal') + '?')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/meals/${mealId}`);
|
await api.delete(`/meals/${mealId}`);
|
||||||
state.meals = state.meals.filter((m) => m.id !== mealId);
|
state.meals = state.meals.filter((m) => m.id !== mealId);
|
||||||
renderWeekGrid();
|
renderWeekGrid();
|
||||||
window.oikos?.showToast('Mahlzeit gelöscht', 'success');
|
window.oikos?.showToast(t('meals.deleteMeal'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler beim Löschen', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,7 +686,7 @@ async function deleteMeal(mealId) {
|
|||||||
|
|
||||||
async function transferMeal(mealId) {
|
async function transferMeal(mealId) {
|
||||||
if (!state.lists.length) {
|
if (!state.lists.length) {
|
||||||
window.oikos?.showToast('Keine Einkaufslisten vorhanden', 'error');
|
window.oikos?.showToast(t('meals.noShoppingLists'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,14 +703,14 @@ async function transferMeal(mealId) {
|
|||||||
try {
|
try {
|
||||||
const res = await api.post(`/meals/${mealId}/to-shopping-list`, { listId });
|
const res = await api.post(`/meals/${mealId}/to-shopping-list`, { listId });
|
||||||
if (res.data.transferred > 0) {
|
if (res.data.transferred > 0) {
|
||||||
window.oikos?.showToast(`${res.data.transferred} Zutat${res.data.transferred !== 1 ? 'en' : ''} übertragen`, 'success');
|
window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success');
|
||||||
await loadWeek(state.currentWeek);
|
await loadWeek(state.currentWeek);
|
||||||
renderWeekGrid();
|
renderWeekGrid();
|
||||||
} else {
|
} else {
|
||||||
window.oikos?.showToast('Alle Zutaten bereits übertragen', 'info');
|
window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler beim Übertragen', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-38
@@ -7,6 +7,7 @@
|
|||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js';
|
||||||
import { stagger, vibrate } from '/utils/ux.js';
|
import { stagger, vibrate } from '/utils/ux.js';
|
||||||
|
import { t } from '/i18n.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -48,20 +49,20 @@ export async function render(container, { user }) {
|
|||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="notes-page">
|
<div class="notes-page">
|
||||||
<div class="notes-toolbar">
|
<div class="notes-toolbar">
|
||||||
<h1 class="notes-toolbar__title">Pinnwand</h1>
|
<h1 class="notes-toolbar__title">${t('notes.title')}</h1>
|
||||||
<div class="notes-toolbar__search">
|
<div class="notes-toolbar__search">
|
||||||
<i data-lucide="search" class="notes-toolbar__search-icon" aria-hidden="true"></i>
|
<i data-lucide="search" class="notes-toolbar__search-icon" aria-hidden="true"></i>
|
||||||
<input type="search" id="notes-search" class="notes-toolbar__search-input"
|
<input type="search" id="notes-search" class="notes-toolbar__search-input"
|
||||||
placeholder="Notizen durchsuchen…" autocomplete="off"
|
placeholder="${t('notes.searchPlaceholder')}" autocomplete="off"
|
||||||
value="${escHtml(state.filterQuery)}">
|
value="${escHtml(state.filterQuery)}">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--primary" id="notes-add-btn">
|
<button class="btn btn--primary" id="notes-add-btn">
|
||||||
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
||||||
Neue Notiz
|
${t('notes.addNoteLabel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="notes-grid" class="notes-grid"></div>
|
<div id="notes-grid" class="notes-grid"></div>
|
||||||
<button class="page-fab" id="fab-new-note" aria-label="Neue Notiz">
|
<button class="page-fab" id="fab-new-note" aria-label="${t('notes.addNoteLabel')}">
|
||||||
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,7 +76,7 @@ export async function render(container, { user }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Notes] Laden fehlgeschlagen:', err);
|
console.error('[Notes] Laden fehlgeschlagen:', err);
|
||||||
state.notes = [];
|
state.notes = [];
|
||||||
window.oikos?.showToast('Notizen konnten nicht geladen werden.', 'danger');
|
window.oikos?.showToast(t('notes.loadError'), 'danger');
|
||||||
}
|
}
|
||||||
const grid = container.querySelector('#notes-grid');
|
const grid = container.querySelector('#notes-grid');
|
||||||
grid.addEventListener('click', async (e) => {
|
grid.addEventListener('click', async (e) => {
|
||||||
@@ -131,8 +132,8 @@ function renderGrid() {
|
|||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
<polyline points="10 9 9 9 8 9"/>
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="empty-state__title">${isFiltered ? 'Keine Treffer' : 'Noch keine Notizen'}</div>
|
<div class="empty-state__title">${isFiltered ? t('notes.noResultsTitle') : t('notes.emptyTitle')}</div>
|
||||||
<div class="empty-state__description">${isFiltered ? `Keine Notiz enthält „${escHtml(state.filterQuery)}".` : 'Neue Notiz über den + Button erstellen.'}</div>
|
<div class="empty-state__description">${isFiltered ? t('notes.noResultsDescription', { query: state.filterQuery }) : t('notes.emptyDescription')}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
@@ -156,7 +157,7 @@ function renderNoteCard(note) {
|
|||||||
data-id="${note.id}"
|
data-id="${note.id}"
|
||||||
style="background-color:${escHtml(note.color)};color:${textColor};">
|
style="background-color:${escHtml(note.color)};color:${textColor};">
|
||||||
<button class="note-card__pin" data-action="pin" data-id="${note.id}"
|
<button class="note-card__pin" data-action="pin" data-id="${note.id}"
|
||||||
aria-label="${note.pinned ? 'Anpinnen aufheben' : 'Anpinnen'}">
|
aria-label="${note.pinned ? t('notes.unpinAction') : t('notes.pinAction')}">
|
||||||
<i data-lucide="${note.pinned ? 'pin-off' : 'pin'}" style="width:12px;height:12px;" aria-hidden="true"></i>
|
<i data-lucide="${note.pinned ? 'pin-off' : 'pin'}" style="width:12px;height:12px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
${note.title ? `<div class="note-card__title">${escHtml(note.title)}</div>` : ''}
|
${note.title ? `<div class="note-card__title">${escHtml(note.title)}</div>` : ''}
|
||||||
@@ -167,7 +168,7 @@ function renderNoteCard(note) {
|
|||||||
style="background-color:${escHtml(note.creator_color || '#8E8E93')}">${initials}</span>
|
style="background-color:${escHtml(note.creator_color || '#8E8E93')}">${initials}</span>
|
||||||
<span>${escHtml(note.creator_name || '')}</span>
|
<span>${escHtml(note.creator_name || '')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="note-card__delete" data-action="delete" data-id="${note.id}" aria-label="Notiz löschen">
|
<button class="note-card__delete" data-action="delete" data-id="${note.id}" aria-label="${t('notes.deleteLabel')}">
|
||||||
<i data-lucide="trash-2" style="width:12px;height:12px;" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:12px;height:12px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,58 +316,58 @@ function openNoteModal({ mode, note = null }) {
|
|||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="note-title">Titel (optional)</label>
|
<label class="form-label" for="note-title">${t('notes.titleLabel')}</label>
|
||||||
<input type="text" class="form-input" id="note-title"
|
<input type="text" class="form-input" id="note-title"
|
||||||
placeholder="Kein Titel" value="${escHtml(isEdit && note.title ? note.title : '')}">
|
placeholder="${t('notes.titlePlaceholder')}" value="${escHtml(isEdit && note.title ? note.title : '')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="note-content">Inhalt <span style="font-weight:400;color:var(--text-tertiary);font-size:.85em;">(Markdown-Formatierung möglich)</span></label>
|
<label class="form-label" for="note-content">${t('notes.contentLabel')} <span style="font-weight:400;color:var(--text-tertiary);font-size:.85em;">${t('notes.contentMarkdownHint')}</span></label>
|
||||||
<div class="note-format-toolbar">
|
<div class="note-format-toolbar">
|
||||||
<button type="button" class="note-format-btn" data-format="bold" title="Fett (Strg+B)">
|
<button type="button" class="note-format-btn" data-format="bold" title="${t('notes.formatBold')}">
|
||||||
<i data-lucide="bold" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="bold" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="note-format-btn" data-format="italic" title="Kursiv (Strg+I)">
|
<button type="button" class="note-format-btn" data-format="italic" title="${t('notes.formatItalic')}">
|
||||||
<i data-lucide="italic" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="italic" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="note-format-btn" data-format="underline" title="Unterstrichen (Strg+U)">
|
<button type="button" class="note-format-btn" data-format="underline" title="${t('notes.formatUnderline')}">
|
||||||
<i data-lucide="underline" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="underline" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="note-format-btn" data-format="strikethrough" title="Durchgestrichen">
|
<button type="button" class="note-format-btn" data-format="strikethrough" title="${t('notes.formatStrikethrough')}">
|
||||||
<i data-lucide="strikethrough" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="strikethrough" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<span class="note-format-btn--sep"></span>
|
<span class="note-format-btn--sep"></span>
|
||||||
<button type="button" class="note-format-btn" data-format="heading" title="Überschrift">
|
<button type="button" class="note-format-btn" data-format="heading" title="${t('notes.formatHeading')}">
|
||||||
<i data-lucide="heading" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="heading" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="note-format-btn" data-format="list" title="Aufzählung">
|
<button type="button" class="note-format-btn" data-format="list" title="${t('notes.formatList')}">
|
||||||
<i data-lucide="list" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="list" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="note-format-btn" data-format="ordered-list" title="Nummerierte Liste">
|
<button type="button" class="note-format-btn" data-format="ordered-list" title="${t('notes.formatOrderedList')}">
|
||||||
<i data-lucide="list-ordered" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="list-ordered" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="note-format-btn" data-format="checklist" title="Checkliste">
|
<button type="button" class="note-format-btn" data-format="checklist" title="${t('notes.formatChecklist')}">
|
||||||
<i data-lucide="list-checks" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="list-checks" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<span class="note-format-btn--sep"></span>
|
<span class="note-format-btn--sep"></span>
|
||||||
<button type="button" class="note-format-btn" data-format="link" title="Link">
|
<button type="button" class="note-format-btn" data-format="link" title="${t('notes.formatLink')}">
|
||||||
<i data-lucide="link" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="link" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="note-format-btn" data-format="code" title="Code">
|
<button type="button" class="note-format-btn" data-format="code" title="${t('notes.formatCode')}">
|
||||||
<i data-lucide="code" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="code" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="note-format-btn" data-format="quote" title="Zitat">
|
<button type="button" class="note-format-btn" data-format="quote" title="${t('notes.formatQuote')}">
|
||||||
<i data-lucide="quote" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="quote" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="note-format-btn" data-format="divider" title="Trennlinie">
|
<button type="button" class="note-format-btn" data-format="divider" title="${t('notes.formatDivider')}">
|
||||||
<i data-lucide="minus" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="minus" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea class="form-input" id="note-content" rows="6"
|
<textarea class="form-input" id="note-content" rows="6"
|
||||||
placeholder="Notiz eingeben…"
|
placeholder="${t('notes.contentPlaceholder')}"
|
||||||
style="resize:vertical;">${escHtml(isEdit ? note.content : '')}</textarea>
|
style="resize:vertical;">${escHtml(isEdit ? note.content : '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Farbe</label>
|
<label class="form-label">${t('notes.colorLabel')}</label>
|
||||||
<div class="note-color-picker">
|
<div class="note-color-picker">
|
||||||
${NOTE_COLORS.map((c) => `
|
${NOTE_COLORS.map((c) => `
|
||||||
<div class="note-color-swatch ${c === selColor ? 'note-color-swatch--active' : ''}"
|
<div class="note-color-swatch ${c === selColor ? 'note-color-swatch--active' : ''}"
|
||||||
@@ -379,17 +380,17 @@ function openNoteModal({ mode, note = null }) {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="allday-toggle">
|
<label class="allday-toggle">
|
||||||
<input type="checkbox" id="note-pinned" ${isEdit && note.pinned ? 'checked' : ''}>
|
<input type="checkbox" id="note-pinned" ${isEdit && note.pinned ? 'checked' : ''}>
|
||||||
<span class="allday-toggle__label">Anpinnen (erscheint auf Dashboard)</span>
|
<span class="allday-toggle__label">${t('notes.pinnedLabel')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||||
<button class="btn btn--secondary" id="note-modal-cancel">Abbrechen</button>
|
<button class="btn btn--secondary" id="note-modal-cancel">${t('common.cancel')}</button>
|
||||||
<button class="btn btn--primary" id="note-modal-save">${isEdit ? 'Speichern' : 'Erstellen'}</button>
|
<button class="btn btn--primary" id="note-modal-save">${isEdit ? t('common.save') : t('common.create')}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
openSharedModal({
|
openSharedModal({
|
||||||
title: isEdit ? 'Notiz bearbeiten' : 'Neue Notiz',
|
title: isEdit ? t('notes.editNote') : t('notes.newNote'),
|
||||||
content,
|
content,
|
||||||
size: 'md',
|
size: 'md',
|
||||||
onSave(panel) {
|
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 color = panel.querySelector('.note-color-swatch--active')?.dataset.color || NOTE_COLORS[0];
|
||||||
const pinned = panel.querySelector('#note-pinned').checked ? 1 : 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.disabled = true;
|
||||||
saveBtn.textContent = '…';
|
saveBtn.textContent = '…';
|
||||||
@@ -444,12 +445,12 @@ function openNoteModal({ mode, note = null }) {
|
|||||||
}
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
renderGrid();
|
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) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||||
btnError(saveBtn);
|
btnError(saveBtn);
|
||||||
saveBtn.disabled = false;
|
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);
|
state.notes.sort((a, b) => b.pinned - a.pinned);
|
||||||
renderGrid();
|
renderGrid();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteNote(id) {
|
async function deleteNote(id) {
|
||||||
if (!confirm('Notiz wirklich löschen?')) return;
|
if (!confirm(t('notes.deleteConfirm'))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/notes/${id}`);
|
await api.delete(`/notes/${id}`);
|
||||||
state.notes = state.notes.filter((n) => n.id !== id);
|
state.notes = state.notes.filter((n) => n.id !== id);
|
||||||
renderGrid();
|
renderGrid();
|
||||||
vibrate([30, 50, 30]);
|
vibrate([30, 50, 30]);
|
||||||
window.oikos?.showToast('Notiz gelöscht', 'success');
|
window.oikos?.showToast(t('notes.deletedToast'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+90
-75
@@ -5,6 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api, auth } from '/api.js';
|
import { api, auth } from '/api.js';
|
||||||
|
import { t, formatDate, formatTime } from '/i18n.js';
|
||||||
|
import '/components/oikos-locale-picker.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {HTMLElement} container
|
* @param {HTMLElement} container
|
||||||
@@ -32,40 +34,58 @@ export async function render(container, { user }) {
|
|||||||
if (aStatus.status === 'fulfilled') appleStatus = aStatus.value;
|
if (aStatus.status === 'fulfilled') appleStatus = aStatus.value;
|
||||||
} catch (_) { /* non-critical */ }
|
} 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 = `
|
container.innerHTML = `
|
||||||
<div class="page settings-page">
|
<div class="page settings-page">
|
||||||
<div class="page__header">
|
<div class="page__header">
|
||||||
<h1 class="page__title">Einstellungen</h1>
|
<h1 class="page__title">${t('settings.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${syncOk ? `<div class="settings-banner settings-banner--success">Kalender-Sync mit ${syncOk === 'google' ? 'Google' : 'Apple'} erfolgreich verbunden.</div>` : ''}
|
${syncOk ? `<div class="settings-banner settings-banner--success">${syncOk === 'google' ? t('settings.syncSuccessGoogle') : t('settings.syncSuccessApple')}</div>` : ''}
|
||||||
${syncErr ? `<div class="settings-banner settings-banner--error">Verbindung mit ${syncErr === 'google' ? 'Google' : 'Apple'} fehlgeschlagen. Bitte erneut versuchen.</div>` : ''}
|
${syncErr ? `<div class="settings-banner settings-banner--error">${syncErr === 'google' ? t('settings.syncErrorGoogle') : t('settings.syncErrorApple')}</div>` : ''}
|
||||||
|
|
||||||
<!-- Design -->
|
<!-- Design -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section__title">Design</h2>
|
<h2 class="settings-section__title">${t('settings.sectionDesign')}</h2>
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h3 class="settings-card__title">Darstellung</h3>
|
<h3 class="settings-card__title">${t('settings.cardAppearance')}</h3>
|
||||||
<div class="theme-toggle" id="theme-toggle">
|
<div class="theme-toggle" id="theme-toggle">
|
||||||
<button class="theme-toggle__btn ${currentTheme() === 'system' ? 'theme-toggle__btn--active' : ''}" data-theme-value="system" aria-label="System-Einstellung verwenden">
|
<button class="theme-toggle__btn ${currentTheme() === 'system' ? 'theme-toggle__btn--active' : ''}" data-theme-value="system" aria-label="${t('settings.themeSysLabel')}">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
System
|
${t('settings.themeSystem')}
|
||||||
</button>
|
</button>
|
||||||
<button class="theme-toggle__btn ${currentTheme() === 'light' ? 'theme-toggle__btn--active' : ''}" data-theme-value="light" aria-label="Helles Design">
|
<button class="theme-toggle__btn ${currentTheme() === 'light' ? 'theme-toggle__btn--active' : ''}" data-theme-value="light" aria-label="${t('settings.themeLightLabel')}">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
Hell
|
${t('settings.themeLight')}
|
||||||
</button>
|
</button>
|
||||||
<button class="theme-toggle__btn ${currentTheme() === 'dark' ? 'theme-toggle__btn--active' : ''}" data-theme-value="dark" aria-label="Dunkles Design">
|
<button class="theme-toggle__btn ${currentTheme() === 'dark' ? 'theme-toggle__btn--active' : ''}" data-theme-value="dark" aria-label="${t('settings.themeDarkLabel')}">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
Dunkel
|
${t('settings.themeDark')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Sprache -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="settings-section__title">${t('settings.languageTitle')}</h2>
|
||||||
|
<div class="settings-card">
|
||||||
|
<oikos-locale-picker></oikos-locale-picker>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Mein Konto -->
|
<!-- Mein Konto -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section__title">Mein Konto</h2>
|
<h2 class="settings-section__title">${t('settings.sectionAccount')}</h2>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<div class="settings-user-info">
|
<div class="settings-user-info">
|
||||||
@@ -80,29 +100,29 @@ export async function render(container, { user }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h3 class="settings-card__title">Passwort ändern</h3>
|
<h3 class="settings-card__title">${t('settings.changePassword')}</h3>
|
||||||
<form id="password-form" class="settings-form">
|
<form id="password-form" class="settings-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="current-password">Aktuelles Passwort</label>
|
<label class="form-label" for="current-password">${t('settings.currentPasswordLabel')}</label>
|
||||||
<input class="form-input" type="password" id="current-password" autocomplete="current-password" required />
|
<input class="form-input" type="password" id="current-password" autocomplete="current-password" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="new-password">Neues Passwort</label>
|
<label class="form-label" for="new-password">${t('settings.newPasswordLabel')}</label>
|
||||||
<input class="form-input" type="password" id="new-password" autocomplete="new-password" minlength="8" required />
|
<input class="form-input" type="password" id="new-password" autocomplete="new-password" minlength="8" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="confirm-password">Neues Passwort bestätigen</label>
|
<label class="form-label" for="confirm-password">${t('settings.confirmPasswordLabel')}</label>
|
||||||
<input class="form-input" type="password" id="confirm-password" autocomplete="new-password" minlength="8" required />
|
<input class="form-input" type="password" id="confirm-password" autocomplete="new-password" minlength="8" required />
|
||||||
</div>
|
</div>
|
||||||
<div id="password-error" class="form-error" hidden></div>
|
<div id="password-error" class="form-error" hidden></div>
|
||||||
<button type="submit" class="btn btn--primary">Passwort speichern</button>
|
<button type="submit" class="btn btn--primary">${t('settings.savePassword')}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Kalender-Synchronisation -->
|
<!-- Kalender-Synchronisation -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section__title">Kalender-Synchronisation</h2>
|
<h2 class="settings-section__title">${t('settings.sectionCalendarSync')}</h2>
|
||||||
|
|
||||||
<!-- Google Calendar -->
|
<!-- Google Calendar -->
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
@@ -116,21 +136,19 @@ export async function render(container, { user }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-sync-info">
|
<div class="settings-sync-info">
|
||||||
<div class="settings-sync-info__name">Google Calendar</div>
|
<div class="settings-sync-info__name">${t('settings.googleCalendar')}</div>
|
||||||
<div class="settings-sync-info__status ${googleStatus.connected ? 'settings-sync-info__status--connected' : ''}">
|
<div class="settings-sync-info__status ${googleStatus.connected ? 'settings-sync-info__status--connected' : ''}">
|
||||||
${googleStatus.connected
|
${googleStatusText}
|
||||||
? `Verbunden${googleStatus.lastSync ? ` · Zuletzt: ${formatDate(googleStatus.lastSync)}` : ''}`
|
|
||||||
: googleStatus.configured ? 'Nicht verbunden' : 'Nicht konfiguriert (fehlende .env-Variablen)'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${googleStatus.configured ? `
|
${googleStatus.configured ? `
|
||||||
<div class="settings-sync-actions">
|
<div class="settings-sync-actions">
|
||||||
${googleStatus.connected ? `
|
${googleStatus.connected ? `
|
||||||
<button class="btn btn--secondary" id="google-sync-btn">Jetzt synchronisieren</button>
|
<button class="btn btn--secondary" id="google-sync-btn">${t('settings.syncNow')}</button>
|
||||||
${user?.role === 'admin' ? `<button class="btn btn--danger-outline" id="google-disconnect-btn">Verbindung trennen</button>` : ''}
|
${user?.role === 'admin' ? `<button class="btn btn--danger-outline" id="google-disconnect-btn">${t('settings.disconnect')}</button>` : ''}
|
||||||
` : `
|
` : `
|
||||||
${user?.role === 'admin' ? `<a href="/api/v1/calendar/google/auth" class="btn btn--primary">Mit Google verbinden</a>` : '<span class="form-hint">Nur Admin kann Google Calendar verbinden.</span>'}
|
${user?.role === 'admin' ? `<a href="/api/v1/calendar/google/auth" class="btn btn--primary">${t('settings.connectGoogle')}</a>` : `<span class="form-hint">${t('settings.googleOnlyAdmin')}</span>`}
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
@@ -145,84 +163,80 @@ export async function render(container, { user }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-sync-info">
|
<div class="settings-sync-info">
|
||||||
<div class="settings-sync-info__name">Apple Calendar (iCloud)</div>
|
<div class="settings-sync-info__name">${t('settings.appleCalendar')}</div>
|
||||||
<div class="settings-sync-info__status ${appleStatus.configured ? 'settings-sync-info__status--connected' : ''}">
|
<div class="settings-sync-info__status ${appleStatus.configured ? 'settings-sync-info__status--connected' : ''}">
|
||||||
${appleStatus.connected
|
${appleStatusText}
|
||||||
? `Verbunden${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}`
|
|
||||||
: appleStatus.configured
|
|
||||||
? `Konfiguriert (via .env)${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}`
|
|
||||||
: 'Nicht verbunden'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${appleStatus.configured ? `
|
${appleStatus.configured ? `
|
||||||
<div class="settings-sync-actions">
|
<div class="settings-sync-actions">
|
||||||
<button class="btn btn--secondary" id="apple-sync-btn">Jetzt synchronisieren</button>
|
<button class="btn btn--secondary" id="apple-sync-btn">${t('settings.syncNow')}</button>
|
||||||
${appleStatus.connected && user?.role === 'admin' ? `<button class="btn btn--danger-outline" id="apple-disconnect-btn">Verbindung trennen</button>` : ''}
|
${appleStatus.connected && user?.role === 'admin' ? `<button class="btn btn--danger-outline" id="apple-disconnect-btn">${t('settings.disconnect')}</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
` : user?.role === 'admin' ? `
|
` : user?.role === 'admin' ? `
|
||||||
<form id="apple-connect-form" class="settings-form settings-form--compact">
|
<form id="apple-connect-form" class="settings-form settings-form--compact">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="apple-caldav-url">CalDAV-Server-URL</label>
|
<label class="form-label" for="apple-caldav-url">${t('settings.caldavUrlLabel')}</label>
|
||||||
<input class="form-input" type="url" id="apple-caldav-url" placeholder="https://caldav.icloud.com" required />
|
<input class="form-input" type="url" id="apple-caldav-url" placeholder="${t('settings.caldavUrlPlaceholder')}" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="apple-username">Apple-ID (E-Mail)</label>
|
<label class="form-label" for="apple-username">${t('settings.appleIdLabel')}</label>
|
||||||
<input class="form-input" type="email" id="apple-username" autocomplete="username" required />
|
<input class="form-input" type="email" id="apple-username" autocomplete="username" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="apple-password">App-spezifisches Passwort</label>
|
<label class="form-label" for="apple-password">${t('settings.applePasswordLabel')}</label>
|
||||||
<input class="form-input" type="password" id="apple-password" autocomplete="current-password" required />
|
<input class="form-input" type="password" id="apple-password" autocomplete="current-password" required />
|
||||||
<span class="form-hint">Passwort unter <strong>appleid.apple.com → Sicherheit</strong> erstellen.</span>
|
<span class="form-hint">${t('settings.applePasswordHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="apple-connect-error" class="form-error" hidden></div>
|
<div id="apple-connect-error" class="form-error" hidden></div>
|
||||||
<button type="submit" class="btn btn--primary" id="apple-connect-btn">Verbinden & testen</button>
|
<button type="submit" class="btn btn--primary" id="apple-connect-btn">${t('settings.appleConnectBtn')}</button>
|
||||||
</form>
|
</form>
|
||||||
` : '<span class="form-hint">Nur Admin kann Apple Calendar verbinden.</span>'}
|
` : `<span class="form-hint">${t('settings.appleOnlyAdmin')}</span>`}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Familienmitglieder (nur Admin) -->
|
<!-- Familienmitglieder (nur Admin) -->
|
||||||
${user?.role === 'admin' ? `
|
${user?.role === 'admin' ? `
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section__title">Familienmitglieder</h2>
|
<h2 class="settings-section__title">${t('settings.sectionFamily')}</h2>
|
||||||
<div class="settings-card" id="members-card">
|
<div class="settings-card" id="members-card">
|
||||||
<ul class="settings-members" id="members-list">
|
<ul class="settings-members" id="members-list">
|
||||||
${users.map(memberHtml).join('')}
|
${users.map(memberHtml).join('')}
|
||||||
</ul>
|
</ul>
|
||||||
<button class="btn btn--primary settings-add-btn" id="add-member-btn">+ Mitglied hinzufügen</button>
|
<button class="btn btn--primary settings-add-btn" id="add-member-btn">${t('settings.addMember')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card settings-card--hidden" id="add-member-form-card">
|
<div class="settings-card settings-card--hidden" id="add-member-form-card">
|
||||||
<h3 class="settings-card__title">Neues Familienmitglied</h3>
|
<h3 class="settings-card__title">${t('settings.newMemberTitle')}</h3>
|
||||||
<form id="add-member-form" class="settings-form">
|
<form id="add-member-form" class="settings-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="new-username">Benutzername</label>
|
<label class="form-label" for="new-username">${t('settings.usernameLabel')}</label>
|
||||||
<input class="form-input" type="text" id="new-username" required autocomplete="off" />
|
<input class="form-input" type="text" id="new-username" required autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="new-display-name">Anzeigename</label>
|
<label class="form-label" for="new-display-name">${t('settings.displayNameLabel')}</label>
|
||||||
<input class="form-input" type="text" id="new-display-name" required />
|
<input class="form-input" type="text" id="new-display-name" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="new-member-password">Passwort</label>
|
<label class="form-label" for="new-member-password">${t('settings.memberPasswordLabel')}</label>
|
||||||
<input class="form-input" type="password" id="new-member-password" minlength="8" required autocomplete="new-password" />
|
<input class="form-input" type="password" id="new-member-password" minlength="8" required autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="new-avatar-color">Farbe</label>
|
<label class="form-label" for="new-avatar-color">${t('settings.colorLabel')}</label>
|
||||||
<input class="form-input form-input--color" type="color" id="new-avatar-color" value="#007AFF" />
|
<input class="form-input form-input--color" type="color" id="new-avatar-color" value="#007AFF" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="new-role">Rolle</label>
|
<label class="form-label" for="new-role">${t('settings.roleLabel')}</label>
|
||||||
<select class="form-input" id="new-role">
|
<select class="form-input" id="new-role">
|
||||||
<option value="member">Mitglied</option>
|
<option value="member">${t('settings.roleMember')}</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">${t('settings.roleAdmin')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="member-error" class="form-error" hidden></div>
|
<div id="member-error" class="form-error" hidden></div>
|
||||||
<div class="settings-form-actions">
|
<div class="settings-form-actions">
|
||||||
<button type="submit" class="btn btn--primary">Erstellen</button>
|
<button type="submit" class="btn btn--primary">${t('settings.createMember')}</button>
|
||||||
<button type="button" class="btn btn--secondary" id="cancel-add-member">Abbrechen</button>
|
<button type="button" class="btn btn--secondary" id="cancel-add-member">${t('settings.cancelAddMember')}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,7 +245,7 @@ export async function render(container, { user }) {
|
|||||||
|
|
||||||
<!-- Abmelden -->
|
<!-- Abmelden -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<button class="btn btn--danger-outline settings-logout-btn" id="logout-btn">Abmelden</button>
|
<button class="btn btn--danger-outline settings-logout-btn" id="logout-btn">${t('settings.logout')}</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -270,7 +284,7 @@ function bindEvents(container, user) {
|
|||||||
errorEl.hidden = true;
|
errorEl.hidden = true;
|
||||||
|
|
||||||
if (newPw !== confirmPw) {
|
if (newPw !== confirmPw) {
|
||||||
showError(errorEl, 'Passwörter stimmen nicht überein.');
|
showError(errorEl, t('settings.passwordMismatch'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +293,7 @@ function bindEvents(container, user) {
|
|||||||
try {
|
try {
|
||||||
await api.patch('/auth/me/password', { current_password: currentPw, new_password: newPw });
|
await api.patch('/auth/me/password', { current_password: currentPw, new_password: newPw });
|
||||||
passwordForm.reset();
|
passwordForm.reset();
|
||||||
window.oikos?.showToast('Passwort erfolgreich geändert.', 'success');
|
window.oikos?.showToast(t('settings.passwordSavedToast'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(errorEl, err.message);
|
showError(errorEl, err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -293,15 +307,15 @@ function bindEvents(container, user) {
|
|||||||
if (googleSyncBtn) {
|
if (googleSyncBtn) {
|
||||||
googleSyncBtn.addEventListener('click', async () => {
|
googleSyncBtn.addEventListener('click', async () => {
|
||||||
googleSyncBtn.disabled = true;
|
googleSyncBtn.disabled = true;
|
||||||
googleSyncBtn.textContent = 'Synchronisiere…';
|
googleSyncBtn.textContent = t('settings.synchronizing');
|
||||||
try {
|
try {
|
||||||
await api.post('/calendar/google/sync', {});
|
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) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.message, 'danger');
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
} finally {
|
} finally {
|
||||||
googleSyncBtn.disabled = false;
|
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');
|
const googleDisconnectBtn = container.querySelector('#google-disconnect-btn');
|
||||||
if (googleDisconnectBtn) {
|
if (googleDisconnectBtn) {
|
||||||
googleDisconnectBtn.addEventListener('click', async () => {
|
googleDisconnectBtn.addEventListener('click', async () => {
|
||||||
if (!confirm('Google Calendar-Verbindung trennen?')) return;
|
if (!confirm(t('settings.googleDisconnectConfirm'))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete('/calendar/google/disconnect');
|
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');
|
window.oikos?.navigate('/settings');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.message, 'danger');
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
@@ -326,15 +340,15 @@ function bindEvents(container, user) {
|
|||||||
if (appleSyncBtn) {
|
if (appleSyncBtn) {
|
||||||
appleSyncBtn.addEventListener('click', async () => {
|
appleSyncBtn.addEventListener('click', async () => {
|
||||||
appleSyncBtn.disabled = true;
|
appleSyncBtn.disabled = true;
|
||||||
appleSyncBtn.textContent = 'Synchronisiere…';
|
appleSyncBtn.textContent = t('settings.synchronizing');
|
||||||
try {
|
try {
|
||||||
await api.post('/calendar/apple/sync', {});
|
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) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.message, 'danger');
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
} finally {
|
} finally {
|
||||||
appleSyncBtn.disabled = false;
|
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');
|
const appleDisconnectBtn = container.querySelector('#apple-disconnect-btn');
|
||||||
if (appleDisconnectBtn) {
|
if (appleDisconnectBtn) {
|
||||||
appleDisconnectBtn.addEventListener('click', async () => {
|
appleDisconnectBtn.addEventListener('click', async () => {
|
||||||
if (!confirm('Apple Calendar-Verbindung trennen?')) return;
|
if (!confirm(t('settings.appleDisconnectConfirm'))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete('/calendar/apple/disconnect');
|
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');
|
window.oikos?.navigate('/settings');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.message, 'danger');
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
@@ -368,16 +382,16 @@ function bindEvents(container, user) {
|
|||||||
const btn = container.querySelector('#apple-connect-btn');
|
const btn = container.querySelector('#apple-connect-btn');
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Verbinde…';
|
btn.textContent = t('settings.appleConnecting');
|
||||||
try {
|
try {
|
||||||
await api.post('/calendar/apple/connect', { url, username, password });
|
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');
|
window.oikos?.navigate('/settings');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(errorEl, err.message);
|
showError(errorEl, err.message);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Verbinden & testen';
|
btn.textContent = t('settings.appleConnectBtn');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -425,7 +439,7 @@ function bindEvents(container, user) {
|
|||||||
addMemberForm.reset();
|
addMemberForm.reset();
|
||||||
container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
|
container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
|
||||||
container.querySelector('#add-member-btn').hidden = false;
|
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);
|
bindDeleteButtons(container, user);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(errorEl, err.message);
|
showError(errorEl, err.message);
|
||||||
@@ -458,11 +472,11 @@ function bindDeleteButtons(container, user) {
|
|||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const id = parseInt(btn.dataset.deleteUser, 10);
|
const id = parseInt(btn.dataset.deleteUser, 10);
|
||||||
const name = btn.dataset.name;
|
const name = btn.dataset.name;
|
||||||
if (!confirm(`${name} wirklich löschen?`)) return;
|
if (!confirm(t('settings.deleteMemberConfirm', { name }))) return;
|
||||||
try {
|
try {
|
||||||
await auth.deleteUser(id);
|
await auth.deleteUser(id);
|
||||||
btn.closest('.settings-member').remove();
|
btn.closest('.settings-member').remove();
|
||||||
window.oikos?.showToast(`${name} gelöscht.`, 'default');
|
window.oikos?.showToast(t('settings.memberDeletedToast', { name }), 'default');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos?.showToast(err.message, 'danger');
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
}
|
}
|
||||||
@@ -480,9 +494,9 @@ function memberHtml(u) {
|
|||||||
<div class="settings-avatar settings-avatar--sm" style="background:${u.avatar_color}">${initials(u.display_name)}</div>
|
<div class="settings-avatar settings-avatar--sm" style="background:${u.avatar_color}">${initials(u.display_name)}</div>
|
||||||
<div class="settings-member__info">
|
<div class="settings-member__info">
|
||||||
<span class="settings-member__name">${u.display_name}</span>
|
<span class="settings-member__name">${u.display_name}</span>
|
||||||
<span class="settings-member__meta">@${u.username} · ${u.role === 'admin' ? 'Admin' : 'Mitglied'}</span>
|
<span class="settings-member__meta">@${u.username} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${u.display_name}" aria-label="${u.display_name} löschen" title="Löschen">
|
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${u.display_name}" aria-label="${u.display_name} ${t('settings.deleteMemberLabel')}" title="${t('settings.deleteMemberLabel')}">
|
||||||
<i data-lucide="trash-2" aria-hidden="true"></i>
|
<i data-lucide="trash-2" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -494,9 +508,10 @@ function initials(name) {
|
|||||||
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
|
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso) {
|
function formatDateTime(iso) {
|
||||||
if (!iso) return '';
|
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() {
|
function currentTheme() {
|
||||||
|
|||||||
+42
-28
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { stagger, vibrate } from '/utils/ux.js';
|
import { stagger, vibrate } from '/utils/ux.js';
|
||||||
|
import { t } from '/i18n.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -21,6 +22,18 @@ const ITEM_CATEGORIES = [
|
|||||||
'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges',
|
'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 = {
|
const CATEGORY_ICONS = {
|
||||||
'Obst & Gemüse': 'apple',
|
'Obst & Gemüse': 'apple',
|
||||||
'Backwaren': 'wheat',
|
'Backwaren': 'wheat',
|
||||||
@@ -95,9 +108,9 @@ function renderListContent(container) {
|
|||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="no-lists">
|
<div class="no-lists">
|
||||||
<i data-lucide="shopping-cart" style="width:56px;height:56px;color:var(--color-text-disabled)" aria-hidden="true"></i>
|
<i data-lucide="shopping-cart" style="width:56px;height:56px;color:var(--color-text-disabled)" aria-hidden="true"></i>
|
||||||
<div style="font-size:var(--text-lg);font-weight:var(--font-weight-semibold)">Keine Listen</div>
|
<div style="font-size:var(--text-lg);font-weight:var(--font-weight-semibold)">${t('shopping.noLists')}</div>
|
||||||
<div style="font-size:var(--text-sm);color:var(--color-text-secondary)">
|
<div style="font-size:var(--text-sm);color:var(--color-text-secondary)">
|
||||||
Erstelle eine Liste mit dem + Button.
|
${t('shopping.noListsDescription')}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
@@ -110,7 +123,7 @@ function renderListContent(container) {
|
|||||||
<!-- Liste-Header -->
|
<!-- Liste-Header -->
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<span class="list-header__name" data-action="rename-list" data-id="${state.activeList.id}"
|
<span class="list-header__name" data-action="rename-list" data-id="${state.activeList.id}"
|
||||||
role="button" tabindex="0" aria-label="Liste umbenennen">
|
role="button" tabindex="0" aria-label="${t('shopping.renameListLabel')}">
|
||||||
${state.activeList.name}
|
${state.activeList.name}
|
||||||
<i data-lucide="pencil" class="list-header__edit-icon" aria-hidden="true"></i>
|
<i data-lucide="pencil" class="list-header__edit-icon" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
@@ -119,10 +132,10 @@ function renderListContent(container) {
|
|||||||
<button class="btn btn--ghost" data-action="clear-checked"
|
<button class="btn btn--ghost" data-action="clear-checked"
|
||||||
style="font-size:var(--text-sm);color:var(--color-text-secondary)">
|
style="font-size:var(--text-sm);color:var(--color-text-secondary)">
|
||||||
<i data-lucide="trash-2" style="width:15px;height:15px" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:15px;height:15px" aria-hidden="true"></i>
|
||||||
Abgehakt löschen (${checkedCount})
|
${t('shopping.clearChecked', { count: checkedCount })}
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
<button class="btn btn--ghost btn--icon" data-action="delete-list"
|
<button class="btn btn--ghost btn--icon" data-action="delete-list"
|
||||||
data-id="${state.activeList.id}" aria-label="Liste löschen"
|
data-id="${state.activeList.id}" aria-label="${t('shopping.deleteListLabel')}"
|
||||||
style="color:var(--color-text-secondary)">
|
style="color:var(--color-text-secondary)">
|
||||||
<i data-lucide="trash" style="width:18px;height:18px" aria-hidden="true"></i>
|
<i data-lucide="trash" style="width:18px;height:18px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -134,17 +147,17 @@ function renderListContent(container) {
|
|||||||
<form class="quick-add__form" id="quick-add-form" novalidate autocomplete="off">
|
<form class="quick-add__form" id="quick-add-form" novalidate autocomplete="off">
|
||||||
<div class="quick-add__input-wrap">
|
<div class="quick-add__input-wrap">
|
||||||
<input class="quick-add__input" type="text" id="item-name-input"
|
<input class="quick-add__input" type="text" id="item-name-input"
|
||||||
placeholder="Artikel hinzufügen…" aria-label="Artikelname" autocomplete="off">
|
placeholder="${t('shopping.itemNamePlaceholder')}" aria-label="${t('shopping.itemNameLabel')}" autocomplete="off">
|
||||||
<input class="quick-add__qty" type="text" id="item-qty-input"
|
<input class="quick-add__qty" type="text" id="item-qty-input"
|
||||||
placeholder="Menge" aria-label="Menge" autocomplete="off">
|
placeholder="${t('shopping.itemQtyPlaceholder')}" aria-label="${t('shopping.itemQtyLabel')}" autocomplete="off">
|
||||||
<div class="autocomplete-dropdown" id="autocomplete-dropdown" hidden></div>
|
<div class="autocomplete-dropdown" id="autocomplete-dropdown" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<select class="quick-add__cat" id="item-cat-select" aria-label="Kategorie">
|
<select class="quick-add__cat" id="item-cat-select" aria-label="${t('shopping.categoryLabel')}">
|
||||||
${ITEM_CATEGORIES.map((c) =>
|
${ITEM_CATEGORIES.map((c) =>
|
||||||
`<option value="${c}">${c}</option>`
|
`<option value="${c}">${c}</option>`
|
||||||
).join('')}
|
).join('')}
|
||||||
</select>
|
</select>
|
||||||
<button class="quick-add__btn" type="submit" aria-label="Artikel hinzufügen">
|
<button class="quick-add__btn" type="submit" aria-label="${t('shopping.addItemLabel')}">
|
||||||
<i data-lucide="plus" style="width:20px;height:20px" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:20px;height:20px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -170,17 +183,18 @@ function renderItems() {
|
|||||||
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
|
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
|
||||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
|
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="empty-state__title">Die Liste ist leer</div>
|
<div class="empty-state__title">${t('shopping.emptyList')}</div>
|
||||||
<div class="empty-state__description">Artikel über das Eingabefeld oben hinzufügen.</div>
|
<div class="empty-state__description">${t('shopping.emptyListDescription')}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const catLabels = CATEGORY_LABELS();
|
||||||
const groups = groupItemsByCategory(state.items);
|
const groups = groupItemsByCategory(state.items);
|
||||||
return groups.map(([cat, items]) => `
|
return groups.map(([cat, items]) => `
|
||||||
<div class="item-category">
|
<div class="item-category">
|
||||||
<div class="item-category__header">
|
<div class="item-category__header">
|
||||||
<i data-lucide="${CATEGORY_ICONS[cat] ?? 'tag'}" class="item-category__icon" aria-hidden="true"></i>
|
<i data-lucide="${CATEGORY_ICONS[cat] ?? 'tag'}" class="item-category__icon" aria-hidden="true"></i>
|
||||||
${cat}
|
${catLabels[cat] || cat}
|
||||||
</div>
|
</div>
|
||||||
${items.map(renderItem).join('')}
|
${items.map(renderItem).join('')}
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
@@ -192,17 +206,17 @@ function renderItem(item) {
|
|||||||
<div class="swipe-row" data-swipe-id="${item.id}" data-swipe-checked="${item.is_checked}">
|
<div class="swipe-row" data-swipe-id="${item.id}" data-swipe-checked="${item.is_checked}">
|
||||||
<div class="swipe-reveal swipe-reveal--done" aria-hidden="true">
|
<div class="swipe-reveal swipe-reveal--done" aria-hidden="true">
|
||||||
<i data-lucide="${isDone ? 'rotate-ccw' : 'check'}" style="width:22px;height:22px" aria-hidden="true"></i>
|
<i data-lucide="${isDone ? 'rotate-ccw' : 'check'}" style="width:22px;height:22px" aria-hidden="true"></i>
|
||||||
<span>${isDone ? 'Zurück' : 'Abhaken'}</span>
|
<span>${isDone ? t('shopping.swipeBack') : t('shopping.swipeCheck')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="swipe-reveal swipe-reveal--delete" aria-hidden="true">
|
<div class="swipe-reveal swipe-reveal--delete" aria-hidden="true">
|
||||||
<i data-lucide="trash-2" style="width:22px;height:22px" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:22px;height:22px" aria-hidden="true"></i>
|
||||||
<span>Löschen</span>
|
<span>${t('shopping.swipeDelete')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shopping-item ${isDone ? 'shopping-item--checked' : ''}"
|
<div class="shopping-item ${isDone ? 'shopping-item--checked' : ''}"
|
||||||
data-item-id="${item.id}">
|
data-item-id="${item.id}">
|
||||||
<button class="item-check ${isDone ? 'item-check--checked' : ''}"
|
<button class="item-check ${isDone ? 'item-check--checked' : ''}"
|
||||||
data-action="toggle-item" data-id="${item.id}" data-checked="${item.is_checked}"
|
data-action="toggle-item" data-id="${item.id}" data-checked="${item.is_checked}"
|
||||||
aria-label="${escHtml(item.name)} ${isDone ? 'als nicht erledigt markieren' : 'abhaken'}">
|
aria-label="${isDone ? t('shopping.markUndoneLabel', { name: escHtml(item.name) }) : t('shopping.markDoneLabel', { name: escHtml(item.name) })}">
|
||||||
<i data-lucide="check" class="item-check__icon" aria-hidden="true"></i>
|
<i data-lucide="check" class="item-check__icon" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="item-body">
|
<div class="item-body">
|
||||||
@@ -210,7 +224,7 @@ function renderItem(item) {
|
|||||||
${item.quantity ? `<div class="item-quantity">${escHtml(item.quantity)}</div>` : ''}
|
${item.quantity ? `<div class="item-quantity">${escHtml(item.quantity)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button class="item-delete" data-action="delete-item" data-id="${item.id}"
|
<button class="item-delete" data-action="delete-item" data-id="${item.id}"
|
||||||
aria-label="${escHtml(item.name)} löschen">
|
aria-label="${t('shopping.deleteItemLabel', { name: escHtml(item.name) })}">
|
||||||
<i data-lucide="x" style="width:16px;height:16px" aria-hidden="true"></i>
|
<i data-lucide="x" style="width:16px;height:16px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,7 +488,7 @@ function updateItemsList(container) {
|
|||||||
<button class="btn btn--ghost" data-action="clear-checked"
|
<button class="btn btn--ghost" data-action="clear-checked"
|
||||||
style="font-size:var(--text-sm);color:var(--color-text-secondary)">
|
style="font-size:var(--text-sm);color:var(--color-text-secondary)">
|
||||||
<i data-lucide="trash-2" style="width:15px;height:15px" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:15px;height:15px" aria-hidden="true"></i>
|
||||||
Abgehakt löschen (${checkedCount})
|
${t('shopping.clearChecked', { count: checkedCount })}
|
||||||
</button>`);
|
</button>`);
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
} else if (clearBtn) {
|
} else if (clearBtn) {
|
||||||
@@ -483,7 +497,7 @@ function updateItemsList(container) {
|
|||||||
} else {
|
} else {
|
||||||
clearBtn.innerHTML = `
|
clearBtn.innerHTML = `
|
||||||
<i data-lucide="trash-2" style="width:15px;height:15px" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:15px;height:15px" aria-hidden="true"></i>
|
||||||
Abgehakt löschen (${checkedCount})`;
|
${t('shopping.clearChecked', { count: checkedCount })}`;
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,7 +523,7 @@ async function loadLists() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Shopping] loadLists Fehler:', err);
|
console.error('[Shopping] loadLists Fehler:', err);
|
||||||
state.lists = [];
|
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);
|
console.error('[Shopping] loadItems Fehler:', err);
|
||||||
state.items = [];
|
state.items = [];
|
||||||
state.activeList = state.lists.find((l) => l.id === listId) ?? null;
|
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);
|
renderListContent(container);
|
||||||
wireListContentEvents(container);
|
wireListContentEvents(container);
|
||||||
@@ -548,7 +562,7 @@ function wireTabBar(container) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (target.dataset.action === 'new-list') {
|
if (target.dataset.action === 'new-list') {
|
||||||
const name = prompt('Name der neuen Liste:');
|
const name = prompt(t('shopping.newListPrompt'));
|
||||||
if (!name?.trim()) return;
|
if (!name?.trim()) return;
|
||||||
try {
|
try {
|
||||||
const data = await api.post('/shopping', { name: name.trim() });
|
const data = await api.post('/shopping', { name: name.trim() });
|
||||||
@@ -621,7 +635,7 @@ function wireListContentEvents(container) {
|
|||||||
updateItemsList(container);
|
updateItemsList(container);
|
||||||
updateListCounter(state.activeListId, -count, -count);
|
updateListCounter(state.activeListId, -count, -count);
|
||||||
renderTabs(container);
|
renderTabs(container);
|
||||||
window.oikos.showToast(`${count} Artikel entfernt.`);
|
window.oikos.showToast(t('shopping.itemsRemovedToast', { count }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos.showToast(err.message, 'danger');
|
window.oikos.showToast(err.message, 'danger');
|
||||||
}
|
}
|
||||||
@@ -629,7 +643,7 @@ function wireListContentEvents(container) {
|
|||||||
|
|
||||||
// ---- Liste umbenennen ----
|
// ---- Liste umbenennen ----
|
||||||
if (action === 'rename-list') {
|
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;
|
if (!newName?.trim() || newName.trim() === state.activeList?.name) return;
|
||||||
try {
|
try {
|
||||||
const data = await api.put(`/shopping/${state.activeListId}`, { name: newName.trim() });
|
const data = await api.put(`/shopping/${state.activeListId}`, { name: newName.trim() });
|
||||||
@@ -646,7 +660,7 @@ function wireListContentEvents(container) {
|
|||||||
|
|
||||||
// ---- Liste löschen ----
|
// ---- Liste löschen ----
|
||||||
if (action === 'delete-list') {
|
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 {
|
try {
|
||||||
await api.delete(`/shopping/${state.activeListId}`);
|
await api.delete(`/shopping/${state.activeListId}`);
|
||||||
state.lists = state.lists.filter((l) => l.id !== state.activeListId);
|
state.lists = state.lists.filter((l) => l.id !== state.activeListId);
|
||||||
@@ -659,7 +673,7 @@ function wireListContentEvents(container) {
|
|||||||
renderTabs(container);
|
renderTabs(container);
|
||||||
renderListContent(container);
|
renderListContent(container);
|
||||||
}
|
}
|
||||||
window.oikos.showToast('Liste gelöscht.');
|
window.oikos.showToast(t('shopping.deletedListToast'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos.showToast(err.message, 'danger');
|
window.oikos.showToast(err.message, 'danger');
|
||||||
}
|
}
|
||||||
@@ -701,15 +715,15 @@ export async function render(container, { user }) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Shopping] Ladefehler:', err.message);
|
console.error('[Shopping] Ladefehler:', err.message);
|
||||||
window.oikos.showToast('Einkaufslisten konnten nicht geladen werden.', 'danger');
|
window.oikos.showToast(t('shopping.listsLoadError'), 'danger');
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="shopping-page">
|
<div class="shopping-page">
|
||||||
<h1 class="sr-only">Einkaufslisten</h1>
|
<h1 class="sr-only">${t('shopping.title')}</h1>
|
||||||
<div class="list-tabs-bar" id="list-tabs-bar"></div>
|
<div class="list-tabs-bar" id="list-tabs-bar"></div>
|
||||||
<div id="list-content" style="flex:1;display:flex;flex-direction:column;overflow:hidden"></div>
|
<div id="list-content" style="flex:1;display:flex;flex-direction:column;overflow:hidden"></div>
|
||||||
<button class="page-fab" id="fab-new-item" aria-label="Artikel hinzufügen">
|
<button class="page-fab" id="fab-new-item" aria-label="${t('shopping.addItemLabel')}">
|
||||||
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+106
-83
@@ -8,22 +8,23 @@ import { api } from '/api.js';
|
|||||||
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
||||||
import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError } from '/components/modal.js';
|
||||||
import { stagger, vibrate } from '/utils/ux.js';
|
import { stagger, vibrate } from '/utils/ux.js';
|
||||||
|
import { t, formatDate } from '/i18n.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
const PRIORITIES = [
|
const PRIORITIES = () => [
|
||||||
{ value: 'urgent', label: 'Dringend', color: 'var(--color-priority-urgent)' },
|
{ value: 'urgent', label: t('tasks.priorityUrgent'), color: 'var(--color-priority-urgent)' },
|
||||||
{ value: 'high', label: 'Hoch', color: 'var(--color-priority-high)' },
|
{ value: 'high', label: t('tasks.priorityHigh'), color: 'var(--color-priority-high)' },
|
||||||
{ value: 'medium', label: 'Mittel', color: 'var(--color-priority-medium)' },
|
{ value: 'medium', label: t('tasks.priorityMedium'), color: 'var(--color-priority-medium)' },
|
||||||
{ value: 'low', label: 'Niedrig', color: 'var(--color-priority-low)' },
|
{ value: 'low', label: t('tasks.priorityLow'), color: 'var(--color-priority-low)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUSES = [
|
const STATUSES = () => [
|
||||||
{ value: 'open', label: 'Offen' },
|
{ value: 'open', label: t('tasks.statusOpen') },
|
||||||
{ value: 'in_progress', label: 'In Bearbeitung'},
|
{ value: 'in_progress', label: t('tasks.statusInProgress') },
|
||||||
{ value: 'done', label: 'Erledigt' },
|
{ value: 'done', label: t('tasks.statusDone') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
@@ -31,8 +32,19 @@ const CATEGORIES = [
|
|||||||
'Gesundheit', 'Finanzen', 'Freizeit', 'Sonstiges',
|
'Gesundheit', 'Finanzen', 'Freizeit', 'Sonstiges',
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRIORITY_LABELS = Object.fromEntries(PRIORITIES.map((p) => [p.value, p.label]));
|
const CATEGORY_LABELS = () => ({
|
||||||
const STATUS_LABELS = Object.fromEntries(STATUSES.map((s) => [s.value, s.label]));
|
'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
|
// Hilfsfunktionen
|
||||||
@@ -49,10 +61,10 @@ function formatDueDate(dateStr) {
|
|||||||
now.setHours(0, 0, 0, 0);
|
now.setHours(0, 0, 0, 0);
|
||||||
const diffDays = Math.round((due - now) / 86400000);
|
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: t('tasks.overdueDay', { count: Math.abs(diffDays) }), cls: 'due-date--overdue' };
|
||||||
if (diffDays === 0) return { label: 'Heute fällig', cls: 'due-date--today' };
|
if (diffDays === 0) return { label: t('tasks.dueToday'), cls: 'due-date--today' };
|
||||||
if (diffDays === 1) return { label: 'Morgen fällig', cls: '' };
|
if (diffDays === 1) return { label: t('tasks.dueTomorrow'), cls: '' };
|
||||||
return { label: due.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }), cls: '' };
|
return { label: formatDate(due), cls: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupBy(tasks, mode) {
|
function groupBy(tasks, mode) {
|
||||||
@@ -67,21 +79,28 @@ function groupBy(tasks, mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mode === 'due'
|
// 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;
|
let key;
|
||||||
if (!t.due_date) key = 'Kein Datum';
|
if (!task.due_date) key = groupNoDate;
|
||||||
else {
|
else {
|
||||||
const diff = Math.round((new Date(t.due_date) - new Date().setHours(0,0,0,0)) / 86400000);
|
const diff = Math.round((new Date(task.due_date) - new Date().setHours(0,0,0,0)) / 86400000);
|
||||||
if (diff < 0) key = 'Überfällig';
|
if (diff < 0) key = groupOverdue;
|
||||||
else if (diff === 0) key = 'Heute';
|
else if (diff === 0) key = groupToday;
|
||||||
else if (diff <= 3) key = 'Diese Woche';
|
else if (diff <= 3) key = groupThisWeek;
|
||||||
else if (diff <= 7) key = 'Nächste Woche';
|
else if (diff <= 7) key = groupNextWeek;
|
||||||
else key = 'Später';
|
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]]);
|
return order.filter((k) => groups[k]).map((k) => [k, groups[k]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +111,7 @@ function groupBy(tasks, mode) {
|
|||||||
function renderPriorityBadge(priority) {
|
function renderPriorityBadge(priority) {
|
||||||
return `<span class="priority-badge priority-badge--${priority}">
|
return `<span class="priority-badge priority-badge--${priority}">
|
||||||
<span class="priority-dot priority-dot--${priority}"></span>
|
<span class="priority-dot priority-dot--${priority}"></span>
|
||||||
${PRIORITY_LABELS[priority] ?? priority}
|
${PRIORITY_LABELS()[priority] ?? priority}
|
||||||
</span>`;
|
</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,11 +129,11 @@ function renderSwipeRow(task, innerHtml) {
|
|||||||
<div class="swipe-row" data-swipe-id="${task.id}" data-swipe-status="${task.status}">
|
<div class="swipe-row" data-swipe-id="${task.id}" data-swipe-status="${task.status}">
|
||||||
<div class="swipe-reveal swipe-reveal--done" aria-hidden="true">
|
<div class="swipe-reveal swipe-reveal--done" aria-hidden="true">
|
||||||
<i data-lucide="${isDone ? 'rotate-ccw' : 'check'}" style="width:22px;height:22px" aria-hidden="true"></i>
|
<i data-lucide="${isDone ? 'rotate-ccw' : 'check'}" style="width:22px;height:22px" aria-hidden="true"></i>
|
||||||
<span>${isDone ? 'Öffnen' : 'Erledigt'}</span>
|
<span>${isDone ? t('tasks.swipeOpen') : t('tasks.swipeDone')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="swipe-reveal swipe-reveal--edit" aria-hidden="true">
|
<div class="swipe-reveal swipe-reveal--edit" aria-hidden="true">
|
||||||
<i data-lucide="pencil" style="width:22px;height:22px" aria-hidden="true"></i>
|
<i data-lucide="pencil" style="width:22px;height:22px" aria-hidden="true"></i>
|
||||||
<span>Bearbeiten</span>
|
<span>${t('tasks.swipeEdit')}</span>
|
||||||
</div>
|
</div>
|
||||||
${innerHtml}
|
${innerHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -133,7 +152,7 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
data-subtask-id="${s.id}">
|
data-subtask-id="${s.id}">
|
||||||
<button class="subtask-item__checkbox ${s.status === 'done' ? 'subtask-item__checkbox--done' : ''}"
|
<button class="subtask-item__checkbox ${s.status === 'done' ? 'subtask-item__checkbox--done' : ''}"
|
||||||
data-action="toggle-subtask" data-id="${s.id}"
|
data-action="toggle-subtask" data-id="${s.id}"
|
||||||
data-status="${s.status}" aria-label="${s.title} als erledigt markieren">
|
data-status="${s.status}" aria-label="${t('tasks.subtaskMarkDone', { title: s.title })}">
|
||||||
${s.status === 'done' ? '<i data-lucide="check" style="width:10px;height:10px;color:#fff" aria-hidden="true"></i>' : ''}
|
${s.status === 'done' ? '<i data-lucide="check" style="width:10px;height:10px;color:#fff" aria-hidden="true"></i>' : ''}
|
||||||
</button>
|
</button>
|
||||||
<span class="subtask-item__title">${s.title}</span>
|
<span class="subtask-item__title">${s.title}</span>
|
||||||
@@ -145,7 +164,7 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
<div class="task-card__main">
|
<div class="task-card__main">
|
||||||
<button class="task-status-btn task-status-btn--${task.status}"
|
<button class="task-status-btn task-status-btn--${task.status}"
|
||||||
data-action="toggle-status" data-id="${task.id}" data-status="${task.status}"
|
data-action="toggle-status" data-id="${task.id}" data-status="${task.status}"
|
||||||
aria-label="${task.title} als erledigt markieren">
|
aria-label="${t('tasks.markDone', { title: task.title })}">
|
||||||
<i data-lucide="check" class="task-status-btn__check" aria-hidden="true"></i>
|
<i data-lucide="check" class="task-status-btn__check" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -156,8 +175,8 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
<div class="task-card__meta">
|
<div class="task-card__meta">
|
||||||
${renderPriorityBadge(task.priority)}
|
${renderPriorityBadge(task.priority)}
|
||||||
${renderDueDate(task.due_date)}
|
${renderDueDate(task.due_date)}
|
||||||
${task.is_recurring ? '<span class="due-date" aria-label="Wiederkehrend"><i data-lucide="repeat" style="width:12px;height:12px" aria-hidden="true"></i></span>' : ''}
|
${task.is_recurring ? `<span class="due-date" aria-label="${t('tasks.recurring')}"><i data-lucide="repeat" style="width:12px;height:12px" aria-hidden="true"></i></span>` : ''}
|
||||||
${task.category !== 'Sonstiges' ? `<span class="due-date">${task.category}</span>` : ''}
|
${task.category !== 'Sonstiges' ? `<span class="due-date">${CATEGORY_LABELS()[task.category] ?? task.category}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,14 +187,14 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
||||||
<button class="btn btn--ghost btn--icon" data-action="edit-task" data-id="${task.id}"
|
<button class="btn btn--ghost btn--icon" data-action="edit-task" data-id="${task.id}"
|
||||||
aria-label="Aufgabe bearbeiten" style="min-height:unset;width:36px;height:36px">
|
aria-label="${t('tasks.editButton')}" style="min-height:unset;width:36px;height:36px">
|
||||||
<i data-lucide="pencil" style="width:16px;height:16px" aria-hidden="true"></i>
|
<i data-lucide="pencil" style="width:16px;height:16px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${progress !== null ? `
|
${progress !== null ? `
|
||||||
<div class="subtask-progress" data-action="toggle-subtasks" data-id="${task.id}"
|
<div class="subtask-progress" data-action="toggle-subtasks" data-id="${task.id}"
|
||||||
aria-label="Teilaufgaben anzeigen">
|
aria-label="${t('tasks.subtaskToggle')}">
|
||||||
<div class="subtask-progress__bar-wrap">
|
<div class="subtask-progress__bar-wrap">
|
||||||
<div class="subtask-progress__bar-fill" style="width:${progress}%"></div>
|
<div class="subtask-progress__bar-fill" style="width:${progress}%"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,7 +206,7 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
id="subtasks-${task.id}">
|
id="subtasks-${task.id}">
|
||||||
${subtasksHtml}
|
${subtasksHtml}
|
||||||
<button class="subtask-item__add" data-action="add-subtask" data-parent="${task.id}">
|
<button class="subtask-item__add" data-action="add-subtask" data-parent="${task.id}">
|
||||||
+ Teilaufgabe hinzufügen
|
${t('tasks.subtaskAdd')}
|
||||||
</button>
|
</button>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -200,8 +219,8 @@ function renderTaskGroups(tasks, groupMode) {
|
|||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="empty-state__title">Keine Aufgaben — alles erledigt?</div>
|
<div class="empty-state__title">${t('tasks.emptyTitle')}</div>
|
||||||
<div class="empty-state__description">Neue Aufgaben über den + Button erstellen.</div>
|
<div class="empty-state__description">${t('tasks.emptyDescription')}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,11 +246,12 @@ function renderModalContent({ task = null, users = [] } = {}) {
|
|||||||
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${u.display_name}</option>`
|
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${u.display_name}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
|
const catLabels = CATEGORY_LABELS();
|
||||||
const categoryOptions = CATEGORIES.map((c) =>
|
const categoryOptions = CATEGORIES.map((c) =>
|
||||||
`<option value="${c}" ${(task?.category ?? 'Sonstiges') === c ? 'selected' : ''}>${c}</option>`
|
`<option value="${c}" ${(task?.category ?? 'Sonstiges') === c ? 'selected' : ''}>${catLabels[c] ?? c}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const priorityOptions = PRIORITIES.map((p) =>
|
const priorityOptions = PRIORITIES().map((p) =>
|
||||||
`<option value="${p.value}" ${(task?.priority ?? 'medium') === p.value ? 'selected' : ''}>${p.label}</option>`
|
`<option value="${p.value}" ${(task?.priority ?? 'medium') === p.value ? 'selected' : ''}>${p.label}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
@@ -241,36 +261,36 @@ function renderModalContent({ task = null, users = [] } = {}) {
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="label" for="task-title">Titel *</label>
|
<label class="label" for="task-title">${t('tasks.titleLabel')}</label>
|
||||||
<input class="input" type="text" id="task-title" name="title"
|
<input class="input" type="text" id="task-title" name="title"
|
||||||
value="${task?.title ?? ''}" placeholder="Was muss erledigt werden?"
|
value="${task?.title ?? ''}" placeholder="${t('tasks.titlePlaceholder')}"
|
||||||
required autocomplete="off">
|
required autocomplete="off">
|
||||||
<div class="form-field__error">
|
<div class="form-field__error">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/>
|
stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/>
|
||||||
<line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12" y2="16.01"/>
|
<line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12" y2="16.01"/>
|
||||||
</svg>
|
</svg>
|
||||||
Dieses Feld ist erforderlich.
|
${t('common.required')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="task-description">Notiz</label>
|
<label class="label" for="task-description">${t('tasks.descriptionLabel')}</label>
|
||||||
<textarea class="input" id="task-description" name="description"
|
<textarea class="input" id="task-description" name="description"
|
||||||
rows="2" placeholder="Optionale Details…"
|
rows="2" placeholder="${t('tasks.descriptionPlaceholder')}"
|
||||||
style="resize:vertical">${task?.description ?? ''}</textarea>
|
style="resize:vertical">${task?.description ?? ''}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label class="label" for="task-priority">Priorität</label>
|
<label class="label" for="task-priority">${t('tasks.priorityLabel')}</label>
|
||||||
<select class="input" id="task-priority" name="priority" style="min-height:44px">
|
<select class="input" id="task-priority" name="priority" style="min-height:44px">
|
||||||
${priorityOptions}
|
${priorityOptions}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label class="label" for="task-category">Kategorie</label>
|
<label class="label" for="task-category">${t('tasks.categoryLabel')}</label>
|
||||||
<select class="input" id="task-category" name="category" style="min-height:44px">
|
<select class="input" id="task-category" name="category" style="min-height:44px">
|
||||||
${categoryOptions}
|
${categoryOptions}
|
||||||
</select>
|
</select>
|
||||||
@@ -279,30 +299,30 @@ function renderModalContent({ task = null, users = [] } = {}) {
|
|||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin-top:var(--space-4)">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);margin-top:var(--space-4)">
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label class="label" for="task-due-date">Fälligkeit</label>
|
<label class="label" for="task-due-date">${t('tasks.dueDateLabel')}</label>
|
||||||
<input class="input" type="date" id="task-due-date" name="due_date"
|
<input class="input" type="date" id="task-due-date" name="due_date"
|
||||||
value="${task?.due_date ?? ''}">
|
value="${task?.due_date ?? ''}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<div class="form-group" style="margin-bottom:0">
|
||||||
<label class="label" for="task-due-time">Uhrzeit</label>
|
<label class="label" for="task-due-time">${t('tasks.dueTimeLabel')}</label>
|
||||||
<input class="input" type="time" id="task-due-time" name="due_time"
|
<input class="input" type="time" id="task-due-time" name="due_time"
|
||||||
value="${task?.due_time ?? ''}">
|
value="${task?.due_time ?? ''}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-top:var(--space-4)">
|
<div class="form-group" style="margin-top:var(--space-4)">
|
||||||
<label class="label" for="task-assigned">Zugewiesen an</label>
|
<label class="label" for="task-assigned">${t('tasks.assignedLabel')}</label>
|
||||||
<select class="input" id="task-assigned" name="assigned_to" style="min-height:44px">
|
<select class="input" id="task-assigned" name="assigned_to" style="min-height:44px">
|
||||||
<option value="">— Niemand —</option>
|
<option value="">${t('tasks.assignedNobody')}</option>
|
||||||
${userOptions}
|
${userOptions}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${isEdit ? `
|
${isEdit ? `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="task-status">Status</label>
|
<label class="label" for="task-status">${t('tasks.statusLabel')}</label>
|
||||||
<select class="input" id="task-status" name="status" style="min-height:44px">
|
<select class="input" id="task-status" name="status" style="min-height:44px">
|
||||||
${STATUSES.map((s) =>
|
${STATUSES().map((s) =>
|
||||||
`<option value="${s.value}" ${task.status === s.value ? 'selected' : ''}>${s.label}</option>`
|
`<option value="${s.value}" ${task.status === s.value ? 'selected' : ''}>${s.label}</option>`
|
||||||
).join('')}
|
).join('')}
|
||||||
</select>
|
</select>
|
||||||
@@ -315,9 +335,9 @@ function renderModalContent({ task = null, users = [] } = {}) {
|
|||||||
<div class="modal-panel__footer" style="padding:0;border:none;margin-top:var(--space-6)">
|
<div class="modal-panel__footer" style="padding:0;border:none;margin-top:var(--space-6)">
|
||||||
${isEdit ? `
|
${isEdit ? `
|
||||||
<button type="button" class="btn btn--danger" data-action="delete-task"
|
<button type="button" class="btn btn--danger" data-action="delete-task"
|
||||||
data-id="${task.id}">Löschen</button>` : ''}
|
data-id="${task.id}">${t('common.delete')}</button>` : ''}
|
||||||
<button type="submit" class="btn btn--primary" id="task-submit-btn">
|
<button type="submit" class="btn btn--primary" id="task-submit-btn">
|
||||||
${isEdit ? 'Speichern' : 'Erstellen'}
|
${isEdit ? t('common.save') : t('common.create')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>`;
|
</form>`;
|
||||||
@@ -375,7 +395,7 @@ async function loadTaskForEdit(id) {
|
|||||||
function openTaskModal({ task = null, users = [] } = {}, container) {
|
function openTaskModal({ task = null, users = [] } = {}, container) {
|
||||||
const isEdit = !!task;
|
const isEdit = !!task;
|
||||||
openSharedModal({
|
openSharedModal({
|
||||||
title: isEdit ? 'Aufgabe bearbeiten' : 'Neue Aufgabe',
|
title: isEdit ? t('tasks.editTask') : t('tasks.newTask'),
|
||||||
content: renderModalContent({ task, users }),
|
content: renderModalContent({ task, users }),
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
onSave(panel) {
|
onSave(panel) {
|
||||||
@@ -408,9 +428,9 @@ async function handleFormSubmit(e, container) {
|
|||||||
|
|
||||||
errorEl.hidden = true;
|
errorEl.hidden = true;
|
||||||
submitBtn.disabled = 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 rrule = getRRuleValues(document, 'task');
|
||||||
const body = {
|
const body = {
|
||||||
@@ -429,10 +449,10 @@ async function handleFormSubmit(e, container) {
|
|||||||
try {
|
try {
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
await api.put(`/tasks/${taskId}`, body);
|
await api.put(`/tasks/${taskId}`, body);
|
||||||
window.oikos.showToast('Aufgabe gespeichert.', 'success');
|
window.oikos.showToast(t('tasks.savedToast'), 'success');
|
||||||
} else {
|
} else {
|
||||||
await api.post('/tasks', body);
|
await api.post('/tasks', body);
|
||||||
window.oikos.showToast('Aufgabe erstellt.', 'success');
|
window.oikos.showToast(t('tasks.createdToast'), 'success');
|
||||||
}
|
}
|
||||||
btnSuccess(submitBtn, originalLabel);
|
btnSuccess(submitBtn, originalLabel);
|
||||||
setTimeout(() => closeModal(), 700);
|
setTimeout(() => closeModal(), 700);
|
||||||
@@ -447,11 +467,11 @@ async function handleFormSubmit(e, container) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteTask(id, container) {
|
async function handleDeleteTask(id, container) {
|
||||||
if (!confirm('Aufgabe und alle Teilaufgaben löschen?')) return;
|
if (!confirm(t('tasks.deleteConfirm'))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/tasks/${id}`);
|
await api.delete(`/tasks/${id}`);
|
||||||
closeModal();
|
closeModal();
|
||||||
window.oikos.showToast('Aufgabe gelöscht.', 'default');
|
window.oikos.showToast(t('tasks.deletedToast'), 'default');
|
||||||
await loadTasks(container);
|
await loadTasks(container);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos.showToast(err.message, 'danger');
|
window.oikos.showToast(err.message, 'danger');
|
||||||
@@ -459,7 +479,7 @@ async function handleDeleteTask(id, container) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddSubtask(parentId, container) {
|
async function handleAddSubtask(parentId, container) {
|
||||||
const title = prompt('Teilaufgabe:');
|
const title = prompt(t('tasks.subtaskPrompt'));
|
||||||
if (!title?.trim()) return;
|
if (!title?.trim()) return;
|
||||||
try {
|
try {
|
||||||
await api.post('/tasks', { title: title.trim(), parent_task_id: parentId });
|
await api.post('/tasks', { title: title.trim(), parent_task_id: parentId });
|
||||||
@@ -473,10 +493,10 @@ async function handleAddSubtask(parentId, container) {
|
|||||||
// Kanban-Ansicht
|
// Kanban-Ansicht
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
const KANBAN_COLS = [
|
const KANBAN_COLS = () => [
|
||||||
{ status: 'open', label: 'Offen', colorVar: '--color-text-secondary' },
|
{ status: 'open', label: t('tasks.kanbanOpen'), colorVar: '--color-text-secondary' },
|
||||||
{ status: 'in_progress', label: 'In Bearbeitung', colorVar: '--color-warning' },
|
{ status: 'in_progress', label: t('tasks.kanbanInProgress'), colorVar: '--color-warning' },
|
||||||
{ status: 'done', label: 'Erledigt', colorVar: '--color-success' },
|
{ status: 'done', label: t('tasks.kanbanDone'), colorVar: '--color-success' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function renderKanbanCard(task) {
|
function renderKanbanCard(task) {
|
||||||
@@ -503,8 +523,9 @@ function renderKanban(container) {
|
|||||||
const listEl = container.querySelector('#task-list');
|
const listEl = container.querySelector('#task-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
|
|
||||||
|
const cols = KANBAN_COLS();
|
||||||
const grouped = {};
|
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) {
|
for (const t of state.tasks) {
|
||||||
if (grouped[t.status]) grouped[t.status].push(t);
|
if (grouped[t.status]) grouped[t.status].push(t);
|
||||||
else grouped['open'].push(t);
|
else grouped['open'].push(t);
|
||||||
@@ -512,7 +533,7 @@ function renderKanban(container) {
|
|||||||
|
|
||||||
listEl.innerHTML = `
|
listEl.innerHTML = `
|
||||||
<div class="kanban-board">
|
<div class="kanban-board">
|
||||||
${KANBAN_COLS.map((col) => `
|
${cols.map((col) => `
|
||||||
<div class="kanban-col" data-status="${col.status}">
|
<div class="kanban-col" data-status="${col.status}">
|
||||||
<div class="kanban-col__header">
|
<div class="kanban-col__header">
|
||||||
<span class="kanban-col__title" style="color:${col.colorVar.startsWith('--') ? `var(${col.colorVar})` : col.colorVar}">
|
<span class="kanban-col__title" style="color:${col.colorVar.startsWith('--') ? `var(${col.colorVar})` : col.colorVar}">
|
||||||
@@ -606,7 +627,7 @@ function wireKanbanDrag(container) {
|
|||||||
const task = await loadTaskForEdit(card.dataset.taskId);
|
const task = await loadTaskForEdit(card.dataset.taskId);
|
||||||
openTaskModal({ task, users: state.users }, container);
|
openTaskModal({ task, users: state.users }, container);
|
||||||
} catch (err) {
|
} 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;
|
if (!bar) return;
|
||||||
|
|
||||||
const chips = [];
|
const chips = [];
|
||||||
|
const statusLabels = STATUS_LABELS();
|
||||||
|
const priorityLabels = PRIORITY_LABELS();
|
||||||
if (state.filters.status) {
|
if (state.filters.status) {
|
||||||
chips.push(`<span class="filter-chip filter-chip--active" data-filter="status">
|
chips.push(`<span class="filter-chip filter-chip--active" data-filter="status">
|
||||||
${STATUS_LABELS[state.filters.status]}
|
${statusLabels[state.filters.status]}
|
||||||
<span class="filter-chip__remove" aria-hidden="true">×</span>
|
<span class="filter-chip__remove" aria-hidden="true">×</span>
|
||||||
</span>`);
|
</span>`);
|
||||||
}
|
}
|
||||||
if (state.filters.priority) {
|
if (state.filters.priority) {
|
||||||
chips.push(`<span class="filter-chip filter-chip--active" data-filter="priority">
|
chips.push(`<span class="filter-chip filter-chip--active" data-filter="priority">
|
||||||
${PRIORITY_LABELS[state.filters.priority]}
|
${priorityLabels[state.filters.priority]}
|
||||||
<span class="filter-chip__remove" aria-hidden="true">×</span>
|
<span class="filter-chip__remove" aria-hidden="true">×</span>
|
||||||
</span>`);
|
</span>`);
|
||||||
}
|
}
|
||||||
@@ -657,12 +680,12 @@ function renderFilters(container) {
|
|||||||
|
|
||||||
// Inaktive Filter-Chips (zum Aktivieren)
|
// Inaktive Filter-Chips (zum Aktivieren)
|
||||||
if (!state.filters.status) {
|
if (!state.filters.status) {
|
||||||
STATUSES.forEach((s) => {
|
STATUSES().forEach((s) => {
|
||||||
chips.push(`<span class="filter-chip" data-filter="status" data-value="${s.value}">${s.label}</span>`);
|
chips.push(`<span class="filter-chip" data-filter="status" data-value="${s.value}">${s.label}</span>`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!state.filters.priority) {
|
if (!state.filters.priority) {
|
||||||
PRIORITIES.forEach((p) => {
|
PRIORITIES().forEach((p) => {
|
||||||
chips.push(`<span class="filter-chip" data-filter="priority" data-value="${p.value}">${p.label}</span>`);
|
chips.push(`<span class="filter-chip" data-filter="priority" data-value="${p.value}">${p.label}</span>`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -802,7 +825,7 @@ function wireSwipeGestures(container) {
|
|||||||
const task = await loadTaskForEdit(taskId);
|
const task = await loadTaskForEdit(taskId);
|
||||||
openTaskModal({ task, users: state.users }, container);
|
openTaskModal({ task, users: state.users }, container);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger');
|
window.oikos.showToast(t('tasks.loadError'), 'danger');
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
@@ -912,7 +935,7 @@ function wireTaskList(container) {
|
|||||||
const task = await loadTaskForEdit(id);
|
const task = await loadTaskForEdit(id);
|
||||||
openTaskModal({ task, users: state.users }, container);
|
openTaskModal({ task, users: state.users }, container);
|
||||||
} catch (err) {
|
} 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 = `
|
container.innerHTML = `
|
||||||
<div class="tasks-page">
|
<div class="tasks-page">
|
||||||
<div class="tasks-toolbar">
|
<div class="tasks-toolbar">
|
||||||
<h1 class="tasks-toolbar__title">Aufgaben</h1>
|
<h1 class="tasks-toolbar__title">${t('tasks.title')}</h1>
|
||||||
<div class="tasks-toolbar__actions">
|
<div class="tasks-toolbar__actions">
|
||||||
<div class="group-toggle" id="view-toggle">
|
<div class="group-toggle" id="view-toggle">
|
||||||
<button class="group-toggle__btn group-toggle__btn--active" data-view="list"
|
<button class="group-toggle__btn group-toggle__btn--active" data-view="list"
|
||||||
title="Listenansicht" aria-label="Listenansicht">
|
title="${t('tasks.listView')}" aria-label="${t('tasks.listView')}">
|
||||||
<i data-lucide="list" style="width:14px;height:14px;pointer-events:none" aria-hidden="true"></i>
|
<i data-lucide="list" style="width:14px;height:14px;pointer-events:none" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="group-toggle__btn" data-view="kanban"
|
<button class="group-toggle__btn" data-view="kanban"
|
||||||
title="Kanban-Ansicht" aria-label="Kanban-Ansicht">
|
title="${t('tasks.kanbanView')}" aria-label="${t('tasks.kanbanView')}">
|
||||||
<i data-lucide="columns" style="width:14px;height:14px;pointer-events:none" aria-hidden="true"></i>
|
<i data-lucide="columns" style="width:14px;height:14px;pointer-events:none" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="group-toggle" id="group-mode-toggle">
|
<div class="group-toggle" id="group-mode-toggle">
|
||||||
<button class="group-toggle__btn group-toggle__btn--active" data-mode="category">Kategorie</button>
|
<button class="group-toggle__btn group-toggle__btn--active" data-mode="category">${t('tasks.categoryLabel')}</button>
|
||||||
<button class="group-toggle__btn" data-mode="due">Fälligkeit</button>
|
<button class="group-toggle__btn" data-mode="due">${t('tasks.dueDateLabel')}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--primary" id="btn-new-task" style="gap:var(--space-1)">
|
<button class="btn btn--primary" id="btn-new-task" style="gap:var(--space-1)">
|
||||||
<i data-lucide="plus" style="width:18px;height:18px" aria-hidden="true"></i> Neu
|
<i data-lucide="plus" style="width:18px;height:18px" aria-hidden="true"></i> ${t('tasks.newTask')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -963,7 +986,7 @@ export async function render(container, { user }) {
|
|||||||
<div class="skeleton skeleton-line skeleton-line--short" style="height:12px"></div>
|
<div class="skeleton skeleton-line skeleton-line--short" style="height:12px"></div>
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<button class="page-fab" id="fab-new-task" aria-label="Neue Aufgabe">
|
<button class="page-fab" id="fab-new-task" aria-label="${t('tasks.newTask')}">
|
||||||
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -981,7 +1004,7 @@ export async function render(container, { user }) {
|
|||||||
state.users = metaData.users ?? [];
|
state.users = metaData.users ?? [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Tasks] Ladefehler:', err.message);
|
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.tasks = [];
|
||||||
state.users = [];
|
state.users = [];
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-21
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { auth } from '/api.js';
|
import { auth } from '/api.js';
|
||||||
|
import { initI18n, getLocale, t } from '/i18n.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Routen-Definitionen
|
// Routen-Definitionen
|
||||||
@@ -215,8 +216,8 @@ async function renderPage(route, previousPath = null) {
|
|||||||
*/
|
*/
|
||||||
function renderAppShell(container) {
|
function renderAppShell(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<a href="#page-content" class="sr-only">Zum Inhalt springen</a>
|
<a href="#page-content" class="sr-only">${t('common.skipToContent')}</a>
|
||||||
<nav class="nav-sidebar" aria-label="Hauptnavigation">
|
<nav class="nav-sidebar" aria-label="${t('nav.main')}">
|
||||||
<div class="nav-sidebar__logo"><span>Oikos</span></div>
|
<div class="nav-sidebar__logo"><span>Oikos</span></div>
|
||||||
<div class="nav-sidebar__items" role="list">
|
<div class="nav-sidebar__items" role="list">
|
||||||
${navItems().map(navItemHtml).join('')}
|
${navItems().map(navItemHtml).join('')}
|
||||||
@@ -226,7 +227,7 @@ function renderAppShell(container) {
|
|||||||
<main class="app-content" id="page-content" aria-live="polite">
|
<main class="app-content" id="page-content" aria-live="polite">
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<nav class="nav-bottom" aria-label="Navigation">
|
<nav class="nav-bottom" aria-label="${t('nav.navigation')}">
|
||||||
<div class="nav-bottom__dots" aria-hidden="true">
|
<div class="nav-bottom__dots" aria-hidden="true">
|
||||||
<span class="nav-bottom__dot nav-bottom__dot--active"></span>
|
<span class="nav-bottom__dot nav-bottom__dot--active"></span>
|
||||||
<span class="nav-bottom__dot"></span>
|
<span class="nav-bottom__dot"></span>
|
||||||
@@ -285,15 +286,15 @@ function scrollNavToActive() {
|
|||||||
|
|
||||||
function navItems() {
|
function navItems() {
|
||||||
return [
|
return [
|
||||||
{ path: '/', label: 'Übersicht', icon: 'layout-dashboard' },
|
{ path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard' },
|
||||||
{ path: '/tasks', label: 'Aufgaben', icon: 'check-square' },
|
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
|
||||||
{ path: '/calendar', label: 'Kalender', icon: 'calendar' },
|
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar' },
|
||||||
{ path: '/meals', label: 'Essen', icon: 'utensils' },
|
{ path: '/meals', label: t('nav.meals'), icon: 'utensils' },
|
||||||
{ path: '/shopping', label: 'Einkauf', icon: 'shopping-cart' },
|
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
|
||||||
{ path: '/notes', label: 'Pinnwand', icon: 'sticky-note' },
|
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
|
||||||
{ path: '/contacts', label: 'Kontakte', icon: 'book-user' },
|
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
|
||||||
{ path: '/budget', label: 'Budget', icon: 'wallet' },
|
{ path: '/budget', label: t('nav.budget'), icon: 'wallet' },
|
||||||
{ path: '/settings', label: 'Einstellungen', icon: 'settings' },
|
{ path: '/settings', label: t('nav.settings'), icon: 'settings' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,9 +332,9 @@ function updateNav(path) {
|
|||||||
function renderError(container, err) {
|
function renderError(container, err) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state__title">Etwas ist schiefgelaufen.</div>
|
<div class="empty-state__title">${t('common.errorOccurred')}</div>
|
||||||
<div class="empty-state__description">${err.message}</div>
|
<div class="empty-state__description">${err.message}</div>
|
||||||
<button class="btn btn--primary" id="error-reload-btn">Neu laden</button>
|
<button class="btn btn--primary" id="error-reload-btn">${t('common.reload')}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.querySelector('#error-reload-btn')?.addEventListener('click', () => location.reload());
|
container.querySelector('#error-reload-btn')?.addEventListener('click', () => location.reload());
|
||||||
@@ -377,14 +378,14 @@ window.addEventListener('error', (e) => {
|
|||||||
// Ressource-Ladefehler (z.B. fehlgeschlagenes Bild): ignorieren
|
// Ressource-Ladefehler (z.B. fehlgeschlagenes Bild): ignorieren
|
||||||
if (e.target && e.target !== window) return;
|
if (e.target && e.target !== window) return;
|
||||||
console.error('[Oikos] Unbehandelter Fehler:', e.error ?? e.message);
|
console.error('[Oikos] Unbehandelter Fehler:', e.error ?? e.message);
|
||||||
showToast('Ein unerwarteter Fehler ist aufgetreten.', 'danger');
|
showToast(t('common.unexpectedError'), 'danger');
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('unhandledrejection', (e) => {
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
// Auth-Fehler werden bereits von auth:expired behandelt
|
// Auth-Fehler werden bereits von auth:expired behandelt
|
||||||
if (e.reason?.status === 401) return;
|
if (e.reason?.status === 401) return;
|
||||||
console.error('[Oikos] Unbehandeltes Promise-Rejection:', e.reason);
|
console.error('[Oikos] Unbehandeltes Promise-Rejection:', e.reason);
|
||||||
const msg = e.reason?.message || 'Ein Fehler ist aufgetreten.';
|
const msg = e.reason?.message || t('common.errorGeneric');
|
||||||
showToast(msg, 'danger');
|
showToast(msg, 'danger');
|
||||||
e.preventDefault(); // Konsolenfehler unterdrücken (bereits geloggt)
|
e.preventDefault(); // Konsolenfehler unterdrücken (bereits geloggt)
|
||||||
});
|
});
|
||||||
@@ -395,11 +396,7 @@ if ('serviceWorker' in navigator) {
|
|||||||
if (e.data?.type === 'SW_UPDATED') {
|
if (e.data?.type === 'SW_UPDATED') {
|
||||||
// Modul-Cache leeren damit nächste Navigation frische Module lädt
|
// Modul-Cache leeren damit nächste Navigation frische Module lädt
|
||||||
moduleCache.clear();
|
moduleCache.clear();
|
||||||
showToast(
|
showToast(t('common.updateAvailable'), 'default', 8000);
|
||||||
'Update verfügbar — Seite neu laden für die neueste Version.',
|
|
||||||
'default',
|
|
||||||
8000
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -415,6 +412,38 @@ window.addEventListener('auth:expired', () => {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden
|
||||||
|
window.addEventListener('locale-changed', () => {
|
||||||
|
const navSidebarItems = document.querySelector('.nav-sidebar__items');
|
||||||
|
const navBottomPages = document.querySelectorAll('.nav-bottom__page');
|
||||||
|
const skipLink = document.querySelector('.sr-only[href="#page-content"]');
|
||||||
|
const navSidebar = document.querySelector('.nav-sidebar');
|
||||||
|
const navBottom = document.querySelector('.nav-bottom');
|
||||||
|
|
||||||
|
if (skipLink) skipLink.textContent = t('common.skipToContent');
|
||||||
|
if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main'));
|
||||||
|
if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation'));
|
||||||
|
|
||||||
|
if (navSidebarItems) {
|
||||||
|
navSidebarItems.innerHTML = navItems().map(navItemHtml).join('');
|
||||||
|
}
|
||||||
|
if (navBottomPages.length >= 2) {
|
||||||
|
navBottomPages[0].innerHTML = navItems().slice(0, 5).map(navItemHtml).join('');
|
||||||
|
navBottomPages[1].innerHTML = navItems().slice(5).map(navItemHtml).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klick-Handler für neu gerenderte Nav-Links
|
||||||
|
document.querySelectorAll('[data-route]').forEach((el) => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(el.dataset.route);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aktiven Zustand und Icons wiederherstellen
|
||||||
|
updateNav(currentPath);
|
||||||
|
});
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Virtuelle Tastatur: FAB ausblenden wenn Keyboard offen
|
// Virtuelle Tastatur: FAB ausblenden wenn Keyboard offen
|
||||||
// Erkennung via visualViewport — Höhe < 75% des Fensters = Keyboard aktiv.
|
// Erkennung via visualViewport — Höhe < 75% des Fensters = Keyboard aktiv.
|
||||||
@@ -430,7 +459,10 @@ if (window.visualViewport) {
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Initialisierung
|
// Initialisierung
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
(async () => {
|
||||||
|
await initI18n();
|
||||||
navigate(location.pathname, false);
|
navigate(location.pathname, false);
|
||||||
|
})();
|
||||||
|
|
||||||
// Globale Exporte
|
// Globale Exporte
|
||||||
window.oikos = {
|
window.oikos = {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* test-browser-loader.mjs — Node.js Custom Loader für Tests
|
||||||
|
* Zweck: Browser-absolute Pfade (/foo.js) auf Stubs umleiten, damit
|
||||||
|
* Frontend-Module im Node-Test-Kontext importierbar sind.
|
||||||
|
* Verwendung: node --loader ./test-browser-loader.mjs test-xxx.js
|
||||||
|
* Dependencies: none
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STUBS = {
|
||||||
|
'/i18n.js': `
|
||||||
|
export const t = (key) => key;
|
||||||
|
export const initI18n = async () => {};
|
||||||
|
export const setLocale = async () => {};
|
||||||
|
export const getLocale = () => 'de';
|
||||||
|
export const getSupportedLocales = () => ['de', 'en'];
|
||||||
|
export const formatDate = (d) => String(d);
|
||||||
|
export const formatTime = (d) => String(d);
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolve(specifier, context, nextResolve) {
|
||||||
|
if (STUBS[specifier]) {
|
||||||
|
return {
|
||||||
|
shortCircuit: true,
|
||||||
|
url: `data:text/javascript,${encodeURIComponent(STUBS[specifier])}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return nextResolve(specifier, context);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
// /i18n.js wird durch test-browser-loader.mjs gemockt (--loader Flag)
|
||||||
const { wireBlurValidation, btnSuccess, btnError } = await import('./public/components/modal.js');
|
const { wireBlurValidation, btnSuccess, btnError } = await import('./public/components/modal.js');
|
||||||
|
|
||||||
const _origSetTimeout = setTimeout;
|
const _origSetTimeout = setTimeout;
|
||||||
|
|||||||
Reference in New Issue
Block a user