- Benutzername
+ Benutzername
diff --git a/frontend/js/api.js b/frontend/js/api.js
index c470bad..38b4a1e 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -1,3 +1,4 @@
+import { t } from './i18n.js';
const BASE = '/api';
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) {
- 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}`);
}
@@ -49,7 +50,7 @@ async function uploadRequest(path, formData) {
return null;
}
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}`);
}
if (res.status === 204) return null;
diff --git a/frontend/js/app.js b/frontend/js/app.js
index c77d7e6..b99f093 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -1,5 +1,6 @@
import { api } from './api.js';
import { initCalendar, showToast, openProfileModal } from './calendar.js';
+import { t } from './i18n.js';
// ── Bootstrap ─────────────────────────────────────────────
async function boot() {
@@ -109,12 +110,12 @@ function bindSetupForm() {
errEl.classList.add('hidden');
if (pw1 !== pw2) {
- errEl.textContent = 'Passwörter stimmen nicht überein';
+ errEl.textContent = t('setup_pw_mismatch');
errEl.classList.remove('hidden');
return;
}
if (pw1.length < 6) {
- errEl.textContent = 'Passwort muss mindestens 6 Zeichen haben';
+ errEl.textContent = t('setup_pw_short');
errEl.classList.remove('hidden');
return;
}
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 0005531..d9ee375 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -4,6 +4,7 @@ import { renderMonth } from './views/month.js';
import { renderWeek } from './views/week.js';
import { renderAgenda } from './views/agenda.js';
import { openColorPicker } from './color-picker.js';
+import { t, setLang, applyLang } from './i18n.js';
// Fetch avatar image as blob URL (with auth header)
function fetchAvatarBlob() {
@@ -19,8 +20,7 @@ function fetchAvatarBlob() {
// week start day global (loaded from settings)
let weekStartDay = 'monday';
-const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
- 'Juli','August','September','Oktober','November','Dezember'];
+// month names come from i18n: t('months')
let state = {
currentDate: new Date(),
@@ -55,6 +55,7 @@ export async function initCalendar() {
state.dimPast = settings.dim_past_events;
weekStartDay = settings.week_start_day || 'monday';
+ setLang(settings.language || 'de');
applyTheme(settings);
updateViewButtons();
renderCalendarList();
@@ -78,7 +79,7 @@ async function fetchAndRender() {
const events = await api.get(`/caldav/events?start=${start.toISOString()}&end=${end.toISOString()}`);
state.events = events;
} catch (e) {
- showToast('Fehler beim Laden der Termine: ' + e.message, true);
+ showToast(t('error_load_events') + ': ' + e.message, true);
state.events = [];
}
renderView();
@@ -161,20 +162,21 @@ function showLoading() {
function updateTitle() {
const d = state.currentDate;
let title = '';
+ const M = t('months');
if (state.currentView === 'month') {
- title = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
+ title = `${M[d.getMonth()]} ${d.getFullYear()}`;
} else if (state.currentView === 'week') {
const mon = weekStart(d, weekStartDay);
const sun = new Date(mon);
sun.setDate(mon.getDate() + 6);
const sameMonth = mon.getMonth() === sun.getMonth();
title = sameMonth
- ? `${mon.getDate()}. – ${sun.getDate()}. ${MONTHS[sun.getMonth()]} ${sun.getFullYear()}`
- : `${mon.getDate()}. ${MONTHS[mon.getMonth()]} – ${sun.getDate()}. ${MONTHS[sun.getMonth()]} ${sun.getFullYear()}`;
+ ? `${mon.getDate()}. – ${sun.getDate()}. ${M[sun.getMonth()]} ${sun.getFullYear()}`
+ : `${mon.getDate()}. ${M[mon.getMonth()]} – ${sun.getDate()}. ${M[sun.getMonth()]} ${sun.getFullYear()}`;
} else if (state.currentView === 'day') {
- title = `${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
+ title = `${d.getDate()}. ${M[d.getMonth()]} ${d.getFullYear()}`;
} 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.title = `Calendarr - ${title}`;
@@ -191,7 +193,7 @@ function renderMiniCal() {
const d = state.currentDate;
const miniD = new Date(d.getFullYear(), d.getMonth(), 1);
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 gridStart = new Date(firstDay);
@@ -270,9 +272,9 @@ function renderCalendarList() {
visibleCals.map(cal =>
`
-
+
${escHtml(cal.name)}
-
+
`
@@ -282,13 +284,13 @@ function renderCalendarList() {
// ── Local calendars ────────────────────────────────────
if (state.localCalendars.length) {
- html += `
Lokale Kalender
`;
+ html += `
${t('cal_local')}
`;
html += state.localCalendars.map(cal =>
`
-
+
${escHtml(cal.name)}
-
+
`
@@ -297,13 +299,13 @@ function renderCalendarList() {
// ── iCal subscriptions ─────────────────────────────────
if (state.icalSubscriptions.length) {
- html += `
Abonnements
`;
+ html += `
${t('cal_ical')}
`;
html += state.icalSubscriptions.map(sub =>
`
-
+
${escHtml(sub.name)}
-
+
`
@@ -319,9 +321,9 @@ function renderCalendarList() {
visibleCals.map(cal =>
`
-
+
${escHtml(cal.name)}
-
+
`
@@ -330,7 +332,7 @@ function renderCalendarList() {
}
if (!html) {
- container.innerHTML = `
Keine Kalender
`;
+ container.innerHTML = `
${t('error_no_calendars')}
`;
return;
}
@@ -476,12 +478,12 @@ function renderCalendarList() {
}
}
} 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);
await api.delete(`/local/calendars/${calId}`);
state.localCalendars = state.localCalendars.filter(c => c.id !== calId);
} else if (source === 'ical') {
- if (!confirm('Abonnement wirklich entfernen?')) return;
+ if (!confirm(t('confirm_remove_ical'))) return;
const subId = parseInt(btn.dataset.subId);
await api.delete(`/ical/subscriptions/${subId}`);
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId);
@@ -576,13 +578,13 @@ function bindSidebar() {
try {
const { configured } = await api.get('/google/configured');
if (!configured) {
- showToast('Google OAuth ist nicht konfiguriert (Admin muss GOOGLE_CLIENT_ID/SECRET setzen)', true);
+ showToast(t('google_not_configured'), true);
return;
}
const { url } = await api.get('/google/auth-url');
window.location.href = url;
} catch (e) {
- showToast('Fehler: ' + e.message, true);
+ showToast(t('error_prefix') + e.message, true);
}
};
}
@@ -627,7 +629,7 @@ function showEventPopup(ev, anchor) {
openEditEventModal(ev);
};
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');
try {
if (ev.source === 'google') {
@@ -641,7 +643,7 @@ function showEventPopup(ev, anchor) {
} else {
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`);
}
- showToast('Termin gelöscht');
+ showToast(t('event_deleted'));
fetchAndRender();
} catch (e) { showToast(e.message, true); }
};
@@ -782,7 +784,7 @@ function bindEventModal() {
document.getElementById('ev-save').onclick = async () => {
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 calVal = document.getElementById('ev-calendar').value;
@@ -796,12 +798,12 @@ function bindEventModal() {
if (allDay) {
start = document.getElementById('ev-start-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;
} else {
const sv = document.getElementById('ev-start').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();
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 }
);
}
- showToast('Termin aktualisiert');
+ showToast(t('event_updated'));
} else if (isGoogle) {
const calDbId = parseInt(calVal.replace('google-', ''));
await api.post('/google/events', {
calendar_db_id: calDbId, title, start, end, allDay,
location: loc, description: desc,
});
- showToast('Termin erstellt');
+ showToast(t('event_created'));
} else if (isLocal) {
const calId = parseInt(calVal.replace('local-', ''));
await api.post('/local/events', {
calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null,
});
- showToast('Termin erstellt');
+ showToast(t('event_created'));
} else {
const calId = parseInt(calVal);
await api.post('/caldav/events', {
calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null,
});
- showToast('Termin erstellt');
+ showToast(t('event_created'));
}
closeModal('modal-event');
fetchAndRender();
@@ -862,7 +864,7 @@ function bindEventModal() {
document.getElementById('ev-delete').onclick = async () => {
const ev = state.editingEvent;
if (!ev) return;
- if (!confirm(`"${ev.title}" wirklich löschen?`)) return;
+ if (!confirm(t("confirm_delete_event", {title: ev.title}))) return;
try {
if (ev.source === 'google') {
const accId = ev.calendar_id.replace('google-', '');
@@ -875,7 +877,7 @@ function bindEventModal() {
} else {
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`);
}
- showToast('Termin gelöscht');
+ showToast(t('event_deleted'));
closeModal('modal-event');
fetchAndRender();
} catch (e) { showToast(e.message, true); }
@@ -929,7 +931,7 @@ function bindAccountModal() {
state.accounts.push(acc);
renderCalendarList();
closeModal('modal-account');
- showToast(`Konto "${name}" hinzugefügt`);
+ showToast(t("account_added", {name}));
fetchAndRender();
} catch (e) {
errEl.textContent = e.message;
@@ -964,14 +966,14 @@ function bindLocalCalModal() {
document.getElementById('local-cal-save').onclick = async () => {
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;
try {
const cal = await api.post('/local/calendars', { name, color });
state.localCalendars.push(cal);
renderCalendarList();
closeModal('modal-local-cal');
- showToast(`Kalender "${name}" erstellt`);
+ showToast(t("calendar_created", {name}));
} catch (e) { showToast(e.message, true); }
};
}
@@ -1017,7 +1019,7 @@ function bindICalSubModal() {
state.icalSubscriptions.push(sub);
renderCalendarList();
closeModal('modal-ical-sub');
- showToast(`"${name}" abonniert`);
+ showToast(t("ical_subscribed", {name}));
fetchAndRender();
} catch (e) {
errEl.textContent = e.message;
@@ -1044,6 +1046,7 @@ function openSettingsModal() {
document.getElementById(id + '-preview').style.background = val;
});
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
+ document.getElementById('cfg-language').value = s.language || 'de';
// Set active contrast/hour-height buttons
[
@@ -1084,15 +1087,15 @@ function renderGoogleAccounts() {
const list = document.getElementById('google-accounts-list');
if (!list) return;
if (!state.googleAccounts.length) {
- list.innerHTML = '
Keine Google-Konten verbunden ';
+ list.innerHTML = `
${t('settings_no_google')} `;
return;
}
list.innerHTML = state.googleAccounts.map(acc =>
`
${escHtml(acc.email)}
- Sync
- Trennen
+ ${t('sync')}
+ ${t('disconnect')}
`
).join('');
@@ -1107,20 +1110,20 @@ function renderGoogleAccounts() {
renderGoogleAccounts();
renderCalendarList();
fetchAndRender();
- showToast('Kalender synchronisiert');
+ showToast(t('google_synced'));
} catch (e) { showToast(e.message, true); }
});
});
list.querySelectorAll('[data-disconnect-acc]').forEach(btn => {
btn.addEventListener('click', async () => {
- if (!confirm('Google-Konto wirklich trennen?')) return;
+ if (!confirm(t('confirm_google_disconnect'))) return;
try {
await api.delete(`/google/accounts/${btn.dataset.disconnectAcc}`);
state.googleAccounts = state.googleAccounts.filter(a => a.id !== parseInt(btn.dataset.disconnectAcc));
renderGoogleAccounts();
renderCalendarList();
fetchAndRender();
- showToast('Google-Konto getrennt');
+ showToast(t('google_disconnected'));
} catch (e) { showToast(e.message, true); }
});
});
@@ -1140,13 +1143,13 @@ function renderHiddenCalendars() {
}
}
if (!hidden.length) {
- list.innerHTML = '
Keine ausgeblendeten Kalender ';
+ list.innerHTML = `
${t('settings_no_hidden_cals')} `;
return;
}
list.innerHTML = hidden.map(c =>
`
${escHtml(c.acc)} / ${escHtml(c.name)}
- Einblenden
+ ${t('show_cal')}
`
).join('');
list.querySelectorAll('[data-restore-cal]').forEach(btn => {
@@ -1196,7 +1199,7 @@ async function loadUsers() {
list.querySelectorAll('[data-del-user]').forEach(btn => {
btn.addEventListener('click', async () => {
- if (!confirm('Benutzer löschen?')) return;
+ if (!confirm(t('confirm_delete_user'))) return;
try {
await api.delete(`/users/${btn.dataset.delUser}`);
loadUsers();
@@ -1244,10 +1247,10 @@ function bindSettingsModal() {
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
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 {
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('new-username').value = '';
document.getElementById('new-password').value = '';
@@ -1270,14 +1273,16 @@ function bindSettingsModal() {
text_contrast: getActive('cfg-text-contrast') || 3,
line_contrast: getActive('cfg-line-contrast') || 3,
hour_height: getActive('cfg-hour-height') || 60,
+ language: document.getElementById('cfg-language').value,
};
try {
await api.put('/settings/', settings);
state.settings = { ...state.settings, ...settings };
state.dimPast = settings.dim_past_events;
weekStartDay = settings.week_start_day;
+ setLang(settings.language);
applyTheme(state.settings);
- showToast('Einstellungen gespeichert');
+ showToast(t('settings_saved'));
closeModal('modal-settings');
renderMiniCal();
fetchAndRender();
@@ -1341,7 +1346,7 @@ export function openProfileModal() {
function renderProfileCalendars() {
const container = document.getElementById('profile-calendars');
if (!state.accounts.length) {
- container.innerHTML = '
Keine CalDAV-Konten verbunden.
';
+ container.innerHTML = `
${t('no_caldav')}
`;
return;
}
const html = state.accounts.map(acc =>
@@ -1364,7 +1369,7 @@ function bindProfileModal() {
const email = document.getElementById('profile-email').value.trim();
try {
await api.put('/profile/', { email: email || null });
- showToast('Profil gespeichert');
+ showToast(t('profile_saved'));
} catch (e) { showToast(e.message, true); }
};
@@ -1383,7 +1388,7 @@ function bindProfileModal() {
document.getElementById('profile-avatar-remove').onclick = async () => {
try {
await api.delete('/profile/avatar');
- showToast('Profilbild entfernt');
+ showToast(t('avatar_removed'));
document.getElementById('profile-avatar-img').classList.add('hidden');
document.getElementById('profile-avatar-letter').classList.remove('hidden');
document.getElementById('profile-avatar-remove').classList.add('hidden');
@@ -1396,12 +1401,12 @@ function bindProfileModal() {
const current = document.getElementById('profile-pw-current').value;
const newPw = document.getElementById('profile-pw-new').value;
const confirm = document.getElementById('profile-pw-confirm').value;
- if (!current || !newPw) { showToast('Bitte alle Felder ausfüllen', true); return; }
- if (newPw !== confirm) { showToast('Passwörter stimmen nicht überein', true); return; }
- if (newPw.length < 6) { showToast('Mindestens 6 Zeichen', true); return; }
+ if (!current || !newPw) { showToast(t('error_fill_all'), true); return; }
+ if (newPw !== confirm) { showToast(t('error_pw_mismatch'), true); return; }
+ if (newPw.length < 6) { showToast(t('error_pw_length'), true); return; }
try {
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-new').value = '';
document.getElementById('profile-pw-confirm').value = '';
@@ -1421,15 +1426,15 @@ function bindProfileModal() {
document.getElementById('2fa-copy-secret').onclick = () => {
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 () => {
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 {
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-enabled-section').classList.remove('hidden');
} catch (e) { showToast(e.message, true); }
@@ -1442,10 +1447,10 @@ function bindProfileModal() {
document.getElementById('2fa-disable-btn').onclick = async () => {
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 {
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-disabled-section').classList.remove('hidden');
document.getElementById('2fa-disable-pw').value = '';
@@ -1557,7 +1562,7 @@ document.getElementById('crop-save').onclick = async () => {
form.append('file', blob, 'avatar.jpg');
try {
await api.upload('/profile/avatar', form);
- showToast('Profilbild hochgeladen');
+ showToast(t('avatar_uploaded'));
// Update profile modal avatar
const img = document.getElementById('profile-avatar-img');
fetchAvatarBlob().then(blobUrl => {
diff --git a/frontend/js/color-picker.js b/frontend/js/color-picker.js
index eb07c03..b41418c 100644
--- a/frontend/js/color-picker.js
+++ b/frontend/js/color-picker.js
@@ -3,6 +3,8 @@
Returns hex string or null if cancelled.
────────────────────────────────────────────────────────── */
+import { t } from './i18n.js';
+
// ── HSV ↔ RGB helpers ─────────────────────────────────────
function hsvToRgb(h, s, v) {
h = h / 360 * 6;
@@ -80,7 +82,7 @@ export function openColorPicker(anchorEl, currentColor = '#4285f4') {
-
Auswählen
+
${t('color_select')}
`;
const svCanvas = picker.querySelector('.gcp-sv');
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
new file mode 100644
index 0000000..94095af
--- /dev/null
+++ b/frontend/js/i18n.js
@@ -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);
+ });
+}
diff --git a/frontend/js/views/agenda.js b/frontend/js/views/agenda.js
index 240431e..45a33f4 100644
--- a/frontend/js/views/agenda.js
+++ b/frontend/js/views/agenda.js
@@ -1,11 +1,9 @@
import { isPast } from '../utils.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'];
+import { t, getLang } from '../i18n.js';
export function renderAgenda(container, currentDate, events, onEventClick) {
if (!events.length) {
- container.innerHTML = `
Keine Termine im angezeigten Zeitraum
`;
+ container.innerHTML = `
`;
return;
}
@@ -35,7 +33,7 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
.map(ev => {
const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : '';
- let timeStr = 'Ganztägig';
+ let timeStr = t('allday_cap');
if (!ev.allDay) {
const s = new Date(ev.start);
const e = new Date(ev.end);
@@ -57,8 +55,8 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
${date.getDate()}
- ${DOW[date.getDay()]}
- ${MON[date.getMonth()]} ${date.getFullYear()}
+ ${t('days_long')[date.getDay()]}
+ ${t('months_short')[date.getMonth()]} ${date.getFullYear()}
${evHtml}
@@ -83,7 +81,7 @@ function isTodayDate(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) {
diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js
index 13de6f3..1f87125 100644
--- a/frontend/js/views/month.js
+++ b/frontend/js/views/month.js
@@ -1,12 +1,10 @@
import { formatDate, isSameDay, isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
-
-const DOW_MONDAY = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
-const DOW_SUNDAY = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
+import { t } from '../i18n.js';
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
const year = currentDate.getFullYear();
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 lastDay = new Date(year, month + 1, 0);
@@ -82,7 +80,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
}).join('');
const moreHtml = hiddenCount > 0
- ? `
+${hiddenCount} weitere
`
+ ? `
${t('more_events', {n: hiddenCount})}
`
: '';
cellsHtml += `
diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js
index 716ba86..943f32d 100644
--- a/frontend/js/views/week.js
+++ b/frontend/js/views/week.js
@@ -1,6 +1,5 @@
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
-
-const DOW_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
+import { t } from '../i18n.js';
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)
@@ -23,14 +22,14 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
// ── KW Badge ──────────────────────────────────────────
const kwNum = getISOWeekNumber(days[0]);
const kwBadge = !isSingleDay
- ? `
KW ${kwNum}
`
+ ? `
${t('week_abbr')} ${kwNum}
`
: '';
// ── Header ────────────────────────────────────────────
const headerCols = days.map(day => {
const todayCls = isToday(day) ? 'today' : '';
return ``;
}).join('');
@@ -105,7 +104,7 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
${headerCols}
-
ganztägig
+
${t('allday')}
${alldayCols}