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 }) {
const [activeTool, setActiveTool] = useState('kino');
const [modal, setModal] = useState(null);
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 (
Media
Kino Β· Vorschau Β· Favoriten
{mobile ? (
{chipScroll.left && (
)}
{TOOL_DEFS.map(t => (
))}
{chipScroll.right && (
)}
) : (
{TOOL_DEFS.map(t => (
))}
)}
{active?.label}
{active?.desc}
{activeTool === 'kino' &&
setXrelIds(prev => new Set([...prev, ...ids]))} />}
{activeTool === 'demnΓ€chst' && setXrelIds(prev => new Set([...prev, ...ids]))} />}
{activeTool === 'favoriten' && }
{activeTool === 'suchen' && setXrelIds(prev => new Set([...prev, ...ids]))} />}
{modal && createPortal(
setModal(null)}
mobile={mobile}
onHasXrel={id => setXrelIds(prev => new Set([...prev, id]))}
noYearFilter={activeTool === 'suchen'}
/>,
document.body
)}
);
}
// ββ 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 (
onOpenModal(movie.id)}>
{movie.poster_path
?

:
π¬
}
{/* Top-Badge */}
{isTop && (
#{movie.rank}
)}
{/* Rating */}
{movie.vote_average > 0 && (
β
{movie.vote_average.toFixed(1)}
)}
{/* FSK */}
{fskStyle && (
{movie.fsk}
)}
{/* xREL-Badge */}
{hasXrel && (
xREL
)}
{/* Fav */}
onOpenModal(movie.id)}>
{isTop && (
β
TOP {movie.rank <= 10 ? '10' : ''}
)}
{movie.title}
{movie.original_title && movie.original_title !== movie.title && (
({movie.original_title})
)}
{movie.genres?.length > 0 && (
{movie.genres.slice(0,2).join(' Β· ')}
)}
{movie.release_date && (
{fmtDE(movie.release_date)}
)}
);
}
// ββ 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, 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 β³ LΓ€dt Kinochartsβ¦;
if (error) return ;
if (!movies.length) return Keine Filme gefunden;
const top10 = movies.filter(m => m.rank != null);
const rest = movies.filter(m => m.rank == null);
return (
{top10.length > 0 && (
<>
β
TOP 10 β MEISTGESEHEN
{top10.map(m => )}
>
)}
{rest.length > 0 && (
<>
WEITERE FILME IM KINO
{rest.map(m => )}
>
)}
);
}
// ββ 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 β³ LΓ€dt Neustartsβ¦;
if (error) return ;
const entries = Object.entries(groups).sort(([a],[b]) => a.localeCompare(b));
if (!entries.length) return Keine kommenden Filme gefunden;
return (
{entries.map(([weekStart, movies]) => (
NEUSTART {fmtWeek(weekStart)}
{movies.length} Film{movies.length!==1?'e':''}
{movies.map(m => )}
))}
);
}
// ββ Suche ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
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 (
{/* Suchfeld */}
setQuery(e.target.value)}
onKeyDown={onKey}
placeholder="Filmtitel 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',
}}
/>
{error &&
}
{results === null && !loading && (
Filmtitel eingeben und Suchen drΓΌcken
)}
{results !== null && results.length === 0 && (
Keine Filme gefunden fΓΌr β{query}"
)}
{results !== null && results.length > 0 && (
{results.map(m => (
))}
)}
);
}
// ββ 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 β³ LΓ€dtβ¦;
if (error) return ;
if (!favs.length) return Noch keine Favoriten β markiere Filme mit β€;
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 (
{Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
{group.label.toUpperCase()}
{group.items.length} Film{group.items.length!==1?'e':''}
{group.items.map(f => {
const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })();
const fskStyle = FSK_COLOR(f.fsk);
return (
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',
}}>
{f.poster_path
?

:
π¬
}
{f.title}
π©πͺ {f.release_date_de || 'β'}
{genres.length > 0 && (
{genres.slice(0,3).join(' Β· ')}
)}
{fskStyle && (
FSK {f.fsk}
)}
);
})}
))}
);
}
// ββ Film-Detail-Modal (via Portal) ββββββββββββββββββββββββββββββββββββββββββββ
function MovieModal({ tmdbId, onClose, mobile, onHasXrel, noYearFilter }) {
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/movie/${tmdbId}`)
.then(d => {
setDetail(d);
detailRef.current = d;
setLoading(false);
// Gecachte Releases laden sobald wir den Titel kennen
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) : ''),
});
api(`/tools/media/movie/${tmdbId}/xrel?${params}`)
.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]);
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) : ''),
});
if (forceRefresh || noYearFilter) params.set('refresh', '1');
const r = await api(`/tools/media/movie/${tmdbId}/xrel?${params}`);
setXrel(r.xrel || []);
if (r.xrel_dates?.length > 0) setXrelDates(r.xrel_dates);
if (r.has_xrel) onHasXrel?.(tmdbId);
} 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 (
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 */}
{detail?.backdrop_path && (

)}
{detail?.backdrop_path && (
)}
{/* SchlieΓen β sticky auΓerhalb des Scroll-Containers */}
{/* Scrollbarer Inhalt */}
{loading &&
β³ LΓ€dtβ¦}
{error &&
}
{detail && (<>
{/* Poster */}
{detail.poster_path
?

:
π¬
}
{/* Info */}
{detail.title}
{detail.tagline && (
{detail.tagline}
)}
{detail.vote_average > 0 && β
{detail.vote_average.toFixed(1)}}
{detail.runtime > 0 && β± {detail.runtime} Min}
{fskStyle && FSK {detail.fsk}}
{detail.release_date && π©πͺ {fmtDE(detail.release_date)}}
{detail.countries?.length > 0 && π {detail.countries.slice(0,2).join(' Β· ')}}
{detail.genres?.length > 0 && (
{detail.genres.map(g => (
{g}
))}
)}
{xrelDates.length > 0 && (
ERSCHEINUNGSDATEN
{xrelDates.map((r, i) => (
{r.label}
{fmtDE(r.date)}
))}
)}
{detail.overview && (
HANDLUNG
{detail.overview}
)}
{detail.director && (
)}
{detail.cast?.length > 0 && (
BESETZUNG
{detail.cast.map(name => (
{name}
))}
)}
{/* Links */}
{/* xREL Releases */}
{/* 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 ? (
π Erster Web-Release: {fmtDE(webRelease.date)}
{webRelease.p2p ? 'P2P' : 'SCN'}
) : null;
})()}
RELEASES (XREL.TO)
{xrel !== null && xrel.length === 0 && (
Keine deutschen Releases gefunden
)}
{xrel !== null && xrel.length > 0 && (
)}
>)}
{/* Ende scroll-wrapper */}
);
}
function Badge({ children, bg, color }) {
return (
{children}
);
}
// 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 {dirname};
}
const before = dirname.slice(0, idx + 7); // inkl. "German."
const after = dirname.slice(idx + 7);
return (
{before}
{after && <>
{after}>}
);
}
function StatusBox({ children }) {
return (
{children}
);
}
function ErrorBox({ msg }) {
return (
β {msg}
{msg?.includes('Token') && (
Token eintragen: Einstellungen β Benutzer β TMDB API
)}
);
}