/**
* Modul: Einstellungen (Settings)
* Zweck: Benutzerkonto, Passwort, Kalender-Sync, Familienmitglieder
* Abhängigkeiten: /api.js
*/
import { api, auth } from '/api.js';
import { confirmModal } from '/components/modal.js';
import { t, formatDate, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js';
import '/components/oikos-locale-picker.js';
const SUPPORTED_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
const SETTINGS_TAB_KEY = 'oikos:settings:tab';
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
const DEFAULT_APP_NAME = 'Oikos';
const FAMILY_ROLES = ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'];
const CATEGORY_I18N = {
'Obst & Gemüse': 'shopping.catFruitVeg',
'Backwaren': 'shopping.catBakery',
'Milchprodukte': 'shopping.catDairy',
'Fleisch & Fisch': 'shopping.catMeatFish',
'Tiefkühl': 'shopping.catFrozen',
'Getränke': 'shopping.catDrinks',
'Haushalt': 'shopping.catHousehold',
'Drogerie': 'shopping.catDrugstore',
'Sonstiges': 'shopping.catMisc',
};
function catLabel(name) {
const key = CATEGORY_I18N[name];
return key ? t(key) : name;
}
function buildCurrencyOptions(selected) {
const display = typeof Intl.DisplayNames !== 'undefined'
? new Intl.DisplayNames([document.documentElement.lang || 'en'], { type: 'currency' })
: null;
return SUPPORTED_CURRENCIES
.map((code) => {
const label = display ? `${code} - ${display.of(code)}` : code;
const sel = code === selected ? ' selected' : '';
return ``;
})
.join('');
}
function familyRoleLabel(role) {
return t(`settings.familyRole${String(role || 'other').replace(/(^|_)([a-z])/g, (_, __, c) => c.toUpperCase())}`);
}
function buildFamilyRoleOptions(selected = 'other') {
return FAMILY_ROLES.map((role) => `
`).join('');
}
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
export async function render(container, { user }) {
// URL-Parameter auswerten (z.B. nach OAuth-Callback)
const params = new URLSearchParams(location.search);
const syncOk = params.get('sync_ok');
const syncErr = params.get('sync_error');
// State für Familienmitglieder + Sync-Status
let users = [];
let googleStatus = { configured: false, connected: false, lastSync: null };
let appleStatus = { configured: false, lastSync: null };
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', app_name: DEFAULT_APP_NAME };
let categories = [];
let icsSubscriptions = [];
let apiTokens = [];
try {
const [usersRes, gStatus, aStatus, prefsRes, catsRes, icsRes, apiTokensRes] = await Promise.allSettled([
user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }),
api.get('/calendar/google/status'),
api.get('/calendar/apple/status'),
api.get('/preferences'),
api.get('/shopping/categories'),
api.get('/calendar/subscriptions'),
user.role === 'admin' ? api.get('/auth/api-tokens') : Promise.resolve({ data: [] }),
]);
if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? [];
if (gStatus.status === 'fulfilled') googleStatus = gStatus.value;
if (aStatus.status === 'fulfilled') appleStatus = aStatus.value;
if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs;
if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? [];
if (icsRes.status === 'fulfilled') icsSubscriptions = icsRes.value.data ?? [];
if (apiTokensRes.status === 'fulfilled') apiTokens = apiTokensRes.value.data ?? [];
} catch (_) { /* non-critical */ }
if (prefs.date_format) {
try { localStorage.setItem('oikos-date-format', prefs.date_format); } catch (_) {}
}
if (prefs.app_name) {
try { localStorage.setItem(APP_NAME_STORAGE_KEY, prefs.app_name); } catch (_) {}
}
const googleStatusText = googleStatus.connected
? (googleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(googleStatus.lastSync) }) : t('settings.connected'))
: googleStatus.configured ? t('settings.notConnected') : t('settings.notConfigured');
const appleStatusText = appleStatus.connected
? (appleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(appleStatus.lastSync) }) : t('settings.connected'))
: appleStatus.configured
? (appleStatus.lastSync ? t('settings.configuredLastSync', { date: formatDateTime(appleStatus.lastSync) }) : t('settings.configured'))
: t('settings.notConnected');
const activeTab = (syncOk || syncErr)
? 'calendar'
: (sessionStorage.getItem(SETTINGS_TAB_KEY) ?? 'general');
const panelHidden = (id) => id === activeTab ? '' : ' hidden';
const btnClass = (id) => `settings-tab-btn${id === activeTab ? ' settings-tab-btn--active' : ''}`;
const btnAria = (id) => id === activeTab ? 'true' : 'false';
container.innerHTML = `
${syncOk ? `
${syncOk === 'google' ? t('settings.syncSuccessGoogle') : t('settings.syncSuccessApple')}
` : ''}
${syncErr ? `
${syncErr === 'google' ? t('settings.syncErrorGoogle') : t('settings.syncErrorApple')}
` : ''}
${t('settings.sectionDesign')}
${t('settings.cardAppearance')}
${user?.role === 'admin' ? `
${t('settings.sectionAppName')}
${t('settings.appNameTitle')}
${t('settings.appNameHint')}
` : ''}
${t('settings.sectionDate')}
${t('settings.dateFormatTitle')}
${t('settings.dateFormatHint')}
${t('settings.languageTitle')}
${t('settings.sectionMeals')}
${t('settings.sectionBudget')}
${t('settings.currencyLabel')}
${t('settings.currencyHint')}
${t('settings.sectionShopping')}
${t('settings.shoppingCategoriesLabel')}
${t('settings.shoppingCategoriesHint')}
${categories.map((c, i) => categoryRowHtml(c, i === 0, i === categories.length - 1)).join('')}
${t('settings.sectionCalendarSync')}
${googleStatus.configured ? `
${googleStatus.connected ? `
${user?.role === 'admin' ? `
` : ''}
` : `
${user?.role === 'admin' ? `
${t('settings.connectGoogle')}` : `
${t('settings.googleOnlyAdmin')}`}
`}
` : ''}
${appleStatus.configured ? `
${appleStatus.connected && user?.role === 'admin' ? `` : ''}
` : user?.role === 'admin' ? `
` : `
${t('settings.appleOnlyAdmin')}`}
${t('settings.sectionAccount')}
${esc(initials(user?.display_name))}
${esc(user?.display_name)}
@${esc(user?.username)}
${t('settings.changePassword')}
${user?.role === 'admin' ? `
${t('settings.apiTokensTitle')}
${t('settings.apiTokensCardTitle')}
${t('settings.apiTokensHint')}
${apiTokens.map(apiTokenHtml).join('')}
${t('settings.sectionFamily')}
${users.map(memberHtml).join('')}
` : ''}
`;
// Meal-Type-Checkboxen initialisieren
const toggles = container.querySelector('#meal-type-toggles');
if (toggles) {
toggles.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
cb.checked = prefs.visible_meal_types.includes(cb.value);
});
}
bindEvents(container, user, categories, icsSubscriptions, apiTokens);
}
// --------------------------------------------------------
// Event-Binding
// --------------------------------------------------------
function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
bindTabEvents(container);
bindCategoryEvents(container);
bindIcsEvents(container, user, icsSubscriptions);
bindApiTokenEvents(container, apiTokens);
// Theme-Toggle
const themeToggle = container.querySelector('#theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', (e) => {
const btn = e.target.closest('[data-theme-value]');
if (!btn) return;
const value = btn.dataset.themeValue;
applyTheme(value);
themeToggle.querySelectorAll('.theme-toggle__btn').forEach(b => b.classList.remove('theme-toggle__btn--active'));
btn.classList.add('theme-toggle__btn--active');
});
}
// Meal-Type-Toggles
const mealToggles = container.querySelector('#meal-type-toggles');
if (mealToggles) {
mealToggles.addEventListener('change', async () => {
const checked = [...mealToggles.querySelectorAll('input:checked')].map((cb) => cb.value);
if (checked.length === 0) {
window.oikos?.showToast(t('settings.mealTypesMinOne'), 'error');
// Revert: re-check all
mealToggles.querySelectorAll('input').forEach((cb) => { cb.checked = true; });
return;
}
try {
await api.put('/preferences', { visible_meal_types: checked });
window.oikos?.showToast(t('settings.mealTypesSaved'), 'success');
} catch (err) {
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
}
});
}
// Währungs-Auswahl
const currencySelect = container.querySelector('#currency-select');
if (currencySelect) {
currencySelect.addEventListener('change', async () => {
try {
await api.put('/preferences', { currency: currencySelect.value });
window.oikos?.showToast(t('settings.currencySaved'), 'success');
} catch (err) {
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
}
});
}
const dateFormatSelect = container.querySelector('#date-format-select');
if (dateFormatSelect) {
dateFormatSelect.addEventListener('change', async () => {
try {
await api.put('/preferences', { date_format: dateFormatSelect.value });
try { localStorage.setItem('oikos-date-format', dateFormatSelect.value); } catch (_) {}
window.dispatchEvent(new CustomEvent('date-format-changed', { detail: { dateFormat: dateFormatSelect.value } }));
window.oikos?.showToast(t('settings.dateFormatSavedToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
}
});
}
const appNameForm = container.querySelector('#app-name-form');
if (appNameForm) {
appNameForm.addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = container.querySelector('#app-name-error');
const input = container.querySelector('#app-name-input');
errorEl.hidden = true;
const value = input.value.trim();
try {
await api.put('/preferences', { app_name: value });
try {
if (value) localStorage.setItem(APP_NAME_STORAGE_KEY, value);
else localStorage.removeItem(APP_NAME_STORAGE_KEY);
} catch (_) {}
input.value = value || DEFAULT_APP_NAME;
window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: value || DEFAULT_APP_NAME } }));
window.oikos?.showToast(t('settings.appNameSavedToast'), 'success');
} catch (err) {
showError(errorEl, err.message ?? t('common.errorGeneric'));
}
});
container.querySelector('#app-name-reset-btn')?.addEventListener('click', async () => {
const errorEl = container.querySelector('#app-name-error');
const input = container.querySelector('#app-name-input');
errorEl.hidden = true;
input.value = DEFAULT_APP_NAME;
try {
await api.put('/preferences', { app_name: '' });
try { localStorage.removeItem(APP_NAME_STORAGE_KEY); } catch (_) {}
window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: DEFAULT_APP_NAME } }));
window.oikos?.showToast(t('settings.appNameSavedToast'), 'success');
} catch (err) {
showError(errorEl, err.message ?? t('common.errorGeneric'));
}
});
}
// Passwort ändern
const passwordForm = container.querySelector('#password-form');
if (passwordForm) {
passwordForm.addEventListener('submit', async (e) => {
e.preventDefault();
const currentPw = container.querySelector('#current-password').value;
const newPw = container.querySelector('#new-password').value;
const confirmPw = container.querySelector('#confirm-password').value;
const errorEl = container.querySelector('#password-error');
errorEl.hidden = true;
if (newPw !== confirmPw) {
showError(errorEl, t('settings.passwordMismatch'));
return;
}
const btn = passwordForm.querySelector('[type=submit]');
btn.disabled = true;
try {
await api.patch('/auth/me/password', { current_password: currentPw, new_password: newPw });
passwordForm.reset();
window.oikos?.showToast(t('settings.passwordSavedToast'), 'success');
} catch (err) {
showError(errorEl, err.message);
} finally {
btn.disabled = false;
}
});
}
// Google Sync
const googleSyncBtn = container.querySelector('#google-sync-btn');
if (googleSyncBtn) {
googleSyncBtn.addEventListener('click', async () => {
googleSyncBtn.disabled = true;
googleSyncBtn.textContent = t('settings.synchronizing');
try {
await api.post('/calendar/google/sync', {});
window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Google Calendar' }), 'success');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
} finally {
googleSyncBtn.disabled = false;
googleSyncBtn.textContent = t('settings.syncNow');
}
});
}
// Google Disconnect (Admin)
const googleDisconnectBtn = container.querySelector('#google-disconnect-btn');
if (googleDisconnectBtn) {
googleDisconnectBtn.addEventListener('click', async () => {
if (!await confirmModal(t('settings.googleDisconnectConfirm'), { danger: true })) return;
try {
await api.delete('/calendar/google/disconnect');
window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Google Calendar' }), 'default');
window.oikos?.navigate('/settings');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
}
// Apple Sync
const appleSyncBtn = container.querySelector('#apple-sync-btn');
if (appleSyncBtn) {
appleSyncBtn.addEventListener('click', async () => {
appleSyncBtn.disabled = true;
appleSyncBtn.textContent = t('settings.synchronizing');
try {
await api.post('/calendar/apple/sync', {});
window.oikos?.showToast(t('settings.syncSuccess', { provider: 'Apple Calendar' }), 'success');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
} finally {
appleSyncBtn.disabled = false;
appleSyncBtn.textContent = t('settings.syncNow');
}
});
}
// Apple Disconnect (Admin)
const appleDisconnectBtn = container.querySelector('#apple-disconnect-btn');
if (appleDisconnectBtn) {
appleDisconnectBtn.addEventListener('click', async () => {
if (!await confirmModal(t('settings.appleDisconnectConfirm'), { danger: true })) return;
try {
await api.delete('/calendar/apple/disconnect');
window.oikos?.showToast(t('settings.disconnectedToast', { provider: 'Apple Calendar' }), 'default');
window.oikos?.navigate('/settings');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
}
// Apple Connect-Formular (Admin)
const appleConnectForm = container.querySelector('#apple-connect-form');
if (appleConnectForm) {
appleConnectForm.addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = container.querySelector('#apple-connect-error');
errorEl.hidden = true;
const url = container.querySelector('#apple-caldav-url').value.trim();
const username = container.querySelector('#apple-username').value.trim();
const password = container.querySelector('#apple-password').value;
const btn = container.querySelector('#apple-connect-btn');
btn.disabled = true;
btn.textContent = t('settings.appleConnecting');
try {
await api.post('/calendar/apple/connect', { url, username, password });
window.oikos?.showToast(t('settings.appleConnectedToast'), 'success');
window.oikos?.navigate('/settings');
} catch (err) {
showError(errorEl, err.message);
} finally {
btn.disabled = false;
btn.textContent = t('settings.appleConnectBtn');
}
});
}
// Mitglied hinzufügen (Admin)
const addMemberBtn = container.querySelector('#add-member-btn');
if (addMemberBtn) {
addMemberBtn.addEventListener('click', () => {
container.querySelector('#add-member-form-card').classList.remove('settings-card--hidden');
addMemberBtn.hidden = true;
});
}
const cancelAddMember = container.querySelector('#cancel-add-member');
if (cancelAddMember) {
cancelAddMember.addEventListener('click', () => {
container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
container.querySelector('#add-member-btn').hidden = false;
container.querySelector('#add-member-form').reset();
container.querySelector('#member-error').hidden = true;
});
}
const addMemberForm = container.querySelector('#add-member-form');
if (addMemberForm) {
addMemberForm.addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = container.querySelector('#member-error');
errorEl.hidden = true;
const data = {
username: container.querySelector('#new-username').value.trim(),
display_name: container.querySelector('#new-display-name').value.trim(),
password: container.querySelector('#new-member-password').value,
avatar_color: container.querySelector('#new-avatar-color').value,
family_role: container.querySelector('#new-family-role').value,
system_admin: container.querySelector('#new-system-admin')?.checked === true,
};
const btn = addMemberForm.querySelector('[type=submit]');
btn.disabled = true;
try {
const res = await auth.createUser(data);
const list = container.querySelector('#members-list');
list.insertAdjacentHTML('beforeend', memberHtml(res.user));
addMemberForm.reset();
container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
container.querySelector('#add-member-btn').hidden = false;
window.oikos?.showToast(t('settings.memberAddedToast', { name: res.user.display_name }), 'success');
bindDeleteButtons(container, user);
} catch (err) {
showError(errorEl, err.message);
} finally {
btn.disabled = false;
}
});
}
bindDeleteButtons(container, user);
// Abmelden
const logoutBtn = container.querySelector('#logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', async () => {
try {
await auth.logout();
} finally {
window.location.href = '/login';
}
});
}
}
// --------------------------------------------------------
// Tab-Navigation
// --------------------------------------------------------
function bindTabEvents(container) {
const tabList = container.querySelector('.settings-tabs');
if (!tabList) return;
tabList.addEventListener('click', (e) => {
const btn = e.target.closest('[data-tab]');
if (!btn) return;
const tab = btn.dataset.tab;
tabList.querySelectorAll('[data-tab]').forEach((b) => {
const active = b.dataset.tab === tab;
b.classList.toggle('settings-tab-btn--active', active);
b.setAttribute('aria-selected', String(active));
});
container.querySelectorAll('[data-panel]').forEach((panel) => {
panel.hidden = panel.dataset.panel !== tab;
});
try { sessionStorage.setItem(SETTINGS_TAB_KEY, tab); } catch (_) {}
});
}
function bindDeleteButtons(container, user) {
container.querySelectorAll('[data-delete-user]').forEach((btn) => {
btn.replaceWith(btn.cloneNode(true)); // Doppelte Listener vermeiden
});
container.querySelectorAll('[data-delete-user]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.deleteUser, 10);
const name = btn.dataset.name;
if (!await confirmModal(t('settings.deleteMemberConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return;
try {
await auth.deleteUser(id);
btn.closest('.settings-member').remove();
window.oikos?.showToast(t('settings.memberDeletedToast', { name }), 'default');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
});
}
function apiTokenHtml(token) {
const status = token.revoked_at
? t('settings.apiTokenRevoked')
: token.expires_at && new Date(token.expires_at).getTime() <= Date.now()
? t('settings.apiTokenExpired')
: t('settings.apiTokenActive');
const meta = [
`${t('settings.apiTokenPrefix')}: ${token.token_prefix}...`,
token.expires_at ? `${t('settings.apiTokenExpires')}: ${formatDateTime(token.expires_at)}` : t('settings.apiTokenNeverExpires'),
token.last_used_at ? `${t('settings.apiTokenLastUsed')}: ${formatDateTime(token.last_used_at)}` : t('settings.apiTokenNeverUsed'),
status,
].join(' · ');
return `
${esc(token.name)}
${esc(meta)}
`;
}
function renderApiTokenList(container, tokens) {
const list = container.querySelector('#api-token-list');
if (!list) return;
list.replaceChildren();
tokens.forEach((token) => {
const tmp = document.createElement('template');
tmp.innerHTML = apiTokenHtml(token);
list.appendChild(tmp.content.firstElementChild);
});
if (window.lucide) window.lucide.createIcons();
}
function datetimeLocalToIso(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function bindApiTokenEvents(container, initialTokens) {
const form = container.querySelector('#api-token-form');
const list = container.querySelector('#api-token-list');
if (!form || !list) return;
let tokens = [...initialTokens];
form.addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = container.querySelector('#api-token-error');
const output = container.querySelector('#api-token-created');
const outputValue = container.querySelector('#api-token-created-value');
errorEl.hidden = true;
output.hidden = true;
const name = container.querySelector('#api-token-name').value.trim();
const expiresValue = container.querySelector('#api-token-expires').value;
const expires_at = datetimeLocalToIso(expiresValue);
if (expiresValue && !expires_at) {
showError(errorEl, t('settings.apiTokenInvalidExpiration'));
return;
}
const btn = form.querySelector('[type=submit]');
btn.disabled = true;
try {
const res = await api.post('/auth/api-tokens', { name, expires_at });
tokens.unshift(res.data);
renderApiTokenList(container, tokens);
form.reset();
outputValue.value = res.token;
output.hidden = false;
outputValue.focus();
outputValue.select();
window.oikos?.showToast(t('settings.apiTokenCreatedToast'), 'success');
} catch (err) {
showError(errorEl, err.message);
} finally {
btn.disabled = false;
}
});
list.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-revoke-api-token]');
if (!btn) return;
const id = Number(btn.dataset.revokeApiToken);
const name = btn.dataset.name;
if (!await confirmModal(t('settings.apiTokenRevokeConfirm', { name }), { danger: true, confirmLabel: t('settings.apiTokenRevoke') })) return;
try {
await api.delete(`/auth/api-tokens/${id}`);
tokens = tokens.map((token) => token.id === id ? { ...token, revoked_at: new Date().toISOString() } : token);
renderApiTokenList(container, tokens);
window.oikos?.showToast(t('settings.apiTokenRevokedToast'), 'default');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
}
// --------------------------------------------------------
// Kategorie-Verwaltung
// --------------------------------------------------------
function categoryRowHtml(cat, isFirst, isLast) {
return `
${esc(catLabel(cat.name))}
`;
}
function renderCatList(container, cats) {
const list = container.querySelector('#cat-list');
if (!list) return;
// DOM-API statt innerHTML (Security-Constraint des Projekts)
list.replaceChildren();
cats.forEach((c, i) => {
const tmp = document.createElement('template');
tmp.innerHTML = categoryRowHtml(c, i === 0, i === cats.length - 1);
list.appendChild(tmp.content.firstElementChild);
});
if (window.lucide) window.lucide.createIcons();
}
function bindCategoryEvents(container) {
let cats = [];
api.get('/shopping/categories').then((res) => {
cats = res.data ?? [];
renderCatList(container, cats);
}).catch(() => {});
const addForm = container.querySelector('#cat-add-form');
if (addForm) {
addForm.addEventListener('submit', async (e) => {
e.preventDefault();
const input = container.querySelector('#cat-add-input');
const name = input.value.trim();
if (!name) return;
try {
const res = await api.post('/shopping/categories', { name });
cats.push(res.data);
renderCatList(container, cats);
input.value = '';
input.focus();
window.oikos?.showToast(t('settings.shoppingCategoryAdded'), 'success');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
}
const catList = container.querySelector('#cat-list');
if (!catList) return;
catList.addEventListener('click', async (e) => {
const target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
const rowEl = target.closest('[data-cat-id]');
const id = rowEl ? Number(rowEl.dataset.catId) : Number(target.dataset.id);
if (action === 'rename-cat') {
const cat = cats.find((c) => c.id === id);
if (!cat) return;
const { promptModal } = await import('/components/modal.js');
const newName = await promptModal(t('settings.shoppingCategoryRenamePrompt'), catLabel(cat.name));
if (!newName || newName === cat.name) return;
try {
const res = await api.put(`/shopping/categories/${id}`, { name: newName });
const idx = cats.findIndex((c) => c.id === id);
if (idx >= 0) cats[idx] = res.data;
renderCatList(container, cats);
window.oikos?.showToast(t('settings.shoppingCategoryRenamed'), 'success');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
if (action === 'move-cat-up') {
const idx = cats.findIndex((c) => c.id === id);
if (idx <= 0) return;
[cats[idx - 1], cats[idx]] = [cats[idx], cats[idx - 1]];
renderCatList(container, cats);
try {
await api.patch('/shopping/categories/reorder', { order: cats.map((c) => c.id) });
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
if (action === 'move-cat-down') {
const idx = cats.findIndex((c) => c.id === id);
if (idx < 0 || idx >= cats.length - 1) return;
[cats[idx], cats[idx + 1]] = [cats[idx + 1], cats[idx]];
renderCatList(container, cats);
try {
await api.patch('/shopping/categories/reorder', { order: cats.map((c) => c.id) });
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
if (action === 'delete-cat') {
const cat = cats.find((c) => c.id === id);
if (!cat) return;
const { confirmModal: confirmDel } = await import('/components/modal.js');
if (!await confirmDel(
t('settings.shoppingCategoryDeleteConfirm', { name: catLabel(cat.name) }),
{ danger: true, confirmLabel: t('common.delete') }
)) return;
try {
await api.delete(`/shopping/categories/${id}`);
cats = cats.filter((c) => c.id !== id);
renderCatList(container, cats);
window.oikos?.showToast(t('settings.shoppingCategoryDeleted'), 'default');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
});
}
function memberHtml(u) {
const familyRole = familyRoleLabel(u.family_role);
const systemRole = u.role === 'admin' ? ` · ${esc(t('settings.systemAdminBadge'))}` : '';
return `
${initials(u.display_name)}
${esc(u.display_name)}
@${esc(u.username)} · ${esc(familyRole)}${systemRole}
`;
}
// --------------------------------------------------------
// ICS-Abonnements
// --------------------------------------------------------
function renderIcsList(container, subs, user) {
const listEl = container.querySelector('#ics-list-container');
if (!listEl) return;
listEl.replaceChildren();
if (subs.length === 0) {
const empty = document.createElement('p');
empty.className = 'form-hint';
empty.style.padding = 'var(--space-3) 0';
empty.textContent = t('settings.ics.empty');
listEl.appendChild(empty);
return;
}
const ul = document.createElement('ul');
ul.className = 'settings-members';
subs.forEach((sub) => {
const li = document.createElement('li');
li.className = 'settings-member';
li.dataset.subId = sub.id;
const dot = document.createElement('span');
dot.className = 'settings-avatar settings-avatar--sm';
dot.style.background = sub.color;
dot.style.flexShrink = '0';
li.appendChild(dot);
const info = document.createElement('div');
info.className = 'settings-member__info';
const nameLine = document.createElement('span');
nameLine.className = 'settings-member__name';
nameLine.textContent = sub.name;
const badge = document.createElement('span');
badge.className = `badge ${sub.shared ? 'badge--success' : 'badge--neutral'}`;
badge.style.marginLeft = 'var(--space-2)';
badge.textContent = sub.shared ? t('settings.ics.badges.shared') : t('settings.ics.badges.private');
nameLine.appendChild(badge);
info.appendChild(nameLine);
const meta = document.createElement('span');
meta.className = 'settings-member__meta';
if (sub.last_sync) {
const d = new Date(sub.last_sync);
meta.textContent = `${t('settings.ics.status.lastSync')} ${formatDate(d)} ${formatTime(d)}`;
} else {
meta.textContent = t('settings.ics.status.never');
}
info.appendChild(meta);
li.appendChild(info);
const isOwner = sub.created_by === user.id || user.role === 'admin';
if (isOwner) {
const actions = document.createElement('div');
actions.className = 'cat-row__actions';
const syncBtn = document.createElement('button');
syncBtn.className = 'btn btn--icon btn--ghost';
syncBtn.title = t('settings.ics.actions.sync');
syncBtn.setAttribute('aria-label', t('settings.ics.actions.sync'));
syncBtn.dataset.action = 'ics-sync';
syncBtn.dataset.id = sub.id;
const syncIcon = document.createElement('i');
syncIcon.setAttribute('data-lucide', 'refresh-cw');
syncIcon.style.cssText = 'width:16px;height:16px';
syncIcon.setAttribute('aria-hidden', 'true');
syncBtn.appendChild(syncIcon);
actions.appendChild(syncBtn);
const delBtn = document.createElement('button');
delBtn.className = 'btn btn--icon btn--danger-outline';
delBtn.title = t('settings.ics.actions.delete');
delBtn.setAttribute('aria-label', t('settings.ics.actions.delete'));
delBtn.dataset.action = 'ics-delete';
delBtn.dataset.id = sub.id;
delBtn.dataset.name = sub.name;
const delIcon = document.createElement('i');
delIcon.setAttribute('data-lucide', 'trash-2');
delIcon.style.cssText = 'width:14px;height:14px';
delIcon.setAttribute('aria-hidden', 'true');
delBtn.appendChild(delIcon);
actions.appendChild(delBtn);
li.appendChild(actions);
}
ul.appendChild(li);
});
listEl.appendChild(ul);
if (window.lucide) window.lucide.createIcons();
}
function bindIcsEvents(container, user, initialSubs) {
let subs = [...initialSubs];
renderIcsList(container, subs, user);
const addBtn = container.querySelector('#ics-add-btn');
const formWrapper = container.querySelector('#ics-add-form-wrapper');
const addForm = container.querySelector('#ics-add-form');
const cancelBtn = container.querySelector('#ics-cancel-btn');
const submitBtn = container.querySelector('#ics-submit-btn');
const errorEl = container.querySelector('#ics-add-error');
const listEl = container.querySelector('#ics-list-container');
if (addBtn) {
addBtn.addEventListener('click', () => {
formWrapper.hidden = false;
addBtn.hidden = true;
container.querySelector('#ics-url')?.focus();
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
formWrapper.hidden = true;
addBtn.hidden = false;
addForm?.reset();
errorEl.hidden = true;
});
}
if (addForm) {
addForm.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.hidden = true;
const url = container.querySelector('#ics-url').value.trim();
const name = container.querySelector('#ics-name').value.trim();
const color = container.querySelector('#ics-color').value;
const shared = container.querySelector('#ics-shared').checked ? 1 : 0;
submitBtn.disabled = true;
try {
const res = await api.post('/calendar/subscriptions', { url, name, color, shared });
subs.push(res.data);
renderIcsList(container, subs, user);
addForm.reset();
formWrapper.hidden = true;
addBtn.hidden = false;
if (res.syncError) {
window.oikos?.showToast(`${t('settings.ics.status.syncError')}: ${res.syncError}`, 'danger');
} else {
window.oikos?.showToast(t('settings.ics.addedToast'), 'success');
}
} catch (err) {
errorEl.textContent = err.message ?? t('common.errorGeneric');
errorEl.hidden = false;
} finally {
submitBtn.disabled = false;
}
});
}
if (listEl) {
listEl.addEventListener('click', async (e) => {
const target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
const id = parseInt(target.dataset.id, 10);
if (action === 'ics-sync') {
const origIcon = target.querySelector('[data-lucide]');
const origTitle = target.title;
target.disabled = true;
target.title = t('settings.ics.status.syncing');
if (origIcon) origIcon.setAttribute('data-lucide', 'loader');
if (window.lucide) window.lucide.createIcons();
try {
const res = await api.post(`/calendar/subscriptions/${id}/sync`, {});
const idx = subs.findIndex((s) => s.id === id);
if (idx >= 0) subs[idx] = res.data;
renderIcsList(container, subs, user);
window.oikos?.showToast(t('settings.ics.syncedToast'), 'success');
} catch (err) {
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
target.disabled = false;
target.title = origTitle;
if (origIcon) origIcon.setAttribute('data-lucide', 'refresh-cw');
if (window.lucide) window.lucide.createIcons();
}
}
if (action === 'ics-delete') {
const name = target.dataset.name;
if (!await confirmModal(t('settings.ics.confirm_delete'), { danger: true, confirmLabel: t('common.delete') })) return;
try {
await api.delete(`/calendar/subscriptions/${id}`);
subs = subs.filter((s) => s.id !== id);
renderIcsList(container, subs, user);
window.oikos?.showToast(t('settings.ics.deletedToast'), 'default');
} catch (err) {
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
}
}
});
}
}
function initials(name) {
if (!name) return '?';
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
}
function formatDateTime(iso) {
if (!iso) return '';
const d = new Date(iso);
return `${formatDate(d)} ${formatTime(d)}`.trim();
}
function currentTheme() {
return localStorage.getItem('oikos-theme') || 'system';
}
function applyTheme(value) {
localStorage.setItem('oikos-theme', value);
if (value === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (value === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
// tokens.css @media (prefers-color-scheme: dark) übernimmt sofort
}
}
function showError(el, msg) {
el.textContent = msg;
el.hidden = false;
}