Files
dickendock/frontend/src/calendar.jsx

398 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── Kalender Widget mit iCal-Unterstützung ────────────────────────────────────
import { useState, useEffect, useMemo } from 'react';
import { api, S } from './lib.js';
// iCal-Parser unterstützt gefaltete Zeilen, TZID, VALUE=DATE
function parseIcal(raw) {
// 1. Gefaltete Zeilen (RFC 5545) zusammenführen
const text = raw.replace(/\r\n[ \t]/g, '').replace(/\r/g, '');
// 2. Alle Properties als Map extrahieren
function getProps(block) {
// VALARM-Blöcke entfernen (haben eigene SUMMARY/DESCRIPTION die nicht zum Termin gehören)
const stripped = block.replace(/BEGIN:VALARM[\s\S]*?END:VALARM/g, '');
const props = {};
for (const line of stripped.split('\n')) {
const col = line.indexOf(':');
if (col < 0) continue;
const namepart = line.slice(0, col).toUpperCase();
const val = line.slice(col + 1).trim();
// Basisname ohne Parameter
const name = namepart.split(';')[0];
// TZID-Parameter extrahieren
const tzidM = namepart.match(/TZID=([^;:]+)/);
props[name] = { val, raw: namepart, tzid: tzidM?.[1] || null };
}
return props;
}
// 3. Datum parsen Ortszeit wenn TZID gesetzt, UTC wenn Z-Suffix
function parseDate(prop, isEnd) {
if (!prop) return null;
const s = prop.val;
if (!s) return null;
const allDay = s.length === 8 || prop.raw.includes('VALUE=DATE');
if (allDay) {
// DTEND bei all-day ist exklusiv → einen Tag abziehen
const d = new Date(+s.slice(0,4), +s.slice(4,6)-1, +s.slice(6,8));
if (isEnd) d.setDate(d.getDate() - 1);
return { date: d, allDay: true };
}
const utc = s.endsWith('Z');
const y=+s.slice(0,4), mo=+s.slice(4,6)-1, d=+s.slice(6,8);
const h=+s.slice(9,11)||0, mi=+s.slice(11,13)||0, sec=+s.slice(13,15)||0;
const date = utc
? new Date(Date.UTC(y,mo,d,h,mi,sec))
: new Date(y,mo,d,h,mi,sec); // Ortszeit des Browsers (beste Annäherung)
return { date, allDay: false };
}
const events = [];
const blocks = text.split('BEGIN:VEVENT');
for (let i = 1; i < blocks.length; i++) {
const end = blocks[i].indexOf('END:VEVENT');
const block = end >= 0 ? blocks[i].slice(0, end) : blocks[i];
const p = getProps(block);
const startObj = parseDate(p['DTSTART']);
const endObj = parseDate(p['DTEND'], true) || startObj;
if (!startObj || isNaN(startObj.date)) continue;
events.push({
summary: (p['SUMMARY']?.val || '(kein Titel)').replace(/\\n/g,'\n').replace(/\\,/g,','),
location: (p['LOCATION']?.val || '').replace(/\\n/g,'\n').replace(/\\,/g,','),
dtstart: startObj.date,
dtend: endObj.date,
allDay: startObj.allDay,
});
}
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([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState(new Date()); // default: heute
const [errors, setErrors] = useState([]);
// Load all feeds
const loadFeeds = async (feedList) => {
if (!feedList.length) { setEvents([]); setDebug(['Keine Feeds konfiguriert']); return; }
setLoading(true);
setErrors([]);
const all = [];
const errs = [];
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);
} else {
const d = await res.json().catch(()=>({error:'Unbekannter Fehler'}));
errs.push(`${f.name}: ${d.error}`);
}
} catch(e) { errs.push(`${f.name}: ${e.message}`); }
}
setEvents(all);
setErrors(errs);
setLoading(false);
};
const refresh = () => { api('/calendar/feeds').then(f=>{ setFeeds(f); loadFeeds(f); }).catch(()=>{}); };
// Load on mount so next event shows without opening
useEffect(() => { refresh(); }, []);
// Reload when opened
useEffect(() => { if (isOpen) refresh(); }, [isOpen]);
// Nächster oder aktueller Termin (für zugeklappte Ansicht)
const nextEvent = useMemo(() => {
const now = new Date();
const upcoming = events
.filter(e => e.dtend >= now)
.sort((a,b) => a.dtstart - b.dtstart);
return upcoming[0] || null;
}, [events]);
// 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 (
<div style={{ ...S.card, marginBottom:10 }}>
{/* Header */}
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center' }}>
<button onClick={()=>setIsOpen(v=>!v)} style={{ flex:1, background:'transparent', border:'none',
display:'flex', justifyContent:'space-between', alignItems:'center', cursor:'pointer', padding:0, minWidth:0 }}>
<div style={{ display:'flex', alignItems:'center', gap:10, flex:1, minWidth:0 }}>
<div style={S.head}>KALENDER</div>
{!isOpen && nextEvent && (
<div style={{ display:'flex', alignItems:'center', gap:6, flex:1, minWidth:0, overflow:'hidden' }}>
<span style={{ width:6, height:6, borderRadius:'50%', background:nextEvent.color||'#4ecdc4',
flexShrink:0, display:'inline-block' }}/>
<span style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:10,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{nextEvent.summary}
</span>
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{nextEvent.allDay
? nextEvent.dtstart.toLocaleDateString('de-DE',{day:'numeric',month:'short'})
: nextEvent.dtstart.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})
}
</span>
</div>
)}
</div>
<span style={{ color:'rgba(255,255,255,0.4)', fontSize:11, display:'inline-block', flexShrink:0,
transform:isOpen?'rotate(90deg)':'rotate(0)', transition:'transform 0.2s' }}></span>
</button>
{isOpen && feeds.length > 0 && (
<button onClick={refresh} disabled={loading}
style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.4)',
cursor:'pointer', fontSize:13, padding:'0 0 0 8px', marginLeft:8 }}
title="Neu laden">
{loading ? '⏳' : '↻'}
</button>
)}
</div>
{isOpen && (
<div style={{ marginTop:12 }}>
{/* Fehler */}
{errors.length > 0 && (
<div style={{ marginBottom:10 }}>
{errors.map((e,i) => (
<div key={i} style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:10,
background:'rgba(255,107,157,0.08)', borderRadius:6, padding:'4px 8px', marginBottom:3 }}>
{e}
</div>
))}
</div>
)}
{/* Monat Nav */}
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:10 }}>
<button onClick={prevMonth} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)',
cursor:'pointer', fontSize:18, padding:'0 6px' }}></button>
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<span style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700 }}>
{MONTHS[cur.m]} {cur.y}
</span>
{(!selected || !sameDay(selected, today) || cur.m !== today.getMonth() || cur.y !== today.getFullYear()) && (
<button onClick={() => { setCur({ y:today.getFullYear(), m:today.getMonth() }); setSelected(new Date()); }}
style={{ background:'rgba(78,205,196,0.15)', border:'1px solid rgba(78,205,196,0.3)',
borderRadius:6, color:'#4ecdc4', fontFamily:'monospace', fontSize:9,
cursor:'pointer', padding:'2px 8px' }}>Heute</button>
)}
</div>
<button onClick={nextMonth} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)',
cursor:'pointer', fontSize:18, padding:'0 6px' }}></button>
</div>
<div style={{ display: window.innerWidth>=768 ? 'flex' : 'block', gap:16, alignItems:'flex-start' }}>
{/* Kalender-Grid */}
<div style={{ flex:'0 0 auto', width: window.innerWidth>=768 ? 'min(300px,50%)' : '100%' }}>
{/* Wochentage */}
<div style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', gap:2, marginBottom:4 }}>
{WEEKDAYS.map(d => (
<div key={d} style={{ textAlign:'center', color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:9 }}>{d}</div>
))}
</div>
{/* Tage */}
<div style={{ display:'grid', gridTemplateColumns:'repeat(7,1fr)', gap:2 }}>
{days.map((day, i) => {
const evs = eventsOnDay(day);
const isToday = day && sameDay(day, today);
const isSel = day && selected && sameDay(day, selected);
return (
<button key={i} onClick={()=>day&&setSelected(isSel?null:day)} style={{
aspectRatio:'1', borderRadius:6, border:'none', cursor:day?'pointer':'default',
background: isSel ? 'rgba(78,205,196,0.25)' : isToday ? 'rgba(78,205,196,0.1)' : 'transparent',
outline: isToday ? '1px solid rgba(78,205,196,0.4)' : 'none',
position:'relative', padding:0, display:'flex', flexDirection:'column',
alignItems:'center', justifyContent:'center',
}}>
{day && <>
<span style={{ color: isSel?'#4ecdc4':isToday?'#4ecdc4':'rgba(255,255,255,0.75)',
fontFamily:'monospace', fontSize:11, fontWeight:isToday?700:400 }}>
{day.getDate()}
</span>
{evs.length > 0 && (
<div style={{ display:'flex', gap:1, flexWrap:'wrap', justifyContent:'center', marginTop:1 }}>
{evs.slice(0,3).map((e,j) => (
<span key={j} style={{ width:4, height:4, borderRadius:'50%',
background: e.color || '#4ecdc4', display:'inline-block' }}/>
))}
</div>
)}
</>}
</button>
);
})}
</div>
</div>{/* Ende Kalender-Grid */}
{/* Termine */}
<div style={{ flex:1, minWidth:0, width:'100%', marginTop: window.innerWidth>=768 ? 0 : 10 }}>
{/* Ausgewählter Tag Events */}
{selected && (
<div style={{ marginTop:10, borderTop:'1px solid rgba(255,255,255,0.07)', paddingTop:10 }}>
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginBottom:6 }}>
{selected.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'})}
</div>
{selectedEvents.length === 0
? <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>Keine Termine</div>
: selectedEvents.map((e,i) => (
<div key={i} style={{ display:'flex', gap:8, padding:'6px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
<span style={{ width:3, borderRadius:2, background:e.color||'#4ecdc4', flexShrink:0 }}/>
<div>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:12 }}>{e.summary}</div>
{e.location && <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10 }}>{e.location}</div>}
{!e.allDay && <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10 }}>
{e.dtstart.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}
{' '}{e.dtend.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}
</div>}
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:9 }}>{e.feedName}</div>
</div>
</div>
))
}
</div>
)}
</div>{/* Ende Termine */}
</div>{/* Ende Flex-Layout */}
{feeds.length === 0 && (
<div style={{ marginTop:10, color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11, textAlign:'center' }}>
Noch keine Kalender abonniert. Füge Kalender in den Einstellungen hinzu.
</div>
)}
</div>
)}
</div>
);
}
// ── iCal-Einstellungen (für Einstellungen-Seite) ──────────────────────────────
export function CalendarSettings({ toast }) {
const [feeds, setFeeds] = useState([]);
const [form, setForm] = useState({ name:'', url:'', color:'#4ecdc4' });
const [testing, setTesting] = useState(false);
useEffect(() => { api('/calendar/feeds').then(setFeeds).catch(()=>{}); }, []);
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; }
const newFeed = await api('/calendar/feeds', { body: { name:form.name.trim(), url:form.url.trim(), color:form.color } });
setFeeds(p => [...p, newFeed]);
setForm({ name:'', url:'', color:'#4ecdc4' });
toast('Kalender hinzugefügt ✓');
} catch(e) { toast(e.message,'error'); } finally { setTesting(false); }
};
const remove = async (id) => {
try { await api(`/calendar/feeds/${id}`, {method:'DELETE'}); setFeeds(p=>p.filter(f=>f.id!==id)); toast('Entfernt'); }
catch(e) { toast(e.message,'error'); }
};
const COLORS = ['#4ecdc4','#ff6b9d','#ffe66d','#6bcb77','#a78bfa','#60a5fa','#fb923c'];
return (
<div>
<div style={{ marginBottom:12 }}>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>NAME</label>
<input value={form.name} onChange={e=>setForm(f=>({...f,name:e.target.value}))}
placeholder="z.B. Google Kalender" style={{ ...S.inp, fontSize:15, marginBottom:8 }}/>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>ICAL URL</label>
<input value={form.url} onChange={e=>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"/>
<label style={{ ...S.head, display:'block', marginBottom:6 }}>FARBE</label>
<div style={{ display:'flex', gap:6, marginBottom:12 }}>
{COLORS.map(col => (
<button key={col} onClick={()=>setForm(f=>({...f,color:col}))} style={{
width:28, height:28, borderRadius:'50%', background:col, border:'none', cursor:'pointer',
outline: form.color===col ? '3px solid #fff' : '2px solid transparent',
outlineOffset:2,
}}/>
))}
</div>
<button onClick={add} disabled={testing} style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'10px 0' }}>
{testing ? '⏳ Teste Feed…' : '+ Kalender hinzufügen'}
</button>
</div>
{feeds.length > 0 && (
<div>
<div style={{ ...S.head, marginBottom:6 }}>ABONNIERTE KALENDER</div>
{feeds.map(f => (
<div key={f.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
<span style={{ width:12, height:12, borderRadius:'50%', background:f.color, flexShrink:0 }}/>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:12 }}>{f.name}</div>
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:9,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.url}</div>
</div>
<button onClick={()=>remove(f.id)} style={{ background:'transparent', border:'none',
color:'rgba(255,107,157,0.7)', cursor:'pointer', fontSize:16, padding:'0 4px' }}></button>
</div>
))}
</div>
)}
</div>
);
}