feat: media – portal modal scroll fix, alle Kinofilme, KW-Neustarts, Top-Badges, GlobalSearch

This commit is contained in:
2026-06-06 20:36:09 +02:00
parent 87c02652b5
commit ae9291b8d6
3 changed files with 331 additions and 240 deletions

View File

@@ -23,7 +23,7 @@ async function tmdb(path, params = {}) {
return res.json(); return res.json();
} }
// Genre-Cache (in-memory, wird bei erstem Aufruf befüllt) // Genre-Cache
let genreCache = null; let genreCache = null;
async function getGenreMap() { async function getGenreMap() {
if (genreCache) return genreCache; if (genreCache) return genreCache;
@@ -33,93 +33,123 @@ async function getGenreMap() {
return genreCache; return genreCache;
} }
// FSK aus release_dates extrahieren
function extractFSK(releaseDates) { function extractFSK(releaseDates) {
const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE'); const de = releaseDates?.results?.find(r => r.iso_3166_1 === 'DE');
if (!de) return null; if (!de) return null;
const cert = de.release_dates.find(r => r.certification)?.certification; return de.release_dates.find(r => r.certification)?.certification || null;
return cert || null;
} }
// Einen Film anreichern (Genres + FSK aus append_to_response) // Alle Seiten einer TMDb-Liste holen (bis max. maxPages)
function enrichMovie(movie, genreMap, releaseDates) { 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 { return {
id: movie.id, id: movie.id,
title: movie.title || movie.original_title, title: movie.title || movie.original_title,
original_title: movie.original_title, original_title: movie.original_title,
original_language: movie.original_language, original_language: movie.original_language,
poster_path: movie.poster_path, poster_path: movie.poster_path,
backdrop_path: movie.backdrop_path,
overview: movie.overview, overview: movie.overview,
release_date: movie.release_date, release_date: movie.release_date,
vote_average: movie.vote_average, vote_average: movie.vote_average,
genres: (movie.genre_ids || movie.genres?.map(g => g.id) || []) genres: (movie.genre_ids || movie.genres?.map(g => g.id) || [])
.map(id => genreMap[id]).filter(Boolean), .map(id => genreMap[id]).filter(Boolean),
fsk: extractFSK(releaseDates), 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) => { router.get('/now-playing', authenticate, async (req, res) => {
try { try {
const [listData, genreMap] = await Promise.all([ const [all, genreMap] = await Promise.all([
tmdb('/movie/now_playing'), fetchAllPages('/movie/now_playing', 3),
getGenreMap(), getGenreMap(),
]); ]);
// Türkische Produktionen rausfiltern, max 10 const filtered = all.filter(m => m.original_language !== 'tr');
const filtered = listData.results const enriched = await withFSK(filtered, genreMap, 10);
.filter(m => m.original_language !== 'tr') res.json(enriched);
.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);
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message }); 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) => { router.get('/upcoming', authenticate, async (req, res) => {
try { try {
const [listData, genreMap] = await Promise.all([ const [all, genreMap] = await Promise.all([
tmdb('/movie/upcoming'), fetchAllPages('/movie/upcoming', 4),
getGenreMap(), getGenreMap(),
]); ]);
const filtered = listData.results const filtered = all.filter(m => m.original_language !== 'tr');
.filter(m => m.original_language !== 'tr')
.slice(0, 10);
const withFSK = await Promise.all(filtered.map(async (m, idx) => { // Deduplizieren nach ID
try { const seen = new Set();
const detail = await tmdb(`/movie/${m.id}/release_dates`); const unique = filtered.filter(m => { if (seen.has(m.id)) return false; seen.add(m.id); return true; });
return { ...enrichMovie(m, genreMap, detail), rank: idx + 1 };
} catch { // Nach Erscheinungsdatum sortieren
return { ...enrichMovie(m, genreMap, null), rank: idx + 1 }; 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(withFSK); }
res.json(result); // { "2026-06-02": [...], "2026-06-09": [...], ... }
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message }); 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) => { router.get('/movie/:id', authenticate, async (req, res) => {
try { try {
const [detail, genreMap] = await Promise.all([ const [detail, genreMap] = await Promise.all([
tmdb(`/movie/${req.params.id}`, { append_to_response: 'credits,release_dates' }), tmdb(`/movie/${req.params.id}`, { append_to_response: 'credits,release_dates' }),
getGenreMap(), 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({ res.json({
id: detail.id, id: detail.id,
title: detail.title || detail.original_title, title: detail.title || detail.original_title,
@@ -134,8 +164,8 @@ router.get('/movie/:id', authenticate, async (req, res) => {
vote_count: detail.vote_count, vote_count: detail.vote_count,
genres: (detail.genres || []).map(g => g.name), genres: (detail.genres || []).map(g => g.name),
fsk: extractFSK(detail.release_dates), fsk: extractFSK(detail.release_dates),
cast, cast: (detail.credits?.cast || []).slice(0, 8).map(a => a.name),
director, director: detail.credits?.crew?.find(c => c.job === 'Director')?.name || null,
}); });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message }); res.status(500).json({ error: e.message });
@@ -184,7 +214,7 @@ router.put('/settings', authenticate, requireAdmin, (req, res) => {
const { tmdb_token } = req.body; const { tmdb_token } = req.body;
if (typeof tmdb_token !== 'string') return res.status(400).json({ error: 'tmdb_token fehlt' }); 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()); 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 }); res.json({ ok: true });
}); });

View File

@@ -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:'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:'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:'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 ────────────────────────────────────────────────── // ── Dashboard-Widgets ──────────────────────────────────────────────────
{type:'tool',label:'Schnellzugriff',icon:'⚡',sub:'Dashboard',id:'dashboard',keywords:['quick','links','favoriten','shortcuts']}, {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']}, {type:'tool',label:'Kalender',icon:'📅',sub:'Dashboard',id:'dashboard',keywords:['kalender','termine','events','ical','abo']},

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { S, api } from '../lib.js'; import { S, api } from '../lib.js';
const POSTER = 'https://image.tmdb.org/t/p/w342'; const POSTER = 'https://image.tmdb.org/t/p/w342';
const BACKDROP = 'https://image.tmdb.org/t/p/w780'; const BACKDROP = 'https://image.tmdb.org/t/p/w780';
const TOOL_DEFS = [ const TOOL_DEFS = [
{ id: 'kino', label: '🎬 Kino', desc: 'Aktuell im Kino Top 10 (DE)', ready: true }, { id: 'kino', label: '🎬 Kino', desc: 'Aktuell im Kino (DE) alle laufenden Filme', ready: true },
{ id: 'demnächst', label: '🗓 Demnächst', desc: 'Bald im Kino Vorschau (DE)', 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: 'favoriten', label: '❤ Favoriten', desc: 'Deine gespeicherten Lieblingsfilme', ready: true },
]; ];
@@ -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', const fmtDE = d => d ? new Date(d).toLocaleDateString('de-DE',
{ day:'2-digit', month:'2-digit', year:'numeric' }) : ''; { 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 => { const FSK_COLOR = fsk => {
if (!fsk) return null; if (!fsk) return null;
const n = parseInt(fsk); const n = parseInt(fsk);
@@ -29,7 +39,7 @@ const FSK_COLOR = fsk => {
export default function Media({ toast, mobile }) { export default function Media({ toast, mobile }) {
const [activeTool, setActiveTool] = useState('kino'); const [activeTool, setActiveTool] = useState('kino');
const [modal, setModal] = useState(null); // tmdb_id const [modal, setModal] = useState(null);
const chipScrollRef = useRef(null); const chipScrollRef = useRef(null);
const [chipScroll, setChipScroll] = useState({ left: false, right: true }); 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); const active = TOOL_DEFS.find(t => t.id === activeTool);
return ( return (
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth: 860 }}> <div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth: 900 }}>
<div style={{ display:'flex', alignItems:'center', gap:12, marginBottom:16, flexWrap:'wrap' }}> <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> <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> <span style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10 }}>Kino · Vorschau · Favoriten</span>
</div> </div>
{/* Tool-Auswahl */}
{mobile ? ( {mobile ? (
<div style={{ marginBottom:16 }}> <div style={{ marginBottom:16 }}>
<div style={{ display:'flex', alignItems:'center', gap:4 }}> <div style={{ display:'flex', alignItems:'center', gap:4 }}>
@@ -113,12 +122,15 @@ export default function Media({ toast, mobile }) {
<span style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10 }}>{active?.desc}</span> <span style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10 }}>{active?.desc}</span>
</div> </div>
{activeTool === 'kino' && <KinoGrid endpoint="/tools/media/now-playing" emptyText="Keine aktuellen Kinofilme" onOpenModal={setModal} />} {activeTool === 'kino' && <KinoGrid onOpenModal={setModal} />}
{activeTool === 'demnächst' && <KinoGrid endpoint="/tools/media/upcoming" emptyText="Keine kommenden Filme" onOpenModal={setModal} />} {activeTool === 'demnächst' && <DemnächstGrid onOpenModal={setModal} />}
{activeTool === 'favoriten' && <FavoritenKalender onOpenModal={setModal} />} {activeTool === 'favoriten' && <FavoritenKalender onOpenModal={setModal} />}
</div> </div>
{modal && <MovieModal tmdbId={modal} onClose={() => setModal(null)} mobile={mobile} />} {modal && createPortal(
<MovieModal tmdbId={modal} onClose={() => setModal(null)} mobile={mobile} />,
document.body
)}
</div> </div>
); );
} }
@@ -126,15 +138,14 @@ export default function Media({ toast, mobile }) {
// ── Film-Karte ──────────────────────────────────────────────────────────────── // ── Film-Karte ────────────────────────────────────────────────────────────────
function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) { 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); const fskStyle = FSK_COLOR(movie.fsk);
return ( return (
<div style={{ <div style={{
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.08)', 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', borderRadius:10, overflow:'hidden', display:'flex', flexDirection:'column', cursor:'pointer',
cursor:'pointer',
}}> }}>
{/* Poster */}
<div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }} <div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}
onClick={() => onOpenModal(movie.id)}> onClick={() => onOpenModal(movie.id)}>
{movie.poster_path {movie.poster_path
@@ -143,11 +154,14 @@ function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) {
: <div style={{ width:'100%', height:'100%', display:'flex', alignItems:'center', : <div style={{ width:'100%', height:'100%', display:'flex', alignItems:'center',
justifyContent:'center', fontSize:36, color:'rgba(255,255,255,0.1)' }}>🎬</div> justifyContent:'center', fontSize:36, color:'rgba(255,255,255,0.1)' }}>🎬</div>
} }
{/* Rank */} {/* Top-Badge */}
{movie.rank && ( {isTop && (
<div style={{ position:'absolute', bottom:6, left:6, background:'rgba(0,0,0,0.85)', <div style={{ position:'absolute', bottom:6, left:6, background:'rgba(0,0,0,0.85)',
borderRadius:5, padding:'2px 8px', fontSize:13, color:'#fff', fontWeight:900, borderRadius:5, padding:'2px 8px', fontSize:13, fontWeight:900,
fontFamily:"'Space Mono',monospace" }}>#{movie.rank}</div> fontFamily:"'Space Mono',monospace",
color: movie.rank === 1 ? '#ffd700' : movie.rank <= 3 ? '#c0c0c0' : '#fff' }}>
#{movie.rank}
</div>
)} )}
{/* Rating */} {/* Rating */}
{movie.vote_average > 0 && ( {movie.vote_average > 0 && (
@@ -160,7 +174,7 @@ function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) {
<div style={{ position:'absolute', top:6, right:36, background:fskStyle.bg, <div style={{ position:'absolute', top:6, right:36, background:fskStyle.bg,
borderRadius:4, padding:'1px 5px', fontSize:9, color:fskStyle.color, borderRadius:4, padding:'1px 5px', fontSize:9, color:fskStyle.color,
fontWeight:900, fontFamily:"'Space Mono',monospace", lineHeight:1.5 }}> fontWeight:900, fontFamily:"'Space Mono',monospace", lineHeight:1.5 }}>
FSK {movie.fsk} {movie.fsk}
</div> </div>
)} )}
{/* Fav */} {/* Fav */}
@@ -170,10 +184,13 @@ function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) {
fontSize:14, display:'flex', alignItems:'center', justifyContent:'center', fontSize:14, display:'flex', alignItems:'center', justifyContent:'center',
}}>{isFav ? '❤' : '🤍'}</button> }}>{isFav ? '❤' : '🤍'}</button>
</div> </div>
{/* Info */}
<div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }} <div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}
onClick={() => onOpenModal(movie.id)}> 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, <div style={{ fontSize:12, fontWeight:700, color:'#fff', lineHeight:1.3,
fontFamily:"'Space Mono',monospace", overflow:'hidden', fontFamily:"'Space Mono',monospace", overflow:'hidden',
display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical' }}> display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical' }}>
@@ -195,63 +212,113 @@ function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) {
); );
} }
// ── Kino/Demnächst Grid ─────────────────────────────────────────────────────── // ── Favoriten-Hook ────────────────────────────────────────────────────────────
function KinoGrid({ endpoint, emptyText, onOpenModal }) { function useFavIds() {
const [movies, setMovies] = useState([]);
const [favIds, setFavIds] = useState(new Set()); const [favIds, setFavIds] = useState(new Set());
const [loading, setLoading] = useState(true); const load = useCallback(async () => {
const [error, setError] = useState('');
const loadFavs = useCallback(async () => {
try { const d = await api('/tools/media/favorites'); setFavIds(new Set(d.map(f => f.tmdb_id))); } try { const d = await api('/tools/media/favorites'); setFavIds(new Set(d.map(f => f.tmdb_id))); }
catch (_) {} catch (_) {}
}, []); }, []);
useEffect(() => { load(); }, []);
useEffect(() => { const toggle = async (movie, isFav) => {
setLoading(true); setError('');
Promise.all([
api(endpoint).then(setMovies).catch(e => setError(e.message)),
loadFavs(),
]).finally(() => setLoading(false));
}, [endpoint]);
const toggleFav = async (movie, isFav) => {
try {
if (isFav) { if (isFav) {
await api(`/tools/media/favorites/${movie.id}`, { method:'DELETE' }); await api(`/tools/media/favorites/${movie.id}`, { method:'DELETE' });
setFavIds(prev => { const s = new Set(prev); s.delete(movie.id); return s; }); setFavIds(prev => { const s = new Set(prev); s.delete(movie.id); return s; });
} else { } else {
await api('/tools/media/favorites', { body: { await api('/tools/media/favorites', { body: {
tmdb_id: movie.id, tmdb_id: movie.id, title: movie.title,
title: movie.title,
poster_path: movie.poster_path || '', poster_path: movie.poster_path || '',
release_date: movie.release_date || '', release_date: movie.release_date || '',
release_date_de: fmtDE(movie.release_date), release_date_de: fmtDE(movie.release_date),
genres: movie.genres || [], genres: movie.genres || [], fsk: movie.fsk || '',
fsk: movie.fsk || '',
}}); }});
setFavIds(prev => new Set([...prev, movie.id])); setFavIds(prev => new Set([...prev, movie.id]));
} }
} catch (e) { alert(e.message); } };
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('');
api('/tools/media/now-playing').then(setMovies).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 <StatusBox> Lädt</StatusBox>; if (loading) return <StatusBox> Lädt Kinocharts</StatusBox>;
if (error) return ( if (error) return <ErrorBox msg={error} />;
<StatusBox color="#ff6b9d"> if (!movies.length) return <StatusBox>Keine Filme gefunden</StatusBox>;
<div> {error}</div>
{error.includes('Token') && ( const top10 = movies.filter(m => m.rank != null);
<div style={{ marginTop:8, fontSize:11, color:'rgba(255,255,255,0.4)' }}> const rest = movies.filter(m => m.rank == null);
Token eintragen: Einstellungen Benutzer <span style={{ color:'#4ecdc4' }}>TMDB API</span>
</div>
)}
</StatusBox>
);
if (!movies.length) return <StatusBox>{emptyText}</StatusBox>;
return ( 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} 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 }}> <div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px,1fr))', gap:12 }}>
{movies.map(m => ( {rest.map(m => <MovieCard key={m.id} movie={m} favIds={favIds} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
<MovieCard key={m.id} movie={m} favIds={favIds} onToggleFav={toggleFav} 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 [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 <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} onToggleFav={onToggle} onOpenModal={onOpenModal} />)}
</div>
</div>
))} ))}
</div> </div>
); );
@@ -276,10 +343,9 @@ function FavoritenKalender({ onOpenModal }) {
}; };
if (loading) return <StatusBox> Lädt</StatusBox>; if (loading) return <StatusBox> Lädt</StatusBox>;
if (error) return <StatusBox color="#ff6b9d"> {error}</StatusBox>; if (error) return <ErrorBox msg={error} />;
if (!favs.length) return <StatusBox>Noch keine Favoriten markiere Filme mit </StatusBox>; if (!favs.length) return <StatusBox>Noch keine Favoriten markiere Filme mit </StatusBox>;
// Gruppieren nach Jahr+Monat
const groups = {}; const groups = {};
for (const f of favs) { for (const f of favs) {
const d = f.release_date ? new Date(f.release_date) : null; const d = f.release_date ? new Date(f.release_date) : null;
@@ -293,33 +359,27 @@ function FavoritenKalender({ onOpenModal }) {
<div style={{ display:'flex', flexDirection:'column', gap:24 }}> <div style={{ display:'flex', flexDirection:'column', gap:24 }}>
{Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => ( {Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
<div key={key}> <div key={key}>
{/* Monats-Überschrift */}
<div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}> <div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}>
<span>{group.label.toUpperCase()}</span> <span>{group.label.toUpperCase()}</span>
<div style={{ flex:1, height:1, background:'rgba(255,255,255,0.07)' }}/> <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> <span style={{ fontSize:9 }}>{group.items.length} Film{group.items.length!==1?'e':''}</span>
</div> </div>
{/* Film-Zeilen */}
<div style={{ display:'flex', flexDirection:'column', gap:8 }}> <div style={{ display:'flex', flexDirection:'column', gap:8 }}>
{group.items.map(f => { {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); const fskStyle = FSK_COLOR(f.fsk);
return ( return (
<div key={f.tmdb_id} style={{ <div key={f.tmdb_id} onClick={() => onOpenModal(f.tmdb_id)} style={{
display:'flex', gap:12, alignItems:'flex-start', display:'flex', gap:12, alignItems:'flex-start',
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.06)', background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.06)',
borderRadius:8, padding:'10px 12px', cursor:'pointer', borderRadius:8, padding:'10px 12px', cursor:'pointer',
}} onClick={() => onOpenModal(f.tmdb_id)}> }}>
{/* Mini-Poster */}
<div style={{ width:44, height:66, flexShrink:0, borderRadius:5, overflow:'hidden', background:'#111' }}> <div style={{ width:44, height:66, flexShrink:0, borderRadius:5, overflow:'hidden', background:'#111' }}>
{f.poster_path {f.poster_path
? <img src={POSTER + f.poster_path} alt={f.title} ? <img src={POSTER + f.poster_path} alt={f.title} style={{ width:'100%', height:'100%', objectFit:'cover', display:'block' }} />
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 style={{ width:'100%', height:'100%', display:'flex', alignItems:'center',
justifyContent:'center', fontSize:18 }}>🎬</div>
} }
</div> </div>
{/* Text */}
<div style={{ flex:1, minWidth:0 }}> <div style={{ flex:1, minWidth:0 }}>
<div style={{ fontSize:13, fontWeight:700, color:'#fff', fontFamily:"'Space Mono',monospace", <div style={{ fontSize:13, fontWeight:700, color:'#fff', fontFamily:"'Space Mono',monospace",
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.title}</div> overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.title}</div>
@@ -337,11 +397,9 @@ function FavoritenKalender({ onOpenModal }) {
fontWeight:900, fontFamily:'monospace' }}>FSK {f.fsk}</div> fontWeight:900, fontFamily:'monospace' }}>FSK {f.fsk}</div>
)} )}
</div> </div>
{/* Entfernen */}
<button onClick={e => { e.stopPropagation(); removeFav(f.tmdb_id); }} style={{ <button onClick={e => { e.stopPropagation(); removeFav(f.tmdb_id); }} style={{
background:'none', border:'none', color:'rgba(255,107,157,0.6)', background:'none', border:'none', color:'rgba(255,107,157,0.6)',
cursor:'pointer', fontSize:16, flexShrink:0, padding:'0 2px', cursor:'pointer', fontSize:16, flexShrink:0, padding:'0 2px', alignSelf:'center',
alignSelf:'center',
}}></button> }}></button>
</div> </div>
); );
@@ -353,7 +411,7 @@ function FavoritenKalender({ onOpenModal }) {
); );
} }
// ── Film-Detail-Modal ───────────────────────────────────────────────────────── // ── Film-Detail-Modal (via Portal) ────────────────────────────────────────────
function MovieModal({ tmdbId, onClose, mobile }) { function MovieModal({ tmdbId, onClose, mobile }) {
const [detail, setDetail] = useState(null); const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -362,94 +420,83 @@ function MovieModal({ tmdbId, onClose, mobile }) {
useEffect(() => { useEffect(() => {
setLoading(true); setError(''); setLoading(true); setError('');
api(`/tools/media/movie/${tmdbId}`) api(`/tools/media/movie/${tmdbId}`)
.then(setDetail) .then(setDetail).catch(e => setError(e.message)).finally(() => setLoading(false));
.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]); }, [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; const fskStyle = detail ? FSK_COLOR(detail.fsk) : null;
// Overlay
return ( return (
<div onClick={onClose} style={{ <div onClick={onClose} style={{
position:'fixed', inset:0, background:'rgba(0,0,0,0.75)', zIndex:1000, position:'fixed', inset:0, background:'rgba(0,0,0,0.8)', zIndex:9000,
display:'flex', alignItems: mobile ? 'flex-end' : 'center', justifyContent:'center', display:'flex', alignItems: mobile ? 'flex-end' : 'center', justifyContent:'center',
backdropFilter:'blur(4px)', backdropFilter:'blur(4px)',
}}> }}>
<div <div onClick={e => e.stopPropagation()} style={{
onClick={e => e.stopPropagation()} background:'#1a1a1f', border:'1px solid rgba(255,255,255,0.12)',
onTouchMove={e => e.stopPropagation()}
style={{
background:'#1a1a1f', border:'1px solid rgba(255,255,255,0.1)',
borderRadius: mobile ? '16px 16px 0 0' : 16, borderRadius: mobile ? '16px 16px 0 0' : 16,
width: mobile ? '100%' : 560, width: mobile ? '100%' : 560,
maxHeight: mobile ? '90vh' : '85vh', maxHeight: mobile ? '88vh' : '85vh',
height: mobile ? '90vh' : 'auto', overflowY:'auto',
overflowY:'scroll',
WebkitOverflowScrolling:'touch', WebkitOverflowScrolling:'touch',
position:'relative', position:'relative',
}}> }}>
{/* Backdrop */} {/* Backdrop */}
{detail?.backdrop_path && ( {detail?.backdrop_path && (
<div style={{ position:'relative', height:180, overflow:'hidden', <div style={{ height:160, overflow:'hidden',
borderRadius: mobile ? '16px 16px 0 0' : '16px 16px 0 0' }}> borderRadius: mobile ? '16px 16px 0 0' : '16px 16px 0 0', flexShrink:0 }}>
<img src={BACKDROP + detail.backdrop_path} alt="" <img src={BACKDROP + detail.backdrop_path} alt=""
style={{ width:'100%', height:'100%', objectFit:'cover', display:'block', opacity:0.5 }} /> style={{ width:'100%', height:'100%', objectFit:'cover', display:'block', opacity:0.45 }} />
<div style={{ position:'absolute', inset:0, <div style={{ position:'absolute', top:0, left:0, right:0, height:160,
background:'linear-gradient(to bottom, transparent 40%, #1a1a1f)' }} /> background:'linear-gradient(to bottom, transparent 30%, #1a1a1f)' }} />
</div> </div>
)} )}
{/* Schließen */} {/* Schließen */}
<button onClick={onClose} style={{ <button onClick={onClose} style={{
position:'absolute', top:12, right:12, background:'rgba(0,0,0,0.6)', position:'absolute', top:12, right:12, background:'rgba(0,0,0,0.7)',
border:'none', borderRadius:'50%', width:32, height:32, cursor:'pointer', border:'none', borderRadius:'50%', width:34, height:34, cursor:'pointer',
color:'#fff', fontSize:16, display:'flex', alignItems:'center', justifyContent:'center', color:'#fff', fontSize:18, display:'flex', alignItems:'center', justifyContent:'center', zIndex:1,
zIndex:10,
}}></button> }}></button>
<div style={{ padding:'16px 20px 24px' }}> <div style={{ padding:'16px 20px 28px' }}>
{loading && <StatusBox> Lädt</StatusBox>} {loading && <StatusBox> Lädt</StatusBox>}
{error && <StatusBox color="#ff6b9d"> {error}</StatusBox>} {error && <ErrorBox msg={error} />}
{detail && ( {detail && (<>
<div style={{ display:'flex', gap:16, alignItems:'flex-start' }}> <div style={{ display:'flex', gap:16, alignItems:'flex-start' }}>
{/* Poster */} {/* Poster */}
<div style={{ width:90, flexShrink:0, borderRadius:8, overflow:'hidden', background:'#111' }}> <div style={{ width:88, flexShrink:0, borderRadius:8, overflow:'hidden', background:'#111' }}>
{detail.poster_path {detail.poster_path
? <img src={POSTER + detail.poster_path} alt={detail.title} ? <img src={POSTER + detail.poster_path} alt={detail.title} style={{ width:'100%', display:'block' }} />
style={{ width:'100%', display:'block' }} /> : <div style={{ height:132, display:'flex', alignItems:'center', justifyContent:'center', fontSize:28 }}>🎬</div>
: <div style={{ height:135, display:'flex', alignItems:'center',
justifyContent:'center', fontSize:28 }}>🎬</div>
} }
</div> </div>
{/* Info */} {/* Info */}
<div style={{ flex:1, minWidth:0 }}> <div style={{ flex:1, minWidth:0 }}>
<div style={{ fontSize:16, fontWeight:900, color:'#fff', lineHeight:1.3, <div style={{ fontSize:15, fontWeight:900, color:'#fff', lineHeight:1.3,
fontFamily:"'Space Mono',monospace", marginBottom:4 }}>{detail.title}</div> fontFamily:"'Space Mono',monospace", marginBottom:4 }}>{detail.title}</div>
{detail.tagline && ( {detail.tagline && (
<div style={{ fontSize:11, color:'rgba(255,255,255,0.4)', fontStyle:'italic', marginBottom:8 }}> <div style={{ fontSize:11, color:'rgba(255,255,255,0.4)', fontStyle:'italic', marginBottom:8 }}>{detail.tagline}</div>
{detail.tagline} )}
<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> </div>
)}
{/* Badges */}
<div style={{ display:'flex', gap:6, flexWrap:'wrap', marginBottom:10 }}>
{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.6)">🇩🇪 {fmtDE(detail.release_date)}</Badge>
)}
</div>
{/* Genres */}
{detail.genres?.length > 0 && ( {detail.genres?.length > 0 && (
<div style={{ display:'flex', gap:4, flexWrap:'wrap', marginBottom:10 }}> <div style={{ display:'flex', gap:4, flexWrap:'wrap' }}>
{detail.genres.map(g => ( {detail.genres.map(g => (
<span key={g} style={{ fontSize:10, color:'#4ecdc4', background:'rgba(78,205,196,0.1)', <span key={g} style={{ fontSize:10, color:'#4ecdc4', background:'rgba(78,205,196,0.1)',
borderRadius:4, padding:'2px 7px', fontFamily:'monospace' }}>{g}</span> borderRadius:4, padding:'2px 7px', fontFamily:'monospace' }}>{g}</span>
@@ -458,37 +505,33 @@ function MovieModal({ tmdbId, onClose, mobile }) {
)} )}
</div> </div>
</div> </div>
)}
{/* Plot */} {detail.overview && (
{detail?.overview && ( <div style={{ marginTop:18 }}>
<div style={{ marginTop:16 }}>
<div style={{ ...S.head, marginBottom:6 }}>HANDLUNG</div> <div style={{ ...S.head, marginBottom:6 }}>HANDLUNG</div>
<div style={{ fontSize:13, color:'rgba(255,255,255,0.75)', lineHeight:1.7 }}>{detail.overview}</div> <div style={{ fontSize:13, color:'rgba(255,255,255,0.75)', lineHeight:1.75 }}>{detail.overview}</div>
</div> </div>
)} )}
{detail.director && (
{/* Regie */}
{detail?.director && (
<div style={{ marginTop:14 }}> <div style={{ marginTop:14 }}>
<div style={{ ...S.head, marginBottom:4 }}>REGIE</div> <div style={{ ...S.head, marginBottom:4 }}>REGIE</div>
<div style={{ fontSize:13, color:'rgba(255,255,255,0.8)' }}>{detail.director}</div> <div style={{ fontSize:13, color:'rgba(255,255,255,0.8)' }}>{detail.director}</div>
</div> </div>
)} )}
{detail.cast?.length > 0 && (
{/* Cast */}
{detail?.cast?.length > 0 && (
<div style={{ marginTop:14 }}> <div style={{ marginTop:14 }}>
<div style={{ ...S.head, marginBottom:6 }}>BESETZUNG</div> <div style={{ ...S.head, marginBottom:6 }}>BESETZUNG</div>
<div style={{ display:'flex', flexWrap:'wrap', gap:6 }}> <div style={{ display:'flex', flexWrap:'wrap', gap:6 }}>
{detail.cast.map(name => ( {detail.cast.map(name => (
<span key={name} style={{ fontSize:11, color:'rgba(255,255,255,0.65)', <span key={name} style={{ fontSize:11, color:'rgba(255,255,255,0.65)',
background:'rgba(255,255,255,0.06)', borderRadius:4, background:'rgba(255,255,255,0.06)', borderRadius:4, padding:'3px 8px', fontFamily:'monospace' }}>
padding:'3px 8px', fontFamily:'monospace' }}>{name}</span> {name}
</span>
))} ))}
</div> </div>
</div> </div>
)} )}
</>)}
</div> </div>
</div> </div>
</div> </div>
@@ -504,11 +547,25 @@ function Badge({ children, bg, color }) {
); );
} }
function StatusBox({ children, color }) { function StatusBox({ children }) {
return ( return (
<div style={{ padding:'28px 20px', textAlign:'center', <div style={{ padding:'28px 20px', textAlign:'center',
color: color || 'rgba(255,255,255,0.35)', fontFamily:"'Space Mono',monospace", fontSize:13 }}> color:'rgba(255,255,255,0.35)', fontFamily:"'Space Mono',monospace", fontSize:13 }}>
{children} {children}
</div> </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>
);
}