Dateien nach "frontend/src" hochladen

This commit is contained in:
2026-05-26 14:29:05 +02:00
parent 74b534a2a7
commit 061c8bad3b
2 changed files with 290 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from './lib.js'; import { api, S } from './lib.js';
import { TOOLS, getGroupedTools } from './toolRegistry.js'; import { TOOLS, getGroupedTools } from './toolRegistry.js';
import CalendarWidget, { CalendarSettings } from './calendar.jsx';
import { useConfirm } from './confirm.jsx'; import { useConfirm } from './confirm.jsx';
import { HomeIcon, AppsIcon, AdminIcon, MoreIcon, UserIcon, LogOutIcon, ChevronIcon, UpdateIcon, TrashIcon } from './icons.jsx'; import { HomeIcon, AppsIcon, AdminIcon, MoreIcon, UserIcon, LogOutIcon, ChevronIcon, UpdateIcon, TrashIcon } from './icons.jsx';
@@ -704,6 +705,7 @@ function Dashboard({ toast, mobile }) {
</p> </p>
</div> </div>
<QuickLinks toast={toast} mobile={mobile} /> <QuickLinks toast={toast} mobile={mobile} />
<CalendarWidget/>
<BestellStats/> <BestellStats/>
<TodoList toast={toast} /> <TodoList toast={toast} />
<Notepad toast={toast} /> <Notepad toast={toast} />
@@ -1008,6 +1010,9 @@ function AdminPanel({ toast, mobile, user }) {
{busy.pw ? '…' : '✓ Passwort ändern'} {busy.pw ? '…' : '✓ Passwort ändern'}
</button> </button>
</Sec> </Sec>
<Sec title="KALENDER (iCal)">
<CalendarSettings toast={toast}/>
</Sec>
<Sec title="ACCOUNT LÖSCHEN"> <Sec title="ACCOUNT LÖSCHEN">
<p style={{ color:'rgba(255,255,255,0.55)', fontSize:12, fontFamily:'monospace', marginBottom:12, lineHeight:1.6 }}> <p style={{ color:'rgba(255,255,255,0.55)', fontSize:12, fontFamily:'monospace', marginBottom:12, lineHeight:1.6 }}>
Löscht deinen Account und alle deine Daten unwiderruflich. Löscht deinen Account und alle deine Daten unwiderruflich.

285
frontend/src/calendar.jsx Normal file
View File

@@ -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 (
<div style={{ ...S.card, marginBottom:10 }}>
{/* Header */}
<button onClick={()=>setIsOpen(v=>!v)} style={{ width:'100%', background:'transparent', border:'none',
display:'flex', justifyContent:'space-between', alignItems:'center', cursor:'pointer', padding:0 }}>
<div style={S.head}>KALENDER{loading?' …':''}</div>
<span style={{ color:'rgba(255,255,255,0.4)', fontSize:11, display:'inline-block',
transform:isOpen?'rotate(90deg)':'rotate(0)', transition:'transform 0.2s' }}></span>
</button>
{isOpen && (
<div style={{ marginTop:12 }}>
{/* 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>
<span style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:13, fontWeight:700 }}>
{MONTHS[cur.m]} {cur.y}
</span>
<button onClick={nextMonth} style={{ background:'transparent', border:'none', color:'rgba(255,255,255,0.5)',
cursor:'pointer', fontSize:18, padding:'0 6px' }}></button>
</div>
{/* 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>
{/* 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>
)}
{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(() => {
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 (
<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>
);
}