From ae9291b8d6e25fe099b3e72c2d4520bea915fde2 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sat, 6 Jun 2026 20:36:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20media=20=E2=80=93=20portal=20modal=20sc?= =?UTF-8?q?roll=20fix,=20alle=20Kinofilme,=20KW-Neustarts,=20Top-Badges,?= =?UTF-8?q?=20GlobalSearch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/tools/media/routes.js | 164 +++++++----- frontend/src/App.jsx | 4 + frontend/src/tools/media.jsx | 403 +++++++++++++++++------------- 3 files changed, 331 insertions(+), 240 deletions(-) diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index 7fcb382..1d990a6 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -23,7 +23,7 @@ async function tmdb(path, params = {}) { return res.json(); } -// Genre-Cache (in-memory, wird bei erstem Aufruf befüllt) +// Genre-Cache let genreCache = null; async function getGenreMap() { if (genreCache) return genreCache; @@ -33,109 +33,139 @@ async function getGenreMap() { return genreCache; } -// FSK aus release_dates extrahieren function extractFSK(releaseDates) { const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE'); if (!de) return null; - const cert = de.release_dates.find(r => r.certification)?.certification; - return cert || null; + return de.release_dates.find(r => r.certification)?.certification || null; } -// Einen Film anreichern (Genres + FSK aus append_to_response) -function enrichMovie(movie, genreMap, releaseDates) { +// Alle Seiten einer TMDb-Liste holen (bis max. maxPages) +async function fetchAllPages(path, maxPages = 5) { + const first = await tmdb(path, { page: 1 }); + const total = Math.min(first.total_pages || 1, maxPages); + let results = [...first.results]; + const extra = await Promise.all( + Array.from({ length: total - 1 }, (_, i) => tmdb(path, { page: i + 2 })) + ); + for (const p of extra) results = results.concat(p.results); + return results; +} + +function enrichMovie(movie, genreMap, fsk, rank) { return { - id: movie.id, - title: movie.title || movie.original_title, - original_title: movie.original_title, + id: movie.id, + title: movie.title || movie.original_title, + original_title: movie.original_title, original_language: movie.original_language, - poster_path: movie.poster_path, - backdrop_path: movie.backdrop_path, - overview: movie.overview, - release_date: movie.release_date, - vote_average: movie.vote_average, - genres: (movie.genre_ids || movie.genres?.map(g=>g.id) || []) - .map(id => genreMap[id]).filter(Boolean), - fsk: extractFSK(releaseDates), + poster_path: movie.poster_path, + overview: movie.overview, + release_date: movie.release_date, + vote_average: movie.vote_average, + genres: (movie.genre_ids || movie.genres?.map(g => g.id) || []) + .map(id => genreMap[id]).filter(Boolean), + fsk: fsk || null, + rank: rank || null, }; } -// GET /api/tools/media/now-playing +// FSK für eine Liste von Filmen parallel holen +async function withFSK(movies, genreMap, topN = 10) { + return Promise.all(movies.map(async (m, i) => { + let fsk = null; + try { + const detail = await tmdb(`/movie/${m.id}/release_dates`); + fsk = extractFSK(detail); + } catch (_) {} + return enrichMovie(m, genreMap, fsk, i < topN ? i + 1 : null); + })); +} + +// GET /api/tools/media/now-playing – alles was aktuell in DE läuft router.get('/now-playing', authenticate, async (req, res) => { try { - const [listData, genreMap] = await Promise.all([ - tmdb('/movie/now_playing'), + const [all, genreMap] = await Promise.all([ + fetchAllPages('/movie/now_playing', 3), getGenreMap(), ]); - // Türkische Produktionen rausfiltern, max 10 - const filtered = listData.results - .filter(m => m.original_language !== 'tr') - .slice(0, 10); - - // FSK für jeden Film parallel holen - const withFSK = await Promise.all(filtered.map(async (m, idx) => { - try { - const detail = await tmdb(`/movie/${m.id}/release_dates`); - return { ...enrichMovie(m, genreMap, detail), rank: idx + 1 }; - } catch { - return { ...enrichMovie(m, genreMap, null), rank: idx + 1 }; - } - })); - res.json(withFSK); + const filtered = all.filter(m => m.original_language !== 'tr'); + const enriched = await withFSK(filtered, genreMap, 10); + res.json(enriched); } catch (e) { res.status(500).json({ error: e.message }); } }); -// GET /api/tools/media/upcoming +// GET /api/tools/media/upcoming – wochenweise Neustarts, alle verfügbaren router.get('/upcoming', authenticate, async (req, res) => { try { - const [listData, genreMap] = await Promise.all([ - tmdb('/movie/upcoming'), + const [all, genreMap] = await Promise.all([ + fetchAllPages('/movie/upcoming', 4), getGenreMap(), ]); - const filtered = listData.results - .filter(m => m.original_language !== 'tr') - .slice(0, 10); + const filtered = all.filter(m => m.original_language !== 'tr'); - const withFSK = await Promise.all(filtered.map(async (m, idx) => { - try { - const detail = await tmdb(`/movie/${m.id}/release_dates`); - return { ...enrichMovie(m, genreMap, detail), rank: idx + 1 }; - } catch { - return { ...enrichMovie(m, genreMap, null), rank: idx + 1 }; - } - })); - res.json(withFSK); + // Deduplizieren nach ID + const seen = new Set(); + const unique = filtered.filter(m => { if (seen.has(m.id)) return false; seen.add(m.id); return true; }); + + // Nach Erscheinungsdatum sortieren + unique.sort((a, b) => (a.release_date || '').localeCompare(b.release_date || '')); + + // Wochenweise gruppieren (Montag der jeweiligen Woche als Key) + const groups = {}; + for (const m of unique) { + if (!m.release_date) continue; + const d = new Date(m.release_date); + // Montag der Woche berechnen + const day = d.getDay(); // 0=So + const diff = (day === 0 ? -6 : 1 - day); + const mon = new Date(d); + mon.setDate(d.getDate() + diff); + const key = mon.toISOString().slice(0, 10); + if (!groups[key]) groups[key] = []; + groups[key].push(m); + } + + // Anreichern: nur die erste Gruppe bekommt FSK (spart API-Calls), Rest ohne + const result = {}; + let globalRank = 0; + for (const [week, movies] of Object.entries(groups)) { + result[week] = await Promise.all(movies.map(async m => { + globalRank++; + let fsk = null; + try { const d = await tmdb(`/movie/${m.id}/release_dates`); fsk = extractFSK(d); } catch (_) {} + return enrichMovie(m, genreMap, fsk, null); + })); + } + res.json(result); // { "2026-06-02": [...], "2026-06-09": [...], ... } } catch (e) { res.status(500).json({ error: e.message }); } }); -// GET /api/tools/media/movie/:id – Details + Credits für Modal +// GET /api/tools/media/movie/:id – Detail + Credits für Modal router.get('/movie/:id', authenticate, async (req, res) => { try { const [detail, genreMap] = await Promise.all([ tmdb(`/movie/${req.params.id}`, { append_to_response: 'credits,release_dates' }), getGenreMap(), ]); - const cast = (detail.credits?.cast || []).slice(0, 8).map(a => a.name); - const director = detail.credits?.crew?.find(c => c.job === 'Director')?.name || null; res.json({ - id: detail.id, - title: detail.title || detail.original_title, + id: detail.id, + title: detail.title || detail.original_title, original_title: detail.original_title, - tagline: detail.tagline, - overview: detail.overview, - poster_path: detail.poster_path, + tagline: detail.tagline, + overview: detail.overview, + poster_path: detail.poster_path, backdrop_path: detail.backdrop_path, - release_date: detail.release_date, - runtime: detail.runtime, - vote_average: detail.vote_average, - vote_count: detail.vote_count, - genres: (detail.genres || []).map(g => g.name), - fsk: extractFSK(detail.release_dates), - cast, - director, + release_date: detail.release_date, + runtime: detail.runtime, + vote_average: detail.vote_average, + vote_count: detail.vote_count, + genres: (detail.genres || []).map(g => g.name), + fsk: extractFSK(detail.release_dates), + cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name), + director: detail.credits?.crew?.find(c => c.job === 'Director')?.name || null, }); } catch (e) { res.status(500).json({ error: e.message }); @@ -184,7 +214,7 @@ router.put('/settings', authenticate, requireAdmin, (req, res) => { const { tmdb_token } = req.body; if (typeof tmdb_token !== 'string') return res.status(400).json({ error: 'tmdb_token fehlt' }); db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run('tmdb_token', tmdb_token.trim()); - genreCache = null; // Cache invalidieren + genreCache = null; res.json({ ok: true }); }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a2bff3d..c6cf09e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1513,6 +1513,10 @@ const SEARCH_TOOL_INDEX = [ {type:'tool',label:'Code-Schnipsel',icon:'📄',sub:'Werkzeuge',id:'codeschnipsel',keywords:['code','snippet','skript','programmierung','source']}, {type:'tool',label:'CAD-Skizzen',icon:'📐',sub:'Werkzeuge',id:'skizze',keywords:['cad','skizze','zeichnung','sketch']}, {type:'tool',label:'Dev-Tools',icon:'🔧',sub:'Werkzeuge',id:'devtools',keywords:['entwickler','developer','tools','werkzeuge']}, + {type:'tool',label:'Media',icon:'🎬',sub:'Freizeit',id:'media',keywords:['kino','film','movie','streaming','demnächst','favoriten','cinema']}, + {type:'tool',label:'Kino – Aktuell',icon:'🎬',sub:'Media',id:'media',keywords:['kino','kinocharts','charts','laufend','now playing']}, + {type:'tool',label:'Kino – Demnächst',icon:'🗓',sub:'Media',id:'media',keywords:['demnächst','neustart','upcoming','vorschau','kino']}, + {type:'tool',label:'Kino – Favoriten',icon:'❤',sub:'Media',id:'media',keywords:['favoriten','film favoriten','gemerkte filme','watchlist']}, // ── Dashboard-Widgets ────────────────────────────────────────────────── {type:'tool',label:'Schnellzugriff',icon:'⚡',sub:'Dashboard',id:'dashboard',keywords:['quick','links','favoriten','shortcuts']}, {type:'tool',label:'Kalender',icon:'📅',sub:'Dashboard',id:'dashboard',keywords:['kalender','termine','events','ical','abo']}, diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index 49b1456..f114272 100644 --- a/frontend/src/tools/media.jsx +++ b/frontend/src/tools/media.jsx @@ -1,13 +1,14 @@ 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 – Top 10 (DE)', ready: true }, - { id: 'demnächst', label: '🗓 Demnächst', desc: 'Bald im Kino – Vorschau (DE)', ready: true }, - { id: 'favoriten', label: '❤ Favoriten', desc: 'Deine gespeicherten Lieblingsfilme', ready: true }, + { 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] ?? '🎬'; @@ -16,6 +17,15 @@ const getText = lbl => { const c = [...lbl]; return c.slice(1).join('').trim() | 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); @@ -29,7 +39,7 @@ const FSK_COLOR = fsk => { export default function Media({ toast, mobile }) { const [activeTool, setActiveTool] = useState('kino'); - const [modal, setModal] = useState(null); // tmdb_id + const [modal, setModal] = useState(null); const chipScrollRef = useRef(null); const [chipScroll, setChipScroll] = useState({ left: false, right: true }); @@ -42,13 +52,12 @@ export default function Media({ toast, mobile }) { const active = TOOL_DEFS.find(t => t.id === activeTool); return ( -
+

Media

Kino · Vorschau · Favoriten
- {/* Tool-Auswahl */} {mobile ? (
@@ -113,28 +122,30 @@ export default function Media({ toast, mobile }) { {active?.desc}
- {activeTool === 'kino' && } - {activeTool === 'demnächst' && } + {activeTool === 'kino' && } + {activeTool === 'demnächst' && } {activeTool === 'favoriten' && }
- {modal && setModal(null)} mobile={mobile} />} + {modal && createPortal( + setModal(null)} mobile={mobile} />, + document.body + )}
); } // ── Film-Karte ──────────────────────────────────────────────────────────────── function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) { - const isFav = favIds.has(movie.id); + const isFav = favIds.has(movie.id); + const isTop = movie.rank != null && movie.rank <= 10; const fskStyle = FSK_COLOR(movie.fsk); return (
- {/* Poster */}
onOpenModal(movie.id)}> {movie.poster_path @@ -143,11 +154,14 @@ function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) { :
🎬
} - {/* Rank */} - {movie.rank && ( + {/* Top-Badge */} + {isTop && (
#{movie.rank}
+ 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} +
)} {/* Rating */} {movie.vote_average > 0 && ( @@ -160,7 +174,7 @@ function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) {
- FSK {movie.fsk} + {movie.fsk}
)} {/* Fav */} @@ -170,10 +184,13 @@ function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) { fontSize:14, display:'flex', alignItems:'center', justifyContent:'center', }}>{isFav ? '❤' : '🤍'}
- - {/* Info */}
onOpenModal(movie.id)}> + {isTop && ( +
+ ★ TOP {movie.rank <= 10 ? '10' : ''} +
+ )}
@@ -195,63 +212,113 @@ function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) { ); } -// ── Kino/Demnächst Grid ─────────────────────────────────────────────────────── -function KinoGrid({ endpoint, emptyText, onOpenModal }) { - const [movies, setMovies] = useState([]); - const [favIds, setFavIds] = useState(new Set()); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - - const loadFavs = useCallback(async () => { +// ── 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 [favIds, toggleFav] = useFavIds(); useEffect(() => { setLoading(true); setError(''); - Promise.all([ - api(endpoint).then(setMovies).catch(e => setError(e.message)), - loadFavs(), - ]).finally(() => setLoading(false)); - }, [endpoint]); + api('/tools/media/now-playing').then(setMovies).catch(e => setError(e.message)).finally(() => setLoading(false)); + }, []); - const toggleFav = async (movie, isFav) => { - try { - 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])); - } - } catch (e) { alert(e.message); } + const onToggle = async (movie, isFav) => { + try { await toggleFav(movie, isFav); } catch (e) { alert(e.message); } }; - if (loading) return ⏳ Lädt…; - if (error) return ( - -
⚠ {error}
- {error.includes('Token') && ( -
- Token eintragen: Einstellungen → Benutzer → TMDB API -
- )} -
- ); - if (!movies.length) return {emptyText}; + 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 ( -
- {movies.map(m => ( - +
+ {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 [favIds, toggleFav] = useFavIds(); + + useEffect(() => { + setLoading(true); setError(''); + api('/tools/media/upcoming').then(setGroups).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 => )} +
+
))}
); @@ -276,14 +343,13 @@ function FavoritenKalender({ onOpenModal }) { }; if (loading) return ⏳ Lädt…; - if (error) return ⚠ {error}; + if (error) return ; if (!favs.length) return Noch keine Favoriten – markiere Filme mit ❤; - // Gruppieren nach Jahr+Monat 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 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); @@ -293,33 +359,27 @@ function FavoritenKalender({ onOpenModal }) {
{Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
- {/* Monats-Überschrift */}
{group.label.toUpperCase()}
{group.items.length} Film{group.items.length!==1?'e':''}
- {/* Film-Zeilen */}
{group.items.map(f => { - const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })(); + 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', - }} onClick={() => onOpenModal(f.tmdb_id)}> - {/* Mini-Poster */} + }}>
{f.poster_path - ? {f.title} - :
🎬
+ ? {f.title} + :
🎬
}
- {/* Text */}
{f.title}
@@ -337,11 +397,9 @@ function FavoritenKalender({ onOpenModal }) { fontWeight:900, fontFamily:'monospace' }}>FSK {f.fsk}
)}
- {/* Entfernen */}
); @@ -353,7 +411,7 @@ function FavoritenKalender({ onOpenModal }) { ); } -// ── Film-Detail-Modal ───────────────────────────────────────────────────────── +// ── Film-Detail-Modal (via Portal) ──────────────────────────────────────────── function MovieModal({ tmdbId, onClose, mobile }) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); @@ -362,94 +420,83 @@ function MovieModal({ tmdbId, onClose, mobile }) { useEffect(() => { setLoading(true); setError(''); api(`/tools/media/movie/${tmdbId}`) - .then(setDetail) - .catch(e => setError(e.message)) - .finally(() => setLoading(false)); + .then(setDetail).catch(e => setError(e.message)).finally(() => 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; - // Overlay return (
-
e.stopPropagation()} - onTouchMove={e => e.stopPropagation()} - style={{ - background:'#1a1a1f', border:'1px solid rgba(255,255,255,0.1)', - borderRadius: mobile ? '16px 16px 0 0' : 16, - width: mobile ? '100%' : 560, - maxHeight: mobile ? '90vh' : '85vh', - height: mobile ? '90vh' : 'auto', - overflowY:'scroll', - WebkitOverflowScrolling:'touch', - position:'relative', - }}> +
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', + overflowY:'auto', + WebkitOverflowScrolling:'touch', + position:'relative', + }}> {/* Backdrop */} {detail?.backdrop_path && ( -
+
-
+ style={{ width:'100%', height:'100%', objectFit:'cover', display:'block', opacity:0.45 }} /> +
)} {/* Schließen */} -
+
{loading && ⏳ Lädt…} - {error && ⚠ {error}} - {detail && ( + {error && } + {detail && (<>
{/* Poster */} -
+
{detail.poster_path - ? {detail.title} - :
🎬
+ ? {detail.title} + :
🎬
}
- {/* Info */}
-
{detail.title}
{detail.tagline && ( -
- {detail.tagline} -
+
{detail.tagline}
)} - {/* Badges */} -
- {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.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)}}
- {/* Genres */} {detail.genres?.length > 0 && ( -
+
{detail.genres.map(g => ( {g} @@ -458,37 +505,33 @@ function MovieModal({ tmdbId, onClose, mobile }) { )}
- )} - {/* Plot */} - {detail?.overview && ( -
-
HANDLUNG
-
{detail.overview}
-
- )} - - {/* Regie */} - {detail?.director && ( -
-
REGIE
-
{detail.director}
-
- )} - - {/* Cast */} - {detail?.cast?.length > 0 && ( -
-
BESETZUNG
-
- {detail.cast.map(name => ( - {name} - ))} + {detail.overview && ( +
+
HANDLUNG
+
{detail.overview}
-
- )} + )} + {detail.director && ( +
+
REGIE
+
{detail.director}
+
+ )} + {detail.cast?.length > 0 && ( +
+
BESETZUNG
+
+ {detail.cast.map(name => ( + + {name} + + ))} +
+
+ )} + )}
@@ -504,11 +547,25 @@ function Badge({ children, bg, color }) { ); } -function StatusBox({ children, color }) { +function StatusBox({ children }) { return (
+ color:'rgba(255,255,255,0.35)', fontFamily:"'Space Mono',monospace", fontSize:13 }}> {children}
); } + +function ErrorBox({ msg }) { + return ( +
+
⚠ {msg}
+ {msg?.includes('Token') && ( +
+ Token eintragen: Einstellungen → Benutzer → TMDB API +
+ )} +
+ ); +}