1288 lines
62 KiB
JavaScript
1288 lines
62 KiB
JavaScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { S, api } from '../lib.js';
|
||
|
||
const POSTER = 'https://image.tmdb.org/t/p/w342';
|
||
const BACKDROP = 'https://image.tmdb.org/t/p/w780';
|
||
|
||
const TOOL_DEFS = [
|
||
{ id: 'kino', label: '🎬 Kino', desc: 'Aktuell im Kino (DE) – alle laufenden Filme', ready: true },
|
||
{ id: 'demnächst', label: '🗓 Demnächst', desc: 'Neustarts wochenweise – nächste Wochen', ready: true },
|
||
{ id: 'streaming', label: '📡 Streaming', desc: 'Top 10 der deutschen Streaming-Anbieter', ready: true },
|
||
{ id: 'favoriten', label: '❤ Favoriten', desc: 'Deine gespeicherten Lieblingsfilme', ready: true },
|
||
{ id: 'suchen', label: '🔍 Suchen', desc: 'Filme suchen und Infos abrufen', ready: true },
|
||
];
|
||
|
||
const getIcon = lbl => [...lbl][0] ?? '🎬';
|
||
const getText = lbl => { const c = [...lbl]; return c.slice(1).join('').trim() || lbl; };
|
||
|
||
const fmtDE = d => d ? new Date(d).toLocaleDateString('de-DE',
|
||
{ day:'2-digit', month:'2-digit', year:'numeric' }) : '';
|
||
|
||
const fmtWeek = dateStr => {
|
||
const d = new Date(dateStr);
|
||
// KW berechnen
|
||
const jan4 = new Date(d.getFullYear(), 0, 4);
|
||
const kw = Math.ceil(((d - jan4) / 86400000 + jan4.getDay() + 1) / 7);
|
||
const end = new Date(d); end.setDate(d.getDate() + 6);
|
||
return `KW ${kw} · ${d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit'})} – ${end.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'})}`;
|
||
};
|
||
|
||
const FSK_COLOR = fsk => {
|
||
if (!fsk) return null;
|
||
const n = parseInt(fsk);
|
||
if (n === 0) return { bg:'#fff', color:'#000' };
|
||
if (n === 6) return { bg:'#f9e200', color:'#000' };
|
||
if (n === 12) return { bg:'#00a859', color:'#fff' };
|
||
if (n === 16) return { bg:'#0070bb', color:'#fff' };
|
||
if (n === 18) return { bg:'#e2001a', color:'#fff' };
|
||
return { bg:'#444', color:'#fff' };
|
||
};
|
||
|
||
export default function Media({ toast, mobile, initTab, onInitTabConsumed }) {
|
||
const [activeTool, setActiveTool] = useState('kino');
|
||
const [favIds, toggleFav] = useFavIds();
|
||
|
||
// Auf Custom Event hören (z.B. Dashboard-Badge → Favoriten)
|
||
useEffect(() => {
|
||
const handler = (e) => setActiveTool(e.detail);
|
||
window.addEventListener('media-navigate', handler);
|
||
return () => window.removeEventListener('media-navigate', handler);
|
||
}, []);
|
||
|
||
// Wenn Favoriten-Tab geöffnet wird → user-notify (Badge verschwindet)
|
||
useEffect(() => {
|
||
if (activeTool === 'favoriten') {
|
||
api('/tools/media/favorites/user-notify', { method:'POST', body:{} }).catch(()=>{});
|
||
}
|
||
}, [activeTool]);
|
||
const [modal, setModal] = useState(null); // { id, media_type }
|
||
const [xrelIds, setXrelIds] = useState(new Set()); // tmdb_ids mit bekannten Releases
|
||
|
||
const chipScrollRef = useRef(null);
|
||
const [chipScroll, setChipScroll] = useState({ left: false, right: true });
|
||
|
||
const onChipScroll = () => {
|
||
const el = chipScrollRef.current;
|
||
if (!el) return;
|
||
setChipScroll({ left: el.scrollLeft > 4, right: el.scrollLeft < el.scrollWidth - el.clientWidth - 4 });
|
||
};
|
||
|
||
const active = TOOL_DEFS.find(t => t.id === activeTool);
|
||
|
||
return (
|
||
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth: 900 }}>
|
||
<div style={{ display:'flex', alignItems:'center', gap:12, marginBottom:16, flexWrap:'wrap' }}>
|
||
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize: mobile?17:22, margin:0 }}>Media</h1>
|
||
<span style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10 }}>Kino · Vorschau · Favoriten</span>
|
||
</div>
|
||
|
||
{mobile ? (
|
||
<div style={{ marginBottom:16 }}>
|
||
<div style={{ display:'flex', alignItems:'center', gap:4 }}>
|
||
{chipScroll.left && (
|
||
<button onClick={() => chipScrollRef.current?.scrollBy({ left:-160, behavior:'smooth' })}
|
||
style={{ flexShrink:0, background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)',
|
||
borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14 }}>‹</button>
|
||
)}
|
||
<div ref={chipScrollRef} onScroll={onChipScroll}
|
||
style={{ display:'flex', gap:6, overflowX:'auto', flex:1,
|
||
padding:'3px 2px 6px', scrollbarWidth:'none', WebkitOverflowScrolling:'touch' }}>
|
||
{TOOL_DEFS.map(t => (
|
||
<button key={t.id} onClick={() => setActiveTool(t.id)} style={{
|
||
display:'flex', alignItems:'center', gap:6, flexShrink:0,
|
||
padding:'7px 12px', borderRadius:20, fontFamily:'monospace', fontSize:11,
|
||
cursor:'pointer', whiteSpace:'nowrap',
|
||
background: activeTool===t.id ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.05)',
|
||
border: activeTool===t.id ? '1px solid #4ecdc4' : '1px solid rgba(255,255,255,0.1)',
|
||
color: activeTool===t.id ? '#4ecdc4' : 'rgba(255,255,255,0.75)',
|
||
fontWeight: activeTool===t.id ? 700 : 400,
|
||
}}>
|
||
<span style={{ fontSize:15 }}>{getIcon(t.label)}</span>
|
||
<span>{getText(t.label)}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
{chipScroll.right && (
|
||
<button onClick={() => chipScrollRef.current?.scrollBy({ left:160, behavior:'smooth' })}
|
||
style={{ flexShrink:0, background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)',
|
||
borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14 }}>›</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:5, marginBottom:16 }}>
|
||
{TOOL_DEFS.map(t => (
|
||
<button key={t.id} title={t.desc} onClick={() => setActiveTool(t.id)} style={{
|
||
display:'flex', alignItems:'center', gap:9,
|
||
padding:'8px 11px', borderRadius:9, fontFamily:'monospace',
|
||
cursor:'pointer', textAlign:'left', border:'none',
|
||
background: activeTool===t.id ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)',
|
||
outline: activeTool===t.id ? '1px solid #4ecdc4' : 'none',
|
||
}}>
|
||
<span style={{ fontSize:17, flexShrink:0, lineHeight:1, minWidth:22, textAlign:'center' }}>{getIcon(t.label)}</span>
|
||
<div style={{ minWidth:0, flex:1 }}>
|
||
<div style={{ color: activeTool===t.id ? '#4ecdc4' : 'rgba(255,255,255,0.85)', fontSize:12,
|
||
fontWeight: activeTool===t.id?700:500, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||
{getText(t.label)}
|
||
</div>
|
||
<div style={{ color:'rgba(255,255,255,0.28)', fontSize:9, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', marginTop:1 }}>
|
||
{t.desc}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
|
||
<div style={{ ...S.card }}>
|
||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:18, flexWrap:'wrap' }}>
|
||
<div style={{ ...S.head, marginBottom:0 }}>{active?.label}</div>
|
||
<span style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10 }}>{active?.desc}</span>
|
||
</div>
|
||
|
||
{activeTool === 'kino' && <KinoGrid onOpenModal={setModal} xrelIds={xrelIds} onXrelLoaded={ids => setXrelIds(prev => new Set([...prev, ...ids]))} favIds={favIds} onToggleFav={toggleFav}/>}
|
||
{activeTool === 'demnächst' && <DemnächstGrid onOpenModal={setModal} xrelIds={xrelIds} onXrelLoaded={ids => setXrelIds(prev => new Set([...prev, ...ids]))} favIds={favIds} onToggleFav={toggleFav}/>}
|
||
{activeTool === 'streaming' && <StreamingGrid onOpenModal={setModal} xrelIds={xrelIds} onXrelLoaded={ids => setXrelIds(prev => new Set([...prev, ...ids]))} favIds={favIds} onToggleFav={toggleFav}/>}
|
||
{activeTool === 'favoriten' && <FavoritenKalender onOpenModal={setModal} />}
|
||
{activeTool === 'suchen' && <SucheGrid onOpenModal={setModal} xrelIds={xrelIds} onXrelLoaded={ids => setXrelIds(prev => new Set([...prev, ...ids]))} favIds={favIds} onToggleFav={toggleFav}/>}
|
||
</div>
|
||
|
||
{modal && createPortal(
|
||
<MovieModal
|
||
tmdbId={modal?.id || modal}
|
||
mediaType={modal?.media_type || 'movie'}
|
||
onClose={() => setModal(null)}
|
||
mobile={mobile}
|
||
onHasXrel={id => setXrelIds(prev => new Set([...prev, id]))}
|
||
noYearFilter={activeTool === 'suchen' || activeTool === 'streaming'}
|
||
isFav={favIds.has(modal?.id || modal)}
|
||
onToggleFav={movie => toggleFav(movie, favIds.has(modal?.id || modal))}
|
||
/>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Film-Karte ────────────────────────────────────────────────────────────────
|
||
function MovieCard({ movie, favIds, hasXrel, onToggleFav, onOpenModal }) {
|
||
const isFav = favIds.has(movie.id);
|
||
const isTop = movie.rank != null && movie.rank <= 10;
|
||
const fskStyle = FSK_COLOR(movie.fsk);
|
||
|
||
return (
|
||
<div style={{
|
||
background:'rgba(255,255,255,0.03)', border: isTop ? '1px solid rgba(255,215,0,0.25)' : '1px solid rgba(255,255,255,0.08)',
|
||
borderRadius:10, overflow:'hidden', display:'flex', flexDirection:'column', cursor:'pointer',
|
||
}}>
|
||
<div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}
|
||
onClick={() => onOpenModal({ id: movie.id, media_type: movie.media_type || 'movie' })}>
|
||
{movie.poster_path
|
||
? <img src={POSTER + movie.poster_path} alt={movie.title}
|
||
style={{ width:'100%', height:'100%', objectFit:'cover', display:'block' }} />
|
||
: <div style={{ width:'100%', height:'100%', display:'flex', alignItems:'center',
|
||
justifyContent:'center', fontSize:36, color:'rgba(255,255,255,0.1)' }}>🎬</div>
|
||
}
|
||
{/* TV-Badge */}
|
||
{movie.media_type === 'tv' && (
|
||
<div style={{ position:'absolute', top:6, left:6, background:'rgba(96,165,250,0.85)',
|
||
borderRadius:5, padding:'2px 7px', fontSize:9, fontWeight:700,
|
||
fontFamily:'monospace', color:'#fff', letterSpacing:1 }}>
|
||
SERIE
|
||
</div>
|
||
)}
|
||
{/* Top-Badge */}
|
||
{isTop && (
|
||
<div style={{ position:'absolute', bottom:6, left:6, background:'rgba(0,0,0,0.85)',
|
||
borderRadius:5, padding:'2px 8px', fontSize:13, fontWeight:900,
|
||
fontFamily:"'Space Mono',monospace",
|
||
color: movie.rank === 1 ? '#ffd700' : movie.rank <= 3 ? '#c0c0c0' : '#fff' }}>
|
||
#{movie.rank}
|
||
</div>
|
||
)}
|
||
{/* Rating */}
|
||
{movie.vote_average > 0 && (
|
||
<div style={{ position:'absolute', top:6, left:6, background:'rgba(0,0,0,0.8)',
|
||
borderRadius:5, padding:'2px 6px', fontSize:10, color:'#ffe66d', fontWeight:700,
|
||
fontFamily:"'Space Mono',monospace" }}>★ {movie.vote_average.toFixed(1)}</div>
|
||
)}
|
||
{/* FSK */}
|
||
{fskStyle && (
|
||
<div style={{ position:'absolute', top:6, right:36, background:fskStyle.bg,
|
||
borderRadius:4, padding:'1px 5px', fontSize:9, color:fskStyle.color,
|
||
fontWeight:900, fontFamily:"'Space Mono',monospace", lineHeight:1.5 }}>
|
||
{movie.fsk}
|
||
</div>
|
||
)}
|
||
{/* xREL-Badge */}
|
||
{hasXrel && (
|
||
<div style={{ position:'absolute', bottom:6, right:6, background:'rgba(78,205,196,0.9)',
|
||
borderRadius:4, padding:'1px 5px', fontSize:9, color:'#000',
|
||
fontWeight:900, fontFamily:"'Space Mono',monospace", lineHeight:1.5 }}>
|
||
xREL
|
||
</div>
|
||
)}
|
||
{/* Fav */}
|
||
<button onClick={e => { e.stopPropagation(); onToggleFav(movie, isFav); }} style={{
|
||
position:'absolute', top:5, right:5, background:'rgba(0,0,0,0.65)',
|
||
border:'none', borderRadius:'50%', width:30, height:30, cursor:'pointer',
|
||
fontSize:16, display:'flex', alignItems:'center', justifyContent:'center',
|
||
color: isFav ? '#f87171' : '#ffffff',
|
||
}}>{isFav ? '♥' : '♥'}</button>
|
||
</div>
|
||
<div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}
|
||
onClick={() => onOpenModal({ id: movie.id, media_type: movie.media_type || 'movie' })}>
|
||
{isTop && (
|
||
<div style={{ fontSize:9, color:'#ffd700', fontFamily:'monospace', fontWeight:700, letterSpacing:1 }}>
|
||
★ TOP {movie.rank <= 10 ? '10' : ''}
|
||
</div>
|
||
)}
|
||
<div style={{ fontSize:12, fontWeight:700, color:'#fff', lineHeight:1.3,
|
||
fontFamily:"'Space Mono',monospace", overflow:'hidden',
|
||
display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical' }}>
|
||
{movie.title}
|
||
</div>
|
||
{movie.original_title && movie.original_title !== movie.title && (
|
||
<div style={{ fontSize:9, color:'rgba(255,255,255,0.35)', fontFamily:'monospace',
|
||
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', fontStyle:'italic' }}>
|
||
({movie.original_title})
|
||
</div>
|
||
)}
|
||
{movie.genres?.length > 0 && (
|
||
<div style={{ fontSize:9, color:'#4ecdc4', fontFamily:'monospace', overflow:'hidden',
|
||
textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||
{movie.genres.slice(0,2).join(' · ')}
|
||
</div>
|
||
)}
|
||
{movie.release_date && (
|
||
<div style={{ fontSize:9, color:'rgba(255,255,255,0.3)', fontFamily:'monospace' }}>
|
||
{fmtDE(movie.release_date)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Favoriten-Hook ────────────────────────────────────────────────────────────
|
||
function useFavIds() {
|
||
const [favIds, setFavIds] = useState(new Set());
|
||
const load = useCallback(async () => {
|
||
try { const d = await api('/tools/media/favorites'); setFavIds(new Set(d.map(f => f.tmdb_id))); }
|
||
catch (_) {}
|
||
}, []);
|
||
useEffect(() => { load(); }, []);
|
||
|
||
const toggle = async (movie, isFav) => {
|
||
if (isFav) {
|
||
await api(`/tools/media/favorites/${movie.id}`, { method:'DELETE' });
|
||
setFavIds(prev => { const s = new Set(prev); s.delete(movie.id); return s; });
|
||
} else {
|
||
await api('/tools/media/favorites', { body: {
|
||
tmdb_id: movie.id, title: movie.title,
|
||
poster_path: movie.poster_path || '',
|
||
release_date: movie.release_date || '',
|
||
release_date_de: fmtDE(movie.release_date),
|
||
genres: movie.genres || [], fsk: movie.fsk || '',
|
||
media_type: movie.media_type || 'movie',
|
||
}});
|
||
setFavIds(prev => new Set([...prev, movie.id]));
|
||
}
|
||
};
|
||
return [favIds, toggle];
|
||
}
|
||
|
||
// ── Kino Grid – alle laufenden Filme ─────────────────────────────────────────
|
||
function KinoGrid({ onOpenModal, xrelIds, onXrelLoaded }) {
|
||
const [movies, setMovies] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [favIds, toggleFav] = useFavIds();
|
||
|
||
// Filme laden + Cache-Status für Badges
|
||
useEffect(() => {
|
||
setLoading(true); setError('');
|
||
api('/tools/media/now-playing')
|
||
.then(list => {
|
||
setMovies(list);
|
||
// Badges aus Cache laden (kein xREL-Call, nur DB)
|
||
const ids = list.map(m => m.id).join(',');
|
||
if (ids) api(`/tools/media/xrel-cached?ids=${ids}`)
|
||
.then(result => {
|
||
const hits = Object.entries(result).filter(([,v])=>v).map(([k])=>Number(k));
|
||
if (hits.length) onXrelLoaded?.(hits);
|
||
}).catch(()=>{});
|
||
})
|
||
.catch(e => setError(e.message))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
// xREL-Badges: startet einmalig wenn Filme geladen, ID-String als stable dependency
|
||
|
||
const onToggle = async (movie, isFav) => {
|
||
try { await toggleFav(movie, isFav); } catch (e) { alert(e.message); }
|
||
};
|
||
|
||
if (loading) return <StatusBox>⏳ Lädt Kinocharts…</StatusBox>;
|
||
if (error) return <ErrorBox msg={error} />;
|
||
if (!movies.length) return <StatusBox>Keine Filme gefunden</StatusBox>;
|
||
|
||
const top10 = movies.filter(m => m.rank != null);
|
||
const rest = movies.filter(m => m.rank == null);
|
||
|
||
return (
|
||
<div>
|
||
{top10.length > 0 && (
|
||
<>
|
||
<div style={{ ...S.head, marginBottom:12 }}>★ TOP 10 – MEISTGESEHEN</div>
|
||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px,1fr))', gap:12, marginBottom:24 }}>
|
||
{top10.map(m => <MovieCard key={m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
|
||
</div>
|
||
</>
|
||
)}
|
||
{rest.length > 0 && (
|
||
<>
|
||
<div style={{ ...S.head, marginBottom:12 }}>WEITERE FILME IM KINO</div>
|
||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px,1fr))', gap:12 }}>
|
||
{rest.map(m => <MovieCard key={m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Demnächst – wochenweise Neustarts ────────────────────────────────────────
|
||
function DemnächstGrid({ onOpenModal, xrelIds, onXrelLoaded }) {
|
||
const [groups, setGroups] = useState({});
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [favIds, toggleFav] = useFavIds();
|
||
|
||
useEffect(() => {
|
||
setLoading(true); setError('');
|
||
api('/tools/media/upcoming')
|
||
.then(grps => {
|
||
setGroups(grps);
|
||
const ids = Object.values(grps).flat().map(m => m.id).join(',');
|
||
if (ids) api(`/tools/media/xrel-cached?ids=${ids}`)
|
||
.then(result => {
|
||
const hits = Object.entries(result).filter(([,v])=>v).map(([k])=>Number(k));
|
||
if (hits.length) onXrelLoaded?.(hits);
|
||
}).catch(()=>{});
|
||
})
|
||
.catch(e => setError(e.message))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
|
||
const onToggle = async (movie, isFav) => {
|
||
try { await toggleFav(movie, isFav); } catch (e) { alert(e.message); }
|
||
};
|
||
|
||
if (loading) return <StatusBox>⏳ Lädt Neustarts…</StatusBox>;
|
||
if (error) return <ErrorBox msg={error} />;
|
||
|
||
const entries = Object.entries(groups).sort(([a],[b]) => a.localeCompare(b));
|
||
if (!entries.length) return <StatusBox>Keine kommenden Filme gefunden</StatusBox>;
|
||
|
||
return (
|
||
<div style={{ display:'flex', flexDirection:'column', gap:28 }}>
|
||
{entries.map(([weekStart, movies]) => (
|
||
<div key={weekStart}>
|
||
<div style={{ ...S.head, marginBottom:12, display:'flex', alignItems:'center', gap:8 }}>
|
||
<span>NEUSTART {fmtWeek(weekStart)}</span>
|
||
<div style={{ flex:1, height:1, background:'rgba(255,255,255,0.07)' }}/>
|
||
<span style={{ fontSize:9 }}>{movies.length} Film{movies.length!==1?'e':''}</span>
|
||
</div>
|
||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px,1fr))', gap:12 }}>
|
||
{movies.map(m => <MovieCard key={m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Suche ────────────────────────────────────────────────────────────────────
|
||
// ── Aufklappbare Film/Serien-Sektion ─────────────────────────────────────────
|
||
function CollapsibleSection({ label, count, children, defaultOpen=true }) {
|
||
const [open, setOpen] = useState(defaultOpen);
|
||
return (
|
||
<div style={{marginBottom:16}}>
|
||
<button onClick={()=>setOpen(v=>!v)} style={{
|
||
display:'flex',alignItems:'center',gap:8,background:'none',border:'none',cursor:'pointer',
|
||
width:'100%',padding:'6px 0',marginBottom: open?10:0,
|
||
}}>
|
||
<span style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,letterSpacing:1}}>
|
||
{label} ({count})
|
||
</span>
|
||
<div style={{flex:1,height:1,background:'rgba(255,255,255,0.07)'}}/>
|
||
<span style={{color:'rgba(255,255,255,0.3)',fontSize:11,transform:open?'rotate(90deg)':'none',transition:'transform 0.2s'}}>▶</span>
|
||
</button>
|
||
{open && children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SectionedGrid({ films, serien, favIds, xrelIds, onToggle, onOpenModal }) {
|
||
return (
|
||
<div>
|
||
{films.length > 0 && (
|
||
<CollapsibleSection label="🎬 FILME" count={films.length}>
|
||
<div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(130px,1fr))',gap:12}}>
|
||
{films.map(m => <MovieCard key={'m'+m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal}/>)}
|
||
</div>
|
||
</CollapsibleSection>
|
||
)}
|
||
{serien.length > 0 && (
|
||
<CollapsibleSection label="📺 SERIEN" count={serien.length}>
|
||
<div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(130px,1fr))',gap:12}}>
|
||
{serien.map(m => <MovieCard key={'t'+m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal}/>)}
|
||
</div>
|
||
</CollapsibleSection>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StreamingGrid({ onOpenModal, xrelIds, onXrelLoaded, favIds: favIdsProp, onToggleFav: toggleFavProp }) {
|
||
const [favIdsLocal, onToggleLocal] = useFavIds();
|
||
const favIds = favIdsProp || favIdsLocal;
|
||
const onToggle = toggleFavProp || onToggleLocal;
|
||
|
||
const [providers, setProviders] = useState([]);
|
||
const [activeProvider, setActiveProvider] = useState(null);
|
||
const [data, setData] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
|
||
useEffect(() => {
|
||
api('/tools/media/streaming/providers')
|
||
.then(list => { setProviders(list); if (list.length) setActiveProvider(list[0].id); })
|
||
.catch(e => setError(e.message));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!activeProvider) return;
|
||
setLoading(true); setError('');
|
||
api(`/tools/media/streaming/${activeProvider}`)
|
||
.then(setData)
|
||
.catch(e => setError(e.message))
|
||
.finally(() => setLoading(false));
|
||
}, [activeProvider]);
|
||
|
||
return (
|
||
<div>
|
||
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,marginBottom:12}}>
|
||
Top 10 Filme & Serien pro Anbieter (DE) · nur Titel ab {new Date().getFullYear()-1} · sortiert nach Popularität
|
||
</div>
|
||
{/* Anbieter-Auswahl */}
|
||
<div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:16}}>
|
||
{providers.map(p => (
|
||
<button key={p.id} onClick={() => setActiveProvider(p.id)} style={{
|
||
background: activeProvider===p.id ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)',
|
||
border: `1px solid ${activeProvider===p.id ? 'rgba(78,205,196,0.5)' : 'rgba(255,255,255,0.1)'}`,
|
||
borderRadius:20, padding:'6px 14px',
|
||
color: activeProvider===p.id ? '#4ecdc4' : 'rgba(255,255,255,0.6)',
|
||
fontFamily:'monospace', fontSize:12, cursor:'pointer', fontWeight: activeProvider===p.id?700:400,
|
||
}}>{p.label}</button>
|
||
))}
|
||
</div>
|
||
|
||
{error && <ErrorBox msg={error} />}
|
||
{loading && <StatusBox>⏳ Lädt Top 10…</StatusBox>}
|
||
|
||
{!loading && data && (
|
||
<SectionedGrid
|
||
films={data.movies || []}
|
||
serien={data.tvShows || []}
|
||
favIds={favIds} xrelIds={xrelIds} onToggle={onToggle} onOpenModal={onOpenModal}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SucheGrid({ onOpenModal, xrelIds, onXrelLoaded }) {
|
||
const [query, setQuery] = useState('');
|
||
const [results, setResults] = useState(null); // null=noch nicht gesucht
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [favIds, toggleFav] = useFavIds();
|
||
const inputRef = useRef(null);
|
||
|
||
const doSearch = async (q = query) => {
|
||
const trimmed = q.trim();
|
||
if (!trimmed || trimmed.length < 2) return;
|
||
setLoading(true); setError(''); setResults(null);
|
||
try {
|
||
const list = await api(`/tools/media/search?q=${encodeURIComponent(trimmed)}`);
|
||
// Neueste zuerst
|
||
list.sort((a,b) => (b.release_date||'').localeCompare(a.release_date||''));
|
||
setResults(list);
|
||
// xREL-Badges aus Cache
|
||
if (list.length) {
|
||
const ids = list.map(m => m.id).join(',');
|
||
api(`/tools/media/xrel-cached?ids=${ids}`)
|
||
.then(r => {
|
||
const hits = Object.entries(r).filter(([,v])=>v).map(([k])=>Number(k));
|
||
if (hits.length) onXrelLoaded?.(hits);
|
||
}).catch(()=>{});
|
||
}
|
||
} catch(e) { setError(e.message); }
|
||
finally { setLoading(false); }
|
||
};
|
||
|
||
const onToggle = async (movie, isFav) => {
|
||
try { await toggleFav(movie, isFav); } catch(e) { alert(e.message); }
|
||
};
|
||
|
||
const onKey = e => { if (e.key === 'Enter') doSearch(); };
|
||
|
||
return (
|
||
<div>
|
||
{/* Suchfeld */}
|
||
<div style={{ display:'flex', gap:8, marginBottom:20 }}>
|
||
<input
|
||
ref={inputRef}
|
||
value={query}
|
||
onChange={e => setQuery(e.target.value)}
|
||
onKeyDown={onKey}
|
||
placeholder="Film- oder Serientitel eingeben…"
|
||
style={{
|
||
flex:1, background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.15)',
|
||
borderRadius:8, padding:'9px 14px', color:'#fff', fontFamily:"'Space Mono',monospace",
|
||
fontSize:13, outline:'none',
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => doSearch()}
|
||
disabled={loading || query.trim().length < 2}
|
||
style={{
|
||
background:'rgba(78,205,196,0.15)', border:'1px solid rgba(78,205,196,0.3)',
|
||
color:'#4ecdc4', borderRadius:8, padding:'9px 18px', cursor:'pointer',
|
||
fontFamily:"'Space Mono',monospace", fontSize:12, fontWeight:700,
|
||
opacity: loading || query.trim().length < 2 ? 0.5 : 1,
|
||
}}
|
||
>
|
||
{loading ? '⏳' : '🔍 Suchen'}
|
||
</button>
|
||
</div>
|
||
|
||
{error && <ErrorBox msg={error} />}
|
||
|
||
{results === null && !loading && (
|
||
<StatusBox>Filmtitel eingeben und Suchen drücken</StatusBox>
|
||
)}
|
||
|
||
{results !== null && results.length === 0 && (
|
||
<StatusBox>Keine Ergebnisse für „{query}"</StatusBox>
|
||
)}
|
||
{results !== null && results.length > 0 && (() => {
|
||
const films = results.filter(m => m.media_type !== 'tv');
|
||
const serien = results.filter(m => m.media_type === 'tv');
|
||
return <SectionedGrid films={films} serien={serien} favIds={favIds} xrelIds={xrelIds} onToggle={onToggle} onOpenModal={onOpenModal}/>;
|
||
})()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── FavCard – einzelner Favoriten-Eintrag ─────────────────────────────────────
|
||
function FavCard({ f, onOpenModal, onRemove }) {
|
||
const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })();
|
||
const fskStyle = FSK_COLOR(f.fsk);
|
||
const isAcked = !!f.acknowledged; // Badge bleibt bis Favorit gelöscht
|
||
return (
|
||
<div onClick={() => onOpenModal({ id: f.tmdb_id, media_type: f.media_type || 'movie' })} style={{
|
||
display:'flex', gap:12, alignItems:'flex-start',
|
||
background: isAcked ? 'rgba(74,222,128,0.05)' : 'rgba(255,255,255,0.03)',
|
||
border: `1px solid ${isAcked ? 'rgba(74,222,128,0.25)' : 'rgba(255,255,255,0.06)'}`,
|
||
borderRadius:8, padding:'10px 12px', cursor:'pointer', position:'relative',
|
||
}}>
|
||
{isAcked && (
|
||
<div style={{position:'absolute',top:6,right:44,background:'rgba(74,222,128,0.15)',
|
||
border:'1px solid rgba(74,222,128,0.35)',borderRadius:10,padding:'2px 8px',
|
||
color:'#4ade80',fontFamily:'monospace',fontSize:9,pointerEvents:'none'}}>
|
||
✓ bestätigt
|
||
</div>
|
||
)}
|
||
<div style={{ width:44, height:66, flexShrink:0, borderRadius:5, overflow:'hidden', background:'#111' }}>
|
||
{f.poster_path
|
||
? <img src={POSTER + f.poster_path} alt={f.title} style={{ width:'100%', height:'100%', objectFit:'cover', display:'block' }} />
|
||
: <div style={{ width:'100%', height:'100%', display:'flex', alignItems:'center', justifyContent:'center', fontSize:18 }}>
|
||
{f.media_type==='tv'?'📺':'🎬'}
|
||
</div>
|
||
}
|
||
</div>
|
||
<div style={{ flex:1, minWidth:0 }}>
|
||
<div style={{ fontSize:13, fontWeight:700, color:'#fff', fontFamily:"'Space Mono',monospace",
|
||
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.title}</div>
|
||
<div style={{ fontSize:10, color:'rgba(255,255,255,0.4)', marginTop:3, fontFamily:'monospace' }}>
|
||
🇩🇪 {f.release_date_de || '–'}
|
||
</div>
|
||
{genres.length > 0 && (
|
||
<div style={{ fontSize:10, color:'#4ecdc4', marginTop:2, fontFamily:'monospace' }}>
|
||
{genres.slice(0,3).join(' · ')}
|
||
</div>
|
||
)}
|
||
{f.fsk && (
|
||
<span style={{ ...fskStyle, fontSize:9, padding:'1px 5px', borderRadius:3, marginTop:4, display:'inline-block' }}>
|
||
FSK {f.fsk}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<button onClick={e=>{ e.stopPropagation(); onRemove(f.tmdb_id); }} style={{
|
||
background:'rgba(248,113,113,0.1)', border:'1px solid rgba(248,113,113,0.25)',
|
||
borderRadius:5, padding:'3px 8px', color:'#f87171', fontFamily:'monospace',
|
||
fontSize:10, cursor:'pointer', flexShrink:0,
|
||
}}>✕</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Favoriten Kalender ────────────────────────────────────────────────────────
|
||
function FavoritenKalender({ onOpenModal }) {
|
||
const [favs, setFavs] = useState([]);
|
||
const [allFavs, setAllFavs] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [adminTab, setAdminTab]= useState(false);
|
||
const [isAdmin, setIsAdmin] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setLoading(true); setError('');
|
||
Promise.all([
|
||
api('/tools/media/favorites'),
|
||
api('/tools/media/favorites/all').catch(() => null),
|
||
]).then(([mine, all]) => {
|
||
setFavs(mine);
|
||
if (all) { setAllFavs(all); setIsAdmin(true); }
|
||
}).catch(e => setError(e.message)).finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
const ackAll = async () => {
|
||
await api('/tools/media/favorites/acknowledge-all', { method:'POST', body:{} });
|
||
setAllFavs(prev => prev.map(f => ({ ...f, acknowledged: 1 })));
|
||
};
|
||
|
||
const removeFav = async (tmdbId) => {
|
||
try {
|
||
await api(`/tools/media/favorites/${tmdbId}`, { method:'DELETE' });
|
||
setFavs(prev => prev.filter(f => f.tmdb_id !== tmdbId));
|
||
} catch (e) { alert(e.message); }
|
||
};
|
||
|
||
if (loading) return <StatusBox>⏳ Lädt…</StatusBox>;
|
||
if (error) return <ErrorBox msg={error} />;
|
||
|
||
// Admin-Ansicht: alle Favoriten nach User gruppiert
|
||
if (isAdmin && adminTab) {
|
||
const unacked = allFavs.filter(f => !f.acknowledged).length;
|
||
const userGroups = {};
|
||
allFavs.forEach(f => {
|
||
if (!userGroups[f.username]) userGroups[f.username] = [];
|
||
userGroups[f.username].push(f);
|
||
});
|
||
return (
|
||
<div>
|
||
<div style={{display:'flex',gap:8,marginBottom:16,alignItems:'center',flexWrap:'wrap'}}>
|
||
<button onClick={()=>setAdminTab(false)}
|
||
style={{background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',
|
||
borderRadius:7,padding:'5px 12px',color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}>
|
||
← Meine Favoriten
|
||
</button>
|
||
{unacked > 0 && (
|
||
<>
|
||
<span style={{background:'rgba(245,158,11,0.12)',border:'1px solid rgba(245,158,11,0.35)',
|
||
borderRadius:7,padding:'5px 12px',color:'#f59e0b',fontFamily:'monospace',fontSize:11}}>
|
||
⚠ {unacked} unquittiert
|
||
</span>
|
||
<button onClick={ackAll}
|
||
style={{background:'rgba(74,222,128,0.12)',border:'1px solid rgba(74,222,128,0.35)',
|
||
borderRadius:7,padding:'5px 12px',color:'#4ade80',fontFamily:'monospace',fontSize:11,cursor:'pointer'}}>
|
||
✓ Alle quittieren
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
{!allFavs.length
|
||
? <StatusBox>Noch keine Favoriten von irgendjemandem.</StatusBox>
|
||
: Object.entries(userGroups).map(([username, items]) => (
|
||
<div key={username} 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}}>
|
||
{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>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))
|
||
}
|
||
</div>
|
||
);
|
||
}
|
||
if (!favs.length) return (
|
||
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||
{isAdmin && allFavs.filter(f=>!f.acknowledged).length > 0 && (
|
||
<div style={{background:'rgba(245,158,11,0.1)',border:'1px solid rgba(245,158,11,0.4)',
|
||
borderRadius:8,padding:'8px 12px',color:'#f59e0b',fontFamily:'monospace',fontSize:11,
|
||
display:'flex',alignItems:'center',gap:8}}>
|
||
⚠ {allFavs.filter(f=>!f.acknowledged).length} unquittierte Favoriten
|
||
<button onClick={()=>setAdminTab(true)}
|
||
style={{background:'rgba(245,158,11,0.2)',border:'1px solid rgba(245,158,11,0.4)',
|
||
borderRadius:6,padding:'2px 10px',color:'#f59e0b',fontFamily:'monospace',fontSize:10,cursor:'pointer'}}>
|
||
→ Ansehen
|
||
</button>
|
||
</div>
|
||
)}
|
||
{isAdmin && (
|
||
<button onClick={()=>setAdminTab(true)}
|
||
style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
|
||
borderRadius:7,padding:'5px 12px',color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,cursor:'pointer',width:'fit-content'}}>
|
||
👥 Alle Favoriten ({allFavs.length})
|
||
</button>
|
||
)}
|
||
<StatusBox>Noch keine eigenen Favoriten – markiere Filme mit ♥</StatusBox>
|
||
</div>
|
||
);
|
||
|
||
// Aufteilen in Filme und Serien, dann nach Datum gruppieren
|
||
const filmeFavs = favs.filter(f => f.media_type !== 'tv');
|
||
const serienFavs = favs.filter(f => f.media_type === 'tv');
|
||
|
||
function buildGroups(list) {
|
||
const g = {};
|
||
for (const f of list) {
|
||
const d = f.release_date ? new Date(f.release_date) : null;
|
||
const key = d ? `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}` : 'unbekannt';
|
||
const label = d ? d.toLocaleDateString('de-DE', { month:'long', year:'numeric' }) : 'Unbekannt';
|
||
if (!g[key]) g[key] = { label, items:[] };
|
||
g[key].items.push(f);
|
||
}
|
||
return g;
|
||
}
|
||
const filmGroups = buildGroups(filmeFavs);
|
||
const serienGroups = buildGroups(serienFavs);
|
||
|
||
return (
|
||
<div style={{ display:'flex', flexDirection:'column', gap:24 }}>
|
||
{isAdmin && (
|
||
<div style={{display:'flex',flexDirection:'column',gap:6,marginBottom:4}}>
|
||
{allFavs.filter(f=>!f.acknowledged).length > 0 && (
|
||
<div style={{background:'rgba(245,158,11,0.1)',border:'1px solid rgba(245,158,11,0.4)',
|
||
borderRadius:8,padding:'8px 12px',color:'#f59e0b',fontFamily:'monospace',fontSize:11,
|
||
display:'flex',alignItems:'center',gap:8}}>
|
||
⚠ {allFavs.filter(f=>!f.acknowledged).length} unquittierte Favoriten
|
||
<button onClick={()=>setAdminTab(true)}
|
||
style={{background:'rgba(245,158,11,0.2)',border:'1px solid rgba(245,158,11,0.4)',
|
||
borderRadius:6,padding:'2px 10px',color:'#f59e0b',fontFamily:'monospace',fontSize:10,cursor:'pointer'}}>
|
||
→ Ansehen
|
||
</button>
|
||
</div>
|
||
)}
|
||
<button onClick={()=>setAdminTab(true)}
|
||
style={{background:'rgba(255,255,255,0.05)',border:'1px solid rgba(255,255,255,0.1)',
|
||
borderRadius:7,padding:'5px 12px',color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,cursor:'pointer',
|
||
display:'flex',alignItems:'center',gap:6,width:'fit-content'}}>
|
||
👥 Alle Favoriten ({allFavs.length})
|
||
</button>
|
||
</div>
|
||
)}
|
||
{isAdmin && allFavs.filter(f=>!f.acknowledged).length > 0 && (
|
||
<div style={{background:'rgba(245,158,11,0.1)',border:'1px solid rgba(245,158,11,0.4)',
|
||
borderRadius:8,padding:'8px 12px',color:'#f59e0b',fontFamily:'monospace',fontSize:11,
|
||
display:'flex',alignItems:'center',gap:8,marginBottom:4}}>
|
||
⚠ {allFavs.filter(f=>!f.acknowledged).length} unquittierte Favoriten
|
||
<button onClick={()=>setAdminTab(true)}
|
||
style={{background:'rgba(245,158,11,0.2)',border:'1px solid rgba(245,158,11,0.4)',
|
||
borderRadius:6,padding:'2px 10px',color:'#f59e0b',fontFamily:'monospace',fontSize:10,cursor:'pointer'}}>
|
||
→ Ansehen
|
||
</button>
|
||
</div>
|
||
)}
|
||
{filmeFavs.length > 0 && (
|
||
<CollapsibleSection label="🎬 FILME" count={filmeFavs.length}>
|
||
{Object.entries(filmGroups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
|
||
<div key={'film-'+key} style={{marginBottom:12}}>
|
||
<div style={{...S.head,marginBottom:6,display:'flex',alignItems:'center',gap:6}}>
|
||
<span>{group.label.toUpperCase()}</span>
|
||
<div style={{flex:1,height:1,background:'rgba(255,255,255,0.07)'}}/>
|
||
<span style={{fontSize:9}}>{group.items.length}×</span>
|
||
</div>
|
||
<div style={{display:'flex',flexDirection:'column',gap:6}}>
|
||
{group.items.map(f => <FavCard key={f.tmdb_id} f={f} onOpenModal={onOpenModal} onRemove={removeFav}/>)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CollapsibleSection>
|
||
)}
|
||
{serienFavs.length > 0 && (
|
||
<CollapsibleSection label="📺 SERIEN" count={serienFavs.length}>
|
||
{Object.entries(serienGroups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
|
||
<div key={'serie-'+key} style={{marginBottom:12}}>
|
||
<div style={{...S.head,marginBottom:6,display:'flex',alignItems:'center',gap:6}}>
|
||
<span>{group.label.toUpperCase()}</span>
|
||
<div style={{flex:1,height:1,background:'rgba(255,255,255,0.07)'}}/>
|
||
<span style={{fontSize:9}}>{group.items.length}×</span>
|
||
</div>
|
||
<div style={{display:'flex',flexDirection:'column',gap:6}}>
|
||
{group.items.map(f => <FavCard key={f.tmdb_id} f={f} onOpenModal={onOpenModal} onRemove={removeFav}/>)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CollapsibleSection>
|
||
)}
|
||
{false && Object.entries({}).map(([key, group]) => (
|
||
<div key={key}>
|
||
<div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}>
|
||
<span>{group.label.toUpperCase()}</span>
|
||
<div style={{ flex:1, height:1, background:'rgba(255,255,255,0.07)' }}/>
|
||
<span style={{ fontSize:9 }}>{group.items.length} Film{group.items.length!==1?'e':''}</span>
|
||
</div>
|
||
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
|
||
{group.items.map(f => {
|
||
const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })();
|
||
const fskStyle = FSK_COLOR(f.fsk);
|
||
return (
|
||
<div key={f.tmdb_id} onClick={() => onOpenModal({ id: f.tmdb_id, media_type: f.media_type || 'movie' })} style={{
|
||
display:'flex', gap:12, alignItems:'flex-start',
|
||
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.06)',
|
||
borderRadius:8, padding:'10px 12px', cursor:'pointer',
|
||
}}>
|
||
<div style={{ width:44, height:66, flexShrink:0, borderRadius:5, overflow:'hidden', background:'#111' }}>
|
||
{f.poster_path
|
||
? <img src={POSTER + f.poster_path} alt={f.title} style={{ width:'100%', height:'100%', objectFit:'cover', display:'block' }} />
|
||
: <div style={{ width:'100%', height:'100%', display:'flex', alignItems:'center', justifyContent:'center', fontSize:18 }}>🎬</div>
|
||
}
|
||
</div>
|
||
<div style={{ flex:1, minWidth:0 }}>
|
||
<div style={{ fontSize:13, fontWeight:700, color:'#fff', fontFamily:"'Space Mono',monospace",
|
||
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.title}</div>
|
||
<div style={{ fontSize:10, color:'rgba(255,255,255,0.4)', marginTop:3, fontFamily:'monospace' }}>
|
||
🇩🇪 {f.release_date_de || '–'}
|
||
</div>
|
||
{genres.length > 0 && (
|
||
<div style={{ fontSize:10, color:'#4ecdc4', marginTop:2, fontFamily:'monospace' }}>
|
||
{genres.slice(0,3).join(' · ')}
|
||
</div>
|
||
)}
|
||
{fskStyle && (
|
||
<div style={{ display:'inline-block', marginTop:4, background:fskStyle.bg,
|
||
borderRadius:3, padding:'1px 5px', fontSize:9, color:fskStyle.color,
|
||
fontWeight:900, fontFamily:'monospace' }}>FSK {f.fsk}</div>
|
||
)}
|
||
</div>
|
||
<button onClick={e => { e.stopPropagation(); removeFav(f.tmdb_id); }} style={{
|
||
background:'none', border:'none', color:'rgba(255,107,157,0.6)',
|
||
cursor:'pointer', fontSize:16, flexShrink:0, padding:'0 2px', alignSelf:'center',
|
||
}}>✕</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Film-Detail-Modal (via Portal) ────────────────────────────────────────────
|
||
function MovieModal({ tmdbId, mediaType='movie', onClose, mobile, onHasXrel, noYearFilter, isFav=false, onToggleFav }) {
|
||
const [detail, setDetail] = useState(null);
|
||
const [xrel, setXrel] = useState(null); // null=nicht gesucht, []=gesucht
|
||
const [xrelDates, setXrelDates] = useState([]);
|
||
const [xrelLoading, setXrelLoading] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
|
||
// detail als Ref damit searchXrel immer den aktuellen Wert hat
|
||
const detailRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
setLoading(true); setDetail(null); setXrel(null); setXrelDates([]); setError('');
|
||
detailRef.current = null;
|
||
api(`/tools/media/${mediaType === 'tv' ? 'tv' : 'movie'}/${tmdbId}`)
|
||
.then(d => {
|
||
setDetail(d);
|
||
detailRef.current = d;
|
||
setLoading(false);
|
||
// Gecachte Releases laden (nur für Filme)
|
||
const xrelParams = new URLSearchParams({
|
||
title: d.title || '',
|
||
orig: d.original_title || '',
|
||
imdb_id: d.imdb_id || '',
|
||
year: noYearFilter ? '0' : (d.release_date ? d.release_date.slice(0,4) : ''),
|
||
media_type: mediaType || 'movie',
|
||
});
|
||
api(`/tools/media/movie/${tmdbId}/xrel?${xrelParams}`)
|
||
.then(r => {
|
||
if (r.xrel_dates?.length > 0) setXrelDates(r.xrel_dates);
|
||
if (r.xrel?.length > 0) { setXrel(r.xrel); onHasXrel?.(tmdbId); }
|
||
})
|
||
.catch(() => {});
|
||
})
|
||
.catch(e => { setError(e.message); setLoading(false); });
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
return () => { document.body.style.overflow = prev; };
|
||
}, [tmdbId, mediaType]);
|
||
|
||
const searchXrel = async (forceRefresh = false) => {
|
||
const d = detailRef.current;
|
||
if (!d) return;
|
||
setXrelLoading(true);
|
||
try {
|
||
const params = new URLSearchParams({
|
||
title: d.title || '',
|
||
orig: d.original_title || '',
|
||
imdb_id: d.imdb_id || '',
|
||
year: noYearFilter ? '0' : (d.release_date ? d.release_date.slice(0,4) : ''),
|
||
media_type: mediaType || 'movie',
|
||
});
|
||
if (forceRefresh || noYearFilter) params.set('refresh', '1');
|
||
const r = await api(`/tools/media/movie/${tmdbId}/xrel?${params}`);
|
||
// Nur überschreiben wenn neue Daten gefunden – sonst alte behalten
|
||
if ((r.xrel || []).length > 0) {
|
||
setXrel(r.xrel);
|
||
onHasXrel?.(tmdbId);
|
||
} else if (!forceRefresh) {
|
||
// Erster Load ohne Ergebnis → explizit leer setzen
|
||
setXrel([]);
|
||
}
|
||
// Sonst: forceRefresh ohne Ergebnis → alte Liste bleibt stehen
|
||
if (r.xrel_dates?.length > 0) setXrelDates(r.xrel_dates);
|
||
} catch(_) { setXrel([]); }
|
||
finally { setXrelLoading(false); }
|
||
};
|
||
|
||
// ESC schließt
|
||
useEffect(() => {
|
||
const h = e => { if (e.key === 'Escape') onClose(); };
|
||
document.addEventListener('keydown', h);
|
||
return () => document.removeEventListener('keydown', h);
|
||
}, [onClose]);
|
||
|
||
const fskStyle = detail ? FSK_COLOR(detail.fsk) : null;
|
||
|
||
return (
|
||
<div onClick={onClose} style={{
|
||
position:'fixed', inset:0, background:'rgba(0,0,0,0.8)', zIndex:9000,
|
||
display:'flex', alignItems: mobile ? 'flex-end' : 'center', justifyContent:'center',
|
||
backdropFilter:'blur(4px)',
|
||
}}>
|
||
<div onClick={e => e.stopPropagation()} style={{
|
||
background:'#1a1a1f', border:'1px solid rgba(255,255,255,0.12)',
|
||
borderRadius: mobile ? '16px 16px 0 0' : 16,
|
||
width: mobile ? '100%' : 560,
|
||
maxHeight: mobile ? '88vh' : '85vh',
|
||
display:'flex', flexDirection:'column',
|
||
position:'relative',
|
||
}}>
|
||
{/* Backdrop + fixer Schließen-Button – NICHT im scrollenden Bereich */}
|
||
<div style={{ position:'relative', flexShrink:0,
|
||
borderRadius: mobile ? '16px 16px 0 0' : '16px 16px 0 0',
|
||
overflow:'hidden',
|
||
height: detail?.backdrop_path ? 160 : 0,
|
||
background:'#111',
|
||
}}>
|
||
{detail?.backdrop_path && (
|
||
<img src={BACKDROP + detail.backdrop_path} alt=""
|
||
style={{ width:'100%', height:'100%', objectFit:'cover', display:'block', opacity:0.45 }} />
|
||
)}
|
||
{detail?.backdrop_path && (
|
||
<div style={{ position:'absolute', inset:0,
|
||
background:'linear-gradient(to bottom, transparent 30%, #1a1a1f)' }} />
|
||
)}
|
||
</div>
|
||
|
||
{/* Schließen + Favorit – sticky außerhalb des Scroll-Containers */}
|
||
<button onClick={onClose} style={{
|
||
position:'absolute', top:12, right:12,
|
||
background:'rgba(0,0,0,0.75)', border:'none', borderRadius:'50%',
|
||
width:34, height:34, cursor:'pointer', zIndex:10,
|
||
color:'#fff', fontSize:18, display:'flex', alignItems:'center', justifyContent:'center',
|
||
}}>✕</button>
|
||
{onToggleFav && detail && (
|
||
<button onClick={() => onToggleFav(detail)} style={{
|
||
position:'absolute', top:12, right:54,
|
||
background:'rgba(0,0,0,0.75)', border:'none', borderRadius:'50%',
|
||
width:34, height:34, cursor:'pointer', zIndex:10,
|
||
color: isFav ? '#f87171' : '#ffffff',
|
||
fontSize:18, display:'flex', alignItems:'center', justifyContent:'center',
|
||
}}>♥</button>
|
||
)}
|
||
|
||
{/* Scrollbarer Inhalt */}
|
||
<div style={{ overflowY:'auto', WebkitOverflowScrolling:'touch', flex:1 }}>
|
||
<div style={{ padding:'16px 20px 28px' }}>
|
||
{loading && <StatusBox>⏳ Lädt…</StatusBox>}
|
||
{error && <ErrorBox msg={error} />}
|
||
{detail && (<>
|
||
<div style={{ display:'flex', gap:16, alignItems:'flex-start' }}>
|
||
{/* Poster */}
|
||
<div style={{ width:88, flexShrink:0, borderRadius:8, overflow:'hidden', background:'#111' }}>
|
||
{detail.poster_path
|
||
? <img src={POSTER + detail.poster_path} alt={detail.title} style={{ width:'100%', display:'block' }} />
|
||
: <div style={{ height:132, display:'flex', alignItems:'center', justifyContent:'center', fontSize:28 }}>🎬</div>
|
||
}
|
||
</div>
|
||
{/* Info */}
|
||
<div style={{ flex:1, minWidth:0 }}>
|
||
<div style={{ fontSize:15, fontWeight:900, color:'#fff', lineHeight:1.3,
|
||
fontFamily:"'Space Mono',monospace", marginBottom:4 }}>{detail.title}</div>
|
||
{detail.tagline && (
|
||
<div style={{ fontSize:11, color:'rgba(255,255,255,0.4)', fontStyle:'italic', marginBottom:8 }}>{detail.tagline}</div>
|
||
)}
|
||
<div style={{ display:'flex', gap:5, flexWrap:'wrap', marginBottom:8 }}>
|
||
{detail.vote_average > 0 && <Badge bg="rgba(255,230,109,0.15)" color="#ffe66d">★ {detail.vote_average.toFixed(1)}</Badge>}
|
||
{detail.media_type !== 'tv' && detail.runtime > 0 && <Badge bg="rgba(255,255,255,0.07)" color="rgba(255,255,255,0.7)">⏱ {detail.runtime} Min</Badge>}
|
||
{detail.media_type === 'tv' && detail.number_of_seasons > 0 && <Badge bg="rgba(96,165,250,0.15)" color="#60a5fa">📺 {detail.number_of_seasons} Staffel{detail.number_of_seasons!==1?'n':''}</Badge>}
|
||
{detail.media_type === 'tv' && detail.number_of_episodes > 0 && <Badge bg="rgba(255,255,255,0.07)" color="rgba(255,255,255,0.7)">{detail.number_of_episodes} Ep.</Badge>}
|
||
{fskStyle && <Badge bg={fskStyle.bg} color={fskStyle.color}>FSK {detail.fsk}</Badge>}
|
||
{detail.release_date && <Badge bg="rgba(255,255,255,0.07)" color="rgba(255,255,255,0.55)">🇩🇪 {fmtDE(detail.release_date)}</Badge>}
|
||
{detail.countries?.length > 0 && <Badge bg="rgba(255,255,255,0.07)" color="rgba(255,255,255,0.4)">🌍 {detail.countries.slice(0,2).join(' · ')}</Badge>}
|
||
</div>
|
||
{detail.genres?.length > 0 && (
|
||
<div style={{ display:'flex', gap:4, flexWrap:'wrap' }}>
|
||
{detail.genres.map(g => (
|
||
<span key={g} style={{ fontSize:10, color:'#4ecdc4', background:'rgba(78,205,196,0.1)',
|
||
borderRadius:4, padding:'2px 7px', fontFamily:'monospace' }}>{g}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{xrelDates.length > 0 && (
|
||
<div style={{ marginTop:16 }}>
|
||
<div style={{ ...S.head, marginBottom:8 }}>ERSCHEINUNGSDATEN</div>
|
||
<div style={{ display:'flex', flexDirection:'column', gap:4 }}>
|
||
{xrelDates.map((r, i) => (
|
||
<div key={i} style={{ display:'flex', justifyContent:'space-between', alignItems:'center',
|
||
fontSize:11, fontFamily:'monospace', padding:'3px 0',
|
||
borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
|
||
<span style={{ color:'rgba(255,255,255,0.5)' }}>{r.label}</span>
|
||
<span style={{ color:'rgba(255,255,255,0.8)', fontWeight:600 }}>{fmtDE(r.date)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{detail.overview && (
|
||
<div style={{ marginTop:18 }}>
|
||
<div style={{ ...S.head, marginBottom:6 }}>HANDLUNG</div>
|
||
<div style={{ fontSize:13, color:'rgba(255,255,255,0.75)', lineHeight:1.75 }}>{detail.overview}</div>
|
||
</div>
|
||
)}
|
||
{detail.director && (
|
||
<div style={{ marginTop:14 }}>
|
||
<div style={{ ...S.head, marginBottom:4 }}>REGIE</div>
|
||
<div style={{ fontSize:13, color:'rgba(255,255,255,0.8)' }}>{detail.director}</div>
|
||
</div>
|
||
)}
|
||
{detail.cast?.length > 0 && (
|
||
<div style={{ marginTop:14 }}>
|
||
<div style={{ ...S.head, marginBottom:6 }}>BESETZUNG</div>
|
||
<div style={{ display:'flex', flexWrap:'wrap', gap:6 }}>
|
||
{detail.cast.map(name => (
|
||
<span key={name} style={{ fontSize:11, color:'rgba(255,255,255,0.65)',
|
||
background:'rgba(255,255,255,0.06)', borderRadius:4, padding:'3px 8px', fontFamily:'monospace' }}>
|
||
{name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Links */}
|
||
<div style={{ marginTop:18, display:'flex', gap:8, flexWrap:'wrap' }}>
|
||
{detail.tmdb_url && (
|
||
<a href={detail.tmdb_url} target="_blank" rel="noopener noreferrer" style={{
|
||
display:'inline-flex', alignItems:'center', gap:5,
|
||
fontSize:11, color:'#01d277', background:'rgba(1,210,119,0.1)',
|
||
border:'1px solid rgba(1,210,119,0.25)', borderRadius:6,
|
||
padding:'5px 11px', fontFamily:'monospace', textDecoration:'none',
|
||
}}>🎬 TMDb</a>
|
||
)}
|
||
{detail.imdb_url && (
|
||
<a href={detail.imdb_url} target="_blank" rel="noopener noreferrer" style={{
|
||
display:'inline-flex', alignItems:'center', gap:5,
|
||
fontSize:11, color:'#f5c518', background:'rgba(245,197,24,0.1)',
|
||
border:'1px solid rgba(245,197,24,0.25)', borderRadius:6,
|
||
padding:'5px 11px', fontFamily:'monospace', textDecoration:'none',
|
||
}}>⭐ IMDb</a>
|
||
)}
|
||
</div>
|
||
|
||
{/* xREL Releases */}
|
||
<div style={{ marginTop:18 }}>
|
||
{/* Web-Release aus frühestem WEB-Release ableiten */}
|
||
{xrel?.length > 0 && (() => {
|
||
const webRelease = xrel
|
||
.filter(r => /web/i.test(r.dirname||''))
|
||
.sort((a,b) => (a.date||'').localeCompare(b.date||''))[0];
|
||
return webRelease?.date ? (
|
||
<div style={{ fontSize:11, color:'rgba(255,255,255,0.4)', fontFamily:'monospace', marginBottom:8 }}>
|
||
🌐 Erster Web-Release: <span style={{ color:'#4ecdc4' }}>{fmtDE(webRelease.date)}</span>
|
||
<span style={{ marginLeft:6, color:'rgba(255,255,255,0.25)', fontSize:10 }}>{webRelease.p2p ? 'P2P' : 'SCN'}</span>
|
||
</div>
|
||
) : null;
|
||
})()}
|
||
<div>
|
||
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom: xrel?.length ? 8 : 0 }}>
|
||
<div style={{ ...S.head, marginBottom:0, flex:1 }}>RELEASES (XREL.TO)</div>
|
||
<button
|
||
onClick={() => searchXrel(xrel !== null && xrel.length > 0)}
|
||
disabled={xrelLoading}
|
||
style={{
|
||
fontSize:10, fontFamily:'monospace', cursor: xrelLoading ? 'default' : 'pointer',
|
||
background:'rgba(78,205,196,0.1)', border:'1px solid rgba(78,205,196,0.25)',
|
||
color: xrelLoading ? 'rgba(255,255,255,0.3)' : '#4ecdc4',
|
||
borderRadius:6, padding:'3px 10px', flexShrink:0,
|
||
}}
|
||
>
|
||
{xrelLoading ? '⏳ Suche…' : xrel === null ? '🔍 Nach Releases suchen' : '🔄 Erneut suchen'}
|
||
</button>
|
||
</div>
|
||
{xrel !== null && xrel.length === 0 && (
|
||
<div style={{ fontSize:11, color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontStyle:'italic' }}>
|
||
Keine deutschen Releases gefunden
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{xrel !== null && xrel.length > 0 && (
|
||
<div style={{ marginTop:4 }}>
|
||
<div style={{ display:'flex', flexDirection:'column', gap:4 }}>
|
||
{xrel.map((r, i) => (
|
||
<a key={i} href={r.url} target="_blank" rel="noopener noreferrer" style={{
|
||
display:'flex', alignItems:'flex-start', gap:8,
|
||
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.07)',
|
||
borderRadius:6, padding:'6px 10px', textDecoration:'none',
|
||
}}>
|
||
<span style={{ fontSize:9, fontWeight:700, fontFamily:'monospace',
|
||
color: r.p2p ? '#4ecdc4' : '#ff6b9d',
|
||
background: r.p2p ? 'rgba(78,205,196,0.12)' : 'rgba(255,107,157,0.12)',
|
||
borderRadius:3, padding:'1px 5px', flexShrink:0, marginTop:1 }}>
|
||
{r.p2p ? 'P2P' : 'SCN'}
|
||
</span>
|
||
<div style={{ flex:1, minWidth:0 }}>
|
||
<DirnameWrapped dirname={r.dirname} />
|
||
<div style={{ display:'flex', gap:8, marginTop:2 }}>
|
||
{r.size && <span style={{ fontSize:9, color:'rgba(255,255,255,0.35)', fontFamily:'monospace' }}>{r.size}</span>}
|
||
{r.date && <span style={{ fontSize:9, color:'rgba(255,255,255,0.25)', fontFamily:'monospace' }}>{r.date}</span>}
|
||
</div>
|
||
</div>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>)}
|
||
</div>
|
||
</div>{/* Ende scroll-wrapper */}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Badge({ children, bg, color }) {
|
||
return (
|
||
<span style={{ fontSize:10, background:bg, color, borderRadius:4,
|
||
padding:'2px 7px', fontFamily:"'Space Mono',monospace", fontWeight:700 }}>
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// Dirname zweizeilig: vor "German." trennen
|
||
function DirnameWrapped({ dirname }) {
|
||
if (!dirname) return null;
|
||
const lower = dirname.toLowerCase();
|
||
const idx = lower.indexOf('german.');
|
||
if (idx < 0) {
|
||
// Kein "German." – einfach umbrechen nach 40 Zeichen Worttrennung
|
||
return <span style={{ fontSize:10, color:'rgba(255,255,255,0.75)', fontFamily:'monospace',
|
||
wordBreak:'break-all', lineHeight:1.4 }}>{dirname}</span>;
|
||
}
|
||
const before = dirname.slice(0, idx + 7); // inkl. "German."
|
||
const after = dirname.slice(idx + 7);
|
||
return (
|
||
<span style={{ fontSize:10, color:'rgba(255,255,255,0.75)', fontFamily:'monospace', lineHeight:1.4 }}>
|
||
{before}
|
||
{after && <><br/><span style={{ color:'rgba(255,255,255,0.5)' }}>{after}</span></>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function StatusBox({ children }) {
|
||
return (
|
||
<div style={{ padding:'28px 20px', textAlign:'center',
|
||
color:'rgba(255,255,255,0.35)', fontFamily:"'Space Mono',monospace", fontSize:13 }}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ErrorBox({ msg }) {
|
||
return (
|
||
<div style={{ padding:'20px', textAlign:'center', color:'#ff6b9d',
|
||
fontFamily:"'Space Mono',monospace", fontSize:13 }}>
|
||
<div>⚠ {msg}</div>
|
||
{msg?.includes('Token') && (
|
||
<div style={{ marginTop:8, fontSize:11, color:'rgba(255,255,255,0.4)' }}>
|
||
Token eintragen: Einstellungen → Benutzer → <span style={{ color:'#4ecdc4' }}>TMDB API</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|