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

722 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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 },
];
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 }) {
const [activeTool, setActiveTool] = useState('kino');
const [modal, setModal] = useState(null);
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} />}
{activeTool === 'demnächst' && <DemnächstGrid onOpenModal={setModal} />}
{activeTool === 'favoriten' && <FavoritenKalender onOpenModal={setModal} />}
</div>
{modal && createPortal(
<MovieModal tmdbId={modal} onClose={() => setModal(null)} mobile={mobile} />,
document.body
)}
</div>
);
}
// ── Film-Karte ────────────────────────────────────────────────────────────────
function MovieCard({ movie, favIds, xrelIds, onToggleFav, onOpenModal }) {
const isFav = favIds.has(movie.id);
const isTop = movie.rank != null && movie.rank <= 10;
const fskStyle = FSK_COLOR(movie.fsk);
const hasXrel = xrelIds?.has(movie.id);
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(movie.id)}>
{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>
}
{/* 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(movie.id)}>
{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.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 || '',
}});
setFavIds(prev => new Set([...prev, movie.id]));
}
};
return [favIds, toggle];
}
// ── Kino Grid alle laufenden Filme ─────────────────────────────────────────
function KinoGrid({ onOpenModal }) {
const [movies, setMovies] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [xrelIds, setXrelIds] = useState(new Set());
const [favIds, toggleFav] = useFavIds();
// Filme laden
useEffect(() => {
setLoading(true); setError('');
api('/tools/media/now-playing')
.then(setMovies)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, []);
// xREL-Badges: eigener Effect, startet wenn movies gefüllt sind
useEffect(() => {
if (!movies.length) return;
let cancelled = false;
const moviesMeta = movies.map(m => ({ id: m.id, title: m.title, original_title: m.original_title }));
(async () => {
for (let i = 0; i < moviesMeta.length; i += 1) {
if (cancelled) break;
try {
const chunk = moviesMeta.slice(i, i + 1);
const result = await api('/tools/media/xrel-check', { method:'POST', body:{ movies: chunk } });
if (cancelled) break;
const hits = Object.entries(result).filter(([,v]) => v).map(([k]) => Number(k));
if (hits.length > 0) setXrelIds(prev => new Set([...prev, ...hits]));
} catch (_) {}
await new Promise(r => setTimeout(r, 800));
}
})();
return () => { cancelled = true; };
}, [movies]);
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} xrelIds={xrelIds} 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} xrelIds={xrelIds} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
</div>
</>
)}
</div>
);
}
// ── Demnächst wochenweise Neustarts ────────────────────────────────────────
function DemnächstGrid({ onOpenModal }) {
const [groups, setGroups] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [xrelIds, setXrelIds] = useState(new Set());
const [favIds, toggleFav] = useFavIds();
useEffect(() => {
setLoading(true); setError('');
api('/tools/media/upcoming')
.then(setGroups)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
const allMovies = Object.values(groups).flat();
if (!allMovies.length) return;
let cancelled = false;
const moviesMeta = allMovies.map(m => ({ id: m.id, title: m.title, original_title: m.original_title }));
(async () => {
for (let i = 0; i < moviesMeta.length; i += 1) {
if (cancelled) break;
try {
const chunk = moviesMeta.slice(i, i + 1);
const result = await api('/tools/media/xrel-check', { method:'POST', body:{ movies: chunk } });
if (cancelled) break;
const hits = Object.entries(result).filter(([,v]) => v).map(([k]) => Number(k));
if (hits.length > 0) setXrelIds(prev => new Set([...prev, ...hits]));
} catch (_) {}
await new Promise(r => setTimeout(r, 800));
}
})();
return () => { cancelled = true; };
}, [groups]);
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} xrelIds={xrelIds} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
</div>
</div>
))}
</div>
);
}
// ── Favoriten Kalender ────────────────────────────────────────────────────────
function FavoritenKalender({ onOpenModal }) {
const [favs, setFavs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true); setError('');
api('/tools/media/favorites').then(setFavs).catch(e => setError(e.message)).finally(() => setLoading(false));
}, []);
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} />;
if (!favs.length) return <StatusBox>Noch keine Favoriten markiere Filme mit </StatusBox>;
const groups = {};
for (const f of favs) {
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 (!groups[key]) groups[key] = { label, items:[] };
groups[key].items.push(f);
}
return (
<div style={{ display:'flex', flexDirection:'column', gap:24 }}>
{Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).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(f.tmdb_id)} 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, onClose, mobile }) {
const [detail, setDetail] = useState(null);
const [xrel, setXrel] = useState(null); // null=lädt, []+=fertig
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true); setDetail(null); setXrel(null); setError('');
api(`/tools/media/movie/${tmdbId}`)
.then(d => {
// TMDb-Daten sofort anzeigen
setDetail(d);
setLoading(false);
// xREL ist bereits im response enthalten (Backend lädt parallel)
setXrel(Array.isArray(d.xrel) ? d.xrel : []);
})
.catch(e => { setError(e.message); setLoading(false); });
// Body-Scroll sperren
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [tmdbId]);
// 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 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>
{/* 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.runtime > 0 && <Badge bg="rgba(255,255,255,0.07)" color="rgba(255,255,255,0.7)"> {detail.runtime} Min</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>}
</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>
{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 */}
{xrel === null && (
<div style={{ marginTop:16, fontSize:11, color:'rgba(255,255,255,0.3)',
fontFamily:'monospace', fontStyle:'italic' }}> Suche deutsche Releases</div>
)}
{xrel !== null && xrel.length > 0 && (
<div style={{ marginTop:18 }}>
<div style={{ ...S.head, marginBottom:8 }}>RELEASES (XREL.TO)</div>
<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>
);
}