Files
dickendock/frontend/src/tools/media.jsx

1174 lines
56 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '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();
// Wenn von außen ein Tab vorgegeben wird (z.B. Dashboard-Badge)
useEffect(() => {
if (initTab) {
setActiveTool(initTab);
onInitTabConsumed?.();
}
}, [initTab]);
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 === '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'}
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:14, display:'flex', alignItems:'center', justifyContent:'center',
}}>{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 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);
return (
<div 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', position:'relative',
}}>
<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 && (
<button onClick={ackAll}
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,cursor:'pointer'}}>
✓ Alle {unacked} 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>
{isAdmin && (
<button onClick={()=>setAdminTab(true)}
style={{marginBottom:12,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'}}>
👥 Alle Favoriten ({allFavs.length})
</button>
)}
<StatusBox>Noch keine 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 && (
<button onClick={()=>setAdminTab(true)}
style={{marginBottom:4,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})
{allFavs.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',fontSize:9}}>
{allFavs.filter(f=>!f.acknowledged).length} neu
</span>
)}
</button>
)}
{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' : 'rgba(255,255,255,0.6)',
fontSize:18, display:'flex', alignItems:'center', justifyContent:'center',
}}>{isFav ? '❤' : '🤍'}</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>
);
}