Initial commit

This commit is contained in:
2026-07-18 23:50:32 +02:00
commit 8baba7b51f
98 changed files with 30017 additions and 0 deletions

4396
frontend/src/App.jsx Normal file

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,401 @@
// ── 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>
);
}

53
frontend/src/confirm.jsx Normal file
View File

@@ -0,0 +1,53 @@
// ── Globaler Bestätigungs-Dialog ─────────────────────────────────────────────
// Verwendung: const { confirm, ConfirmDialog } = useConfirm();
// await confirm('Wirklich löschen?') && deleteFn();
import { useState, useCallback } from 'react';
export function useConfirm() {
const [state, setState] = useState({ open:false, msg:'', resolve:null });
const confirm = useCallback((msg='Wirklich fortfahren?') => {
return new Promise(resolve => {
setState({ open:true, msg, resolve });
});
}, []);
const handle = yes => {
state.resolve?.(yes);
setState({ open:false, msg:'', resolve:null });
};
const ConfirmDialog = () => !state.open ? null : (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.75)',
zIndex:9000, display:'flex',
alignItems: window.innerWidth>=768 ? 'center' : 'flex-end',
justifyContent:'center', padding: window.innerWidth>=768 ? 24 : 0 }}
onClick={() => handle(false)}>
<div style={{ background:'#1a1a1e',
borderRadius: window.innerWidth>=768 ? 16 : '16px 16px 0 0',
width:'100%', maxWidth:500, padding:'20px 20px 32px',
border:'1px solid rgba(255,255,255,0.15)',
boxShadow: window.innerWidth>=768 ? '0 24px 80px rgba(0,0,0,0.6)' : 'none' }}
onClick={e => e.stopPropagation()}>
{window.innerWidth < 768 && <div style={{ width:36, height:4, background:'rgba(255,255,255,0.15)',
borderRadius:2, margin:'0 auto 18px' }}/>}
<p style={{ color:'#fff', fontFamily:'monospace', fontSize:14,
textAlign:'center', marginBottom:20, lineHeight:1.6 }}>{state.msg}</p>
<div style={{ display:'flex', gap:10 }}>
<button onClick={() => handle(false)} style={{
flex:1, padding:'12px 0', background:'rgba(255,255,255,0.06)',
border:'1px solid rgba(255,255,255,0.1)', borderRadius:10,
color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:13, cursor:'pointer',
}}>Abbrechen</button>
<button onClick={() => handle(true)} style={{
flex:1, padding:'12px 0', background:'rgba(255,107,157,0.15)',
border:'1px solid rgba(255,107,157,0.4)', borderRadius:10,
color:'#ff6b9d', fontFamily:'monospace', fontSize:13, cursor:'pointer', fontWeight:700,
}}>Ja, löschen</button>
</div>
</div>
</div>
);
return { confirm, ConfirmDialog };
}

216
frontend/src/crypto.js Normal file
View File

@@ -0,0 +1,216 @@
/**
* DickenDock E2E-Verschlüsselung
*
* Algorithmen:
* X25519 → Schlüsselaustausch (WebCrypto; Chrome 113+/Firefox 130+/Safari 17.4+)
* AES-256-GCM → Nachrichtenverschlüsselung
* PBKDF2-SHA256 → Export-Passwortverschlüsselung (200k Iterationen)
* ↳ statt Argon2id: kein WebCrypto-Support ohne WASM-Lib → keine externen Abhängigkeiten
*
* Private Key: ausschließlich in IndexedDB, niemals an den Server übertragen.
*/
const IDB_NAME = 'dd_e2e_v1';
const IDB_STORE = 'keys';
const KEY_ID = 'identity';
// ── IndexedDB helpers ─────────────────────────────────────────────────────────
function _openDB() {
return new Promise((res, rej) => {
const r = indexedDB.open(IDB_NAME, 1);
r.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
r.onsuccess = e => res(e.target.result);
r.onerror = () => rej(r.error);
});
}
async function _idbGet(k) {
const db = await _openDB();
return new Promise((res, rej) => {
const r = db.transaction(IDB_STORE, 'readonly').objectStore(IDB_STORE).get(k);
r.onsuccess = () => res(r.result ?? null);
r.onerror = () => rej(r.error);
});
}
async function _idbSet(k, v) {
const db = await _openDB();
return new Promise((res, rej) => {
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).put(v, k);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
async function _idbDel(k) {
const db = await _openDB();
return new Promise((res, rej) => {
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).delete(k);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
// ── Base64 helpers ────────────────────────────────────────────────────────────
const b64 = u8 => btoa(String.fromCharCode(...u8));
const ub64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
// ── Internal ──────────────────────────────────────────────────────────────────
async function _importJwkPair(pub, priv) {
const publicKey = await crypto.subtle.importKey('jwk', pub, { name:'X25519' }, true, []);
const privateKey = await crypto.subtle.importKey('jwk', priv, { name:'X25519' }, true, ['deriveKey']);
return { publicKey, privateKey };
}
async function _pbkdf2Key(password, salt) {
const raw = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name:'PBKDF2', salt, iterations:200_000, hash:'SHA-256' },
raw,
{ name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']
);
}
// ── Keypair Management ────────────────────────────────────────────────────────
/**
* Lädt Keypair aus IndexedDB.
* Migriert altes localStorage-Format (P-256) → löscht es, erzeugt neues X25519-Keypair.
* Erstellt neues Keypair falls keins vorhanden.
*/
export async function getOrCreateKeyPair() {
// Migration: altes P-256-Keypair aus localStorage entfernen
if (localStorage.getItem('dd_msg_keypair')) {
localStorage.removeItem('dd_msg_keypair');
// Kein Import möglich (Algorithmuswechsel P-256→X25519), neues Keypair wird unten erzeugt
}
const stored = await _idbGet(KEY_ID);
if (stored) return stored;
// Neues X25519-Keypair erzeugen
const kp = await crypto.subtle.generateKey({ name:'X25519' }, true, ['deriveKey']);
await _idbSet(KEY_ID, kp);
return kp;
}
/** Neues Keypair erzeugen und in IndexedDB speichern (überschreibt vorhandenes). */
export async function generateNewKeyPair() {
const kp = await crypto.subtle.generateKey({ name:'X25519' }, true, ['deriveKey']);
await _idbSet(KEY_ID, kp);
return kp;
}
/** Keypair aus IndexedDB löschen. */
export async function clearStoredKeyPair() {
await _idbDel(KEY_ID);
}
/** Public Key als JWK exportieren (für Server-Registrierung). */
export async function getPublicKeyJwk(kp) {
return crypto.subtle.exportKey('jwk', kp.publicKey);
}
// ── Fingerprint ───────────────────────────────────────────────────────────────
/**
* SHA-256-Hash des Public Key, angezeigt als Kurzformat: ABCD-EF12-3456-CDEF
* Kann manuell verglichen werden um Man-in-the-Middle zu erkennen.
*/
export async function getFingerprint(kp) {
const jwk = await getPublicKeyJwk(kp);
const enc = new TextEncoder().encode(JSON.stringify(jwk));
const hash = await crypto.subtle.digest('SHA-256', enc);
const hex = [...new Uint8Array(hash)]
.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
return `${hex.slice(0,4)}-${hex.slice(4,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}`;
}
// ── Export / Import ───────────────────────────────────────────────────────────
/**
* Keypair verschlüsselt als JSON-String exportieren.
* Verschlüsselung: PBKDF2-SHA256 (200k) → AES-256-GCM
*/
export async function exportEncrypted(kp, password) {
const pub = await crypto.subtle.exportKey('jwk', kp.publicKey);
const priv = await crypto.subtle.exportKey('jwk', kp.privateKey);
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name:'AES-GCM', iv },
await _pbkdf2Key(password, salt),
new TextEncoder().encode(JSON.stringify({ pub, priv }))
);
return JSON.stringify({
v: 1,
alg: 'X25519+PBKDF2-SHA256-200k+AES-256-GCM',
salt: b64(salt),
iv: b64(iv),
data: b64(new Uint8Array(enc)),
});
}
/**
* Verschlüsseltes Keypair importieren, entschlüsseln und in IndexedDB speichern.
* Wirft bei falschem Passwort oder korrupter Datei einen Fehler.
*/
export async function importEncrypted(jsonStr, password) {
const obj = JSON.parse(jsonStr);
if (obj.v !== 1) throw new Error('Unbekanntes Export-Format');
let dec;
try {
dec = await crypto.subtle.decrypt(
{ name:'AES-GCM', iv: ub64(obj.iv) },
await _pbkdf2Key(password, ub64(obj.salt)),
ub64(obj.data)
);
} catch {
throw new Error('Entschlüsselung fehlgeschlagen falsches Passwort?');
}
const { pub, priv } = JSON.parse(new TextDecoder().decode(dec));
const kp = await _importJwkPair(pub, priv);
await _idbSet(KEY_ID, kp);
return kp;
}
// ── Message Encryption ────────────────────────────────────────────────────────
/**
* Gemeinsamen AES-256-GCM-Schlüssel aus eigenem Private Key + Public Key des Partners ableiten.
* Beide Seiten erhalten denselben Schlüssel (X25519-Eigenschaft).
*/
export async function deriveSharedKey(myPrivateKey, theirPubJwk) {
const theirKey = await crypto.subtle.importKey('jwk', theirPubJwk, { name:'X25519' }, false, []);
return crypto.subtle.deriveKey(
{ name:'X25519', public: theirKey },
myPrivateKey,
{ name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']
);
}
/** Nachricht mit AES-256-GCM verschlüsseln. */
export async function encryptMsg(sharedKey, text) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name:'AES-GCM', iv }, sharedKey, new TextEncoder().encode(text)
);
return {
encrypted_content: b64(new Uint8Array(enc)),
iv: b64(iv),
};
}
/** Nachricht entschlüsseln. Wirft bei falschem Schlüssel/korrupten Daten. */
export async function decryptMsg(sharedKey, ecB64, ivB64) {
const dec = await crypto.subtle.decrypt(
{ name:'AES-GCM', iv: ub64(ivB64) },
sharedKey,
ub64(ecB64)
);
return new TextDecoder().decode(dec);
}

219
frontend/src/icons.jsx Normal file
View File

@@ -0,0 +1,219 @@
// ── Icon Library stroke-basierte SVG Icons ──────────────────────────────────
// Verwendung: <Icon name="home" size={20} color="#4ecdc4" />
// Oder direkt: <HomeIcon size={20} color="#fff" />
const base = (path, size, color, sw = 1.75) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke={color} strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
{path}
</svg>
);
export const HomeIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9.5z"/>
<path d="M9 21V12h6v9"/>
</>, size, color, sw);
export const AppsIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</>, size, color, sw);
export const CalculatorIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="4" y="2" width="16" height="20" rx="2"/>
<line x1="8" y1="7" x2="16" y2="7"/>
<line x1="8" y1="12" x2="10" y2="12"/>
<line x1="14" y1="12" x2="16" y2="12"/>
<line x1="8" y1="17" x2="10" y2="17"/>
<line x1="14" y1="17" x2="16" y2="17"/>
</>, size, color, sw);
export const ArchiveIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="2" y="3" width="20" height="4" rx="1"/>
<path d="M4 7v13a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V7"/>
<line x1="9" y1="12" x2="15" y2="12"/>
</>, size, color, sw);
export const AdminIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</>, size, color, sw);
export const MoreIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="5" cy="12" r="1.2" fill={color}/>
<circle cx="12" cy="12" r="1.2" fill={color}/>
<circle cx="19" cy="12" r="1.2" fill={color}/>
</>, size, color, sw);
export const UserIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
</>, size, color, sw);
export const LogOutIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</>, size, color, sw);
export const SearchIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="11" cy="11" r="7"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</>, size, color, sw);
export const PlusIcon = ({size=20,color='currentColor',sw}) => base(<>
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</>, size, color, sw);
export const XIcon = ({size=20,color='currentColor',sw}) => base(<>
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</>, size, color, sw);
export const CheckIcon = ({size=20,color='currentColor',sw}) => base(<>
<polyline points="20 6 9 17 4 12"/>
</>, size, color, sw);
export const EditIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</>, size, color, sw);
export const TrashIcon = ({size=20,color='currentColor',sw}) => base(<>
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</>, size, color, sw);
export const DownloadIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</>, size, color, sw);
export const UploadIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</>, size, color, sw);
export const UpdateIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="12" cy="12" r="9"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</>, size, color, sw);
export const ChevronIcon = ({size=20,color='currentColor',sw,dir='right'}) => base(<>
{dir==='right' && <polyline points="9 18 15 12 9 6"/>}
{dir==='down' && <polyline points="6 9 12 15 18 9"/>}
{dir==='left' && <polyline points="15 18 9 12 15 6"/>}
{dir==='up' && <polyline points="18 15 12 9 6 15"/>}
</>, size, color, sw);
export const CameraIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</>, size, color, sw);
export const KeyIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="8" cy="12" r="5"/>
<path d="M13 12h8M17 10v4"/>
</>, size, color, sw);
export const DatabaseIcon = ({size=20,color='currentColor',sw}) => base(<>
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v4c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/>
<path d="M3 9v4c0 1.66 4.03 3 9 3s9-1.34 9-3V9"/>
<path d="M3 13v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/>
</>, size, color, sw);
export const ZapIcon = ({size=20,color='currentColor',sw}) => base(<>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</>, size, color, sw);
export const MessageIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round" strokeLinecap="round"/>
</>, size, color, sw);
export const LinkIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</>, size, color, sw);
export const CodeIcon = ({size=20,color='currentColor',sw}) => base(<>
<polyline points="16 18 22 12 16 6" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<polyline points="8 6 2 12 8 18" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</>, size, color, sw);
export const SkizzeIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="3" width="18" height="18" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<path d="M3 9h18M9 21V9" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round"/>
<path d="M13 13h5M13 17h3" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round"/>
</>, size, color, sw);
export const WrenchIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</>, size, color, sw);
export const PaywallIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="11" width="18" height="11" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M7 11V7a5 5 0 0 1 9.9-1" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="8" y1="16" x2="10" y2="16" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="14" y1="16" x2="16" y2="16" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="11" y1="14" x2="13" y2="18" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
</>, size, color, sw);
export const FilmIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="2" y="2" width="20" height="20" rx="2.18" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="7" y1="2" x2="7" y2="22" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="17" y1="2" x2="17" y2="22" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="2" y1="12" x2="22" y2="12" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="2" y1="7" x2="7" y2="7" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="2" y1="17" x2="7" y2="17" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="17" y1="17" x2="22" y2="17" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="17" y1="7" x2="22" y2="7" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
</>, size, color, sw);
export const ChartIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="12" width="4" height="8" rx="1" fill={color}/>
<rect x="9" y="7" width="4" height="13" rx="1" fill={color}/>
<rect x="15" y="3" width="4" height="17" rx="1" fill={color}/>
</>, size, color, sw);
export const KanbanIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="3" width="5" height="13" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<rect x="10" y="3" width="5" height="8" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<rect x="17" y="3" width="4" height="5" rx="1.5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
</>, size, color, sw);
export const WhiteboardIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="3" width="18" height="14" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<path d="M7 13 L10 9 L13 11 L16 7" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="3" y1="19" x2="21" y2="19" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
</>, size, color, sw);
export const KoepiIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M5 3h14l-2 10H7L5 3z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
<path d="M7 13c0 3 2 5 5 5s5-2 5-5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<path d="M19 7h2v4h-2" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
</>, size, color, sw);
export const SchockenIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<circle cx="8.5" cy="9.5" r="1.2" fill={color}/>
<circle cx="15.5" cy="9.5" r="1.2" fill={color}/>
<circle cx="8.5" cy="14.5" r="1.2" fill={color}/>
<circle cx="15.5" cy="14.5" r="1.2" fill={color}/>
<circle cx="12" cy="12" r="1.2" fill={color}/>
</>, size, color, sw);
export const GeoIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="3" width="18" height="18" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<rect x="3" y="3" width="8" height="8" rx="1" fill={color} opacity="0.7"/>
<rect x="13" y="13" width="8" height="8" rx="1" fill={color} opacity="0.4"/>
<rect x="11" y="3" width="10" height="5" rx="1" stroke={color} strokeWidth={sw||1.2} fill="none"/>
<rect x="3" y="11" width="5" height="10" rx="1" stroke={color} strokeWidth={sw||1.2} fill="none"/>
</>, size, color, sw);
export const ScaleIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M12 3v18" stroke={color} strokeWidth={sw||1.5}/>
<path d="M7 21h10" stroke={color} strokeWidth={sw||1.5}/>
<path d="M5 7h14" stroke={color} strokeWidth={sw||1.5}/>
<path d="M12 3l3 4" stroke={color} strokeWidth={sw||1.5}/>
<path d="M2 12l3-5 3 5a3 3 0 0 1-6 0z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
<path d="M16 12l3-5 3 5a3 3 0 0 1-6 0z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
</>, size, color, sw);

80
frontend/src/lib.js Normal file
View File

@@ -0,0 +1,80 @@
// ── API Client ────────────────────────────────────────────────────────────────
// Einheitlicher Fetch-Wrapper für alle Tools und Core-Komponenten
export const api = async (path, { body, method, isFile } = {}) => {
const token = localStorage.getItem('sk_token');
const headers = token ? { Authorization: `Bearer ${token}` } : {};
if (!isFile) headers['Content-Type'] = 'application/json';
const res = await fetch(`/api${path}`, {
method: method || (body ? 'POST' : 'GET'),
headers,
body: isFile ? body : body ? JSON.stringify(body) : undefined,
});
if (path.includes('/admin/backup') && res.ok) return res.blob();
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Fehler');
return data;
};
// ── Upload mit echtem Byte-Fortschritt ──────────────────────────────────────
// fetch() liefert keinerlei Upload-Progress-Events, daher hier bewusst auf
// XMLHttpRequest aufgebaut. Nur für Datei-Uploads gedacht (FormData-Body).
// onProgress(percent: 0-100) wird während des Uploads laufend aufgerufen.
export const apiUpload = (path, formData, { onProgress } = {}) => {
const token = localStorage.getItem('sk_token');
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api${path}`);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = e => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
let data = {};
try { data = JSON.parse(xhr.responseText); } catch { /* leere/ungültige Antwort */ }
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else reject(new Error(data.error || 'Fehler'));
};
xhr.onerror = () => reject(new Error('Verbindung fehlgeschlagen'));
xhr.send(formData);
});
};
// ── Design Tokens ─────────────────────────────────────────────────────────────
// Einheitliche Styles für alle Tools in jedem Tool importierbar
export const S = {
// Eingabefeld
inp: {
width: '100%', background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)', borderRadius: 6,
padding: '8px 10px', color: '#fff', fontSize: 13,
fontFamily: "'Space Mono',monospace", outline: 'none', boxSizing: 'border-box',
},
// Button-Factory: btn('#4ecdc4') oder btn('#ff6b9d', true) für kleine Variante
btn: (c = '#4ecdc4', sm = false) => ({
background: `${c}18`, border: `1px solid ${c}44`,
borderRadius: 6, padding: sm ? '5px 10px' : '8px 14px',
color: c, cursor: 'pointer', fontFamily: 'monospace',
fontSize: sm ? 11 : 12, whiteSpace: 'nowrap', transition: 'all 0.15s',
}),
// Karten-Container
card: {
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 12, padding: 20,
},
// Abschnitts-Überschrift
head: {
color: 'rgba(255,255,255,0.6)', fontSize: 10,
fontFamily: 'monospace', letterSpacing: 2, marginBottom: 14,
},
// Untergeordneter/Hilfstext (Beschreibungen, Hinweise)
sub: {
color: 'rgba(255,255,255,0.4)', fontSize: 11,
fontFamily: 'monospace', lineHeight: 1.5,
},
};

101
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,101 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
// ── Auto-Reload bei neuem Build ───────────────────────────────────────────────
// __BUILD_TIME__ wird von Vite zur Build-Zeit aus version.txt gebacken.
// /api/build-time liefert denselben Wert vom Server.
// Wenn sie abweichen → neues Build wurde deployed → Reload.
// Kein localStorage, keine Race-Condition.
const MY_BUILD_TIME = __BUILD_TIME__;
window.__newBuildAvailable = false;
function isUserTyping() {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
function hasUnsavedInput() {
const fields = document.querySelectorAll('input, textarea');
for (const f of fields) {
const isText = f.tagName === 'TEXTAREA' ||
['text','search','url','email','number','password','tel'].includes(f.type);
if (isText && f.value && f.value.trim() !== '') return true;
}
return false;
}
async function getBuildTime() {
try {
const r = await fetch('/api/build-time', { cache: 'no-store' });
const { buildTime } = await r.json();
return buildTime;
} catch { return null; }
}
// Prüft ob Server-Build != Bundle-Build → Flag setzen
// reloadNow: sofort laden wenn kein Input aktiv (für App-Fokus)
async function checkBuild({ reloadNow = false } = {}) {
const serverBt = await getBuildTime();
if (!serverBt) return;
if (serverBt !== MY_BUILD_TIME) {
window.__newBuildAvailable = true;
if (reloadNow && !isUserTyping() && !hasUnsavedInput()) {
window.location.reload();
}
}
}
// Sicherer Reload bei Seitenwechsel (aufgerufen aus App.jsx setActive)
window.__safeReloadIfNewBuild = function () {
if (!window.__newBuildAvailable) return false;
if (isUserTyping() || hasUnsavedInput()) return false;
window.location.reload();
return true;
};
// Beim Start prüfen nach 3s sofort reloaden wenn neue Version (App hat Zeit zu rendern)
checkBuild().then(() => {
if (window.__newBuildAvailable && !isUserTyping() && !hasUnsavedInput()) {
setTimeout(() => {
if (!isUserTyping() && !hasUnsavedInput()) window.location.reload();
}, 3000);
}
});
// Im Hintergrund alle 60s
setInterval(() => checkBuild(), 60_000);
// App kommt in den Vordergrund → prüfen und bei Bedarf sofort laden
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
checkBuild({ reloadNow: true });
}
});
// ── Service Worker ────────────────────────────────────────────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (e) => {
// SW hat neue Version aktiviert → sofort reloaden
if (e.data?.type === 'SW_ACTIVATED') {
window.__newBuildAvailable = true;
if (!isUserTyping() && !hasUnsavedInput()) window.location.reload();
}
});
navigator.serviceWorker.register('/sw.js').then(reg => {
setInterval(() => reg.update().catch(()=>{}), 60_000);
if (reg.waiting) reg.waiting.postMessage('SKIP_WAITING');
reg.addEventListener('updatefound', () => {
const nw = reg.installing;
nw?.addEventListener('statechange', () => {
if (nw.state === 'installed') nw.postMessage('SKIP_WAITING');
});
});
}).catch(()=>{});
}
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode><App /></React.StrictMode>
)

View File

@@ -0,0 +1,178 @@
import Kalkulator3D, { SavedList } from './tools/kalkulator3d.jsx';
import Bestellungen from './tools/bestellungen.jsx';
import Statistik from './tools/statistik.jsx';
import Dateien from './tools/dateien.jsx';
import Nachrichten from './tools/nachrichten.jsx';
import Linkliste from './tools/linkliste.jsx';
import CodeSchnipsel from './tools/codeschnipsel.jsx';
import Skizze from './tools/skizze.jsx';
import DevTools from './tools/devtools.jsx';
import Media from './tools/media.jsx';
import PaywallKiller from './tools/paywallkiller.jsx';
import Kanban from './tools/kanban.jsx';
import Whiteboard from './tools/whiteboard.jsx';
import Gebietseroberung from './tools/gebietseroberung.jsx';
import Schocken from './tools/schocken.jsx';
import Koepi from './tools/koepi.jsx';
import Wettrechner from './tools/wettrechner.jsx';
import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon, WhiteboardIcon, GeoIcon, SchockenIcon, KoepiIcon, ScaleIcon } from './icons.jsx';
export const TOOLS = [
{
id: 'kalkulator3d',
Icon: CalculatorIcon,
label: 'Kostenrechner',
navLabel: 'Kosten',
group: '3D-Druck',
component: Kalkulator3D,
},
{
id: 'kalkulator3d-saved',
Icon: ArchiveIcon,
label: 'Archiv',
navLabel: 'Archiv',
group: '3D-Druck',
component: SavedList,
},
{
id: 'bestellungen',
Icon: DatabaseIcon,
label: 'Bestellungen',
navLabel: 'Aufträge',
group: '3D-Druck',
component: Bestellungen,
},
{
id: 'statistik3d',
Icon: ChartIcon,
label: 'Statistik',
navLabel: 'Statistik',
group: '3D-Druck',
component: Statistik,
},
{
id: 'kanban',
Icon: KanbanIcon,
label: 'Kanban',
navLabel: 'Kanban',
group: 'Werkzeuge',
component: Kanban,
},
{
id: 'whiteboard',
Icon: WhiteboardIcon,
label: 'Whiteboard',
navLabel: 'Whiteboard',
group: 'Werkzeuge',
component: Whiteboard,
},
{
id: 'gebietseroberung',
Icon: GeoIcon,
label: 'Hex Wars',
navLabel: 'Hex Wars',
group: 'Freizeit',
component: Gebietseroberung,
},
{
id: 'schocken',
Icon: SchockenIcon,
label: 'Schocken',
navLabel: 'Schocken',
group: 'Freizeit',
component: Schocken,
},
{
id: 'koepi',
Icon: KoepiIcon,
label: 'KöPi',
navLabel: 'KöPi',
group: 'Freizeit',
component: Koepi,
},
{
id: 'wettrechner',
Icon: ScaleIcon,
label: 'Wettrechner',
navLabel: 'Wetten',
group: 'Freizeit',
component: Wettrechner,
},
{
id: 'dateien',
Icon: DatabaseIcon,
label: 'Dateien',
navLabel: 'Dateien',
group: 'Werkzeuge',
component: Dateien,
},
{
id: 'nachrichten',
Icon: MessageIcon,
label: 'Nachrichten',
navLabel: 'Nachrichten',
group: 'Werkzeuge',
component: Nachrichten,
},
{
id: 'linkliste',
Icon: LinkIcon,
label: 'Linkliste',
navLabel: 'Linkliste',
group: 'Werkzeuge',
component: Linkliste,
},
{
id: 'codeschnipsel',
Icon: CodeIcon,
label: 'Code-Schnipsel',
navLabel: 'Code',
group: 'Werkzeuge',
component: CodeSchnipsel,
},
{
id: 'skizze',
Icon: SkizzeIcon,
label: 'CAD-Skizzen 🚧',
navLabel: 'CAD-Skizzen 🚧',
group: 'Werkzeuge',
component: Skizze,
},
{
id: 'devtools',
Icon: WrenchIcon,
label: 'Dev-Tools',
navLabel: 'Dev-Tools',
group: 'Werkzeuge',
component: DevTools,
},
{
id: 'paywallkiller',
Icon: PaywallIcon,
label: 'Paywall-Killer',
navLabel: 'Paywall',
group: 'Werkzeuge',
component: PaywallKiller,
},
{
id: 'media',
Icon: FilmIcon,
label: 'Media',
navLabel: 'Media',
group: 'Freizeit',
component: Media,
},
// Neues Tool: { id:'...', Icon:..., label:'...', navLabel:'...', group:'...', component:... },
];
export function getGroupedTools(isAdmin = false, hiddenTools = []) {
const groups = {};
for (const t of TOOLS) {
if (t.adminOnly && !isAdmin) continue;
if (hiddenTools.includes(t.id)) continue;
const g = t.group || 'Allgemein';
if (!groups[g]) groups[g] = [];
groups[g].push(t);
}
return Object.entries(groups);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,701 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from '../lib.js';
// ── Spracherkennung ───────────────────────────────────────────────────────────
const LANG_PATTERNS = [
{ lang:'javascript', label:'JavaScript', patterns:[/\b(const|let|var|function|=>|require|import|export|console\.log)\b/] },
{ lang:'typescript', label:'TypeScript', patterns:[/\b(interface|type\s+\w+\s*=|:\s*(string|number|boolean|void)|<T>)\b/] },
{ lang:'python', label:'Python', patterns:[/\b(def |import |from .* import|elif |print\(|self\.|__init__)\b/] },
{ lang:'jsx', label:'JSX/React', patterns:[/<[A-Z][a-zA-Z]+|useState|useEffect|return\s*\([\s\S]*<|className=/] },
{ lang:'html', label:'HTML', patterns:[/<html|<body|<div|<span|<head|<!DOCTYPE/i] },
{ lang:'css', label:'CSS', patterns:[/\{[\s\S]*:\s*[^;]+;\s*\}|@media|@keyframes|\.[\w-]+\s*\{/] },
{ lang:'sql', label:'SQL', patterns:[/\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN|CREATE TABLE)\b/i] },
{ lang:'bash', label:'Bash/Shell', patterns:[/^#!/m, /\b(echo|grep|sed|awk|chmod|sudo|apt|curl|wget)\b/] },
{ lang:'json', label:'JSON', patterns:[/^\s*[\[{][\s\S]*[\]}]\s*$/, /"\w+":\s*[\w"[\]{]/] },
{ lang:'rust', label:'Rust', patterns:[/\b(fn |let mut|impl |pub fn|use std|println!)\b/] },
{ lang:'go', label:'Go', patterns:[/\b(func |package |import |fmt\.|var |:=)\b/] },
{ lang:'java', label:'Java', patterns:[/\b(public class|private |protected |void |System\.out)\b/] },
{ lang:'php', label:'PHP', patterns:[/<\?php|\$\w+\s*=|echo |function \w+|\$this->/] },
{ lang:'csharp', label:'C#', patterns:[/\b(using System|namespace |class \w+|public void|Console\.Write)\b/] },
{ lang:'cpp', label:'C/C++', patterns:[/#include|std::|cout <<|int main\(|nullptr/] },
{ lang:'yaml', label:'YAML', patterns:[/^[\w-]+:\s*$/m, /^\s+-\s+\w/m] },
{ lang:'dockerfile', label:'Dockerfile', patterns:[/^FROM |^RUN |^CMD |^EXPOSE |^COPY /m] },
];
const LANG_COLORS = {
javascript:'#f7df1e', typescript:'#3178c6', python:'#3572a5', jsx:'#61dafb',
html:'#e34c26', css:'#563d7c', sql:'#e38c00', bash:'#4EAA25',
json:'#4ecdc4', rust:'#dea584', go:'#00add8', java:'#b07219',
php:'#4f5d95', csharp:'#178600', cpp:'#f34b7d', yaml:'#cb171e',
dockerfile:'#2496ed', text:'rgba(255,255,255,0.3)',
};
function detectLanguage(code) {
if (!code?.trim()) return 'text';
for (const { lang, patterns } of LANG_PATTERNS) {
if (patterns.some(p => p.test(code))) return lang;
}
return 'text';
}
function getLangLabel(lang) {
return LANG_PATTERNS.find(l=>l.lang===lang)?.label || lang.toUpperCase();
}
function fmtDt(s) {
if (!s) return '';
const d = new Date(s.replace(' ','T'));
return isNaN(d) ? '' : d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
}
// ── Share Modal ───────────────────────────────────────────────────────────────
function SnippetShareModal({ snippet, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/snippets/${snippet.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/snippets/${snippet.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e){ toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/snippets/${snippet.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); }
catch(e){ toast(e.message,'error'); }
};
const isMob = window.innerWidth < 768;
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:7000,
display:'flex',alignItems:isMob?'flex-end':'center',justifyContent:'center',padding:isMob?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMob?'16px 16px 0 0':14,
width:'100%',maxWidth:400,padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{isMob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',flex:1}}>🤝 {snippet.title}</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,marginLeft:8}}></button>
</div>
<p style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,marginBottom:12,lineHeight:1.6}}>
Empfänger können lesen, aber nicht bearbeiten.
</p>
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'7px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch nicht geteilt.</div>}
</div>
</div>
);
}
// ── Simple Diff ───────────────────────────────────────────────────────────────
function DiffView({ oldCode, newCode }) {
if (!oldCode) return (
<pre style={{margin:0,padding:'12px 14px',color:'#d0d0d0',fontFamily:"'Courier New',monospace",
fontSize:11,lineHeight:1.6,whiteSpace:'pre-wrap',wordBreak:'break-all',
background:'rgba(0,0,0,0.35)'}}>
{newCode}
</pre>
);
const oldLines = oldCode.split('\n');
const newLines = newCode.split('\n');
const maxLen = Math.max(oldLines.length, newLines.length);
const rows = [];
for (let i = 0; i < maxLen; i++) {
const o = oldLines[i];
const n = newLines[i];
if (o === n) {
rows.push({ type:'same', text: n ?? '' });
} else {
if (o !== undefined) rows.push({ type:'del', text: o });
if (n !== undefined) rows.push({ type:'add', text: n });
}
}
return (
<div style={{background:'rgba(0,0,0,0.4)',border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8,overflow:'auto',maxHeight:320,fontFamily:"'Courier New',monospace",fontSize:11,lineHeight:1.6}}>
{rows.map((r,i) => (
<div key={i} style={{
padding:'0 12px',whiteSpace:'pre-wrap',wordBreak:'break-all',
background: r.type==='add'?'rgba(78,205,196,0.12)':r.type==='del'?'rgba(255,107,157,0.12)':'transparent',
borderLeft:`2px solid ${r.type==='add'?'#4ecdc4':r.type==='del'?'#ff6b9d':'transparent'}`,
}}>
<span style={{color: r.type==='add'?'#4ecdc4':r.type==='del'?'rgba(255,107,157,0.7)':'rgba(255,255,255,0.2)',
marginRight:6,userSelect:'none',fontSize:10}}>
{r.type==='add'?'+':r.type==='del'?'':' '}
</span>
<span style={{color: r.type==='same'?'#d0d0d0':'#fff'}}>{r.text}</span>
</div>
))}
</div>
);
}
function HistoryModal({ snippet, onClose, onRestore, toast }) {
const [history, setHistory] = useState([]);
const [selectedIdx, setSelectedIdx] = useState(0); // 0 = Aktuell (current)
const [showDiff, setShowDiff] = useState(true);
const isMob = window.innerWidth < 768;
const load = () => api(`/tools/snippets/${snippet.id}/history`).then(setHistory).catch(()=>{});
useEffect(()=>{ load(); },[]);
// Entries: [current, ...history] where index 0 = current
const entries = [
{ id:'current', code: snippet.code, language: snippet.language,
saved_at: snippet.updated_at, label:'Aktuell', isCurrent: true },
...history.map((h,i) => ({ ...h, label:`v${history.length - i}`, isCurrent: false })),
];
// Check if current code matches a history entry (= was restored)
const restoredFromIdx = history.findIndex(h => h.code === snippet.code);
const restoredFromLabel = restoredFromIdx >= 0 ? `v${history.length - restoredFromIdx}` : null;
const selected = entries[selectedIdx];
const prevEntry = entries[selectedIdx + 1];
const oldCode = prevEntry?.code ?? null;
const delHistory = async (hid) => {
if (!window.confirm('Diese Version wirklich löschen?')) return;
try {
await api(`/tools/snippets/${snippet.id}/history/${hid}`, { method:'DELETE' });
await load();
setSelectedIdx(0);
toast('Version gelöscht');
} catch(e) { toast(e.message,'error'); }
};
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',zIndex:7000,
display:'flex',alignItems:isMob?'flex-end':'center',justifyContent:'center',padding:isMob?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMob?'16px 16px 0 0':14,
width:'100%',maxWidth:700,border:'1px solid rgba(255,255,255,0.12)',
display:'flex',flexDirection:'column',maxHeight:isMob?'88vh':'82vh',overflow:'hidden'}}>
{/* Header */}
<div style={{padding:'14px 20px',borderBottom:'1px solid rgba(255,255,255,0.08)',
display:'flex',justifyContent:'space-between',alignItems:'center',flexShrink:0,gap:10}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
📜 Versionshistorie · {snippet.title}
</div>
<div style={{display:'flex',gap:6,alignItems:'center'}}>
<button onClick={()=>setShowDiff(v=>!v)} style={{
background:showDiff?'rgba(78,205,196,0.12)':'rgba(255,255,255,0.05)',
border:`1px solid ${showDiff?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
borderRadius:6,color:showDiff?'#4ecdc4':'rgba(255,255,255,0.4)',
cursor:'pointer',fontSize:9,fontFamily:'monospace',padding:'3px 8px'}}>
{showDiff?'± Diff':'📄 Code'}
</button>
<button onClick={onClose} style={{background:'transparent',border:'none',
color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
</div>
{entries.length <= 1 ? (
<div style={{padding:32,textAlign:'center',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>
Keine älteren Versionen vorhanden.
</div>
) : (
<div style={{display:'flex',flex:1,overflow:'hidden'}}>
{/* Versions-Liste */}
<div style={{width:155,borderRight:'1px solid rgba(255,255,255,0.07)',overflowY:'auto',flexShrink:0}}>
{entries.map((e,i) => (
<div key={e.id} style={{
display:'flex',alignItems:'center',
background: i===selectedIdx?'rgba(78,205,196,0.08)':'transparent',
borderLeft:`2px solid ${i===selectedIdx?'#4ecdc4':'transparent'}`,
borderBottom:'1px solid rgba(255,255,255,0.04)',
}}>
<button onClick={()=>setSelectedIdx(i)} style={{
flex:1,padding:'10px 10px',background:'transparent',
border:'none',cursor:'pointer',textAlign:'left',
}}>
<div style={{color: e.isCurrent?'#4ecdc4':'rgba(255,255,255,0.65)',
fontFamily:'monospace',fontSize:10,fontWeight: e.isCurrent?700:400}}>
{e.label}
{e.isCurrent&&<span style={{color:'rgba(78,205,196,0.5)',fontSize:8,marginLeft:4}}></span>}
{e.isCurrent && restoredFromLabel && (
<div style={{color:'rgba(255,230,109,0.7)',fontSize:8,fontWeight:400,marginTop:2}}>
aus {restoredFromLabel}
</div>
)}
</div>
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:8,marginTop:2}}>
{fmtDt(e.saved_at)}
</div>
</button>
{/* Löschen nur für Altversionen, nicht Aktuell */}
{!e.isCurrent && !snippet.is_shared && (
<button onClick={()=>delHistory(e.id)}
title="Version löschen"
style={{background:'transparent',border:'none',cursor:'pointer',
padding:'0 8px',color:'rgba(255,107,157,0.35)',fontSize:12,
flexShrink:0,lineHeight:1}}></button>
)}
</div>
))}
</div>
{/* Detail */}
<div style={{flex:1,overflow:'auto',padding:14,display:'flex',flexDirection:'column',gap:10}}>
{selected && (
<>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',flexWrap:'wrap',gap:6}}>
<div style={{display:'flex',gap:6,alignItems:'center',flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>
{getLangLabel(selected.language)} · {fmtDt(selected.saved_at)}
</span>
{showDiff && oldCode===null && (
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9}}>älteste Version kein Diff verfügbar</span>
)}
{showDiff && oldCode!==null && prevEntry && (
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9}}>
Änderungen gegenüber {prevEntry.label}
</span>
)}
</div>
{/* Wiederherstellen nur für Altversionen die vom aktuellen abweichen */}
{!selected.isCurrent && !snippet.is_shared && selected.code !== snippet.code && (
<button onClick={()=>onRestore(selected)} style={{
background:'rgba(255,230,109,0.1)',border:'1px solid rgba(255,230,109,0.25)',
borderRadius:6,color:'#ffe66d',cursor:'pointer',fontSize:10,padding:'4px 10px'}}>
Wiederherstellen
</button>
)}
</div>
{showDiff && oldCode!==null
? <DiffView oldCode={oldCode} newCode={selected.code}/>
: <pre style={{margin:0,padding:'12px 14px',color:'#d0d0d0',
fontFamily:"'Courier New',monospace",fontSize:11,lineHeight:1.6,
background:'rgba(0,0,0,0.35)',border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8,overflowX:'auto',whiteSpace:'pre-wrap',wordBreak:'break-all',
maxHeight:380,overflowY:'auto'}}>
{selected.code}
</pre>
}
</>
)}
</div>
</div>
)}
</div>
</div>
);
}
// ── Snippet Card ──────────────────────────────────────────────────────────────
function SnippetCard({ snippet, onEdit, onDelete, onShare, onHistory, toast }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const langColor = LANG_COLORS[snippet.language] || LANG_COLORS.text;
const copy = async () => {
try { await navigator.clipboard.writeText(snippet.code); setCopied(true); setTimeout(()=>setCopied(false),1500); }
catch { toast('Kopieren fehlgeschlagen','error'); }
};
return (
<div style={{...S.card,marginBottom:10,padding:0,overflow:'hidden'}}>
{/* Header */}
<div onClick={()=>setExpanded(v=>!v)} style={{padding:'12px 14px',cursor:'pointer',
display:'flex',alignItems:'center',gap:10}}>
<div style={{width:8,height:8,borderRadius:'50%',background:langColor,flexShrink:0,
boxShadow:`0 0 6px ${langColor}80`}}/>
<div style={{flex:1,minWidth:0}}>
<div style={{display:'flex',alignItems:'center',gap:6,flexWrap:'wrap'}}>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{snippet.title}</span>
{snippet.is_shared && (
<span style={{fontSize:9,fontFamily:'monospace',color:'rgba(78,205,196,0.6)',
background:'rgba(78,205,196,0.08)',border:'1px solid rgba(78,205,196,0.2)',
borderRadius:4,padding:'1px 5px',flexShrink:0}}>von {snippet.owner}</span>
)}
</div>
{snippet.description && (
<div style={{color:'rgba(255,255,255,0.38)',fontFamily:'monospace',fontSize:10,
marginTop:2,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
{snippet.description}
</div>
)}
<div style={{display:'flex',alignItems:'center',gap:6,marginTop:2,flexWrap:'wrap'}}>
<span style={{fontSize:9,fontFamily:'monospace',color:langColor,opacity:0.8}}>
{getLangLabel(snippet.language)}
</span>
{snippet.tags?.map(t=>(
<span key={t} style={{fontSize:8,fontFamily:'monospace',
color:'rgba(255,255,255,0.4)',background:'rgba(255,255,255,0.05)',
border:'1px solid rgba(255,255,255,0.08)',borderRadius:4,padding:'1px 5px'}}>
{t}
</span>
))}
</div>
</div>
<span style={{color:'rgba(255,255,255,0.3)',fontSize:10,
transform:expanded?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s',flexShrink:0}}></span>
</div>
{/* Expanded */}
{expanded && (
<div style={{borderTop:'1px solid rgba(255,255,255,0.06)'}}>
{snippet.description && (
<div style={{padding:'8px 14px',color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
{snippet.description}
</div>
)}
{/* Code */}
<div style={{position:'relative'}}>
<pre style={{margin:0,padding:'12px 14px',paddingRight:48,
color:'#d0d0d0',fontFamily:"'Courier New',Courier,monospace",
fontSize:12,lineHeight:1.65,overflowX:'auto',
background:'rgba(0,0,0,0.35)',whiteSpace:'pre',maxHeight:320,overflowY:'auto'}}>
{snippet.code}
</pre>
<button onClick={copy} title="Kopieren" style={{
position:'absolute',top:8,right:8,
background:'rgba(255,255,255,0.07)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:6,color: copied?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',fontSize:13,padding:'4px 8px',transition:'color 0.2s',
}}>{copied?'✓':'⎘'}</button>
</div>
{/* Meta + Aktionen */}
<div style={{padding:'10px 14px',display:'flex',alignItems:'center',
justifyContent:'space-between',gap:8,flexWrap:'wrap',
borderTop:'1px solid rgba(255,255,255,0.05)'}}>
<div style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9,lineHeight:1.7}}>
<div>erstellt: {fmtDt(snippet.created_at)}</div>
{snippet.updated_at !== snippet.created_at && (
<div>bearbeitet: {fmtDt(snippet.updated_at)}</div>
)}
</div>
<div style={{display:'flex',gap:5,flexWrap:'wrap'}}>
<button onClick={()=>onHistory(snippet)}
style={{background:'rgba(255,255,255,0.07)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:6,color:'rgba(255,255,255,0.5)',cursor:'pointer',
fontSize:10,padding:'4px 8px'}}>📜</button>
{!snippet.is_shared && (
<>
<button onClick={()=>onShare(snippet)}
style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 8px'}}>🤝</button>
<button onClick={()=>onEdit(snippet)}
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 8px'}}></button>
<button onClick={()=>onDelete(snippet.id)}
style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 8px'}}></button>
</>
)}
</div>
</div>
</div>
)}
</div>
);
}
// ── Snippet Form ──────────────────────────────────────────────────────────────
function SnippetForm({ initial, onSave, onCancel, toast, existingTags=[] }) {
const ALL_LANGS = ['text',...LANG_PATTERNS.map(l=>l.lang)];
const [title, setTitle] = useState(initial?.title || '');
const [code, setCode] = useState(initial?.code || '');
const [lang, setLang] = useState(initial?.language || 'text');
const [desc, setDesc] = useState(initial?.description || '');
const [tags, setTags] = useState(initial?.tags?.filter(t=>t!==initial?.language).join(', ') || '');
const [busy, setBusy] = useState(false);
const [autoLang,setAutoLang]= useState(!initial?.id);
useEffect(()=>{
if (!autoLang) return;
const detected = detectLanguage(code);
setLang(detected);
},[code, autoLang]);
// Nur Komma als Trennzeichen
const parseTags = () => {
const langTag = lang !== 'text' ? [lang] : [];
const manual = tags.split(',').map(t=>t.trim().toLowerCase()).filter(Boolean);
return [...new Set([...langTag, ...manual])];
};
const addSuggestedTag = tag => {
const cur = tags.split(',').map(t=>t.trim()).filter(Boolean);
if (cur.includes(tag)) return;
setTags(cur.length ? cur.join(', ') + ', ' + tag : tag);
};
// Tags die noch nicht eingetragen sind als Vorschläge
const currentParsed = parseTags();
const suggestions = existingTags.filter(t => t !== lang && !currentParsed.includes(t));
const save = async () => {
if (!title.trim()) { toast('Titel erforderlich','error'); return; }
setBusy(true);
try {
await onSave({ title:title.trim(), code, language:lang, description:desc.trim(), tags:parseTags() });
} catch(e) { toast(e.message,'error'); }
setBusy(false);
};
return (
<div style={{...S.card,marginBottom:14}}>
<div style={{...S.head,marginBottom:10}}>{initial?.id ? 'BEARBEITEN' : 'NEUER SCHNIPSEL'}</div>
<input value={title} onChange={e=>setTitle(e.target.value)} placeholder="Titel *"
style={{...S.inp,marginBottom:8}}/>
<input value={desc} onChange={e=>setDesc(e.target.value)} placeholder="Beschreibung (optional)"
style={{...S.inp,marginBottom:8,fontSize:12}}/>
{/* Code */}
<textarea value={code} onChange={e=>setCode(e.target.value)} placeholder="Code einfügen…"
rows={8} style={{...S.inp,resize:'vertical',fontFamily:"'Courier New',monospace",
fontSize:12,lineHeight:1.6,marginBottom:8,width:'100%',boxSizing:'border-box'}}/>
{/* Sprache */}
<div style={{display:'flex',gap:8,marginBottom:8,alignItems:'center'}}>
<select value={lang} onChange={e=>{setLang(e.target.value);setAutoLang(false);}}
style={{...S.inp,flex:1,fontSize:12}}>
{ALL_LANGS.map(l=>(
<option key={l} value={l} style={{background:'#1a1a1e'}}>
{getLangLabel(l)}
</option>
))}
</select>
<label style={{display:'flex',alignItems:'center',gap:5,cursor:'pointer',flexShrink:0}}>
<input type="checkbox" checked={autoLang} onChange={e=>setAutoLang(e.target.checked)}
style={{accentColor:'#4ecdc4'}}/>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>Auto</span>
</label>
{lang!=='text'&&<div style={{width:10,height:10,borderRadius:'50%',
background:LANG_COLORS[lang]||'#fff',flexShrink:0}}/>}
</div>
{/* Tags */}
<div style={{marginBottom:12}}>
<label style={{...S.head,display:'block',marginBottom:4,fontSize:9}}>WEITERE TAGS (kommagetrennt)</label>
<input value={tags} onChange={e=>setTags(e.target.value)} placeholder="z.B. api, auth, utils"
autoCapitalize="none" style={{...S.inp,fontSize:12}}/>
{/* Vorhandene Tags als Vorschläge */}
{suggestions.length>0 && (
<div style={{marginTop:6}}>
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:8,marginBottom:4}}>VORHANDENE TAGS HINZUFÜGEN:</div>
<div style={{display:'flex',gap:4,flexWrap:'wrap'}}>
{suggestions.map(t=>(
<button key={t} onClick={()=>addSuggestedTag(t)} style={{
fontSize:9,fontFamily:'monospace',padding:'2px 8px',borderRadius:12,cursor:'pointer',
background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.1)',
color:'rgba(255,255,255,0.4)',
}}>+ {t}</button>
))}
</div>
</div>
)}
{parseTags().length>0&&(
<div style={{display:'flex',gap:4,flexWrap:'wrap',marginTop:6}}>
{parseTags().map(t=>(
<span key={t} style={{fontSize:9,fontFamily:'monospace',
color:t===lang?LANG_COLORS[lang]:'rgba(255,255,255,0.5)',
background:'rgba(255,255,255,0.06)',borderRadius:4,padding:'2px 7px',
border:`1px solid ${t===lang?(LANG_COLORS[lang]+'40'):'rgba(255,255,255,0.1)'}`}}>
{t}
</span>
))}
</div>
)}
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={save} disabled={busy}
style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
{busy?'…':initial?.id?'✓ Aktualisieren':'↓ Speichern'}
</button>
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
</div>
</div>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function CodeSchnipsel({ toast, mobile }) {
const [own, setOwn] = useState([]);
const [shared, setShared] = useState([]);
const [sharedByMe,setSharedByMe]= useState([]);
const [tab, setTab] = useState('own');
const [search, setSearch] = useState('');
const [tagFilters,setTagFilters]= useState([]); // multi-select array
const [showForm, setShowForm] = useState(false);
const [editItem, setEditItem] = useState(null);
const [shareItem, setShareItem] = useState(null);
const [histItem, setHistItem] = useState(null);
const load = useCallback(()=>{
api('/tools/snippets').then(d=>{ setOwn(d.own||[]); setShared(d.shared||[]); setSharedByMe(d.sharedByMe||[]); }).catch(()=>{});
},[]);
useEffect(()=>{ load(); },[load]);
// Only tags that are currently in use
const allItems = [...own, ...shared];
const allTags = [...new Set(allItems.flatMap(s=>s.tags||[]))].sort();
const toggleTag = t => setTagFilters(p => p.includes(t) ? p.filter(x=>x!==t) : [...p, t]);
const filterItems = items => {
let res = items;
if (tagFilters.length) res = res.filter(s => tagFilters.every(t=>s.tags?.includes(t)));
if (search.trim()) {
const q = search.toLowerCase();
res = res.filter(s =>
s.title.toLowerCase().includes(q) ||
s.code.toLowerCase().includes(q) ||
s.description?.toLowerCase().includes(q) ||
s.tags?.some(t=>t.includes(q)) ||
s.language.includes(q)
);
}
return res;
};
const curList = tab==='own' ? own : tab==='shared' ? shared : sharedByMe;
const items = filterItems(curList);
const handleSave = async form => {
if (editItem?.id) {
const r = await api(`/tools/snippets/${editItem.id}`,{method:'PUT',body:form});
setOwn(p=>p.map(s=>s.id===r.id?r:s)); setEditItem(null);
} else {
const r = await api('/tools/snippets',{body:form});
setOwn(p=>[r,...p]); setShowForm(false);
}
toast('Gespeichert ✓');
};
const handleDelete = async id => {
if (!window.confirm('Schnipsel und alle seine Versionen wirklich löschen?')) return;
await api(`/tools/snippets/${id}`,{method:'DELETE'});
setOwn(p=>p.filter(s=>s.id!==id)); toast('Gelöscht');
};
const handleRestore = async (histEntry) => {
if (!histItem) return;
const r = await api(`/tools/snippets/${histItem.id}`,{method:'PUT',body:{
code: histEntry.code, language: histEntry.language
}});
setOwn(p=>p.map(s=>s.id===r.id?r:s));
setHistItem(null); toast('Version wiederhergestellt ✓');
};
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:860}}>
{shareItem && <SnippetShareModal snippet={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
{histItem && <HistoryModal snippet={histItem} onClose={()=>setHistItem(null)} onRestore={handleRestore} toast={toast}/>}
{/* Header */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Code-Schnipsel</h1>
<div style={{display:'flex',gap:8}}>
<button onClick={load} title="Neu laden"
style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,
color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'6px 10px',fontSize:14,fontFamily:'monospace'}}></button>
{!showForm && !editItem && tab==='own' && (
<button onClick={()=>setShowForm(true)}
style={{...S.btn('#4ecdc4'),padding:'7px 16px',fontSize:12}}>+ Neu</button>
)}
</div>
</div>
{/* Form */}
{(showForm || editItem) && (
<SnippetForm
initial={editItem}
onSave={handleSave}
onCancel={()=>{ setShowForm(false); setEditItem(null); }}
toast={toast}
existingTags={allTags}
/>
)}
{/* Tabs */}
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length],['sharedByMe','Geteilt von mir',sharedByMe.length]].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');setTagFilters([]);}} style={{
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
background:tab===k?'#4ecdc4':'rgba(255,255,255,0.05)',
color:tab===k?'#0d0d0f':'rgba(255,255,255,0.55)',
border:tab===k?'none':'1px solid rgba(255,255,255,0.1)',
fontWeight:tab===k?700:400,
}}>
{l}{cnt>0&&<span style={{marginLeft:4,opacity:0.7}}>({cnt})</span>}
</button>
))}
</div>
{/* Suche */}
<div style={{position:'relative',marginBottom:10}}>
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)',
color:'rgba(255,255,255,0.4)',pointerEvents:'none'}}></span>
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Suchen (Titel, Code, Tags…)"
style={{...S.inp,paddingLeft:34}}/>
{search && <button onClick={()=>setSearch('')} style={{position:'absolute',right:10,top:'50%',
transform:'translateY(-50%)',background:'transparent',border:'none',
color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:16}}></button>}
</div>
{/* Tag-Filter (Mehrfachauswahl) */}
{allTags.length>0 && (
<div style={{display:'flex',gap:5,flexWrap:'wrap',marginBottom:14,alignItems:'center'}}>
<button onClick={()=>setTagFilters([])} style={{
fontSize:9,fontFamily:'monospace',padding:'3px 9px',borderRadius:20,cursor:'pointer',
background:!tagFilters.length?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
border:`1px solid ${!tagFilters.length?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.08)'}`,
color:!tagFilters.length?'#4ecdc4':'rgba(255,255,255,0.4)',
}}>Alle</button>
{allTags.map(t=>{
const active = tagFilters.includes(t);
const c = LANG_COLORS[t] || '#4ecdc4';
return (
<button key={t} onClick={()=>toggleTag(t)} style={{
fontSize:9,fontFamily:'monospace',padding:'3px 9px',borderRadius:20,cursor:'pointer',
background:active?`${c}20`:'rgba(255,255,255,0.04)',
border:`1px solid ${active?`${c}50`:'rgba(255,255,255,0.08)'}`,
color:active?c:'rgba(255,255,255,0.45)',
}}>{active?'✓ ':''}{t}</button>
);
})}
{tagFilters.length>0 && (
<span style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginLeft:2}}>
({tagFilters.length} aktiv)
</span>
)}
</div>
)}
{/* Liste */}
{items.length===0 ? (
<div style={{...S.card,textAlign:'center',padding:40}}>
<div style={{fontSize:28,marginBottom:10}}>{'</>'}</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>
{search||tagFilters.length ? 'Keine Treffer.' : tab==='own' ? 'Noch keine Schnipsel füge deinen ersten hinzu.' : 'Noch nichts geteilt.'}
</div>
</div>
) : items.map(s=>(
<SnippetCard
key={s.id} snippet={s} toast={toast}
onEdit={setEditItem}
onDelete={handleDelete}
onShare={setShareItem}
onHistory={setHistItem}
/>
))}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,643 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { api, S } from '../lib.js';
const GRID = 20;
const MY_COLOR = '#4ecdc4';
const OPP_COLOR = '#ff6b9d';
const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]];
// Terrain-Konstanten (müssen mit Backend übereinstimmen)
const T_NORMAL = 0;
const T_GOLD = 1;
const T_MINE = 2;
const T_ROCK = 3;
const T_FOG = -1; // maskiert vom Server
function getMyId() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).id || null; } catch { return null; }
}
function getMyRole() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role || null; } catch { return null; }
}
// ── Spielerklärung ────────────────────────────────────────────────────────────
function HelpModal({ onClose }) {
return (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.85)', zIndex:1000, display:'flex', flexDirection:'column' }}
onClick={onClose}>
<div style={{ ...S.card, maxWidth:460, width:'100%', margin:'16px auto', display:'flex', flexDirection:'column',
maxHeight:'calc(100dvh - 32px)', overflow:'hidden' }}
onClick={e => e.stopPropagation()}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
paddingBottom:14, marginBottom:14, borderBottom:'1px solid rgba(255,255,255,0.08)', flexShrink:0 }}>
<span style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:14, fontWeight:700, letterSpacing:1 }}> SPIELREGELN</span>
<button onClick={onClose} style={S.btn('#ff6b9d', true)}> Schließen</button>
</div>
<div style={{ overflowY:'auto', paddingBottom:'calc(56px + env(safe-area-inset-bottom, 0px))' }}>
<div style={{ color:'rgba(255,255,255,0.75)', fontSize:13, lineHeight:1.9, fontFamily:'monospace' }}>
<p style={{ marginTop:0 }}>
<strong style={{ color:MY_COLOR }}>Ziel:</strong> Sammle mehr <strong>Punkte</strong> als dein Gegner durch clevere Expansion und das Meiden von Fallen.
</p>
<p><strong style={{ color:'#fff' }}>Ablauf:</strong><br/>
Beide starten in gegenüberliegenden Ecken. Du hast einen <strong style={{ color:MY_COLOR }}>Kopf</strong> dein letzter Zug, markiert mit einem leuchtenden Rahmen. Du kannst <strong>nur an den Kopf anbauen</strong> (alle 8 Richtungen). Dein Gebiet bleibt erhalten, aber du kannst nicht mehr von alten Feldern aus expandieren. Tippe ein Feld an, bestätige fertig.
</p>
<div style={{ background:'rgba(255,255,255,0.04)', borderRadius:8, padding:'12px 14px', marginBottom:12, border:'1px solid rgba(255,255,255,0.08)' }}>
<div style={{ marginBottom:8, color:'#fff', fontSize:12, letterSpacing:1 }}>FELDER & PUNKTE</div>
<div style={{ display:'grid', gridTemplateColumns:'28px 1fr', gap:'6px 10px', alignItems:'center' }}>
<span style={{ fontSize:16, textAlign:'center' }}></span><span><strong style={{ color:'rgba(255,255,255,0.9)' }}>Normales Feld</strong> +1 Punkt</span>
<span style={{ fontSize:16, textAlign:'center' }}></span><span><strong style={{ color:'#ffe66d' }}>Goldfeld</strong> +3 Punkte, sehr wertvoll!</span>
<span style={{ fontSize:16, textAlign:'center' }}>💣</span><span><strong style={{ color:'#ff6b9d' }}>Sprengmine</strong> Betreten zündet eine Explosion: alle 8 umliegenden Felder werden zu Felsen (auch Gold!). Besetzte Felder gehen verloren. Liegt eine weitere Mine im Umkreis, explodiert auch diese (Kettenreaktion!). Du verlierst deinen nächsten Zug.</span>
<span style={{ fontSize:16, textAlign:'center' }}>🪨</span><span><strong style={{ color:'rgba(255,255,255,0.4)' }}>Felsen</strong> unbesetzbar, blockiert Wege</span>
</div>
</div>
<p>
<strong style={{ color:'#fff' }}>🌫 Nebel:</strong><br/>
Du siehst nur dein Gebiet und die direkt angrenzenden Felder. Goldfelder und Minen bleiben verborgen bis du nahe genug herankommst also aufgepasst!
</p>
<p>
<strong style={{ color:'#ffe66d' }}>🔭 Kundschaften:</strong><br/>
Statt ein Feld zu besetzen kannst du einmal pro Zug <strong>kundschaften</strong>: tippe auf den Button <strong style={{ color:'#ffe66d' }}>🔭 Kundschaften</strong>, dann wähle einen Mittelpunkt auf dem Spielfeld ein <strong>3×3 Bereich</strong> wird nur für dich aufgedeckt. Dein Gegner sieht nicht was du entdeckt hast. Kostet deinen Zug, gibt dir aber wertvolle Information über Minen und Gold.
</p>
<p>
<strong style={{ color:'#fff' }}>Spielende:</strong><br/>
Das Spiel endet wenn niemand mehr ziehen kann oder wenn jemand <strong>doppelt so viele Punkte</strong> hat wie der Gegner (Dominanzsieg 👑). Wer mehr Punkte hat, gewinnt.
</p>
<div style={{ background:'rgba(255,255,255,0.04)', borderRadius:8, padding:'10px 14px', border:'1px solid rgba(255,255,255,0.08)' }}>
📱 <em>Auf dem Handy: Pinch zum Zoomen, Feld antippen und bestätigen.</em>
</div>
</div>
</div>
</div>
</div>
);
}
// ── Neues Spiel Modal ─────────────────────────────────────────────────────────
function NewGameModal({ onClose, onCreated, toast }) {
const [users, setUsers] = useState([]);
const [oppId, setOppId] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => { api('/tools/gebietseroberung/users').then(setUsers).catch(() => {}); }, []);
const create = async () => {
if (!oppId) return;
setLoading(true);
try {
const res = await api('/tools/gebietseroberung', { body: { opponent_id: Number(oppId) } });
toast('Spiel erstellt! Dein Gegner wurde benachrichtigt ⬡');
onCreated(res.id);
} catch(e) {
toast(e.message || 'Fehler beim Erstellen', 'error');
} finally { setLoading(false); }
};
return (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.85)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:1000, padding:20 }}>
<div style={{ ...S.card, maxWidth:360, width:'100%' }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:18 }}>
<span style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:13, fontWeight:700, letterSpacing:1 }}>NEUES SPIEL</span>
<button onClick={onClose} style={S.btn('#666666', true)}></button>
</div>
<div style={{ ...S.head, marginBottom:6 }}>GEGNER WÄHLEN</div>
<select value={oppId} onChange={e => setOppId(e.target.value)} style={{ ...S.inp, marginBottom:18 }}>
<option value=''>-- Benutzer wählen --</option>
{users.map(u => <option key={u.id} value={u.id}>{u.username}</option>)}
</select>
<div style={{ display:'flex', gap:8 }}>
<button onClick={onClose} style={{ ...S.btn('#666666'), flex:1 }}>Abbrechen</button>
<button onClick={create} disabled={!oppId || loading} style={{ ...S.btn('#4ecdc4'), flex:1 }}>
{loading ? 'Erstelle…' : 'Spiel starten'}
</button>
</div>
</div>
</div>
);
}
// ── Topliste ──────────────────────────────────────────────────────────────────
function Leaderboard({ reloadKey }) {
const [rows, setRows] = useState(null);
useEffect(() => { api('/tools/gebietseroberung/leaderboard').then(setRows).catch(() => setRows([])); }, [reloadKey]);
if (!rows) return null;
const medals = ['🥇','🥈','🥉'];
return (
<div style={{ marginTop:28 }}>
<div style={{ ...S.head, marginBottom:10 }}>TOPLISTE SIEGE</div>
{rows.length === 0
? <div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:12, textAlign:'center', padding:'16px 0' }}>Noch keine abgeschlossenen Spiele.</div>
: rows.map((r,i) => (
<div key={r.username} style={{ display:'flex', alignItems:'center', gap:12, padding:'10px 14px', marginBottom:6, background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:8 }}>
<span style={{ fontSize:16, width:24, textAlign:'center', flexShrink:0 }}>{medals[i] || `${i+1}.`}</span>
<span style={{ flex:1, color:'#fff', fontFamily:'monospace', fontSize:13 }}>{r.username}</span>
<span style={{ color:MY_COLOR, fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700 }}>{r.wins}</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }}>Siege</span>
</div>
))
}
</div>
);
}
// ── Spielfeld ─────────────────────────────────────────────────────────────────
function GameBoard({ game, myId, onMove, onScout, toast }) {
const [pending, setPending] = useState(null);
const [moving, setMoving] = useState(false);
const [scoutMode, setScoutMode] = useState(false); // Scout-Modus aktiv
const [scoutPend, setScoutPend] = useState(null); // gewählter Scout-Mittelpunkt
const [mineAlert, setMineAlert] = useState(null); // letztes Mine-Event
const grid = JSON.parse(game.grid);
const terrain = JSON.parse(game.terrain || '[]');
const hasTerrain = terrain.length > 0;
const isMyTurn = game.current_turn === myId;
const opponent = game.owner_id === myId ? game.opponent_id : game.owner_id;
const myColor = game.owner_id === myId ? MY_COLOR : OPP_COLOR;
const oppColor = game.owner_id === myId ? OPP_COLOR : MY_COLOR;
const oppName = game.owner_id === myId ? game.opp_name : game.owner_name;
// Mine-Event anzeigen wenn frisch
const [blastHighlight, setBlastHighlight] = useState(new Set());
const [alertShownAt, setAlertShownAt] = useState(null); // move_count beim Anzeigen
useEffect(() => {
if (!game.last_event) {
setMineAlert(null); // Event wurde vom Server geclearet (nächster Zug)
return;
}
try {
const ev = JSON.parse(game.last_event);
if (ev.type === 'mine') {
setMineAlert(ev);
setAlertShownAt(game.move_count);
if (ev.blastCells?.length) {
const keys = new Set(ev.blastCells.map(([r,c]) => `${r}-${c}`));
setBlastHighlight(keys);
setTimeout(() => setBlastHighlight(new Set()), 2000);
}
}
} catch {}
}, [game.last_event, game.move_count]);
// Schlangen-Köpfe aus Server-Response
const myHeadStr = game.myHead;
const myHead = myHeadStr ? myHeadStr.split(',').map(Number) : null;
const [myHR, myHC] = myHead ?? [null, null];
const oppHeadStr = game.oppHead;
const oppHead = oppHeadStr ? oppHeadStr.split(',').map(Number) : null;
const [oppHR, oppHC] = oppHead ?? [null, null];
const isAdjacentToHead = (r, c) => {
if (myHR === null) return false;
return Math.abs(r - myHR) <= 1 && Math.abs(c - myHC) <= 1 && !(r === myHR && c === myHC);
};
const canClick = (r, c) => {
if (scoutMode) return false; // im Scout-Modus kein normaler Zug
if (!isMyTurn || game.status !== 'active') return false;
if (grid[r][c] !== 0) return false;
if (hasTerrain && terrain[r][c] === T_ROCK) return false;
if (myHead) return isAdjacentToHead(r, c);
return DIRS8.some(([dr,dc]) => {
const nr=r+dr, nc=c+dc;
return nr>=0&&nr<GRID&&nc>=0&&nc<GRID&&grid[nr][nc]===myId;
});
};
const handleCellClick = (r, c) => {
if (!canClick(r, c) || moving) return;
if (pending?.row === r && pending?.col === c) { setPending(null); return; }
setPending({ row:r, col:c });
};
const confirmMove = async () => {
if (!pending || moving) return;
setMoving(true);
try {
await onMove(pending.row, pending.col);
setPending(null);
} catch(e) {
toast?.(e.message || 'Fehler', 'error');
} finally { setMoving(false); }
};
// Scores kommen vom Server (auf ungemasktem Grid berechnet — korrekt für beide Spieler)
const myScore = game.myScore ?? 0;
const oppScore = game.oppScore ?? 0;
const myCount = grid.flat().filter(v => v === myId).length;
const oppCount = grid.flat().filter(v => v === opponent).length;
const fogCount = grid.flat().filter(v => v === 0 && hasTerrain).length;
let banner = null;
if (game.status === 'finished') {
const isDominance = myScore >= oppScore * 2 || oppScore >= myScore * 2;
if (!game.winner_id) banner = { text:'Unentschieden! 🤝', color:'#ffe66d' };
else if (game.winner_id === myId) banner = { text: isDominance ? 'Dominanzsieg! 👑🎉' : 'Du hast gewonnen! 🎉', color:MY_COLOR };
else banner = { text: isDominance ? 'Dominanzniederlage 💀' : 'Du hast verloren.', color:OPP_COLOR };
}
const [cellSize, setCellSize] = useState(14);
useEffect(() => {
const calc = () => {
const vw = Math.min(window.innerWidth, 600);
const vh = window.visualViewport?.height ?? window.innerHeight;
// Overhead: Header(36) + Scoreboard(50) + Status(30) + Legende(26) + margins(28) = ~170px
const availH = vh - 170;
const byW = Math.floor((vw - 32) / GRID);
const byH = Math.floor(availH / GRID);
setCellSize(Math.max(10, Math.min(byW, byH)));
};
calc();
window.visualViewport?.addEventListener('resize', calc);
window.addEventListener('resize', calc);
return () => {
window.visualViewport?.removeEventListener('resize', calc);
window.removeEventListener('resize', calc);
};
}, []);
// Zell-Rendering
const handleScoutClick = (r, c) => {
if (!scoutPend) return;
setScoutPend({ row:r, col:c, confirmed: true });
};
const confirmScout = async () => {
if (!scoutPend || moving) return;
setMoving(true);
try {
await onScout(scoutPend.row, scoutPend.col);
setScoutMode(false);
setScoutPend(null);
} catch(e) {
toast?.(e.message || 'Fehler', 'error');
} finally { setMoving(false); }
};
const renderCell = (r, c) => {
const cell = grid[r][c];
const t = hasTerrain ? terrain[r][c] : T_NORMAL;
const clic = canClick(r, c);
const isPend = pending?.row === r && pending?.col === c;
const isBlast = blastHighlight.has(`${r}-${c}`);
const isHead = myHR === r && myHC === c;
const isOppHead = game.status === 'finished' && oppHR === r && oppHC === c;
// Scout-Preview: 3x3 um Maus-Position im Scout-Modus
const isScoutPreview = scoutPend &&
Math.abs(r - scoutPend.row) <= 1 && Math.abs(c - scoutPend.col) <= 1;
const isFog = t === T_FOG;
const isRock = t === T_ROCK;
const isGold = t === T_GOLD;
const isMine = t === T_MINE;
let bg = 'rgba(255,255,255,0.05)';
let content = null;
let border = '1px solid transparent';
if (isFog || (cell === 0 && !clic && hasTerrain && t === T_FOG)) {
bg = 'rgba(0,0,0,0.4)'; // dunkler Nebel
} else if (isRock) {
bg = 'rgba(255,255,255,0.12)';
content = cellSize > 16 ? '🪨' : null;
} else if (cell === myId) {
bg = isGold ? `#ffe66d88` : isMine ? `#ff6b9d44` : `${myColor}55`;
if (isGold) content = cellSize > 16 ? '⭐' : null;
if (isMine) content = cellSize > 16 ? '💣' : null;
} else if (cell === opponent) {
bg = isGold ? `#ffe66d55` : isMine ? `#ff6b9d33` : `${oppColor}55`;
} else {
// neutral & sichtbar
if (isGold) { bg = 'rgba(255,230,109,0.18)'; content = cellSize > 16 ? '⭐' : null; }
else if (isMine) { bg = 'rgba(255,107,157,0.15)'; content = cellSize > 16 ? '💣' : null; }
else if (clic) bg = `${myColor}22`;
}
if (isBlast) { bg = '#ff440066'; border = '2px solid #ff4400'; }
if (isOppHead) { border = `2px solid ${oppColor}`; }
if (isScoutPreview && !isHead) { bg = `rgba(255,230,109,0.2)`; border = `1px solid rgba(255,230,109,0.5)`; }
if (isHead && !isPend) { border = `2px solid ${myColor}`; }
if (isPend) { bg = myColor; border = `2px solid ${myColor}`; }
else if (clic && !isPend && !isBlast && !isHead) border = `1px solid ${myColor}44`;
return (
<div
key={`${r}-${c}`}
onClick={() => scoutMode ? setScoutPend({row:r,col:c}) : handleCellClick(r, c)}
onMouseEnter={() => scoutMode ? setScoutPend({row:r,col:c}) : setHover(`${r}-${c}`)}
onMouseLeave={() => { if(!scoutMode) setHover(null); }}
style={{
width:cellSize, height:cellSize, background:bg,
borderRadius:1, cursor:clic?'pointer':'default',
transition:'background 0.08s', border, boxSizing:'border-box',
display:'flex', alignItems:'center', justifyContent:'center',
fontSize: cellSize > 18 ? 10 : 7, lineHeight:1, userSelect:'none',
}}
>{content}</div>
);
};
return (
<div>
{/* Scoreboard */}
<div style={{ display:'flex', gap:8, alignItems:'center', marginBottom:6 }}>
<div style={{ ...S.card, padding:'5px 8px', flex:1 }}>
<div style={{ color:myColor, fontFamily:'monospace', fontSize:9, letterSpacing:1, marginBottom:2 }}>DU</div>
<div style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700, lineHeight:1 }}>{myScore} <span style={{ fontSize:9, color:'rgba(255,255,255,0.3)' }}>Pkt</span></div>
</div>
<div style={{ textAlign:'center', color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:9 }}>🌫{fogCount}</div>
<div style={{ ...S.card, padding:'5px 8px', flex:1, textAlign:'right' }}>
<div style={{ color:oppColor, fontFamily:'monospace', fontSize:9, letterSpacing:1, marginBottom:2 }}>{oppName?.toUpperCase()}</div>
<div style={{ color:'#fff', fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700, lineHeight:1 }}><span style={{ fontSize:9, color:'rgba(255,255,255,0.3)' }}>Pkt </span>{oppScore}</div>
</div>
</div>
{/* Mine-Alert */}
{mineAlert && (
<div style={{
background:'rgba(255,107,157,0.15)', border:'1px solid rgba(255,107,157,0.4)',
borderRadius:8, padding:'10px 14px', marginBottom:12,
display:'flex', alignItems:'center', gap:10,
}}>
<span style={{ fontSize:20 }}>💣</span>
<span style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:12, flex:1 }}>
{mineAlert.player === myId
? `💥 Mine! ${mineAlert.destroyedOwn ? mineAlert.destroyedOwn + ' deiner Felder zerstört — ' : ''}Zug verloren.`
: `💥 ${oppName} trat auf eine Mine! ${mineAlert.destroyedOpp ? mineAlert.destroyedOpp + ' deiner Felder zerstört!' : 'Kein Schaden.'} 😈`}
</span>
<button onClick={() => setMineAlert(null)} style={{ ...S.btn('#666666', true), padding:'2px 8px' }}></button>
</div>
)}
{/* Gewinner-Banner */}
{banner && (
<div style={{
background:`${banner.color}18`, border:`1px solid ${banner.color}44`,
borderRadius:10, padding:'12px 16px', marginBottom:14,
color:banner.color, fontFamily:'Space Mono,monospace', fontSize:15,
textAlign:'center', fontWeight:700, letterSpacing:1,
}}>{banner.text}</div>
)}
{/* Zug-Status */}
{game.status === 'active' && (
<div style={{ marginBottom:6 }}>
{!isMyTurn && !pending && (
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:11, textAlign:'center', padding:'4px 0' }}>
{oppName} ist dran
</div>
)}
{isMyTurn && !pending && !scoutMode && (
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
<div style={{ color:myColor, fontFamily:'monospace', fontSize:11, flex:1, textAlign:'center', padding:'4px 0' }}>
Baue vom Kopf aus Vorsicht vor Minen! 💣
</div>
<button onClick={() => { setScoutMode(true); setPending(null); }}
style={{ ...S.btn('#ffe66d', true), fontSize:10, flexShrink:0 }} title="3x3 Bereich aufdecken (kostet Zug)">
🔭 Kundschaften
</button>
</div>
)}
{isMyTurn && scoutMode && !scoutPend && (
<div style={{ display:'flex', alignItems:'center', gap:8, background:'rgba(255,230,109,0.1)', border:'1px solid rgba(255,230,109,0.4)', borderRadius:8, padding:'7px 10px' }}>
<span style={{ color:'#ffe66d', fontFamily:'monospace', fontSize:11, flex:1 }}>
🔭 Klicke einen Mittelpunkt zum Aufdecken (3×3)
</span>
<button onClick={() => { setScoutMode(false); setScoutPend(null); }} style={S.btn('#666666', true)}> Abbrechen</button>
</div>
)}
{isMyTurn && scoutMode && scoutPend && (
<div style={{ display:'flex', alignItems:'center', gap:8, background:'rgba(255,230,109,0.1)', border:'1px solid rgba(255,230,109,0.44)', borderRadius:8, padding:'7px 10px' }}>
<span style={{ color:'#ffe66d', fontFamily:'monospace', fontSize:11, flex:1 }}>
🔭 R{scoutPend.row+1} S{scoutPend.col+1} aufdecken kostet einen Zug
</span>
<button onClick={() => setScoutPend(null)} style={S.btn('#666666', true)}></button>
<button onClick={confirmScout} disabled={moving} style={S.btn('#ffe66d', true)}>{moving ? '…' : '✓ Aufdecken'}</button>
</div>
)}
{pending && !scoutMode && (
<div style={{ display:'flex', alignItems:'center', gap:8, background:`${myColor}12`, border:`1px solid ${myColor}44`, borderRadius:8, padding:'7px 10px' }}>
<span style={{ color:myColor, fontFamily:'monospace', fontSize:11, flex:1 }}>
R{pending.row+1} S{pending.col+1} bestätigen?
</span>
<button onClick={() => setPending(null)} style={S.btn('#666666', true)}></button>
<button onClick={confirmMove} disabled={moving} style={S.btn(MY_COLOR, true)}>{moving ? '…' : '✓ Ja'}</button>
</div>
)}
</div>
)}
{/* Legende */}
<div style={{ display:'flex', gap:6, marginBottom:4, flexWrap:'wrap' }}>
{[['⭐','Gold +3','#ffe66d'],['💣','Mine 💥','#ff6b9d'],['🪨','Felsen','rgba(255,255,255,0.35)'],['🌫️','Nebel','rgba(255,255,255,0.25)']].map(([icon,label,color]) => (
<div key={label} style={{ display:'flex', alignItems:'center', gap:4 }}>
<span style={{ fontSize:12 }}>{icon}</span>
<span style={{ color, fontFamily:'monospace', fontSize:10 }}>{label}</span>
</div>
))}
</div>
{/* Grid */}
<div style={{ overflowX:'auto', overflowY:'auto', WebkitOverflowScrolling:'touch', touchAction:'pinch-zoom', borderRadius:8, width:'fit-content' }}>
<div style={{ display:'grid', gridTemplateColumns:`repeat(${GRID}, ${cellSize}px)`, gridTemplateRows:`repeat(${GRID}, ${cellSize}px)`, gap:1, background:'rgba(255,255,255,0.04)', borderRadius:8, padding:3 }}>
{grid.map((row, r) => row.map((_, c) => renderCell(r, c)))}
</div>
</div>
</div>
);
}
// ── Spielzeile ────────────────────────────────────────────────────────────────
function GameRow({ g, myId, isAdmin, onSelect, onDelete }) {
const isMyTurn = g.status === 'active' && g.current_turn === myId;
const opp = g.owner_id === myId ? g.opp_name : g.owner_name;
const finished = g.status === 'finished' && (g.move_count || 0) >= 2;
let result = null;
if (g.status === 'finished') {
if (!g.winner_id) result = { label:'Unentschieden', color:'#ffe66d' };
else if (g.winner_id === myId) result = { label:'Gewonnen 🎉', color:MY_COLOR };
else result = { label:'Verloren', color:OPP_COLOR };
}
return (
<div style={{ ...S.card, marginBottom:8, display:'flex', alignItems:'center', gap:10, border: isMyTurn ? '1px solid rgba(78,205,196,0.35)' : '1px solid rgba(255,255,255,0.07)', padding:'12px 14px' }}>
<button onClick={() => onSelect(g.id)} style={{ flex:1, background:'none', border:'none', cursor:'pointer', textAlign:'left', padding:0 }}>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, marginBottom:4 }}>
{isMyTurn && <span style={{ color:MY_COLOR, marginRight:6 }}></span>}
vs <strong>{opp}</strong>
</div>
<div style={{ color:'rgba(255,255,255,0.35)', fontSize:11, fontFamily:'monospace' }}>
{g.move_count || 0} Züge gespielt
</div>
</button>
{result
? <span style={{ color:result.color, fontSize:11, fontFamily:'monospace', flexShrink:0 }}>{result.label}</span>
: <span style={{ color: isMyTurn ? MY_COLOR : 'rgba(255,255,255,0.3)', fontSize:11, fontFamily:'monospace', flexShrink:0 }}>
{isMyTurn ? 'Du bist dran' : 'Gegner dran'}
</span>
}
{isAdmin && finished && (
<button onClick={e => { e.stopPropagation(); onDelete(g.id); }} style={{ ...S.btn('#ff6b9d', true), flexShrink:0 }} title="Löschen">🗑</button>
)}
</div>
);
}
// ── Spielliste ────────────────────────────────────────────────────────────────
function GameList({ games, myId, isAdmin, onSelect, onNew, onDelete, reloadKey }) {
const active = games.filter(g => g.status === 'active');
const finished = games.filter(g => g.status === 'finished' && (g.move_count || 0) >= 2);
return (
<div>
<button onClick={onNew} style={{ ...S.btn('#4ecdc4'), width:'100%', marginBottom:20, padding:'10px 14px', fontSize:13 }}>
+ Neues Spiel starten
</button>
{active.length > 0 && <>
<div style={{ ...S.head, marginBottom:10 }}>LAUFENDE SPIELE ({active.length})</div>
{active.map(g => <GameRow key={g.id} g={g} myId={myId} isAdmin={isAdmin} onSelect={onSelect} onDelete={onDelete} />)}
</>}
{finished.length > 0 && <>
<div style={{ ...S.head, marginTop:20, marginBottom:10 }}>BEENDETE SPIELE</div>
{finished.map(g => <GameRow key={g.id} g={g} myId={myId} isAdmin={isAdmin} onSelect={onSelect} onDelete={onDelete} finished />)}
</>}
{games.length === 0 && (
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:13, textAlign:'center', paddingTop:40 }}>
Noch keine Spiele starte eines!
</div>
)}
<Leaderboard reloadKey={reloadKey} />
</div>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function HexWars({ toast }) {
const [games, setGames] = useState([]);
const [activeId, setActiveId] = useState(null);
const [game, setGame] = useState(null);
const [showNew, setShowNew] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
const myId = getMyId();
const isAdmin = getMyRole() === 'admin';
const loadGames = useCallback(async () => {
try { setGames(await api('/tools/gebietseroberung')); }
catch { toast?.('Fehler beim Laden', 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { loadGames(); }, [loadGames]);
useEffect(() => {
if (!activeId) { setGame(null); return; }
api(`/tools/gebietseroberung/${activeId}`).then(g => {
setGame(g);
// Beendetes Spiel als gesehen markieren → Badge verschwindet
if (g.status === 'finished') {
api(`/tools/gebietseroberung/${activeId}/seen`, { body: {} }).catch(() => {});
}
}).catch(() => {});
}, [activeId]);
// Polling wenn Gegner dran
useEffect(() => {
if (!activeId || !game || !myId) return;
if (game.status !== 'active' || game.current_turn === myId) return;
const iv = setInterval(async () => {
const g = await api(`/tools/gebietseroberung/${activeId}`).catch(() => null);
if (!g) return;
setGame(g);
if (g.current_turn === myId) {
toast?.('Du bist dran! ⬡');
// games-Liste sofort aktualisieren
setGames(prev => prev.map(p => p.id === g.id
? { ...p, current_turn: g.current_turn, move_count: g.move_count }
: p
));
}
}, 10000);
return () => clearInterval(iv);
}, [activeId, game, myId]);
const handleMove = async (row, col) => {
const updated = await api(`/tools/gebietseroberung/${activeId}/move`, { body: { row, col } });
setGame(updated);
if (updated.status === 'finished') {
api(`/tools/gebietseroberung/${activeId}/seen`, { body: {} }).catch(() => {});
}
// Direkt den games-State patchen damit die Liste sofort stimmt (kein Warten auf loadGames)
setGames(prev => prev.map(g => g.id === updated.id
? { ...g, current_turn: updated.current_turn, move_count: updated.move_count, status: updated.status, winner_id: updated.winner_id }
: g
));
};
const handleScout = async (row, col) => {
const updated = await api(`/tools/gebietseroberung/${activeId}/scout`, { body: { row, col } });
setGame(updated);
setGames(prev => prev.map(g => g.id === updated.id
? { ...g, current_turn: updated.current_turn, move_count: updated.move_count }
: g
));
};
const handleResign = async () => {
if (!window.confirm('Wirklich aufgeben?')) return;
await api(`/tools/gebietseroberung/${activeId}/resign`, { body: {} });
const updated = await api(`/tools/gebietseroberung/${activeId}`);
setGame(updated);
setGames(prev => prev.map(g => g.id === updated.id ? { ...g, ...updated } : g));
toast('Du hast aufgegeben.');
};
const handleDelete = async (id) => {
if (!window.confirm('Spiel wirklich löschen?')) return;
try {
await api(`/tools/gebietseroberung/${id}`, { method: 'DELETE' });
setGames(prev => prev.filter(g => g.id !== id));
setReloadKey(k => k + 1);
toast('Spiel gelöscht.');
} catch(e) { toast?.(e.message || 'Fehler', 'error'); }
};
if (loading) return <div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', padding:40, textAlign:'center' }}>Lade</div>;
return (
<div style={{ maxWidth:600, margin:'0 auto' }}>
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8 }}>
{activeId && (
<button onClick={() => { setActiveId(null); setGame(null); loadGames(); }} style={S.btn('#666666', true)}> Zurück</button>
)}
<h2 style={{ margin:0, fontSize:13, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400, flexShrink:0 }}>
HEX WARS
</h2>
<div style={{ flex:1 }} />
<button onClick={() => setShowHelp(true)} style={S.btn('#ffe66d', true)}>? Regeln</button>
{activeId && game?.status === 'active' && (
<button onClick={handleResign} style={S.btn('#ff6b9d', true)}></button>
)}
</div>
{!activeId
? <GameList games={games} myId={myId} isAdmin={isAdmin} onSelect={setActiveId} onNew={() => setShowNew(true)} onDelete={handleDelete} reloadKey={reloadKey} />
: game && myId
? <GameBoard game={game} myId={myId} onMove={handleMove} onScout={handleScout} toast={toast} />
: <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', textAlign:'center', paddingTop:40 }}>Lade Spiel</div>
}
{showNew && <NewGameModal onClose={() => setShowNew(false)} toast={toast} onCreated={id => { setShowNew(false); setActiveId(id); loadGames(); }} />}
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
</div>
);
}

View File

@@ -0,0 +1,844 @@
import { useConfirm } from '../confirm.jsx';
import { useState, useMemo, useEffect, useRef } from 'react';
import { api, S } from '../lib.js';
const MODEL_EXTENSIONS = ['.stl','.3mf','.f3d','.step','.stp','.obj','.iges','.igs','.fcstd','.amf','.scad'];
const DEFAULTS = {
materialpreis_pro_rolle: 10, // €/Rolle (1 kg = 1000 g → 0.01 €/g)
stromverbrauch_kw: 0.15,
strompreis_pro_kwh: 0.38, druckerpreis: 550,
gesamtdruckstunden: 5000, verschleiss_pro_stunde: 0.25,
};
const LS_CUSTOM = 'dd_kalk3d_custom_defaults';
const getEffectiveDefaults = () => {
try { const s = localStorage.getItem(LS_CUSTOM); if (s) return { ...DEFAULTS, ...JSON.parse(s) }; } catch {}
return { ...DEFAULTS };
};
const getFarbFaktor = n => Math.round((1.0 + Math.max(0, n - 1) * 0.1) * 100) / 100;
const STUFEN = [
{ key:'f', emoji:'💖', label:'Freundschaft', mult:1.0, c:'#ff6b9d' },
{ key:'n', emoji:'🤝', label:'Normal', mult:1.5, c:'#4ecdc4' },
{ key:'a', emoji:'💼', label:'Auftrag', mult:2.5, c:'#ffe66d' },
];
// ── Bild komprimieren (client-seitig, max 800px, JPEG 0.75) ──────────────────
function compressImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const MAX = 800;
let w = img.width, h = img.height;
if (w > MAX || h > MAX) {
if (w > h) { h = Math.round(h * MAX / w); w = MAX; }
else { w = Math.round(w * MAX / h); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', 0.75));
};
img.onerror = reject;
img.src = e.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// ── Stepper ───────────────────────────────────────────────────────────────────
function Stepper({ label, value, onChange, step, unit, min = 0, hint }) {
const [raw, setRaw] = useState(String(value));
// Sync wenn value sich von außen ändert (z.B. beim Bearbeiten eines Archiv-Eintrags)
useEffect(() => { setRaw(String(value)); }, [value]);
const dec = () => { const v = Math.max(min, Math.round((value - step) * 10000) / 10000); onChange(v); setRaw(String(v)); };
const inc = () => { const v = Math.round((value + step) * 10000) / 10000; onChange(v); setRaw(String(v)); };
return (
<div>
<div style={{ color:'rgba(255,255,255,0.4)', fontSize:10, fontFamily:'monospace', marginBottom:5, letterSpacing:1 }}>
{label.toUpperCase()}{unit ? ` (${unit})` : ''}
</div>
<div style={{ display:'flex', alignItems:'center', background:'rgba(255,255,255,0.05)', borderRadius:8, border:'1px solid rgba(255,255,255,0.1)', overflow:'hidden' }}>
<button onClick={dec} style={{ padding:'10px 12px', background:'transparent', border:'none', color:'rgba(255,255,255,0.6)', fontSize:20, cursor:'pointer', flexShrink:0, lineHeight:1 }}></button>
<input
type="number" value={raw} step={step}
onChange={e => {
setRaw(e.target.value);
const parsed = parseFloat(e.target.value);
if (!isNaN(parsed)) onChange(Math.max(min, parsed));
}}
onBlur={() => {
const parsed = parseFloat(raw);
const safe = isNaN(parsed) ? min : Math.max(min, parsed);
onChange(safe); setRaw(String(safe));
}}
style={{ flex:1, background:'transparent', border:'none', color:'#fff', fontSize:15, fontFamily:"'Space Mono',monospace", textAlign:'center', outline:'none', minWidth:0, padding:'10px 4px' }}
/>
<button onClick={inc} style={{ padding:'10px 12px', background:'transparent', border:'none', color:'rgba(255,255,255,0.6)', fontSize:20, cursor:'pointer', flexShrink:0, lineHeight:1 }}>+</button>
</div>
{hint && <div style={{ color:'rgba(255,255,255,0.5)', fontSize:10, fontFamily:'monospace', marginTop:3 }}>{hint}</div>}
</div>
);
}
function SmallInput({ label, value, onChange, step = 0.01 }) {
const [raw, setRaw] = useState(String(value));
useEffect(() => { setRaw(String(value)); }, [value]);
return (
<div>
<div style={{ color:'rgba(255,255,255,0.6)', fontSize:9, fontFamily:'monospace', marginBottom:3 }}>{label}</div>
<input
type="number" value={raw} step={step}
onChange={e => {
setRaw(e.target.value);
const parsed = parseFloat(e.target.value);
if (!isNaN(parsed)) onChange(parsed);
}}
onBlur={() => {
const parsed = parseFloat(raw);
if (!isNaN(parsed)) { onChange(parsed); setRaw(String(parsed)); }
else setRaw(String(value));
}}
style={{ ...S.inp, padding:'7px 8px', fontSize:13 }}
/>
</div>
);
}
// ── Bild-Modal ────────────────────────────────────────────────────────────────
function ImageModal({ src, onClose }) {
return (
<div style={{ position:'fixed', inset:0, background:'rgba(0,0,0,0.9)', zIndex:9000,
display:'flex', alignItems:'center', justifyContent:'center', padding:20 }}
onClick={onClose}>
<img src={src} alt="Vorschau"
style={{ maxWidth:'100%', maxHeight:'90vh', borderRadius:10, objectFit:'contain' }} />
</div>
);
}
// ── Kostenrechner ─────────────────────────────────────────────────────────────
export default function Kalkulator3D({ toast, editData, onEditDone, mobile, setActive }) {
const [gramm, setGramm] = useState(editData?.gramm ?? 50);
const [stunden, setStunden] = useState(editData?.stunden ?? 3);
const [farben, setFarben] = useState(editData?.farben ?? 1);
const [vars, setVars] = useState(editData ? {
materialpreis_pro_rolle: editData.materialpreis_pro_gramm != null ? Math.round(editData.materialpreis_pro_gramm * 1000 * 100) / 100 : DEFAULTS.materialpreis_pro_rolle,
stromverbrauch_kw: editData.stromverbrauch_kw,
strompreis_pro_kwh: editData.strompreis_pro_kwh,
druckerpreis: editData.druckerpreis,
gesamtdruckstunden: editData.gesamtdruckstunden,
verschleiss_pro_stunde: editData.verschleiss_pro_stunde,
} : { ...getEffectiveDefaults() });
const [showAdv, setShowAdv] = useState(false);
const [name, setName] = useState(editData?.name ?? '');
const [bemerkung, setBemerkung] = useState(editData?.bemerkung ?? '');
const [image, setImage] = useState(editData?.image ?? null);
const [saving, setSaving] = useState(false);
const [modelFiles, setModelFiles] = useState([]); // neue Dateien zum Hochladen
const [existingFiles, setExistingFiles] = useState([]); // bereits gespeicherte Dateien
const [modelFolderId, setModelFolderId] = useState(null);
const fileRef = useRef(null);
const modelFileRef = useRef(null);
const sv = (k, v) => setVars(p => ({ ...p, [k]: v }));
// Im Bearbeitungsmodus: existierende Dateien laden (ohne Ordner zu erstellen)
useEffect(() => {
if (!editData?.id || !editData?.name) return;
(async () => {
try {
const base = await api(`/tools/dateien/find-folder?name=3D-Modelle`);
if (!base) return; // Basisordner existiert noch nicht → keine Dateien
const sub = await api(`/tools/dateien/find-folder?name=${encodeURIComponent(editData.name.trim())}&parent_id=${base.id}`);
if (!sub) return; // Unterordner existiert nicht → keine Dateien
setModelFolderId(sub.id);
const data = await api(`/tools/dateien?folder_id=${sub.id}`);
setExistingFiles(data.own || []);
} catch {}
})();
}, [editData?.id]);
const calc = useMemo(() => {
const f = getFarbFaktor(farben);
const matProGramm = vars.materialpreis_pro_rolle / 1000;
const mat = gramm * matProGramm * f;
const str = stunden * vars.stromverbrauch_kw * vars.strompreis_pro_kwh;
const ver = stunden * vars.verschleiss_pro_stunde;
return { mat, str, ver, grund: mat + str + ver };
}, [gramm, stunden, farben, vars]);
const handleImagePick = async e => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) { toast('Nur Bilddateien erlaubt (JPG, PNG, …)', 'error'); e.target.value = ''; return; }
if (file.size > 20 * 1024 * 1024) { toast(`Bild zu groß (${(file.size/1024/1024).toFixed(1)} MB) max. 20 MB`, 'error'); e.target.value = ''; return; }
try {
const compressed = await compressImage(file);
setImage(compressed);
} catch { toast('Bild konnte nicht verarbeitet werden ist die Datei beschädigt?', 'error'); }
e.target.value = '';
};
const save = async () => {
if (!name.trim()) { toast('Bitte einen Namen eingeben', 'error'); return; }
setSaving(true);
try {
const payload = { name, gramm, stunden, farben,
materialpreis_pro_gramm: vars.materialpreis_pro_rolle / 1000,
stromverbrauch_kw: vars.stromverbrauch_kw,
strompreis_pro_kwh: vars.strompreis_pro_kwh,
druckerpreis: vars.druckerpreis,
gesamtdruckstunden: vars.gesamtdruckstunden,
verschleiss_pro_stunde: vars.verschleiss_pro_stunde,
preis_freundschaft: calc.grund * 1.0,
preis_normal: calc.grund * 1.5,
preis_auftrag: calc.grund * 2.5,
image, bemerkung };
if (editData?.id) {
await api(`/tools/kalkulator3d/${editData.id}`, { method:'PUT', body:payload });
// Neue Dateien hochladen falls vorhanden
if (modelFiles.length > 0) {
let folderId = modelFolderId;
if (!folderId) {
// Ordner erst jetzt anlegen wenn wirklich Dateien da sind
const base = await api('/tools/dateien/ensure-folder', { body: { name: '3D-Modelle' } });
const sub = await api('/tools/dateien/ensure-folder', { body: { name: editData.name.trim(), parent_id: base.id } });
folderId = sub.id;
setModelFolderId(folderId);
}
const form = new FormData();
form.append('folder_id', folderId);
for (const f of modelFiles) form.append('files', f);
await fetch('/api/tools/dateien/upload-3d', {
method:'POST',
headers:{ Authorization:`Bearer ${localStorage.getItem('sk_token')}` },
body: form,
});
setModelFiles([]);
const data = await api(`/tools/dateien?folder_id=${folderId}`);
setExistingFiles(data.own || []);
}
toast('Aktualisiert ✓'); onEditDone?.();
} else {
await api('/tools/kalkulator3d', { body:payload });
// 3D-Dateien hochladen wenn vorhanden
if (modelFiles.length > 0) {
try {
// 1. "3D-Modelle" Basisordner sicherstellen
const base = await api('/tools/dateien/ensure-folder', { body: { name: '3D-Modelle' } });
// 2. Unterordner mit Modellnamen anlegen
const sub = await api('/tools/dateien/ensure-folder', { body: { name: name.trim(), parent_id: base.id } });
// 3. Dateien hochladen
const form = new FormData();
form.append('folder_id', sub.id);
for (const f of modelFiles) form.append('files', f);
await fetch('/api/tools/dateien/upload-3d', {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('sk_token')}` },
body: form,
});
toast(`Gespeichert ✓ · ${modelFiles.length} Datei(en) in 3D-Modelle/${name.trim()} abgelegt`);
} catch {
toast('Gespeichert ✓ (Datei-Upload fehlgeschlagen)', 'error');
}
} else {
toast('Gespeichert ✓');
}
setName(''); setBemerkung(''); setImage(null); setModelFiles([]);
setActive?.('kalkulator3d-saved');
}
} catch(e) { toast(e.message, 'error'); } finally { setSaving(false); }
};
const pad = mobile ? '14px 14px 90px' : '36px 44px';
return (
<div style={{ padding:pad, maxWidth:860 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:20 }}>
<div>
{editData && <div style={{ color:'#ffe66d', fontSize:10, fontFamily:'monospace', marginBottom:4 }}> {editData.name}</div>}
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?18:22, margin:0 }}>Kostenrechner</h1>
</div>
<div style={{ display:'flex', gap:8 }}>
{editData && <button onClick={onEditDone} style={S.btn('#ff6b9d', true)}> Abbrechen</button>}
{!editData && <button onClick={() => window.location.reload()} title="Neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}></button>}
</div>
</div>
{/* Eingaben */}
<div style={{ ...S.card, marginBottom:10 }}>
<div style={{ display:'flex', flexDirection:'column', gap:10, marginBottom:10 }}>
<Stepper label="Gewicht" value={gramm} onChange={setGramm} step={1} unit="g" min={0} />
<Stepper label="Druckdauer" value={stunden} onChange={setStunden} step={0.5} unit="h" min={0} />
<Stepper label="Anzahl Farben" value={farben}
onChange={v => setFarben(Math.max(1, Math.round(v)))} step={1} min={1}
hint={`Farbfaktor: ×${getFarbFaktor(farben).toFixed(2)}`} />
</div>
<button onClick={() => setShowAdv(v => !v)} style={{
width:'100%', marginTop:4, background:'rgba(255,255,255,0.02)',
border:'1px solid rgba(255,255,255,0.06)', borderRadius:7,
padding:'9px 12px', color:'rgba(255,255,255,0.6)', cursor:'pointer',
fontFamily:'monospace', fontSize:11, textAlign:'left',
display:'flex', alignItems:'center', gap:7,
}}>
<span style={{ display:'inline-block', transform:showAdv?'rotate(90deg)':'rotate(0)', transition:'transform 0.18s' }}></span>
Erweiterte Einstellungen
<span style={{ marginLeft:'auto', fontSize:9, background:'rgba(78,205,196,0.1)', borderRadius:3, padding:'2px 6px', color:'#4ecdc4' }}>
{Object.entries(vars).some(([k,v]) => v !== getEffectiveDefaults()[k]) ? 'GEÄNDERT' : 'STANDARD'}
</span>
</button>
{showAdv && (
<div style={{ marginTop:10, background:'rgba(0,0,0,0.2)', border:'1px solid rgba(255,255,255,0.05)', borderRadius:9, padding:12 }}>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:10 }}>
<SmallInput label="Material €/Rolle" value={vars.materialpreis_pro_rolle} onChange={v=>sv('materialpreis_pro_rolle',v)} step={0.5}/>
<SmallInput label="Strom kW" value={vars.stromverbrauch_kw} onChange={v=>sv('stromverbrauch_kw',v)} step={0.01}/>
<SmallInput label="Strom €/kWh" value={vars.strompreis_pro_kwh} onChange={v=>sv('strompreis_pro_kwh',v)} step={0.01}/>
<SmallInput label="Drucker €" value={vars.druckerpreis} onChange={v=>sv('druckerpreis',v)} step={10}/>
<SmallInput label="Lebensdauer h" value={vars.gesamtdruckstunden} onChange={v=>sv('gesamtdruckstunden',v)} step={100}/>
<SmallInput label="Verschleiß €/h" value={vars.verschleiss_pro_stunde} onChange={v=>sv('verschleiss_pro_stunde',v)} step={0.01}/>
</div>
<div style={{ display:'flex', gap:8, marginTop:10 }}>
<button onClick={() => {
localStorage.setItem(LS_CUSTOM, JSON.stringify(vars));
toast('Als persönlicher Standard gespeichert ✓');
}} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}>
Als Standard speichern
</button>
<button onClick={() => {
localStorage.removeItem(LS_CUSTOM);
setVars({ ...DEFAULTS });
toast('Auf Werkseinstellungen zurückgesetzt');
}} style={{ ...S.btn('#ff6b9d', true) }} title="Werkseinstellungen wiederherstellen">
</button>
</div>
</div>
)}
</div>
{/* Ergebnis */}
<div style={{ ...S.card, marginBottom:10 }}>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:0 }}>
{[['Material',calc.mat,'#a78bfa'],['Strom',calc.str,'#60a5fa'],['Verschleiß',calc.ver,'#f59e0b'],['Grundkosten',calc.grund,'#4ecdc4']].map(([l,v,c],i) => (
<div key={l} style={{
padding:'10px 14px',
borderBottom: i < 2 ? '1px solid rgba(255,255,255,0.05)' : 'none',
borderRight: i % 2 === 0 ? '1px solid rgba(255,255,255,0.05)' : 'none',
}}>
<div style={{ color:'rgba(255,255,255,0.6)', fontSize:9, fontFamily:'monospace', marginBottom:2 }}>{l.toUpperCase()}</div>
<div style={{ color:c, fontSize:i===3?18:14, fontFamily:"'Space Mono',monospace", fontWeight:i===3?700:400 }}>
{v.toFixed(2)}
</div>
</div>
))}
</div>
</div>
{/* Preise */}
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:8, marginBottom:12 }}>
{STUFEN.map((s, i) => (
<div key={s.key} style={{
background:`${s.c}10`, border:`1px solid ${s.c}30`, borderRadius:10,
padding:'12px 10px', textAlign:'center', transform:i===1?'scale(1.04)':'scale(1)',
}}>
<div style={{ fontSize:20, marginBottom:3 }}>{s.emoji}</div>
<div style={{ color:'rgba(255,255,255,0.6)', fontSize:9, fontFamily:'monospace', marginBottom:4 }}>{s.label}</div>
<div style={{ color:s.c, fontSize:mobile?16:20, fontFamily:"'Space Mono',monospace", fontWeight:700 }}>
{(calc.grund * s.mult).toFixed(2)}
</div>
<div style={{ color:'rgba(255,255,255,0.5)', fontSize:8, fontFamily:'monospace', marginTop:2 }}>×{s.mult}</div>
</div>
))}
</div>
{/* Speichern */}
<div style={S.card}>
<div style={{ ...S.head, marginBottom:10 }}>BERECHNUNG SPEICHERN</div>
{/* Name */}
<div style={{ marginBottom:10 }}>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>NAME</label>
<input value={name} onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && save()}
placeholder='z.B. "Halterung Müller"'
style={{ ...S.inp, fontSize:15 }} />
</div>
{/* Bemerkung */}
<div style={{ marginBottom:10 }}>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>BEMERKUNG</label>
<textarea value={bemerkung} onChange={e => setBemerkung(e.target.value)}
placeholder="Material, Infill, Notizen…"
rows={3}
style={{ ...S.inp, resize:'vertical', lineHeight:1.6, fontSize:14, padding:'9px 12px' }} />
</div>
{/* Bild */}
<div style={{ marginBottom:14 }}>
<label style={{ ...S.head, display:'block', marginBottom:6 }}>BILD (optional)</label>
<input ref={fileRef} type="file" accept="image/*" onChange={handleImagePick}
style={{ display:'none' }} />
{image ? (
<div style={{ position:'relative', display:'inline-block' }}>
<img src={image} alt="Vorschau"
style={{ width:'100%', maxWidth:240, height:140, objectFit:'cover', borderRadius:8,
border:'1px solid rgba(255,255,255,0.1)', display:'block' }} />
<button onClick={() => setImage(null)} style={{
position:'absolute', top:6, right:6,
width:24, height:24, borderRadius:'50%',
background:'rgba(0,0,0,0.7)', border:'none',
color:'#fff', fontSize:13, cursor:'pointer', lineHeight:1,
}}></button>
</div>
) : (
<button onClick={() => fileRef.current?.click()} style={{
width:'100%', padding:'20px', background:'rgba(255,255,255,0.03)',
border:'1px dashed rgba(255,255,255,0.15)', borderRadius:8,
color:'rgba(255,255,255,0.6)', cursor:'pointer', fontFamily:'monospace', fontSize:12,
}}>📷 Bild hinzufügen</button>
)}
</div>
{/* 3D-Modelldateien beim Neuanlegen */}
{!editData?.id && (
<div style={{ marginBottom:14 }}>
<label style={{ ...S.head, display:'block', marginBottom:6 }}>
3D-DATEIEN (optional) werden in 3D-Modelle/{name.trim()||'…'} abgelegt
</label>
<input ref={modelFileRef} type="file" multiple
accept={MODEL_EXTENSIONS.join(',')}
onChange={e => setModelFiles(Array.from(e.target.files||[]))}
style={{ display:'none' }} />
{modelFiles.length > 0 ? (
<div style={{ background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8, padding:10 }}>
{modelFiles.map((f,i) => (
<div key={i} style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
padding:'4px 0', borderBottom: i<modelFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:11,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', maxWidth:'80%' }}>
🖨 {f.name}
</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
</div>
))}
<div style={{ display:'flex', gap:6, marginTop:8 }}>
<button onClick={() => modelFileRef.current?.click()}
style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center', fontSize:11 }}>
+ Weitere hinzufügen
</button>
<button onClick={() => setModelFiles([])}
style={{ ...S.btn('#ff6b9d', true), fontSize:11 }}></button>
</div>
</div>
) : (
<button onClick={() => modelFileRef.current?.click()} style={{
width:'100%', padding:'16px', background:'rgba(255,255,255,0.03)',
border:'1px dashed rgba(255,255,255,0.15)', borderRadius:8,
color:'rgba(255,255,255,0.6)', cursor:'pointer', fontFamily:'monospace', fontSize:12,
}}>🖨 3D-Dateien anhängen (STL, 3MF, F3D, STEP )</button>
)}
</div>
)}
{/* 3D-Modelldateien beim Bearbeiten */}
{editData?.id && (
<div style={{ marginBottom:14 }}>
<label style={{ ...S.head, display:'block', marginBottom:6 }}>
3D-DATEIEN 3D-Modelle/{editData.name}
</label>
<input ref={modelFileRef} type="file" multiple
accept={MODEL_EXTENSIONS.join(',')}
onChange={e => setModelFiles(Array.from(e.target.files||[]))}
style={{ display:'none' }} />
{/* Existierende Dateien */}
{existingFiles.length > 0 && (
<div style={{ background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8, padding:10, marginBottom:8 }}>
{existingFiles.map((f,i) => (
<div key={f.id} style={{ display:'flex', alignItems:'center', gap:8,
padding:'5px 0', borderBottom: i<existingFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ fontSize:16, flexShrink:0 }}>🖨</span>
<span style={{ color:'rgba(255,255,255,0.7)', fontFamily:'monospace', fontSize:11,
flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{f.originalname}
</span>
<span style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
<button onClick={async () => {
try {
await api(`/tools/dateien/${f.id}`, { method:'DELETE' });
const remaining = existingFiles.filter(x => x.id !== f.id);
setExistingFiles(remaining);
// Ordner löschen wenn er jetzt leer ist
if (remaining.length === 0 && modelFolderId) {
await api(`/tools/dateien/folders/${modelFolderId}`, { method:'DELETE' });
setModelFolderId(null);
}
} catch(e) { toast(e.message, 'error'); }
}} style={{ background:'transparent', border:'none', cursor:'pointer',
color:'rgba(255,107,157,0.5)', fontSize:14, padding:'0 2px', flexShrink:0, lineHeight:1 }}></button>
</div>
))}
</div>
)}
{/* Neue Dateien zum Hochladen */}
{modelFiles.length > 0 && (
<div style={{ background:'rgba(78,205,196,0.04)', border:'1px solid rgba(78,205,196,0.15)',
borderRadius:8, padding:10, marginBottom:8 }}>
<div style={{ ...S.head, marginBottom:6 }}>NEU HINZUFÜGEN</div>
{modelFiles.map((f,i) => (
<div key={i} style={{ display:'flex', justifyContent:'space-between',
padding:'3px 0', borderBottom: i<modelFiles.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:11,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', maxWidth:'80%' }}>
{f.name}
</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
</div>
))}
</div>
)}
<div style={{ display:'flex', gap:6 }}>
<button onClick={() => modelFileRef.current?.click()}
style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center', fontSize:11, padding:'9px 0' }}>
+ Dateien hinzufügen
</button>
{modelFiles.length > 0 && (
<button onClick={() => setModelFiles([])}
style={{ ...S.btn('#ff6b9d', true), fontSize:11 }}> Abbrechen</button>
)}
</div>
</div>
)}
<button onClick={save} disabled={saving} style={{
width:'100%', background:'linear-gradient(135deg,#4ecdc4,#45b7d1)', border:'none',
borderRadius:8, padding:'12px 0', color:'#0d0d0f',
fontFamily:"'Space Mono',monospace", fontWeight:700,
fontSize:13, cursor:saving?'default':'pointer', opacity:saving?0.7:1,
}}>{saving ? '…' : editData?.id ? '✓ Aktualisieren' : '↓ Speichern'}</button>
</div>
</div>
);
}
// ── Archiv ──────────────────────────────────────────────────────────────
// ── Datei-Downloads für ein Archiv-Modell ────────────────────────────────────
function ModelFiles({ calcId, toast }) {
const [files, setFiles] = useState(null); // null = noch nicht geladen
const [open, setOpen] = useState(false);
const load = async () => {
if (files !== null) return;
try { setFiles(await api(`/tools/kalkulator3d/${calcId}/files`)); }
catch { setFiles([]); }
};
const download = async f => {
try {
const res = await fetch(`/api/tools/kalkulator3d/${calcId}/files/${f.id}/download`, {
headers:{ Authorization:`Bearer ${localStorage.getItem('sk_token')}` }
});
if (!res.ok) { toast('Download fehlgeschlagen','error'); return; }
const blob = await res.blob();
const a = Object.assign(document.createElement('a'),{ href:URL.createObjectURL(blob), download:f.originalname });
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
} catch(e){ toast(e.message,'error'); }
};
const toggle = () => { if (!open) load(); setOpen(v=>!v); };
return (
<div>
<button onClick={toggle} style={{ ...S.btn('#4ecdc4', true), width:'100%', textAlign:'left',
display:'flex', alignItems:'center', gap:6, fontSize:11 }}>
<span>🖨</span>
<span style={{flex:1}}>3D-Dateien {files!==null&&files.length>0?`(${files.length})`:''}</span>
<span style={{transform:open?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s',fontSize:10}}></span>
</button>
{open && (
<div style={{ marginTop:6, background:'rgba(255,255,255,0.02)', border:'1px solid rgba(255,255,255,0.07)',
borderRadius:8, overflow:'hidden' }}>
{files === null ? (
<div style={{ padding:'10px 14px', color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>Lädt</div>
) : files.length === 0 ? (
<div style={{ padding:'10px 14px', color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>Keine Dateien vorhanden</div>
) : files.map((f,i) => (
<div key={f.id} style={{ display:'flex', alignItems:'center', gap:8, padding:'8px 12px',
borderBottom: i<files.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:11,
flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{f.originalname}
</span>
<span style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
<button onClick={()=>download(f)} style={{ ...S.btn('#4ecdc4'), padding:'4px 10px', fontSize:10, flexShrink:0 }}>
</button>
</div>
))}
</div>
)}
</div>
);
}
// ── Share Modal ───────────────────────────────────────────────────────────────
function CalcShareModal({ item, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/kalkulator3d/${item.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/kalkulator3d/${item.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e) { toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/kalkulator3d/${item.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); toast('Freigabe entfernt'); }
catch(e) { toast(e.message,'error'); }
};
const isMobile = window.innerWidth<768;
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
display:'flex',alignItems:isMobile?'flex-end':'center',justifyContent:'center',padding:isMobile?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMobile?'16px 16px 0 0':16,
width:'100%',maxWidth:420,padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
🖨 {item.name}
</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,padding:'0 4px'}}></button>
</div>
<p style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:12,lineHeight:1.6}}>
Empfänger können das Modell nur ansehen, nicht bearbeiten oder löschen.
</p>
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1,fontSize:15}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? (
<div>
<div style={{...S.head,marginBottom:6}}>GETEILT MIT</div>
{shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<div>
<div style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:13}}>{s.username}</div>
{s.shared_at && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10}}>seit {new Date(s.shared_at.replace(' ','T')).toLocaleDateString('de-DE')}</div>}
</div>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
))}
</div>
) : (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>
)}
</div>
</div>
);
}
export function SavedList({ toast, mobile }) {
const { confirm, ConfirmDialog } = useConfirm();
const [own, setOwn] = useState([]);
const [shared, setShared] = useState([]);
const [sharedByMe, setSharedByMe] = useState([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('own');
const [editData, setEditData] = useState(null);
const [search, setSearch] = useState('');
const [lightbox, setLightbox] = useState(null);
const [shareItem, setShareItem]= useState(null);
const load = () => {
setLoading(true);
api('/tools/kalkulator3d')
.then(d => { setOwn(d.own||[]); setShared(d.shared||[]); setSharedByMe(d.sharedByMe||[]); })
.catch(e => toast(e.message,'error'))
.finally(()=>setLoading(false));
};
useEffect(()=>{ load(); },[]);
const del = async id => {
try { await api(`/tools/kalkulator3d/${id}`,{method:'DELETE'}); setOwn(p=>p.filter(i=>i.id!==id)); toast('Gelöscht'); }
catch(e) { toast(e.message,'error'); }
};
const items = tab==='own' ? own : tab==='shared' ? shared : sharedByMe;
const filtered = search.trim()
? items.filter(i => i.name.toLowerCase().includes(search.toLowerCase()) || (i.bemerkung&&i.bemerkung.toLowerCase().includes(search.toLowerCase())))
: items;
if (editData) return (
<Kalkulator3D toast={toast} mobile={mobile} editData={editData} onEditDone={() => { setEditData(null); load(); }} />
);
return (
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth:900 }}>
{lightbox && <ImageModal src={lightbox} onClose={() => setLightbox(null)} />}
{shareItem && <CalcShareModal item={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
<ConfirmDialog/>
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:14 }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?18:22, margin:0 }}>Archiv</h1>
<button onClick={load} title="Neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}></button>
</div>
{/* Tabs */}
<div style={{ display:'flex', gap:6, marginBottom:14, flexWrap:'wrap' }}>
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length],['sharedByMe','Geteilt von mir',sharedByMe.length]].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');}} style={{
padding:'6px 14px', borderRadius:20, fontFamily:'monospace', fontSize:11, cursor:'pointer',
background: tab===k ? '#4ecdc4' : 'rgba(255,255,255,0.05)',
color: tab===k ? '#0d0d0f' : 'rgba(255,255,255,0.55)',
border: tab===k ? 'none' : '1px solid rgba(255,255,255,0.1)',
fontWeight: tab===k ? 700 : 400,
}}>
{l}{cnt>0&&<span style={{marginLeft:5,opacity:0.7}}>({cnt})</span>}
</button>
))}
</div>
{/* Suche */}
<div style={{ position:'relative', marginBottom:12 }}>
<span style={{ position:'absolute', left:12, top:'50%', transform:'translateY(-50%)',
color:'rgba(255,255,255,0.6)', fontSize:14, pointerEvents:'none' }}></span>
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Name oder Bemerkung suchen…"
style={{ ...S.inp, paddingLeft:34, fontSize:15 }} />
{search && (
<button onClick={() => setSearch('')} style={{
position:'absolute', right:10, top:'50%', transform:'translateY(-50%)',
background:'transparent', border:'none', color:'rgba(255,255,255,0.6)',
cursor:'pointer', fontSize:16,
}}></button>
)}
</div>
<p style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginBottom:14 }}>
{filtered.length} von {items.length} Einträgen{search ? ` für „${search}"` : ''}
</p>
{loading && <div style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace' }}>Lädt</div>}
{!loading && items.length === 0 && (
<div style={{ ...S.card, textAlign:'center', padding:40 }}>
<div style={{ fontSize:28, marginBottom:10 }}></div>
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:12 }}>Noch keine Berechnungen.</div>
</div>
)}
{!loading && search && filtered.length === 0 && (
<div style={{ ...S.card, textAlign:'center', padding:24 }}>
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:12 }}>Keine Treffer.</div>
</div>
)}
{filtered.map(item => (
<div key={item.id} style={{ ...S.card, marginBottom:10, padding:'14px' }}>
<div style={{ display:'flex', gap:12 }}>
{/* Bild Thumbnail */}
{item.image && (
<img src={item.image} alt={item.name}
onClick={() => setLightbox(item.image)}
style={{ width:72, height:72, objectFit:'cover', borderRadius:8, flexShrink:0,
border:'1px solid rgba(255,255,255,0.1)', cursor:'pointer' }} />
)}
<div style={{ flex:1, minWidth:0 }}>
{/* Name + Datum */}
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', marginBottom:4 }}>
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:700 }}>{item.name}</div>
{item.has_files && (
<span title="3D-Dateien vorhanden"
style={{ background:'rgba(78,205,196,0.12)', border:'1px solid rgba(78,205,196,0.25)',
borderRadius:5, padding:'1px 5px', color:'#4ecdc4', fontFamily:'monospace', fontSize:9,
letterSpacing:0.5, flexShrink:0 }}>🖨 Dateien</span>
)}
</div>
<div style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10, flexShrink:0, marginLeft:8 }}>
{new Date(item.created_at).toLocaleDateString('de-DE')}
</div>
</div>
{/* Infos */}
<div style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:10, marginBottom:6 }}>
{item.gramm}g · {item.stunden}h · {item.farben} Farbe{item.farben > 1 ? 'n' : ''}
</div>
{/* Bemerkung */}
{item.bemerkung && (
<div style={{ color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:11,
lineHeight:1.5, marginBottom:8, wordBreak:'break-word' }}>
{item.bemerkung}
</div>
)}
{/* Preise */}
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:6, marginBottom:10 }}>
{[['💖',item.preis_freundschaft,'#ff6b9d'],['🤝',item.preis_normal,'#4ecdc4'],['💼',item.preis_auftrag,'#ffe66d']].map(([e,v,c]) => (
<div key={e} style={{ background:`${c}10`, border:`1px solid ${c}25`, borderRadius:7, padding:'6px 4px', textAlign:'center' }}>
<div style={{ fontSize:12 }}>{e}</div>
<div style={{ color:c, fontFamily:"'Space Mono',monospace", fontSize:12, fontWeight:700 }}>{v.toFixed(2)}</div>
</div>
))}
</div>
</div>
</div>
{/* Aktionen */}
<div style={{ display:'flex', gap:6, marginTop:10, flexDirection:'column' }}>
{/* Datei-Downloads */}
{item.has_files && (
<ModelFiles calcId={item.id} toast={toast}/>
)}
<div style={{ display:'flex', gap:6 }}>
{!item.is_shared && tab !== 'sharedByMe' ? (
<>
<button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}> Bearbeiten</button>
<button onClick={() => setShareItem(item)} style={{ ...S.btn('#ffe66d', true), padding:'0 12px' }} title="Teilen">🤝</button>
<button onClick={() => del(item.id)} style={{ ...S.btn('#ff6b9d', true), padding:'0 12px' }} title="Löschen"></button>
</>
) : tab === 'sharedByMe' ? (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, padding:'4px 0' }}>
{item.shared_with_name}
</div>
) : (
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, padding:'6px 0' }}>
von {item.owner_name} · nur Ansicht
</div>
)}
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,513 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from '../lib.js';
// ── Konstanten ────────────────────────────────────────────────────────────────
const PRIORITIES = [
{ id: 'none', label: 'Keine', color: '#666666' },
{ id: 'low', label: 'Niedrig', color: '#4ecdc4' },
{ id: 'medium', label: 'Mittel', color: '#ffe66d' },
{ id: 'high', label: 'Hoch', color: '#ff6b9d' },
];
const COL_COLORS = ['#4ecdc4','#60a5fa','#a78bfa','#ff6b9d','#ffe66d','#fb923c','#4ade80','#f472b6','#94a3b8'];
const fmtCardDate = s => {
if (!s) return '';
const d = new Date(String(s).replace(' ', 'T'));
if (isNaN(d)) return '';
return d.toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' })
+ ' ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
};
const prioOf = id => PRIORITIES.find(p => p.id === id) || PRIORITIES[0];
// ── Karten-Modal ──────────────────────────────────────────────────────────────
function CardModal({ card, columnId, columns, onSave, onClose, toast }) {
const [title, setTitle] = useState(card?.title || '');
const [desc, setDesc] = useState(card?.description || '');
const [prio, setPrio] = useState(card?.priority || 'none');
const [moveToCol, setMoveToCol] = useState(card?.column_id || columnId);
const [newColName, setNewColName] = useState('');
const [showNewCol, setShowNewCol] = useState(false);
const [saving, setSaving] = useState(false);
const titleRef = useRef(null);
const newColRef = useRef(null);
const isMob = window.innerWidth < 768;
useEffect(() => { titleRef.current?.focus(); }, []);
useEffect(() => { if (showNewCol) newColRef.current?.focus(); }, [showNewCol]);
const save = async () => {
if (!title.trim()) return;
setSaving(true);
try {
// Neue Spalte anlegen wenn gewünscht
let targetCol = moveToCol;
if (showNewCol && newColName.trim()) {
const nc = await api('/tools/kanban/columns', { body: { title: newColName.trim() } });
targetCol = nc.id;
}
if (card) {
await api(`/tools/kanban/cards/${card.id}`, {
method: 'PATCH',
body: { title, description: desc, priority: prio },
});
// Spalte gewechselt?
if (targetCol && targetCol !== card.column_id) {
await api(`/tools/kanban/cards/${card.id}/move`, { body: { column_id: targetCol } });
}
} else {
await api('/tools/kanban/cards', { body: { column_id: targetCol || columnId, title, description: desc, priority: prio } });
}
onSave();
} catch (e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
const onKey = e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) save();
if (e.key === 'Escape') onClose();
};
return (
<div onClick={e => e.target === e.currentTarget && onClose()}
style={{ position:'fixed',inset:0,background:'rgba(0,0,0,0.7)',zIndex:1000,
display:'flex',
alignItems:isMob?'flex-end':'center',
justifyContent:'center',
padding:isMob?0:20,
// Auf Mobile: unten Platz für die fixe Bottom-Nav (56px + safe-area) lassen
paddingBottom: isMob ? 'calc(56px + env(safe-area-inset-bottom, 0px))' : 20,
}}>
<div style={{ background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:isMob?'16px 16px 0 0':14,
width:'100%',maxWidth:460,
display:'flex',flexDirection:'column',
maxHeight: isMob ? '80vh' : '90vh',
}}>
{/* Drag-Handle + Header immer sichtbar, nicht scrollend */}
<div style={{ padding:'18px 20px 0', flexShrink:0 }}>
{isMob && <div style={{ width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px' }}/>}
<div style={{ display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16 }}>
<div style={{ ...S.head, marginBottom:0 }}>{card ? 'KARTE BEARBEITEN' : 'NEUE KARTE'}</div>
<button onClick={onClose} style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18 }}></button>
</div>
</div>
{/* Scrollbarer Inhalt */}
<div style={{ overflowY:'auto',padding:'0 20px',flex:1,scrollbarWidth:'thin',scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
<input ref={titleRef} value={title} onChange={e=>setTitle(e.target.value)} onKeyDown={onKey}
placeholder="Titel" style={{ ...S.inp, marginBottom:10 }} />
<textarea value={desc} onChange={e=>setDesc(e.target.value)} onKeyDown={onKey}
placeholder="Beschreibung (optional)" rows={3}
style={{ ...S.inp, resize:'vertical', marginBottom:14 }} />
{/* Priorität */}
<div style={{ marginBottom:14 }}>
<div style={{ ...S.head, marginBottom:8 }}>PRIORITÄT</div>
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
{PRIORITIES.map(p => (
<button key={p.id} onClick={()=>setPrio(p.id)} style={{
...S.btn(p.color, true),
background: prio===p.id ? `${p.color}28` : `${p.color}0e`,
borderColor: prio===p.id ? p.color : `${p.color}35`,
fontWeight: prio===p.id ? 700 : 400,
}}>{p.label}</button>
))}
</div>
</div>
{/* Verschieben nach */}
<div style={{ marginBottom:14 }}>
<div style={{ ...S.head, marginBottom:8 }}>{card ? 'VERSCHIEBEN NACH' : 'IN SPALTE'}</div>
{!showNewCol ? (
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
{columns.map(c => (
<button key={c.id} onClick={()=>setMoveToCol(c.id)} style={{
...S.btn(c.color || '#4ecdc4', true),
background: moveToCol===c.id ? `${c.color||'#4ecdc4'}28` : `${c.color||'#4ecdc4'}0e`,
borderColor: moveToCol===c.id ? (c.color||'#4ecdc4') : `${c.color||'#4ecdc4'}35`,
fontWeight: moveToCol===c.id ? 700 : 400,
}}>{c.title}</button>
))}
<button onClick={()=>setShowNewCol(true)} style={S.btn('#888888', true)}>+ Neue Spalte</button>
</div>
) : (
<div style={{ display:'flex', gap:6 }}>
<input ref={newColRef} value={newColName} onChange={e=>setNewColName(e.target.value)}
onKeyDown={e=>{ if(e.key==='Escape'){setShowNewCol(false);setNewColName('');} }}
placeholder="Name der neuen Spalte"
style={{ ...S.inp, flex:1, fontSize:12 }} />
<button onClick={()=>{setShowNewCol(false);setNewColName('');}} style={S.btn('#888888',true)}></button>
</div>
)}
</div>
</div>
{/* Aktionsbuttons immer sichtbar am unteren Rand, nicht scrollend */}
<div style={{ padding:'14px 20px 24px', flexShrink:0, borderTop:'1px solid rgba(255,255,255,0.07)',
display:'flex', gap:8, justifyContent:'flex-end' }}>
<button onClick={onClose} style={S.btn('#888888', true)}>Abbrechen</button>
<button onClick={save} disabled={!title.trim()||saving} style={S.btn('#4ecdc4', true)}>
{saving ? '…' : card ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
</div>
);
}
// ── Spalten-Header ────────────────────────────────────────────────────────────
function ColumnHeader({ col, onUpdate, onDelete, toast }) {
const [editTitle, setEditTitle] = useState(false);
const [titleVal, setTitleVal] = useState(col.title);
const [showColor, setShowColor] = useState(false);
const inputRef = useRef(null);
useEffect(() => { if (editTitle) inputRef.current?.focus(); }, [editTitle]);
const commitTitle = async () => {
setEditTitle(false);
if (titleVal.trim() && titleVal.trim() !== col.title) {
try { await onUpdate(col.id, { title: titleVal.trim() }); }
catch (e) { toast(e.message, 'error'); setTitleVal(col.title); }
} else { setTitleVal(col.title); }
};
const setColor = async (color) => {
setShowColor(false);
try { await onUpdate(col.id, { color }); }
catch (e) { toast(e.message, 'error'); }
};
return (
<div style={{ marginBottom:12 }}>
{/* Farbstreifen oben */}
<div style={{ height:3,borderRadius:'2px 2px 0 0',background:col.color||'#4ecdc4',margin:'-12px -12px 10px',marginTop:-12 }}/>
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
{/* Farbpicker-Button */}
<div style={{ position:'relative' }}>
<button onClick={()=>setShowColor(v=>!v)} title="Farbe ändern"
style={{ width:16,height:16,borderRadius:'50%',background:col.color||'#4ecdc4',
border:'2px solid rgba(255,255,255,0.2)',cursor:'pointer',flexShrink:0,padding:0 }}/>
{showColor && (
<div style={{ position:'absolute',top:22,left:0,zIndex:200,
background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:10,padding:8,display:'flex',flexWrap:'wrap',gap:6,width:130 }}>
{COL_COLORS.map(c => (
<button key={c} onClick={()=>setColor(c)} style={{
width:22,height:22,borderRadius:'50%',background:c,border:
(col.color||'#4ecdc4')===c?'2px solid #fff':'2px solid transparent',
cursor:'pointer',padding:0,transition:'border 0.1s',
}}/>
))}
</div>
)}
</div>
{/* Titel */}
{editTitle ? (
<input ref={inputRef} value={titleVal} onChange={e=>setTitleVal(e.target.value)}
onBlur={commitTitle}
onKeyDown={e=>{ if(e.key==='Enter') commitTitle(); if(e.key==='Escape'){setTitleVal(col.title);setEditTitle(false);} }}
style={{ ...S.inp, fontSize:12,fontWeight:700,padding:'3px 7px',flex:1 }} />
) : (
<span onDoubleClick={()=>setEditTitle(true)} title="Doppelklick zum Umbenennen"
style={{ flex:1,fontSize:12,fontWeight:700,fontFamily:'monospace',color:'#fff',
cursor:'pointer',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap' }}>
{col.title}
</span>
)}
<span style={{ color:'rgba(255,255,255,0.3)',fontSize:11,fontFamily:'monospace',flexShrink:0 }}>
{col.cards.length}
</span>
<button onClick={()=>onDelete(col.id)} title="Spalte löschen"
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',
cursor:'pointer',fontSize:13,padding:'0 2px',lineHeight:1,flexShrink:0 }}></button>
</div>
</div>
);
}
// ── Einzelne Karte ─────────────────────────────────────────────────────────────
function KanbanCard({ card, col, allCols, onEdit, onDelete, onMove, isDragging, dragHandlers }) {
const prio = prioOf(card.priority);
const colIdx = allCols.findIndex(c => c.id === col.id);
const cardIdx = col.cards.findIndex(c => c.id === card.id);
const isFirst = cardIdx === 0;
const isLast = cardIdx === col.cards.length - 1;
const isFirstCol = colIdx === 0;
const isLastCol = colIdx === allCols.length - 1;
const arrowBtn = (label, disabled, onClick) => (
<button onClick={disabled ? undefined : onClick} disabled={disabled}
style={{ background:'transparent',border:'none',padding:'1px 4px',lineHeight:1,fontSize:13,
color: disabled ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)',
cursor: disabled ? 'default' : 'pointer' }}>
{label}
</button>
);
return (
<div {...dragHandlers} style={{
background: isDragging ? 'rgba(78,205,196,0.06)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${isDragging ? '#4ecdc4' : 'rgba(255,255,255,0.08)'}`,
borderLeft: `3px solid ${prio.color}`,
borderRadius:8, padding:'10px 10px 8px', marginBottom:6,
cursor:'grab', opacity: isDragging ? 0.45 : 1,
transition:'border-color 0.12s, opacity 0.12s', userSelect:'none',
}}>
{/* Titel-Zeile */}
<div style={{ display:'flex',alignItems:'flex-start',gap:6,marginBottom:card.description?6:8 }}>
<span style={{ flex:1,fontSize:13,fontFamily:'monospace',color:'#fff',wordBreak:'break-word',lineHeight:1.4 }}>
{card.title}
</span>
<div style={{ display:'flex',gap:3,flexShrink:0 }}>
<button onClick={()=>onEdit(card)} title="Bearbeiten"
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.35)',
cursor:'pointer',fontSize:12,padding:'1px 3px',lineHeight:1 }}></button>
<button onClick={()=>onDelete(card.id)} title="Löschen"
style={{ background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',
cursor:'pointer',fontSize:12,padding:'1px 3px',lineHeight:1 }}></button>
</div>
</div>
{/* Beschreibung */}
{card.description && (
<div style={{ fontSize:11,color:'rgba(255,255,255,0.4)',fontFamily:'monospace',
lineHeight:1.4,whiteSpace:'pre-wrap',wordBreak:'break-word',marginBottom:8 }}>
{card.description}
</div>
)}
{/* Priorität + Pfeile */}
<div style={{ display:'flex',alignItems:'center',justifyContent:'space-between',gap:4 }}>
{prio.id !== 'none' ? (
<span style={{ fontSize:9,fontFamily:'monospace',letterSpacing:1,
color:prio.color, background:`${prio.color}15`,
border:`1px solid ${prio.color}30`, borderRadius:4, padding:'1px 5px' }}>
{prio.label.toUpperCase()}
</span>
) : <span/>}
<div style={{ display:'flex',gap:0,flexShrink:0 }}>
{arrowBtn('↑', isFirst, ()=>onMove(card.id, col.id, cardIdx-1))}
{arrowBtn('↓', isLast, ()=>onMove(card.id, col.id, cardIdx+1))}
{arrowBtn('←', isFirstCol, ()=>onMove(card.id, allCols[colIdx-1]?.id))}
{arrowBtn('→', isLastCol, ()=>onMove(card.id, allCols[colIdx+1]?.id))}
</div>
</div>
{/* Erstellungsdatum */}
{card.created_at && (
<div style={{ fontSize:9,fontFamily:'monospace',color:'rgba(255,255,255,0.2)',
marginTop:6, textAlign:'right', letterSpacing:0.5 }}>
{fmtCardDate(card.created_at)}
</div>
)}
</div>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function Kanban({ toast, mobile }) {
const [columns, setColumns] = useState([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState(null); // null | { card?, columnId }
const [newColInput, setNewColInput] = useState('');
const [addingCol, setAddingCol] = useState(false);
const [dragCardId, setDragCardId] = useState(null);
const [dragOverCol, setDragOverCol] = useState(null);
const [dragOverPos, setDragOverPos] = useState(null);
const newColRef = useRef(null);
const load = useCallback(() => {
setLoading(true);
api('/tools/kanban/board')
.then(d => setColumns(d.columns || []))
.catch(() => toast('Fehler beim Laden', 'error'))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => { if (addingCol) newColRef.current?.focus(); }, [addingCol]);
// ── Spalten-Aktionen ────────────────────────────────────────────────────────
const addColumn = async () => {
if (!newColInput.trim()) return;
try {
await api('/tools/kanban/columns', { body: { title: newColInput.trim() } });
setNewColInput(''); setAddingCol(false); load();
} catch (e) { toast(e.message, 'error'); }
};
const updateColumn = async (id, patch) => {
await api(`/tools/kanban/columns/${id}`, { method:'PATCH', body: patch });
load();
};
const deleteColumn = async (id) => {
const col = columns.find(c => c.id === id);
const msg = col?.cards?.length
? `Spalte "${col.title}" und alle ${col.cards.length} Karte(n) löschen?`
: `Spalte "${col?.title}" löschen?`;
if (!confirm(msg)) return;
try { await api(`/tools/kanban/columns/${id}`, { method:'DELETE' }); load(); }
catch (e) { toast(e.message, 'error'); }
};
// ── Karten-Aktionen ─────────────────────────────────────────────────────────
const deleteCard = async (id) => {
if (!confirm('Karte löschen?')) return;
try { await api(`/tools/kanban/cards/${id}`, { method:'DELETE' }); load(); }
catch (e) { toast(e.message, 'error'); }
};
const moveCard = async (cardId, colIdOrPos, posOrUndef) => {
// Aufruf-Varianten:
// onMove(id, col.id, newPos) → Position innerhalb Spalte
// onMove(id, targetColId) → Spalte wechseln, ans Ende
try {
if (posOrUndef !== undefined) {
await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:colIdOrPos, position:posOrUndef } });
} else {
await api(`/tools/kanban/cards/${cardId}/move`, { body:{ column_id:colIdOrPos } });
}
load();
} catch (e) { toast(e.message, 'error'); }
};
// ── Drag & Drop ─────────────────────────────────────────────────────────────
const onDragStart = (e, cardId) => {
setDragCardId(cardId);
e.dataTransfer.effectAllowed = 'move';
};
const onDragEnd = () => { setDragCardId(null); setDragOverCol(null); setDragOverPos(null); };
const onDragOverCard = (e, colId, pos) => {
e.preventDefault(); e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
setDragOverCol(colId); setDragOverPos(pos);
};
const onDragOverColBg = (e, colId) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const col = columns.find(c => c.id === colId);
setDragOverCol(colId); setDragOverPos(col ? col.cards.length : 0);
};
const onDrop = async (e, colId, pos) => {
e.preventDefault();
if (!dragCardId) return;
const targetPos = pos !== undefined ? pos : dragOverPos;
setDragCardId(null); setDragOverCol(null); setDragOverPos(null);
try {
await api(`/tools/kanban/cards/${dragCardId}/move`, { body:{ column_id:colId, position:targetPos } });
load();
} catch (err) { toast(err.message, 'error'); }
};
// ── Render ────────────────────────────────────────────────────────────────────
if (loading) return (
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px' }}>
<div style={{ color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12 }}>Lädt</div>
</div>
);
return (
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', height:'100%', boxSizing:'border-box', display:'flex', flexDirection:'column' }}>
{/* ── Header ─────────────────────────────────────────────────────────── */}
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24, flexWrap:'wrap' }}>
<h2 style={{ margin:0, fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
KANBAN BOARD
</h2>
<div style={{ flex:1 }}/>
{addingCol ? (
<div style={{ display:'flex', gap:6 }}>
<input ref={newColRef} value={newColInput} onChange={e=>setNewColInput(e.target.value)}
onKeyDown={e=>{ if(e.key==='Enter') addColumn(); if(e.key==='Escape'){setAddingCol(false);setNewColInput('');} }}
placeholder="Spaltenname" style={{ ...S.inp, width: mobile?140:180, fontSize:12 }} />
<button onClick={addColumn} style={S.btn('#4ecdc4', true)}>Hinzufügen</button>
<button onClick={()=>{setAddingCol(false);setNewColInput('');}} style={S.btn('#888888', true)}></button>
</div>
) : (
<button onClick={()=>setAddingCol(true)} style={S.btn('#4ecdc4', true)}>+ Spalte</button>
)}
</div>
{/* ── Board ──────────────────────────────────────────────────────────── */}
{columns.length === 0 ? (
<div style={{ ...S.card, textAlign:'center', padding:40 }}>
<div style={{ color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:12 }}>
Noch keine Spalten. Erstelle eine Spalte um loszulegen.
</div>
</div>
) : (
<div style={{ display:'flex', gap:14, overflowX:'auto', flex:1,
paddingBottom:12, scrollbarWidth:'thin', scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
{columns.map(col => (
<div key={col.id}
onDragOver={e=>onDragOverColBg(e, col.id)}
onDrop={e=>onDrop(e, col.id, col.cards.length)}
style={{
...S.card,
minWidth: mobile ? 260 : 280,
maxWidth: 320,
width: mobile ? 260 : 280,
flexShrink:0,
display:'flex', flexDirection:'column',
padding:12, overflow:'hidden',
border:`1px solid ${dragOverCol===col.id && dragCardId ? (col.color||'#4ecdc4')+'66' : 'rgba(255,255,255,0.07)'}`,
transition:'border-color 0.15s',
}}>
<ColumnHeader col={col} onUpdate={updateColumn} onDelete={deleteColumn} toast={toast}/>
{/* Karten-Liste */}
<div style={{ flex:1, overflowY:'auto', scrollbarWidth:'thin', scrollbarColor:'rgba(255,255,255,0.08) transparent' }}>
{col.cards.map((card, cardIdx) => (
<div key={card.id}
onDragOver={e=>onDragOverCard(e, col.id, cardIdx)}
onDrop={e=>{ e.stopPropagation(); onDrop(e, col.id, cardIdx); }}>
{dragCardId && dragOverCol===col.id && dragOverPos===cardIdx && (
<div style={{ height:3,background:col.color||'#4ecdc4',borderRadius:2,marginBottom:4,opacity:0.7 }}/>
)}
<KanbanCard
card={card} col={col} allCols={columns}
onEdit={card=>setModal({ card, columnId:col.id })}
onDelete={deleteCard}
onMove={moveCard}
isDragging={dragCardId===card.id}
dragHandlers={{ draggable:true, onDragStart:e=>onDragStart(e,card.id), onDragEnd }}
/>
</div>
))}
{dragCardId && dragOverCol===col.id && dragOverPos===col.cards.length && (
<div style={{ height:3,background:col.color||'#4ecdc4',borderRadius:2,marginTop:2,opacity:0.7 }}/>
)}
</div>
{/* Karte hinzufügen */}
<button onClick={()=>setModal({ card:null, columnId:col.id })}
style={{ ...S.btn(col.color||'#4ecdc4', true), width:'100%', marginTop:10, textAlign:'center' }}>
+ Karte
</button>
</div>
))}
</div>
)}
{/* ── Modal ──────────────────────────────────────────────────────────── */}
{modal && (
<CardModal
card={modal.card}
columnId={modal.columnId}
columns={columns}
onSave={()=>{ setModal(null); load(); }}
onClose={()=>setModal(null)}
toast={toast}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,468 @@
import { useState, useEffect } from 'react';
import { api, S } from '../lib.js';
const GOLD = '#f59e0b';
function getMyRole() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; }
}
function publisherColor(name) {
if (!name) return '#555';
const n = name.toLowerCase();
if (n.includes('rewe')) return '#e2001a';
if (n.includes('edeka') || n.includes('e center')) return '#e8a800';
if (n.includes('netto')) return '#0057a8';
if (n.includes('trinkgut')) return '#e87722';
if (n.includes('penny')) return '#e2001a';
if (n.includes('kaufland')) return '#e2001a';
return '#888';
}
// Logo-Bild je Händler — Reihenfolge wichtig: "Netto Getränke-Discount" muss
// VOR dem generischen "netto"-Check geprüft werden. Kein Eintrag (z.B.
// Kaufland) -> Aufrufer fällt auf den farbigen Text-Badge zurück.
function retailerLogo(name) {
if (!name) return null;
const n = name.toLowerCase();
if (n.includes('netto') && n.includes('getränke')) return '/koepi/netto_getraenkemarkt.png';
if (n.includes('netto')) return '/koepi/netto.png';
if (n.includes('rewe dortmund')) return null;
if (n.includes('rewe')) return '/koepi/rewe.png';
if (n.includes('edeka') || n.includes('e center')) return '/koepi/edeka.png';
if (n.includes('trinkgut')) return '/koepi/trinkgut.png';
if (n.includes('penny')) return '/koepi/penny.png';
if (n.includes('lidl')) return '/koepi/lidl.png';
if (n.includes('aldi')) return '/koepi/aldi.png';
if (n.includes('hornbach')) return '/koepi/hornbach.png';
if (n.includes('kaufland')) return '/koepi/kaufland.png';
return null;
}
function fmtDate(str) {
if (!str) return null;
try { return new Date(str).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }); }
catch { return str; }
}
// Prospekte mit Bier-Icon (König Pilsener im Angebot) immer nach oben,
// ansonsten Reihenfolge vom Backend (chronologisch) beibehalten
function sortBeerFirst(arr) {
return [...arr].sort((a, b) => (b.hasKoenigPilsener?1:0) - (a.hasKoenigPilsener?1:0));
}
// ISO-Kalenderwoche aus einem Datum berechnen
function getISOWeek(dateStr) {
const d = new Date(dateStr);
d.setHours(0,0,0,0);
d.setDate(d.getDate() + 4 - (d.getDay() || 7));
const yearStart = new Date(d.getFullYear(), 0, 1);
return { week: Math.ceil((((d - yearStart) / 86400000) + 1) / 7), year: d.getFullYear() };
}
// Kommende Prospekte nach Kalenderwoche gruppieren, Gruppen chronologisch sortiert
function groupByWeek(arr) {
const groups = new Map();
for (const b of arr) {
if (!b.validFrom) { // kein Datum -> eigene Sammelgruppe ganz unten
const key = 'unbekannt';
if (!groups.has(key)) groups.set(key, { label: 'Ohne Datum', sortKey: Infinity, items: [] });
groups.get(key).items.push(b);
continue;
}
const { week, year } = getISOWeek(b.validFrom);
const key = `${year}-${week}`;
if (!groups.has(key)) groups.set(key, { label: `KW${week}`, sortKey: year*100+week, items: [] });
groups.get(key).items.push(b);
}
return [...groups.values()].sort((a,b) => a.sortKey - b.sortKey);
}
// ── Angebots-Karte (marktguru) ────────────────────────────────────────────────
function OfferCard({ offer }) {
const color = publisherColor(offer.retailer);
const logo = retailerLogo(offer.retailer);
return (
<div style={{
...S.card, padding:'12px 14px',
border:'1px solid rgba(255,255,255,0.1)',
display:'flex', flexDirection:'column', gap:8,
}}>
{/* Bild oben */}
{offer.imageLocal && (
<div style={{ margin:'-12px -14px 8px -14px', borderRadius:'8px 8px 0 0', background:'transparent', height:130, display:'flex', alignItems:'center', justifyContent:'center' }}>
<img src={offer.imageLocal + '?v=4'} alt="König Pilsener"
style={{ maxWidth:'90%', maxHeight:'130px', width:'auto', height:'auto', display:'block', objectFit:'contain' }}
/>
</div>
)}
{/* Händler + Adresse */}
<div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
{logo ? (
<img src={logo} alt={offer.retailer} style={{ height:20, maxWidth:100, objectFit:'contain', borderRadius:3 }}/>
) : (
<span style={{
background:`${color}22`, border:`1px solid ${color}55`,
borderRadius:4, padding:'2px 7px',
color, fontFamily:'monospace', fontSize:10, fontWeight:700,
}}>{offer.retailer}</span>
)}
{offer.oldPrice && <span style={{ background:'#e2001a', color:'#fff', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>-29%</span>}
</div>
{offer.address && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9 }}>
📍 {offer.address}
</div>
)}
{/* Name + Beschreibung */}
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:600 }}>König Pilsener</div>
{offer.description && (
<div style={{ color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:10, lineHeight:1.4 }}>
{offer.description}
</div>
)}
{/* Preis */}
<div style={{ display:'flex', alignItems:'baseline', gap:8 }}>
<span style={{ color:GOLD, fontFamily:'Space Mono,monospace', fontSize:22, fontWeight:700 }}>
{offer.price}
</span>
{offer.oldPrice && (
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12, textDecoration:'line-through' }}>
{offer.oldPrice}
</span>
)}
</div>
{/* Gültigkeit */}
<div style={{ borderTop:'1px solid rgba(255,255,255,0.06)', paddingTop:6, display:'flex', gap:12, flexWrap:'wrap', alignItems:'center' }}>
{offer.dateRange && (
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10 }}>📅 {offer.dateRange}</span>
)}
{offer.leafletUrl && (
<a href={offer.leafletUrl} target="_blank" rel="noopener noreferrer"
style={{ color:'#4ecdc4', fontFamily:'monospace', fontSize:10, textDecoration:'none', marginLeft:'auto' }}>
🔗 Im Prospekt ansehen
</a>
)}
</div>
</div>
);
}
// ── Prospekt-Karte (marktguru) ────────────────────────────────────────────────
function ProspektCard({ b }) {
const color = publisherColor(b.publisher);
const logo = retailerLogo(b.publisher);
return (
<a href={b.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration:'none' }}>
<div style={{
...S.card, padding:0, overflow:'hidden', cursor:'pointer',
border: '1px solid rgba(255,255,255,0.08)',
}}>
{b.image && (
<img src={`/api/tools/koepi/img?url=${encodeURIComponent(b.image)}`}
alt={b.title} style={{ width:'100%', height:110, objectFit:'cover', display:'block' }}
onError={e => { e.target.style.display='none'; }}
/>
)}
<div style={{ padding:'10px 12px' }}>
<div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:4, flexWrap:'wrap' }}>
{logo ? (
<img src={logo} alt={b.publisher} style={{ height:20, maxWidth:110, objectFit:'contain', borderRadius:3 }}/>
) : (
<div style={{ background:`${color}22`, border:`1px solid ${color}44`,
borderRadius:4, padding:'2px 8px', color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}>
{b.publisher}
</div>
)}
{b.hasKoenigPilsener && (
<span title="König Pilsener gerade im Angebot" style={{ fontSize:13 }}>🍺</span>
)}
</div>
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}>
📍 {b.street}
</div>
{b.title && (
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginTop:2 }}>
{b.title}
</div>
)}
{(b.validFrom || b.validTo || b.weekInfo) && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, marginTop:4 }}>
{b.validFrom && `Ab ${fmtDate(b.validFrom)}`}{b.validFrom&&b.validTo&&' · '}{b.validTo&&`Bis ${fmtDate(b.validTo)}`}
{b.weekInfo && ` ${b.weekInfo}`}
</div>
)}
</div>
</div>
</a>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function Koepi({ toast }) {
const [tab, setTab] = useState('angebote');
const [offers, setOffers] = useState(null);
const [prospekte, setProspekte] = useState(null);
const [loading, setLoading] = useState(false);
const isAdmin = getMyRole() === 'admin';
const loadOffers = async (force=false) => {
if (offers && !force) return;
setLoading(true);
try { setOffers(await api('/tools/koepi/offers')); }
catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setLoading(false); }
};
const loadProspekte = async (force=false) => {
if (prospekte && !force) return;
setLoading(true);
try { setProspekte(await api('/tools/koepi/prospekte')); }
catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setLoading(false); }
};
useEffect(() => {
if (tab === 'angebote') loadOffers();
else loadProspekte();
}, [tab]);
const clearCache = async () => {
try {
await api('/tools/koepi/clear-cache', { body:{} });
setOffers(null); setProspekte(null);
toast('Cache geleert');
setTimeout(() => { if (tab==='angebote') loadOffers(true); else loadProspekte(true); }, 100);
} catch(e) { toast?.(e.message,'error'); }
};
const [cronRunning, setCronRunning] = useState(false);
const testDailyCheck = async () => {
setCronRunning(true);
try {
const r = await api('/tools/koepi/run-daily-check', { method:'POST', body:{} });
toast(`🔔 Test: ${r.offerCount} Angebot(e) gefunden, Pushover ${r.sent ? 'versendet' : 'nicht versendet'}${r.changed ? ' (Änderung erkannt)' : ' (unverändert)'}`);
} catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setCronRunning(false); }
};
const [resolvingStores, setResolvingStores] = useState(false);
const resolveStores = async () => {
setResolvingStores(true);
try {
const r = await api('/tools/koepi/local-stores/resolve', { method:'POST', body:{} });
toast(`🏪 Filial-Liste: ${r.resolved} von ${r.total} aufgelöst`);
setOffers(null); setProspekte(null);
setTimeout(() => { if (tab==='angebote') loadOffers(true); else loadProspekte(true); }, 100);
} catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setResolvingStores(false); }
};
const [shareLinks, setShareLinks] = useState([]);
const [shareOpen, setShareOpen] = useState(false);
const [shareBusy, setShareBusy] = useState(false);
const [newLinkName, setNewLinkName] = useState('');
const loadShareLinks = () => {
api('/tools/koepi/share-links').then(r => setShareLinks(r.links || [])).catch(()=>{});
};
useEffect(() => { if (isAdmin) loadShareLinks(); }, [isAdmin]);
const createShareLink = async () => {
const name = newLinkName.trim();
if (!name) { toast?.('Bitte einen Namen eingeben','error'); return; }
setShareBusy(true);
try {
await api('/tools/koepi/share-links', { method:'POST', body:{ name } });
setNewLinkName('');
toast(`🔗 Link für "${name}" erstellt`);
loadShareLinks();
} catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setShareBusy(false); }
};
const resetShareLink = async (id, name) => {
if (!window.confirm(`Link für "${name}" wirklich zurücksetzen? Der alte Link funktioniert danach nicht mehr.`)) return;
setShareBusy(true);
try {
await api(`/tools/koepi/share-links/${id}/reset`, { method:'POST', body:{} });
toast(`🔄 Link für "${name}" zurückgesetzt`);
loadShareLinks();
} catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setShareBusy(false); }
};
const deleteShareLink = async (id, name) => {
if (!window.confirm(`Link für "${name}" wirklich löschen?`)) return;
setShareBusy(true);
try {
await api(`/tools/koepi/share-links/${id}`, { method:'DELETE' });
toast(`🗑 Link für "${name}" gelöscht`);
loadShareLinks();
} catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setShareBusy(false); }
};
const copyLink = async (url) => {
try { await navigator.clipboard.writeText(url); toast('Link kopiert'); return; } catch {}
try {
const ta = document.createElement('textarea');
ta.value = url; ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta); ta.focus(); ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
toast?.(ok ? 'Link kopiert' : 'Kopieren nicht möglich', ok ? 'success' : 'error');
} catch { toast?.('Kopieren nicht möglich','error'); }
};
return (
<div style={{ maxWidth:700, margin:'0 auto', padding:'0 14px' }}>
{/* Header */}
<div style={{ marginBottom:20 }}>
<h2 style={{ margin:'0 0 10px 0', fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
🍺 KÖPI König Pilsener
</h2>
{isAdmin && (
<div style={{ display:'flex', gap:8, overflowX:'auto', scrollbarWidth:'none',
WebkitOverflowScrolling:'touch', paddingBottom:2 }}>
<button onClick={testDailyCheck} disabled={cronRunning} style={{ ...S.btn('#f59e0b',true), fontSize:10, opacity:cronRunning?0.5:1, flexShrink:0, whiteSpace:'nowrap' }}>
{cronRunning ? '⏳ läuft…' : '🔔 Cron testen'}
</button>
<button onClick={resolveStores} disabled={resolvingStores} style={{ ...S.btn('#4ecdc4',true), fontSize:10, opacity:resolvingStores?0.5:1, flexShrink:0, whiteSpace:'nowrap' }}>
{resolvingStores ? '⏳ läuft…' : '🏪 Filial-Liste neu auflösen'}
</button>
<button onClick={clearCache} style={{ ...S.btn('#666666',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>🗑 Cache leeren</button>
<button onClick={()=>setShareOpen(v=>!v)} style={{ ...S.btn(shareLinks.length?'#ffe66d':'#888888',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>
🔗 Teilen{shareLinks.length ? ` (${shareLinks.length})` : ''}
</button>
</div>
)}
{isAdmin && shareOpen && (
<div style={{ ...S.card, marginTop:10 }}>
<div style={{ ...S.sub, marginBottom:10 }}>
Öffentliche, schreibgeschützte Links ohne Login zeigen zuerst die Prospekte, man kann dort
auch zu den Angeboten wechseln. Keine Admin-Funktionen sichtbar/erreichbar über diese Links.
Du kannst mehrere Links gleichzeitig anlegen (z.B. einen pro Person) in den Logs siehst du
dann, wer welchen Link benutzt hat.
</div>
{shareLinks.length > 0 && (
<div style={{ display:'flex', flexDirection:'column', gap:8, marginBottom:14 }}>
{shareLinks.map(l => (
<div key={l.id} style={{ border:'1px solid rgba(255,255,255,0.08)', borderRadius:8, padding:'8px 10px' }}>
<div style={{ color:'#ffe66d', fontFamily:"'Space Mono',monospace", fontSize:12, fontWeight:700, marginBottom:4 }}>
{l.name}
</div>
<div style={{ display:'flex', gap:8, marginBottom:6, flexWrap:'wrap' }}>
<input readOnly value={l.url} onClick={e=>e.target.select()} style={{ ...S.inp, flex:1, minWidth:180, fontSize:10 }}/>
<button onClick={()=>copyLink(l.url)} style={{ ...S.btn('#4ecdc4'), fontSize:11 }}>📋</button>
</div>
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
<button onClick={()=>resetShareLink(l.id, l.name)} disabled={shareBusy} style={{ ...S.btn('#f59e0b'), fontSize:10, opacity:shareBusy?0.5:1 }}>
🔄 Zurücksetzen
</button>
<button onClick={()=>deleteShareLink(l.id, l.name)} disabled={shareBusy} style={{ ...S.btn('#f87171'), fontSize:10, opacity:shareBusy?0.5:1 }}>
🗑 Löschen
</button>
</div>
</div>
))}
</div>
)}
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
<input value={newLinkName} onChange={e=>setNewLinkName(e.target.value)}
onKeyDown={e=>e.key==='Enter' && createShareLink()}
placeholder="Name (z.B. Sina)" style={{ ...S.inp, flex:1, minWidth:140 }}/>
<button onClick={createShareLink} disabled={shareBusy || !newLinkName.trim()} style={{ ...S.btn('#4ecdc4'), opacity:(shareBusy || !newLinkName.trim())?0.5:1 }}>
{shareBusy?'…':' Neuen Link erstellen'}
</button>
</div>
</div>
)}
</div>
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, marginBottom:14 }}>
Raum Duisburg 47259 · EDEKA · REWE · Netto · Kaufland · Penny · Trinkgut · Lidl · ALDI Süd · HORNBACH · Cache 1h
</div>
{/* Tabs */}
<div style={{ display:'flex', gap:8, marginBottom:20 }}>
{[['angebote','🍺 Angebote'],['prospekte','📋 Prospekte']].map(([t,l]) => (
<button key={t} onClick={()=>setTab(t)} style={{ ...S.btn(tab===t?GOLD:'#444444',true), fontSize:12, padding:'6px 16px' }}>{l}</button>
))}
</div>
{/* Laden */}
{loading && (
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
Lade (kann bis zu 20 Sekunden dauern)
</div>
)}
{/* Angebote */}
{!loading && tab==='angebote' && offers && (
<div>
{offers.offers?.length > 0 ? (
<>
<div style={{ ...S.head, marginBottom:12 }}>
{offers.offers.length} AKTUELLE ANGEBOTE · Quelle: marktguru.de
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:14 }}>
{offers.offers.map((o,i) => <OfferCard key={i} offer={o}/>)}
</div>
</>
) : (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
Aktuell keine König Pilsener Angebote bei deinen Märkten gefunden.
</div>
)}
<div style={{ marginTop:20, textAlign:'center' }}>
<a href="https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259" target="_blank" rel="noopener noreferrer"
style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }}>
Alle Angebote auf marktguru.de
</a>
</div>
</div>
)}
{/* Prospekte */}
{!loading && tab==='prospekte' && prospekte && (
<div>
{prospekte.current?.length > 0 && (
<>
<div style={{ ...S.head, marginBottom:12 }}>DEINE MÄRKTE AKTUELL ({prospekte.current.length})</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:12, marginBottom:24 }}>
{sortBeerFirst(prospekte.current).map(b=><ProspektCard key={b.id} b={b}/>)}
</div>
</>
)}
{prospekte.future?.length > 0 && (
<>
<div style={{ ...S.head, marginBottom:12, paddingTop:12, borderTop:'1px solid rgba(255,255,255,0.08)',
color:'rgba(255,255,255,0.35)' }}>
KOMMENDE PROSPEKTE ({prospekte.future.length})
</div>
{groupByWeek(prospekte.future).map(group => (
<div key={group.label} style={{ marginBottom:20 }}>
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, letterSpacing:1, marginBottom:8 }}>
{group.label}
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:12, opacity:0.75 }}>
{sortBeerFirst(group.items).map(b=><ProspektCard key={b.id} b={b}/>)}
</div>
</div>
))}
</>
)}
{!prospekte.current?.length && !prospekte.future?.length && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
Keine Prospekte gefunden.
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,728 @@
import { useState, useEffect, useRef } from 'react';
import { api, S } from '../lib.js';
const LINK_ICONS = ['🔗','📄','🌐','📊','🎯','⚙️','📁','🔧','📱','💡','🏠','🛒','📺','🎮','🎵','📧','💬','📰','🔬','🏥','🏦','✈️','🚀','🔐','📂','⭐','🔑','💻','🖥','📡'];
const FOLDER_ICONS = ['📁','📂','🗂','💼','🗃','📦','🏷','🔖','📋','📌','🗓','🖇'];
// ── Alle DockStation-Tools & Unterfunktionen ──────────────────────────────────
const TOOL_ENTRIES = [
{ url:'tool://statistik', label:'📊 Statistik', group:'3D-Druck' },
{ url:'tool://bestellungen', label:'📦 Bestellungen', group:'3D-Druck' },
{ url:'tool://kalkulator3d', label:'🧮 Kostenrechner', group:'3D-Druck' },
{ url:'tool://kanban', label:'🗂 Kanban', group:'Werkzeuge' },
{ url:'tool://whiteboard', label:'🖊 Whiteboard', group:'Werkzeuge' },
{ url:'tool://dateien', label:'📁 Dateien', group:'Werkzeuge' },
{ url:'tool://nachrichten', label:'💬 Nachrichten', group:'Werkzeuge' },
{ url:'tool://linkliste', label:'🔗 Linkliste', group:'Werkzeuge' },
{ url:'tool://codeschnipsel', label:'</> Code-Schnipsel', group:'Werkzeuge' },
{ url:'tool://media', label:'🎬 Media / Kino', group:'Freizeit' },
{ url:'tool://gebietseroberung', label:'⬡ Hex Wars', group:'Freizeit' },
{ url:'tool://koepi', label:'🍺 KöPi', group:'Freizeit' },
{ url:'tool://devtools', label:'🔧 Dev-Tools (alle)', group:'Dev-Tools' },
{ url:'tool://devtools?sub=cron', label:'⏱ Crontab', group:'Dev-Tools' },
{ url:'tool://devtools?sub=elektro', label:'⚡ Elektro', group:'Dev-Tools' },
{ url:'tool://devtools?sub=json', label:'{ } JSON', group:'Dev-Tools' },
{ url:'tool://devtools?sub=regex', label:'🔍 Regex', group:'Dev-Tools' },
{ url:'tool://devtools?sub=diff', label:'± Text Diff', group:'Dev-Tools' },
{ url:'tool://devtools?sub=netzwerk', label:'🌐 Netzwerk', group:'Dev-Tools' },
{ url:'tool://devtools?sub=datetime', label:'📅 Datetime', group:'Dev-Tools' },
{ url:'tool://devtools?sub=farben', label:'🎨 Farben', group:'Dev-Tools' },
{ url:'tool://devtools?sub=keygen', label:'🔐 Key-Gen', group:'Dev-Tools' },
{ url:'tool://devtools?sub=qrcode', label:'◻ QR-Code', group:'Dev-Tools' },
];
// ── Tool-Picker Modal ─────────────────────────────────────────────────────────
function ToolPicker({ folders, onSave, onClose, toast }) {
const [search, setSearch] = useState('');
const [folderId, setFolderId] = useState(null);
const [saving, setSaving] = useState(null);
const mob = isMob();
const filtered = TOOL_ENTRIES.filter(t =>
!search || t.label.toLowerCase().includes(search.toLowerCase()) || t.group.toLowerCase().includes(search.toLowerCase())
);
const groups = [...new Set(filtered.map(t => t.group))];
const pick = async (entry) => {
setSaving(entry.url);
try {
await onSave({ title: entry.label.replace(/^[^\s]+\s/, ''), url: entry.url, icon: entry.label.split(' ')[0], folder_id: folderId, in_quickaccess: 1 });
toast(`${entry.label} zum Schnellzugriff hinzugefügt ✓`);
onClose();
} catch(e) { toast(e.message, 'error'); setSaving(null); }
};
return (
<div onClick={e=>e.target===e.currentTarget&&onClose()}
style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.75)',zIndex:6000,
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:20}}>
<div style={{background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:480,
display:'flex',flexDirection:'column',maxHeight:mob?'85vh':'80vh',
paddingBottom:mob?'calc(56px + env(safe-area-inset-bottom,0px))':0}}>
{/* Header */}
<div style={{padding:'18px 20px 0',flexShrink:0}}>
{mob && <div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
<div style={{...S.head,marginBottom:0}}>APP HINZUFÜGEN</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
{/* Ordner-Auswahl */}
{folders.length > 0 && (
<div style={{marginBottom:10}}>
<div style={{fontSize:9,fontFamily:'monospace',color:'rgba(255,255,255,0.35)',letterSpacing:1,marginBottom:5}}>IN ORDNER (optional)</div>
<div style={{display:'flex',gap:5,flexWrap:'wrap'}}>
<button onClick={()=>setFolderId(null)} style={{
...S.btn(folderId===null?'#4ecdc4':'#888888',true),fontSize:10,padding:'3px 8px'}}>
Kein Ordner
</button>
{folders.map(f=>(
<button key={f.id} onClick={()=>setFolderId(f.id)} style={{
...S.btn(folderId===f.id?'#4ecdc4':'#888888',true),fontSize:10,padding:'3px 8px'}}>
{f.icon||'📁'} {f.name}
</button>
))}
</div>
</div>
)}
<input value={search} onChange={e=>setSearch(e.target.value)}
placeholder="App oder Funktion suchen…"
style={{...S.inp,marginBottom:10,fontSize:12}}/>
</div>
{/* Liste */}
<div style={{overflowY:'auto',padding:'0 20px 20px',scrollbarWidth:'thin',scrollbarColor:'rgba(255,255,255,0.08) transparent'}}>
{groups.map(group=>(
<div key={group} style={{marginBottom:10}}>
<div style={{fontSize:9,fontFamily:'monospace',color:'rgba(255,255,255,0.3)',letterSpacing:1,marginBottom:5,paddingTop:4}}>
{group.toUpperCase()}
</div>
{filtered.filter(t=>t.group===group).map(entry=>(
<button key={entry.url} onClick={()=>pick(entry)} disabled={!!saving}
style={{display:'flex',alignItems:'center',gap:10,width:'100%',
background:saving===entry.url?'rgba(78,205,196,0.1)':'rgba(255,255,255,0.03)',
border:'1px solid rgba(255,255,255,0.07)',borderRadius:8,
padding:'9px 12px',marginBottom:4,cursor:'pointer',textAlign:'left',
transition:'background 0.12s'}}>
<span style={{fontSize:16,flexShrink:0}}>{entry.label.split(' ')[0]}</span>
<span style={{flex:1,fontSize:12,fontFamily:'monospace',color:'#fff'}}>
{entry.label.replace(/^[^\s]+\s/, '')}
</span>
{saving===entry.url
? <span style={{fontSize:10,color:'#4ecdc4',fontFamily:'monospace'}}></span>
: <span style={{fontSize:10,color:'rgba(255,255,255,0.25)',fontFamily:'monospace'}}>+ hinzufügen</span>}
</button>
))}
</div>
))}
</div>
</div>
</div>
);
}
function extIcon(url) {
try { return `https://www.google.com/s2/favicons?sz=32&domain=${new URL(url).hostname}`; }
catch { return null; }
}
function isMob() { return window.innerWidth < 768; }
// ── Folder Share Modal ────────────────────────────────────────────────────────
function FolderShareModal({ folder, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/linkliste/folders/${folder.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/linkliste/folders/${folder.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e) { toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/linkliste/folders/${folder.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); }
catch(e) { toast(e.message,'error'); }
};
const mob = isMob();
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:400,
padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:10}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
🤝 {folder.icon} {folder.name}
</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:10}}>
Ganzer Ordner inkl. aller Links wird geteilt
</div>
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>}
</div>
</div>
);
}
// ── Share Modal ───────────────────────────────────────────────────────────────
function ShareModal({ link, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/linkliste/${link.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/linkliste/${link.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e) { toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/linkliste/${link.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); }
catch(e) { toast(e.message,'error'); }
};
const mob = isMob();
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:mob?'16px 16px 0 0':14,width:'100%',maxWidth:400,
padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:10}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>🤝 {link.title}</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>}
</div>
</div>
);
}
// ── Link Form ─────────────────────────────────────────────────────────────────
function LinkForm({ initial, folders, onSave, onCancel, toast }) {
const [form, setForm] = useState({ title:'', url:'', icon:'🔗', description:'', folder_id:null, ...initial });
const [busy, setBusy] = useState(false);
const set = (k,v) => setForm(p=>({...p,[k]:v}));
const save = async () => {
if (!form.title.trim() || !form.url.trim()) { toast('Titel und URL erforderlich','error'); return; }
let url = form.url.trim();
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
setBusy(true);
try { await onSave({...form, url}); }
catch(e) { toast(e.message,'error'); }
setBusy(false);
};
return (
<div style={{...S.card,marginBottom:12}}>
<div style={{...S.head,marginBottom:10}}>{initial?.id?'LINK BEARBEITEN':'NEUER LINK'}</div>
<div style={{marginBottom:8}}>
<div style={{...S.head,fontSize:9,marginBottom:4}}>ICON</div>
<div style={{display:'flex',flexWrap:'wrap',gap:4,marginBottom:6}}>
{LINK_ICONS.map(ic=>(
<button key={ic} onClick={()=>set('icon',ic)} style={{
width:32,height:32,borderRadius:7,fontSize:16,cursor:'pointer',border:'none',
background:form.icon===ic?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
outline:form.icon===ic?'1px solid #4ecdc4':'none'}}>{ic}</button>
))}
</div>
</div>
<input value={form.title} onChange={e=>set('title',e.target.value)} placeholder="Titel *" style={{...S.inp,marginBottom:8}}/>
<input value={form.url} onChange={e=>set('url',e.target.value)} placeholder="URL *" autoCapitalize="none" style={{...S.inp,marginBottom:8}}/>
<input value={form.description} onChange={e=>set('description',e.target.value)} placeholder="Beschreibung (optional)" style={{...S.inp,marginBottom:8,fontSize:13}}/>
{folders.length>0 && (
<div style={{marginBottom:12}}>
<div style={{...S.head,fontSize:9,marginBottom:4}}>IN ORDNER</div>
<select value={form.folder_id||''} onChange={e=>set('folder_id',e.target.value?parseInt(e.target.value):null)}
style={{...S.inp,cursor:'pointer'}}>
<option value=""> Kein Ordner </option>
{folders.map(f=><option key={f.id} value={f.id}>{f.icon} {f.name}</option>)}
</select>
</div>
)}
<div style={{display:'flex',gap:8}}>
<button onClick={save} disabled={busy} style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
{busy?'…':'✓ Speichern'}
</button>
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
</div>
</div>
);
}
// ── Folder Form ───────────────────────────────────────────────────────────────
function FolderForm({ initial, onSave, onCancel, toast }) {
const [form, setForm] = useState({ name:'', icon:'📁', ...initial });
const [busy, setBusy] = useState(false);
const save = async () => {
if (!form.name.trim()) { toast('Name erforderlich','error'); return; }
setBusy(true);
try { await onSave(form); }
catch(e) { toast(e.message,'error'); }
setBusy(false);
};
return (
<div style={{...S.card,marginBottom:12}}>
<div style={{...S.head,marginBottom:10}}>{initial?.id?'ORDNER BEARBEITEN':'NEUER ORDNER'}</div>
<div style={{marginBottom:8}}>
<div style={{...S.head,fontSize:9,marginBottom:4}}>ICON</div>
<div style={{display:'flex',flexWrap:'wrap',gap:4,marginBottom:6}}>
{FOLDER_ICONS.map(ic=>(
<button key={ic} onClick={()=>setForm(p=>({...p,icon:ic}))} style={{
width:32,height:32,borderRadius:7,fontSize:16,cursor:'pointer',border:'none',
background:form.icon===ic?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
outline:form.icon===ic?'1px solid #4ecdc4':'none'}}>{ic}</button>
))}
</div>
</div>
<input value={form.name} onChange={e=>setForm(p=>({...p,name:e.target.value}))} placeholder="Ordner-Name *"
style={{...S.inp,marginBottom:12}}/>
<div style={{display:'flex',gap:8}}>
<button onClick={save} disabled={busy} style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
{busy?'…':'✓ Speichern'}
</button>
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
</div>
</div>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function Linkliste({ toast, mobile }) {
const [own, setOwn] = useState([]);
const [folders, setFolders] = useState([]);
const [shared, setShared] = useState([]);
const [byMe, setByMe] = useState([]);
const [sharedFolders, setSharedFolders] = useState([]);
const [sharedFoldersByMe, setSharedFoldersByMe] = useState([]);
const [tab, setTab] = useState('own');
const [showForm, setShowForm] = useState(false);
const [showFolderForm, setShowFolderForm] = useState(false);
const [showToolPicker, setShowToolPicker] = useState(false);
const [editItem, setEditItem] = useState(null);
const [editFolder,setEditFolder]= useState(null);
const [shareItem, setShareItem] = useState(null);
const [shareFolderItem, setShareFolderItem] = useState(null);
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState({}); // folder_id → bool
const [sortMode, setSortMode] = useState(false);
const dragRef = useRef(null);
const load = () => api('/tools/linkliste')
.then(d=>{ setOwn(d.own||[]); setFolders(d.folders||[]); setShared(d.shared||[]); setByMe(d.sharedByMe||[]); setSharedFolders(d.sharedFolders||[]); setSharedFoldersByMe(d.sharedFoldersByMe||[]); })
.catch(()=>{});
useEffect(()=>{ load(); },[]);
// ── CRUD Links ──────────────────────────────────────────────────────────────
const addLink = async form => {
const r = await api('/tools/linkliste',{body:form});
setOwn(p=>[...p,r]); setShowForm(false);
};
const updateLink = async form => {
const r = await api(`/tools/linkliste/${editItem.id}`,{method:'PUT',body:form});
setOwn(p=>p.map(l=>l.id===r.id?r:l)); setEditItem(null);
};
const deleteLink = async id => {
await api(`/tools/linkliste/${id}`,{method:'DELETE'});
setOwn(p=>p.filter(l=>l.id!==id)); toast('Gelöscht');
};
const toggleQA = async link => {
const val = link.in_quickaccess ? 0 : 1;
const r = await api(`/tools/linkliste/${link.id}`,{method:'PUT',body:{in_quickaccess:val}});
setOwn(p=>p.map(l=>l.id===r.id?r:l));
toast(val ? `${link.title}" zum Schnellzugriff hinzugefügt ✓` : `${link.title}" aus Schnellzugriff entfernt`);
};
// ── CRUD Folders ────────────────────────────────────────────────────────────
const addFolder = async form => {
const r = await api('/tools/linkliste/folders',{body:form});
setFolders(p=>[...p,r]); setShowFolderForm(false);
};
const updateFolder = async form => {
const r = await api(`/tools/linkliste/folders/${editFolder.id}`,{method:'PUT',body:form});
setFolders(p=>p.map(f=>f.id===r.id?r:f)); setEditFolder(null);
};
const deleteFolder = async id => {
await api(`/tools/linkliste/folders/${id}`,{method:'DELETE'});
setFolders(p=>p.filter(f=>f.id!==id));
// Links aus Ordner herauslösen (Backend macht das, Frontend nachführen)
setOwn(p=>p.map(l=>l.folder_id===id?{...l,folder_id:null}:l));
toast('Ordner gelöscht');
};
const toggleFolderQA = async folder => {
const val = folder.in_quickaccess ? 0 : 1;
const r = await api(`/tools/linkliste/folders/${folder.id}`,{method:'PUT',body:{in_quickaccess:val}});
setFolders(p=>p.map(f=>f.id===r.id?r:f));
toast(val ? `Ordner „${folder.name}" zum Schnellzugriff hinzugefügt ✓` : `Ordner „${folder.name}" aus Schnellzugriff entfernt`);
};
// ── Sortierung ──────────────────────────────────────────────────────────────
const moveLink = (id, dir) => {
setOwn(prev => {
const arr = [...prev];
const idx = arr.findIndex(l=>l.id===id);
const nxt = idx+dir;
if (nxt<0||nxt>=arr.length) return prev;
[arr[idx],arr[nxt]] = [arr[nxt],arr[idx]];
// Persist
api('/tools/linkliste/sort',{method:'PUT',body:{links:arr.map(l=>l.id)}}).catch(()=>{});
return arr;
});
};
const moveFolder = (id, dir) => {
setFolders(prev => {
const arr = [...prev];
const idx = arr.findIndex(f=>f.id===id);
const nxt = idx+dir;
if (nxt<0||nxt>=arr.length) return prev;
[arr[idx],arr[nxt]] = [arr[nxt],arr[idx]];
api('/tools/linkliste/sort',{method:'PUT',body:{folders:arr.map(f=>f.id)}}).catch(()=>{});
return arr;
});
};
// ── Suche ───────────────────────────────────────────────────────────────────
const q = search.trim().toLowerCase();
const matchLink = l =>
l.title.toLowerCase().includes(q) ||
l.url.toLowerCase().includes(q) ||
(l.description||'').toLowerCase().includes(q);
const matchFolder = f => f.name.toLowerCase().includes(q);
// Für "Meine" Tab: flache Liste aller Links die passen + Ordner die passen
const filteredOwn = q ? own.filter(matchLink) : own;
const filteredFolders = q ? folders.filter(f => matchFolder(f) || own.some(l=>l.folder_id===f.id&&matchLink(l))) : folders;
const filteredShared = shared.filter(l=>!q||matchLink(l));
const filteredByMe = byMe.filter(l=>!q||matchLink(l));
// ── Drag für Sortierung ─────────────────────────────────────────────────────
const onDragStart = (e,id) => { dragRef.current = id; e.dataTransfer.effectAllowed='move'; };
const onDragOver = (e,id,type) => {
e.preventDefault();
if (dragRef.current===id) return;
if (type==='link') {
const arr=[...own]; const from=arr.findIndex(l=>l.id===dragRef.current); const to=arr.findIndex(l=>l.id===id);
if (from<0||to<0) return;
arr.splice(to,0,arr.splice(from,1)[0]); setOwn(arr);
}
if (type==='folder') {
const arr=[...folders]; const from=arr.findIndex(f=>f.id===dragRef.current); const to=arr.findIndex(f=>f.id===id);
if (from<0||to<0) return;
arr.splice(to,0,arr.splice(from,1)[0]); setFolders(arr);
}
};
const onDrop = (type) => {
if (type==='link') api('/tools/linkliste/sort',{method:'PUT',body:{links:own.map(l=>l.id)}}).catch(()=>{});
if (type==='folder') api('/tools/linkliste/sort',{method:'PUT',body:{folders:folders.map(f=>f.id)}}).catch(()=>{});
dragRef.current=null;
};
// ── Render ──────────────────────────────────────────────────────────────────
const QABadge = ({active}) => (
<span style={{
display:'inline-block', width:7, height:7, borderRadius:'50%',
background:active?'#4ecdc4':'rgba(255,255,255,0.15)',
boxShadow:active?'0 0 5px #4ecdc4':'none',
marginRight:3, flexShrink:0,
}}/>
);
function LinkCard({ link, isShared=false, isByMe=false, inSort=false }) {
const [imgOk, setImgOk] = useState(true);
const favicon = (link.icon==='🔗'||!link.icon) ? extIcon(link.url) : null;
const showFavicon = !!favicon && imgOk;
return (
<div style={{...S.card,marginBottom:6,padding:'10px 12px'}}
draggable={sortMode&&!isShared&&!isByMe}
onDragStart={sortMode?e=>onDragStart(e,link.id):undefined}
onDragOver={sortMode?e=>onDragOver(e,link.id,'link'):undefined}
onDrop={sortMode?()=>onDrop('link'):undefined}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
{/* Sort handle */}
{sortMode && !isShared && !isByMe && (
<span style={{color:'rgba(255,255,255,0.2)',fontSize:14,cursor:'grab',flexShrink:0}}></span>
)}
{/* Icon */}
<div style={{width:32,height:32,borderRadius:8,flexShrink:0,overflow:'hidden',
background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.08)',
display:'flex',alignItems:'center',justifyContent:'center',fontSize:16}}>
{showFavicon
? <img src={favicon} onError={()=>setImgOk(false)} style={{width:18,height:18,objectFit:'contain'}} alt=""/>
: <span>{link.icon||'🔗'}</span>}
</div>
{/* Info */}
<div style={{flex:1,minWidth:0}}>
<a href={link.url} target="_blank" rel="noopener noreferrer"
style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,
textDecoration:'none',display:'block',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
{link.title}
</a>
<div style={{color:'rgba(78,205,196,0.55)',fontFamily:'monospace',fontSize:10,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',marginTop:1}}>
{link.url}
</div>
{link.description&&<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginTop:1}}>{link.description}</div>}
{isShared&&<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginTop:1}}>von {link.owner_name}</div>}
{isByMe&&<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginTop:1}}> {link.shared_with_name}</div>}
</div>
{/* Sort buttons */}
{sortMode && !isShared && !isByMe && (
<div style={{display:'flex',flexDirection:'column',gap:2,flexShrink:0}}>
<button onClick={()=>moveLink(link.id,-1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}></button>
<button onClick={()=>moveLink(link.id,+1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}></button>
</div>
)}
</div>
{/* Aktionen */}
{!sortMode && (
<div style={{display:'flex',gap:5,marginTop:8,flexWrap:'wrap'}}>
{!isShared && !isByMe && (
<button onClick={()=>toggleQA(link)} style={{
background: link.in_quickaccess?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
border: `1px solid ${link.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.15)'}`,
borderRadius:6,color:link.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',fontFamily:'monospace',fontSize:10,padding:'4px 10px',
display:'flex',alignItems:'center',gap:4,whiteSpace:'nowrap'}}>
<QABadge active={!!link.in_quickaccess}/> Schnellzugriff
</button>
)}
{!isShared && !isByMe && (
<>
<button onClick={()=>setShareItem(link)} style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 10px'}}>
🤝{link.share_count>0?` (${link.share_count})`:''}
</button>
<button onClick={()=>setEditItem(link)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 10px'}}></button>
<button onClick={()=>deleteLink(link.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}></button>
</>
)}
</div>
)}
</div>
);
}
function FolderSection({ folder }) {
const isOpen = !!expanded[folder.id];
const folderLinks = filteredOwn.filter(l=>l.folder_id===folder.id);
return (
<div style={{marginBottom:8}}>
<div style={{...S.card,padding:'10px 12px',marginBottom:isOpen?0:0,
borderRadius:isOpen?'10px 10px 0 0':'10px',borderBottom:isOpen?'1px solid rgba(255,255,255,0.06)':undefined}}
draggable={sortMode}
onDragStart={sortMode?e=>onDragStart(e,folder.id):undefined}
onDragOver={sortMode?e=>onDragOver(e,folder.id,'folder'):undefined}
onDrop={sortMode?()=>onDrop('folder'):undefined}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
{sortMode && <span style={{color:'rgba(255,255,255,0.2)',fontSize:14,cursor:'grab',flexShrink:0}}></span>}
<button onClick={()=>setExpanded(p=>({...p,[folder.id]:!isOpen}))}
style={{background:'none',border:'none',cursor:'pointer',color:'rgba(255,255,255,0.4)',
fontSize:12,padding:0,flexShrink:0,width:16}}>
{isOpen?'▾':'▸'}
</button>
<span style={{fontSize:18,flexShrink:0}}>{folder.icon}</span>
<div style={{flex:1,minWidth:0}}>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{folder.name}</span>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginLeft:8}}>
{folderLinks.length} Link{folderLinks.length!==1?'s':''}
</span>
</div>
{sortMode && (
<div style={{display:'flex',flexDirection:'column',gap:2,flexShrink:0}}>
<button onClick={()=>moveFolder(folder.id,-1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}></button>
<button onClick={()=>moveFolder(folder.id,+1)} style={{background:'rgba(255,255,255,0.05)',border:'none',
borderRadius:4,color:'rgba(255,255,255,0.5)',cursor:'pointer',padding:'1px 7px',fontSize:11}}></button>
</div>
)}
{!sortMode && (
<div style={{display:'flex',gap:5,flexShrink:0}}>
<button onClick={()=>toggleFolderQA(folder)} style={{
background: folder.in_quickaccess?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
border: `1px solid ${folder.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.15)'}`,
borderRadius:6,color:folder.in_quickaccess?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',fontFamily:'monospace',fontSize:10,padding:'4px 10px',
display:'flex',alignItems:'center',gap:4,whiteSpace:'nowrap'}}>
<QABadge active={!!folder.in_quickaccess}/> Schnellzugriff
</button>
<button onClick={()=>setEditFolder(folder)} style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 10px'}}></button>
<button onClick={()=>setShareFolderItem(folder)} style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 10px'}}>🤝</button>
<button onClick={()=>deleteFolder(folder.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}></button>
</div>
)}
</div>
</div>
{isOpen && (
<div style={{background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.07)',
borderTop:'none',borderRadius:'0 0 10px 10px',padding:'8px 8px 4px'}}>
{folderLinks.length===0
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,padding:'8px 4px',textAlign:'center'}}>
Ordner ist leer bearbeite einen Link und wähle diesen Ordner
</div>
: folderLinks.map(link=>(
<LinkCard key={link.id} link={link}/>
))
}
{!sortMode && (
<button onClick={()=>{ setShowForm(true); setShowFolderForm(false); setEditItem({folder_id:folder.id}); }}
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'5px 12px',width:'100%',textAlign:'center',marginBottom:4}}>
+ Link in diesem Ordner hinzufügen
</button>
)}
</div>
)}
</div>
);
}
const unfoldered = filteredOwn.filter(l=>!l.folder_id);
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:760}}>
{shareItem && <ShareModal link={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
{shareFolderItem && <FolderShareModal folder={shareFolderItem} onClose={()=>{setShareFolderItem(null);load();}} toast={toast}/>}
{/* Header */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14,gap:8,flexWrap:'wrap'}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Linkliste</h1>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
<button onClick={load} style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8,color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'6px 10px',fontSize:14,fontFamily:'monospace'}}></button>
{tab==='own' && (
<>
<button onClick={()=>setSortMode(p=>!p)} style={{
background: sortMode?'rgba(255,230,109,0.15)':'rgba(255,255,255,0.04)',
border: `1px solid ${sortMode?'#ffe66d':'rgba(255,255,255,0.15)'}`,
borderRadius:6,color:sortMode?'#ffe66d':'rgba(255,255,255,0.6)',
cursor:'pointer',fontFamily:'monospace',fontSize:11,padding:'6px 12px',whiteSpace:'nowrap'}}>
{sortMode?'✓ Fertig':'⇅ Sortieren'}
</button>
{!showFolderForm && !editFolder && !sortMode && (
<button onClick={()=>{setShowFolderForm(true);setShowForm(false);}}
style={{...S.btn('#ffe66d',true),fontSize:11,padding:'6px 12px'}}>+ Ordner</button>
)}
{!showForm && !editItem && !sortMode && (
<button onClick={()=>{setShowToolPicker(true);setShowForm(false);setShowFolderForm(false);}}
style={{...S.btn('#a78bfa',true),fontSize:11,padding:'6px 12px'}}>+ App</button>
)}
{!showForm && !editItem && !sortMode && (
<button onClick={()=>{setShowForm(true);setShowFolderForm(false);setEditItem(null);}}
style={{...S.btn('#4ecdc4'),padding:'7px 16px',fontSize:12}}>+ Link</button>
)}
</>
)}
</div>
</div>
{/* Formulare */}
{showToolPicker && <ToolPicker folders={folders} onSave={addLink} onClose={()=>setShowToolPicker(false)} toast={toast}/>}
{showFolderForm && !editFolder && <FolderForm onSave={addFolder} onCancel={()=>setShowFolderForm(false)} toast={toast}/>}
{editFolder && <FolderForm initial={editFolder} onSave={updateFolder} onCancel={()=>setEditFolder(null)} toast={toast}/>}
{showForm && !editItem && <LinkForm folders={folders} onSave={addLink} onCancel={()=>setShowForm(false)} toast={toast}/>}
{editItem && <LinkForm initial={editItem} folders={folders} onSave={updateLink} onCancel={()=>setEditItem(null)} toast={toast}/>}
{/* Tabs */}
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{[['own','Meine',own.length+folders.length],['shared','Geteilt mit mir',shared.length],['byMe','Geteilt von mir',byMe.length]].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');setSortMode(false);}} style={{
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
background:tab===k?'#4ecdc4':'rgba(255,255,255,0.05)',
color:tab===k?'#0d0d0f':'rgba(255,255,255,0.55)',
border:tab===k?'none':'1px solid rgba(255,255,255,0.1)',
fontWeight:tab===k?700:400,
}}>{l}{cnt>0&&<span style={{marginLeft:4,opacity:0.7}}>({cnt})</span>}</button>
))}
</div>
{/* Suche */}
<div style={{position:'relative',marginBottom:12}}>
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)',color:'rgba(255,255,255,0.5)',pointerEvents:'none'}}></span>
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Suchen in Links, Ordnern, URLs…"
style={{...S.inp,paddingLeft:34}}/>
{search&&<button onClick={()=>setSearch('')} style={{position:'absolute',right:10,top:'50%',transform:'translateY(-50%)',
background:'transparent',border:'none',color:'rgba(255,255,255,0.5)',cursor:'pointer',fontSize:16}}></button>}
</div>
{/* Inhalt */}
{tab==='own' && (
<div>
{/* Ordner */}
{filteredFolders.map(folder=><FolderSection key={folder.id} folder={folder}/>)}
{/* Links ohne Ordner */}
{unfoldered.length>0 && filteredFolders.length>0 && (
<div style={{...S.head,fontSize:9,margin:'12px 0 6px',color:'rgba(255,255,255,0.3)'}}>OHNE ORDNER</div>
)}
{unfoldered.map(link=><LinkCard key={link.id} link={link}/>)}
{filteredFolders.length===0 && unfoldered.length===0 && (
<div style={{...S.card,textAlign:'center',padding:40}}>
<div style={{fontSize:28,marginBottom:10}}>🔗</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>
{search?'Keine Treffer.':'Noch keine Links füge deinen ersten Link oder Ordner hinzu.'}
</div>
</div>
)}
</div>
)}
{tab==='shared' && (
<div>
{/* Geteilte Ordner */}
{sharedFolders.map(folder=>(
<div key={`sf-${folder.id}`} style={{marginBottom:8}}>
<div style={{...S.card,padding:'10px 12px',borderRadius:'10px 10px 0 0',
borderBottom:'1px solid rgba(255,255,255,0.06)'}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<span style={{fontSize:18}}>{folder.icon}</span>
<div style={{flex:1}}>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{folder.name}</span>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,marginLeft:8}}>von {folder.owner_name}</span>
</div>
</div>
</div>
<div style={{background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.07)',
borderTop:'none',borderRadius:'0 0 10px 10px',padding:'8px 8px 4px'}}>
{(folder.links||[]).map(link=><LinkCard key={link.id} link={link} isShared/>)}
{(!folder.links||folder.links.length===0) && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,padding:'8px',textAlign:'center'}}>Ordner ist leer</div>}
</div>
</div>
))}
{/* Einzelne Links */}
{filteredShared.map(link=><LinkCard key={link.id} link={link} isShared/>)}
{sharedFolders.length===0&&filteredShared.length===0 && (
<div style={{...S.card,textAlign:'center',padding:40}}><div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Nichts geteilt.</div></div>
)}
</div>
)}
{tab==='byMe' && (
filteredByMe.length===0
? <div style={{...S.card,textAlign:'center',padding:40}}><div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Noch nichts geteilt.</div></div>
: filteredByMe.map(link=><LinkCard key={link.id} link={link} isByMe/>)
)}
</div>
);
}

1403
frontend/src/tools/media.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,650 @@
import { useState, useEffect, useRef } from 'react';
import { api, S } from '../lib.js';
import { UserIcon, TrashIcon } from '../icons.jsx';
import {
getOrCreateKeyPair,
getPublicKeyJwk,
getFingerprint,
deriveSharedKey,
encryptMsg,
decryptMsg,
} from '../crypto.js';
// ── Emoji-Konvertierung ───────────────────────────────────────────────────────
const EMOJI_MAP = {
':D':'😁', ':)':'🙂', ':-)':'🙂', '(:':'🙂', ';)':'😉', ';-)':'😉',
':P':'😛', ':-P':'😛', ':p':'😛', ':O':'😮', ':-O':'😮', ':o':'😮',
':(':'😢', ':-(':'😢', ":'(":'😭', '>:(':"😠", '>:-(':"😠",
':*':'😘', ':-*':'😘', '<3':'❤️', '</3':'💔',
'xD':'😆', 'XD':'😆', 'x)':"😄",
':/':"😕", ':-/':"😕", ':|':"😐", ':-|':"😐",
'B)':"😎", 'B-)':"😎", '^_^':"😊", '-_-':"😑",
'O:)':"😇", 'O:-)':"😇", '>:)':"😈",
':3':"🐱", ':$':"😳",
};
// Ersetze Kürzel am Ende des Textes (nach Leerzeichen oder Satzanfang)
function convertEmojis(text) {
// Letztes "Wort" vor dem Leerzeichen/Newline prüfen
return text.replace(/(\S+)(\s)$/, (_, word, space) => {
return (EMOJI_MAP[word] ?? word) + space;
});
}
// ── Emoji-Picker Daten ────────────────────────────────────────────────────────
const EMOJI_GROUPS = [
{ label:'Smileys', emojis:['😀','😁','😂','🤣','😊','😇','🙂','😉','😌','😍','🥰','😘','😎','🤩','😏','😒','😞','😢','😭','😤','😠','😡','🤬','😈','👿','💀','😱','😨','😰','😓','🤔','🤐','😶','😐','😑','😬','🙄','😴','🤧','🤒','😷','🤕'] },
{ label:'Gesten', emojis:['👍','👎','👌','✌️','🤞','🤟','🤙','👋','🤚','🖐','✋','🤜','🤛','👏','🙌','🤝','🙏','💪','🫶','❤️','🧡','💛','💚','💙','💜','🖤','💔','💯','💥','✨','🎉','🔥','⭐','🌟'] },
{ label:'Dinge', emojis:['😂💀','🎮','🏆','🎯','🎲','🃏','🎸','🎵','🎤','📱','💻','⌨️','🖱️','📷','🎬','📺','🚀','🛸','🌍','🌈','☀️','🌙','⭐','🍕','🍔','🍟','🌮','🍩','🍺','☕','🧃'] },
];
function EmojiPicker({ onSelect, onClose }) {
const [group, setGroup] = useState(0);
return (
<div style={{ position:'absolute', bottom:'100%', right:0, marginBottom:6, zIndex:100,
background:'#1a1a1e', border:'1px solid rgba(255,255,255,0.12)', borderRadius:12,
width:272, boxShadow:'0 8px 32px rgba(0,0,0,0.5)' }}
onMouseDown={e => e.preventDefault()}>
<div style={{ display:'flex', borderBottom:'1px solid rgba(255,255,255,0.07)', padding:'6px 8px', gap:4 }}>
{EMOJI_GROUPS.map((g,i) => (
<button key={i} onClick={() => setGroup(i)} style={{ flex:1, background: group===i ?
'rgba(78,205,196,0.15)' : 'transparent', border:'none', borderRadius:7,
color: group===i ? '#4ecdc4' : 'rgba(255,255,255,0.4)', cursor:'pointer',
fontSize:9, fontFamily:'monospace', padding:'4px 2px' }}>{g.label}</button>
))}
</div>
<div style={{ display:'flex', flexWrap:'wrap', padding:8, gap:2, maxHeight:180, overflowY:'auto' }}>
{EMOJI_GROUPS[group].emojis.filter(e => e.length <= 2 || [...e].length <= 2).concat(
EMOJI_GROUPS[group].emojis.filter(e => [...e].length > 2)
).map((em,i) => (
<button key={i} onClick={() => onSelect(em)} style={{ background:'transparent', border:'none',
borderRadius:6, cursor:'pointer', fontSize:20, padding:'4px 5px',
transition:'background 0.1s' }}
onMouseEnter={e => e.target.style.background='rgba(255,255,255,0.08)'}
onMouseLeave={e => e.target.style.background='transparent'}>
{em}
</button>
))}
</div>
</div>
);
}
export default function Nachrichten({ toast, mobile, nav }) {
const [kp, setKp] = useState(null);
const [initError, setInitError] = useState(false);
const [users, setUsers] = useState([]);
const [activeUser, setActiveUser] = useState(null);
const [messages, setMessages] = useState([]);
const [sharedKey, setSharedKey] = useState(null); // null | CryptoKey | 'error'
const [decrypted, setDecrypted] = useState({});
const [partnerFp, setPartnerFp] = useState('');
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const [mirc, setMirc] = useState(false);
// Preference vom Server laden
useEffect(() => {
api('/auth/preferences').then(p => { if (p.chat_design === 'mirc') setMirc(true); }).catch(()=>{});
}, []);
const toggleDesign = () => setMirc(v => {
const next = !v;
api('/auth/preferences', { method:'PUT', body:{ chat_design: next ? 'mirc' : 'modern' } }).catch(()=>{});
return next;
});
const bottomRef = useRef(null);
const pollRef = useRef(null);
const pickerRef = useRef(null);
const heartbeatRef = useRef(null);
// Presence Heartbeat: solange Nachrichten offen + Tab sichtbar → aktiv
useEffect(() => {
const sendPresence = (active) => api('/tools/nachrichten/presence', { method:'POST', body:{ active } }).catch(()=>{});
const start = () => {
if (document.visibilityState === 'visible') {
sendPresence(true);
heartbeatRef.current = setInterval(() => {
if (document.visibilityState === 'visible') sendPresence(true);
}, 30000);
}
};
const onVisibility = () => {
if (document.visibilityState === 'visible') {
sendPresence(true);
if (!heartbeatRef.current) heartbeatRef.current = setInterval(() => sendPresence(true), 30000);
} else {
sendPresence(false);
clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
}
};
start();
document.addEventListener('visibilitychange', onVisibility);
return () => {
sendPresence(false);
clearInterval(heartbeatRef.current);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
useEffect(() => {
if (!showPicker) return;
const handler = e => {
if (pickerRef.current && !pickerRef.current.contains(e.target)) setShowPicker(false);
};
document.addEventListener('mousedown', handler);
document.addEventListener('touchstart', handler);
return () => { document.removeEventListener('mousedown', handler); document.removeEventListener('touchstart', handler); };
}, [showPicker]);
// ── Initialisierung ──────────────────────────────────────────────────────────
useEffect(() => {
getOrCreateKeyPair()
.then(async k => {
setKp(k);
try {
const jwk = await getPublicKeyJwk(k);
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
} catch {}
loadUsers();
})
.catch(() => setInitError(true));
}, []);
// ── Polling ──────────────────────────────────────────────────────────────────
useEffect(() => {
clearInterval(pollRef.current);
if (!activeUser) return;
loadMessages(activeUser.id);
pollRef.current = setInterval(() => loadMessages(activeUser.id), 10_000);
return () => clearInterval(pollRef.current);
}, [activeUser?.id]);
// ── Shared Key + Partner-Fingerprint ─────────────────────────────────────────
useEffect(() => {
setSharedKey(null);
setDecrypted({});
setPartnerFp('');
if (!kp || !activeUser?.public_key) return;
try {
const pubJwk = JSON.parse(activeUser.public_key);
deriveSharedKey(kp.privateKey, pubJwk)
.then(setSharedKey)
.catch(() => setSharedKey('error'));
// Fingerprint des Partner-Keys berechnen (aus dem auf dem Server gespeicherten Public Key)
crypto.subtle.importKey('jwk', pubJwk, { name:'X25519' }, true, [])
.then(k => getFingerprint({ publicKey: k }))
.then(setPartnerFp)
.catch(() => setPartnerFp('?'));
} catch {
setSharedKey('error');
}
}, [activeUser?.id, kp]);
// ── Entschlüsseln ────────────────────────────────────────────────────────────
useEffect(() => {
if (sharedKey === null || sharedKey === 'error' || !messages.length) {
setDecrypted({});
return;
}
let cancelled = false;
(async () => {
const result = {};
for (const m of messages) {
if (cancelled) return;
try {
result[m.id] = await decryptMsg(sharedKey, m.encrypted_content, m.iv);
} catch {
result[m.id] = null; // null = nicht entschlüsselbar
}
}
if (!cancelled) setDecrypted(result);
})();
return () => { cancelled = true; };
}, [messages, sharedKey]);
// ── Scroll to bottom ─────────────────────────────────────────────────────────
const prevLengthRef = useRef(0);
const prevUserRef = useRef(null);
function scrollToBottom(behavior = 'instant') {
bottomRef.current?.scrollIntoView({ behavior });
}
useEffect(() => {
if (!bottomRef.current) return;
const userChanged = prevUserRef.current !== activeUser?.id;
const isFirstLoad = prevLengthRef.current === 0 && messages.length > 0;
prevLengthRef.current = messages.length;
prevUserRef.current = activeUser?.id;
if (userChanged || isFirstLoad) {
scrollToBottom('instant');
} else if (messages.length > 0) {
scrollToBottom('smooth');
}
}, [messages.length, activeUser?.id]);
// Wenn Handy-Tastatur aufklappt → ans Ende scrollen NUR wenn schon am Ende war
useEffect(() => {
if (!window.visualViewport) return;
const onResize = () => {
// Prüfe ob bottomRef nah am sichtbaren Bereich war (= User war am Ende)
if (!bottomRef.current) return;
const rect = bottomRef.current.getBoundingClientRect();
const wasAtBottom = rect.top <= (window.visualViewport?.height ?? window.innerHeight) + 100;
if (wasAtBottom) {
setTimeout(() => scrollToBottom('instant'), 50);
}
};
window.visualViewport.addEventListener('resize', onResize);
return () => window.visualViewport.removeEventListener('resize', onResize);
}, []);
// ── Handlers ─────────────────────────────────────────────────────────────────
const loadUsers = async () => {
try { const u = await api('/tools/nachrichten/users'); setUsers(u);
// Nav aus Suche: direkt zu User springen
if (nav?.userId) {
const target = u.find(x => x.id === nav.userId);
if (target) setActiveUser(target);
}
} catch {}
};
const loadMessages = async userId => {
try {
setMessages(await api(`/tools/nachrichten/messages/${userId}`));
await loadUsers();
} catch {}
};
const selectUser = u => {
setActiveUser(u);
setMessages([]);
setDecrypted({});
prevLengthRef.current = 0;
};
const send = async (overrideText) => {
const msg = (overrideText ?? text).trim();
if (!msg || !kp || !activeUser) return;
if (!activeUser.public_key) { toast('Empfänger hat noch keinen Schlüssel', 'error'); return; }
if (sharedKey === 'error') { toast('Schlüssel inkompatibel Empfänger muss Schlüssel neu generieren', 'error'); return; }
if (!sharedKey) { toast('Schlüssel wird vorbereitet…', 'error'); return; }
setSending(true);
try {
const { encrypted_content, iv } = await encryptMsg(sharedKey, msg);
await api(`/tools/nachrichten/messages/${activeUser.id}`, { body: { encrypted_content, iv } });
setText('');
await loadMessages(activeUser.id);
} catch(e) { toast(e.message, 'error'); }
setSending(false);
};
const deleteMsg = async id => {
try {
await api(`/tools/nachrichten/messages/${id}`, { method:'DELETE' });
setMessages(p => p.filter(m => m.id !== id));
} catch(e) { toast(e.message, 'error'); }
};
const deleteConversation = async () => {
if (!window.confirm(`Gesamten Verlauf mit ${activeUser.username} löschen?`)) return;
try {
await api(`/tools/nachrichten/conversation/${activeUser.id}`, { method:'DELETE' });
setMessages([]);
await loadUsers();
} catch(e) { toast(e.message, 'error'); }
};
// ── Layout ───────────────────────────────────────────────────────────────────
const showList = !mobile || !activeUser;
const showChat = !mobile || !!activeUser;
if (initError) return (
<div style={{ padding:32, color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:12, lineHeight:2 }}>
Verschlüsselung konnte nicht initialisiert werden.<br/>
Dein Browser unterstützt X25519 möglicherweise nicht (erfordert Chrome 113+/Firefox 130+/Safari 17.4+).
</div>
);
const mobileStyle = mobile ? {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
zIndex: 10,
} : {
height: '100vh',
};
return (
<div style={{ display:'flex', overflow:'hidden', background:'#111114', ...mobileStyle }}>
{/* ── Konversationsliste ─────────────────────────────────────────── */}
{showList && (
<div style={{
width: mobile ? '100%' : 230,
borderRight: mobile ? 'none' : '1px solid rgba(255,255,255,0.06)',
display:'flex', flexDirection:'column', background:'#0d0d0f', flexShrink:0,
}}>
<div style={{ padding:'16px 14px 12px', borderBottom:'1px solid rgba(255,255,255,0.06)',
display:'flex', alignItems:'center', justifyContent:'space-between' }}>
<div style={{ ...S.head, marginBottom:0 }}>NACHRICHTEN</div>
<div style={{ display:'flex', gap:5 }}>
<button onClick={toggleDesign} title={mirc ? 'Zu Modern wechseln' : 'Zu mIRC wechseln'}
style={{ background: 'rgba(255,255,255,0.05)',
border:'1px solid rgba(255,255,255,0.1)',
borderRadius:7, color:'rgba(255,255,255,0.55)',
cursor:'pointer', padding:'3px 8px', fontSize:10, fontFamily:'monospace', lineHeight:1 }}>
{mirc ? '◈ mIRC' : '◈ Modern'}
</button>
<button onClick={() => { loadUsers(); if (activeUser) loadMessages(activeUser.id); }} title="Neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:7,
color:'rgba(255,255,255,0.35)', cursor:'pointer', padding:'3px 8px',
fontSize:13, fontFamily:'monospace', lineHeight:1 }}></button>
</div>
</div>
<div style={{ flex:1, overflowY:'auto' }}>
{users.length === 0 ? (
<div style={{ color:'rgba(255,255,255,0.28)', fontSize:11, fontFamily:'monospace',
padding:18, textAlign:'center', lineHeight:1.8 }}>
Keine anderen Benutzer vorhanden
</div>
) : users.map(u => (
<button key={u.id} onClick={() => selectUser(u)} style={{
width:'100%', padding:'11px 14px', border:'none', textAlign:'left', cursor:'pointer',
background: activeUser?.id === u.id ? 'rgba(78,205,196,0.07)' : 'transparent',
borderLeft: `2px solid ${activeUser?.id === u.id ? '#4ecdc4' : 'transparent'}`,
display:'flex', alignItems:'center', gap:10,
}}>
<div style={{ width:32, height:32, borderRadius:'50%', flexShrink:0,
background:'linear-gradient(135deg,rgba(255,107,157,0.2),rgba(78,205,196,0.2))',
border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center' }}>
<UserIcon size={14} color="rgba(255,255,255,0.5)"/>
</div>
<div style={{ flex:1, minWidth:0 }}>
<div style={{
color: u.unread > 0 ? '#fff' : 'rgba(255,255,255,0.65)',
fontWeight: u.unread > 0 ? 700 : 400,
fontSize:12, fontFamily:'monospace',
whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis',
}}>{u.username}</div>
{u.last_message_at && (
<div style={{ color:'rgba(255,255,255,0.26)', fontSize:9, fontFamily:'monospace', marginTop:2 }}>
{new Date(u.last_message_at).toLocaleDateString('de-DE',
{ day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}
</div>
)}
</div>
{u.unread > 0 && (
<span style={{ background:'#4ecdc4', color:'#000', borderRadius:10,
fontSize:9, fontFamily:'monospace', padding:'2px 7px', fontWeight:700, flexShrink:0 }}>
{u.unread}
</span>
)}
</button>
))}
</div>
</div>
)}
{/* ── Chat ──────────────────────────────────────────────────────── */}
{showChat && (
<div style={{ flex:1, minHeight:0, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0 }}>
{!activeUser ? (
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column', gap:10 }}>
<div style={{ fontSize:38, opacity:0.1 }}></div>
<div style={{ color:'rgba(255,255,255,0.18)', fontFamily:'monospace', fontSize:11 }}>
Benutzer auswählen
</div>
</div>
) : (
<>
{/* Header */}
<div style={{ padding:'10px 14px', borderBottom:'1px solid rgba(255,255,255,0.06)',
display:'flex', alignItems:'center', gap:10, background:'#0d0d0f', flexShrink:0 }}>
{mobile && (
<button onClick={() => setActiveUser(null)} style={{ background:'transparent', border:'none',
cursor:'pointer', color:'#4ecdc4', fontFamily:'monospace', fontSize:20, padding:'0 4px', lineHeight:1 }}>
</button>
)}
<div style={{ width:28, height:28, borderRadius:'50%', flexShrink:0,
background:'linear-gradient(135deg,rgba(255,107,157,0.2),rgba(78,205,196,0.2))',
border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center' }}>
<UserIcon size={12} color="rgba(255,255,255,0.5)"/>
</div>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ color:'#fff', fontSize:13, fontFamily:'monospace',
whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
{activeUser.username}
</div>
{sharedKey === 'error' ? (
<div style={{ color:'#ff6b9d', fontSize:8, fontFamily:'monospace', letterSpacing:1.5, marginTop:1 }}>
SCHLÜSSEL INKOMPATIBEL
</div>
) : partnerFp ? (
<div title={`Fingerprint von ${activeUser.username} vergleiche mit dessen Anzeige unter Einstellungen → Sicherheit`}
style={{ color:'rgba(78,205,196,0.55)', fontSize:8, fontFamily:'monospace', letterSpacing:1, marginTop:2,
cursor:'help', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>
🔒 {partnerFp}
</div>
) : (
<div style={{ color:'rgba(78,205,196,0.35)', fontSize:8, fontFamily:'monospace', letterSpacing:1.5, marginTop:1 }}>
🔒 E2E VERSCHLÜSSELT · X25519 + AES-256-GCM
</div>
)}
</div>
<button onClick={deleteConversation} style={{ ...S.btn('#ff6b9d', true), fontSize:10, flexShrink:0 }}>
Verlauf löschen
</button>
</div>
{/* Nachrichten */}
<div style={{ flex:1, minHeight:0, overflowY:'auto', padding: mirc ? '0' : '14px',
display:'flex', flexDirection:'column', gap: mirc ? 0 : 10,
background: mirc ? '#000' : undefined,
fontFamily: mirc ? "'Courier New', Courier, monospace" : undefined }}>
{!activeUser.public_key && (
<InfoBox color="#ffe66d">
Dieser Benutzer hat die Nachrichten noch nicht geöffnet und besitzt noch kein Schlüsselpaar.
</InfoBox>
)}
{sharedKey === 'error' && (
<InfoBox color="#ff6b9d">
Schlüssel inkompatibel der Empfänger verwendet ein anderes Schlüsselformat.
Er muss in Einstellungen Sicherheit einen neuen Schlüssel generieren.
</InfoBox>
)}
{messages.length === 0 && activeUser.public_key && sharedKey !== 'error' && (
<div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center',
color: mirc ? '#808080' : 'rgba(255,255,255,0.18)',
fontFamily: mirc ? "'Courier New', monospace" : 'monospace', fontSize:11 }}>
{mirc ? '*** Noch keine Nachrichten ***' : 'Noch keine Nachrichten'}
</div>
)}
{Object.values(decrypted).some(v => v === null) && (
<InfoBox color="#ffe66d">
🔑 Einige Nachrichten können nicht entschlüsselt werden.
Importiere deinen alten Schlüssel unter Einstellungen Sicherheit.
</InfoBox>
)}
{mirc ? (
/* ── mIRC Design ─────────────────────────────────────── */
<div style={{ padding:'4px 0' }}>
{messages.map(m => {
const isMine = !!m.is_mine;
const txt = decrypted[m.id];
const time = m.created_at ? (() => { const d = new Date(m.created_at.replace(' ','T')); return isNaN(d)?'':d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'}); })() : '';
const nick = isMine ? 'du' : activeUser.username;
const nickColor = isMine ? '#00ff00' : '#00ffff';
return (
<div key={m.id} style={{ display:'flex', alignItems:'baseline', gap:0,
padding:'1px 8px', lineHeight:1.5,
background: isMine ? 'rgba(0,255,0,0.03)' : 'transparent' }}
onMouseEnter={e=>e.currentTarget.style.background='rgba(255,255,255,0.04)'}
onMouseLeave={e=>e.currentTarget.style.background=isMine?'rgba(0,255,0,0.03)':'transparent'}>
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:2 }}>[{time}]</span>
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:2 }}>&lt;</span>
<span style={{ color:nickColor, fontSize:11, fontWeight:700, flexShrink:0 }}>{nick}</span>
<span style={{ color:'#808080', fontSize:11, flexShrink:0, marginRight:6 }}>&gt;</span>
<span style={{ color: txt===null?'#404040':'#d0d0d0', fontSize:12, wordBreak:'break-word', flex:1 }}>
{txt===undefined ? '…' : txt===null ? '[verschlüsselt]' : txt}
</span>
{isMine && m.read_by_recipient && <span style={{color:'#004400',fontSize:9,marginLeft:4,flexShrink:0}}></span>}
<button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none',
cursor:'pointer', color:'#400000', fontSize:9, padding:'0 0 0 6px', flexShrink:0, opacity:0.5 }}></button>
</div>
);
})}
</div>
) : (
/* ── Modernes Design ─────────────────────────────────── */
<>
{messages.map(m => {
const isMine = !!m.is_mine;
const txt = decrypted[m.id];
return (
<div key={m.id} style={{ display:'flex',
justifyContent: isMine ? 'flex-end' : 'flex-start', gap:6, alignItems:'flex-end' }}>
{!isMine && (
<div style={{ width:22, height:22, borderRadius:'50%', flexShrink:0,
background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.07)',
display:'flex', alignItems:'center', justifyContent:'center', fontSize:10 }}>👤</div>
)}
<div style={{ maxWidth:'75%', display:'flex', flexDirection:'column',
alignItems: isMine ? 'flex-end' : 'flex-start', gap:4 }}>
<div style={{
background: isMine ? 'rgba(78,205,196,0.09)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${isMine ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.07)'}`,
borderRadius: isMine ? '12px 12px 3px 12px' : '12px 12px 12px 3px',
padding:'8px 12px',
}}>
{txt === undefined ? (
<span style={{ color:'rgba(255,255,255,0.18)', fontSize:12 }}></span>
) : txt === null ? (
<span style={{ color:'rgba(255,107,157,0.5)', fontSize:11, fontFamily:'monospace' }}>🔒 Nicht entschlüsselbar</span>
) : (
<span style={{ color:'#e6e6e6', fontSize:13, fontFamily:'monospace',
lineHeight:1.55, whiteSpace:'pre-wrap', wordBreak:'break-word' }}>{txt}</span>
)}
</div>
<div style={{ display:'flex', alignItems:'center', gap:5 }}>
<span style={{ color:'rgba(255,255,255,0.18)', fontSize:9, fontFamily:'monospace', whiteSpace:'nowrap' }}>
{formatMsgTime(m.created_at)}
</span>
{isMine && (
<span title={m.read_by_recipient ? 'Gelesen' : 'Gesendet'}
style={{ fontSize:10, lineHeight:1, color: m.read_by_recipient ? '#4ecdc4' : 'rgba(255,255,255,0.2)' }}>
{m.read_by_recipient ? '✓✓' : '✓'}
</span>
)}
<button onClick={() => deleteMsg(m.id)} style={{ background:'transparent', border:'none',
cursor:'pointer', padding:'0 2px', opacity:0.3, lineHeight:1,
display:'flex', alignItems:'center' }}>
<TrashIcon size={10} color="#ff6b9d"/>
</button>
</div>
</div>
</div>
);
})}
</>
)}
<div ref={bottomRef}/>
</div>
{/* Eingabe */}
<div style={{ padding:'10px 14px', flexShrink:0,
borderTop:`1px solid ${mirc?'#333':'rgba(255,255,255,0.06)'}`,
background: mirc ? '#000' : '#0d0d0f' }}>
{!activeUser.public_key || sharedKey === 'error' ? (
<div style={{ color:'rgba(255,255,255,0.2)', fontSize:11, fontFamily:'monospace',
textAlign:'center', padding:'8px 0' }}>
Senden nicht möglich
</div>
) : (
<div style={{ display:'flex', gap:8, alignItems:'stretch', position:'relative' }}>
<div ref={pickerRef} style={{ position:'absolute', bottom:'100%', right:0 }}>
{showPicker && (
<EmojiPicker
onSelect={em => { setText(t => t + em); setShowPicker(false); }}
onClose={() => setShowPicker(false)}
/>
)}
</div>
{mirc && (
<span style={{ color:'#00ff00', fontFamily:"'Courier New',monospace", fontSize:12,
alignSelf:'center', flexShrink:0 }}>[du]</span>
)}
<textarea
value={text}
onChange={e => {
const val = e.target.value;
const last = val[val.length - 1];
if (last === ' ' || last === '\n') setText(convertEmojis(val));
else setText(val);
}}
onFocus={() => setShowPicker(false)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const converted = convertEmojis(text + ' ').trimEnd();
if (converted !== text) setText(converted);
send(converted);
}
}}
placeholder={mirc ? 'Nachricht…' : 'Nachricht… (Enter senden · Shift+Enter neue Zeile)'}
rows={2}
style={{ ...S.inp, resize:'none', flex:1, fontSize: mirc ? 12 : 13,
padding:'8px 10px', lineHeight:1.45, minHeight:42,
...(mirc ? { background:'#111', border:'1px solid #444', color:'#d0d0d0',
fontFamily:"'Courier New',monospace", borderRadius:2 } : {}) }}
/>
<button onClick={() => setShowPicker(v => !v)} style={{
background: showPicker ? 'rgba(78,205,196,0.12)' : (mirc ? '#111' : 'rgba(255,255,255,0.05)'),
border:`1px solid ${showPicker ? 'rgba(78,205,196,0.3)' : (mirc ? '#444' : 'rgba(255,255,255,0.1)')}`,
borderRadius: mirc ? 2 : 8, cursor:'pointer', fontSize:17,
padding:'0 10px', flexShrink:0, alignSelf:'stretch' }}>😊</button>
</div>
)}
</div>
</>
)}
</div>
)}
</div>
);
}
function InfoBox({ color, children }) {
return (
<div style={{ background:`${color}08`, border:`1px solid ${color}25`,
borderRadius:8, padding:'10px 14px', color, fontSize:11,
fontFamily:'monospace', textAlign:'center', lineHeight:1.7 }}>
{children}
</div>
);
}
function formatMsgTime(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr.replace(' ', 'T'));
if (isNaN(d)) return '';
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const isThisYear = d.getFullYear() === now.getFullYear();
const time = d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
if (isToday) return `Heute, ${time}`;
if (isThisYear) return `${d.toLocaleDateString('de-DE', { day:'numeric', month:'short' })}, ${time}`;
return `${d.toLocaleDateString('de-DE', { day:'numeric', month:'short', year:'numeric' })}, ${time}`;
}

View File

@@ -0,0 +1,191 @@
import { useState, useRef } from 'react';
import { S } from '../lib.js';
const C = {
accent: '#f59e0b',
success: '#4ade80',
error: '#f87171',
muted: '#6b7280',
};
export default function PaywallKiller() {
const [inputUrl, setInputUrl] = useState('');
const [archiveUrl, setArchiveUrl] = useState('');
const [status, setStatus] = useState('idle');
const inputRef = useRef(null);
function getTargetUrl() {
let url = inputUrl.trim();
if (!url.startsWith('http')) url = 'https://' + url;
// Mobile Subdomains auf Desktop normalisieren (m.bild.de → www.bild.de)
// damit archive.ph den richtigen Snapshot findet
try {
const u = new URL(url);
if (u.hostname.startsWith('m.')) {
u.hostname = 'www.' + u.hostname.slice(2);
url = u.toString();
} else if (u.hostname.startsWith('mobile.')) {
u.hostname = 'www.' + u.hostname.slice(7);
url = u.toString();
}
} catch {}
return url;
}
function handleSearch() {
if (!inputUrl.trim()) return;
const targetUrl = getTargetUrl();
// archive.ph Submit (no-cors, fire-and-forget) triggert Archivierung
try {
const formData = new FormData();
formData.append('url', targetUrl);
formData.append('anyway', '1');
fetch('https://archive.ph/submit/', {
method: 'POST',
mode: 'no-cors',
body: formData,
}).catch(() => {});
} catch {}
setArchiveUrl(`https://archive.ph/newest/${targetUrl}`);
setStatus('ready');
}
function handleReset() {
setInputUrl('');
setArchiveUrl('');
setStatus('idle');
setTimeout(() => inputRef.current?.focus(), 50);
}
return (
<div style={{ padding: '20px 16px', maxWidth: 640, margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<span style={{ fontSize: 20 }}>🔓</span>
<span style={{ color: C.accent, fontFamily: 'monospace', fontSize: 13, letterSpacing: 1 }}>PAYWALL-KILLER</span>
</div>
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', marginBottom: 10 }}>archive.ph · Als PDF speichern</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ color: 'rgba(255,255,255,0.25)', fontFamily: 'monospace', fontSize: 9, letterSpacing: 0.5 }}>GETESTET MIT</span>
{[
{ name: 'waz.de', domain: 'waz.de' },
{ name: 'bild.de', domain: 'bild.de' },
{ name: 'heise.de', domain: 'heise.de' },
].map(({ name, domain }) => (
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 7px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 4 }}>
<img src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`} width={12} height={12} style={{ borderRadius: 2, opacity: 0.7 }} alt="" />
<span style={{ color: 'rgba(255,255,255,0.35)', fontFamily: 'monospace', fontSize: 9 }}>{name}</span>
</div>
))}
</div>
</div>
{/* Eingabe */}
<div style={{ ...S.card, marginBottom: 16 }}>
<div style={{ ...S.head, marginBottom: 10 }}>ARTIKEL-URL</div>
<input
ref={inputRef}
value={inputUrl}
onChange={e => setInputUrl(e.target.value)}
onKeyDown={e => e.key === 'Enter' && status === 'idle' && handleSearch()}
placeholder="URL einfügen (Long-Press → Einfügen)"
style={{ ...S.inp, width: '100%', boxSizing: 'border-box', marginBottom: 10 }}
/>
<div style={{ display: 'flex', gap: 8 }}>
{status === 'idle' ? (
<button onClick={handleSearch} disabled={!inputUrl.trim()}
style={{ ...S.btn(C.accent), flex: 1, opacity: !inputUrl.trim() ? 0.45 : 1, cursor: !inputUrl.trim() ? 'default' : 'pointer' }}>
🔍 Archiv suchen
</button>
) : (
<button onClick={handleReset}
style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.14)', borderRadius: 6, padding: '8px 14px', color: 'rgba(255,255,255,0.6)', cursor: 'pointer', fontFamily: 'monospace', fontSize: 12 }}>
Reset
</button>
)}
</div>
</div>
{/* Ergebnis */}
{status === 'ready' && (
<div style={{ ...S.card, borderColor: `${C.accent}55`, marginBottom: 12 }}>
<div style={{ ...S.head, marginBottom: 12 }}>ARTIKEL ÖFFNEN & ALS PDF SPEICHERN</div>
{/* Schritt 1 */}
<div style={{ display: 'flex', gap: 10, marginBottom: 4 }}>
<span style={{ background: `${C.accent}20`, border: `1px solid ${C.accent}44`, borderRadius: '50%', width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.accent, fontFamily: 'monospace', flexShrink: 0, marginTop: 1 }}>1</span>
<div style={{ flex: 1 }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontFamily: 'monospace', fontSize: 11, marginBottom: 8 }}>
Öffne den Artikel auf archive.ph. Falls CAPTCHA erscheint: einmalig lösen. Falls "No results": auf <strong style={{ color: 'rgba(255,255,255,0.5)' }}>"archive this url"</strong> klicken, kurz warten, dann nochmal öffnen.
</div>
<a href={archiveUrl} target="_blank" rel="noreferrer"
style={{ ...S.btn(C.accent), display: 'block', textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box', fontSize: 13 }}>
🌐 Artikel auf archive.ph öffnen
</a>
</div>
</div>
{/* Nochmal öffnen nach Archivierung */}
<div style={{ margin: '10px 0 16px 32px', padding: '8px 12px', background: 'rgba(255,255,255,0.03)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.07)' }}>
<div style={{ color: C.muted, fontSize: 10, fontFamily: 'monospace', marginBottom: 6 }}>
Nach "archive this url" ~1530s warten, dann:
</div>
<a href={archiveUrl} target="_blank" rel="noreferrer"
style={{ ...S.btn(C.muted), display: 'inline-block', textDecoration: 'none', fontSize: 11 }}>
🔄 Nochmal öffnen
</a>
</div>
{/* Schritt 2 */}
<div style={{ display: 'flex', gap: 10 }}>
<span style={{ background: `${C.success}20`, border: `1px solid ${C.success}44`, borderRadius: '50%', width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.success, fontFamily: 'monospace', flexShrink: 0, marginTop: 1 }}>2</span>
<div style={{ flex: 1 }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontFamily: 'monospace', fontSize: 11, marginBottom: 8 }}>
Wenn der Artikel sichtbar ist, als PDF speichern:
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{[
['📱', 'Android Chrome', 'Menü (⋮) → Teilen → Drucken → Als PDF · Kopf-/Fußzeilen: aus'],
['🍎', 'iOS Safari', 'Teilen-Button → Als PDF sichern'],
['💻', 'Desktop', 'Strg+P / Cmd+P → Als PDF · Kopf-/Fußzeilen: aus'],
].map(([icon, label, desc]) => (
<div key={label} style={{ padding: '8px 10px', background: 'rgba(255,255,255,0.03)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.07)' }}>
<div style={{ color: 'rgba(255,255,255,0.6)', fontFamily: 'monospace', fontSize: 11, marginBottom: 2 }}>{icon} {label}</div>
<div style={{ color: C.muted, fontFamily: 'monospace', fontSize: 10, lineHeight: 1.5 }}>{desc}</div>
</div>
))}
</div>
<div style={{ marginTop: 10, padding: '8px 10px', background: 'rgba(255,255,255,0.02)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ color: 'rgba(255,255,255,0.3)', fontFamily: 'monospace', fontSize: 10, lineHeight: 1.5 }}>
Der archive.ph-Balken oben ist Teil der archivierten Seite und kann nicht automatisch entfernt werden.
</div>
</div>
</div>
</div>
</div>
)}
{/* Idle-Info */}
{status === 'idle' && (
<div style={{ ...S.card }}>
<div style={{ ...S.head, marginBottom: 10 }}>SO FUNKTIONIERT ES</div>
{[
['1', 'URL des gesperrten Artikels einfügen'],
['2', 'Artikel auf archive.ph öffnen'],
['3', 'Falls nicht archiviert: "archive this url" klicken, kurz warten, nochmal öffnen'],
['4', 'Über Drucken-Funktion als PDF speichern'],
].map(([num, text]) => (
<div key={num} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 8 }}>
<span style={{ background: `${C.accent}20`, border: `1px solid ${C.accent}44`, borderRadius: '50%', width: 20, height: 20, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: C.accent, fontFamily: 'monospace', marginTop: 2 }}>{num}</span>
<span style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 }}>{text}</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,686 @@
import { useState, useEffect, useCallback } from 'react';
import { api, S } from '../lib.js';
// Testmodus: API-Call als anderer User (nur Admin, nur Schocken)
async function apiAs(userId, path, opts = {}) {
const token = localStorage.getItem('sk_token');
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
if (userId) headers['X-Schocken-Test-As'] = String(userId);
const res = await fetch(`/api${path}`, {
method: opts.method || (opts.body ? 'POST' : 'GET'),
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (!res.ok) { const e = await res.json().catch(()=>({error:'Fehler'})); throw new Error(e.error||'Fehler'); }
return res.json();
}
const MY_COLOR = '#4ecdc4';
function getMyId() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).id; } catch { return null; }
}
function getMyRole() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; }
}
// ── Würfel-Visualisierung ─────────────────────────────────────────────────────
const DOTS = {
1: [[50,50]],
2: [[25,25],[75,75]],
3: [[25,25],[50,50],[75,75]],
4: [[25,25],[75,25],[25,75],[75,75]],
5: [[25,25],[75,25],[50,50],[25,75],[75,75]],
6: [[25,25],[75,25],[25,50],[75,50],[25,75],[75,75]],
};
function Die({ value, kept, selected, onClick, dark, size=56 }) {
const dots = value ? DOTS[value] : [];
const bg = dark ? 'rgba(40,40,60,0.9)' : kept ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.08)';
const border = selected ? '2px solid #4ecdc4' : kept ? '2px solid rgba(78,205,196,0.4)' : '1px solid rgba(255,255,255,0.15)';
return (
<div onClick={onClick} style={{
width:size, height:size, borderRadius:10, background:bg, border,
cursor:onClick?'pointer':'default', position:'relative', flexShrink:0,
transition:'all 0.15s', boxShadow: selected ? '0 0 12px rgba(78,205,196,0.4)' : 'none',
}}>
{dark && <div style={{position:'absolute',inset:0,display:'flex',alignItems:'center',justifyContent:'center',color:'rgba(255,255,255,0.2)',fontSize:22}}>🎲</div>}
{!dark && dots.map(([x,y],i) => (
<div key={i} style={{
position:'absolute',
left:`${x}%`, top:`${y}%`,
transform:'translate(-50%,-50%)',
width:size>40?10:7, height:size>40?10:7,
borderRadius:'50%', background:'#fff',
}}/>
))}
{!value && !dark && <div style={{position:'absolute',inset:0,display:'flex',alignItems:'center',justifyContent:'center',color:'rgba(255,255,255,0.15)',fontSize:10,fontFamily:'monospace'}}>?</div>}
</div>
);
}
// ── Spieler-Zeile ─────────────────────────────────────────────────────────────
function PlayerRow({ player, chips, has16th, isMe, isCurrent, result, drinkLosses, game }) {
const isLoserH1 = game.has_16th === player.id;
return (
<div style={{
...S.card, padding:'10px 14px', marginBottom:8,
border: isCurrent ? '1px solid rgba(78,205,196,0.4)' : '1px solid rgba(255,255,255,0.07)',
background: isMe ? 'rgba(78,205,196,0.04)' : 'rgba(255,255,255,0.02)',
}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{flex:1}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,display:'flex',alignItems:'center',gap:6}}>
{isCurrent && <span style={{color:MY_COLOR}}></span>}
<strong>{player.username}</strong>
{isMe && <span style={{color:'rgba(255,255,255,0.3)',fontSize:10}}>(du)</span>}
{isLoserH1 && <span title="Verlierer 1. Hälfte" style={{fontSize:14}}>🪶</span>}
</div>
</div>
<div style={{display:'flex',alignItems:'center',gap:12}}>
{drinkLosses > 0 && (
<span style={{color:'#f59e0b',fontFamily:'monospace',fontSize:11}}>🍺×{drinkLosses}</span>
)}
<div style={{textAlign:'center'}}>
<div style={{color:chips>0?'#ff6b9d':MY_COLOR,fontFamily:'Space Mono,monospace',fontSize:18,fontWeight:700,lineHeight:1}}>{chips}</div>
<div style={{color:'rgba(255,255,255,0.25)',fontSize:9,fontFamily:'monospace'}}>Scheiben</div>
</div>
</div>
</div>
{result && (
<div style={{marginTop:6,padding:'4px 8px',background:'rgba(255,255,255,0.04)',borderRadius:6,
color:MY_COLOR,fontFamily:'monospace',fontSize:11}}>
{result.label}
</div>
)}
</div>
);
}
// ── Würfelbereich ─────────────────────────────────────────────────────────────
function DiceArea({ game, myId, onRoll, onReady, onEvaluate }) {
const [selectedKeep, setSelectedKeep] = useState([]);
const [goDark, setGoDark] = useState(false);
const [flipSixes, setFlipSixes] = useState(false);
const [rolling, setRolling] = useState(false);
const round = game.current_round;
const players = game.players;
const activePlayers = getActivePlayers(game);
const myState = round?.playerStates?.[myId];
const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length];
const isMyTurn = currentPlayer?.id === myId;
const allReady = activePlayers.every(p => round?.ready?.[p.id]);
const allDone = round?.phase === 'reveal';
const isFirstRound = !!game.first_round;
const myDice = myState?.dice || [null, null, null];
const myRolls = myState?.roll_count || 0;
const maxRolls = round?.max_rolls || 3;
const canFlipSixes = myDice.filter(d => d===6).length >= 2 && myRolls < maxRolls - 1;
// Becher umdrehen Phase — warte bis alle ready
if (!round || !allReady) {
const iMReady = round?.ready?.[myId];
const readyCount = Object.keys(round?.ready || {}).length;
const activeCount = activePlayers.length;
return (
<div>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:12,textAlign:'center',marginBottom:12}}>
{readyCount}/{activeCount} haben den Becher umgedreht
</div>
{!iMReady && (
<button onClick={onReady} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
🎲 Becher umdrehen
</button>
)}
{iMReady && (
<div style={{color:MY_COLOR,fontFamily:'monospace',fontSize:12,textAlign:'center',padding:'12px 0'}}>
Becher umgedreht warte auf andere
</div>
)}
{isFirstRound && iMReady && readyCount === activeCount && (
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,textAlign:'center',marginTop:8}}>
Alle bereit Becher werden gleichzeitig gehoben!
</div>
)}
</div>
);
}
// Auswertungsphase — alle Würfel sichtbar
if (allDone) {
const isFirstPlayer = activePlayers[0]?.id === myId || players[0]?.id === myId;
return (
<div>
<div style={{color:MY_COLOR,fontFamily:'monospace',fontSize:12,textAlign:'center',marginBottom:14}}>
🎲 Alle Becher oben wer hat was?
</div>
{/* Alle Spieler mit Würfeln und Ergebnis */}
{activePlayers.map(p => {
const ps = round.playerStates?.[p.id];
const isP = p.id === myId;
return (
<div key={p.id} style={{
...S.card, padding:'10px 14px', marginBottom:8,
border: isP ? '1px solid rgba(78,205,196,0.3)' : '1px solid rgba(255,255,255,0.07)',
}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:8}}>
<span style={{color:isP?MY_COLOR:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:12,fontWeight:700}}>
{p.username}{isP?' (du)':''}
</span>
{ps?.dark && <span style={{color:'rgba(255,255,255,0.4)',fontSize:10,fontFamily:'monospace'}}>🌑 war dunkel</span>}
{ps?.roll_count && <span style={{color:'rgba(255,255,255,0.3)',fontSize:10,fontFamily:'monospace',marginLeft:'auto'}}>{ps.roll_count}. Wurf</span>}
</div>
<div style={{display:'flex',gap:8,marginBottom:8}}>
{(ps?.dice||[null,null,null]).map((d,i) => <Die key={i} value={d} size={44}/>)}
</div>
{ps?.result && (
<div style={{
padding:'4px 10px', borderRadius:6, display:'inline-block',
background: ps.result.type==='schock_aus'?'rgba(239,68,68,0.15)':
ps.result.type==='julchen'?'rgba(255,230,109,0.15)':
ps.result.type==='general'?'rgba(78,205,196,0.12)':'rgba(255,255,255,0.05)',
border: ps.result.type==='schock_aus'?'1px solid rgba(239,68,68,0.4)':
ps.result.type==='julchen'?'1px solid rgba(255,230,109,0.4)':
ps.result.type==='general'?'1px solid rgba(78,205,196,0.3)':'1px solid rgba(255,255,255,0.08)',
color: ps.result.type==='schock_aus'?'#ef4444':
ps.result.type==='julchen'?'#ffe66d':
ps.result.type==='general'?MY_COLOR:'rgba(255,255,255,0.6)',
fontFamily:'monospace', fontSize:11,
}}>
{ps.result.label} {ps.result.scheiben > 0 ? `${ps.result.scheiben} Scheibe${ps.result.scheiben!==1?'n':''}` : ''}
</div>
)}
</div>
);
})}
{isFirstPlayer && (
<button onClick={onEvaluate} style={{...S.btn('#ffe66d'),width:'100%',marginTop:4}}>
Scheiben verteilen
</button>
)}
{!isFirstPlayer && (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,textAlign:'center',padding:'8px 0'}}>
Warte auf {activePlayers[0]?.username} zum Auswerten
</div>
)}
</div>
);
}
// Mein Würfeln
return (
<div>
{/* Status */}
<div style={{color:isMyTurn?MY_COLOR:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,textAlign:'center',marginBottom:10}}>
{isMyTurn ? `▶ Dein Wurf (${myRolls}/${maxRolls||'?'})` : `${currentPlayer?.username} würfelt…`}
</div>
{/* Würfel */}
<div style={{display:'flex',gap:10,justifyContent:'center',marginBottom:14}}>
{myDice.map((d, i) => (
<Die
key={i}
value={d}
dark={myState?.dark}
kept={selectedKeep.includes(i)}
selected={selectedKeep.includes(i)}
onClick={isMyTurn && myRolls > 0 && !myState?.done ? () => {
setSelectedKeep(prev =>
prev.includes(i) ? prev.filter(x=>x!==i) : [...prev,i]
);
} : undefined}
size={64}
/>
))}
</div>
{/* Optionen */}
{isMyTurn && !myState?.done && (
<div style={{display:'flex',flexDirection:'column',gap:8}}>
{myRolls > 0 && (
<div style={{display:'flex',gap:8,fontSize:11,fontFamily:'monospace',color:'rgba(255,255,255,0.5)'}}>
{selectedKeep.length > 0 && <span>📌 {selectedKeep.length} Würfel stehen lassen</span>}
</div>
)}
{canFlipSixes && (
<label style={{display:'flex',alignItems:'center',gap:8,cursor:'pointer',fontFamily:'monospace',fontSize:11,color:'rgba(255,255,255,0.6)'}}>
<input type="checkbox" checked={flipSixes} onChange={e=>setFlipSixes(e.target.checked)}/>
Zwei Sechsen zu Einer Eins umdrehen
</label>
)}
{myRolls > 0 && myRolls < (maxRolls||3) && (
<label style={{display:'flex',alignItems:'center',gap:8,cursor:'pointer',fontFamily:'monospace',fontSize:11,color:'rgba(255,255,255,0.6)'}}>
<input type="checkbox" checked={goDark} onChange={e=>setGoDark(e.target.checked)}/>
Dunkel legen (Becher nicht heben)
</label>
)}
<button
disabled={rolling}
onClick={async () => {
setRolling(true);
try { await onRoll(selectedKeep, flipSixes, goDark); setSelectedKeep([]); setFlipSixes(false); setGoDark(false); }
finally { setRolling(false); }
}}
style={{...S.btn(MY_COLOR), padding:'10px', fontSize:13}}
>
{rolling ? '🎲 Würfle…' : myRolls === 0 ? '🎲 Becher heben' : goDark ? '🎲 Dunkel würfeln' : '🎲 Nochmal würfeln'}
</button>
</div>
)}
{myState?.done && !allDone && (
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11,textAlign:'center',padding:'8px 0'}}>
{myState.dark ? '🎲 Du bist dunkel — warte auf andere…' : `✓ Du stehst im ${myRolls}. — warte auf andere…`}
</div>
)}
{/* Andere Spieler Status */}
<div style={{marginTop:12,display:'flex',flexWrap:'wrap',gap:6}}>
{activePlayers.filter(p => p.id !== myId).map(p => {
const ps = round.playerStates?.[p.id];
return (
<div key={p.id} style={{
padding:'4px 10px', borderRadius:20,
background: ps?.done ? 'rgba(78,205,196,0.1)' : 'rgba(255,255,255,0.04)',
border: ps?.done ? '1px solid rgba(78,205,196,0.3)' : '1px solid rgba(255,255,255,0.08)',
color: ps?.done ? MY_COLOR : 'rgba(255,255,255,0.4)',
fontFamily:'monospace', fontSize:10,
}}>
{ps?.done ? '✓' : '⏳'} {p.username} {ps?.dark && '🌑'}
</div>
);
})}
</div>
</div>
);
}
// ── Schockstock ───────────────────────────────────────────────────────────────
function Schockstock({ total=15, stock, players, chips }) {
const used = total - stock;
return (
<div style={{...S.card,padding:'10px 14px',marginBottom:12}}>
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:8}}>
<span style={{color:'rgba(255,255,255,0.55)',fontFamily:'monospace',fontSize:11,letterSpacing:1}}>SCHOCKSTOCK</span>
<span style={{color:MY_COLOR,fontFamily:'monospace',fontSize:11,marginLeft:'auto'}}>{stock} verbleibend</span>
</div>
<div style={{display:'flex',gap:3,flexWrap:'wrap'}}>
{Array.from({length:16}).map((_,i) => {
const is16th = i === 15;
const isUsed = !is16th && i >= stock;
return (
<div key={i} style={{
width:14, height:14, borderRadius:'50%',
background: is16th
? 'rgba(245,158,11,0.6)'
: isUsed
? 'rgba(255,255,255,0.1)'
: '#4ecdc4',
border: is16th ? '1px solid #f59e0b' : '1px solid rgba(255,255,255,0.1)',
title: is16th ? '16. Scheibe' : '',
}}/>
);
})}
</div>
</div>
);
}
// ── Neue Runde Button ─────────────────────────────────────────────────────────
function NewRoundButton({ game, myId, onReady }) {
// Der Beginner der neuen Runde startet mit Becher umdrehen
const isStarter = game.beginner_id === myId;
return (
<div style={{...S.card,padding:'14px',textAlign:'center',marginBottom:12,border:'1px solid rgba(78,205,196,0.2)'}}>
<div style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:12,marginBottom:10}}>
Neue Runde {isStarter ? 'du fängst an!' : `${game.players.find(p=>p.id===game.beginner_id)?.username} fängt an`}
</div>
<button onClick={onReady} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
🎲 Becher umdrehen
</button>
</div>
);
}
// ── Spielende ─────────────────────────────────────────────────────────────────
function GameOver({ game }) {
const dl = game.drink_losses;
const loser = game.players.find(p => dl[p.id] > 0);
const isDoppelfeige = game.loser_h1 === game.loser_h2;
return (
<div style={{...S.card,padding:'20px',textAlign:'center',border:'1px solid rgba(245,158,11,0.3)'}}>
<div style={{fontSize:48,marginBottom:12}}>{isDoppelfeige ? '🪶🪶' : '🍺'}</div>
{isDoppelfeige ? (
<div style={{color:'#f59e0b',fontFamily:'Space Mono,monospace',fontSize:16,fontWeight:700,marginBottom:8}}>
DOPPELFEIGE!
</div>
) : (
<div style={{color:'#f59e0b',fontFamily:'Space Mono,monospace',fontSize:16,fontWeight:700,marginBottom:8}}>
RUNDE VORBEI
</div>
)}
{loser && (
<div style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13,marginBottom:16}}>
{loser.username} zahlt die nächste Runde! 🍺
</div>
)}
<div style={{marginTop:12}}>
<div style={{...S.head,marginBottom:8}}>GETRÄNKE-STATISTIK</div>
{game.players.map(p => (
<div key={p.id} style={{display:'flex',justifyContent:'space-between',padding:'4px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)',fontFamily:'monospace',fontSize:12}}>
<span style={{color:'rgba(255,255,255,0.7)'}}>{p.username}</span>
<span style={{color:dl[p.id]>0?'#f59e0b':MY_COLOR}}>🍺 {dl[p.id]||0}</span>
</div>
))}
</div>
</div>
);
}
// ── Aktive Spieler berechnen (Frontend) ───────────────────────────────────────
function getActivePlayers(game) {
const { players, player_chips, stock, status, loser_h1, loser_h2 } = game;
if (status === 'endkampf') {
return players.filter(p => p.id === loser_h1 || p.id === loser_h2);
}
if (stock > 0) return players;
return players.filter(p => player_chips[p.id] > 0);
}
// ── Neues Spiel Modal ─────────────────────────────────────────────────────────
function NewGameModal({ onClose, onCreate, toast }) {
const [users, setUsers] = useState([]);
const [picked, setPicked] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => { api('/tools/schocken/users').then(setUsers).catch(()=>{}); }, []);
const toggle = id => setPicked(p => p.includes(id) ? p.filter(x=>x!==id) : [...p,id]);
const create = async () => {
if (!picked.length) return;
setLoading(true);
try {
const r = await api('/tools/schocken', { body: { player_ids: picked } });
toast('Spiel erstellt! 🎲');
onCreate(r.id);
} catch(e) { toast(e.message||'Fehler','error'); }
finally { setLoading(false); }
};
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',display:'flex',alignItems:'center',justifyContent:'center',zIndex:1000,padding:20}}>
<div style={{...S.card,maxWidth:380,width:'100%'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:18}}>
<span style={{color:'#fff',fontFamily:'Space Mono,monospace',fontSize:13,fontWeight:700,letterSpacing:1}}>NEUES SPIEL</span>
<button onClick={onClose} style={S.btn('#666666',true)}></button>
</div>
<div style={{...S.head,marginBottom:8}}>MITSPIELER WÄHLEN</div>
<div style={{maxHeight:240,overflowY:'auto',marginBottom:16}}>
{users.map(u => (
<div key={u.id} onClick={()=>toggle(u.id)} style={{
display:'flex',alignItems:'center',gap:10,padding:'8px 10px',
cursor:'pointer',borderRadius:8,marginBottom:4,
background: picked.includes(u.id) ? 'rgba(78,205,196,0.1)' : 'rgba(255,255,255,0.03)',
border: picked.includes(u.id) ? '1px solid rgba(78,205,196,0.3)' : '1px solid rgba(255,255,255,0.06)',
}}>
<span style={{color:picked.includes(u.id)?MY_COLOR:'rgba(255,255,255,0.5)',fontSize:14}}>
{picked.includes(u.id)?'☑':'☐'}
</span>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13}}>{u.username}</span>
</div>
))}
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={onClose} style={{...S.btn('#666666'),flex:1}}>Abbrechen</button>
<button onClick={create} disabled={!picked.length||loading} style={{...S.btn('#4ecdc4'),flex:1}}>
{loading ? 'Erstelle…' : `Starten (${picked.length+1} Spieler)`}
</button>
</div>
</div>
</div>
);
}
// ── Spielliste ────────────────────────────────────────────────────────────────
function GameList({ games, myId, onSelect, onNew, onDelete, isAdmin }) {
const active = games.filter(g => g.status !== 'finished');
const finished = games.filter(g => g.status === 'finished');
const statusLabel = s => ({
lobby:'Lobby', half1:'1. Hälfte', half2:'2. Hälfte', endkampf:'Endkampf', finished:'Beendet'
}[s]||s);
const GameRow = ({ g }) => {
const others = g.players.filter(p=>p.id!==myId).map(p=>p.username).join(', ');
return (
<div style={{...S.card,padding:'12px 14px',marginBottom:8,display:'flex',alignItems:'center',gap:10,border:'1px solid rgba(255,255,255,0.07)'}}>
<button onClick={()=>onSelect(g.id)} style={{flex:1,background:'none',border:'none',cursor:'pointer',textAlign:'left',padding:0}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,marginBottom:3}}>
🎲 mit <strong>{others}</strong>
</div>
<div style={{color:'rgba(255,255,255,0.35)',fontSize:11,fontFamily:'monospace'}}>
{g.players.length} Spieler · {statusLabel(g.status)}
</div>
</button>
{isAdmin && (
<button onClick={async e => {
e.stopPropagation();
if (!window.confirm('Spiel löschen?')) return;
try {
await apiAs(myId, `/tools/schocken/${g.id}/cancel`, { body:{} });
onDelete(g.id);
} catch(e) { alert(e.message||'Fehler'); }
}} style={{...S.btn('#ff6b9d',true),flexShrink:0}}>🗑</button>
)}
</div>
);
};
return (
<div>
<button onClick={onNew} style={{...S.btn('#4ecdc4'),width:'100%',marginBottom:20,padding:'10px 14px',fontSize:13}}>
+ Neues Spiel starten
</button>
{active.length > 0 && <>
<div style={{...S.head,marginBottom:8}}>LAUFEND ({active.length})</div>
{active.map(g=><GameRow key={g.id} g={g}/>)}
</>}
{finished.length > 0 && <>
<div style={{...S.head,marginTop:16,marginBottom:8}}>BEENDET</div>
{finished.map(g=><GameRow key={g.id} g={g}/>)}
</>}
{games.length===0 && (
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:13,textAlign:'center',paddingTop:40}}>
Noch keine Spiele starte eines!
</div>
)}
</div>
);
}
// ── Spielansicht ──────────────────────────────────────────────────────────────
function GameView({ game, myId, onRefresh, toast, isAdmin=false, onSeatChange }) {
const players = game.players;
const chips = game.player_chips;
const dl = game.drink_losses;
const round = game.current_round;
const activePlayers = getActivePlayers(game);
const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length];
const handleRoll = async (keepDice, flipSixes, goDark) => {
await apiAs(myId, `/tools/schocken/${game.id}/roll`, {
body: { keep_dice: keepDice, flip_sixes: flipSixes, go_dark: goDark }
});
onRefresh();
};
const handleReady = async () => {
await apiAs(myId, `/tools/schocken/${game.id}/ready`, { body: {} });
onRefresh();
};
const handleEvaluate = async () => {
const result = await apiAs(myId, `/tools/schocken/${game.id}/evaluate`, { body: {} });
if (result.round_result?.eventMsg) toast(result.round_result.eventMsg);
onRefresh();
};
const handleStart = async () => {
await apiAs(myId, `/tools/schocken/${game.id}/start`, { body: {} });
onRefresh();
};
const statusLabel = { lobby:'Lobby', half1:'1. Hälfte', half2:'2. Hälfte', endkampf:'Endkampf', finished:'Beendet' };
return (
<div>
{/* Phase-Header */}
<div style={{...S.card,padding:'8px 14px',marginBottom:12,display:'flex',alignItems:'center',gap:10}}>
<span style={{color:MY_COLOR,fontFamily:'monospace',fontSize:11,letterSpacing:1}}>{statusLabel[game.status]}</span>
<span style={{flex:1}}/>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>Runde {game.round_number}</span>
</div>
{/* Testmodus-Switcher (nur Admin) */}
{isAdmin && (
<div style={{marginBottom:12,padding:'8px 12px',background:'rgba(245,158,11,0.08)',border:'1px solid rgba(245,158,11,0.25)',borderRadius:8}}>
<div style={{color:'#f59e0b',fontFamily:'monospace',fontSize:10,letterSpacing:1,marginBottom:6}}>🧪 TESTMODUS SPIELER WECHSELN</div>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{players.map(p => (
<button key={p.id} onClick={() => onSeatChange(p.id)}
style={{...S.btn(myId===p.id?'#f59e0b':'#666666',true),fontSize:10}}>
{p.username}{myId===p.id?' ✓':''}
</button>
))}
</div>
</div>
)}
{/* Schockstock */}
<Schockstock stock={game.stock} players={players} chips={chips}/>
{/* Spieler */}
<div style={{marginBottom:12}}>
{players.map(p => (
<PlayerRow
key={p.id}
player={p}
chips={chips[p.id]||0}
has16th={game.has_16th}
isMe={p.id===myId}
isCurrent={currentPlayer?.id===p.id}
result={round?.playerStates?.[p.id]?.result}
drinkLosses={dl[p.id]||0}
game={game}
/>
))}
</div>
{/* Lobby */}
{game.status==='lobby' && (
<div style={{textAlign:'center'}}>
{players[0]?.id===myId ? (
<button onClick={handleStart} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
🎲 Spiel starten
</button>
) : (
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12,padding:'20px 0'}}>
Warte auf {players[0]?.username} um das Spiel zu starten
</div>
)}
</div>
)}
{/* Spielfeld */}
{game.status!=='lobby' && game.status!=='finished' && (
<div style={{...S.card,padding:'14px',border:'1px solid rgba(78,205,196,0.15)'}}>
<DiceArea
game={game}
myId={myId}
onRoll={handleRoll}
onReady={handleReady}
onEvaluate={handleEvaluate}
/>
</div>
)}
{/* Spielende */}
{game.status==='finished' && <GameOver game={game}/>}
</div>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function Schocken({ toast }) {
const [games, setGames] = useState([]);
const [activeId, setActiveId] = useState(null);
const [game, setGame] = useState(null);
const [showNew, setShowNew] = useState(false);
const [loading, setLoading] = useState(true);
const [testSeat, setTestSeat] = useState(null); // null = eigener Account, sonst userId
const myRealId = getMyId();
const isAdmin = getMyRole() === 'admin';
// Im Testmodus: als anderen Spieler agieren
const myId = (isAdmin && testSeat) ? testSeat : myRealId;
const loadGames = useCallback(async () => {
try { setGames(await api('/tools/schocken')); }
catch { toast?.('Fehler beim Laden','error'); }
finally { setLoading(false); }
}, []);
const loadGame = useCallback(async () => {
if (!activeId) { setGame(null); return; }
try { setGame(await api(`/tools/schocken/${activeId}`)); }
catch {}
}, [activeId]);
useEffect(() => { loadGames(); }, [loadGames]);
useEffect(() => { loadGame(); }, [loadGame]);
// Polling wenn Spiel aktiv
useEffect(() => {
if (!activeId) return;
const iv = setInterval(loadGame, 5000);
return () => clearInterval(iv);
}, [activeId, loadGame]);
if (loading) return <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',padding:40,textAlign:'center'}}>Lade</div>;
return (
<div style={{maxWidth:560,margin:'0 auto'}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:24,flexWrap:'wrap'}}>
{activeId && (
<button onClick={()=>{setActiveId(null);setGame(null);loadGames();}} style={S.btn('#666666',true)}> Zurück</button>
)}
<h2 style={{margin:0,fontSize:15,fontFamily:'monospace',color:'rgba(255,255,255,0.55)',letterSpacing:2,fontWeight:400}}>
🎲 SCHOCKEN
</h2>
<div style={{flex:1}}/>
{activeId && game?.status !== 'finished' && isAdmin && (
<button onClick={async () => {
if (!window.confirm('Spiel wirklich abbrechen? Keine Punkte werden vergeben.')) return;
try {
await apiAs(myRealId, `/tools/schocken/${activeId}/cancel`, { body:{} });
setActiveId(null); setGame(null); loadGames();
toast('Spiel abgebrochen.');
} catch(e) { toast(e.message||'Fehler','error'); }
}} style={S.btn('#ff6b9d', true)}> Abbrechen</button>
)}
</div>
{!activeId
? <GameList games={games} myId={myId} onSelect={setActiveId} onNew={()=>setShowNew(true)} onDelete={id=>setGames(prev=>prev.filter(g=>g.id!==id))} isAdmin={isAdmin}/>
: game && myId
? <GameView game={game} myId={myId} onRefresh={loadGame} toast={toast} isAdmin={isAdmin} onSeatChange={id=>{setTestSeat(id);}} />
: <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',textAlign:'center',paddingTop:40}}>Lade</div>
}
{showNew && <NewGameModal onClose={()=>setShowNew(false)} toast={toast} onCreate={id=>{setShowNew(false);setActiveId(id);loadGames();}}/>}
</div>
);
}

View File

@@ -0,0 +1,492 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { api, S } from '../lib.js';
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
const uid = () => Math.random().toString(36).slice(2,8);
const dist = (x1,y1,x2,y2) => Math.sqrt((x2-x1)**2+(y2-y1)**2);
const snapToGrid = (v, g) => Math.round(v / g) * g;
function parseDim(s) {
if (!s) return null;
const n = parseFloat(String(s).replace(/[^0-9.,\-]/g,'').replace(',','.'));
return isNaN(n) ? null : n;
}
function evalFormula(expr, vars) {
try {
const sanitized = expr.replace(/[^0-9+\-*/().,%a-zA-Z_\s]/g,'');
const keys = Object.keys(vars), vals = keys.map(k=>vars[k]);
// eslint-disable-next-line no-new-func
return new Function(...keys, `"use strict"; return (${sanitized});`)(...vals);
} catch { return null; }
}
function buildVars(shapes) {
const vars = {};
shapes.forEach(s => {
const n = (s.name||'').replace(/[^a-zA-Z0-9_]/g,'_');
if (!n) return;
if (s.type==='rect') {
const l=parseDim(s.dimW), b=parseDim(s.dimH);
if (l!=null) { vars[`${n}_l`]=l; vars[`${n}_b`]=b??l; }
if (l!=null&&b!=null) { vars[`${n}_fl`]=+(l*b).toFixed(4); vars[`${n}_um`]=+(2*(l+b)).toFixed(4); vars[`${n}_d`]=+(Math.sqrt(l*l+b*b)).toFixed(4); }
}
if (s.type==='line') { const v=parseDim(s.dimLen); if(v!=null) vars[`${n}_len`]=v; }
if (s.type==='circle') {
const r=parseDim(s.dimR);
if(r!=null){ vars[`${n}_r`]=r; vars[`${n}_d`]=+(r*2).toFixed(4); vars[`${n}_fl`]=+(Math.PI*r*r).toFixed(4); vars[`${n}_um`]=+(2*Math.PI*r).toFixed(4); }
}
});
return vars;
}
// ── Row-Komponente (AUSSERHALB um Input-Bug zu vermeiden) ─────────────────────
function DimRow({ label, value, field, placeholder, onChange }) {
return (
<div style={{marginBottom:8}}>
<div style={{...S.head,marginBottom:3,fontSize:9}}>{label}</div>
<input value={value||''} onChange={e=>onChange({[field]:e.target.value})}
placeholder={placeholder} autoCapitalize="none"
style={{...S.inp,fontSize:12,padding:'6px 8px'}}/>
</div>
);
}
// ── Maßlinie ─────────────────────────────────────────────────────────────────
function DimLine({x1,y1,x2,y2,label,color='#4ecdc4',offset=18}) {
const mx=(x1+x2)/2, my=(y1+y2)/2;
const dx=x2-x1, dy=y2-y1, len=Math.sqrt(dx*dx+dy*dy);
if (len<4) return null;
const nx=-dy/len*offset, ny=dx/len*offset;
return (
<g>
<line x1={x1+nx} y1={y1+ny} x2={x2+nx} y2={y2+ny} stroke={color} strokeWidth={1} strokeDasharray="3,2" opacity={0.7}/>
<line x1={x1} y1={y1} x2={x1+nx} y2={y1+ny} stroke={color} strokeWidth={0.7} opacity={0.4}/>
<line x1={x2} y1={y2} x2={x2+nx} y2={y2+ny} stroke={color} strokeWidth={0.7} opacity={0.4}/>
<text x={mx+nx} y={my+ny} textAnchor="middle" dominantBaseline="middle"
fontSize={9} fill={color} fontFamily="'Courier New',monospace" style={{userSelect:'none'}}
stroke="#0a0a0c" strokeWidth={3} paintOrder="stroke">{label}</text>
</g>
);
}
// ── Shape + Mittelpunkt + Versatz ─────────────────────────────────────────────
function Shape({ s, selected, onClick }) {
const sel = selected===s.id;
const c = sel ? '#4ecdc4' : (s.color||'#7ecaff');
const offset = parseDim(s.offset) || 0; // Versatz in px (scaled)
if (s.type==='rect') {
const x=Math.min(s.x,s.x+s.w), y=Math.min(s.y,s.y+s.h);
const w=Math.abs(s.w), h=Math.abs(s.h);
const cx=x+w/2, cy=y+h/2;
const offPx = s.offsetPx||0;
return (
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
<rect x={x} y={y} width={w} height={h}
fill={sel?'rgba(78,205,196,0.06)':'rgba(126,202,255,0.04)'}
stroke={c} strokeWidth={sel?1.5:1}/>
{/* Mittelpunkt */}
<line x1={cx-6} y1={cy} x2={cx+6} y2={cy} stroke={c} strokeWidth={0.8} opacity={0.5}/>
<line x1={cx} y1={cy-6} x2={cx} y2={cy+6} stroke={c} strokeWidth={0.8} opacity={0.5}/>
{/* Versatz */}
{offPx!==0&&<rect x={x-offPx} y={y-offPx} width={w+2*offPx} height={h+2*offPx}
fill="none" stroke={offPx>0?'#ffe66d':'#ff6b9d'} strokeWidth={1} strokeDasharray="4,3" opacity={0.7}/>}
{s.name&&<text x={cx} y={cy+2} textAnchor="middle" dominantBaseline="middle"
fontSize={10} fill={c} fontFamily="'Courier New',monospace" style={{userSelect:'none'}}
stroke="#0a0a0c" strokeWidth={3} paintOrder="stroke">{s.name}</text>}
{s.dimW&&<DimLine x1={x} y1={y+h} x2={x+w} y2={y+h} label={s.dimW} offset={14}/>}
{s.dimH&&<DimLine x1={x+w} y1={y} x2={x+w} y2={y+h} label={s.dimH} offset={14}/>}
{sel&&<>
<rect x={x-3} y={y-3} width={6} height={6} fill={c} rx={1}/>
<rect x={x+w-3} y={y-3} width={6} height={6} fill={c} rx={1}/>
<rect x={x-3} y={y+h-3} width={6} height={6} fill={c} rx={1}/>
<rect x={x+w-3} y={y+h-3} width={6} height={6} fill={c} rx={1}/>
</>}
</g>
);
}
if (s.type==='line') {
const len=dist(s.x1,s.y1,s.x2,s.y2);
const mx=(s.x1+s.x2)/2, my=(s.y1+s.y2)/2;
return (
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
<line x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2} stroke="transparent" strokeWidth={12}/>
<line x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2} stroke={c} strokeWidth={sel?2:1.5} strokeLinecap="round"/>
<circle cx={mx} cy={my} r={2} fill={c} opacity={0.6}/>
{(s.dimLen||s.name)&&len>20&&<DimLine x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2} label={s.dimLen||s.name} offset={12}/>}
{sel&&<><circle cx={s.x1} cy={s.y1} r={4} fill={c}/><circle cx={s.x2} cy={s.y2} r={4} fill={c}/></>}
</g>
);
}
if (s.type==='circle') {
const r=Math.max(1,s.r);
const offPx=s.offsetPx||0;
return (
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
<circle cx={s.cx} cy={s.cy} r={r} fill={sel?'rgba(78,205,196,0.06)':'rgba(126,202,255,0.04)'} stroke={c} strokeWidth={sel?1.5:1}/>
{offPx!==0&&<circle cx={s.cx} cy={s.cy} r={r+offPx} fill="none" stroke={offPx>0?'#ffe66d':'#ff6b9d'} strokeWidth={1} strokeDasharray="4,3" opacity={0.7}/>}
{/* Mittelpunkt */}
<line x1={s.cx-6} y1={s.cy} x2={s.cx+6} y2={s.cy} stroke={c} strokeWidth={0.8} opacity={0.5}/>
<line x1={s.cx} y1={s.cy-6} x2={s.cx} y2={s.cy+6} stroke={c} strokeWidth={0.8} opacity={0.5}/>
{/* Radiuslinie */}
<line x1={s.cx} y1={s.cy} x2={s.cx+r} y2={s.cy} stroke={c} strokeWidth={0.7} strokeDasharray="3,2" opacity={0.4}/>
{s.name&&<text x={s.cx} y={s.cy+2} textAnchor="middle" dominantBaseline="middle"
fontSize={10} fill={c} fontFamily="'Courier New',monospace" style={{userSelect:'none'}}
stroke="#0a0a0c" strokeWidth={3} paintOrder="stroke">{s.name}</text>}
{s.dimR&&<DimLine x1={s.cx} y1={s.cy} x2={s.cx+r} y2={s.cy} label={'r='+s.dimR} offset={8} color="#ffe66d"/>}
{sel&&<circle cx={s.cx} cy={s.cy} r={4} fill={c}/>}
</g>
);
}
if (s.type==='text') {
return (
<g onClick={e=>{e.stopPropagation();onClick(s.id);}} style={{cursor:'pointer'}}>
<text x={s.x} y={s.y} fontSize={12} fill={sel?'#4ecdc4':'rgba(255,255,255,0.75)'}
fontFamily="'Courier New',monospace" style={{userSelect:'none'}}>{s.label||'Text'}</text>
{sel&&<rect x={s.x-2} y={s.y-14} width={Math.max(40,(s.label||'Text').length*7+4)} height={18}
fill="none" stroke="#4ecdc4" strokeWidth={1} strokeDasharray="3,2"/>}
</g>
);
}
return null;
}
// ── Properties Panel ──────────────────────────────────────────────────────────
function PropsPanel({ shape, onChange, onDelete, vars, scale, onScaleChange }) {
if (!shape) return (
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10,padding:'14px 0',textAlign:'center',lineHeight:1.8}}>
Form auswählen oder zeichnen.<br/>
<span style={{fontSize:9,opacity:0.6}}>V=Auswählen R=Rect L=Linie C=Kreis</span>
</div>
);
const shapeVarKeys = Object.keys(vars).filter(k=>k.startsWith((shape.name||'').replace(/[^a-zA-Z0-9_]/g,'_')+'_'));
return (
<div>
<div style={{...S.head,marginBottom:10}}>EIGENSCHAFTEN</div>
<DimRow label="NAME" value={shape.name} field="name" placeholder="z.B. Kasten" onChange={onChange}/>
{shape.type==='rect'&&<>
<DimRow label="LÄNGE" value={shape.dimW} field="dimW" placeholder="225mm" onChange={onChange}/>
<DimRow label="BREITE" value={shape.dimH} field="dimH" placeholder="30mm" onChange={onChange}/>
<DimRow label="VERSATZ (+ aussen / innen)" value={shape.dimOffset} field="dimOffset" placeholder="5mm" onChange={onChange}/>
</>}
{shape.type==='line'&&<DimRow label="LÄNGE" value={shape.dimLen} field="dimLen" placeholder="100mm" onChange={onChange}/>}
{shape.type==='circle'&&<>
<DimRow label="RADIUS" value={shape.dimR} field="dimR" placeholder="50mm" onChange={onChange}/>
<DimRow label="VERSATZ (+ aussen / innen)" value={shape.dimOffset} field="dimOffset" placeholder="5mm" onChange={onChange}/>
</>}
{shape.type==='text'&&<DimRow label="TEXT" value={shape.label} field="label" placeholder="Beschriftung" onChange={onChange}/>}
<button onClick={onDelete}
style={{...S.btn('#ff6b9d',true),width:'100%',textAlign:'center',padding:'7px 0',fontSize:11,marginTop:4}}>
Löschen
</button>
{shapeVarKeys.length>0&&(
<div style={{marginTop:12}}>
<div style={{...S.head,marginBottom:5,fontSize:9}}>VARIABLEN</div>
{shapeVarKeys.map(k=>(
<div key={k} style={{display:'flex',justifyContent:'space-between',padding:'3px 0',
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
<code style={{color:'#4ecdc4',fontSize:10}}>{k}</code>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>
{typeof vars[k]==='number'?vars[k].toFixed(2):vars[k]}
</span>
</div>
))}
</div>
)}
<div style={{marginTop:14}}>
<div style={{...S.head,marginBottom:5,fontSize:9}}>MASSSTAB (mm/px)</div>
<div style={{display:'flex',gap:6,alignItems:'center'}}>
<input type="number" value={scale} onChange={e=>onScaleChange(parseFloat(e.target.value)||1)}
step={0.1} min={0.1} style={{...S.inp,flex:1,fontSize:12,padding:'5px 8px'}}/>
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:9}}>mm/px</span>
</div>
</div>
</div>
);
}
// ── Main ──────────────────────────────────────────────────────────────────────
const TOOLS = [
{id:'select',label:'▲',title:'Auswählen (V)'},
{id:'rect', label:'▭',title:'Rechteck (R)'},
{id:'line', label:'',title:'Linie (L)'},
{id:'circle',label:'○',title:'Kreis (C)'},
{id:'text', label:'T', title:'Text (T)'},
];
const GRID = 10;
const CALC_BTNS = ['(',')',' ','7','8','9','×','4','5','6','÷','1','2','3','-','0','.','=','+'];
export default function Skizze({ toast, mobile }) {
const [shapes, setShapes] = useState([]);
const [selected, setSelected] = useState(null);
const [tool, setTool] = useState('select');
const [drawing, setDrawing] = useState(null);
const [formula, setFormula] = useState('');
const [results, setResults] = useState([]);
const [snapGrid, setSnapGrid] = useState(true);
const [scale, setScale] = useState(1); // mm per pixel
const svgRef = useRef(null);
const formulaRef= useRef(null);
const vars = buildVars(shapes);
const selShape = shapes.find(s=>s.id===selected)||null;
// Keyboard shortcuts
useEffect(()=>{
const h = e => {
if (e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return;
const map={v:'select',r:'rect',l:'line',c:'circle',t:'text'};
if (map[e.key.toLowerCase()]) setTool(map[e.key.toLowerCase()]);
if ((e.key==='Delete'||e.key==='Backspace')&&selected) deleteSelected();
if (e.key==='Escape') { setSelected(null); setDrawing(null); }
};
window.addEventListener('keydown',h);
return()=>window.removeEventListener('keydown',h);
},[selected]);
const getSVGPoint = useCallback(e=>{
const rect=svgRef.current?.getBoundingClientRect();
if (!rect) return{x:0,y:0};
const cx=e.touches?e.touches[0].clientX:e.clientX;
const cy=e.touches?e.touches[0].clientY:e.clientY;
const x=cx-rect.left, y=cy-rect.top;
return {x:snapGrid?snapToGrid(x,GRID):x, y:snapGrid?snapToGrid(y,GRID):y};
},[snapGrid]);
const onSVGDown = useCallback(e=>{
if (tool==='select'){setSelected(null);return;}
const pt=getSVGPoint(e);
if (tool==='text'){
setShapes(p=>[...p,{id:uid(),type:'text',x:pt.x,y:pt.y,label:'Text',name:''}]);
setTool('select'); return;
}
setDrawing({tool,startX:pt.x,startY:pt.y,curX:pt.x,curY:pt.y});
},[tool,getSVGPoint]);
const onSVGMove = useCallback(e=>{
if (!drawing) return;
const pt=getSVGPoint(e);
setDrawing(d=>({...d,curX:pt.x,curY:pt.y}));
},[drawing,getSVGPoint]);
const onSVGUp = useCallback(e=>{
if (!drawing) return;
const pt=getSVGPoint(e);
const dx=pt.x-drawing.startX, dy=pt.y-drawing.startY;
if (Math.abs(dx)<4&&Math.abs(dy)<4){setDrawing(null);return;}
let s;
if (drawing.tool==='rect') s={id:uid(),type:'rect',x:drawing.startX,y:drawing.startY,w:dx,h:dy,name:'',dimW:'',dimH:'',dimOffset:''};
if (drawing.tool==='line') s={id:uid(),type:'line',x1:drawing.startX,y1:drawing.startY,x2:pt.x,y2:pt.y,name:'',dimLen:''};
if (drawing.tool==='circle') s={id:uid(),type:'circle',cx:drawing.startX,cy:drawing.startY,r:dist(drawing.startX,drawing.startY,pt.x,pt.y),name:'',dimR:'',dimOffset:''};
if (s){setShapes(p=>[...p,s]);setSelected(s.id);}
setDrawing(null);
},[drawing,getSVGPoint]);
const deleteSelected = ()=>{setShapes(p=>p.filter(s=>s.id!==selected));setSelected(null);};
// Update shape + auto-resize visual proportions based on scale
const updateShape = patch => {
setShapes(p=>p.map(s=>{
if (s.id!==selected) return s;
const upd={...s,...patch};
// Auto-resize rect
if (upd.type==='rect') {
const mW=parseDim(upd.dimW), mH=parseDim(upd.dimH);
if (mW!=null&&mW>0) {
upd.w=Math.sign(upd.w||1)*mW/scale;
if (mH!=null&&mH>0) upd.h=Math.sign(upd.h||1)*mH/scale;
}
// Versatz in px
const mO=parseDim(upd.dimOffset);
upd.offsetPx=mO!=null?mO/scale:0;
}
// Auto-resize circle
if (upd.type==='circle') {
const mR=parseDim(upd.dimR);
if (mR!=null&&mR>0) upd.r=mR/scale;
const mO=parseDim(upd.dimOffset);
upd.offsetPx=mO!=null?mO/scale:0;
}
return upd;
}));
};
const calcFormula = () => {
if (!formula.trim()) return;
const result=evalFormula(formula,vars);
const isArea=formula.toLowerCase().includes('_fl');
setResults(p=>[{
id:uid(), expr:formula,
result:result!=null?`${typeof result==='number'?result.toFixed(4).replace(/.?0+$/,''):result}${isArea?' mm²':' mm'}`:null,
},...p].slice(0,30));
};
const insertCalc = btn => {
if (btn==='='){calcFormula();return;}
const map={'×':'*','÷':'/',' ':' '};
const char=map[btn]??btn;
setFormula(f=>f+char);
formulaRef.current?.focus();
};
// Preview while drawing
let preview=null;
if (drawing){
const dx=drawing.curX-drawing.startX, dy=drawing.curY-drawing.startY;
const pc='#4ecdc4';
if (drawing.tool==='rect') preview=<rect x={Math.min(drawing.startX,drawing.curX)} y={Math.min(drawing.startY,drawing.curY)} width={Math.abs(dx)} height={Math.abs(dy)} fill="rgba(78,205,196,0.04)" stroke={pc} strokeWidth={1} strokeDasharray="4,3"/>;
if (drawing.tool==='line') preview=<line x1={drawing.startX} y1={drawing.startY} x2={drawing.curX} y2={drawing.curY} stroke={pc} strokeWidth={1.5} strokeDasharray="4,3"/>;
if (drawing.tool==='circle'){const r=dist(drawing.startX,drawing.startY,drawing.curX,drawing.curY); preview=<circle cx={drawing.startX} cy={drawing.startY} r={r} fill="rgba(78,205,196,0.04)" stroke={pc} strokeWidth={1} strokeDasharray="4,3"/>;}
}
const canvasH = mobile ? 260 : 420;
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:1100}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:12,flexWrap:'wrap',gap:8}}>
<div style={{display:'flex',alignItems:'center',gap:12,flexWrap:'wrap'}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?16:22,margin:0}}>Skizzen-Rechner</h1>
<div style={{
display:'flex',alignItems:'center',gap:6,
background:'repeating-linear-gradient(45deg,rgba(255,230,109,0.12),rgba(255,230,109,0.12) 6px,rgba(0,0,0,0) 6px,rgba(0,0,0,0) 12px)',
border:'1px solid rgba(255,230,109,0.3)',borderRadius:8,
padding:'4px 10px',flexShrink:0,
}}>
<span style={{fontSize:14}}>🚧</span>
<span style={{color:'#ffe66d',fontFamily:'monospace',fontSize:10,fontWeight:700,letterSpacing:0.5}}>IM AUFBAU</span>
</div>
</div>
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<label style={{display:'flex',alignItems:'center',gap:5,cursor:'pointer'}}>
<input type="checkbox" checked={snapGrid} onChange={e=>setSnapGrid(e.target.checked)} style={{accentColor:'#4ecdc4'}}/>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>Raster</span>
</label>
<button onClick={()=>{setShapes([]);setSelected(null);}}
style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'5px 10px'}}> Leeren</button>
</div>
</div>
{/* Toolbar */}
<div style={{display:'flex',gap:5,marginBottom:10,flexWrap:'wrap'}}>
{TOOLS.map(t=>(
<button key={t.id} onClick={()=>setTool(t.id)} title={t.title} style={{
padding:'7px 13px',borderRadius:8,fontFamily:'monospace',fontSize:t.id==='text'?14:16,
cursor:'pointer',fontWeight:700,
background:tool===t.id?'#4ecdc4':'rgba(255,255,255,0.06)',
color: tool===t.id?'#0d0d0f':'rgba(255,255,255,0.6)',
border: tool===t.id?'none':'1px solid rgba(255,255,255,0.1)',
}}>{t.label}</button>
))}
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:9,alignSelf:'center',marginLeft:4}}>
{TOOLS.find(t=>t.id===tool)?.title}
</span>
</div>
{/* Canvas + Panel */}
<div style={{display:'flex',gap:12,flexDirection:mobile?'column':'row'}}>
<svg ref={svgRef} style={{flex:1,minWidth:0,background:'#0a0a0c',border:'1px solid rgba(255,255,255,0.08)',borderRadius:10,display:'block',cursor:tool==='select'?'default':tool==='text'?'text':'crosshair'}}
height={canvasH}
onMouseDown={onSVGDown} onMouseMove={onSVGMove} onMouseUp={onSVGUp}
onTouchStart={onSVGDown} onTouchMove={onSVGMove} onTouchEnd={onSVGUp}>
<defs>
<pattern id="g10" width={GRID} height={GRID} patternUnits="userSpaceOnUse">
<path d={`M ${GRID} 0 L 0 0 0 ${GRID}`} fill="none" stroke="rgba(255,255,255,0.04)" strokeWidth={0.5}/>
</pattern>
<pattern id="g50" width={50} height={50} patternUnits="userSpaceOnUse">
<rect width={50} height={50} fill="url(#g10)"/>
<path d="M 50 0 L 0 0 0 50" fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={0.5}/>
</pattern>
</defs>
{snapGrid&&<rect width="100%" height="100%" fill="url(#g50)"/>}
{shapes.map(s=><Shape key={s.id} s={s} selected={selected} onClick={id=>{if(tool==='select')setSelected(id);}}/>)}
{preview}
</svg>
<div style={{width:mobile?'100%':210,flexShrink:0}}>
<div style={{...S.card}}>
<PropsPanel shape={selShape} onChange={updateShape} onDelete={deleteSelected}
vars={vars} scale={scale} onScaleChange={setScale}/>
</div>
</div>
</div>
{/* Rechner */}
<div style={{...S.card,marginTop:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div style={{...S.head,marginBottom:0}}>RECHNER</div>
{results.length>0&&<button onClick={()=>setResults([])} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'3px 8px'}}> Verlauf</button>}
</div>
{/* Alle Variablen */}
{Object.keys(vars).length>0&&(
<div style={{display:'flex',flexWrap:'wrap',gap:'3px 14px',marginBottom:10,padding:'7px 10px',
background:'rgba(0,0,0,0.3)',borderRadius:7,border:'1px solid rgba(255,255,255,0.05)'}}>
{Object.entries(vars).map(([k,v])=>(
<span key={k} style={{cursor:'pointer'}} onClick={()=>{setFormula(f=>f+k);formulaRef.current?.focus();}}>
<code style={{color:'#4ecdc4',fontSize:10}}>{k}</code>
<span style={{color:'rgba(255,255,255,0.55)',fontFamily:'monospace',fontSize:10}}>={typeof v==='number'?v.toFixed(2):v}</span>
</span>
))}
</div>
)}
{/* Input */}
<div style={{display:'flex',gap:8,marginBottom:8}}>
<input ref={formulaRef} value={formula} onChange={e=>setFormula(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&calcFormula()}
placeholder="z.B. Kasten_l / 2"
autoCapitalize="none"
style={{...S.inp,flex:1,fontFamily:"'Courier New',monospace",fontSize:13}}/>
<button onClick={calcFormula} style={{...S.btn('#4ecdc4'),padding:'0 16px',flexShrink:0}}>= Berechnen</button>
</div>
{/* Operator-Buttons */}
<div style={{display:'grid',gridTemplateColumns:'repeat(5,1fr)',gap:5,marginBottom:10}}>
{['(',')',' × ',' ÷ ',' '].map((b,i)=>(
b.trim()===''?<div key={i}/>:
<button key={i} onClick={()=>insertCalc(b.trim())} style={{
background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:7,color:'rgba(255,255,255,0.7)',cursor:'pointer',
fontFamily:'monospace',fontSize:14,padding:'6px 0',fontWeight:700,
}}>{b.trim()==='×'?'×':b.trim()==='÷'?'÷':b.trim()}</button>
))}
</div>
{/* Ergebnisse */}
{results.length===0&&Object.keys(vars).length===0&&(
<div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,textAlign:'center',padding:'8px 0'}}>
Formen zeichnen benennen hier rechnen
</div>
)}
{results.map(r=>(
<div key={r.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'7px 10px',marginBottom:5,borderRadius:7,
background:r.result?'rgba(78,205,196,0.06)':'rgba(255,107,157,0.06)',
border:`1px solid ${r.result?'rgba(78,205,196,0.14)':'rgba(255,107,157,0.14)'}`}}>
<code style={{color:'rgba(255,255,255,0.45)',fontSize:11,fontFamily:"'Courier New',monospace",
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',maxWidth:'60%'}}>
{r.expr}
</code>
<div style={{display:'flex',alignItems:'center',gap:8}}>
<code style={{color:r.result?'#4ecdc4':'#ff6b9d',fontSize:14,fontFamily:"'Courier New',monospace",fontWeight:700}}>
{r.result||'❌ Fehler'}
</code>
<button onClick={()=>setResults(p=>p.filter(x=>x.id!==r.id))}
style={{background:'transparent',border:'none',cursor:'pointer',
color:'rgba(255,255,255,0.5)',fontSize:12,padding:'0 2px',lineHeight:1}}></button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,510 @@
import { useState, useEffect, useRef } from 'react';
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend } from 'recharts';
const api = (path, opts={}) => {
const token = localStorage.getItem('sk_token');
return fetch('/api' + path, {
headers: { 'Content-Type':'application/json', ...(token?{'Authorization':'Bearer '+token}:{}) },
...(opts.body ? { method: opts.method||'POST', body: JSON.stringify(opts.body) } : { method: opts.method||'GET' }),
}).then(r => r.json());
};
const S = {
card: { background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:12, padding:'16px 18px', marginBottom:12 },
head: { color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, letterSpacing:1, marginBottom:10, fontWeight:700 },
val: (c='#fff') => ({ color:c, fontFamily:"'Space Mono',monospace", fontSize:22, fontWeight:700 }),
sub: { color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 },
inp: { background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)', borderRadius:8, padding:'8px 12px', color:'#fff', fontFamily:'monospace', fontSize:12, outline:'none', width:'100%', boxSizing:'border-box' },
btn: (c='#4ecdc4') => ({ background:`${c}18`, border:`1px solid ${c}44`, borderRadius:8, padding:'8px 14px', color:c, fontFamily:'monospace', fontSize:11, cursor:'pointer' }),
};
const CATS = ['Filament','Zubehör','Ersatzteile','Werkzeug','Sonstiges'];
const CAT_COLORS = { Filament:'#4ecdc4', Zubehör:'#ffe66d', Ersatzteile:'#ff6b9d', Werkzeug:'#60a5fa', Sonstiges:'#c084fc' };
const fmt = v => `${(v||0).toFixed(2)}`;
// Lokales Datum als YYYY-MM-DD (kein UTC-Bug wie bei toISOString)
function todayLocal() {
const d = new Date();
const y = d.getFullYear(), m = String(d.getMonth()+1).padStart(2,'0'), day = String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${day}`;
}
const fmtMonth = m => {
if (!m) return '';
if (/^\d{4}-\d{2}$/.test(m)) {
const [y,mo] = m.split('-');
return ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'][parseInt(mo)-1]+' '+y.slice(2);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(m)) {
const [,mo,d] = m.split('-');
return `${d}.${mo}.`;
}
return m;
};
// Bei Wochen-Granularität: zeigt "15.06. 21.06." statt nur den Montag
const fmtMonthRange = (m, granularity) => {
if (!m || granularity !== 'week' || !/^\d{4}-\d{2}-\d{2}$/.test(m)) return fmtMonth(m);
const [y,mo,d] = m.split('-').map(Number);
const start = new Date(y, mo-1, d);
const end = new Date(y, mo-1, d+6);
const f = dt => `${String(dt.getDate()).padStart(2,'0')}.${String(dt.getMonth()+1).padStart(2,'0')}.`;
return `${f(start)} ${f(end)}`;
};
const PERIODS = [
{ id:'week', label:'Diese Woche' },
{ id:'month', label:'Dieser Monat' },
{ id:'year', label:'Dieses Jahr' },
{ id:'custom', label:'Eigener Zeitraum' },
{ id:'all', label:'Gesamt'},
];
// Generiere Monat-Optionen (letzten 24 Monate)
function getMonthOptions() {
const opts = [];
const now = new Date();
for (let i = 0; i < 24; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const val = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
const label = d.toLocaleDateString('de-DE', { month:'long', year:'numeric' });
opts.push({ val, label });
}
return opts;
}
// Generiere Jahr-Optionen
function getYearOptions() {
const opts = [];
const now = new Date().getFullYear();
for (let y = now; y >= now - 5; y--) opts.push(y);
return opts;
}
function periodRange(id, custom) {
const now = new Date();
const pad = n => String(n).padStart(2,'0');
const iso = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
if (id==='week') {
const d = new Date(now);
const day = d.getDay() || 7; // 1=Mo … 7=So
d.setDate(d.getDate() - (day - 1)); // zurück auf Montag
return { from:iso(d), to:iso(now) };
}
if (id==='month') { const d=new Date(now.getFullYear(),now.getMonth(),1); return { from:iso(d), to:iso(now) }; }
if (id==='year') { return { from:`${now.getFullYear()}-01-01`, to:iso(now) }; }
if (id==='custom' && custom) {
if (custom.type==='month' && custom.month) {
const [y,m] = custom.month.split('-').map(Number);
const last = new Date(y, m, 0);
return { from:`${y}-${pad(m)}-01`, to:iso(last) };
}
if (custom.type==='year' && custom.year) {
return { from:`${custom.year}-01-01`, to:`${custom.year}-12-31` };
}
}
return {};
}
function KPI({ label, value, color, sub }) {
return (
<div style={{flex:1,minWidth:130}}>
<div style={S.sub}>{label}</div>
<div style={S.val(color)}>{value}</div>
{sub && <div style={{...S.sub,marginTop:2}}>{sub}</div>}
</div>
);
}
// Bestellungen im gewählten Zeitraum
function OrdersInPeriod({ fromDate, toDate }) {
const [orders, setOrders] = useState(null);
useEffect(() => {
const q = (fromDate || toDate) ? `?from=${fromDate}&to=${toDate}` : '';
api(`/tools/statistik/month-orders${q}`).then(setOrders).catch(()=>setOrders([]));
}, [fromDate, toDate]);
const STATUS_ORDER = ['warteliste','in_arbeit','fertig','bezahlt','abgeschlossen'];
const STATUS_COLOR = { warteliste:'#ffe66d', in_arbeit:'#4ecdc4', fertig:'#6bcb77', bezahlt:'#c084fc', abgeschlossen:'#4ade80' };
const STATUS_LABEL = { warteliste:'Warteliste', in_arbeit:'In Arbeit', fertig:'Fertig', bezahlt:'Bezahlt', abgeschlossen:'Abgeschlossen' };
const periodLabel = (fromDate || toDate) ? 'IM ZEITRAUM' : 'GESAMT';
// Gruppieren nach Status
const grouped = {};
(orders||[]).forEach(o => {
const st = !!o.bezahlt && !!o.abgeholt ? 'abgeschlossen' : !!o.bezahlt ? 'bezahlt' : o.status;
if (!grouped[st]) grouped[st] = [];
grouped[st].push({ ...o, _st: st });
});
// Innerhalb jeder Gruppe nach Datum sortieren (neueste zuerst)
Object.values(grouped).forEach(g => g.sort((a,b) => new Date(b.created_at) - new Date(a.created_at)));
return (
<div style={S.card}>
<div style={S.head}>BESTELLUNGEN {periodLabel} {orders ? `(${orders.length})` : ''}</div>
{!orders
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt</div>
: !orders.length
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine Bestellungen im Zeitraum.</div>
: STATUS_ORDER.filter(st => grouped[st]?.length).map(st => {
const col = STATUS_COLOR[st];
const group = grouped[st];
const total = group.reduce((s,o)=>s+(o.revenue||0),0);
return (
<div key={st} style={{marginBottom:16}}>
{/* Status-Header mit Summe */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',
padding:'6px 10px',background:`${col}10`,borderRadius:8,marginBottom:6}}>
<div style={{display:'flex',alignItems:'center',gap:8}}>
<span style={{background:`${col}20`,border:`1px solid ${col}44`,borderRadius:10,
padding:'2px 10px',color:col,fontFamily:'monospace',fontSize:10}}>
{STATUS_LABEL[st]}
</span>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>
{group.length} Bestellung{group.length!==1?'en':''}
</span>
</div>
{total > 0 && (
<span style={{color:col,fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700}}>
Σ {fmt(total)}
</span>
)}
</div>
{/* Einträge */}
{group.map(o => (
<div key={o.id} style={{display:'flex',alignItems:'center',gap:10,padding:'7px 10px',
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0,width:60}}>
{new Date(o.created_at).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit'})}
</span>
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:12,flex:1}}>
{o.name}
</span>
<span style={{color:o.revenue>0?'#4ade80':'rgba(255,255,255,0.25)',
fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700,flexShrink:0}}>
{fmt(o.revenue||0)}
</span>
</div>
))}
</div>
);
})
}
</div>
);
}
export default function Statistik({ mobile, user, toast }) {
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState(todayLocal());
const [data, setData] = useState(null);
const [loading,setLoading] = useState(false);
const [tab, setTab] = useState('overview');
const [expForm,setExpForm] = useState({ date: todayLocal(), category:'Filament', description:'', amount:'' });
const [saving, setSaving] = useState(false);
function load() {
setLoading(true);
const q = (fromDate || toDate) ? `?from=${fromDate}&to=${toDate}` : '';
api('/tools/statistik/overview'+q)
.then(d => setData(d))
.catch(()=>{})
.finally(()=>setLoading(false));
}
useEffect(() => { load(); }, [fromDate, toDate]);
async function addExpense() {
if (!expForm.description || !expForm.amount) return;
setSaving(true);
try {
await api('/tools/statistik/expenses', { body: expForm });
setExpForm(p => ({ ...p, description:'', amount:'' }));
load();
} finally { setSaving(false); }
}
async function delExpense(id) {
await api(`/tools/statistik/expenses/${id}`, { method:'DELETE' });
load();
}
const netColor = (data?.netProfit||0) >= 0 ? '#4ade80' : '#f87171';
return (
<div style={{ padding: mobile?'14px 14px 90px':'28px 36px', maxWidth:900 }}>
{/* ── Header ── */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:20,flexWrap:'wrap',gap:10}}>
<h2 style={{color:'#fff',fontFamily:'monospace',fontSize:18,fontWeight:700,margin:0}}>
📊 3D-Druck Statistik
</h2>
{/* Zeitraum */}
<div style={{display:'flex',gap:8,alignItems:'center',flexWrap:'wrap'}}>
<div style={{display:'flex',alignItems:'center',gap:6}}>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Von</span>
<input type="date" value={fromDate} onChange={e=>setFromDate(e.target.value)}
style={{background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:8,padding:'5px 10px',color:'#fff',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}/>
</div>
<div style={{display:'flex',alignItems:'center',gap:6}}>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Bis</span>
<input type="date" value={toDate} onChange={e=>setToDate(e.target.value)}
style={{background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:8,padding:'5px 10px',color:'#fff',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}/>
</div>
{fromDate && (
<button onClick={()=>{setFromDate('');setToDate(todayLocal());}}
style={{background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8,padding:'5px 10px',color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}>
Zurücksetzen
</button>
)}
</div>
</div>
{/* ── Tabs ── */}
<div style={{display:'flex',gap:4,marginBottom:16,overflowX:'auto',overflowY:'hidden',
scrollbarWidth:'none',WebkitOverflowScrolling:'touch',paddingBottom:2}}>
{[['overview','📈 Übersicht'],['revenue','💰 Einnahmen'],['expenses','💸 Ausgaben'],['orders','📦 Bestellungen']].map(([id,label])=>(
<button key={id} onClick={()=>setTab(id)} style={{
background: tab===id ? 'rgba(255,255,255,0.08)' : 'transparent',
border: `1px solid ${tab===id ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.08)'}`,
borderRadius:8, padding:'6px 14px', flexShrink:0, whiteSpace:'nowrap',
color: tab===id ? '#fff' : 'rgba(255,255,255,0.4)',
fontFamily:'monospace', fontSize:11, cursor:'pointer',
}}>{label}</button>
))}
</div>
{loading && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt</div>}
{/* ══ ÜBERSICHT ══ */}
{tab==='overview' && data && (
<>
{/* KPI Kacheln */}
<div style={{...S.card}}>
<div style={S.head}>EINNAHMEN & GEWINN</div>
<div style={{display:'flex',flexWrap:'wrap',gap:20,marginBottom:16}}>
<KPI label="Eingenommen" value={fmt(data.totalRevenue)} color="#4ade80" sub="bezahlt"/>
<KPI label="Offen" value={fmt(data.openRevenue)} color="#ffe66d" sub="in Arbeit/Fertig"/>
<KPI label="Materialkosten" value={fmt(data.totalBaseCost)} color="#f87171" sub="Selbstkosten"/>
<KPI label="Rohgewinn" value={fmt(data.totalProfit)} color="#60a5fa" sub="nach Materialkosten"/>
</div>
<div style={{borderTop:'1px solid rgba(255,255,255,0.06)',paddingTop:16,display:'flex',flexWrap:'wrap',gap:20}}>
<KPI label="Ausgaben" value={fmt(data.totalExpenses)} color="#f87171" sub="Einkäufe"/>
<KPI label="NETTOGEWINN" value={fmt(data.netProfit)} color={netColor} sub="Rohgewinn Ausgaben"/>
<KPI label="Bestellungen" value={data.totalOrders} color="rgba(255,255,255,0.6)" sub="im Zeitraum"/>
</div>
</div>
{/* Status-Verteilung */}
<div style={{...S.card}}>
<div style={S.head}>BESTELLUNGEN NACH STATUS</div>
<div style={{display:'flex',flexWrap:'wrap',gap:8}}>
{[
['Warteliste', data.byStatus.warteliste, '#ffe66d'],
['In Arbeit', data.byStatus.in_arbeit, '#4ecdc4'],
['Fertig', data.byStatus.fertig, '#6bcb77'],
['Bezahlt', data.byStatus.bezahlt, '#c084fc'],
['Abgeschlossen', data.byStatus.abgeschlossen, '#4ade80'],
].map(([label, val, color]) => val > 0 && (
<div key={label} style={{background:`${color}12`,border:`1px solid ${color}33`,
borderRadius:8,padding:'8px 14px',textAlign:'center'}}>
<div style={{color,fontFamily:"'Space Mono',monospace",fontSize:18,fontWeight:700}}>{val}</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:9}}>{label}</div>
</div>
))}
</div>
</div>
{/* Balkendiagramm Einnahmen vs Ausgaben */}
{data.monthlyData.length > 0 && (
<div style={S.card}>
<div style={S.head}>EINNAHMEN VS. AUSGABEN {data.granularity==='day'?'(TÄGLICH)':data.granularity==='week'?'(WÖCHENTLICH)':'(MONATLICH)'}</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data.monthlyData} margin={{top:0,right:0,bottom:0,left:0}}>
<XAxis dataKey="month" tickFormatter={fmtMonth} tick={{fill:'rgba(255,255,255,0.3)',fontSize:10}} axisLine={false} tickLine={false}/>
<YAxis tick={{fill:'rgba(255,255,255,0.3)',fontSize:10}} axisLine={false} tickLine={false} tickFormatter={v=>`${v}`} width={45}/>
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11,color:'#fff'}}
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#fff'}}
formatter={(v,n)=>[`${v.toFixed(2)}`, n==='revenue'?'Einnahmen':'Ausgaben']}
labelFormatter={m => fmtMonthRange(m, data.granularity)}/>
<Bar dataKey="revenue" fill="#4ade80" radius={[4,4,0,0]} maxBarSize={28}/>
<Bar dataKey="expenses" fill="#f87171" radius={[4,4,0,0]} maxBarSize={28}/>
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Kumulierter Gewinn + Ausgaben nach Kategorie */}
<div style={{display:'flex',gap:12,flexWrap:'wrap'}}>
{data.cumulativeData.length > 1 && (
<div style={{...S.card,flex:1,minWidth:200}}>
<div style={S.head}>NETTOGEWINN IM ZEITVERLAUF</div>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={data.cumulativeData} margin={{top:5,right:5,bottom:0,left:0}}>
<XAxis dataKey="month" tickFormatter={fmtMonth} tick={{fill:'rgba(255,255,255,0.3)',fontSize:9}} axisLine={false} tickLine={false}/>
<YAxis tick={{fill:'rgba(255,255,255,0.3)',fontSize:9}} axisLine={false} tickLine={false} tickFormatter={v=>`${v}`} width={42}/>
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11,color:'#fff'}}
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#4ecdc4'}}
formatter={v=>[`${v.toFixed(2)}`, 'Gewinn kumuliert']} labelFormatter={m => fmtMonthRange(m, data.granularity)}/>
<Line type="monotone" dataKey="value" stroke="#4ecdc4" strokeWidth={2} dot={false}/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{Object.keys(data.byCategory).length > 0 && (
<div style={{...S.card,flex:1,minWidth:200}}>
<div style={S.head}>AUSGABEN NACH KATEGORIE</div>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={Object.entries(data.byCategory).map(([name,value])=>({name,value}))}
cx="50%" cy="50%" innerRadius={40} outerRadius={65}
dataKey="value" paddingAngle={3}>
{Object.entries(data.byCategory).map(([name],i)=>(
<Cell key={name} fill={CAT_COLORS[name]||'#888'}/>
))}
</Pie>
<Tooltip contentStyle={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,fontFamily:'monospace',fontSize:11,color:'#fff'}}
labelStyle={{color:'rgba(255,255,255,0.6)'}} itemStyle={{color:'#fff'}}
formatter={v=>[`${v.toFixed(2)}`]}/>
<Legend iconType="circle" iconSize={8}
formatter={v=><span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:9}}>{v}</span>}/>
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
</>
)}
{/* ══ AUSGABEN ══ */}
{tab==='expenses' && (
<>
{/* Formular */}
<div style={S.card}>
<div style={S.head}>NEUE AUSGABE</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
<input type="date" value={expForm.date}
onChange={e=>setExpForm(p=>({...p,date:e.target.value}))}
style={{...S.inp,width:140}}/>
<select value={expForm.category}
onChange={e=>setExpForm(p=>({...p,category:e.target.value}))}
style={{...S.inp,width:130}}>
{CATS.map(c=><option key={c}>{c}</option>)}
</select>
<input placeholder="Beschreibung" value={expForm.description}
onChange={e=>setExpForm(p=>({...p,description:e.target.value}))}
style={{...S.inp,flex:1,minWidth:140}}/>
<input type="number" placeholder="0.00" step="0.01" min="0" value={expForm.amount}
onChange={e=>setExpForm(p=>({...p,amount:e.target.value}))}
onKeyDown={e=>e.key==='Enter'&&addExpense()}
style={{...S.inp,width:90,textAlign:'right'}}/>
<button onClick={addExpense} disabled={saving}
style={{...S.btn('#4ade80'),opacity:saving?0.5:1,whiteSpace:'nowrap'}}>
{saving?'…':'+ Hinzufügen'}
</button>
</div>
</div>
{/* Liste */}
<div style={S.card}>
<div style={S.head}>AUSGABEN {(fromDate||toDate!==todayLocal())&&'(IM ZEITRAUM)'}</div>
{!data?.expenses?.length
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine Ausgaben im gewählten Zeitraum.</div>
: data.expenses.map(e => (
<div key={e.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0,width:80}}>
{e.date}
</span>
<span style={{background:`${CAT_COLORS[e.category]||'#888'}18`,
border:`1px solid ${CAT_COLORS[e.category]||'#888'}44`,
borderRadius:6,padding:'2px 8px',color:CAT_COLORS[e.category]||'#888',
fontFamily:'monospace',fontSize:9,flexShrink:0}}>
{e.category}
</span>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:12,flex:1}}>
{e.description}
</span>
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,flexShrink:0}}>
{fmt(e.amount)}
</span>
<button onClick={()=>delExpense(e.id)}
style={{background:'rgba(248,113,113,0.1)',border:'1px solid rgba(248,113,113,0.2)',
borderRadius:6,padding:'3px 8px',color:'#f87171',fontFamily:'monospace',fontSize:10,cursor:'pointer',flexShrink:0}}>
</button>
</div>
))
}
{data?.expenses?.length > 0 && (
<div style={{display:'flex',justifyContent:'flex-end',paddingTop:10}}>
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>
Σ {fmt(data.totalExpenses)}
</span>
</div>
)}
</div>
</>
)}
{/* ══ EINNAHMEN ══ */}
{tab==='revenue' && data && (
<div style={S.card}>
<div style={S.head}>
IM ZEITRAUM BEZAHLTE AUFTRÄGE ({data.paidOrders?.length||0}) · {fmt(data.totalRevenue)} EINGENOMMEN
</div>
{!data.paidOrders?.length
? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>
Keine bezahlten Aufträge im gewählten Zeitraum.
</div>
: data.paidOrders.map(o => (
<div key={o.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)',flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0}}>
{o.bezahlt_am ? new Date(o.bezahlt_am).toLocaleDateString('de-DE') : '—'}
</span>
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:12,flex:1}}>
{o.name}
</span>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,flexShrink:0}}>
Erstellt: {new Date(o.created_at).toLocaleDateString('de-DE')}
</span>
<div style={{display:'flex',gap:6,flexShrink:0}}>
<span style={{color:'#f87171',fontFamily:"'Space Mono',monospace",fontSize:11}}>
-{fmt(o.base_cost)}
</span>
<span style={{color:'#4ade80',fontFamily:"'Space Mono',monospace",fontSize:12,fontWeight:700}}>
{fmt(o.revenue)}
</span>
</div>
</div>
))
}
{data.paidOrders?.length > 0 && (
<div style={{display:'flex',justifyContent:'space-between',paddingTop:10,
borderTop:'1px solid rgba(255,255,255,0.07)'}}>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11}}>
Materialkosten: {fmt(data.totalBaseCost)}
</span>
<span style={{color:'#60a5fa',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
Rohgewinn: {fmt(data.totalProfit)}
</span>
</div>
)}
</div>
)}
{/* ══ BESTELLUNGEN (dieser Monat) ══ */}
{tab==='orders' && (
<OrdersInPeriod fromDate={fromDate} toDate={toDate}/>
)}
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react';
import { S } from '../lib.js';
// ── Wettrechner (Surebet / Arbitrage) ────────────────────────────────────────
// Prinzip: bei 3 sich gegenseitig ausschließenden Ergebnissen mit Quoten
// o1,o2,o3 existiert ein garantierter Gewinn, wenn 1/o1 + 1/o2 + 1/o3 < 1
// ("Arbitrage-Prozentsatz" < 100%). Der Einsatz pro Ergebnis wird proportional
// zu 1/o_i verteilt — dadurch ist die Auszahlung bei JEDEM der drei Ausgänge
// exakt gleich hoch.
function parseOdd(v) {
const n = parseFloat(String(v).replace(',', '.'));
return Number.isFinite(n) && n > 1 ? n : null;
}
function parseMoney(v) {
const n = parseFloat(String(v).replace(',', '.'));
return Number.isFinite(n) && n > 0 ? n : null;
}
const fmtEUR = n => n.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
const fmtPct = n => n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' %';
export default function Wettrechner() {
const [stake, setStake] = useState('100');
const [outcomes, setOutcomes] = useState([
{ label: '', odd: '' },
{ label: '', odd: '' },
{ label: '', odd: '' },
]);
const setOutcome = (i, field, value) => {
setOutcomes(prev => prev.map((o, idx) => idx === i ? { ...o, [field]: value } : o));
};
const budget = parseMoney(stake);
const odds = outcomes.map(o => parseOdd(o.odd));
const allOddsValid = odds.every(o => o !== null);
const canCalculate = allOddsValid && budget !== null;
let result = null;
if (canCalculate) {
const invProbs = odds.map(o => 1 / o);
const sumInv = invProbs.reduce((a, b) => a + b, 0);
const arbitragePercent = sumInv * 100;
const isProfitable = sumInv < 1;
const stakes = invProbs.map(p => budget * p / sumInv);
const payout = budget / sumInv;
const profit = payout - budget;
const profitPercent = (payout / budget - 1) * 100;
result = { arbitragePercent, isProfitable, stakes, payout, profit, profitPercent };
}
return (
<div style={{ padding: 20, maxWidth: 640 }}>
<div style={{ ...S.head, marginBottom: 4 }}>WETTRECHNER</div>
<div style={{ ...S.sub, marginBottom: 16 }}>
Trag die Quoten für 3 sich gegenseitig ausschließende Ergebnisse ein (z.B. Sieg A / Unentschieden / Sieg B).
Der Rechner sagt dir, wie viel du auf jedes Ergebnis setzen musst, damit die Auszahlung immer gleich hoch ist
egal welches Ergebnis eintritt.
</div>
<div style={S.card}>
<div style={{ ...S.sub, marginBottom: 6 }}>GESAMTEINSATZ</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<span style={{ color: 'rgba(255,255,255,0.4)', fontFamily: "'Space Mono',monospace" }}></span>
<input value={stake} onChange={e => setStake(e.target.value)} inputMode="decimal"
placeholder="100" style={{ ...S.inp, maxWidth: 160 }} />
</div>
{outcomes.map((o, i) => (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<input value={o.label} onChange={e => setOutcome(i, 'label', e.target.value)}
placeholder={`Ergebnis ${i + 1} (optional, z.B. "Sieg A")`}
style={{ ...S.inp, flex: 2, minWidth: 160 }} />
<input value={o.odd} onChange={e => setOutcome(i, 'odd', e.target.value)} inputMode="decimal"
placeholder="Quote, z.B. 2.50" style={{ ...S.inp, flex: 1, minWidth: 100 }} />
</div>
))}
</div>
{!canCalculate && (
<div style={{ ...S.sub, marginTop: 12 }}>
Bitte Gesamteinsatz und alle drei Quoten (jeweils größer als 1) eintragen.
</div>
)}
{result && (
<div style={{ ...S.card, marginTop: 16, borderColor: result.isProfitable ? 'rgba(74,222,128,0.35)' : 'rgba(248,113,113,0.35)' }}>
<div style={{
fontFamily: "'Space Mono',monospace", fontSize: 13, fontWeight: 700, marginBottom: 14,
color: result.isProfitable ? '#4ade80' : '#f87171',
}}>
{result.isProfitable
? `✓ Garantierter Gewinn möglich (Arbitrage: ${fmtPct(result.arbitragePercent)})`
: `✕ Kein garantierter Gewinn möglich (Summe der Quoten-Kehrwerte: ${fmtPct(result.arbitragePercent)}, muss unter 100 % liegen)`}
</div>
{outcomes.map((o, i) => (
<div key={i} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
padding: '8px 0', borderBottom: i < 2 ? '1px solid rgba(255,255,255,0.06)' : 'none',
}}>
<span style={{ color: 'rgba(255,255,255,0.7)', fontFamily: 'monospace', fontSize: 13 }}>
{o.label || `Ergebnis ${i + 1}`} <span style={{ color: 'rgba(255,255,255,0.35)' }}>(Quote {odds[i]})</span>
</span>
<span style={{ color: '#ffe66d', fontFamily: "'Space Mono',monospace", fontSize: 16, fontWeight: 700 }}>
{fmtEUR(result.stakes[i])}
</span>
</div>
))}
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.1)', display: 'flex', flexWrap: 'wrap', gap: 20 }}>
<div>
<div style={{ ...S.sub, marginBottom: 2 }}>Auszahlung (immer gleich)</div>
<div style={{ color: '#4ecdc4', fontFamily: "'Space Mono',monospace", fontSize: 16, fontWeight: 700 }}>
{fmtEUR(result.payout)}
</div>
</div>
<div>
<div style={{ ...S.sub, marginBottom: 2 }}>Gewinn / Verlust</div>
<div style={{
color: result.isProfitable ? '#4ade80' : '#f87171',
fontFamily: "'Space Mono',monospace", fontSize: 16, fontWeight: 700,
}}>
{result.profit >= 0 ? '+' : ''}{fmtEUR(result.profit)} ({result.profitPercent >= 0 ? '+' : ''}{fmtPct(result.profitPercent)})
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,975 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from '../lib.js';
// ── Konstanten ────────────────────────────────────────────────────────────────
const COLORS = ['#FFFFFF','#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C','#4ADE80','#F87171','#000000'];
const SIZES = [2, 4, 8, 14, 22];
const FONT_SIZES = [12, 16, 20, 28, 40, 56];
const TOOLS = [
{ id:'select', label:'↖', tip:'Auswählen / Verschieben' },
{ id:'pen', label:'✏', tip:'Stift' },
{ id:'line', label:'', tip:'Linie' },
{ id:'rect', label:'▭', tip:'Rechteck' },
{ id:'ellipse', label:'◯', tip:'Ellipse' },
{ id:'text', label:'T', tip:'Text' },
{ id:'eraser', label:'🧹', tip:'Radierer' },
];
const CURSOR_COLORS = ['#FF6B9D','#4ECDC4','#FFE66D','#60A5FA','#A78BFA','#FB923C'];
const BG = '#0D0D0F';
const getId = () => Math.random().toString(36).slice(2);
const HANDLE_R = 6; // Resize-Handle Radius in Screen-Px
// ── Bounding-Box-Berechnung ───────────────────────────────────────────────────
function getBounds(el) {
switch (el.type) {
case 'pen': case 'eraser': {
if (!el.points?.length) return null;
const xs = el.points.map(p => p.x), ys = el.points.map(p => p.y);
const pad = (el.size||4) * (el.type==='eraser'?3:1);
return { x: Math.min(...xs)-pad, y: Math.min(...ys)-pad,
w: Math.max(...xs)-Math.min(...xs)+pad*2, h: Math.max(...ys)-Math.min(...ys)+pad*2 };
}
case 'line': {
const pad = el.size||4;
return { x: Math.min(el.x1,el.x2)-pad, y: Math.min(el.y1,el.y2)-pad,
w: Math.abs(el.x2-el.x1)+pad*2, h: Math.abs(el.y2-el.y1)+pad*2 };
}
case 'rect':
return { x: Math.min(el.x,el.x+el.w), y: Math.min(el.y,el.y+el.h),
w: Math.abs(el.w), h: Math.abs(el.h) };
case 'ellipse':
return { x: el.cx-Math.abs(el.rx), y: el.cy-Math.abs(el.ry),
w: Math.abs(el.rx)*2, h: Math.abs(el.ry)*2 };
case 'text': {
const fs = el.fontSize || 20;
const w = (el.text?.length || 4) * fs * 0.6;
return { x: el.x, y: el.y - fs, w, h: fs * 1.3 };
}
default: return null;
}
}
// Punkt-in-Bounds Hit-Test
function hitTest(el, cx, cy) {
const b = getBounds(el);
if (!b) return false;
return cx >= b.x && cx <= b.x+b.w && cy >= b.y && cy <= b.y+b.h;
}
// ── Element verschieben ───────────────────────────────────────────────────────
function moveElement(el, dx, dy) {
switch (el.type) {
case 'pen': case 'eraser':
return { ...el, points: el.points.map(p => ({ x: p.x+dx, y: p.y+dy })) };
case 'line':
return { ...el, x1: el.x1+dx, y1: el.y1+dy, x2: el.x2+dx, y2: el.y2+dy };
case 'rect':
return { ...el, x: el.x+dx, y: el.y+dy };
case 'ellipse':
return { ...el, cx: el.cx+dx, cy: el.cy+dy };
case 'text':
return { ...el, x: el.x+dx, y: el.y+dy };
default: return el;
}
}
// ── Element skalieren (Resize via Ecke rechts-unten) ─────────────────────────
function resizeElement(el, nx, ny) {
const b = getBounds(el);
if (!b) return el;
const scaleX = b.w > 1 ? Math.max(0.05, (nx - b.x) / b.w) : 1;
const scaleY = b.h > 1 ? Math.max(0.05, (ny - b.y) / b.h) : 1;
switch (el.type) {
case 'pen': case 'eraser':
return { ...el, points: el.points.map(p => ({ x: b.x + (p.x-b.x)*scaleX, y: b.y + (p.y-b.y)*scaleY })) };
case 'line':
return { ...el, x1: b.x+(el.x1-b.x)*scaleX, y1: b.y+(el.y1-b.y)*scaleY,
x2: b.x+(el.x2-b.x)*scaleX, y2: b.y+(el.y2-b.y)*scaleY };
case 'rect':
return { ...el, w: el.w * scaleX, h: el.h * scaleY };
case 'ellipse':
return { ...el, rx: el.rx * scaleX, ry: el.ry * scaleY };
case 'text':
return { ...el, fontSize: Math.max(8, Math.round((el.fontSize||20) * ((scaleX+scaleY)/2))) };
default: return el;
}
}
// ── Canvas rendern ────────────────────────────────────────────────────────────
function renderElements(ctx, elements, vp, selectedId, forExport) {
const W = ctx.canvas.width, H = ctx.canvas.height;
ctx.save();
// Hintergrund
ctx.fillStyle = BG;
ctx.fillRect(0, 0, W, H);
ctx.translate(vp.x, vp.y);
ctx.scale(vp.zoom, vp.zoom);
for (const el of elements) {
ctx.save();
ctx.globalAlpha = 1;
ctx.strokeStyle = el.color || '#fff';
ctx.fillStyle = el.color || '#fff';
ctx.lineWidth = (el.size || 4) / vp.zoom;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
switch (el.type) {
case 'pen': {
if (!el.points?.length) break;
ctx.beginPath();
ctx.moveTo(el.points[0].x, el.points[0].y);
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
ctx.stroke();
break;
}
case 'eraser': {
if (!el.points?.length) break;
ctx.strokeStyle = BG;
ctx.lineWidth = (el.size||4) * 3 / vp.zoom;
ctx.beginPath();
ctx.moveTo(el.points[0].x, el.points[0].y);
for (const p of el.points.slice(1)) ctx.lineTo(p.x, p.y);
ctx.stroke();
break;
}
case 'line': {
ctx.beginPath(); ctx.moveTo(el.x1, el.y1); ctx.lineTo(el.x2, el.y2); ctx.stroke();
break;
}
case 'rect':
ctx.strokeRect(el.x, el.y, el.w, el.h);
break;
case 'ellipse':
ctx.beginPath();
ctx.ellipse(el.cx, el.cy, Math.abs(el.rx||1), Math.abs(el.ry||1), 0, 0, Math.PI*2);
ctx.stroke();
break;
case 'text': {
const fs = el.fontSize || 20;
ctx.font = `${fs}px monospace`;
ctx.fillText(el.text, el.x, el.y);
break;
}
}
ctx.restore();
// Auswahl-Rahmen + Resize-Handle
if (!forExport && selectedId === el.id) {
const b = getBounds(el);
if (b) {
ctx.save();
ctx.strokeStyle = '#4ECDC4';
ctx.lineWidth = 1.5 / vp.zoom;
ctx.setLineDash([4/vp.zoom, 3/vp.zoom]);
ctx.strokeRect(b.x-2/vp.zoom, b.y-2/vp.zoom, b.w+4/vp.zoom, b.h+4/vp.zoom);
ctx.setLineDash([]);
// Resize-Handle rechts-unten
const hx = b.x+b.w, hy = b.y+b.h, hr = HANDLE_R/vp.zoom;
ctx.fillStyle = '#4ECDC4';
ctx.beginPath(); ctx.arc(hx, hy, hr, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
}
}
ctx.restore();
}
// Offscreen-Export (ohne Auswahl-Rahmen, original Viewport)
function exportToPng(elements, title) {
if (!elements?.length) return; // Nichts zu exportieren
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
for (const el of elements) {
const b = getBounds(el); if (!b) continue;
minX=Math.min(minX,b.x); minY=Math.min(minY,b.y);
maxX=Math.max(maxX,b.x+b.w); maxY=Math.max(maxY,b.y+b.h);
}
if (!isFinite(minX)) { minX=0; minY=0; maxX=800; maxY=600; }
const pad=40, W=Math.max(800,maxX-minX+pad*2), H=Math.max(600,maxY-minY+pad*2);
const offscreen = document.createElement('canvas');
offscreen.width=W; offscreen.height=H;
const ctx = offscreen.getContext('2d');
renderElements(ctx, elements, { x: -minX+pad, y: -minY+pad, zoom:1 }, null, true);
const a = document.createElement('a');
a.href = offscreen.toDataURL('image/png');
a.download = (title||'whiteboard').replace(/[^\w\s-]/g,'').trim()+'.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// ── Share-Modal ───────────────────────────────────────────────────────────────
function ShareModal({ wbId, onClose, toast }) {
const [allUsers, setAllUsers] = useState([]);
const [perms, setPerms] = useState([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
Promise.all([api('/tools/whiteboard/users-list'), api(`/tools/whiteboard/${wbId}/data`)])
.then(([u,d]) => { setAllUsers(u.users||[]); setPerms(d.permissions||[]); }).catch(()=>{});
}, [wbId]);
const roleOf = uid => perms.find(p=>p.user_id===uid)?.role||'none';
const setRole = (uid, role) => setPerms(prev => {
const f = prev.filter(p=>p.user_id!==uid);
return role==='none' ? f : [...f,{user_id:uid,role}];
});
const save = async () => {
setSaving(true);
try {
await api(`/tools/whiteboard/${wbId}/permissions`,{ method:'PUT',
body:{ permissions: allUsers.map(u=>({ user_id:u.id, role: roleOf(u.id)==='none'?null:roleOf(u.id) })) }});
toast('Berechtigungen gespeichert ✓'); onClose();
} catch(e){ toast(e.message,'error'); } finally { setSaving(false); }
};
const mob = window.innerWidth < 768;
return (
<div onClick={e=>e.target===e.currentTarget&&onClose()} style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.75)',zIndex:2000,
display:'flex',alignItems:mob?'flex-end':'center',justifyContent:'center',padding:mob?0:20}}>
<div style={{background:'#1a1a1e',border:'1px solid rgba(255,255,255,0.12)',borderRadius:mob?'16px 16px 0 0':14,
width:'100%',maxWidth:440,display:'flex',flexDirection:'column',maxHeight:mob?'80vh':'75vh',
paddingBottom:mob?'calc(56px + env(safe-area-inset-bottom,0px))':0}}>
<div style={{padding:'18px 20px 0',flexShrink:0}}>
{mob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{...S.head,marginBottom:0}}>TEILEN</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
</div>
<div style={{overflowY:'auto',padding:'0 20px',flex:1}}>
{allUsers.length===0&&<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Keine anderen Benutzer.</div>}
{allUsers.map(u=>(
<div key={u.id} style={{display:'flex',alignItems:'center',gap:8,padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{flex:1,fontFamily:'monospace',fontSize:13,color:'#fff'}}>{u.username}</span>
{['none','view','edit'].map(r=>(
<button key={r} onClick={()=>setRole(u.id,r)} style={{...S.btn(r==='edit'?'#4ECDC4':r==='view'?'#60A5FA':'#888888',true),
fontSize:10,padding:'3px 8px',
background:roleOf(u.id)===r?(r==='edit'?'rgba(78,205,196,0.2)':r==='view'?'rgba(96,165,250,0.2)':'rgba(136,136,136,0.2)'):'transparent',
fontWeight:roleOf(u.id)===r?700:400}}>
{r==='none'?'✕ kein':r==='view'?'👁 lesen':'✏ bearbeiten'}
</button>
))}
</div>
))}
</div>
<div style={{padding:'14px 20px 20px',flexShrink:0,borderTop:'1px solid rgba(255,255,255,0.07)',display:'flex',gap:8,justifyContent:'flex-end'}}>
<button onClick={onClose} style={S.btn('#888888',true)}>Abbrechen</button>
<button onClick={save} disabled={saving} style={S.btn('#4ECDC4',true)}>{saving?'…':'Speichern'}</button>
</div>
</div>
</div>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function Whiteboard({ toast, mobile }) {
// View
const [view, setView] = useState('list');
const [boards, setBoards] = useState([]);
const [unreadIds, setUnreadIds] = useState(new Set());
const [loading, setLoading] = useState(true);
const [current, setCurrent] = useState(null);
const [creating, setCreating] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [editingId, setEditingId] = useState(null);
const [editTitle, setEditTitle] = useState('');
// Canvas
const [elements, setElements] = useState([]);
const [undoStack, setUndoStack] = useState([]);
const [tool, setTool] = useState('pen');
const [color, setColor] = useState('#FFFFFF');
const [size, setSize] = useState(1);
const [fontSize, setFontSize] = useState(2); // Index in FONT_SIZES
const [viewport, setViewport] = useState({x:0,y:0,zoom:1});
const [saving, setSaving] = useState(false);
const [shareModal,setShareModal]= useState(false);
const [textInput, setTextInput] = useState(null);
const [textPreview, setTextPreview] = useState(null); // Live-Vorschau während Tippen
const [selectedId,setSelectedId]= useState(null);
// Kollaboration
const [collab, setCollab] = useState([]);
const wsRef = useRef(null);
const cursorTimers = useRef({});
// Canvas Refs
const canvasRef = useRef(null);
const drawing = useRef(false);
const currentEl = useRef(null);
const startPos = useRef({x:0,y:0});
const isPanning = useRef(false);
const isDragging = useRef(false); // Element verschieben
const isResizing = useRef(false); // Element resize
const panStart = useRef({x:0,y:0});
const dragStart = useRef({x:0,y:0}); // Startpos für Drag
const vpRef = useRef({x:0,y:0,zoom:1});
const elementsRef = useRef([]);
const selectedRef = useRef(null);
const textPreviewRef = useRef(null);
// ── Hilfsfunktionen ──────────────────────────────────────────────────────
const syncVp = (vp) => { vpRef.current=vp; setViewport({...vp}); };
const toCanvas = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const sx = (e.clientX ?? e.touches?.[0]?.clientX ?? 0) - rect.left;
const sy = (e.clientY ?? e.touches?.[0]?.clientY ?? 0) - rect.top;
return { x:(sx-vpRef.current.x)/vpRef.current.zoom, y:(sy-vpRef.current.y)/vpRef.current.zoom, sx, sy };
};
// Ist der Pointer auf dem Resize-Handle des selektierten Elements?
const onResizeHandle = (cx, cy) => {
if (!selectedRef.current) return false;
const el = elementsRef.current.find(e=>e.id===selectedRef.current);
if (!el) return false;
const b = getBounds(el); if (!b) return false;
const hxS = (b.x+b.w)*vpRef.current.zoom + vpRef.current.x;
const hyS = (b.y+b.h)*vpRef.current.zoom + vpRef.current.y;
const rect = canvasRef.current.getBoundingClientRect();
const mxS = cx + rect.left - rect.left;
const myS = cy + rect.top - rect.top;
// cx/cy sind schon in Screen-Koordinaten (sx,sy aus toCanvas)
return Math.hypot(cx - (b.x+b.w)*vpRef.current.zoom - vpRef.current.x,
cy - (b.y+b.h)*vpRef.current.zoom - vpRef.current.y) <= HANDLE_R + 4;
};
// ── Element-Operationen ──────────────────────────────────────────────────
const broadcast = (els) => {
wsRef.current?.readyState===1 && wsRef.current.send(JSON.stringify({type:'elements',elements:els}));
};
const pushElement = (el) => {
const next = [...elementsRef.current, el];
elementsRef.current = next;
setUndoStack(prev => [...prev, elementsRef.current.slice(0,-1)]);
setElements([...next]);
broadcast(next);
};
const updateLastElement = (el) => {
const next = [...elementsRef.current.slice(0,-1), el];
elementsRef.current = next;
setElements([...next]);
};
const finalizeLastElement = (el) => {
const next = [...elementsRef.current.slice(0,-1), el];
elementsRef.current = next;
setElements([...next]);
broadcast(next);
};
const updateElement = (id, updater) => {
const next = elementsRef.current.map(e => e.id===id ? updater(e) : e);
elementsRef.current = next;
setElements([...next]);
broadcast(next);
setUndoStack(prev => [...prev, elementsRef.current]);
};
const undo = () => {
if (!undoStack.length) return;
const prev = undoStack[undoStack.length-1];
setUndoStack(s => s.slice(0,-1));
elementsRef.current = prev;
setElements([...prev]);
broadcast(prev);
setSelectedId(null); selectedRef.current=null;
};
// ── Laden / Speichern ────────────────────────────────────────────────────
const loadBoards = useCallback(() => {
setLoading(true);
Promise.all([
api('/tools/whiteboard'),
api('/tools/whiteboard/unread').catch(() => ({ count: 0 })),
]).then(([d, u]) => {
setBoards(d.whiteboards || []);
// unread count kommt global — wir markieren alle als "möglicherweise unread"
// getGameForUser gibt uns keine IDs, also nutzen wir den globalen count als Indikator
}).catch(() => toast('Fehler', 'error')).finally(() => setLoading(false));
// Einzelne unread IDs vom Backend holen
api('/tools/whiteboard/unread-ids').then(d => setUnreadIds(new Set(d.ids || []))).catch(() => {});
}, []);
const renameBoard = async (id, title) => {
if (!title?.trim()) return;
try {
await api(`/tools/whiteboard/${id}/title`,{method:'PATCH',body:{title:title.trim()}});
setBoards(prev=>prev.map(b=>b.id===id?{...b,title:title.trim()}:b));
if (current?.id===id) setCurrent(prev=>({...prev,title:title.trim()}));
} catch(e){ toast(e.message,'error'); } finally { setEditingId(null); }
};
useEffect(()=>{ loadBoards(); },[loadBoards]);
const openBoard = async (board) => {
setUnreadIds(prev => { const s = new Set(prev); s.delete(board.id); return s; });
try {
const d = await api(`/tools/whiteboard/${board.id}/data`);
const els = JSON.parse(typeof d.elements==='string'?d.elements:JSON.stringify(d.elements||[]));
const vp = JSON.parse(typeof d.viewport==='string'?d.viewport:JSON.stringify(d.viewport||{x:0,y:0,zoom:1}));
elementsRef.current=els; vpRef.current=vp;
setElements(els); setViewport(vp); setUndoStack([]);
setCurrent({id:board.id,title:d.title,role:d.role});
setView('canvas'); connectWs(board.id);
} catch(e){ toast(e.message,'error'); }
};
const closeBoard = () => {
disconnectWs(); setView('list'); setCurrent(null);
setElements([]); setUndoStack([]); setCollab([]);
setSelectedId(null); selectedRef.current=null;
loadBoards();
};
const save = async () => {
if (saving) return; setSaving(true);
try {
await api(`/tools/whiteboard/${current.id}/save`,{body:{elements:elementsRef.current,viewport:vpRef.current}});
toast('Gespeichert ✓');
} catch(e){ toast(e.message,'error'); } finally { setSaving(false); }
};
// ── WebSocket ─────────────────────────────────────────────────────────────
const connectWs = useCallback((wbId) => {
const token = localStorage.getItem('sk_token');
const proto = location.protocol==='https:'?'wss':'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws/whiteboard?token=${token}&id=${wbId}`);
wsRef.current = ws;
const cc = {}; let ci=0;
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
switch(msg.type) {
case 'user_join':
if(!cc[msg.userId]) cc[msg.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length];
setCollab(prev=>[...prev.filter(c=>c.userId!==msg.userId),{userId:msg.userId,username:msg.username,color:cc[msg.userId],x:0,y:0}]); break;
case 'user_leave':
setCollab(prev=>prev.filter(c=>c.userId!==msg.userId)); break;
case 'active_users':
msg.users.forEach(u=>{ if(!cc[u.userId]) cc[u.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length]; });
setCollab(msg.users.map(u=>({...u,color:cc[u.userId]||CURSOR_COLORS[0],x:0,y:0}))); break;
case 'cursor':
if(!cc[msg.userId]) cc[msg.userId]=CURSOR_COLORS[ci++%CURSOR_COLORS.length];
setCollab(prev=>prev.map(c=>c.userId===msg.userId?{...c,x:msg.x,y:msg.y}:c));
clearTimeout(cursorTimers.current[msg.userId]);
cursorTimers.current[msg.userId]=setTimeout(()=>setCollab(prev=>prev.map(c=>c.userId===msg.userId?{...c,x:-9999,y:-9999}:c)),3000); break;
case 'elements':
elementsRef.current=msg.elements; setElements([...msg.elements]); break;
}
};
ws.onclose=()=>setCollab([]); ws.onerror=()=>{};
},[]);
const disconnectWs = useCallback(()=>{ wsRef.current?.close(); wsRef.current=null; setCollab([]); },[]);
// ── Canvas-Rendering ──────────────────────────────────────────────────────
const render = useCallback(() => {
const canvas = canvasRef.current; if(!canvas) return;
const ctx = canvas.getContext('2d');
renderElements(ctx, elementsRef.current, vpRef.current, selectedRef.current, false);
// Cursor anderer User
for (const c of collab) {
if (c.x===-9999) continue;
const sx = c.x*vpRef.current.zoom+vpRef.current.x;
const sy = c.y*vpRef.current.zoom+vpRef.current.y;
ctx.save(); ctx.fillStyle=c.color; ctx.font='10px monospace';
ctx.fillText('▶ '+c.username, sx+6, sy-4); ctx.restore();
}
// Text-Vorschau während Tippen
if (textPreviewRef.current) {
const { text, x, y, fontSize, color } = textPreviewRef.current;
const vp = vpRef.current;
const sx = x * vp.zoom + vp.x;
const sy = y * vp.zoom + vp.y;
ctx.save();
ctx.font = `${fontSize * vp.zoom}px monospace`;
ctx.fillStyle = color;
ctx.globalAlpha = 0.85;
ctx.fillText(text || '|', sx, sy);
// Cursor-Blinken simulieren: kleiner Strich hinter dem Text
if (text) {
const tw = ctx.measureText(text).width;
ctx.fillRect(sx + tw + 1, sy - fontSize * vp.zoom * 0.8, 2, fontSize * vp.zoom);
}
ctx.restore();
}
}, [collab, textPreview]);
useEffect(()=>{ render(); }, [elements, viewport, selectedId, collab, render]);
// Canvas-Resize
useEffect(()=>{
if(view!=='canvas') return;
const resize=()=>{ const c=canvasRef.current; if(!c) return; c.width=c.offsetWidth; c.height=c.offsetHeight; render(); };
resize(); window.addEventListener('resize',resize);
return ()=>window.removeEventListener('resize',resize);
},[view, render]);
// ── Pointer-Events ────────────────────────────────────────────────────────
const onPointerDown = (e) => {
if (current?.role==='view') return;
e.preventDefault();
const { x, y, sx, sy } = toCanvas(e);
// Panning: Mittelklick oder Leerzeichen-Drag (über CSS cursor:grab bei select-tool)
if (e.button===1) {
isPanning.current=true;
panStart.current={ x: sx-vpRef.current.x, y: sy-vpRef.current.y }; return;
}
// Select-Tool
if (tool==='select') {
// Resize-Handle?
if (onResizeHandle(sx, sy)) {
isResizing.current=true;
dragStart.current={x,y}; return;
}
// Element treffen?
const hit = [...elementsRef.current].reverse().find(el=>hitTest(el,x,y));
if (hit) {
selectedRef.current=hit.id; setSelectedId(hit.id);
isDragging.current=true;
dragStart.current={x,y}; return;
}
// Leerer Klick → Auswahl aufheben
selectedRef.current=null; setSelectedId(null); return;
}
// Text
if (tool==='text') {
textPreviewRef.current = { text:'', x, y, fontSize:FONT_SIZES[fontSize], color };
render();
setTextInput({ sx, sy, x, y }); return;
}
drawing.current=true; startPos.current={x,y};
if (tool==='pen'||tool==='eraser') {
const el={id:getId(),type:tool,color,size:SIZES[size],points:[{x,y}]};
currentEl.current=el; pushElement(el);
} else if (tool==='line') {
const el={id:getId(),type:'line',color,size:SIZES[size],x1:x,y1:y,x2:x,y2:y};
currentEl.current=el; pushElement(el);
} else if (tool==='rect') {
const el={id:getId(),type:'rect',color,size:SIZES[size],x,y,w:0,h:0};
currentEl.current=el; pushElement(el);
} else if (tool==='ellipse') {
const el={id:getId(),type:'ellipse',color,size:SIZES[size],cx:x,cy:y,rx:0,ry:0};
currentEl.current=el; pushElement(el);
}
};
const onPointerMove = (e) => {
const { x, y, sx, sy } = toCanvas(e);
wsRef.current?.readyState===1 && wsRef.current.send(JSON.stringify({type:'cursor',x,y}));
if (isPanning.current) {
syncVp({ ...vpRef.current, x: sx-panStart.current.x, y: sy-panStart.current.y }); return;
}
if (isDragging.current && selectedRef.current) {
const dx=x-dragStart.current.x, dy=y-dragStart.current.y;
dragStart.current={x,y};
const next=elementsRef.current.map(el=>el.id===selectedRef.current?moveElement(el,dx,dy):el);
elementsRef.current=next; setElements([...next]); return;
}
if (isResizing.current && selectedRef.current) {
const next=elementsRef.current.map(el=>el.id===selectedRef.current?resizeElement(el,x,y):el);
elementsRef.current=next; setElements([...next]); return;
}
if (!drawing.current||!currentEl.current) return;
const el=currentEl.current;
if (tool==='pen'||tool==='eraser') {
const updated={...el,points:[...el.points,{x,y}]};
currentEl.current=updated; updateLastElement(updated);
} else if (tool==='line') {
const updated={...el,x2:x,y2:y}; currentEl.current=updated; updateLastElement(updated);
} else if (tool==='rect') {
const updated={...el,w:x-el.x,h:y-el.y}; currentEl.current=updated; updateLastElement(updated);
} else if (tool==='ellipse') {
const updated={...el,rx:x-el.cx,ry:y-el.cy}; currentEl.current=updated; updateLastElement(updated);
}
};
const onPointerUp = () => {
isPanning.current=false;
if (isDragging.current||isResizing.current) {
broadcast(elementsRef.current);
setUndoStack(prev=>[...prev, elementsRef.current]);
}
isDragging.current=false; isResizing.current=false;
if (!drawing.current) return;
drawing.current=false;
if (currentEl.current) { finalizeLastElement(currentEl.current); currentEl.current=null; }
};
const onWheel = (e) => {
e.preventDefault();
const delta=e.deltaY>0?0.9:1.1;
const rect=canvasRef.current.getBoundingClientRect();
const mx=e.clientX-rect.left, my=e.clientY-rect.top;
const nz=Math.max(0.1,Math.min(8, vpRef.current.zoom*delta));
syncVp({ x: mx-(mx-vpRef.current.x)*(nz/vpRef.current.zoom),
y: my-(my-vpRef.current.y)*(nz/vpRef.current.zoom), zoom:nz });
};
const zoom = (factor) => {
const nz=Math.max(0.1,Math.min(8,vpRef.current.zoom*factor));
const canvas=canvasRef.current; if(!canvas) return;
const mx=canvas.width/2, my=canvas.height/2;
syncVp({ x: mx-(mx-vpRef.current.x)*(nz/vpRef.current.zoom),
y: my-(my-vpRef.current.y)*(nz/vpRef.current.zoom), zoom:nz });
};
// Tastenkürzel
useEffect(()=>{
if(view!=='canvas') return;
const onKey=(e)=>{
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return;
if((e.ctrlKey||e.metaKey)&&e.key==='z'){ e.preventDefault(); undo(); }
if((e.ctrlKey||e.metaKey)&&e.key==='s'){ e.preventDefault(); save(); }
if((e.ctrlKey||e.metaKey)&&(e.key==='='||e.key==='+')){ e.preventDefault(); zoom(1.2); }
if((e.ctrlKey||e.metaKey)&&e.key==='-'){ e.preventDefault(); zoom(0.8); }
if((e.ctrlKey||e.metaKey)&&e.key==='0'){ e.preventDefault(); syncVp({x:0,y:0,zoom:1}); }
if(e.key==='Escape'){ setSelectedId(null); selectedRef.current=null; }
if((e.key==='Delete'||e.key==='Backspace')&&selectedRef.current&&e.target.tagName!=='INPUT'){
e.preventDefault();
const next=elementsRef.current.filter(el=>el.id!==selectedRef.current);
setUndoStack(prev=>[...prev,elementsRef.current]);
elementsRef.current=next; setElements([...next]); broadcast(next);
setSelectedId(null); selectedRef.current=null;
}
};
window.addEventListener('keydown',onKey);
return ()=>window.removeEventListener('keydown',onKey);
},[view, undoStack]);
// ── Cursor-Style ──────────────────────────────────────────────────────────
const getCursor = () => {
if (tool==='select') return isDragging.current?'grabbing':isResizing.current?'nwse-resize':'default';
if (tool==='eraser') return 'cell';
if (tool==='text') return 'text';
return 'crosshair';
};
// ── Render: Liste ─────────────────────────────────────────────────────────
if (view==='list') return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px'}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:24,flexWrap:'wrap'}}>
<h2 style={{margin:0,fontSize:15,fontFamily:'monospace',color:'rgba(255,255,255,0.55)',letterSpacing:2,fontWeight:400}}>WHITEBOARD</h2>
<div style={{flex:1}}/>
{creating ? (
<div style={{display:'flex',gap:6}}>
<input autoFocus value={newTitle} onChange={e=>setNewTitle(e.target.value)}
onKeyDown={async e=>{
if(e.key==='Enter'&&newTitle.trim()){
try{ const wb=await api('/tools/whiteboard',{body:{title:newTitle.trim()}}); setCreating(false);setNewTitle(''); await openBoard(wb); }
catch(err){ toast(err.message,'error'); }
}
if(e.key==='Escape'){setCreating(false);setNewTitle('');}
}}
placeholder="Name des Whiteboards" style={{...S.inp,width:mobile?160:220,fontSize:12}}/>
<button onClick={async()=>{
if(!newTitle.trim()) return;
try{ const wb=await api('/tools/whiteboard',{body:{title:newTitle.trim()}}); setCreating(false);setNewTitle(''); await openBoard(wb); }
catch(err){ toast(err.message,'error'); }
}} style={S.btn('#4ECDC4',true)}>Erstellen</button>
<button onClick={()=>{setCreating(false);setNewTitle('');}} style={S.btn('#888888',true)}></button>
</div>
) : (
<button onClick={()=>setCreating(true)} style={S.btn('#4ECDC4')}>+ Neu</button>
)}
</div>
{loading&&<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt</div>}
{!loading&&boards.length===0&&(
<div style={{...S.card,textAlign:'center',padding:40,color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:12}}>
Noch kein Whiteboard. Erstelle eines mit "+ Neu".
</div>
)}
{boards.map(b=>(
<div key={b.id} style={{...S.card,display:'flex',alignItems:'center',gap:12,marginBottom:8,cursor:'pointer'}}
onClick={()=>{ if(editingId!==b.id) openBoard(b); }}>
<div style={{fontSize:24,position:'relative',flexShrink:0}}>
🖊
{unreadIds.has(b.id) && (
<span style={{position:'absolute',top:-3,right:-3,width:8,height:8,
borderRadius:'50%',background:'#4ecdc4',boxShadow:'0 0 5px #4ecdc4'}}/>
)}
</div>
<div style={{flex:1,minWidth:0}}>
{editingId===b.id?(
<input autoFocus value={editTitle} onChange={e=>setEditTitle(e.target.value)}
onKeyDown={e=>{if(e.key==='Enter') renameBoard(b.id,editTitle); if(e.key==='Escape') setEditingId(null);}}
onBlur={()=>renameBoard(b.id,editTitle)}
onClick={e=>e.stopPropagation()}
style={{...S.inp,fontSize:12,fontWeight:700,padding:'3px 8px',width:'100%'}}/>
):(
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}
onDoubleClick={e=>{e.stopPropagation();if(b.role==='owner'||b.role==='edit'){setEditingId(b.id);setEditTitle(b.title);}}}>
{b.title}
</div>
)}
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
{b.role==='owner'?'👑 Eigenes':b.role==='edit'?'✏ Bearbeiten':'👁 Lesen'}
{b.owner_name&&b.role!=='owner'&&` · von ${b.owner_name}`}
{' · '+new Date(b.updated_at).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'})}
</div>
</div>
<div style={{display:'flex',gap:4,flexShrink:0}}>
<button onClick={e=>{e.stopPropagation();
api(`/tools/whiteboard/${b.id}/data`).then(d=>{
const els=JSON.parse(typeof d.elements==='string'?d.elements:JSON.stringify(d.elements||[]));
exportToPng(els,b.title);
}).catch(()=>toast('Export fehlgeschlagen','error'));
}} title="Als PNG exportieren"
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.25)',cursor:'pointer',fontSize:14,padding:'4px 6px'}}></button>
{(b.role==='owner'||b.role==='edit')&&(
<button onClick={e=>{e.stopPropagation();setEditingId(b.id);setEditTitle(b.title);}} title="Umbenennen"
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:14,padding:'4px 6px'}}></button>
)}
{b.role==='owner'&&(
<button onClick={e=>{e.stopPropagation();if(confirm(`"${b.title}" löschen?`))
api(`/tools/whiteboard/${b.id}`,{method:'DELETE'}).then(loadBoards).catch(err=>toast(err.message,'error'));
}} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.2)',cursor:'pointer',fontSize:16,padding:'4px 6px'}}></button>
)}
</div>
</div>
))}
</div>
);
// ── Render: Canvas ────────────────────────────────────────────────────────
const canEdit = current?.role !== 'view';
return (
<div style={{display:'flex',flexDirection:'column',height:'100%',background:BG,position:'relative'}}>
{shareModal&&current?.role==='owner'&&(
<ShareModal wbId={current.id} onClose={()=>setShareModal(false)} toast={toast}/>
)}
{textInput&&(
mobile ? (
<div style={{position:'fixed',bottom:0,left:0,right:0,zIndex:500,
background:'#1a1a1e',borderTop:'1px solid rgba(255,255,255,0.15)',
padding:'12px 16px',paddingBottom:'calc(12px + env(safe-area-inset-bottom,0px))'}}>
<div style={{fontSize:10,fontFamily:'monospace',color:'rgba(255,255,255,0.35)',marginBottom:6}}>
TEXT EINGEBEN · Enter = bestätigen · Esc = abbrechen
</div>
<div style={{display:'flex',gap:8}}>
<input autoFocus
onChange={e=>{
textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color};
render();
}}
onKeyDown={e=>{
if(e.key==='Enter'){
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}
if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); }
}}
placeholder="Text eingeben…"
style={{...S.inp,flex:1,fontSize:14,color}}/>
<button onMouseDown={e=>{
e.preventDefault();
const inp=e.currentTarget.previousSibling;
const v=inp?.value||''; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}} style={S.btn('#4ECDC4',true)}></button>
<button onMouseDown={e=>{e.preventDefault();textPreviewRef.current=null;setTextInput(null);render();}} style={S.btn('#888888',true)}></button>
</div>
</div>
) : (
<div style={{position:'absolute',left:textInput.sx,top:textInput.sy,zIndex:100,pointerEvents:'auto'}}>
<input autoFocus
onChange={e=>{
textPreviewRef.current={text:e.target.value,x:textInput.x,y:textInput.y,fontSize:FONT_SIZES[fontSize],color};
render();
}}
onKeyDown={e=>{
if(e.key==='Enter'){
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}
if(e.key==='Escape'){ textPreviewRef.current=null; setTextInput(null); render(); }
}}
onBlur={e=>{
const v=e.target.value; textPreviewRef.current=null; setTextInput(null);
if(v?.trim()) pushElement({id:getId(),type:'text',color,fontSize:FONT_SIZES[fontSize],text:v,x:textInput.x,y:textInput.y});
}}
style={{background:'rgba(0,0,0,0.7)',border:'none',
outline:`1px dashed ${color}`,borderRadius:4,padding:'2px 6px',
color,fontSize:FONT_SIZES[fontSize],fontFamily:'monospace',minWidth:120}}/>
</div>
)
)}
{/* ── Topbar ───────────────────────────────────────────────────────── */}
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
background:'rgba(0,0,0,0.6)',borderBottom:'1px solid rgba(255,255,255,0.08)',flexShrink:0,flexWrap:'wrap'}}>
<button onClick={closeBoard} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.5)',cursor:'pointer',fontSize:18,padding:'2px 6px',lineHeight:1}}></button>
{editingId===current?.id?(
<input autoFocus value={editTitle} onChange={e=>setEditTitle(e.target.value)}
onKeyDown={e=>{if(e.key==='Enter') renameBoard(current.id,editTitle); if(e.key==='Escape') setEditingId(null);}}
onBlur={()=>renameBoard(current.id,editTitle)}
style={{...S.inp,fontSize:13,fontWeight:700,flex:1,padding:'3px 8px'}}/>
):(
<span onDoubleClick={()=>{if(current?.role!=='view'){setEditingId(current.id);setEditTitle(current.title);}}}
title={current?.role!=='view'?'Doppelklick zum Umbenennen':''}
style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,flex:1,minWidth:0,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',cursor:current?.role!=='view'?'text':'default'}}>
{current?.title}
{current?.role==='view'&&<span style={{color:'rgba(255,255,255,0.3)',fontSize:10,marginLeft:8}}>👁 nur lesen</span>}
</span>
)}
{collab.length>0&&(
<div style={{display:'flex',gap:4,alignItems:'center'}}>
{collab.map(c=>(
<span key={c.userId} style={{background:c.color,color:'#000',fontFamily:'monospace',fontSize:9,borderRadius:10,padding:'2px 7px',fontWeight:700}}>
{c.username[0].toUpperCase()}
</span>
))}
</div>
)}
<button onClick={()=>exportToPng(elementsRef.current,current?.title)}
style={{...S.btn('#60A5FA',true),fontSize:11}}> PNG</button>
{canEdit&&<button onClick={save} disabled={saving} style={{...S.btn('#4ECDC4',true),fontSize:11}}>{saving?'…':'💾 Speichern'}</button>}
{current?.role==='owner'&&(
<button onClick={()=>setShareModal(true)} style={{...S.btn('#A78BFA',true),fontSize:11}}>👥 Teilen</button>
)}
</div>
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
{canEdit&&(
<div style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',
background:'rgba(0,0,0,0.45)',borderBottom:'1px solid rgba(255,255,255,0.06)',
flexShrink:0,flexWrap:'wrap'}}>
{/* Tools */}
<div style={{display:'flex',gap:3}}>
{TOOLS.map(t=>(
<button key={t.id} onClick={()=>{
setTool(t.id); setSelectedId(null); selectedRef.current=null;
textPreviewRef.current=null; setTextInput(null); render();
}} title={t.tip}
style={{background:tool===t.id?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${tool===t.id?'#4ECDC4':'rgba(255,255,255,0.1)'}`,
borderRadius:7,padding:'5px 9px',color:tool===t.id?'#4ECDC4':'rgba(255,255,255,0.7)',
cursor:'pointer',fontSize:14,lineHeight:1,fontFamily:'monospace'}}>
{t.label}
</button>
))}
</div>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Farben */}
<div style={{display:'flex',gap:3}}>
{COLORS.map(c=>(
<button key={c} onClick={()=>setColor(c)}
style={{width:18,height:18,borderRadius:'50%',background:c,
border:color===c?'2px solid #fff':'1px solid rgba(255,255,255,0.2)',cursor:'pointer',padding:0,flexShrink:0}}/>
))}
</div>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Strichstärke */}
<div style={{display:'flex',gap:3,alignItems:'center'}}>
{SIZES.map((s,i)=>(
<button key={i} onClick={()=>setSize(i)}
style={{width:18,height:18,borderRadius:'50%',
background:size===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${size===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
cursor:'pointer',padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
<div style={{width:s+2,height:s+2,borderRadius:'50%',background:size===i?'#4ECDC4':'#888'}}/>
</button>
))}
</div>
{/* Textgröße — nur bei Text-Tool */}
{tool==='text'&&<>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:9}}>TEXT</span>
<div style={{display:'flex',gap:3,alignItems:'center'}}>
{FONT_SIZES.map((fs,i)=>(
<button key={i} onClick={()=>setFontSize(i)}
style={{padding:'2px 6px',borderRadius:6,fontFamily:'monospace',fontSize:Math.min(14,fs/2)+1,
background:fontSize===i?'rgba(78,205,196,0.2)':'rgba(255,255,255,0.05)',
border:`1px solid ${fontSize===i?'#4ECDC4':'rgba(255,255,255,0.15)'}`,
color:fontSize===i?'#4ECDC4':'rgba(255,255,255,0.6)',cursor:'pointer'}}>
{fs}
</button>
))}
</div>
</>}
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Undo + Löschen */}
<button onClick={undo} disabled={!undoStack.length} title="Rückgängig (Ctrl+Z)"
style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:7,padding:'5px 9px',color:undoStack.length?'rgba(255,255,255,0.7)':'rgba(255,255,255,0.2)',
cursor:undoStack.length?'pointer':'default',fontSize:12,fontFamily:'monospace'}}></button>
{selectedId&&(
<button onClick={()=>{
const next=elementsRef.current.filter(el=>el.id!==selectedId);
setUndoStack(prev=>[...prev,elementsRef.current]);
elementsRef.current=next; setElements([...next]); broadcast(next);
setSelectedId(null); selectedRef.current=null;
}} style={{background:'rgba(255,107,157,0.08)',border:'1px solid rgba(255,107,157,0.25)',
borderRadius:7,padding:'5px 9px',color:'#ff6b9d',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
Löschen
</button>
)}
<button onClick={()=>{if(confirm('Alles löschen?')){
elementsRef.current=[];setElements([]);setUndoStack([]);broadcast([]);
setSelectedId(null);selectedRef.current=null;
}}} style={{background:'rgba(255,107,157,0.05)',border:'1px solid rgba(255,107,157,0.15)',
borderRadius:7,padding:'5px 9px',color:'rgba(255,107,157,0.5)',cursor:'pointer',fontSize:12,fontFamily:'monospace'}}>
🗑
</button>
<div style={{width:1,height:22,background:'rgba(255,255,255,0.1)'}}/>
{/* Zoom */}
<button onClick={()=>zoom(1.2)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}></button>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,minWidth:32,textAlign:'center'}}>
{Math.round(viewport.zoom*100)}%
</span>
<button onClick={()=>zoom(0.8)} style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',borderRadius:7,padding:'5px 9px',color:'rgba(255,255,255,0.7)',cursor:'pointer',fontSize:14,fontFamily:'monospace'}}></button>
<button onClick={()=>syncVp({x:0,y:0,zoom:1})}
style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.25)',cursor:'pointer',fontFamily:'monospace',fontSize:9}}>
Reset
</button>
</div>
)}
{/* ── Canvas ───────────────────────────────────────────────────────── */}
<canvas ref={canvasRef}
onPointerDown={onPointerDown} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerLeave={onPointerUp} onWheel={onWheel}
style={{flex:1,width:'100%',touchAction:'none',cursor:getCursor()}}
/>
{/* Hinweis: Select-Tool Aktionen */}
{tool==='select'&&selectedId&&!mobile&&(
<div style={{position:'absolute',bottom:12,left:'50%',transform:'translateX(-50%)',
background:'rgba(0,0,0,0.7)',border:'1px solid rgba(255,255,255,0.1)',
borderRadius:8,padding:'5px 12px',fontFamily:'monospace',fontSize:10,color:'rgba(255,255,255,0.4)',
pointerEvents:'none',whiteSpace:'nowrap'}}>
Ziehen: verschieben · Ecke () ziehen: skalieren · Entf: löschen
</div>
)}
</div>
);
}