diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index eae5a44..7ca2049 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { api, S } from './lib.js'; import { TOOLS, getGroupedTools } from './toolRegistry.js'; +import CalendarWidget, { CalendarSettings } from './calendar.jsx'; import { useConfirm } from './confirm.jsx'; import { HomeIcon, AppsIcon, AdminIcon, MoreIcon, UserIcon, LogOutIcon, ChevronIcon, UpdateIcon, TrashIcon } from './icons.jsx'; @@ -704,6 +705,7 @@ function Dashboard({ toast, mobile }) {

+ @@ -1008,6 +1010,9 @@ function AdminPanel({ toast, mobile, user }) { {busy.pw ? '…' : '✓ Passwort ändern'} + + +

Löscht deinen Account und alle deine Daten unwiderruflich. diff --git a/frontend/src/calendar.jsx b/frontend/src/calendar.jsx new file mode 100644 index 0000000..9ed476e --- /dev/null +++ b/frontend/src/calendar.jsx @@ -0,0 +1,285 @@ +// ── Kalender Widget mit iCal-Unterstützung ──────────────────────────────────── +import { useState, useEffect, useMemo } from 'react'; +import { api, S } from './lib.js'; + +// Minimaler iCal-Parser (VEVENT Felder) +function parseIcal(text) { + const events = []; + const blocks = text.split('BEGIN:VEVENT'); + for (let i = 1; i < blocks.length; i++) { + const b = blocks[i]; + const get = (key) => { + const m = b.match(new RegExp(`${key}(?:;[^:]*)?:([^\r\n]+)`, 'i')); + return m ? m[1].trim() : ''; + }; + const parseDate = (s) => { + if (!s) return null; + // Remove VALUE=DATE: prefix handling + const clean = s.replace(/^[^:]*:/,''); + if (clean.length === 8) { + // all-day: YYYYMMDD + return new Date(+clean.slice(0,4), +clean.slice(4,6)-1, +clean.slice(6,8)); + } + // datetime: YYYYMMDDTHHMMSSZ + return new Date( + Date.UTC(+clean.slice(0,4), +clean.slice(4,6)-1, +clean.slice(6,8), + +clean.slice(9,11)||0, +clean.slice(11,13)||0, +clean.slice(13,15)||0) + ); + }; + const dtstart = parseDate(get('DTSTART')); + const dtend = parseDate(get('DTEND')) || dtstart; + if (!dtstart || isNaN(dtstart)) continue; + events.push({ + summary: get('SUMMARY') || '(kein Titel)', + location: get('LOCATION') || '', + dtstart, dtend, + allDay: (get('DTSTART') || '').replace(/^[^:]*:/,'').length === 8, + }); + } + return events; +} + +function sameDay(a, b) { + return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); +} + +function inRange(event, day) { + const d = new Date(day); d.setHours(0,0,0,0); + const s = new Date(event.dtstart); s.setHours(0,0,0,0); + const e = new Date(event.dtend); e.setHours(0,0,0,0); + return d >= s && d <= e; +} + +const WEEKDAYS = ['Mo','Di','Mi','Do','Fr','Sa','So']; +const MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; + +export default function CalendarWidget() { + const [isOpen, setIsOpen] = useState(false); + const [today] = useState(new Date()); + const [cur, setCur] = useState({ y: today.getFullYear(), m: today.getMonth() }); + const [events, setEvents] = useState([]); + const [feeds, setFeeds] = useState(() => { + try { return JSON.parse(localStorage.getItem('dd_cal_feeds') || '[]'); } catch { return []; } + }); + const [loading, setLoading] = useState(false); + const [selected, setSelected] = useState(null); // date object + + // Load all feeds + const loadFeeds = async (feedList) => { + if (!feedList.length) { setEvents([]); return; } + setLoading(true); + const all = []; + for (const f of feedList) { + try { + const res = await fetch(`/api/calendar/fetch?url=${encodeURIComponent(f.url)}`, { + headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` } + }); + if (res.ok) { + const text = await res.text(); + const evs = parseIcal(text).map(e => ({ ...e, color: f.color, feedName: f.name })); + all.push(...evs); + } + } catch {} + } + setEvents(all); + setLoading(false); + }; + + useEffect(() => { if (isOpen) loadFeeds(feeds); }, [isOpen]); + + // Calendar grid + const days = useMemo(() => { + const first = new Date(cur.y, cur.m, 1); + const dow = (first.getDay() + 6) % 7; // 0=Mo + const total = new Date(cur.y, cur.m+1, 0).getDate(); + const grid = []; + for (let i = 0; i < dow; i++) grid.push(null); + for (let d = 1; d <= total; d++) grid.push(new Date(cur.y, cur.m, d)); + return grid; + }, [cur]); + + const eventsOnDay = (day) => day ? events.filter(e => inRange(e, day)) : []; + const selectedEvents = selected ? events.filter(e => inRange(e, selected)) : []; + + const prevMonth = () => setCur(p => p.m === 0 ? { y:p.y-1, m:11 } : { y:p.y, m:p.m-1 }); + const nextMonth = () => setCur(p => p.m === 11 ? { y:p.y+1, m:0 } : { y:p.y, m:p.m+1 }); + + return ( +

+ {/* Header */} + + + {isOpen && ( +
+ {/* Monat Nav */} +
+ + + {MONTHS[cur.m]} {cur.y} + + +
+ + {/* Wochentage */} +
+ {WEEKDAYS.map(d => ( +
{d}
+ ))} +
+ + {/* Tage */} +
+ {days.map((day, i) => { + const evs = eventsOnDay(day); + const isToday = day && sameDay(day, today); + const isSel = day && selected && sameDay(day, selected); + return ( + + ); + })} +
+ + {/* Ausgewählter Tag Events */} + {selected && ( +
+
+ {selected.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'})} +
+ {selectedEvents.length === 0 + ?
Keine Termine
+ : selectedEvents.map((e,i) => ( +
+ +
+
{e.summary}
+ {e.location &&
{e.location}
} + {!e.allDay &&
+ {e.dtstart.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})} + {' – '}{e.dtend.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})} +
} +
{e.feedName}
+
+
+ )) + } +
+ )} + + {feeds.length === 0 && ( +
+ Noch keine Kalender abonniert. Füge Kalender in den Einstellungen hinzu. +
+ )} +
+ )} +
+ ); +} + +// ── iCal-Einstellungen (für Einstellungen-Seite) ────────────────────────────── +export function CalendarSettings({ toast }) { + const [feeds, setFeeds] = useState(() => { + try { return JSON.parse(localStorage.getItem('dd_cal_feeds') || '[]'); } catch { return []; } + }); + const [form, setForm] = useState({ name:'', url:'', color:'#4ecdc4' }); + const [testing, setTesting] = useState(false); + + const save = (newFeeds) => { + setFeeds(newFeeds); + localStorage.setItem('dd_cal_feeds', JSON.stringify(newFeeds)); + }; + + const add = async () => { + if (!form.name.trim() || !form.url.trim()) { toast('Name und URL erforderlich', 'error'); return; } + setTesting(true); + try { + const res = await fetch(`/api/calendar/fetch?url=${encodeURIComponent(form.url)}`, { + headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` } + }); + if (!res.ok) { const d=await res.json(); toast(d.error||'Feed-Fehler','error'); return; } + save([...feeds, { ...form, id: Date.now() }]); + setForm({ name:'', url:'', color:'#4ecdc4' }); + toast('Kalender hinzugefügt ✓'); + } catch(e) { toast(e.message,'error'); } finally { setTesting(false); } + }; + + const remove = (id) => save(feeds.filter(f => f.id !== id)); + + const COLORS = ['#4ecdc4','#ff6b9d','#ffe66d','#6bcb77','#a78bfa','#60a5fa','#fb923c']; + + return ( +
+
+ + setForm(f=>({...f,name:e.target.value}))} + placeholder="z.B. Google Kalender" style={{ ...S.inp, fontSize:15, marginBottom:8 }}/> + + setForm(f=>({...f,url:e.target.value}))} + placeholder="https://calendar.google.com/calendar/ical/…" + style={{ ...S.inp, fontSize:13, marginBottom:8 }} + autoCapitalize="none" autoCorrect="off"/> + +
+ {COLORS.map(col => ( +
+ +
+ + {feeds.length > 0 && ( +
+
ABONNIERTE KALENDER
+ {feeds.map(f => ( +
+ +
+
{f.name}
+
{f.url}
+
+ +
+ ))} +
+ )} +
+ ); +}