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:
@@ -19,6 +19,7 @@
|
|||||||
<!-- Preload: kritische ES-Module (modulepreload ist korrekt für type="module") -->
|
<!-- Preload: kritische ES-Module (modulepreload ist korrekt für type="module") -->
|
||||||
<link rel="modulepreload" href="/api.js" />
|
<link rel="modulepreload" href="/api.js" />
|
||||||
<link rel="modulepreload" href="/router.js" />
|
<link rel="modulepreload" href="/router.js" />
|
||||||
|
<link rel="modulepreload" href="/rrule-ui.js" />
|
||||||
|
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
<link rel="stylesheet" href="/styles/tokens.css" />
|
<link rel="stylesheet" href="/styles/tokens.css" />
|
||||||
@@ -35,6 +36,14 @@
|
|||||||
<link rel="stylesheet" href="/styles/budget.css" />
|
<link rel="stylesheet" href="/styles/budget.css" />
|
||||||
<link rel="stylesheet" href="/styles/settings.css" />
|
<link rel="stylesheet" href="/styles/settings.css" />
|
||||||
|
|
||||||
|
<!-- Theme: Vor CSS-Rendering anwenden (Flash-Prevention) -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var t = localStorage.getItem('oikos-theme');
|
||||||
|
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Lucide Icons (lokal, v0.469.0) -->
|
<!-- Lucide Icons (lokal, v0.469.0) -->
|
||||||
<script src="/lucide.min.js"></script>
|
<script src="/lucide.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
|
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -606,7 +607,7 @@ function renderAgendaEvent(ev) {
|
|||||||
<div class="agenda-event" data-id="${ev.id}">
|
<div class="agenda-event" data-id="${ev.id}">
|
||||||
<div class="agenda-event__color" style="background-color:${escHtml(ev.color)};"></div>
|
<div class="agenda-event__color" style="background-color:${escHtml(ev.color)};"></div>
|
||||||
<div class="agenda-event__body">
|
<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">
|
<div class="agenda-event__meta">
|
||||||
<span>${timeStr}</span>
|
<span>${timeStr}</span>
|
||||||
${ev.location ? `<span>📍 ${escHtml(ev.location)}</span>` : ''}
|
${ev.location ? `<span>📍 ${escHtml(ev.location)}</span>` : ''}
|
||||||
@@ -701,6 +702,9 @@ function openEventModal({ mode, event = null, date = null }) {
|
|||||||
|
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
|
|
||||||
|
// RRULE-Events binden
|
||||||
|
bindRRuleEvents(overlay, 'event');
|
||||||
|
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0];
|
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"
|
<textarea class="form-input" id="modal-description" rows="2"
|
||||||
placeholder="Optional…">${escHtml(isEdit && event.description ? event.description : '')}</textarea>
|
placeholder="Optional…">${escHtml(isEdit && event.description ? event.description : '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
|
||||||
</div>
|
</div>
|
||||||
<div class="event-modal__footer">
|
<div class="event-modal__footer">
|
||||||
${isEdit ? `<button class="btn btn--danger btn--icon" id="modal-delete" title="Löschen">
|
${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 = '…';
|
saveBtn.textContent = '…';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const rrule = getRRuleValues(overlay, 'event');
|
||||||
const body = {
|
const body = {
|
||||||
title, description, start_datetime, end_datetime,
|
title, description, start_datetime, end_datetime,
|
||||||
all_day: allday ? 1 : 0,
|
all_day: allday ? 1 : 0,
|
||||||
location, color, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null,
|
location, color, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null,
|
||||||
|
recurrence_rule: rrule.recurrence_rule,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
|
|||||||
@@ -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>` : ''}
|
${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>` : ''}
|
${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 -->
|
<!-- Mein Konto -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section__title">Mein Konto</h2>
|
<h2 class="settings-section__title">Mein Konto</h2>
|
||||||
@@ -201,6 +223,19 @@ export async function render(container, { user }) {
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
function bindEvents(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
|
// Passwort ändern
|
||||||
const passwordForm = container.querySelector('#password-form');
|
const passwordForm = container.querySelector('#password-form');
|
||||||
if (passwordForm) {
|
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' });
|
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) {
|
function showError(el, msg) {
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
|
|||||||
+17
-7
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
|
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -153,6 +154,7 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
<div class="task-card__meta">
|
<div class="task-card__meta">
|
||||||
${renderPriorityBadge(task.priority)}
|
${renderPriorityBadge(task.priority)}
|
||||||
${renderDueDate(task.due_date)}
|
${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>` : ''}
|
${task.category !== 'Sonstiges' ? `<span class="due-date">${task.category}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,6 +304,8 @@ function renderModal({ task = null, users = [] } = {}) {
|
|||||||
</select>
|
</select>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
||||||
|
${renderRRuleFields('task', task?.recurrence_rule)}
|
||||||
|
|
||||||
<div id="task-form-error" class="login-error" hidden></div>
|
<div id="task-form-error" class="login-error" hidden></div>
|
||||||
|
|
||||||
<div class="modal__actions">
|
<div class="modal__actions">
|
||||||
@@ -370,6 +374,9 @@ function openModal(html) {
|
|||||||
document.body.insertAdjacentHTML('beforeend', html);
|
document.body.insertAdjacentHTML('beforeend', html);
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
|
||||||
|
// RRULE-Events binden
|
||||||
|
bindRRuleEvents(document, 'task');
|
||||||
|
|
||||||
// Fokus auf erstes Eingabefeld
|
// Fokus auf erstes Eingabefeld
|
||||||
setTimeout(() => document.getElementById('task-title')?.focus(), 50);
|
setTimeout(() => document.getElementById('task-title')?.focus(), 50);
|
||||||
|
|
||||||
@@ -407,14 +414,17 @@ async function handleFormSubmit(e, container) {
|
|||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Wird gespeichert…';
|
submitBtn.textContent = 'Wird gespeichert…';
|
||||||
|
|
||||||
|
const rrule = getRRuleValues(document, 'task');
|
||||||
const body = {
|
const body = {
|
||||||
title: form.title.value.trim(),
|
title: form.title.value.trim(),
|
||||||
description: form.description.value.trim() || null,
|
description: form.description.value.trim() || null,
|
||||||
priority: form.priority.value,
|
priority: form.priority.value,
|
||||||
category: form.category.value,
|
category: form.category.value,
|
||||||
due_date: form.due_date?.value || null,
|
due_date: form.due_date?.value || null,
|
||||||
due_time: form.due_time?.value || null,
|
due_time: form.due_time?.value || null,
|
||||||
assigned_to: form.assigned_to.value ? Number(form.assigned_to.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;
|
if (form.status) body.status = form.status.value;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Modul: RRULE UI-Helfer
|
||||||
|
* Zweck: Wiederholungs-Formular (HTML + Logik) für Aufgaben- und Kalender-Modals
|
||||||
|
* Abhängigkeiten: keine
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FREQ_OPTIONS = [
|
||||||
|
{ value: '', label: 'Keine Wiederholung' },
|
||||||
|
{ value: 'DAILY', label: 'Täglich' },
|
||||||
|
{ value: 'WEEKLY', label: 'Wöchentlich' },
|
||||||
|
{ value: 'MONTHLY', label: 'Monatlich' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEEKDAYS = [
|
||||||
|
{ value: 'MO', label: 'Mo' },
|
||||||
|
{ value: 'TU', label: 'Di' },
|
||||||
|
{ value: 'WE', label: 'Mi' },
|
||||||
|
{ value: 'TH', label: 'Do' },
|
||||||
|
{ value: 'FR', label: 'Fr' },
|
||||||
|
{ value: 'SA', label: 'Sa' },
|
||||||
|
{ value: 'SU', label: 'So' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed einen RRULE-String in ein Objekt für die UI.
|
||||||
|
* @param {string|null} rule - z.B. "FREQ=WEEKLY;BYDAY=MO,TH;INTERVAL=2"
|
||||||
|
* @returns {{ freq: string, interval: number, byday: string[], until: string }}
|
||||||
|
*/
|
||||||
|
export function parseRRule(rule) {
|
||||||
|
const result = { freq: '', interval: 1, byday: [], until: '' };
|
||||||
|
if (!rule) return result;
|
||||||
|
|
||||||
|
for (const segment of rule.split(';')) {
|
||||||
|
const eq = segment.indexOf('=');
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = segment.slice(0, eq).toUpperCase();
|
||||||
|
const val = segment.slice(eq + 1);
|
||||||
|
|
||||||
|
if (key === 'FREQ') result.freq = val;
|
||||||
|
if (key === 'INTERVAL') result.interval = parseInt(val, 10) || 1;
|
||||||
|
if (key === 'BYDAY') result.byday = val.split(',').map(d => d.trim());
|
||||||
|
if (key === 'UNTIL') {
|
||||||
|
// YYYYMMDD → YYYY-MM-DD
|
||||||
|
const c = val.replace(/[TZ]/g, '');
|
||||||
|
result.until = `${c.slice(0, 4)}-${c.slice(4, 6)}-${c.slice(6, 8)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut einen RRULE-String aus den UI-Werten.
|
||||||
|
* @param {{ freq: string, interval: number, byday: string[], until: string }} opts
|
||||||
|
* @returns {string|null} - RRULE-String oder null (keine Wiederholung)
|
||||||
|
*/
|
||||||
|
export function buildRRule({ freq, interval, byday, until }) {
|
||||||
|
if (!freq) return null;
|
||||||
|
|
||||||
|
const parts = [`FREQ=${freq}`];
|
||||||
|
if (interval > 1) parts.push(`INTERVAL=${interval}`);
|
||||||
|
if (freq === 'WEEKLY' && byday.length > 0) {
|
||||||
|
parts.push(`BYDAY=${byday.join(',')}`);
|
||||||
|
}
|
||||||
|
if (until) {
|
||||||
|
parts.push(`UNTIL=${until.replace(/-/g, '')}T235959Z`);
|
||||||
|
}
|
||||||
|
return parts.join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert das HTML für die Wiederholungs-Felder.
|
||||||
|
* @param {string} prefix - ID-Prefix (z.B. "task" oder "event")
|
||||||
|
* @param {string|null} existingRule - bestehende RRULE oder null
|
||||||
|
* @returns {string} HTML-String
|
||||||
|
*/
|
||||||
|
export function renderRRuleFields(prefix, existingRule) {
|
||||||
|
const parsed = parseRRule(existingRule);
|
||||||
|
|
||||||
|
const freqOpts = FREQ_OPTIONS.map(o =>
|
||||||
|
`<option value="${o.value}" ${parsed.freq === o.value ? 'selected' : ''}>${o.label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const dayBtns = WEEKDAYS.map(d =>
|
||||||
|
`<button type="button" class="rrule-day ${parsed.byday.includes(d.value) ? 'rrule-day--active' : ''}"
|
||||||
|
data-day="${d.value}" aria-label="${d.label}" aria-pressed="${parsed.byday.includes(d.value)}">${d.label}</button>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rrule-fields" id="${prefix}-rrule-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label form-label" for="${prefix}-rrule-freq">Wiederholung</label>
|
||||||
|
<select class="input form-input" id="${prefix}-rrule-freq" style="min-height:44px">
|
||||||
|
${freqOpts}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rrule-details" id="${prefix}-rrule-details" ${parsed.freq ? '' : 'hidden'}>
|
||||||
|
<div class="rrule-row">
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label class="label form-label" for="${prefix}-rrule-interval">Alle</label>
|
||||||
|
<div class="rrule-interval-wrap">
|
||||||
|
<input class="input form-input" type="number" id="${prefix}-rrule-interval"
|
||||||
|
min="1" max="99" value="${parsed.interval}" style="width:64px;text-align:center">
|
||||||
|
<span class="rrule-interval-unit" id="${prefix}-rrule-unit">${unitLabel(parsed.freq, parsed.interval)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rrule-weekdays" id="${prefix}-rrule-weekdays" ${parsed.freq === 'WEEKLY' ? '' : 'hidden'}>
|
||||||
|
<label class="label form-label">An diesen Tagen</label>
|
||||||
|
<div class="rrule-day-grid">${dayBtns}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top:var(--space-3)">
|
||||||
|
<label class="label form-label" for="${prefix}-rrule-until">Endet am (optional)</label>
|
||||||
|
<input class="input form-input" type="date" id="${prefix}-rrule-until" value="${parsed.until}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitLabel(freq, interval) {
|
||||||
|
const n = interval > 1;
|
||||||
|
if (freq === 'DAILY') return n ? 'Tage' : 'Tag';
|
||||||
|
if (freq === 'WEEKLY') return n ? 'Wochen' : 'Woche';
|
||||||
|
if (freq === 'MONTHLY') return n ? 'Monate' : 'Monat';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bindet Events an die RRULE-Felder (Freq-Change, Day-Toggle, etc.)
|
||||||
|
* @param {HTMLElement} root - Container-Element
|
||||||
|
* @param {string} prefix - ID-Prefix
|
||||||
|
*/
|
||||||
|
export function bindRRuleEvents(root, prefix) {
|
||||||
|
const freqSelect = root.querySelector(`#${prefix}-rrule-freq`);
|
||||||
|
const details = root.querySelector(`#${prefix}-rrule-details`);
|
||||||
|
const weekdays = root.querySelector(`#${prefix}-rrule-weekdays`);
|
||||||
|
const unitEl = root.querySelector(`#${prefix}-rrule-unit`);
|
||||||
|
const intervalEl = root.querySelector(`#${prefix}-rrule-interval`);
|
||||||
|
|
||||||
|
if (!freqSelect) return;
|
||||||
|
|
||||||
|
freqSelect.addEventListener('change', () => {
|
||||||
|
const freq = freqSelect.value;
|
||||||
|
if (details) details.hidden = !freq;
|
||||||
|
if (weekdays) weekdays.hidden = freq !== 'WEEKLY';
|
||||||
|
updateUnit();
|
||||||
|
});
|
||||||
|
|
||||||
|
intervalEl?.addEventListener('input', updateUnit);
|
||||||
|
|
||||||
|
// Day-Toggle
|
||||||
|
root.querySelectorAll(`#${prefix}-rrule-weekdays .rrule-day`).forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
btn.classList.toggle('rrule-day--active');
|
||||||
|
btn.setAttribute('aria-pressed', btn.classList.contains('rrule-day--active'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateUnit() {
|
||||||
|
if (!unitEl) return;
|
||||||
|
const interval = parseInt(intervalEl?.value, 10) || 1;
|
||||||
|
unitEl.textContent = unitLabel(freqSelect.value, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die aktuellen RRULE-Werte aus dem Formular.
|
||||||
|
* @param {HTMLElement} root - Container-Element
|
||||||
|
* @param {string} prefix - ID-Prefix
|
||||||
|
* @returns {{ is_recurring: boolean, recurrence_rule: string|null }}
|
||||||
|
*/
|
||||||
|
export function getRRuleValues(root, prefix) {
|
||||||
|
const freq = root.querySelector(`#${prefix}-rrule-freq`)?.value || '';
|
||||||
|
const interval = parseInt(root.querySelector(`#${prefix}-rrule-interval`)?.value, 10) || 1;
|
||||||
|
const until = root.querySelector(`#${prefix}-rrule-until`)?.value || '';
|
||||||
|
|
||||||
|
const byday = [];
|
||||||
|
root.querySelectorAll(`#${prefix}-rrule-weekdays .rrule-day--active`).forEach(btn => {
|
||||||
|
byday.push(btn.dataset.day);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rule = buildRRule({ freq, interval, byday, until });
|
||||||
|
return {
|
||||||
|
is_recurring: !!rule,
|
||||||
|
recurrence_rule: rule,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1020,6 +1020,84 @@
|
|||||||
to { opacity: 0; transform: scale(0.95) translateY(4px); }
|
to { opacity: 0; transform: scale(0.95) translateY(4px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* RRULE-Felder (Wiederholungs-Formular, shared Tasks + Kalender)
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
|
||||||
|
.rrule-fields {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rrule-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rrule-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rrule-interval-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rrule-interval-unit {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rrule-day-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rrule-day {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rrule-day:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rrule-day--active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rrule-day--active:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
border-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Print-Styles
|
* Print-Styles
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
@@ -262,6 +262,49 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Theme-Toggle
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle__btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle__btn:hover {
|
||||||
|
border-color: var(--color-text-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle__btn--active {
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle__btn--active:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
Abmelden
|
Abmelden
|
||||||
-------------------------------------------------------- */
|
-------------------------------------------------------- */
|
||||||
|
|||||||
@@ -241,9 +241,13 @@
|
|||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Dark Mode
|
* Dark Mode
|
||||||
|
* Zwei Selektoren: (1) System-Preference, (2) manueller Override
|
||||||
|
* via data-theme="dark" auf <html>.
|
||||||
|
* data-theme="light" erzwingt Light Mode (kein Dark-Override).
|
||||||
|
* Ohne data-theme folgt die App der System-Einstellung.
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root:not([data-theme="light"]) {
|
||||||
/* Neutral-Skala invertiert (warm-dunkel) */
|
/* Neutral-Skala invertiert (warm-dunkel) */
|
||||||
--neutral-50: #1A1A18;
|
--neutral-50: #1A1A18;
|
||||||
--neutral-100: #222220;
|
--neutral-100: #222220;
|
||||||
@@ -301,3 +305,52 @@
|
|||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.45);
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Manueller Dark-Mode-Override: data-theme="dark" auf <html> */
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--neutral-50: #1A1A18;
|
||||||
|
--neutral-100: #222220;
|
||||||
|
--neutral-150: #2A2A28;
|
||||||
|
--neutral-200: #333331;
|
||||||
|
--neutral-250: #3D3D3A;
|
||||||
|
--neutral-300: #48484A;
|
||||||
|
--neutral-400: #636360;
|
||||||
|
--neutral-500: #8E8D89;
|
||||||
|
--neutral-600: #AEADB0;
|
||||||
|
--neutral-700: #C8C7C3;
|
||||||
|
--neutral-800: #E2E1DC;
|
||||||
|
--neutral-900: #F5F4F1;
|
||||||
|
--neutral-950: #FAFAF8;
|
||||||
|
|
||||||
|
--color-surface: #2A2A28;
|
||||||
|
--color-surface-2: #1A1A18;
|
||||||
|
--color-surface-3: #333331;
|
||||||
|
|
||||||
|
--sidebar-bg: #1A1A18;
|
||||||
|
--sidebar-shadow-light: rgba(255, 255, 255, 0.04);
|
||||||
|
--sidebar-shadow-dark: rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
--color-accent-light: #1A2D4D;
|
||||||
|
--color-accent-subtle: #162442;
|
||||||
|
--color-success-light: #1A3325;
|
||||||
|
--color-warning-light: #332400;
|
||||||
|
--color-danger-light: #3D1C1A;
|
||||||
|
--color-info-light: #1A2D40;
|
||||||
|
|
||||||
|
--meal-breakfast-light: #332400;
|
||||||
|
--meal-lunch-light: #1A3325;
|
||||||
|
--meal-dinner-light: #1A2D4D;
|
||||||
|
--meal-snack-light: #3D2010;
|
||||||
|
|
||||||
|
--color-priority-low-bg: rgba(142, 141, 137, 0.18);
|
||||||
|
--color-priority-medium-bg: rgba(230, 147, 10, 0.18);
|
||||||
|
--color-priority-high-bg: rgba(212, 81, 30, 0.18);
|
||||||
|
--color-priority-urgent-bg: rgba(229, 83, 75, 0.18);
|
||||||
|
|
||||||
|
--color-overlay: rgba(0, 0, 0, 0.6);
|
||||||
|
--color-overlay-light: rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|||||||
+4
-3
@@ -12,9 +12,9 @@
|
|||||||
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
|
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SHELL_CACHE = 'oikos-shell-v10';
|
const SHELL_CACHE = 'oikos-shell-v11';
|
||||||
const PAGES_CACHE = 'oikos-pages-v10';
|
const PAGES_CACHE = 'oikos-pages-v11';
|
||||||
const ASSETS_CACHE = 'oikos-assets-v10';
|
const ASSETS_CACHE = 'oikos-assets-v11';
|
||||||
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
||||||
|
|
||||||
// App-Shell: sofort benötigt für ersten Render
|
// App-Shell: sofort benötigt für ersten Render
|
||||||
@@ -23,6 +23,7 @@ const APP_SHELL = [
|
|||||||
'/index.html',
|
'/index.html',
|
||||||
'/api.js',
|
'/api.js',
|
||||||
'/router.js',
|
'/router.js',
|
||||||
|
'/rrule-ui.js',
|
||||||
'/sw-register.js',
|
'/sw-register.js',
|
||||||
'/lucide.min.js',
|
'/lucide.min.js',
|
||||||
'/styles/tokens.css',
|
'/styles/tokens.css',
|
||||||
|
|||||||
+27
-20
@@ -143,13 +143,15 @@ router.post('/', (req, res) => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
description = null,
|
description = null,
|
||||||
category = 'Sonstiges',
|
category = 'Sonstiges',
|
||||||
priority = 'medium',
|
priority = 'medium',
|
||||||
due_date = null,
|
due_date = null,
|
||||||
due_time = null,
|
due_time = null,
|
||||||
assigned_to = null,
|
assigned_to = null,
|
||||||
parent_task_id = null,
|
parent_task_id = null,
|
||||||
|
is_recurring = 0,
|
||||||
|
recurrence_rule = null,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Tiefe begrenzen: Subtasks dürfen keine eigenen Subtasks haben (max. 2 Ebenen)
|
// Tiefe begrenzen: Subtasks dürfen keine eigenen Subtasks haben (max. 2 Ebenen)
|
||||||
@@ -164,11 +166,12 @@ router.post('/', (req, res) => {
|
|||||||
const result = db.get().prepare(`
|
const result = db.get().prepare(`
|
||||||
INSERT INTO tasks
|
INSERT INTO tasks
|
||||||
(title, description, category, priority, due_date, due_time,
|
(title, description, category, priority, due_date, due_time,
|
||||||
assigned_to, created_by, parent_task_id)
|
assigned_to, created_by, parent_task_id, is_recurring, recurrence_rule)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
title.trim(), description, category, priority,
|
title.trim(), description, category, priority,
|
||||||
due_date, due_time, assigned_to, req.session.userId, parent_task_id
|
due_date, due_time, assigned_to, req.session.userId, parent_task_id,
|
||||||
|
is_recurring ? 1 : 0, recurrence_rule
|
||||||
);
|
);
|
||||||
|
|
||||||
const task = db.get().prepare(`
|
const task = db.get().prepare(`
|
||||||
@@ -200,23 +203,27 @@ router.put('/:id', (req, res) => {
|
|||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title = task.title,
|
title = task.title,
|
||||||
description = task.description,
|
description = task.description,
|
||||||
category = task.category,
|
category = task.category,
|
||||||
priority = task.priority,
|
priority = task.priority,
|
||||||
status = task.status,
|
status = task.status,
|
||||||
due_date = task.due_date,
|
due_date = task.due_date,
|
||||||
due_time = task.due_time,
|
due_time = task.due_time,
|
||||||
assigned_to = task.assigned_to,
|
assigned_to = task.assigned_to,
|
||||||
|
is_recurring = task.is_recurring,
|
||||||
|
recurrence_rule = task.recurrence_rule,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
db.get().prepare(`
|
db.get().prepare(`
|
||||||
UPDATE tasks SET
|
UPDATE tasks SET
|
||||||
title = ?, description = ?, category = ?, priority = ?,
|
title = ?, description = ?, category = ?, priority = ?,
|
||||||
status = ?, due_date = ?, due_time = ?, assigned_to = ?
|
status = ?, due_date = ?, due_time = ?, assigned_to = ?,
|
||||||
|
is_recurring = ?, recurrence_rule = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(title.trim(), description, category, priority,
|
`).run(title.trim(), description, category, priority,
|
||||||
status, due_date, due_time, assigned_to, req.params.id);
|
status, due_date, due_time, assigned_to,
|
||||||
|
is_recurring ? 1 : 0, recurrence_rule, req.params.id);
|
||||||
|
|
||||||
const updated = db.get().prepare(`
|
const updated = db.get().prepare(`
|
||||||
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
|
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
|
||||||
|
|||||||
Reference in New Issue
Block a user