Multilanguage: Deutsch / English, umschaltbar in Einstellungen

- i18n.js: Übersetzungsmodul mit t(), setLang(), applyLang() + vollst. DE/EN Wörterbuch
- Backend: language-Feld in UserSettings, Migration, Settings-API
- calendar.js: alle deutschen Strings auf t()-Aufrufe umgestellt, setLang() beim Start
- app.js, api.js, color-picker.js, views/*.js: alle UI-Strings übersetzt
- Sprach-Dropdown in Einstellungen > Darstellung, data-i18n-Attribute in index.html
This commit is contained in:
2026-03-27 15:15:07 +01:00
parent e4a14e6927
commit cd5d866cb1
12 changed files with 544 additions and 129 deletions

View File

@@ -58,6 +58,11 @@ def _migrate():
conn.commit() conn.commit()
except Exception: except Exception:
pass pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN language VARCHAR(5) DEFAULT 'de'"))
conn.commit()
except Exception:
pass
_migrate() _migrate()

View File

@@ -78,6 +78,7 @@ class UserSettings(Base):
text_contrast = Column(Integer, default=3) text_contrast = Column(Integer, default=3)
line_contrast = Column(Integer, default=3) line_contrast = Column(Integer, default=3)
hour_height = Column(Integer, default=60) hour_height = Column(Integer, default=60)
language = Column(String(5), default="de")
user = relationship("User", back_populates="settings") user = relationship("User", back_populates="settings")

View File

@@ -21,6 +21,7 @@ class SettingsUpdate(BaseModel):
text_contrast: Optional[int] = None text_contrast: Optional[int] = None
line_contrast: Optional[int] = None line_contrast: Optional[int] = None
hour_height: Optional[int] = None hour_height: Optional[int] = None
language: Optional[str] = None
def _settings_dict(s: models.UserSettings) -> dict: def _settings_dict(s: models.UserSettings) -> dict:
@@ -34,6 +35,7 @@ def _settings_dict(s: models.UserSettings) -> dict:
"text_contrast": s.text_contrast or 3, "text_contrast": s.text_contrast or 3,
"line_contrast": s.line_contrast or 3, "line_contrast": s.line_contrast or 3,
"hour_height": s.hour_height or 60, "hour_height": s.hour_height or 60,
"language": s.language or "de",
} }

View File

@@ -400,98 +400,106 @@
<!-- Einstellungen (merged: Darstellung + Ansicht & Raster + Ausgeblendete Kalender) --> <!-- Einstellungen (merged: Darstellung + Ansicht & Raster + Ausgeblendete Kalender) -->
<div class="settings-panel active" id="settings-panel-general"> <div class="settings-panel active" id="settings-panel-general">
<h4 class="panel-title">Farben</h4> <h4 class="panel-title" data-i18n="settings_colors">Farben</h4>
<div class="form-group"> <div class="form-group">
<label>Primärfarbe</label> <label data-i18n="settings_primary_color">Primärfarbe</label>
<div class="ev-color-row"> <div class="ev-color-row">
<input type="text" id="cfg-primary-hex" class="ev-color-hex" maxlength="7" spellcheck="false" /> <input type="text" id="cfg-primary-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-primary-preview" title="Farbe wählen"></div> <div class="ev-color-preview" id="cfg-primary-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Akzentfarbe</label> <label data-i18n="settings_accent_color">Akzentfarbe</label>
<div class="ev-color-row"> <div class="ev-color-row">
<input type="text" id="cfg-accent-hex" class="ev-color-hex" maxlength="7" spellcheck="false" /> <input type="text" id="cfg-accent-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-accent-preview" title="Farbe wählen"></div> <div class="ev-color-preview" id="cfg-accent-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Heutige-Tag-Farbe</label> <label data-i18n="settings_today_color">Heutige-Tag-Farbe</label>
<div class="ev-color-row"> <div class="ev-color-row">
<input type="text" id="cfg-today-hex" class="ev-color-hex" maxlength="7" spellcheck="false" /> <input type="text" id="cfg-today-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
<div class="ev-color-preview" id="cfg-today-preview" title="Farbe wählen"></div> <div class="ev-color-preview" id="cfg-today-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
</div> </div>
</div> </div>
<h4 class="panel-title" style="margin-top:24px">Schriftkontrast</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_text_contrast">Schriftkontrast</h4>
<p class="panel-desc">Helligkeit der Beschriftungen und Texte</p> <p class="panel-desc" data-i18n="settings_text_contrast_desc">Helligkeit der Beschriftungen und Texte</p>
<div class="contrast-selector" id="cfg-text-contrast" data-setting="text_contrast"> <div class="contrast-selector" id="cfg-text-contrast" data-setting="text_contrast">
<button class="contrast-btn" data-val="1"><span style="color:#606070">Aa</span><span class="contrast-lbl">Dunkel</span></button> <button class="contrast-btn" data-val="1"><span style="color:#606070">Aa</span><span class="contrast-lbl" data-i18n="contrast_dark">Dunkel</span></button>
<button class="contrast-btn" data-val="2"><span style="color:#9090a8">Aa</span><span class="contrast-lbl">Mittel</span></button> <button class="contrast-btn" data-val="2"><span style="color:#9090a8">Aa</span><span class="contrast-lbl" data-i18n="contrast_medium">Mittel</span></button>
<button class="contrast-btn" data-val="3"><span style="color:#c8c8d8">Aa</span><span class="contrast-lbl">Hell</span></button> <button class="contrast-btn" data-val="3"><span style="color:#c8c8d8">Aa</span><span class="contrast-lbl" data-i18n="contrast_light">Hell</span></button>
<button class="contrast-btn" data-val="4"><span style="color:#ffffff">Aa</span><span class="contrast-lbl">Maximum</span></button> <button class="contrast-btn" data-val="4"><span style="color:#ffffff">Aa</span><span class="contrast-lbl" data-i18n="contrast_max">Maximum</span></button>
</div> </div>
<h4 class="panel-title" style="margin-top:24px">Linienkontrast</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_line_contrast">Linienkontrast</h4>
<p class="panel-desc">Sichtbarkeit von Trennlinien und Rahmen</p> <p class="panel-desc" data-i18n="settings_line_contrast_desc">Sichtbarkeit von Trennlinien und Rahmen</p>
<div class="contrast-selector" id="cfg-line-contrast" data-setting="line_contrast"> <div class="contrast-selector" id="cfg-line-contrast" data-setting="line_contrast">
<button class="contrast-btn" data-val="1"><span class="line-preview" style="border-color:#1e1e2c"></span><span class="contrast-lbl">Kaum</span></button> <button class="contrast-btn" data-val="1"><span class="line-preview" style="border-color:#1e1e2c"></span><span class="contrast-lbl" data-i18n="line_barely">Kaum</span></button>
<button class="contrast-btn" data-val="2"><span class="line-preview" style="border-color:#2a2a3c"></span><span class="contrast-lbl">Subtil</span></button> <button class="contrast-btn" data-val="2"><span class="line-preview" style="border-color:#2a2a3c"></span><span class="contrast-lbl" data-i18n="line_subtle">Subtil</span></button>
<button class="contrast-btn" data-val="3"><span class="line-preview" style="border-color:#3a3a52"></span><span class="contrast-lbl">Normal</span></button> <button class="contrast-btn" data-val="3"><span class="line-preview" style="border-color:#3a3a52"></span><span class="contrast-lbl" data-i18n="line_normal">Normal</span></button>
<button class="contrast-btn" data-val="4"><span class="line-preview" style="border-color:#5a5a78"></span><span class="contrast-lbl">Stark</span></button> <button class="contrast-btn" data-val="4"><span class="line-preview" style="border-color:#5a5a78"></span><span class="contrast-lbl" data-i18n="line_strong">Stark</span></button>
</div> </div>
<h4 class="panel-title" style="margin-top:24px">Kalenderansicht</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendar_view">Kalenderansicht</h4>
<div class="form-group"> <div class="form-group">
<label>Standardansicht</label> <label data-i18n="settings_default_view">Standardansicht</label>
<select id="cfg-default-view"> <select id="cfg-default-view">
<option value="month">Monat</option> <option value="month" data-i18n="view_month">Monat</option>
<option value="week">Woche</option> <option value="week" data-i18n="view_week">Woche</option>
<option value="day">Tag</option> <option value="day" data-i18n="view_day">Tag</option>
<option value="agenda">Termine</option> <option value="agenda" data-i18n="view_agenda">Termine</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Erster Wochentag</label> <label data-i18n="settings_week_start">Erster Wochentag</label>
<select id="cfg-week-start"> <select id="cfg-week-start">
<option value="monday">Montag</option> <option value="monday" data-i18n="week_start_monday">Montag</option>
<option value="sunday">Sonntag</option> <option value="sunday" data-i18n="week_start_sunday">Sonntag</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" id="cfg-dim-past" /> <input type="checkbox" id="cfg-dim-past" />
Vergangene Termine ausgrauen <span data-i18n="settings_dim_past">Vergangene Termine ausgrauen</span>
</label> </label>
</div> </div>
<h4 class="panel-title" style="margin-top:24px">Stundenhöhe (Wochen- &amp; Tagesansicht)</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- &amp; Tagesansicht)</h4>
<p class="panel-desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p> <p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p>
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height"> <div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">
<button class="contrast-btn" data-val="40"><span class="hour-preview">━━</span><span class="contrast-lbl">Kompakt</span></button> <button class="contrast-btn" data-val="40"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</span></button>
<button class="contrast-btn" data-val="60"><span class="hour-preview">━━━</span><span class="contrast-lbl">Normal</span></button> <button class="contrast-btn" data-val="60"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</span></button>
<button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━</span><span class="contrast-lbl">Komfort</span></button> <button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</span></button>
<button class="contrast-btn" data-val="100"><span class="hour-preview">━━━━━</span><span class="contrast-lbl">Gross</span></button> <button class="contrast-btn" data-val="100"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button>
</div> </div>
<h4 class="panel-title" style="margin-top:24px">Ausgeblendete Kalender</h4> <h4 class="panel-title" style="margin-top:24px" data-i18n="settings_language">Sprache</h4>
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)">Keine ausgeblendeten Kalender</span></div> <div class="form-group">
<select id="cfg-language">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hidden_cals">Ausgeblendete Kalender</h4>
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
</div> </div>
<!-- Google Konten --> <!-- Google Konten -->
<div class="settings-panel" id="settings-panel-google"> <div class="settings-panel" id="settings-panel-google">
<h4 class="panel-title">Google Konten</h4> <h4 class="panel-title" data-i18n="settings_nav_google">Google Konten</h4>
<div id="google-accounts-list"><span style="font-size:13px;color:var(--text-3)">Keine Google-Konten verbunden</span></div> <div id="google-accounts-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_google">Keine Google-Konten verbunden</span></div>
</div> </div>
<!-- Benutzerverwaltung --> <!-- Benutzerverwaltung -->
<div class="settings-panel" id="settings-panel-users"> <div class="settings-panel" id="settings-panel-users">
<h4 class="panel-title">Benutzerverwaltung <span class="badge-admin">Admin</span></h4> <h4 class="panel-title"><span data-i18n="settings_nav_users">Benutzerverwaltung</span> <span class="badge-admin">Admin</span></h4>
<div id="users-list"></div> <div id="users-list"></div>
<button class="btn btn-secondary" id="btn-add-user" style="margin-top:12px">Benutzer hinzufügen</button> <button class="btn btn-secondary" id="btn-add-user" style="margin-top:12px" data-i18n="users_add">Benutzer hinzufügen</button>
<div id="add-user-form" class="hidden" style="margin-top:12px"> <div id="add-user-form" class="hidden" style="margin-top:12px">
<div class="form-group"> <div class="form-group">
<label>Benutzername</label> <label data-i18n="username">Benutzername</label>
<input type="text" id="new-username" /> <input type="text" id="new-username" />
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@@ -1,3 +1,4 @@
import { t } from './i18n.js';
const BASE = '/api'; const BASE = '/api';
async function request(method, path, body = null, formEncoded = false) { async function request(method, path, body = null, formEncoded = false) {
@@ -27,7 +28,7 @@ async function request(method, path, body = null, formEncoded = false) {
} }
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Unbekannter Fehler' })); const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
throw new Error(err.detail || `HTTP ${res.status}`); throw new Error(err.detail || `HTTP ${res.status}`);
} }
@@ -49,7 +50,7 @@ async function uploadRequest(path, formData) {
return null; return null;
} }
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Unbekannter Fehler' })); const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
throw new Error(err.detail || `HTTP ${res.status}`); throw new Error(err.detail || `HTTP ${res.status}`);
} }
if (res.status === 204) return null; if (res.status === 204) return null;

View File

@@ -1,5 +1,6 @@
import { api } from './api.js'; import { api } from './api.js';
import { initCalendar, showToast, openProfileModal } from './calendar.js'; import { initCalendar, showToast, openProfileModal } from './calendar.js';
import { t } from './i18n.js';
// ── Bootstrap ───────────────────────────────────────────── // ── Bootstrap ─────────────────────────────────────────────
async function boot() { async function boot() {
@@ -109,12 +110,12 @@ function bindSetupForm() {
errEl.classList.add('hidden'); errEl.classList.add('hidden');
if (pw1 !== pw2) { if (pw1 !== pw2) {
errEl.textContent = 'Passwörter stimmen nicht überein'; errEl.textContent = t('setup_pw_mismatch');
errEl.classList.remove('hidden'); errEl.classList.remove('hidden');
return; return;
} }
if (pw1.length < 6) { if (pw1.length < 6) {
errEl.textContent = 'Passwort muss mindestens 6 Zeichen haben'; errEl.textContent = t('setup_pw_short');
errEl.classList.remove('hidden'); errEl.classList.remove('hidden');
return; return;
} }

View File

@@ -4,6 +4,7 @@ import { renderMonth } from './views/month.js';
import { renderWeek } from './views/week.js'; import { renderWeek } from './views/week.js';
import { renderAgenda } from './views/agenda.js'; import { renderAgenda } from './views/agenda.js';
import { openColorPicker } from './color-picker.js'; import { openColorPicker } from './color-picker.js';
import { t, setLang, applyLang } from './i18n.js';
// Fetch avatar image as blob URL (with auth header) // Fetch avatar image as blob URL (with auth header)
function fetchAvatarBlob() { function fetchAvatarBlob() {
@@ -19,8 +20,7 @@ function fetchAvatarBlob() {
// week start day global (loaded from settings) // week start day global (loaded from settings)
let weekStartDay = 'monday'; let weekStartDay = 'monday';
const MONTHS = ['Januar','Februar','März','April','Mai','Juni', // month names come from i18n: t('months')
'Juli','August','September','Oktober','November','Dezember'];
let state = { let state = {
currentDate: new Date(), currentDate: new Date(),
@@ -55,6 +55,7 @@ export async function initCalendar() {
state.dimPast = settings.dim_past_events; state.dimPast = settings.dim_past_events;
weekStartDay = settings.week_start_day || 'monday'; weekStartDay = settings.week_start_day || 'monday';
setLang(settings.language || 'de');
applyTheme(settings); applyTheme(settings);
updateViewButtons(); updateViewButtons();
renderCalendarList(); renderCalendarList();
@@ -78,7 +79,7 @@ async function fetchAndRender() {
const events = await api.get(`/caldav/events?start=${start.toISOString()}&end=${end.toISOString()}`); const events = await api.get(`/caldav/events?start=${start.toISOString()}&end=${end.toISOString()}`);
state.events = events; state.events = events;
} catch (e) { } catch (e) {
showToast('Fehler beim Laden der Termine: ' + e.message, true); showToast(t('error_load_events') + ': ' + e.message, true);
state.events = []; state.events = [];
} }
renderView(); renderView();
@@ -161,20 +162,21 @@ function showLoading() {
function updateTitle() { function updateTitle() {
const d = state.currentDate; const d = state.currentDate;
let title = ''; let title = '';
const M = t('months');
if (state.currentView === 'month') { if (state.currentView === 'month') {
title = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`; title = `${M[d.getMonth()]} ${d.getFullYear()}`;
} else if (state.currentView === 'week') { } else if (state.currentView === 'week') {
const mon = weekStart(d, weekStartDay); const mon = weekStart(d, weekStartDay);
const sun = new Date(mon); const sun = new Date(mon);
sun.setDate(mon.getDate() + 6); sun.setDate(mon.getDate() + 6);
const sameMonth = mon.getMonth() === sun.getMonth(); const sameMonth = mon.getMonth() === sun.getMonth();
title = sameMonth title = sameMonth
? `${mon.getDate()}. ${sun.getDate()}. ${MONTHS[sun.getMonth()]} ${sun.getFullYear()}` ? `${mon.getDate()}. ${sun.getDate()}. ${M[sun.getMonth()]} ${sun.getFullYear()}`
: `${mon.getDate()}. ${MONTHS[mon.getMonth()]} ${sun.getDate()}. ${MONTHS[sun.getMonth()]} ${sun.getFullYear()}`; : `${mon.getDate()}. ${M[mon.getMonth()]} ${sun.getDate()}. ${M[sun.getMonth()]} ${sun.getFullYear()}`;
} else if (state.currentView === 'day') { } else if (state.currentView === 'day') {
title = `${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; title = `${d.getDate()}. ${M[d.getMonth()]} ${d.getFullYear()}`;
} else { } else {
title = `Ab ${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; title = `${d.getDate()}. ${M[d.getMonth()]} ${d.getFullYear()}`;
} }
document.getElementById('view-title').textContent = title; document.getElementById('view-title').textContent = title;
document.title = `Calendarr - ${title}`; document.title = `Calendarr - ${title}`;
@@ -191,7 +193,7 @@ function renderMiniCal() {
const d = state.currentDate; const d = state.currentDate;
const miniD = new Date(d.getFullYear(), d.getMonth(), 1); const miniD = new Date(d.getFullYear(), d.getMonth(), 1);
document.getElementById('mini-title').textContent = document.getElementById('mini-title').textContent =
`${MONTHS[miniD.getMonth()]} ${miniD.getFullYear()}`; `${t('months')[miniD.getMonth()]} ${miniD.getFullYear()}`;
const firstDay = new Date(miniD.getFullYear(), miniD.getMonth(), 1); const firstDay = new Date(miniD.getFullYear(), miniD.getMonth(), 1);
const gridStart = new Date(firstDay); const gridStart = new Date(firstDay);
@@ -270,9 +272,9 @@ function renderCalendarList() {
visibleCals.map(cal => visibleCals.map(cal =>
`<div class="cal-item" data-cal-id="${cal.id}" data-source="caldav"> `<div class="cal-item" data-cal-id="${cal.id}" data-source="caldav">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="caldav" /> <input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="caldav" />
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="caldav" title="Farbe ändern"></div> <div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="caldav" title="${t('change_color')}"></div>
<span class="cal-item-name" data-source="caldav">${escHtml(cal.name)}</span> <span class="cal-item-name" data-source="caldav">${escHtml(cal.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="caldav" title="Kalender ausblenden"> <button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="caldav" title="${t('hide_cal')}">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
</button> </button>
</div>` </div>`
@@ -282,13 +284,13 @@ function renderCalendarList() {
// ── Local calendars ──────────────────────────────────── // ── Local calendars ────────────────────────────────────
if (state.localCalendars.length) { if (state.localCalendars.length) {
html += `<div class="cal-account-name">Lokale Kalender</div>`; html += `<div class="cal-account-name">${t('cal_local')}</div>`;
html += state.localCalendars.map(cal => html += state.localCalendars.map(cal =>
`<div class="cal-item" data-cal-id="${cal.id}" data-source="local"> `<div class="cal-item" data-cal-id="${cal.id}" data-source="local">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="local" /> <input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="local" />
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="local" title="Farbe ändern"></div> <div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" data-source="local" title="${t('change_color')}"></div>
<span class="cal-item-name" data-source="local">${escHtml(cal.name)}</span> <span class="cal-item-name" data-source="local">${escHtml(cal.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="local" title="Kalender entfernen"> <button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="local" title="${t('remove_cal')}">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button> </button>
</div>` </div>`
@@ -297,13 +299,13 @@ function renderCalendarList() {
// ── iCal subscriptions ───────────────────────────────── // ── iCal subscriptions ─────────────────────────────────
if (state.icalSubscriptions.length) { if (state.icalSubscriptions.length) {
html += `<div class="cal-account-name">Abonnements</div>`; html += `<div class="cal-account-name">${t('cal_ical')}</div>`;
html += state.icalSubscriptions.map(sub => html += state.icalSubscriptions.map(sub =>
`<div class="cal-item" data-sub-id="${sub.id}" data-source="ical"> `<div class="cal-item" data-sub-id="${sub.id}" data-source="ical">
<input type="checkbox" ${sub.enabled ? 'checked' : ''} data-sub-id="${sub.id}" data-source="ical" /> <input type="checkbox" ${sub.enabled ? 'checked' : ''} data-sub-id="${sub.id}" data-source="ical" />
<div class="cal-item-dot" style="background:${sub.color}" data-sub-id="${sub.id}" data-source="ical" title="Farbe ändern"></div> <div class="cal-item-dot" style="background:${sub.color}" data-sub-id="${sub.id}" data-source="ical" title="${t('change_color')}"></div>
<span class="cal-item-name" data-source="ical">${escHtml(sub.name)}</span> <span class="cal-item-name" data-source="ical">${escHtml(sub.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-sub-id="${sub.id}" data-source="ical" title="Abo entfernen"> <button class="icon-btn mini-btn cal-item-remove" data-sub-id="${sub.id}" data-source="ical" title="${t('remove_ical_sub')}">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button> </button>
</div>` </div>`
@@ -319,9 +321,9 @@ function renderCalendarList() {
visibleCals.map(cal => visibleCals.map(cal =>
`<div class="cal-item" data-cal-id="${cal.id}" data-source="google"> `<div class="cal-item" data-cal-id="${cal.id}" data-source="google">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="google" /> <input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="google" />
<div class="cal-item-dot" style="background:${cal.color || '#4285f4'}" data-cal-id="${cal.id}" data-source="google" title="Farbe ändern"></div> <div class="cal-item-dot" style="background:${cal.color || '#4285f4'}" data-cal-id="${cal.id}" data-source="google" title="${t('change_color')}"></div>
<span class="cal-item-name" data-source="google">${escHtml(cal.name)}</span> <span class="cal-item-name" data-source="google">${escHtml(cal.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="google" title="Kalender ausblenden"> <button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="google" title="${t('hide_cal')}">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
</button> </button>
</div>` </div>`
@@ -330,7 +332,7 @@ function renderCalendarList() {
} }
if (!html) { if (!html) {
container.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">Keine Kalender</div>`; container.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">${t('error_no_calendars')}</div>`;
return; return;
} }
@@ -476,12 +478,12 @@ function renderCalendarList() {
} }
} }
} else if (source === 'local') { } else if (source === 'local') {
if (!confirm('Lokalen Kalender wirklich löschen?')) return; if (!confirm(t('confirm_delete_local_cal'))) return;
const calId = parseInt(btn.dataset.calId); const calId = parseInt(btn.dataset.calId);
await api.delete(`/local/calendars/${calId}`); await api.delete(`/local/calendars/${calId}`);
state.localCalendars = state.localCalendars.filter(c => c.id !== calId); state.localCalendars = state.localCalendars.filter(c => c.id !== calId);
} else if (source === 'ical') { } else if (source === 'ical') {
if (!confirm('Abonnement wirklich entfernen?')) return; if (!confirm(t('confirm_remove_ical'))) return;
const subId = parseInt(btn.dataset.subId); const subId = parseInt(btn.dataset.subId);
await api.delete(`/ical/subscriptions/${subId}`); await api.delete(`/ical/subscriptions/${subId}`);
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId); state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId);
@@ -576,13 +578,13 @@ function bindSidebar() {
try { try {
const { configured } = await api.get('/google/configured'); const { configured } = await api.get('/google/configured');
if (!configured) { if (!configured) {
showToast('Google OAuth ist nicht konfiguriert (Admin muss GOOGLE_CLIENT_ID/SECRET setzen)', true); showToast(t('google_not_configured'), true);
return; return;
} }
const { url } = await api.get('/google/auth-url'); const { url } = await api.get('/google/auth-url');
window.location.href = url; window.location.href = url;
} catch (e) { } catch (e) {
showToast('Fehler: ' + e.message, true); showToast(t('error_prefix') + e.message, true);
} }
}; };
} }
@@ -627,7 +629,7 @@ function showEventPopup(ev, anchor) {
openEditEventModal(ev); openEditEventModal(ev);
}; };
document.getElementById('popup-delete').onclick = async () => { document.getElementById('popup-delete').onclick = async () => {
if (!confirm(`"${ev.title}" wirklich löschen?`)) return; if (!confirm(t("confirm_delete_event", {title: ev.title}))) return;
popup.classList.add('hidden'); popup.classList.add('hidden');
try { try {
if (ev.source === 'google') { if (ev.source === 'google') {
@@ -641,7 +643,7 @@ function showEventPopup(ev, anchor) {
} else { } else {
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`);
} }
showToast('Termin gelöscht'); showToast(t('event_deleted'));
fetchAndRender(); fetchAndRender();
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
}; };
@@ -782,7 +784,7 @@ function bindEventModal() {
document.getElementById('ev-save').onclick = async () => { document.getElementById('ev-save').onclick = async () => {
const title = document.getElementById('ev-title').value.trim(); const title = document.getElementById('ev-title').value.trim();
if (!title) { showToast('Bitte Titel eingeben', true); return; } if (!title) { showToast(t('error_enter_title'), true); return; }
const allDay = document.getElementById('ev-allday').checked; const allDay = document.getElementById('ev-allday').checked;
const calVal = document.getElementById('ev-calendar').value; const calVal = document.getElementById('ev-calendar').value;
@@ -796,12 +798,12 @@ function bindEventModal() {
if (allDay) { if (allDay) {
start = document.getElementById('ev-start-date').value; start = document.getElementById('ev-start-date').value;
end = document.getElementById('ev-end-date').value; end = document.getElementById('ev-end-date').value;
if (!start) { showToast('Bitte Datum eingeben', true); return; } if (!start) { showToast(t('error_enter_date'), true); return; }
if (!end || end < start) end = start; if (!end || end < start) end = start;
} else { } else {
const sv = document.getElementById('ev-start').value; const sv = document.getElementById('ev-start').value;
const ev2 = document.getElementById('ev-end').value; const ev2 = document.getElementById('ev-end').value;
if (!sv) { showToast('Bitte Start-Zeit eingeben', true); return; } if (!sv) { showToast(t('error_enter_start'), true); return; }
start = new Date(sv).toISOString(); start = new Date(sv).toISOString();
end = ev2 ? new Date(ev2).toISOString() : new Date(new Date(sv).getTime() + 3600000).toISOString(); end = ev2 ? new Date(ev2).toISOString() : new Date(new Date(sv).getTime() + 3600000).toISOString();
} }
@@ -829,28 +831,28 @@ function bindEventModal() {
{ title, start, end, allDay, location: loc, description: desc, color: color || null } { title, start, end, allDay, location: loc, description: desc, color: color || null }
); );
} }
showToast('Termin aktualisiert'); showToast(t('event_updated'));
} else if (isGoogle) { } else if (isGoogle) {
const calDbId = parseInt(calVal.replace('google-', '')); const calDbId = parseInt(calVal.replace('google-', ''));
await api.post('/google/events', { await api.post('/google/events', {
calendar_db_id: calDbId, title, start, end, allDay, calendar_db_id: calDbId, title, start, end, allDay,
location: loc, description: desc, location: loc, description: desc,
}); });
showToast('Termin erstellt'); showToast(t('event_created'));
} else if (isLocal) { } else if (isLocal) {
const calId = parseInt(calVal.replace('local-', '')); const calId = parseInt(calVal.replace('local-', ''));
await api.post('/local/events', { await api.post('/local/events', {
calendar_id: calId, title, start, end, allDay, calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null, location: loc, description: desc, color: color || null,
}); });
showToast('Termin erstellt'); showToast(t('event_created'));
} else { } else {
const calId = parseInt(calVal); const calId = parseInt(calVal);
await api.post('/caldav/events', { await api.post('/caldav/events', {
calendar_id: calId, title, start, end, allDay, calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null, location: loc, description: desc, color: color || null,
}); });
showToast('Termin erstellt'); showToast(t('event_created'));
} }
closeModal('modal-event'); closeModal('modal-event');
fetchAndRender(); fetchAndRender();
@@ -862,7 +864,7 @@ function bindEventModal() {
document.getElementById('ev-delete').onclick = async () => { document.getElementById('ev-delete').onclick = async () => {
const ev = state.editingEvent; const ev = state.editingEvent;
if (!ev) return; if (!ev) return;
if (!confirm(`"${ev.title}" wirklich löschen?`)) return; if (!confirm(t("confirm_delete_event", {title: ev.title}))) return;
try { try {
if (ev.source === 'google') { if (ev.source === 'google') {
const accId = ev.calendar_id.replace('google-', ''); const accId = ev.calendar_id.replace('google-', '');
@@ -875,7 +877,7 @@ function bindEventModal() {
} else { } else {
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`);
} }
showToast('Termin gelöscht'); showToast(t('event_deleted'));
closeModal('modal-event'); closeModal('modal-event');
fetchAndRender(); fetchAndRender();
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
@@ -929,7 +931,7 @@ function bindAccountModal() {
state.accounts.push(acc); state.accounts.push(acc);
renderCalendarList(); renderCalendarList();
closeModal('modal-account'); closeModal('modal-account');
showToast(`Konto "${name}" hinzugefügt`); showToast(t("account_added", {name}));
fetchAndRender(); fetchAndRender();
} catch (e) { } catch (e) {
errEl.textContent = e.message; errEl.textContent = e.message;
@@ -964,14 +966,14 @@ function bindLocalCalModal() {
document.getElementById('local-cal-save').onclick = async () => { document.getElementById('local-cal-save').onclick = async () => {
const name = document.getElementById('local-cal-name').value.trim(); const name = document.getElementById('local-cal-name').value.trim();
if (!name) { showToast('Bitte Name eingeben', true); return; } if (!name) { showToast(t('error_enter_name'), true); return; }
const color = hex.value; const color = hex.value;
try { try {
const cal = await api.post('/local/calendars', { name, color }); const cal = await api.post('/local/calendars', { name, color });
state.localCalendars.push(cal); state.localCalendars.push(cal);
renderCalendarList(); renderCalendarList();
closeModal('modal-local-cal'); closeModal('modal-local-cal');
showToast(`Kalender "${name}" erstellt`); showToast(t("calendar_created", {name}));
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
}; };
} }
@@ -1017,7 +1019,7 @@ function bindICalSubModal() {
state.icalSubscriptions.push(sub); state.icalSubscriptions.push(sub);
renderCalendarList(); renderCalendarList();
closeModal('modal-ical-sub'); closeModal('modal-ical-sub');
showToast(`"${name}" abonniert`); showToast(t("ical_subscribed", {name}));
fetchAndRender(); fetchAndRender();
} catch (e) { } catch (e) {
errEl.textContent = e.message; errEl.textContent = e.message;
@@ -1044,6 +1046,7 @@ function openSettingsModal() {
document.getElementById(id + '-preview').style.background = val; document.getElementById(id + '-preview').style.background = val;
}); });
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
document.getElementById('cfg-language').value = s.language || 'de';
// Set active contrast/hour-height buttons // Set active contrast/hour-height buttons
[ [
@@ -1084,15 +1087,15 @@ function renderGoogleAccounts() {
const list = document.getElementById('google-accounts-list'); const list = document.getElementById('google-accounts-list');
if (!list) return; if (!list) return;
if (!state.googleAccounts.length) { if (!state.googleAccounts.length) {
list.innerHTML = '<span style="font-size:13px;color:var(--text-3)">Keine Google-Konten verbunden</span>'; list.innerHTML = `<span style="font-size:13px;color:var(--text-3)">${t('settings_no_google')}</span>`;
return; return;
} }
list.innerHTML = state.googleAccounts.map(acc => list.innerHTML = state.googleAccounts.map(acc =>
`<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0"> `<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0">
<span style="font-size:13px">${escHtml(acc.email)}</span> <span style="font-size:13px">${escHtml(acc.email)}</span>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<button class="btn btn-secondary btn-sm" data-sync-acc="${acc.id}">Sync</button> <button class="btn btn-secondary btn-sm" data-sync-acc="${acc.id}">${t('sync')}</button>
<button class="btn btn-ghost btn-sm" data-disconnect-acc="${acc.id}">Trennen</button> <button class="btn btn-ghost btn-sm" data-disconnect-acc="${acc.id}">${t('disconnect')}</button>
</div> </div>
</div>` </div>`
).join(''); ).join('');
@@ -1107,20 +1110,20 @@ function renderGoogleAccounts() {
renderGoogleAccounts(); renderGoogleAccounts();
renderCalendarList(); renderCalendarList();
fetchAndRender(); fetchAndRender();
showToast('Kalender synchronisiert'); showToast(t('google_synced'));
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
}); });
}); });
list.querySelectorAll('[data-disconnect-acc]').forEach(btn => { list.querySelectorAll('[data-disconnect-acc]').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
if (!confirm('Google-Konto wirklich trennen?')) return; if (!confirm(t('confirm_google_disconnect'))) return;
try { try {
await api.delete(`/google/accounts/${btn.dataset.disconnectAcc}`); await api.delete(`/google/accounts/${btn.dataset.disconnectAcc}`);
state.googleAccounts = state.googleAccounts.filter(a => a.id !== parseInt(btn.dataset.disconnectAcc)); state.googleAccounts = state.googleAccounts.filter(a => a.id !== parseInt(btn.dataset.disconnectAcc));
renderGoogleAccounts(); renderGoogleAccounts();
renderCalendarList(); renderCalendarList();
fetchAndRender(); fetchAndRender();
showToast('Google-Konto getrennt'); showToast(t('google_disconnected'));
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
}); });
}); });
@@ -1140,13 +1143,13 @@ function renderHiddenCalendars() {
} }
} }
if (!hidden.length) { if (!hidden.length) {
list.innerHTML = '<span style="font-size:13px;color:var(--text-3)">Keine ausgeblendeten Kalender</span>'; list.innerHTML = `<span style="font-size:13px;color:var(--text-3)">${t('settings_no_hidden_cals')}</span>`;
return; return;
} }
list.innerHTML = hidden.map(c => list.innerHTML = hidden.map(c =>
`<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0"> `<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0">
<span style="font-size:13px">${escHtml(c.acc)} / ${escHtml(c.name)}</span> <span style="font-size:13px">${escHtml(c.acc)} / ${escHtml(c.name)}</span>
<button class="btn btn-secondary btn-sm" data-restore-cal="${c.id}" data-restore-source="${c.source}">Einblenden</button> <button class="btn btn-secondary btn-sm" data-restore-cal="${c.id}" data-restore-source="${c.source}">${t('show_cal')}</button>
</div>` </div>`
).join(''); ).join('');
list.querySelectorAll('[data-restore-cal]').forEach(btn => { list.querySelectorAll('[data-restore-cal]').forEach(btn => {
@@ -1196,7 +1199,7 @@ async function loadUsers() {
list.querySelectorAll('[data-del-user]').forEach(btn => { list.querySelectorAll('[data-del-user]').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
if (!confirm('Benutzer löschen?')) return; if (!confirm(t('confirm_delete_user'))) return;
try { try {
await api.delete(`/users/${btn.dataset.delUser}`); await api.delete(`/users/${btn.dataset.delUser}`);
loadUsers(); loadUsers();
@@ -1244,10 +1247,10 @@ function bindSettingsModal() {
const username = document.getElementById('new-username').value.trim(); const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value; const password = document.getElementById('new-password').value;
const is_admin = document.getElementById('new-is-admin').checked; const is_admin = document.getElementById('new-is-admin').checked;
if (!username || !password) { showToast('Benutzername und Passwort erforderlich', true); return; } if (!username || !password) { showToast(t('error_username_password'), true); return; }
try { try {
await api.post('/users/', { username, password, is_admin }); await api.post('/users/', { username, password, is_admin });
showToast(`Benutzer "${username}" erstellt`); showToast(t("user_created", {name: username}));
document.getElementById('add-user-form').classList.add('hidden'); document.getElementById('add-user-form').classList.add('hidden');
document.getElementById('new-username').value = ''; document.getElementById('new-username').value = '';
document.getElementById('new-password').value = ''; document.getElementById('new-password').value = '';
@@ -1270,14 +1273,16 @@ function bindSettingsModal() {
text_contrast: getActive('cfg-text-contrast') || 3, text_contrast: getActive('cfg-text-contrast') || 3,
line_contrast: getActive('cfg-line-contrast') || 3, line_contrast: getActive('cfg-line-contrast') || 3,
hour_height: getActive('cfg-hour-height') || 60, hour_height: getActive('cfg-hour-height') || 60,
language: document.getElementById('cfg-language').value,
}; };
try { try {
await api.put('/settings/', settings); await api.put('/settings/', settings);
state.settings = { ...state.settings, ...settings }; state.settings = { ...state.settings, ...settings };
state.dimPast = settings.dim_past_events; state.dimPast = settings.dim_past_events;
weekStartDay = settings.week_start_day; weekStartDay = settings.week_start_day;
setLang(settings.language);
applyTheme(state.settings); applyTheme(state.settings);
showToast('Einstellungen gespeichert'); showToast(t('settings_saved'));
closeModal('modal-settings'); closeModal('modal-settings');
renderMiniCal(); renderMiniCal();
fetchAndRender(); fetchAndRender();
@@ -1341,7 +1346,7 @@ export function openProfileModal() {
function renderProfileCalendars() { function renderProfileCalendars() {
const container = document.getElementById('profile-calendars'); const container = document.getElementById('profile-calendars');
if (!state.accounts.length) { if (!state.accounts.length) {
container.innerHTML = '<p class="text-muted">Keine CalDAV-Konten verbunden.</p>'; container.innerHTML = `<p class="text-muted">${t('no_caldav')}</p>`;
return; return;
} }
const html = state.accounts.map(acc => const html = state.accounts.map(acc =>
@@ -1364,7 +1369,7 @@ function bindProfileModal() {
const email = document.getElementById('profile-email').value.trim(); const email = document.getElementById('profile-email').value.trim();
try { try {
await api.put('/profile/', { email: email || null }); await api.put('/profile/', { email: email || null });
showToast('Profil gespeichert'); showToast(t('profile_saved'));
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
}; };
@@ -1383,7 +1388,7 @@ function bindProfileModal() {
document.getElementById('profile-avatar-remove').onclick = async () => { document.getElementById('profile-avatar-remove').onclick = async () => {
try { try {
await api.delete('/profile/avatar'); await api.delete('/profile/avatar');
showToast('Profilbild entfernt'); showToast(t('avatar_removed'));
document.getElementById('profile-avatar-img').classList.add('hidden'); document.getElementById('profile-avatar-img').classList.add('hidden');
document.getElementById('profile-avatar-letter').classList.remove('hidden'); document.getElementById('profile-avatar-letter').classList.remove('hidden');
document.getElementById('profile-avatar-remove').classList.add('hidden'); document.getElementById('profile-avatar-remove').classList.add('hidden');
@@ -1396,12 +1401,12 @@ function bindProfileModal() {
const current = document.getElementById('profile-pw-current').value; const current = document.getElementById('profile-pw-current').value;
const newPw = document.getElementById('profile-pw-new').value; const newPw = document.getElementById('profile-pw-new').value;
const confirm = document.getElementById('profile-pw-confirm').value; const confirm = document.getElementById('profile-pw-confirm').value;
if (!current || !newPw) { showToast('Bitte alle Felder ausfüllen', true); return; } if (!current || !newPw) { showToast(t('error_fill_all'), true); return; }
if (newPw !== confirm) { showToast('Passwörter stimmen nicht überein', true); return; } if (newPw !== confirm) { showToast(t('error_pw_mismatch'), true); return; }
if (newPw.length < 6) { showToast('Mindestens 6 Zeichen', true); return; } if (newPw.length < 6) { showToast(t('error_pw_length'), true); return; }
try { try {
await api.post('/profile/password', { current_password: current, new_password: newPw }); await api.post('/profile/password', { current_password: current, new_password: newPw });
showToast('Passwort geändert'); showToast(t('password_changed'));
document.getElementById('profile-pw-current').value = ''; document.getElementById('profile-pw-current').value = '';
document.getElementById('profile-pw-new').value = ''; document.getElementById('profile-pw-new').value = '';
document.getElementById('profile-pw-confirm').value = ''; document.getElementById('profile-pw-confirm').value = '';
@@ -1421,15 +1426,15 @@ function bindProfileModal() {
document.getElementById('2fa-copy-secret').onclick = () => { document.getElementById('2fa-copy-secret').onclick = () => {
const secret = document.getElementById('2fa-secret-code').textContent; const secret = document.getElementById('2fa-secret-code').textContent;
navigator.clipboard.writeText(secret).then(() => showToast('Schlüssel kopiert')); navigator.clipboard.writeText(secret).then(() => showToast(t('key_copied')));
}; };
document.getElementById('2fa-enable-btn').onclick = async () => { document.getElementById('2fa-enable-btn').onclick = async () => {
const code = document.getElementById('2fa-verify-code').value.trim(); const code = document.getElementById('2fa-verify-code').value.trim();
if (!code || code.length !== 6) { showToast('Bitte 6-stelligen Code eingeben', true); return; } if (!code || code.length !== 6) { showToast(t('error_enter_6digit'), true); return; }
try { try {
await api.post('/profile/2fa/enable', { code }); await api.post('/profile/2fa/enable', { code });
showToast('2FA aktiviert'); showToast(t('totp_enabled'));
document.getElementById('2fa-setup-section').classList.add('hidden'); document.getElementById('2fa-setup-section').classList.add('hidden');
document.getElementById('2fa-enabled-section').classList.remove('hidden'); document.getElementById('2fa-enabled-section').classList.remove('hidden');
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
@@ -1442,10 +1447,10 @@ function bindProfileModal() {
document.getElementById('2fa-disable-btn').onclick = async () => { document.getElementById('2fa-disable-btn').onclick = async () => {
const pw = document.getElementById('2fa-disable-pw').value; const pw = document.getElementById('2fa-disable-pw').value;
if (!pw) { showToast('Bitte Passwort eingeben', true); return; } if (!pw) { showToast(t('error_enter_password'), true); return; }
try { try {
await api.post('/profile/2fa/disable', { password: pw }); await api.post('/profile/2fa/disable', { password: pw });
showToast('2FA deaktiviert'); showToast(t('totp_disabled'));
document.getElementById('2fa-enabled-section').classList.add('hidden'); document.getElementById('2fa-enabled-section').classList.add('hidden');
document.getElementById('2fa-disabled-section').classList.remove('hidden'); document.getElementById('2fa-disabled-section').classList.remove('hidden');
document.getElementById('2fa-disable-pw').value = ''; document.getElementById('2fa-disable-pw').value = '';
@@ -1557,7 +1562,7 @@ document.getElementById('crop-save').onclick = async () => {
form.append('file', blob, 'avatar.jpg'); form.append('file', blob, 'avatar.jpg');
try { try {
await api.upload('/profile/avatar', form); await api.upload('/profile/avatar', form);
showToast('Profilbild hochgeladen'); showToast(t('avatar_uploaded'));
// Update profile modal avatar // Update profile modal avatar
const img = document.getElementById('profile-avatar-img'); const img = document.getElementById('profile-avatar-img');
fetchAvatarBlob().then(blobUrl => { fetchAvatarBlob().then(blobUrl => {

View File

@@ -3,6 +3,8 @@
Returns hex string or null if cancelled. Returns hex string or null if cancelled.
────────────────────────────────────────────────────────── */ ────────────────────────────────────────────────────────── */
import { t } from './i18n.js';
// ── HSV ↔ RGB helpers ───────────────────────────────────── // ── HSV ↔ RGB helpers ─────────────────────────────────────
function hsvToRgb(h, s, v) { function hsvToRgb(h, s, v) {
h = h / 360 * 6; h = h / 360 * 6;
@@ -80,7 +82,7 @@ export function openColorPicker(anchorEl, currentColor = '#4285f4') {
<div class="gcp-preview"></div> <div class="gcp-preview"></div>
<input class="gcp-hex" type="text" maxlength="7" spellcheck="false" /> <input class="gcp-hex" type="text" maxlength="7" spellcheck="false" />
</div> </div>
<button class="gcp-select">Auswählen</button> <button class="gcp-select">${t('color_select')}</button>
`; `;
const svCanvas = picker.querySelector('.gcp-sv'); const svCanvas = picker.querySelector('.gcp-sv');

395
frontend/js/i18n.js Normal file
View File

@@ -0,0 +1,395 @@
const translations = {
de: {
// Common
save: 'Speichern', cancel: 'Abbrechen', close: 'Schliessen',
create: 'Erstellen', delete: 'Löschen', edit: 'Bearbeiten',
name: 'Name', username: 'Benutzername', password: 'Passwort',
email: 'E-Mail', color: 'Farbe', color_pick: 'Farbe wählen',
loading: 'Lade…', connecting: 'Verbinde…',
unknown_error: 'Unbekannter Fehler',
// Setup
setup_title: 'Ersteinrichtung',
setup_subtitle: 'Erstelle den Administrator-Account',
setup_email: 'E-Mail (optional)',
setup_pw_confirm: 'Passwort wiederholen',
setup_submit: 'Admin-Account erstellen',
setup_pw_mismatch: 'Passwörter stimmen nicht überein',
setup_pw_short: 'Passwort muss mindestens 6 Zeichen haben',
// Login
login_tagline: 'das minimalistische Kalender Frontend',
login_submit: 'Anmelden',
login_2fa_ph: '6-stelliger Code',
// Topbar
btn_today: 'Heute',
view_month: 'Monat', view_week: 'Woche', view_day: 'Tag', view_agenda: 'Termine',
// Sidebar
btn_create: 'Erstellen',
my_calendars: 'Meine Kalender',
add_calendar: 'Kalender hinzufügen',
cal_local: 'Lokaler Kalender', cal_caldav: 'CalDAV-Konto',
cal_ical: 'iCal-URL abonnieren', cal_google: 'Google Kalender',
// Event modal
event_create_title: 'Termin erstellen', event_edit_title: 'Termin bearbeiten',
event_title_ph: 'Titel hinzufügen', event_allday: 'Ganztägig',
event_start: 'Start', event_end: 'Ende', event_calendar: 'Kalender',
event_location: 'Ort', event_location_ph: 'Ort hinzufügen',
event_description: 'Beschreibung', event_description_ph: 'Beschreibung hinzufügen',
// CalDAV modal
caldav_add_title: 'CalDAV-Konto hinzufügen',
caldav_display_name: 'Anzeigename', caldav_display_name_ph: 'z.B. Mein Nextcloud',
caldav_url: 'CalDAV-URL', caldav_connect: 'Verbinden',
// Local calendar modal
localcal_create_title: 'Lokalen Kalender erstellen',
localcal_name_ph: 'z.B. Persönlich',
// iCal modal
ical_title: 'iCal-URL abonnieren', ical_name_ph: 'z.B. Feiertage',
ical_url_label: 'iCal-URL', ical_refresh: 'Aktualisierung',
ical_15min: 'Alle 15 Minuten', ical_30min: 'Alle 30 Minuten',
ical_hourly: 'Stündlich', ical_6h: 'Alle 6 Stunden', ical_daily: 'Täglich',
ical_subscribe: 'Abonnieren',
// Settings
settings_title: 'Einstellungen',
settings_nav_appearance: 'Darstellung',
settings_nav_google: 'Google Konten',
settings_nav_users: 'Benutzerverwaltung',
settings_colors: 'Farben',
settings_primary_color: 'Primärfarbe', settings_accent_color: 'Akzentfarbe',
settings_today_color: 'Heutige-Tag-Farbe',
settings_text_contrast: 'Schriftkontrast',
settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte',
contrast_dark: 'Dunkel', contrast_medium: 'Mittel',
contrast_light: 'Hell', contrast_max: 'Maximum',
settings_line_contrast: 'Linienkontrast',
settings_line_contrast_desc: 'Sichtbarkeit von Trennlinien und Rahmen',
line_barely: 'Kaum', line_subtle: 'Subtil',
line_normal: 'Normal', line_strong: 'Stark',
settings_calendar_view: 'Kalenderansicht',
settings_default_view: 'Standardansicht',
settings_week_start: 'Erster Wochentag',
week_start_monday: 'Montag', week_start_sunday: 'Sonntag',
settings_dim_past: 'Vergangene Termine ausgrauen',
settings_hour_height: 'Stundenhöhe (Wochen- & Tagesansicht)',
settings_hour_height_desc: 'Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt',
hour_compact: 'Kompakt', hour_normal: 'Normal',
hour_comfort: 'Komfort', hour_large: 'Gross',
settings_language: 'Sprache',
settings_hidden_cals: 'Ausgeblendete Kalender',
settings_no_hidden_cals: 'Keine ausgeblendeten Kalender',
settings_no_google: 'Keine Google-Konten verbunden',
// User management
users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator',
// Profile
profile_title: 'Profil', profile_upload: 'Bild hochladen',
profile_remove_avatar: 'Entfernen', profile_account: 'Konto',
profile_email_ph: 'Keine E-Mail hinterlegt',
profile_change_pw: 'Passwort ändern',
profile_pw_current: 'Aktuelles Passwort', profile_pw_new: 'Neues Passwort',
profile_pw_confirm: 'Neues Passwort wiederholen',
profile_2fa: 'Zwei-Faktor-Authentifizierung',
profile_2fa_disabled_text: '2FA ist deaktiviert. Schütze dein Konto mit einem Authenticator.',
profile_2fa_setup: '2FA einrichten',
profile_2fa_scan: 'Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).',
profile_2fa_manual: 'Oder gib diesen Schlüssel manuell ein',
profile_2fa_copy: 'Kopieren', profile_2fa_code_label: 'Bestätigungscode eingeben',
profile_2fa_ph: '6-stelliger Code', profile_2fa_activate: 'Aktivieren',
profile_2fa_enabled_text: '2FA ist aktiviert.',
profile_2fa_pw_disable: 'Passwort zum Deaktivieren',
profile_2fa_disable: '2FA deaktivieren',
profile_my_calendars: 'Meine Kalender',
// Avatar crop
crop_title: 'Bildausschnitt wählen', crop_submit: 'Zuschneiden & Hochladen',
// Impressum
impressum_title: 'Impressum',
impressum_desc: 'Software & Webentwicklung',
impressum_about: 'Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten\u00a0© 2026 Scarriffleservices.',
impressum_data_title: 'Datenspeicherung',
impressum_data_text: 'Alle Anwendungsdaten werden ausschliesslich in der Schweiz gespeichert und verarbeitet. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API abgerufen, welche von Google auf deren Infrastruktur ausserhalb der Schweiz verarbeitet werden. Für diese Daten gelten die Datenschutzbestimmungen von Google.',
impressum_disclaimer_title: 'Haftungsausschluss',
impressum_disclaimer_text: 'Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.',
impressum_contact: 'Kontakt',
// Toast / confirms
error_load_events: 'Fehler beim Laden der Termine',
error_no_calendars: 'Keine Kalender',
confirm_delete_local_cal: 'Lokalen Kalender wirklich löschen?',
confirm_remove_ical: 'Abonnement wirklich entfernen?',
google_not_configured: 'Google OAuth ist nicht konfiguriert (Admin muss GOOGLE_CLIENT_ID/SECRET setzen)',
error_enter_title: 'Bitte Titel eingeben',
error_enter_date: 'Bitte Datum eingeben',
error_enter_start: 'Bitte Start-Zeit eingeben',
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
confirm_delete_event: '"{title}" wirklich löschen?',
event_deleted: 'Termin gelöscht',
error_fill_all: 'Bitte alle Felder ausfüllen',
account_added: 'Konto "{name}" hinzugefügt',
error_enter_name: 'Bitte Name eingeben',
calendar_created: 'Kalender "{name}" erstellt',
error_name_url: 'Bitte Name und URL eingeben',
ical_subscribed: '"{name}" abonniert',
google_synced: 'Kalender synchronisiert',
confirm_google_disconnect: 'Google-Konto wirklich trennen?',
google_disconnected: 'Google-Konto getrennt',
error_username_password: 'Benutzername und Passwort erforderlich',
user_created: 'Benutzer "{name}" erstellt',
settings_saved: 'Einstellungen gespeichert',
no_caldav: 'Keine CalDAV-Konten verbunden.',
profile_saved: 'Profil gespeichert',
avatar_removed: 'Profilbild entfernt',
avatar_uploaded: 'Profilbild hochgeladen',
error_pw_mismatch: 'Passwörter stimmen nicht überein',
error_pw_length: 'Mindestens 6 Zeichen',
password_changed: 'Passwort geändert',
key_copied: 'Schlüssel kopiert',
error_enter_6digit: 'Bitte 6-stelligen Code eingeben',
totp_enabled: '2FA aktiviert', totp_disabled: '2FA deaktiviert',
error_enter_password: 'Bitte Passwort eingeben',
confirm_delete_user: 'Benutzer löschen?',
show_cal: 'Einblenden', hide_cal: 'Ausblenden',
remove_cal: 'Kalender entfernen', remove_ical_sub: 'Abo entfernen',
change_color: 'Farbe ändern',
sync: 'Sync', disconnect: 'Trennen',
// Calendar / date
months: ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'],
months_short: ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'],
days_long: ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'],
dow_monday: ['Mo','Di','Mi','Do','Fr','Sa','So'],
dow_sunday: ['So','Mo','Di','Mi','Do','Fr','Sa'],
dow_index: ['So','Mo','Di','Mi','Do','Fr','Sa'],
week_abbr: 'KW',
allday: 'ganztägig', allday_cap: 'Ganztägig',
no_events: 'Keine Termine im angezeigten Zeitraum',
more_events: '+{n} weitere',
// Color picker
color_select: 'Auswählen',
// Locale for time formatting
locale: 'de',
},
en: {
// Common
save: 'Save', cancel: 'Cancel', close: 'Close',
create: 'Create', delete: 'Delete', edit: 'Edit',
name: 'Name', username: 'Username', password: 'Password',
email: 'Email', color: 'Color', color_pick: 'Choose color',
loading: 'Loading…', connecting: 'Connecting…',
unknown_error: 'Unknown error',
// Setup
setup_title: 'Initial Setup',
setup_subtitle: 'Create the administrator account',
setup_email: 'Email (optional)',
setup_pw_confirm: 'Confirm password',
setup_submit: 'Create admin account',
setup_pw_mismatch: 'Passwords do not match',
setup_pw_short: 'Password must be at least 6 characters',
// Login
login_tagline: 'the minimalist calendar frontend',
login_submit: 'Sign in',
login_2fa_ph: '6-digit code',
// Topbar
btn_today: 'Today',
view_month: 'Month', view_week: 'Week', view_day: 'Day', view_agenda: 'Events',
// Sidebar
btn_create: 'Create',
my_calendars: 'My Calendars',
add_calendar: 'Add calendar',
cal_local: 'Local calendar', cal_caldav: 'CalDAV account',
cal_ical: 'Subscribe to iCal URL', cal_google: 'Google Calendar',
// Event modal
event_create_title: 'Create event', event_edit_title: 'Edit event',
event_title_ph: 'Add title', event_allday: 'All day',
event_start: 'Start', event_end: 'End', event_calendar: 'Calendar',
event_location: 'Location', event_location_ph: 'Add location',
event_description: 'Description', event_description_ph: 'Add description',
// CalDAV modal
caldav_add_title: 'Add CalDAV account',
caldav_display_name: 'Display name', caldav_display_name_ph: 'e.g. My Nextcloud',
caldav_url: 'CalDAV URL', caldav_connect: 'Connect',
// Local calendar modal
localcal_create_title: 'Create local calendar',
localcal_name_ph: 'e.g. Personal',
// iCal modal
ical_title: 'Subscribe to iCal URL', ical_name_ph: 'e.g. Holidays',
ical_url_label: 'iCal URL', ical_refresh: 'Refresh interval',
ical_15min: 'Every 15 minutes', ical_30min: 'Every 30 minutes',
ical_hourly: 'Every hour', ical_6h: 'Every 6 hours', ical_daily: 'Daily',
ical_subscribe: 'Subscribe',
// Settings
settings_title: 'Settings',
settings_nav_appearance: 'Appearance',
settings_nav_google: 'Google Accounts',
settings_nav_users: 'User Management',
settings_colors: 'Colors',
settings_primary_color: 'Primary color', settings_accent_color: 'Accent color',
settings_today_color: 'Today highlight color',
settings_text_contrast: 'Text contrast',
settings_text_contrast_desc: 'Brightness of labels and text',
contrast_dark: 'Dark', contrast_medium: 'Medium',
contrast_light: 'Light', contrast_max: 'Maximum',
settings_line_contrast: 'Line contrast',
settings_line_contrast_desc: 'Visibility of borders and dividers',
line_barely: 'Barely', line_subtle: 'Subtle',
line_normal: 'Normal', line_strong: 'Strong',
settings_calendar_view: 'Calendar view',
settings_default_view: 'Default view',
settings_week_start: 'First day of week',
week_start_monday: 'Monday', week_start_sunday: 'Sunday',
settings_dim_past: 'Dim past events',
settings_hour_height: 'Hour height (week & day view)',
settings_hour_height_desc: 'How much space one hour takes in the time grid',
hour_compact: 'Compact', hour_normal: 'Normal',
hour_comfort: 'Comfort', hour_large: 'Large',
settings_language: 'Language',
settings_hidden_cals: 'Hidden calendars',
settings_no_hidden_cals: 'No hidden calendars',
settings_no_google: 'No Google accounts connected',
// User management
users_add: 'Add user', users_is_admin: 'Administrator',
// Profile
profile_title: 'Profile', profile_upload: 'Upload image',
profile_remove_avatar: 'Remove', profile_account: 'Account',
profile_email_ph: 'No email set',
profile_change_pw: 'Change password',
profile_pw_current: 'Current password', profile_pw_new: 'New password',
profile_pw_confirm: 'Confirm new password',
profile_2fa: 'Two-Factor Authentication',
profile_2fa_disabled_text: '2FA is disabled. Protect your account with an authenticator.',
profile_2fa_setup: 'Set up 2FA',
profile_2fa_scan: 'Scan the QR code with your authenticator app (e.g. Bitwarden, Google Authenticator).',
profile_2fa_manual: 'Or enter this key manually',
profile_2fa_copy: 'Copy', profile_2fa_code_label: 'Enter verification code',
profile_2fa_ph: '6-digit code', profile_2fa_activate: 'Activate',
profile_2fa_enabled_text: '2FA is enabled.',
profile_2fa_pw_disable: 'Password to disable',
profile_2fa_disable: 'Disable 2FA',
profile_my_calendars: 'My Calendars',
// Avatar crop
crop_title: 'Crop image', crop_submit: 'Crop & Upload',
// Impressum
impressum_title: 'Legal Notice',
impressum_desc: 'Software & Web Development',
impressum_about: 'This software was developed and provided by Scarriffleservices with the utmost care. All rights reserved\u00a0© 2026 Scarriffleservices.',
impressum_data_title: 'Data Storage',
impressum_data_text: 'All application data is stored and processed exclusively in Switzerland. When using the Google Calendar integration, data is retrieved via the Google API, which Google processes on their infrastructure outside Switzerland. Google\'s privacy policy applies to this data.',
impressum_disclaimer_title: 'Disclaimer',
impressum_disclaimer_text: 'Despite careful preparation, no liability is assumed for the accuracy, completeness or timeliness of the content provided. Use is at your own risk.',
impressum_contact: 'Contact',
// Toast / confirms
error_load_events: 'Error loading events',
error_no_calendars: 'No calendars',
confirm_delete_local_cal: 'Really delete local calendar?',
confirm_remove_ical: 'Really remove subscription?',
google_not_configured: 'Google OAuth is not configured (admin must set GOOGLE_CLIENT_ID/SECRET)',
error_enter_title: 'Please enter a title',
error_enter_date: 'Please enter a date',
error_enter_start: 'Please enter a start time',
event_updated: 'Event updated', event_created: 'Event created',
confirm_delete_event: 'Really delete "{title}"?',
event_deleted: 'Event deleted',
error_fill_all: 'Please fill in all fields',
account_added: 'Account "{name}" added',
error_enter_name: 'Please enter a name',
calendar_created: 'Calendar "{name}" created',
error_name_url: 'Please enter a name and URL',
ical_subscribed: '"{name}" subscribed',
google_synced: 'Calendar synced',
confirm_google_disconnect: 'Really disconnect Google account?',
google_disconnected: 'Google account disconnected',
error_username_password: 'Username and password required',
user_created: 'User "{name}" created',
settings_saved: 'Settings saved',
no_caldav: 'No CalDAV accounts connected.',
profile_saved: 'Profile saved',
avatar_removed: 'Profile picture removed',
avatar_uploaded: 'Profile picture uploaded',
error_pw_mismatch: 'Passwords do not match',
error_pw_length: 'At least 6 characters required',
password_changed: 'Password changed',
key_copied: 'Key copied',
error_enter_6digit: 'Please enter a 6-digit code',
totp_enabled: '2FA enabled', totp_disabled: '2FA disabled',
error_enter_password: 'Please enter your password',
confirm_delete_user: 'Delete user?',
show_cal: 'Show', hide_cal: 'Hide',
remove_cal: 'Remove calendar', remove_ical_sub: 'Remove subscription',
change_color: 'Change color',
sync: 'Sync', disconnect: 'Disconnect',
// Calendar / date
months: ['January','February','March','April','May','June','July','August','September','October','November','December'],
months_short: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
days_long: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'],
dow_monday: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'],
dow_sunday: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'],
dow_index: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'],
week_abbr: 'CW',
allday: 'all day', allday_cap: 'All day',
no_events: 'No events in the displayed period',
more_events: '+{n} more',
// Color picker
color_select: 'Select',
// Locale for time formatting
locale: 'en',
},
};
let currentLang = 'de';
export function getLang() { return currentLang; }
export function setLang(lang) {
currentLang = (lang && translations[lang]) ? lang : 'de';
document.documentElement.lang = currentLang;
applyLang();
}
export function t(key, vars = {}) {
const dict = translations[currentLang] ?? translations.de;
const val = dict[key] ?? translations.de[key] ?? key;
if (typeof val !== 'string') return val;
return val.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? '');
}
export function applyLang() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const v = t(el.dataset.i18n);
if (typeof v === 'string') el.textContent = v;
});
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
el.placeholder = t(el.dataset.i18nPh);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset.i18nTitle);
});
}

View File

@@ -1,11 +1,9 @@
import { isPast } from '../utils.js'; import { isPast } from '../utils.js';
import { t, getLang } from '../i18n.js';
const DOW = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
const MON = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
export function renderAgenda(container, currentDate, events, onEventClick) { export function renderAgenda(container, currentDate, events, onEventClick) {
if (!events.length) { if (!events.length) {
container.innerHTML = `<div class="agenda-view"><div class="agenda-empty">Keine Termine im angezeigten Zeitraum</div></div>`; container.innerHTML = `<div class="agenda-view"><div class="agenda-empty">${t('no_events')}</div></div>`;
return; return;
} }
@@ -35,7 +33,7 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
.map(ev => { .map(ev => {
const color = ev.color || ev.calendarColor || '#4285f4'; const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : ''; const pastCls = isPast(ev) ? 'past' : '';
let timeStr = 'Ganztägig'; let timeStr = t('allday_cap');
if (!ev.allDay) { if (!ev.allDay) {
const s = new Date(ev.start); const s = new Date(ev.start);
const e = new Date(ev.end); const e = new Date(ev.end);
@@ -57,8 +55,8 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
<div class="agenda-date ${todayCls}"> <div class="agenda-date ${todayCls}">
<div class="agenda-date-num">${date.getDate()}</div> <div class="agenda-date-num">${date.getDate()}</div>
<div class="agenda-date-label"> <div class="agenda-date-label">
<span class="wd">${DOW[date.getDay()]}</span> <span class="wd">${t('days_long')[date.getDay()]}</span>
<span class="mo">${MON[date.getMonth()]} ${date.getFullYear()}</span> <span class="mo">${t('months_short')[date.getMonth()]} ${date.getFullYear()}</span>
</div> </div>
</div> </div>
${evHtml} ${evHtml}
@@ -83,7 +81,7 @@ function isTodayDate(d) {
} }
function fmtTime(d) { function fmtTime(d) {
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' }); return d.toLocaleTimeString(getLang(), { hour: '2-digit', minute: '2-digit' });
} }
function escHtml(s) { function escHtml(s) {

View File

@@ -1,12 +1,10 @@
import { formatDate, isSameDay, isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js'; import { formatDate, isSameDay, isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
import { t } from '../i18n.js';
const DOW_MONDAY = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const DOW_SUNDAY = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
const month = currentDate.getMonth(); const month = currentDate.getMonth();
const DOW = weekStartDay === 'sunday' ? DOW_SUNDAY : DOW_MONDAY; const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
const firstDay = new Date(year, month, 1); const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0); const lastDay = new Date(year, month + 1, 0);
@@ -82,7 +80,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
}).join(''); }).join('');
const moreHtml = hiddenCount > 0 const moreHtml = hiddenCount > 0
? `<div class="month-more" data-date="${key}">+${hiddenCount} weitere</div>` ? `<div class="month-more" data-date="${key}">${t('more_events', {n: hiddenCount})}</div>`
: ''; : '';
cellsHtml += `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}"> cellsHtml += `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">

View File

@@ -1,6 +1,5 @@
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
import { t } from '../i18n.js';
const DOW_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) { export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) {
// Build the days array (7 days for week, 1 for day) // Build the days array (7 days for week, 1 for day)
@@ -23,14 +22,14 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
// ── KW Badge ────────────────────────────────────────── // ── KW Badge ──────────────────────────────────────────
const kwNum = getISOWeekNumber(days[0]); const kwNum = getISOWeekNumber(days[0]);
const kwBadge = !isSingleDay const kwBadge = !isSingleDay
? `<div class="week-kw-badge">KW ${kwNum}</div>` ? `<div class="week-kw-badge">${t('week_abbr')} ${kwNum}</div>`
: ''; : '';
// ── Header ──────────────────────────────────────────── // ── Header ────────────────────────────────────────────
const headerCols = days.map(day => { const headerCols = days.map(day => {
const todayCls = isToday(day) ? 'today' : ''; const todayCls = isToday(day) ? 'today' : '';
return `<div class="week-day-header ${todayCls}" data-date="${dayKey(day)}"> return `<div class="week-day-header ${todayCls}" data-date="${dayKey(day)}">
<div class="day-name">${DOW_SHORT[day.getDay()]}</div> <div class="day-name">${t('dow_index')[day.getDay()]}</div>
<div class="day-num">${day.getDate()}</div> <div class="day-num">${day.getDate()}</div>
</div>`; </div>`;
}).join(''); }).join('');
@@ -105,7 +104,7 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
${headerCols} ${headerCols}
</div> </div>
<div class="week-allday-row"> <div class="week-allday-row">
<div class="allday-gutter">ganztägig</div> <div class="allday-gutter">${t('allday')}</div>
<div class="allday-cols">${alldayCols}</div> <div class="allday-cols">${alldayCols}</div>
</div> </div>
<div class="week-body"> <div class="week-body">