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 ? {movie.title} :
🎬
} {/* 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.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 ? {detail.title} :
🎬
}
{/* 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 && (
REGIE
{detail.director}
)} {detail.cast?.length > 0 && (
BESETZUNG
{detail.cast.map(name => ( {name} ))}
)} {/* Links */}
{detail.tmdb_url && ( 🎬 TMDb )} {detail.imdb_url && ( ⭐ IMDb )}
{/* xREL Releases */} {xrel === null && (
⏳ Suche deutsche Releases…
)} {xrel !== null && xrel.length > 0 && (
RELEASES (XREL.TO)
)} )}
{/* 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
)}
); }