Merge branch 'main' of github.com:rafaelfoster/oikos
This commit is contained in:
+22
-15
@@ -65,11 +65,12 @@ function renderSuggestions() {
|
||||
const items = suggestions();
|
||||
if (!items.length) {
|
||||
dropdown.hidden = true;
|
||||
dropdown.innerHTML = '';
|
||||
dropdown.replaceChildren();
|
||||
return;
|
||||
}
|
||||
dropdown.hidden = false;
|
||||
dropdown.innerHTML = items.map((birthday, idx) => `
|
||||
dropdown.replaceChildren();
|
||||
dropdown.insertAdjacentHTML('beforeend', items.map((birthday, idx) => `
|
||||
<button class="birthday-suggestion" type="button" data-index="${idx}" data-name="${esc(birthday.name)}">
|
||||
${photoAvatar(birthday, 'birthday-avatar--xs')}
|
||||
<span>
|
||||
@@ -77,20 +78,22 @@ function renderSuggestions() {
|
||||
<small>${esc(ageNote(birthday))}</small>
|
||||
</span>
|
||||
</button>
|
||||
`).join('');
|
||||
`).join(''));
|
||||
}
|
||||
|
||||
function renderUpcoming() {
|
||||
const host = _container.querySelector('#birthdays-upcoming');
|
||||
if (!host) return;
|
||||
if (!state.upcoming.length) {
|
||||
host.innerHTML = `<div class="empty-state empty-state--compact">
|
||||
host.replaceChildren();
|
||||
host.insertAdjacentHTML('beforeend', `<div class="empty-state empty-state--compact">
|
||||
<div class="empty-state__title">${t('birthdays.emptyTitle')}</div>
|
||||
<div class="empty-state__description">${t('birthdays.emptyDescription')}</div>
|
||||
</div>`;
|
||||
</div>`);
|
||||
return;
|
||||
}
|
||||
host.innerHTML = state.upcoming.map((birthday) => `
|
||||
host.replaceChildren();
|
||||
host.insertAdjacentHTML('beforeend', state.upcoming.map((birthday) => `
|
||||
<article class="birthday-card">
|
||||
<div class="birthday-card__media">${photoAvatar(birthday)}</div>
|
||||
<div class="birthday-card__body">
|
||||
@@ -106,7 +109,7 @@ function renderUpcoming() {
|
||||
<div class="birthday-card__note">${esc(ageNote(birthday))}</div>
|
||||
</div>
|
||||
</article>
|
||||
`).join('');
|
||||
`).join(''));
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
@@ -114,14 +117,16 @@ function renderList() {
|
||||
if (!host) return;
|
||||
const list = filteredBirthdays();
|
||||
if (!list.length) {
|
||||
host.innerHTML = `<div class="empty-state">
|
||||
host.replaceChildren();
|
||||
host.insertAdjacentHTML('beforeend', `<div class="empty-state">
|
||||
<div class="empty-state__title">${t('birthdays.emptyTitle')}</div>
|
||||
<div class="empty-state__description">${t('birthdays.emptyDescription')}</div>
|
||||
</div>`;
|
||||
</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
host.innerHTML = list.map((birthday) => `
|
||||
host.replaceChildren();
|
||||
host.insertAdjacentHTML('beforeend', list.map((birthday) => `
|
||||
<article class="birthday-item" data-id="${birthday.id}">
|
||||
<div class="birthday-item__media">${photoAvatar(birthday)}</div>
|
||||
<div class="birthday-item__body">
|
||||
@@ -142,14 +147,15 @@ function renderList() {
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
`).join('');
|
||||
`).join(''));
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
stagger(host.querySelectorAll('.birthday-item'));
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
_container.innerHTML = `
|
||||
_container.replaceChildren();
|
||||
_container.insertAdjacentHTML('beforeend', `
|
||||
<div class="birthdays-page">
|
||||
<h1 class="sr-only">${t('birthdays.title')}</h1>
|
||||
<div class="birthdays-toolbar">
|
||||
@@ -194,7 +200,7 @@ function renderPage() {
|
||||
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
|
||||
renderUpcoming();
|
||||
renderList();
|
||||
@@ -304,7 +310,8 @@ function openBirthdayModal({ mode, birthday = null }) {
|
||||
const nameInput = panel.querySelector('#bd-name');
|
||||
const preview = panel.querySelector('#birthday-preview');
|
||||
const renderPreview = () => {
|
||||
preview.innerHTML = birthdayPreviewHtml(nameInput.value.trim(), photoData);
|
||||
preview.replaceChildren();
|
||||
preview.insertAdjacentHTML('beforeend', birthdayPreviewHtml(nameInput.value.trim(), photoData));
|
||||
};
|
||||
nameInput.addEventListener('input', renderPreview);
|
||||
panel.querySelector('#bd-photo').addEventListener('change', async (e) => {
|
||||
@@ -359,7 +366,7 @@ function openBirthdayModal({ mode, birthday = null }) {
|
||||
renderUpcoming();
|
||||
renderSuggestions();
|
||||
renderList();
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.message, 'danger');
|
||||
saveBtn.disabled = false;
|
||||
|
||||
+32
-15
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js';
|
||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
import { t, formatDate, getLocale } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
@@ -607,7 +607,7 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
panel.querySelector('#bm-cancel').addEventListener('click', closeModal);
|
||||
|
||||
panel.querySelector('#bm-delete')?.addEventListener('click', async () => {
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
await deleteEntry(entry.id);
|
||||
});
|
||||
|
||||
@@ -642,7 +642,7 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
const sumRes = await api.get(`/budget/summary?month=${state.month}`);
|
||||
state.summary = sumRes.data;
|
||||
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
renderBody();
|
||||
window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success');
|
||||
} catch (err) {
|
||||
@@ -660,18 +660,35 @@ function openBudgetModal({ mode, entry = null }) {
|
||||
// --------------------------------------------------------
|
||||
|
||||
async function deleteEntry(id) {
|
||||
if (!await confirmModal(t('budget.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||
try {
|
||||
await api.delete(`/budget/${id}`);
|
||||
state.entries = state.entries.filter((e) => e.id !== id);
|
||||
const sumRes = await api.get(`/budget/summary?month=${state.month}`);
|
||||
state.summary = sumRes.data;
|
||||
renderBody();
|
||||
vibrate([30, 50, 30]);
|
||||
window.oikos?.showToast(t('budget.deletedToast'), 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||
}
|
||||
const entry = state.entries.find((e) => e.id === id);
|
||||
state.entries = state.entries.filter((e) => e.id !== id);
|
||||
renderBody();
|
||||
vibrate([30, 50, 30]);
|
||||
|
||||
let undone = false;
|
||||
window.oikos?.showToast(t('budget.deletedToast'), 'default', 5000, () => {
|
||||
undone = true;
|
||||
if (entry) {
|
||||
state.entries = [...state.entries, entry].sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
renderBody();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
if (undone) return;
|
||||
try {
|
||||
await api.delete(`/budget/${id}`);
|
||||
const sumRes = await api.get(`/budget/summary?month=${state.month}`);
|
||||
state.summary = sumRes.data;
|
||||
renderBody();
|
||||
} catch (err) {
|
||||
if (entry) {
|
||||
state.entries = [...state.entries, entry].sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
renderBody();
|
||||
}
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
+78
-26
@@ -6,7 +6,7 @@
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
||||
import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js';
|
||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||
import { stagger } from '/utils/ux.js';
|
||||
import { t, formatTime } from '/i18n.js';
|
||||
import { esc, fmtLocation } from '/utils/html.js';
|
||||
@@ -46,6 +46,19 @@ const EVENT_COLORS = [
|
||||
'#8E8E93', '#30B0C7',
|
||||
];
|
||||
|
||||
const EVENT_COLOR_NAMES = () => ({
|
||||
'#007AFF': t('calendar.colorBlue'),
|
||||
'#34C759': t('calendar.colorGreen'),
|
||||
'#FF9500': t('calendar.colorOrange'),
|
||||
'#FF3B30': t('calendar.colorRed'),
|
||||
'#AF52DE': t('calendar.colorPurple'),
|
||||
'#FF6B35': t('calendar.colorCoral'),
|
||||
'#5AC8FA': t('calendar.colorSkyBlue'),
|
||||
'#FFCC00': t('calendar.colorYellow'),
|
||||
'#8E8E93': t('calendar.colorGray'),
|
||||
'#30B0C7': t('calendar.colorCyan'),
|
||||
});
|
||||
|
||||
const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht
|
||||
|
||||
/**
|
||||
@@ -764,7 +777,6 @@ function showEventPopup(ev, anchor) {
|
||||
});
|
||||
|
||||
popup.querySelector('#popup-delete').addEventListener('click', async () => {
|
||||
if (!await confirmModal(t('calendar.deleteConfirm', { title: ev.title }), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||
popup.remove();
|
||||
await deleteEvent(ev.id);
|
||||
});
|
||||
@@ -844,15 +856,36 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
|
||||
|
||||
const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0];
|
||||
|
||||
// Farb-Auswahl
|
||||
panel.querySelectorAll('.color-swatch').forEach((sw) => {
|
||||
sw.addEventListener('click', () => {
|
||||
panel.querySelectorAll('.color-swatch').forEach((s) => s.classList.remove('color-swatch--active'));
|
||||
sw.classList.add('color-swatch--active');
|
||||
// Farb-Auswahl: Auswahl + ARIA + Keyboard (Roving Tabindex)
|
||||
function selectSwatch(target) {
|
||||
panel.querySelectorAll('.color-swatch').forEach((s) => {
|
||||
s.classList.remove('color-swatch--active');
|
||||
s.setAttribute('aria-checked', 'false');
|
||||
s.setAttribute('tabindex', '-1');
|
||||
});
|
||||
});
|
||||
target.classList.add('color-swatch--active');
|
||||
target.setAttribute('aria-checked', 'true');
|
||||
target.setAttribute('tabindex', '0');
|
||||
}
|
||||
panel.querySelectorAll('.color-swatch').forEach((sw) => {
|
||||
if (sw.dataset.color === selectedColor) sw.classList.add('color-swatch--active');
|
||||
if (sw.dataset.color === selectedColor) selectSwatch(sw);
|
||||
sw.addEventListener('click', () => { selectSwatch(sw); sw.focus(); });
|
||||
sw.addEventListener('keydown', (e) => {
|
||||
const swatches = [...panel.querySelectorAll('.color-swatch')];
|
||||
const idx = swatches.indexOf(sw);
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = swatches[(idx + 1) % swatches.length];
|
||||
selectSwatch(next); next.focus();
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = swatches[(idx - 1 + swatches.length) % swatches.length];
|
||||
selectSwatch(prev); prev.focus();
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectSwatch(sw);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ganztägig-Toggle
|
||||
@@ -868,8 +901,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
|
||||
panel.querySelector('#modal-cancel').addEventListener('click', closeModal);
|
||||
|
||||
panel.querySelector('#modal-delete')?.addEventListener('click', async () => {
|
||||
if (!await confirmModal(t('calendar.deleteConfirm', { title: event.title }), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
await deleteEvent(event.id);
|
||||
});
|
||||
|
||||
@@ -959,11 +991,14 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('calendar.colorLabel')}</label>
|
||||
<div class="color-picker">
|
||||
${EVENT_COLORS.map((c) => `
|
||||
<label class="form-label" id="event-color-label">${t('calendar.colorLabel')}</label>
|
||||
<div class="color-picker" role="radiogroup" aria-labelledby="event-color-label">
|
||||
${EVENT_COLORS.map((c, i) => `
|
||||
<div class="color-swatch" data-color="${c}" style="background-color:${c};"
|
||||
role="radio" tabindex="0" aria-label="${t('calendar.colorLabel', { color: c })}"></div>
|
||||
role="radio"
|
||||
tabindex="${i === 0 ? '0' : '-1'}"
|
||||
aria-checked="false"
|
||||
aria-label="${EVENT_COLOR_NAMES()[c] ?? c}"></div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1062,7 +1097,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
|
||||
}
|
||||
}
|
||||
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
renderView();
|
||||
window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success');
|
||||
} catch (err) {
|
||||
@@ -1073,15 +1108,32 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
|
||||
}
|
||||
|
||||
async function deleteEvent(id) {
|
||||
try {
|
||||
await api.delete(`/calendar/${id}`);
|
||||
api.delete(`/reminders?entity_type=event&entity_id=${id}`).catch(() => {});
|
||||
refreshReminders();
|
||||
state.events = state.events.filter((e) => e.id !== id);
|
||||
renderView();
|
||||
window.oikos?.showToast(t('calendar.deletedToast'), 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.data?.error ?? t('calendar.deleteError'), 'error');
|
||||
}
|
||||
const event = state.events.find((e) => e.id === id);
|
||||
state.events = state.events.filter((e) => e.id !== id);
|
||||
renderView();
|
||||
|
||||
let undone = false;
|
||||
window.oikos?.showToast(t('calendar.deletedToast'), 'default', 5000, () => {
|
||||
undone = true;
|
||||
if (event) {
|
||||
state.events = [...state.events, event];
|
||||
renderView();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
if (undone) return;
|
||||
try {
|
||||
await api.delete(`/calendar/${id}`);
|
||||
api.delete(`/reminders?entity_type=event&entity_id=${id}`).catch(() => {});
|
||||
refreshReminders();
|
||||
} catch (err) {
|
||||
if (event) {
|
||||
state.events = [...state.events, event];
|
||||
renderView();
|
||||
}
|
||||
window.oikos?.showToast(err.data?.error ?? t('calendar.deleteError'), 'danger');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
|
||||
+29
-13
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js';
|
||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
import { t } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
@@ -304,7 +304,7 @@ function openContactModal({ mode, contact = null }) {
|
||||
panel.querySelector('#cm-cancel').addEventListener('click', closeModal);
|
||||
|
||||
panel.querySelector('#cm-delete')?.addEventListener('click', async () => {
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
await deleteContact(contact.id);
|
||||
});
|
||||
|
||||
@@ -336,7 +336,7 @@ function openContactModal({ mode, contact = null }) {
|
||||
const idx = state.contacts.findIndex((c) => c.id === contact.id);
|
||||
if (idx !== -1) state.contacts[idx] = res.data;
|
||||
}
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
renderList();
|
||||
window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success');
|
||||
} catch (err) {
|
||||
@@ -350,16 +350,32 @@ function openContactModal({ mode, contact = null }) {
|
||||
}
|
||||
|
||||
async function deleteContact(id) {
|
||||
if (!await confirmModal(t('contacts.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||
try {
|
||||
await api.delete(`/contacts/${id}`);
|
||||
state.contacts = state.contacts.filter((c) => c.id !== id);
|
||||
renderList();
|
||||
vibrate([30, 50, 30]);
|
||||
window.oikos?.showToast(t('contacts.deletedToast'), 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||
}
|
||||
const contact = state.contacts.find((c) => c.id === id);
|
||||
state.contacts = state.contacts.filter((c) => c.id !== id);
|
||||
renderList();
|
||||
vibrate([30, 50, 30]);
|
||||
|
||||
let undone = false;
|
||||
window.oikos?.showToast(t('contacts.deletedToast'), 'default', 5000, () => {
|
||||
undone = true;
|
||||
if (contact) {
|
||||
state.contacts = [...state.contacts, contact].sort((a, b) => a.name.localeCompare(b.name));
|
||||
renderList();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
if (undone) return;
|
||||
try {
|
||||
await api.delete(`/contacts/${id}`);
|
||||
} catch (err) {
|
||||
if (contact) {
|
||||
state.contacts = [...state.contacts, contact].sort((a, b) => a.name.localeCompare(b.name));
|
||||
renderList();
|
||||
}
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
|
||||
|
||||
+125
-173
@@ -12,11 +12,105 @@ import { openModal, closeModal } from '/components/modal.js';
|
||||
// Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert.
|
||||
let _fabController = null;
|
||||
|
||||
// ── Onboarding ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ONBOARDING_KEY = 'oikos-onboarded';
|
||||
|
||||
function getOnboardingSteps() {
|
||||
return [
|
||||
{ icon: 'home', title: t('onboarding.step1Title'), body: t('onboarding.step1Body') },
|
||||
{ icon: 'grid-2x2', title: t('onboarding.step2Title'), body: t('onboarding.step2Body') },
|
||||
{ icon: 'circle-check', title: t('onboarding.step3Title'), body: t('onboarding.step3Body') },
|
||||
];
|
||||
}
|
||||
|
||||
function showOnboarding(appContainer) {
|
||||
const steps = getOnboardingSteps();
|
||||
let current = 0;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'onboarding-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
|
||||
function renderStep() {
|
||||
const step = steps[current];
|
||||
const isLast = current === steps.length - 1;
|
||||
overlay.replaceChildren();
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'onboarding-card';
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.dataset.lucide = step.icon;
|
||||
icon.className = 'onboarding-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'onboarding-title';
|
||||
title.textContent = step.title;
|
||||
|
||||
const body = document.createElement('p');
|
||||
body.className = 'onboarding-body';
|
||||
body.textContent = step.body;
|
||||
|
||||
const dots = document.createElement('div');
|
||||
dots.className = 'onboarding-dots';
|
||||
steps.forEach((_, i) => {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = `onboarding-dot${i === current ? ' onboarding-dot--active' : ''}`;
|
||||
dots.appendChild(dot);
|
||||
});
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'onboarding-actions';
|
||||
|
||||
const skipBtn = document.createElement('button');
|
||||
skipBtn.className = 'btn btn--ghost';
|
||||
skipBtn.textContent = t('onboarding.skip');
|
||||
skipBtn.addEventListener('click', finish);
|
||||
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.className = 'btn btn--primary';
|
||||
nextBtn.textContent = isLast ? t('onboarding.done') : t('onboarding.next');
|
||||
nextBtn.addEventListener('click', () => {
|
||||
if (isLast) { finish(); return; }
|
||||
current++;
|
||||
renderStep();
|
||||
if (window.lucide) window.lucide.createIcons({ el: overlay });
|
||||
nextBtn.focus();
|
||||
});
|
||||
|
||||
actions.appendChild(skipBtn);
|
||||
actions.appendChild(nextBtn);
|
||||
card.appendChild(icon);
|
||||
card.appendChild(title);
|
||||
card.appendChild(body);
|
||||
card.appendChild(dots);
|
||||
card.appendChild(actions);
|
||||
overlay.appendChild(card);
|
||||
|
||||
if (window.lucide) window.lucide.createIcons({ el: overlay });
|
||||
setTimeout(() => nextBtn.focus(), 50);
|
||||
}
|
||||
|
||||
function finish() {
|
||||
localStorage.setItem(ONBOARDING_KEY, '1');
|
||||
overlay.classList.add('onboarding-overlay--out');
|
||||
overlay.addEventListener('animationend', () => overlay.remove(), { once: true });
|
||||
// Fallback falls animationend nicht feuert (prefers-reduced-motion):
|
||||
setTimeout(() => overlay.remove(), 300);
|
||||
}
|
||||
|
||||
renderStep();
|
||||
appContainer.appendChild(overlay);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Widget-Definitionen (Reihenfolge = Standard-Layout)
|
||||
// --------------------------------------------------------
|
||||
|
||||
const WIDGET_IDS = ['tasks', 'calendar', 'birthdays', 'budget', 'family', 'weather', 'shopping', 'meals', 'notes'];
|
||||
const WIDGET_IDS = ['weather', 'tasks', 'calendar', 'birthdays', 'budget', 'family', 'shopping', 'meals', 'notes'];
|
||||
|
||||
const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id) => ({ id, visible: true }));
|
||||
|
||||
@@ -427,24 +521,9 @@ function renderQuickAction({ route, label, icon, tone = '' }) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderKpiTile({ title, value, meta, icon, route, tone = '' }) {
|
||||
return `
|
||||
<button type="button" class="dashboard-kpi ${tone ? `dashboard-kpi--${tone}` : ''}" data-route="${route}">
|
||||
<span class="dashboard-kpi__icon"><i data-lucide="${icon}" aria-hidden="true"></i></span>
|
||||
<span class="dashboard-kpi__body">
|
||||
<span class="dashboard-kpi__label">${title}</span>
|
||||
<span class="dashboard-kpi__value">${value}</span>
|
||||
<span class="dashboard-kpi__meta">${meta}</span>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDashboardOverview(user, stats = null, weather = null) {
|
||||
function renderDashboardOverview(user) {
|
||||
const dateLabel = formatDate(new Date());
|
||||
const weatherLabel = weather
|
||||
? `${esc(weather.city)} · ${esc(weather.current?.temp)}${weather.units === 'imperial' ? '°F' : weather.units === 'standard' ? 'K' : '°C'}`
|
||||
: t('dashboard.weather');
|
||||
|
||||
const actions = [
|
||||
{ route: '/tasks', label: t('nav.tasks'), icon: 'check-square', tone: 'blue' },
|
||||
@@ -453,64 +532,6 @@ function renderDashboardOverview(user, stats = null, weather = null) {
|
||||
{ route: '/notes', label: t('nav.notes'), icon: 'sticky-note', tone: 'amber' },
|
||||
].map(renderQuickAction).join('');
|
||||
|
||||
const kpis = stats ? [
|
||||
renderKpiTile({
|
||||
title: t('tasks.title'),
|
||||
value: String(stats.overdueCount ?? 0),
|
||||
meta: t('dashboard.overdue'),
|
||||
icon: 'alert-circle',
|
||||
route: '/tasks',
|
||||
tone: 'danger',
|
||||
}),
|
||||
renderKpiTile({
|
||||
title: t('nav.calendar'),
|
||||
value: String(stats.todayEventCount ?? 0),
|
||||
meta: t('common.today'),
|
||||
icon: 'calendar-days',
|
||||
route: '/calendar',
|
||||
tone: 'calendar',
|
||||
}),
|
||||
renderKpiTile({
|
||||
title: t('nav.meals'),
|
||||
value: stats.todayMealTitle ? esc(stats.todayMealTitle) : '-',
|
||||
meta: t('dashboard.todayMeals'),
|
||||
icon: 'utensils',
|
||||
route: '/meals',
|
||||
tone: 'meals',
|
||||
}),
|
||||
renderKpiTile({
|
||||
title: t('dashboard.weather'),
|
||||
value: weatherLabel,
|
||||
meta: t('dashboard.weatherRefreshTitle'),
|
||||
icon: 'cloud-sun',
|
||||
route: '/',
|
||||
tone: 'weather',
|
||||
}),
|
||||
renderKpiTile({
|
||||
title: t('nav.birthdays'),
|
||||
value: String(stats.birthdayCount ?? 0),
|
||||
meta: t('dashboard.upcomingBirthdays'),
|
||||
icon: 'cake',
|
||||
route: '/birthdays',
|
||||
tone: 'birthdays',
|
||||
}),
|
||||
renderKpiTile({
|
||||
title: t('dashboard.familyMembers'),
|
||||
value: String(stats.familyCount ?? 0),
|
||||
meta: t('dashboard.participantsAdded'),
|
||||
icon: 'users',
|
||||
route: '/settings',
|
||||
tone: 'family',
|
||||
}),
|
||||
].join('') : `
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<section class="dashboard-overview">
|
||||
<div class="dashboard-overview__header">
|
||||
@@ -526,35 +547,13 @@ function renderDashboardOverview(user, stats = null, weather = null) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-kpi-grid">
|
||||
${kpis}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function widgetRegion(id) {
|
||||
return ['budget', 'family', 'weather', 'shopping', 'meals'].includes(id) ? 'side' : 'main';
|
||||
}
|
||||
|
||||
function widgetTileClass(id) {
|
||||
const map = {
|
||||
tasks: 'dashboard-tile--wide',
|
||||
calendar: 'dashboard-tile--compact',
|
||||
birthdays: 'dashboard-tile--compact',
|
||||
budget: 'dashboard-tile--wide',
|
||||
family: 'dashboard-tile--compact',
|
||||
meals: 'dashboard-tile--compact',
|
||||
notes: 'dashboard-tile--wide',
|
||||
shopping: 'dashboard-tile--compact',
|
||||
weather: 'dashboard-tile--wide',
|
||||
};
|
||||
return map[id] || 'dashboard-tile--compact';
|
||||
}
|
||||
|
||||
function renderDashboardTile(id, html) {
|
||||
if (!html) return '';
|
||||
return `<section class="dashboard-tile dashboard-tile--${id} ${widgetTileClass(id)}">${html}</section>`;
|
||||
const wideIds = ['tasks', 'budget', 'notes', 'weather'];
|
||||
return wideIds.includes(id) ? 'widget--wide' : '';
|
||||
}
|
||||
|
||||
function renderDashboardLayout(cfg, data, weather, currency) {
|
||||
@@ -570,31 +569,16 @@ function renderDashboardLayout(cfg, data, weather, currency) {
|
||||
weather: () => (weather ? renderWeatherWidget(weather) : ''),
|
||||
};
|
||||
|
||||
const visible = cfg.filter((w) => w.visible && widgetById[w.id]);
|
||||
const mainTiles = visible
|
||||
.filter((w) => widgetRegion(w.id) === 'main')
|
||||
.map((w) => renderDashboardTile(w.id, widgetById[w.id]()))
|
||||
const tiles = cfg
|
||||
.filter((w) => w.visible && widgetById[w.id])
|
||||
.map((w) => {
|
||||
const html = widgetById[w.id]();
|
||||
if (!html) return '';
|
||||
return `<div class="widget-wrapper ${widgetTileClass(w.id)}">${html}</div>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const sideTiles = visible
|
||||
.filter((w) => widgetRegion(w.id) === 'side')
|
||||
.map((w) => renderDashboardTile(w.id, widgetById[w.id]()))
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<section class="dashboard-workspace">
|
||||
<div class="dashboard-workspace__main">
|
||||
<div class="dashboard-widget-grid">
|
||||
${mainTiles}
|
||||
</div>
|
||||
</div>
|
||||
<aside class="dashboard-workspace__side">
|
||||
<div class="dashboard-side-stack">
|
||||
${sideTiles}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
`;
|
||||
return `<div class="dashboard__grid">${tiles}</div>`;
|
||||
}
|
||||
|
||||
function renderDashboardSkeleton() {
|
||||
@@ -606,32 +590,15 @@ function renderDashboardSkeleton() {
|
||||
<div class="skeleton skeleton-line skeleton-line--medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-kpi-grid">
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="dashboard-workspace">
|
||||
<div class="dashboard-workspace__main">
|
||||
<div class="dashboard-widget-grid">
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(2)}
|
||||
${skeletonWidget(3)}
|
||||
</div>
|
||||
</div>
|
||||
<aside class="dashboard-workspace__side">
|
||||
<div class="dashboard-side-stack">
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(2)}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
<div class="dashboard__grid">
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(2)}
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(2)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -919,7 +886,7 @@ function openCustomizeModal(currentConfig, onSave) {
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
await api.put('/preferences', { dashboard_widgets: draft });
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
onSave(draft);
|
||||
window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500);
|
||||
} catch {
|
||||
@@ -956,7 +923,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) {
|
||||
panel.querySelector('[data-action="done"]').addEventListener('click', async () => {
|
||||
try {
|
||||
await api.patch(`/tasks/${taskId}/status`, { status: 'done' });
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
window.oikos?.showToast(t('tasks.swipedDoneToast'), 'success');
|
||||
rerender();
|
||||
} catch (err) {
|
||||
@@ -964,7 +931,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) {
|
||||
}
|
||||
});
|
||||
panel.querySelector('[data-action="edit"]').addEventListener('click', () => {
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
window.oikos.navigate(`/tasks?open=${taskId}`);
|
||||
});
|
||||
},
|
||||
@@ -1036,35 +1003,16 @@ export async function render(container, { user }) {
|
||||
window.oikos?.showToast(t('dashboard.loadError'), 'warning');
|
||||
}
|
||||
|
||||
const today = new Date().toDateString();
|
||||
const stats = {
|
||||
overdueCount: (data.urgentTasks ?? []).filter((t) => {
|
||||
const due = formatDueDate(t.due_date, t.due_time);
|
||||
return due?.overdue === true;
|
||||
}).length,
|
||||
dueSoonCount: (data.urgentTasks ?? []).filter((t) => {
|
||||
const due = formatDueDate(t.due_date, t.due_time);
|
||||
return due?.soon === true;
|
||||
}).length,
|
||||
todayEventCount: (data.upcomingEvents ?? []).filter((e) =>
|
||||
new Date(e.start_datetime).toDateString() === today
|
||||
).length,
|
||||
todayMealTitle: (data.todayMeals ?? []).find((m) => m.meal_type === 'lunch')?.title
|
||||
?? (data.todayMeals ?? [])[0]?.title
|
||||
?? null,
|
||||
birthdayCount: data.birthdayCount ?? (data.birthdays ?? []).length,
|
||||
familyCount: (data.users ?? []).length,
|
||||
};
|
||||
|
||||
const rerender = () => render(container, { user });
|
||||
|
||||
function rebuildDashboard(cfg) {
|
||||
const shell = container.querySelector('#dashboard-shell');
|
||||
if (!shell) return;
|
||||
shell.innerHTML = `
|
||||
${renderDashboardOverview(user, stats, weather)}
|
||||
shell.replaceChildren();
|
||||
shell.insertAdjacentHTML('beforeend', `
|
||||
${renderDashboardOverview(user)}
|
||||
${renderDashboardLayout(cfg, data, weather, currency)}
|
||||
`;
|
||||
`);
|
||||
wireLinks(container, rerender);
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
wireWeatherRefresh(container, (updatedWeather) => {
|
||||
@@ -1096,6 +1044,10 @@ export async function render(container, { user }) {
|
||||
const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000);
|
||||
_fabController.signal.addEventListener('abort', () => clearInterval(timerId));
|
||||
}
|
||||
|
||||
if (!localStorage.getItem(ONBOARDING_KEY)) {
|
||||
setTimeout(() => showOnboarding(container), 400);
|
||||
}
|
||||
}
|
||||
|
||||
function wireWeatherRefresh(container, onUpdated = null) {
|
||||
|
||||
+31
-4
@@ -68,7 +68,7 @@ export async function render(container) {
|
||||
<div class="login-error" id="login-error" role="alert" aria-live="polite" hidden></div>
|
||||
|
||||
<button type="submit" class="btn btn--primary login-form__submit" id="login-btn">
|
||||
${t('login.loginButton')}
|
||||
<span class="login-btn__label">${t('login.loginButton')}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -101,13 +101,30 @@ export async function render(container) {
|
||||
const username = form.username.value.trim();
|
||||
const password = form.password.value;
|
||||
|
||||
const usernameInput = form.querySelector('#username');
|
||||
const passwordInput = form.querySelector('#password');
|
||||
const usernameGroup = usernameInput.closest('.form-group');
|
||||
const passwordGroup = passwordInput.closest('.form-group');
|
||||
|
||||
usernameGroup.classList.toggle('form-group--error', !username);
|
||||
passwordGroup.classList.toggle('form-group--error', !password);
|
||||
usernameInput.setAttribute('aria-invalid', String(!username));
|
||||
passwordInput.setAttribute('aria-invalid', String(!password));
|
||||
|
||||
if (!username || !password) {
|
||||
showError(errorEl, t('common.allFieldsRequired'));
|
||||
if (!username) usernameInput.focus();
|
||||
else passwordInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const labelEl = submitBtn.querySelector('.login-btn__label');
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t('login.loggingIn');
|
||||
labelEl.textContent = t('login.loggingIn');
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'login-spinner';
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
submitBtn.insertBefore(spinner, labelEl);
|
||||
|
||||
try {
|
||||
const result = await auth.login(username, password);
|
||||
@@ -119,9 +136,19 @@ export async function render(container) {
|
||||
);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = t('login.loginButton');
|
||||
labelEl.textContent = t('login.loginButton');
|
||||
spinner.remove();
|
||||
}
|
||||
});
|
||||
|
||||
form.querySelector('#username').addEventListener('input', (e) => {
|
||||
e.currentTarget.closest('.form-group').classList.remove('form-group--error');
|
||||
e.currentTarget.removeAttribute('aria-invalid');
|
||||
});
|
||||
form.querySelector('#password').addEventListener('input', (e) => {
|
||||
e.currentTarget.closest('.form-group').classList.remove('form-group--error');
|
||||
e.currentTarget.removeAttribute('aria-invalid');
|
||||
});
|
||||
}
|
||||
|
||||
function showError(el, message) {
|
||||
|
||||
+26
-14
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal, confirmModal } from '/components/modal.js';
|
||||
import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal } from '/components/modal.js';
|
||||
import { stagger } from '/utils/ux.js';
|
||||
import { t, formatDate } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
@@ -707,7 +707,7 @@ function openMealModal(opts) {
|
||||
if (res.data.transferred > 0) {
|
||||
window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success');
|
||||
await loadWeek(state.currentWeek);
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
renderWeekGrid();
|
||||
} else {
|
||||
window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info');
|
||||
@@ -843,8 +843,8 @@ function ingredientRowHTML(name, qty, id, category = DEFAULT_CATEGORY_NAME) {
|
||||
`;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
closeSharedModal();
|
||||
function closeModal({ force = false } = {}) {
|
||||
closeSharedModal({ force });
|
||||
state.modal = null;
|
||||
}
|
||||
|
||||
@@ -894,7 +894,7 @@ async function saveModal(overlay) {
|
||||
await loadWeek(state.currentWeek);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
renderWeekGrid();
|
||||
window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success');
|
||||
} catch (err) {
|
||||
@@ -920,15 +920,27 @@ function collectModalIngredients(overlay) {
|
||||
// --------------------------------------------------------
|
||||
|
||||
async function deleteMeal(mealId) {
|
||||
if (!await confirmModal(t('meals.deleteMeal') + '?', { danger: true, confirmLabel: t('common.delete') })) return;
|
||||
try {
|
||||
await api.delete(`/meals/${mealId}`);
|
||||
state.meals = state.meals.filter((m) => m.id !== mealId);
|
||||
renderWeekGrid();
|
||||
window.oikos?.showToast(t('meals.deleteMeal'), 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||||
}
|
||||
const meal = state.meals.find((m) => m.id === mealId);
|
||||
const itemEl = _container.querySelector(`.meal-slot--has-meal[data-meal-id="${mealId}"]`);
|
||||
if (itemEl) itemEl.style.display = 'none';
|
||||
|
||||
let undone = false;
|
||||
window.oikos?.showToast(t('meals.deletedToast'), 'default', 5000, () => {
|
||||
undone = true;
|
||||
if (itemEl) itemEl.style.display = '';
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
if (undone) return;
|
||||
try {
|
||||
await api.delete(`/meals/${mealId}`);
|
||||
state.meals = state.meals.filter((m) => m.id !== mealId);
|
||||
renderWeekGrid();
|
||||
} catch (err) {
|
||||
if (itemEl) itemEl.style.display = '';
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
+73
-19
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal, btnError, confirmModal } from '/components/modal.js';
|
||||
import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js';
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
import { t } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
@@ -19,6 +19,17 @@ const NOTE_COLORS = [
|
||||
'#90CAF9', '#CE93D8', '#FFAB91', '#FFFFFF',
|
||||
];
|
||||
|
||||
const NOTE_COLOR_NAMES = () => ({
|
||||
'#FFEB3B': t('notes.colorYellow'),
|
||||
'#FFD54F': t('notes.colorAmber'),
|
||||
'#A5D6A7': t('notes.colorGreen'),
|
||||
'#80DEEA': t('notes.colorTeal'),
|
||||
'#90CAF9': t('notes.colorBlue'),
|
||||
'#CE93D8': t('notes.colorPurple'),
|
||||
'#FFAB91': t('notes.colorOrange'),
|
||||
'#FFFFFF': t('notes.colorWhite'),
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// State
|
||||
// --------------------------------------------------------
|
||||
@@ -368,13 +379,16 @@ function openNoteModal({ mode, note = null }) {
|
||||
style="resize:vertical;">${esc(isEdit ? note.content : '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">${t('notes.colorLabel')}</label>
|
||||
<div class="note-color-picker">
|
||||
<label class="form-label" id="note-color-label">${t('notes.colorLabel')}</label>
|
||||
<div class="note-color-picker" role="radiogroup" aria-labelledby="note-color-label">
|
||||
${NOTE_COLORS.map((c) => `
|
||||
<div class="note-color-swatch ${c === selColor ? 'note-color-swatch--active' : ''}"
|
||||
data-color="${c}"
|
||||
style="background-color:${c};border:2px solid ${c === '#FFFFFF' ? '#E5E5EA' : c};"
|
||||
role="radio" tabindex="0" aria-label="Farbe ${c}"></div>
|
||||
role="radio"
|
||||
tabindex="${c === selColor ? '0' : '-1'}"
|
||||
aria-checked="${c === selColor ? 'true' : 'false'}"
|
||||
aria-label="${NOTE_COLOR_NAMES()[c] ?? c}"></div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,11 +410,34 @@ function openNoteModal({ mode, note = null }) {
|
||||
content,
|
||||
size: 'md',
|
||||
onSave(panel) {
|
||||
// Farb-Swatch
|
||||
// Farb-Swatch: Auswahl + ARIA + Keyboard (Roving Tabindex)
|
||||
function selectSwatch(target) {
|
||||
panel.querySelectorAll('.note-color-swatch').forEach((s) => {
|
||||
s.classList.remove('note-color-swatch--active');
|
||||
s.setAttribute('aria-checked', 'false');
|
||||
s.setAttribute('tabindex', '-1');
|
||||
});
|
||||
target.classList.add('note-color-swatch--active');
|
||||
target.setAttribute('aria-checked', 'true');
|
||||
target.setAttribute('tabindex', '0');
|
||||
}
|
||||
panel.querySelectorAll('.note-color-swatch').forEach((sw) => {
|
||||
sw.addEventListener('click', () => {
|
||||
panel.querySelectorAll('.note-color-swatch').forEach((s) => s.classList.remove('note-color-swatch--active'));
|
||||
sw.classList.add('note-color-swatch--active');
|
||||
sw.addEventListener('click', () => { selectSwatch(sw); sw.focus(); });
|
||||
sw.addEventListener('keydown', (e) => {
|
||||
const swatches = [...panel.querySelectorAll('.note-color-swatch')];
|
||||
const idx = swatches.indexOf(sw);
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = swatches[(idx + 1) % swatches.length];
|
||||
selectSwatch(next); next.focus();
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = swatches[(idx - 1 + swatches.length) % swatches.length];
|
||||
selectSwatch(prev); prev.focus();
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectSwatch(sw);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -445,7 +482,7 @@ function openNoteModal({ mode, note = null }) {
|
||||
if (idx !== -1) state.notes[idx] = res.data;
|
||||
state.notes.sort((a, b) => b.pinned - a.pinned);
|
||||
}
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
renderGrid();
|
||||
window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success');
|
||||
} catch (err) {
|
||||
@@ -476,16 +513,33 @@ async function togglePin(id) {
|
||||
}
|
||||
|
||||
async function deleteNote(id) {
|
||||
if (!await confirmModal(t('notes.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||
try {
|
||||
await api.delete(`/notes/${id}`);
|
||||
state.notes = state.notes.filter((n) => n.id !== id);
|
||||
renderGrid();
|
||||
vibrate([30, 50, 30]);
|
||||
window.oikos?.showToast(t('notes.deletedToast'), 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error');
|
||||
}
|
||||
closeModal({ force: true });
|
||||
const note = state.notes.find((n) => n.id === id);
|
||||
state.notes = state.notes.filter((n) => n.id !== id);
|
||||
renderGrid();
|
||||
vibrate([30, 50, 30]);
|
||||
|
||||
let undone = false;
|
||||
window.oikos?.showToast(t('notes.deletedToast'), 'default', 5000, () => {
|
||||
undone = true;
|
||||
if (note) {
|
||||
state.notes = [...state.notes, note].sort((a, b) => b.pinned - a.pinned);
|
||||
renderGrid();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
if (undone) return;
|
||||
try {
|
||||
await api.delete(`/notes/${id}`);
|
||||
} catch (err) {
|
||||
if (note) {
|
||||
state.notes = [...state.notes, note].sort((a, b) => b.pinned - a.pinned);
|
||||
renderGrid();
|
||||
}
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
+23
-17
@@ -5,7 +5,7 @@
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { t } from '/i18n.js';
|
||||
import { openModal as openSharedModal, closeModal as closeSharedModal, confirmModal } from '/components/modal.js';
|
||||
import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js';
|
||||
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
|
||||
|
||||
let _container = null;
|
||||
@@ -134,6 +134,7 @@ function renderRecipeList() {
|
||||
for (const recipe of state.recipes) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'recipe-card';
|
||||
card.dataset.id = String(recipe.id);
|
||||
|
||||
const h = document.createElement('h2');
|
||||
h.className = 'recipe-card__title';
|
||||
@@ -325,8 +326,8 @@ function openRecipeModal(mode, recipe = null) {
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
closeSharedModal();
|
||||
function closeModal({ force = false } = {}) {
|
||||
closeSharedModal({ force });
|
||||
}
|
||||
|
||||
async function saveRecipe(panel, mode, recipe) {
|
||||
@@ -360,7 +361,7 @@ async function saveRecipe(panel, mode, recipe) {
|
||||
if (idx >= 0) state.recipes[idx] = res.data;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
renderRecipeList();
|
||||
window.oikos?.showToast(mode === 'create' ? t('recipes.created') : t('recipes.updated'), 'success');
|
||||
} catch (err) {
|
||||
@@ -370,21 +371,26 @@ async function saveRecipe(panel, mode, recipe) {
|
||||
}
|
||||
|
||||
async function removeRecipe(recipe) {
|
||||
const ok = await confirmModal(t('recipes.deleteConfirm', { title: recipe.title }), {
|
||||
danger: true,
|
||||
confirmLabel: t('common.delete'),
|
||||
const itemEl = _container.querySelector(`.recipe-card[data-id="${recipe.id}"]`);
|
||||
if (itemEl) itemEl.style.display = 'none';
|
||||
|
||||
let undone = false;
|
||||
window.oikos?.showToast(t('recipes.deleted'), 'default', 5000, () => {
|
||||
undone = true;
|
||||
if (itemEl) itemEl.style.display = '';
|
||||
});
|
||||
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/recipes/${recipe.id}`);
|
||||
state.recipes = state.recipes.filter((r) => r.id !== recipe.id);
|
||||
renderRecipeList();
|
||||
window.oikos?.showToast(t('recipes.deleted'), 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error');
|
||||
}
|
||||
setTimeout(async () => {
|
||||
if (undone) return;
|
||||
try {
|
||||
await api.delete(`/recipes/${recipe.id}`);
|
||||
state.recipes = state.recipes.filter((r) => r.id !== recipe.id);
|
||||
renderRecipeList();
|
||||
} catch (err) {
|
||||
if (itemEl) itemEl.style.display = '';
|
||||
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function duplicateRecipe(recipe) {
|
||||
|
||||
+27
-16
@@ -8,7 +8,7 @@ import { api } from '/api.js';
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
import { t } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
import { promptModal, confirmModal } from '/components/modal.js';
|
||||
import { promptModal } from '/components/modal.js';
|
||||
import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -781,23 +781,34 @@ function wireListContentEvents(container) {
|
||||
|
||||
// ---- Liste löschen ----
|
||||
if (action === 'delete-list') {
|
||||
if (!await confirmModal(t('shopping.deleteListConfirm', { name: state.activeList?.name }), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||
try {
|
||||
await api.delete(`/shopping/${state.activeListId}`);
|
||||
state.lists = state.lists.filter((l) => l.id !== state.activeListId);
|
||||
state.activeListId = state.lists[0]?.id ?? null;
|
||||
if (state.activeListId) {
|
||||
await switchList(state.activeListId, container);
|
||||
} else {
|
||||
state.items = [];
|
||||
state.activeList = null;
|
||||
const deletedListId = state.activeListId;
|
||||
|
||||
let undone = false;
|
||||
window.oikos.showToast(t('shopping.deletedListToast'), 'default', 5000, () => {
|
||||
undone = true;
|
||||
// Liste wurde nie optimistisch ausgeblendet → kein visuelles Restore nötig
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
if (undone) return;
|
||||
try {
|
||||
await api.delete(`/shopping/${deletedListId}`);
|
||||
await loadLists();
|
||||
state.activeListId = state.lists[0]?.id ?? null;
|
||||
if (state.activeListId) {
|
||||
await switchList(state.activeListId, container);
|
||||
} else {
|
||||
state.items = [];
|
||||
state.activeList = null;
|
||||
renderTabs(container);
|
||||
renderListContent(container);
|
||||
}
|
||||
} catch (err) {
|
||||
window.oikos.showToast(err.message ?? t('common.unknownError'), 'danger');
|
||||
await loadLists();
|
||||
renderTabs(container);
|
||||
renderListContent(container);
|
||||
}
|
||||
window.oikos.showToast(t('shopping.deletedListToast'));
|
||||
} catch (err) {
|
||||
window.oikos.showToast(err.message, 'danger');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+33
-14
@@ -6,7 +6,7 @@
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
||||
import { openModal as openSharedModal, closeModal, wireBlurValidation, validateAll, btnSuccess, btnError, promptModal, confirmModal } from '/components/modal.js';
|
||||
import { openModal as openSharedModal, closeModal, wireBlurValidation, validateAll, btnSuccess, btnError, promptModal } from '/components/modal.js';
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
import { t, formatDate, formatTime } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
@@ -570,7 +570,7 @@ async function handleFormSubmit(e, container) {
|
||||
}
|
||||
|
||||
btnSuccess(submitBtn, originalLabel);
|
||||
setTimeout(() => closeModal(), 700);
|
||||
setTimeout(() => closeModal({ force: true }), 700);
|
||||
await loadTasks(container);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
@@ -582,18 +582,29 @@ async function handleFormSubmit(e, container) {
|
||||
}
|
||||
|
||||
async function handleDeleteTask(id, container) {
|
||||
if (!await confirmModal(t('tasks.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||
try {
|
||||
await api.delete(`/tasks/${id}`);
|
||||
// Erinnerungen für diese Aufgabe ebenfalls entfernen
|
||||
api.delete(`/reminders?entity_type=task&entity_id=${id}`).catch(() => {});
|
||||
refreshReminders();
|
||||
closeModal();
|
||||
window.oikos.showToast(t('tasks.deletedToast'), 'default');
|
||||
await loadTasks(container);
|
||||
} catch (err) {
|
||||
window.oikos.showToast(err.message, 'danger');
|
||||
}
|
||||
closeModal({ force: true });
|
||||
const itemEl = container.querySelector(`[data-task-id="${id}"]`);
|
||||
if (itemEl) itemEl.style.display = 'none';
|
||||
|
||||
let undone = false;
|
||||
window.oikos.showToast(t('tasks.deletedToast'), 'default', 5000, () => {
|
||||
undone = true;
|
||||
if (itemEl) itemEl.style.display = '';
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
if (undone) return;
|
||||
try {
|
||||
await api.delete(`/tasks/${id}`);
|
||||
// Erinnerungen für diese Aufgabe ebenfalls entfernen
|
||||
api.delete(`/reminders?entity_type=task&entity_id=${id}`).catch(() => {});
|
||||
refreshReminders();
|
||||
await loadTasks(container);
|
||||
} catch (err) {
|
||||
if (itemEl) itemEl.style.display = '';
|
||||
window.oikos.showToast(err.message ?? t('common.unknownError'), 'danger');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function handleAddSubtask(parentId, container) {
|
||||
@@ -1081,6 +1092,13 @@ function updateOverdueBadge() {
|
||||
}).length;
|
||||
|
||||
document.querySelectorAll('[data-route="/tasks"] .nav-badge').forEach((el) => el.remove());
|
||||
document.querySelectorAll('[data-route="/tasks"]').forEach((navItem) => {
|
||||
const baseLabel = t('tasks.title');
|
||||
navItem.setAttribute('aria-label', overdue > 0
|
||||
? t('tasks.navLabelOverdue', { count: overdue })
|
||||
: baseLabel
|
||||
);
|
||||
});
|
||||
if (overdue > 0) {
|
||||
document.querySelectorAll('[data-route="/tasks"]').forEach((navItem) => {
|
||||
let anchor = navItem.querySelector('.nav-item__icon-wrap');
|
||||
@@ -1097,6 +1115,7 @@ function updateOverdueBadge() {
|
||||
}
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'nav-badge';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
badge.textContent = String(overdue);
|
||||
anchor.appendChild(badge);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user