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 (
Media
Kino Β· Vorschau Β· Favoriten
{mobile ? (
{chipScroll.left && (
)}
{TOOL_DEFS.map(t => (
))}
{chipScroll.right && (
)}
) : (
{TOOL_DEFS.map(t => (
))}
)}
{active?.label}
{active?.desc}
{activeTool === 'kino' &&
}
{activeTool === 'demnΓ€chst' &&
}
{activeTool === 'favoriten' &&
}
{modal && createPortal(
setModal(null)} mobile={mobile} />,
document.body
)}
);
}
// ββ 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 (
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.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 }) {
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 β³ 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 }) {
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 β³ 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 => )}
))}
);
}
// ββ 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 }) {
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 (
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.genres?.length > 0 && (
{detail.genres.map(g => (
{g}
))}
)}
{detail.overview && (
HANDLUNG
{detail.overview}
)}
{detail.director && (
)}
{detail.cast?.length > 0 && (
BESETZUNG
{detail.cast.map(name => (
{name}
))}
)}
{/* Links */}
{/* xREL Releases */}
{xrel === null && (
β³ Suche deutsche Releasesβ¦
)}
{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
)}
);
}