feat: media – portal modal scroll fix, alle Kinofilme, KW-Neustarts, Top-Badges, GlobalSearch
This commit is contained in:
@@ -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']},
|
||||
|
||||
@@ -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 (
|
||||
<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' }}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Tool-Auswahl */}
|
||||
{mobile ? (
|
||||
<div style={{ marginBottom:16 }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:4 }}>
|
||||
@@ -113,28 +122,30 @@ export default function Media({ toast, mobile }) {
|
||||
<span style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10 }}>{active?.desc}</span>
|
||||
</div>
|
||||
|
||||
{activeTool === 'kino' && <KinoGrid endpoint="/tools/media/now-playing" emptyText="Keine aktuellen Kinofilme" onOpenModal={setModal} />}
|
||||
{activeTool === 'demnächst' && <KinoGrid endpoint="/tools/media/upcoming" emptyText="Keine kommenden Filme" onOpenModal={setModal} />}
|
||||
{activeTool === 'kino' && <KinoGrid onOpenModal={setModal} />}
|
||||
{activeTool === 'demnächst' && <DemnächstGrid onOpenModal={setModal} />}
|
||||
{activeTool === 'favoriten' && <FavoritenKalender onOpenModal={setModal} />}
|
||||
</div>
|
||||
|
||||
{modal && <MovieModal tmdbId={modal} onClose={() => setModal(null)} mobile={mobile} />}
|
||||
{modal && createPortal(
|
||||
<MovieModal tmdbId={modal} onClose={() => setModal(null)} mobile={mobile} />,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{
|
||||
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius:10, overflow:'hidden', display:'flex', flexDirection:'column',
|
||||
cursor:'pointer',
|
||||
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', cursor:'pointer',
|
||||
}}>
|
||||
{/* Poster */}
|
||||
<div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}
|
||||
onClick={() => onOpenModal(movie.id)}>
|
||||
{movie.poster_path
|
||||
@@ -143,11 +154,14 @@ function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) {
|
||||
: <div style={{ width:'100%', height:'100%', display:'flex', alignItems:'center',
|
||||
justifyContent:'center', fontSize:36, color:'rgba(255,255,255,0.1)' }}>🎬</div>
|
||||
}
|
||||
{/* Rank */}
|
||||
{movie.rank && (
|
||||
{/* Top-Badge */}
|
||||
{isTop && (
|
||||
<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,
|
||||
fontFamily:"'Space Mono',monospace" }}>#{movie.rank}</div>
|
||||
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}
|
||||
</div>
|
||||
)}
|
||||
{/* Rating */}
|
||||
{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,
|
||||
borderRadius:4, padding:'1px 5px', fontSize:9, color:fskStyle.color,
|
||||
fontWeight:900, fontFamily:"'Space Mono',monospace", lineHeight:1.5 }}>
|
||||
FSK {movie.fsk}
|
||||
{movie.fsk}
|
||||
</div>
|
||||
)}
|
||||
{/* Fav */}
|
||||
@@ -170,10 +184,13 @@ function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) {
|
||||
fontSize:14, display:'flex', alignItems:'center', justifyContent:'center',
|
||||
}}>{isFav ? '❤' : '🤍'}</button>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}
|
||||
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,
|
||||
fontFamily:"'Space Mono',monospace", overflow:'hidden',
|
||||
display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical' }}>
|
||||
@@ -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 <StatusBox>⏳ Lädt…</StatusBox>;
|
||||
if (error) return (
|
||||
<StatusBox color="#ff6b9d">
|
||||
<div>⚠ {error}</div>
|
||||
{error.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>
|
||||
)}
|
||||
</StatusBox>
|
||||
);
|
||||
if (!movies.length) return <StatusBox>{emptyText}</StatusBox>;
|
||||
if (loading) return <StatusBox>⏳ Lädt Kinocharts…</StatusBox>;
|
||||
if (error) return <ErrorBox msg={error} />;
|
||||
if (!movies.length) return <StatusBox>Keine Filme gefunden</StatusBox>;
|
||||
|
||||
const top10 = movies.filter(m => m.rank != null);
|
||||
const rest = movies.filter(m => m.rank == null);
|
||||
|
||||
return (
|
||||
<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={toggleFav} onOpenModal={onOpenModal} />
|
||||
<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 }}>
|
||||
{rest.map(m => <MovieCard key={m.id} movie={m} favIds={favIds} onToggleFav={onToggle} 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>
|
||||
);
|
||||
@@ -276,14 +343,13 @@ function FavoritenKalender({ onOpenModal }) {
|
||||
};
|
||||
|
||||
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>;
|
||||
|
||||
// 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 }) {
|
||||
<div style={{ display:'flex', flexDirection:'column', gap:24 }}>
|
||||
{Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
|
||||
<div key={key}>
|
||||
{/* Monats-Überschrift */}
|
||||
<div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}>
|
||||
<span>{group.label.toUpperCase()}</span>
|
||||
<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>
|
||||
</div>
|
||||
{/* Film-Zeilen */}
|
||||
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
|
||||
{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 (
|
||||
<div key={f.tmdb_id} style={{
|
||||
<div key={f.tmdb_id} onClick={() => 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 */}
|
||||
}}>
|
||||
<div style={{ width:44, height:66, flexShrink:0, borderRadius:5, overflow:'hidden', background:'#111' }}>
|
||||
{f.poster_path
|
||||
? <img src={POSTER + f.poster_path} alt={f.title}
|
||||
style={{ width:'100%', height:'100%', objectFit:'cover', display:'block' }} />
|
||||
: <div style={{ width:'100%', height:'100%', display:'flex', alignItems:'center',
|
||||
justifyContent:'center', fontSize:18 }}>🎬</div>
|
||||
? <img src={POSTER + f.poster_path} alt={f.title} 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>
|
||||
{/* Text */}
|
||||
<div style={{ flex:1, minWidth:0 }}>
|
||||
<div style={{ fontSize:13, fontWeight:700, color:'#fff', fontFamily:"'Space Mono',monospace",
|
||||
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.title}</div>
|
||||
@@ -337,11 +397,9 @@ function FavoritenKalender({ onOpenModal }) {
|
||||
fontWeight:900, fontFamily:'monospace' }}>FSK {f.fsk}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Entfernen */}
|
||||
<button onClick={e => { e.stopPropagation(); removeFav(f.tmdb_id); }} style={{
|
||||
background:'none', border:'none', color:'rgba(255,107,157,0.6)',
|
||||
cursor:'pointer', fontSize:16, flexShrink:0, padding:'0 2px',
|
||||
alignSelf:'center',
|
||||
cursor:'pointer', fontSize:16, flexShrink:0, padding:'0 2px', alignSelf:'center',
|
||||
}}>✕</button>
|
||||
</div>
|
||||
);
|
||||
@@ -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 (
|
||||
<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',
|
||||
backdropFilter:'blur(4px)',
|
||||
}}>
|
||||
<div
|
||||
onClick={e => 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',
|
||||
}}>
|
||||
<div onClick={e => 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 && (
|
||||
<div style={{ position:'relative', height:180, overflow:'hidden',
|
||||
borderRadius: mobile ? '16px 16px 0 0' : '16px 16px 0 0' }}>
|
||||
<div style={{ height:160, overflow:'hidden',
|
||||
borderRadius: mobile ? '16px 16px 0 0' : '16px 16px 0 0', flexShrink:0 }}>
|
||||
<img src={BACKDROP + detail.backdrop_path} alt=""
|
||||
style={{ width:'100%', height:'100%', objectFit:'cover', display:'block', opacity:0.5 }} />
|
||||
<div style={{ position:'absolute', inset:0,
|
||||
background:'linear-gradient(to bottom, transparent 40%, #1a1a1f)' }} />
|
||||
style={{ width:'100%', height:'100%', objectFit:'cover', display:'block', opacity:0.45 }} />
|
||||
<div style={{ position:'absolute', top:0, left:0, right:0, height:160,
|
||||
background:'linear-gradient(to bottom, transparent 30%, #1a1a1f)' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schließen */}
|
||||
<button onClick={onClose} style={{
|
||||
position:'absolute', top:12, right:12, background:'rgba(0,0,0,0.6)',
|
||||
border:'none', borderRadius:'50%', width:32, height:32, cursor:'pointer',
|
||||
color:'#fff', fontSize:16, display:'flex', alignItems:'center', justifyContent:'center',
|
||||
zIndex:10,
|
||||
position:'absolute', top:12, right:12, background:'rgba(0,0,0,0.7)',
|
||||
border:'none', borderRadius:'50%', width:34, height:34, cursor:'pointer',
|
||||
color:'#fff', fontSize:18, display:'flex', alignItems:'center', justifyContent:'center', zIndex:1,
|
||||
}}>✕</button>
|
||||
|
||||
<div style={{ padding:'16px 20px 24px' }}>
|
||||
<div style={{ padding:'16px 20px 28px' }}>
|
||||
{loading && <StatusBox>⏳ Lädt…</StatusBox>}
|
||||
{error && <StatusBox color="#ff6b9d">⚠ {error}</StatusBox>}
|
||||
{detail && (
|
||||
{error && <ErrorBox msg={error} />}
|
||||
{detail && (<>
|
||||
<div style={{ display:'flex', gap:16, alignItems:'flex-start' }}>
|
||||
{/* 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
|
||||
? <img src={POSTER + detail.poster_path} alt={detail.title}
|
||||
style={{ width:'100%', display:'block' }} />
|
||||
: <div style={{ height:135, display:'flex', alignItems:'center',
|
||||
justifyContent:'center', fontSize:28 }}>🎬</div>
|
||||
? <img src={POSTER + detail.poster_path} alt={detail.title} style={{ width:'100%', display:'block' }} />
|
||||
: <div style={{ height:132, display:'flex', alignItems:'center', justifyContent:'center', fontSize:28 }}>🎬</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<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>
|
||||
{detail.tagline && (
|
||||
<div style={{ fontSize:11, color:'rgba(255,255,255,0.4)', fontStyle:'italic', marginBottom:8 }}>
|
||||
{detail.tagline}
|
||||
</div>
|
||||
<div style={{ fontSize:11, color:'rgba(255,255,255,0.4)', fontStyle:'italic', marginBottom:8 }}>{detail.tagline}</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 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>
|
||||
{/* Genres */}
|
||||
{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 => (
|
||||
<span key={g} style={{ fontSize:10, color:'#4ecdc4', background:'rgba(78,205,196,0.1)',
|
||||
borderRadius:4, padding:'2px 7px', fontFamily:'monospace' }}>{g}</span>
|
||||
@@ -458,37 +505,33 @@ function MovieModal({ tmdbId, onClose, mobile }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plot */}
|
||||
{detail?.overview && (
|
||||
<div style={{ marginTop:16 }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Regie */}
|
||||
{detail?.director && (
|
||||
<div style={{ marginTop:14 }}>
|
||||
<div style={{ ...S.head, marginBottom:4 }}>REGIE</div>
|
||||
<div style={{ fontSize:13, color:'rgba(255,255,255,0.8)' }}>{detail.director}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cast */}
|
||||
{detail?.cast?.length > 0 && (
|
||||
<div style={{ marginTop:14 }}>
|
||||
<div style={{ ...S.head, marginBottom:6 }}>BESETZUNG</div>
|
||||
<div style={{ display:'flex', flexWrap:'wrap', gap:6 }}>
|
||||
{detail.cast.map(name => (
|
||||
<span key={name} style={{ fontSize:11, color:'rgba(255,255,255,0.65)',
|
||||
background:'rgba(255,255,255,0.06)', borderRadius:4,
|
||||
padding:'3px 8px', fontFamily:'monospace' }}>{name}</span>
|
||||
))}
|
||||
{detail.overview && (
|
||||
<div style={{ marginTop:18 }}>
|
||||
<div style={{ ...S.head, marginBottom:6 }}>HANDLUNG</div>
|
||||
<div style={{ fontSize:13, color:'rgba(255,255,255,0.75)', lineHeight:1.75 }}>{detail.overview}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{detail.director && (
|
||||
<div style={{ marginTop:14 }}>
|
||||
<div style={{ ...S.head, marginBottom:4 }}>REGIE</div>
|
||||
<div style={{ fontSize:13, color:'rgba(255,255,255,0.8)' }}>{detail.director}</div>
|
||||
</div>
|
||||
)}
|
||||
{detail.cast?.length > 0 && (
|
||||
<div style={{ marginTop:14 }}>
|
||||
<div style={{ ...S.head, marginBottom:6 }}>BESETZUNG</div>
|
||||
<div style={{ display:'flex', flexWrap:'wrap', gap:6 }}>
|
||||
{detail.cast.map(name => (
|
||||
<span key={name} style={{ fontSize:11, color:'rgba(255,255,255,0.65)',
|
||||
background:'rgba(255,255,255,0.06)', borderRadius:4, padding:'3px 8px', fontFamily:'monospace' }}>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -504,11 +547,25 @@ function Badge({ children, bg, color }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBox({ children, color }) {
|
||||
function StatusBox({ children }) {
|
||||
return (
|
||||
<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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user