402 lines
19 KiB
JavaScript
402 lines
19 KiB
JavaScript
// ── 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(()=>{});
|
||
// Kalender-Cache für Suche mitsynchronisieren
|
||
api('/search/sync-calendar', { method:'POST', body:{ force:true } }).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, marginBottom:0, flexShrink:0}}>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>
|
||
);
|
||
}
|