feat: Whiteboard Dot in Liste, Favoriten eingeklappt + Modal bei Klick
This commit is contained in:
@@ -76,6 +76,13 @@ router.get('/unread', authenticate, (req, res) => {
|
|||||||
res.json({ count });
|
res.json({ count });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Unread IDs (welche Whiteboards konkret) ───────────────────────────────────
|
||||||
|
router.get('/unread-ids', authenticate, (req, res) => {
|
||||||
|
const me = uid(req);
|
||||||
|
const rows = db.prepare('SELECT whiteboard_id FROM whiteboard_unread WHERE user_id=?').all(me);
|
||||||
|
res.json({ ids: rows.map(r => r.whiteboard_id) });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Liste ─────────────────────────────────────────────────────────────────────
|
// ── Liste ─────────────────────────────────────────────────────────────────────
|
||||||
router.get('/', authenticate, (req, res) => {
|
router.get('/', authenticate, (req, res) => {
|
||||||
const me = uid(req);
|
const me = uid(req);
|
||||||
|
|||||||
@@ -678,6 +678,50 @@ function FavCard({ f, onOpenModal, onRemove }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Favoriten Kalender ────────────────────────────────────────────────────────
|
// ── Favoriten Kalender ────────────────────────────────────────────────────────
|
||||||
|
// ── Admin User Group mit einklappbaren quittierten Favoriten ─────────────────
|
||||||
|
function AdminUserGroup({ username, unackedItems, ackedItems, FavRow }) {
|
||||||
|
const [ackedOpen, setAckedOpen] = useState(false);
|
||||||
|
const total = unackedItems.length + ackedItems.length;
|
||||||
|
return (
|
||||||
|
<div style={{marginBottom:20}}>
|
||||||
|
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:8,
|
||||||
|
padding:'6px 10px',background:'rgba(255,255,255,0.04)',borderRadius:8}}>
|
||||||
|
<span style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:12,fontWeight:700}}>
|
||||||
|
👤 {username}
|
||||||
|
</span>
|
||||||
|
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10}}>
|
||||||
|
{total} Favorit{total!==1?'en':''}
|
||||||
|
</span>
|
||||||
|
{unackedItems.length > 0 && (
|
||||||
|
<span style={{background:'rgba(245,158,11,0.2)',border:'1px solid rgba(245,158,11,0.4)',
|
||||||
|
borderRadius:10,padding:'1px 7px',color:'#f59e0b',fontFamily:'monospace',fontSize:9}}>
|
||||||
|
{unackedItems.length} neu
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex',flexDirection:'column',gap:4}}>
|
||||||
|
{unackedItems.map(f => <FavRow key={f.id} f={f} />)}
|
||||||
|
</div>
|
||||||
|
{ackedItems.length > 0 && (
|
||||||
|
<div style={{marginTop:6}}>
|
||||||
|
<button onClick={() => setAckedOpen(v => !v)}
|
||||||
|
style={{background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.08)',
|
||||||
|
borderRadius:7,padding:'4px 12px',color:'rgba(255,255,255,0.35)',fontFamily:'monospace',
|
||||||
|
fontSize:10,cursor:'pointer',width:'100%',textAlign:'left',display:'flex',alignItems:'center',gap:6}}>
|
||||||
|
<span>{ackedOpen ? '▾' : '▸'}</span>
|
||||||
|
<span>{ackedItems.length} quittiert{ackedItems.length!==1?'e':''}</span>
|
||||||
|
</button>
|
||||||
|
{ackedOpen && (
|
||||||
|
<div style={{display:'flex',flexDirection:'column',gap:4,marginTop:4}}>
|
||||||
|
{ackedItems.map(f => <FavRow key={f.id} f={f} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FavoritenKalender({ onOpenModal }) {
|
function FavoritenKalender({ onOpenModal }) {
|
||||||
const [favs, setFavs] = useState([]);
|
const [favs, setFavs] = useState([]);
|
||||||
const [allFavs, setAllFavs] = useState([]);
|
const [allFavs, setAllFavs] = useState([]);
|
||||||
@@ -712,7 +756,7 @@ function FavoritenKalender({ onOpenModal }) {
|
|||||||
if (loading) return <StatusBox>⏳ Lädt…</StatusBox>;
|
if (loading) return <StatusBox>⏳ Lädt…</StatusBox>;
|
||||||
if (error) return <ErrorBox msg={error} />;
|
if (error) return <ErrorBox msg={error} />;
|
||||||
|
|
||||||
// Admin-Ansicht: alle Favoriten nach User gruppiert
|
// Admin-Ansicht: alle Favoriten nach User gruppiert, quittierte eingeklappt
|
||||||
if (isAdmin && adminTab) {
|
if (isAdmin && adminTab) {
|
||||||
const unacked = allFavs.filter(f => !f.acknowledged).length;
|
const unacked = allFavs.filter(f => !f.acknowledged).length;
|
||||||
const userGroups = {};
|
const userGroups = {};
|
||||||
@@ -720,6 +764,52 @@ function FavoritenKalender({ onOpenModal }) {
|
|||||||
if (!userGroups[f.username]) userGroups[f.username] = [];
|
if (!userGroups[f.username]) userGroups[f.username] = [];
|
||||||
userGroups[f.username].push(f);
|
userGroups[f.username].push(f);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FavRow: einzelne Favoriten-Zeile mit Modal-Klick
|
||||||
|
const FavRow = ({ f }) => (
|
||||||
|
<div key={f.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',
|
||||||
|
background:!f.acknowledged?'rgba(245,158,11,0.05)':'rgba(255,255,255,0.02)',
|
||||||
|
border:`1px solid ${!f.acknowledged?'rgba(245,158,11,0.2)':'rgba(255,255,255,0.05)'}`,
|
||||||
|
borderRadius:8, cursor:'pointer'}}
|
||||||
|
onClick={() => onOpenModal({ id: f.tmdb_id, media_type: f.media_type || 'movie' })}>
|
||||||
|
{f.poster_path
|
||||||
|
? <img src={POSTER+f.poster_path} alt={f.title} style={{width:32,height:48,objectFit:'cover',borderRadius:4,flexShrink:0}}/>
|
||||||
|
: <div style={{width:32,height:48,background:'#111',borderRadius:4,flexShrink:0,display:'flex',alignItems:'center',justifyContent:'center',fontSize:14}}>
|
||||||
|
{f.media_type==='tv'?'📺':'🎬'}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div style={{flex:1,minWidth:0}}>
|
||||||
|
<div style={{color:'rgba(255,255,255,0.85)',fontFamily:'monospace',fontSize:12,
|
||||||
|
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
|
||||||
|
{f.title}
|
||||||
|
{f.media_type==='tv' && <span style={{marginLeft:6,color:'#60a5fa',fontSize:9}}>SERIE</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
|
||||||
|
{f.added_at ? new Date(f.added_at).toLocaleDateString('de-DE') : '—'}
|
||||||
|
{!!f.acknowledged && <span style={{marginLeft:6,color:'#4ade80'}}>✓ quittiert</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!f.acknowledged ? (
|
||||||
|
<button onClick={async e=>{e.stopPropagation();
|
||||||
|
await api(`/tools/media/favorites/${f.id}/acknowledge`,{method:'POST',body:{}});
|
||||||
|
setAllFavs(prev=>prev.map(x=>x.id===f.id?{...x,acknowledged:1}:x));
|
||||||
|
}} style={{background:'rgba(245,158,11,0.1)',border:'1px solid rgba(245,158,11,0.3)',
|
||||||
|
borderRadius:6,padding:'3px 10px',color:'#f59e0b',fontFamily:'monospace',fontSize:9,cursor:'pointer',flexShrink:0}}>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={async e=>{e.stopPropagation();
|
||||||
|
await api(`/tools/media/favorites/${f.id}/unacknowledge`,{method:'POST',body:{}});
|
||||||
|
setAllFavs(prev=>prev.map(x=>x.id===f.id?{...x,acknowledged:0}:x));
|
||||||
|
}} title="Quittierung rückgängig"
|
||||||
|
style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius:6,padding:'3px 8px',color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,cursor:'pointer',flexShrink:0}}>
|
||||||
|
↩
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{display:'flex',gap:8,marginBottom:16,alignItems:'center',flexWrap:'wrap'}}>
|
<div style={{display:'flex',gap:8,marginBottom:16,alignItems:'center',flexWrap:'wrap'}}>
|
||||||
@@ -744,69 +834,13 @@ function FavoritenKalender({ onOpenModal }) {
|
|||||||
</div>
|
</div>
|
||||||
{!allFavs.length
|
{!allFavs.length
|
||||||
? <StatusBox>Noch keine Favoriten von irgendjemandem.</StatusBox>
|
? <StatusBox>Noch keine Favoriten von irgendjemandem.</StatusBox>
|
||||||
: Object.entries(userGroups).map(([username, items]) => (
|
: Object.entries(userGroups).map(([username, items]) => {
|
||||||
<div key={username} style={{marginBottom:20}}>
|
const unackedItems = items.filter(f => !f.acknowledged);
|
||||||
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:8,
|
const ackedItems = items.filter(f => f.acknowledged);
|
||||||
padding:'6px 10px',background:'rgba(255,255,255,0.04)',borderRadius:8}}>
|
return (
|
||||||
<span style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:12,fontWeight:700}}>
|
<AdminUserGroup key={username} username={username} unackedItems={unackedItems} ackedItems={ackedItems} FavRow={FavRow} />
|
||||||
👤 {username}
|
);
|
||||||
</span>
|
})
|
||||||
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10}}>
|
|
||||||
{items.length} Favorit{items.length!==1?'en':''}
|
|
||||||
</span>
|
|
||||||
{items.filter(f=>!f.acknowledged).length > 0 && (
|
|
||||||
<span style={{background:'rgba(245,158,11,0.2)',border:'1px solid rgba(245,158,11,0.4)',
|
|
||||||
borderRadius:10,padding:'1px 7px',color:'#f59e0b',fontFamily:'monospace',fontSize:9}}>
|
|
||||||
{items.filter(f=>!f.acknowledged).length} neu
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{display:'flex',flexDirection:'column',gap:4}}>
|
|
||||||
{items.map(f => (
|
|
||||||
<div key={f.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',
|
|
||||||
background:!f.acknowledged?'rgba(245,158,11,0.05)':'rgba(255,255,255,0.02)',
|
|
||||||
border:`1px solid ${!f.acknowledged?'rgba(245,158,11,0.2)':'rgba(255,255,255,0.05)'}`,
|
|
||||||
borderRadius:8}}>
|
|
||||||
{f.poster_path
|
|
||||||
? <img src={POSTER+f.poster_path} alt={f.title} style={{width:32,height:48,objectFit:'cover',borderRadius:4,flexShrink:0}}/>
|
|
||||||
: <div style={{width:32,height:48,background:'#111',borderRadius:4,flexShrink:0,display:'flex',alignItems:'center',justifyContent:'center',fontSize:14}}>
|
|
||||||
{f.media_type==='tv'?'📺':'🎬'}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div style={{flex:1,minWidth:0}}>
|
|
||||||
<div style={{color:'rgba(255,255,255,0.85)',fontFamily:'monospace',fontSize:12,
|
|
||||||
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
|
|
||||||
{f.title}
|
|
||||||
{f.media_type==='tv' && <span style={{marginLeft:6,color:'#60a5fa',fontSize:9}}>SERIE</span>}
|
|
||||||
</div>
|
|
||||||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
|
|
||||||
⭐ {f.added_at ? new Date(f.added_at).toLocaleDateString('de-DE') : '—'}
|
|
||||||
{!!f.acknowledged && <span style={{marginLeft:6}}>✓</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!f.acknowledged ? (
|
|
||||||
<button onClick={async e=>{e.stopPropagation();
|
|
||||||
await api(`/tools/media/favorites/${f.id}/acknowledge`,{method:'POST',body:{}});
|
|
||||||
setAllFavs(prev=>prev.map(x=>x.id===f.id?{...x,acknowledged:1}:x));
|
|
||||||
}} style={{background:'rgba(245,158,11,0.1)',border:'1px solid rgba(245,158,11,0.3)',
|
|
||||||
borderRadius:6,padding:'3px 10px',color:'#f59e0b',fontFamily:'monospace',fontSize:9,cursor:'pointer',flexShrink:0}}>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button onClick={async e=>{e.stopPropagation();
|
|
||||||
await api(`/tools/media/favorites/${f.id}/unacknowledge`,{method:'POST',body:{}});
|
|
||||||
setAllFavs(prev=>prev.map(x=>x.id===f.id?{...x,acknowledged:0}:x));
|
|
||||||
}} title="Quittierung rückgängig"
|
|
||||||
style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',
|
|
||||||
borderRadius:6,padding:'3px 8px',color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,cursor:'pointer',flexShrink:0}}>
|
|
||||||
↩
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export default function Whiteboard({ toast, mobile }) {
|
|||||||
// View
|
// View
|
||||||
const [view, setView] = useState('list');
|
const [view, setView] = useState('list');
|
||||||
const [boards, setBoards] = useState([]);
|
const [boards, setBoards] = useState([]);
|
||||||
|
const [unreadIds, setUnreadIds] = useState(new Set());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [current, setCurrent] = useState(null);
|
const [current, setCurrent] = useState(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -379,7 +380,16 @@ export default function Whiteboard({ toast, mobile }) {
|
|||||||
// ── Laden / Speichern ────────────────────────────────────────────────────
|
// ── Laden / Speichern ────────────────────────────────────────────────────
|
||||||
const loadBoards = useCallback(() => {
|
const loadBoards = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api('/tools/whiteboard').then(d=>setBoards(d.whiteboards||[])).catch(()=>toast('Fehler','error')).finally(()=>setLoading(false));
|
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) => {
|
const renameBoard = async (id, title) => {
|
||||||
@@ -394,6 +404,7 @@ export default function Whiteboard({ toast, mobile }) {
|
|||||||
useEffect(()=>{ loadBoards(); },[loadBoards]);
|
useEffect(()=>{ loadBoards(); },[loadBoards]);
|
||||||
|
|
||||||
const openBoard = async (board) => {
|
const openBoard = async (board) => {
|
||||||
|
setUnreadIds(prev => { const s = new Set(prev); s.delete(board.id); return s; });
|
||||||
try {
|
try {
|
||||||
const d = await api(`/tools/whiteboard/${board.id}/data`);
|
const d = await api(`/tools/whiteboard/${board.id}/data`);
|
||||||
const els = JSON.parse(typeof d.elements==='string'?d.elements:JSON.stringify(d.elements||[]));
|
const els = JSON.parse(typeof d.elements==='string'?d.elements:JSON.stringify(d.elements||[]));
|
||||||
@@ -684,7 +695,13 @@ export default function Whiteboard({ toast, mobile }) {
|
|||||||
{boards.map(b=>(
|
{boards.map(b=>(
|
||||||
<div key={b.id} style={{...S.card,display:'flex',alignItems:'center',gap:12,marginBottom:8,cursor:'pointer'}}
|
<div key={b.id} style={{...S.card,display:'flex',alignItems:'center',gap:12,marginBottom:8,cursor:'pointer'}}
|
||||||
onClick={()=>{ if(editingId!==b.id) openBoard(b); }}>
|
onClick={()=>{ if(editingId!==b.id) openBoard(b); }}>
|
||||||
<div style={{fontSize:24}}>🖊</div>
|
<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}}>
|
<div style={{flex:1,minWidth:0}}>
|
||||||
{editingId===b.id?(
|
{editingId===b.id?(
|
||||||
<input autoFocus value={editTitle} onChange={e=>setEditTitle(e.target.value)}
|
<input autoFocus value={editTitle} onChange={e=>setEditTitle(e.target.value)}
|
||||||
|
|||||||
Reference in New Issue
Block a user