feat: Dark Mode Toggle + RRULE UI für wiederkehrende Aufgaben/Termine

Dark Mode: Manueller Theme-Switch (System/Hell/Dunkel) in Einstellungen
mit localStorage-Persistenz und Flash-Prevention via data-theme Attribut.

RRULE UI: Wiederholungs-Formular in Aufgaben- und Kalender-Modals mit
Frequenz (Täglich/Wöchentlich/Monatlich), Intervall, Wochentag-Auswahl
und optionalem Enddatum. Backend-Routen für is_recurring/recurrence_rule
in POST/PUT erweitert. Repeat-Icon auf wiederkehrenden Einträgen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-03-26 00:11:45 +01:00
parent 093b6a8736
commit f507ef8488
10 changed files with 479 additions and 32 deletions
+9 -1
View File
@@ -5,6 +5,7 @@
*/
import { api } from '/api.js';
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
// --------------------------------------------------------
// Konstanten
@@ -606,7 +607,7 @@ function renderAgendaEvent(ev) {
<div class="agenda-event" data-id="${ev.id}">
<div class="agenda-event__color" style="background-color:${escHtml(ev.color)};"></div>
<div class="agenda-event__body">
<div class="agenda-event__title">${escHtml(ev.title)}</div>
<div class="agenda-event__title">${escHtml(ev.title)}${ev.recurrence_rule ? ' <i data-lucide="repeat" style="width:12px;height:12px;display:inline;vertical-align:middle;opacity:0.5"></i>' : ''}</div>
<div class="agenda-event__meta">
<span>${timeStr}</span>
${ev.location ? `<span>📍 ${escHtml(ev.location)}</span>` : ''}
@@ -701,6 +702,9 @@ function openEventModal({ mode, event = null, date = null }) {
if (window.lucide) lucide.createIcons();
// RRULE-Events binden
bindRRuleEvents(overlay, 'event');
const isEdit = mode === 'edit';
const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0];
@@ -845,6 +849,8 @@ function buildEventModalHTML({ mode, event, date }) {
<textarea class="form-input" id="modal-description" rows="2"
placeholder="Optional…">${escHtml(isEdit && event.description ? event.description : '')}</textarea>
</div>
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
</div>
<div class="event-modal__footer">
${isEdit ? `<button class="btn btn--danger btn--icon" id="modal-delete" title="Löschen">
@@ -909,10 +915,12 @@ async function saveEvent(overlay, mode, eventId) {
saveBtn.textContent = '…';
try {
const rrule = getRRuleValues(overlay, 'event');
const body = {
title, description, start_datetime, end_datetime,
all_day: allday ? 1 : 0,
location, color, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null,
recurrence_rule: rrule.recurrence_rule,
};
if (mode === 'create') {
+48
View File
@@ -41,6 +41,28 @@ export async function render(container, { user }) {
${syncOk ? `<div class="settings-banner settings-banner--success">Kalender-Sync mit ${syncOk === 'google' ? 'Google' : 'Apple'} erfolgreich verbunden.</div>` : ''}
${syncErr ? `<div class="settings-banner settings-banner--error">Verbindung mit ${syncErr === 'google' ? 'Google' : 'Apple'} fehlgeschlagen. Bitte erneut versuchen.</div>` : ''}
<!-- Design -->
<section class="settings-section">
<h2 class="settings-section__title">Design</h2>
<div class="settings-card">
<h3 class="settings-card__title">Darstellung</h3>
<div class="theme-toggle" id="theme-toggle">
<button class="theme-toggle__btn ${currentTheme() === 'system' ? 'theme-toggle__btn--active' : ''}" data-theme-value="system" aria-label="System-Einstellung verwenden">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
System
</button>
<button class="theme-toggle__btn ${currentTheme() === 'light' ? 'theme-toggle__btn--active' : ''}" data-theme-value="light" aria-label="Helles Design">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
Hell
</button>
<button class="theme-toggle__btn ${currentTheme() === 'dark' ? 'theme-toggle__btn--active' : ''}" data-theme-value="dark" aria-label="Dunkles Design">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
Dunkel
</button>
</div>
</div>
</section>
<!-- Mein Konto -->
<section class="settings-section">
<h2 class="settings-section__title">Mein Konto</h2>
@@ -201,6 +223,19 @@ export async function render(container, { user }) {
// --------------------------------------------------------
function bindEvents(container, user) {
// 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');
});
}
// Passwort ändern
const passwordForm = container.querySelector('#password-form');
if (passwordForm) {
@@ -400,6 +435,19 @@ function formatDate(iso) {
return new Date(iso).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function currentTheme() {
return localStorage.getItem('oikos-theme') || 'system';
}
function applyTheme(value) {
localStorage.setItem('oikos-theme', value);
if (value === 'light' || value === 'dark') {
document.documentElement.setAttribute('data-theme', value);
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function showError(el, msg) {
el.textContent = msg;
el.hidden = false;
+17 -7
View File
@@ -5,6 +5,7 @@
*/
import { api } from '/api.js';
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
// --------------------------------------------------------
// Konstanten
@@ -153,6 +154,7 @@ function renderTaskCard(task, opts = {}) {
<div class="task-card__meta">
${renderPriorityBadge(task.priority)}
${renderDueDate(task.due_date)}
${task.is_recurring ? '<span class="due-date" title="Wiederkehrend"><i data-lucide="repeat" style="width:12px;height:12px"></i></span>' : ''}
${task.category !== 'Sonstiges' ? `<span class="due-date">${task.category}</span>` : ''}
</div>
</div>
@@ -302,6 +304,8 @@ function renderModal({ task = null, users = [] } = {}) {
</select>
</div>` : ''}
${renderRRuleFields('task', task?.recurrence_rule)}
<div id="task-form-error" class="login-error" hidden></div>
<div class="modal__actions">
@@ -370,6 +374,9 @@ function openModal(html) {
document.body.insertAdjacentHTML('beforeend', html);
if (window.lucide) window.lucide.createIcons();
// RRULE-Events binden
bindRRuleEvents(document, 'task');
// Fokus auf erstes Eingabefeld
setTimeout(() => document.getElementById('task-title')?.focus(), 50);
@@ -407,14 +414,17 @@ async function handleFormSubmit(e, container) {
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gespeichert…';
const rrule = getRRuleValues(document, 'task');
const body = {
title: form.title.value.trim(),
description: form.description.value.trim() || null,
priority: form.priority.value,
category: form.category.value,
due_date: form.due_date?.value || null,
due_time: form.due_time?.value || null,
assigned_to: form.assigned_to.value ? Number(form.assigned_to.value) : null,
title: form.title.value.trim(),
description: form.description.value.trim() || null,
priority: form.priority.value,
category: form.category.value,
due_date: form.due_date?.value || null,
due_time: form.due_time?.value || null,
assigned_to: form.assigned_to.value ? Number(form.assigned_to.value) : null,
is_recurring: rrule.is_recurring ? 1 : 0,
recurrence_rule: rrule.recurrence_rule,
};
if (form.status) body.status = form.status.value;