feat: media – Genres, FSK-Badges, TR-Filter, Film-Modal, Favoriten-Kalender
This commit is contained in:
@@ -545,6 +545,17 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='mov
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// movie_favorites neue Spalten (Migration)
|
||||||
|
{
|
||||||
|
const cols = db.pragma('table_info(movie_favorites)').map(c => c.name);
|
||||||
|
if (!cols.includes('release_date'))
|
||||||
|
db.exec("ALTER TABLE movie_favorites ADD COLUMN release_date TEXT NOT NULL DEFAULT ''");
|
||||||
|
if (!cols.includes('genres'))
|
||||||
|
db.exec("ALTER TABLE movie_favorites ADD COLUMN genres TEXT NOT NULL DEFAULT '[]'");
|
||||||
|
if (!cols.includes('fsk'))
|
||||||
|
db.exec("ALTER TABLE movie_favorites ADD COLUMN fsk TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
|
||||||
// TMDb-Token Default
|
// TMDb-Token Default
|
||||||
if (!db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get())
|
if (!db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get())
|
||||||
db.prepare("INSERT INTO admin_settings (key,value) VALUES ('tmdb_token','')").run();
|
db.prepare("INSERT INTO admin_settings (key,value) VALUES ('tmdb_token','')").run();
|
||||||
|
|||||||
@@ -23,59 +23,168 @@ async function tmdb(path, params = {}) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Genre-Cache (in-memory, wird bei erstem Aufruf befüllt)
|
||||||
|
let genreCache = null;
|
||||||
|
async function getGenreMap() {
|
||||||
|
if (genreCache) return genreCache;
|
||||||
|
const data = await tmdb('/genre/movie/list');
|
||||||
|
genreCache = {};
|
||||||
|
for (const g of data.genres) genreCache[g.id] = g.name;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einen Film anreichern (Genres + FSK aus append_to_response)
|
||||||
|
function enrichMovie(movie, genreMap, releaseDates) {
|
||||||
|
return {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tools/media/now-playing
|
||||||
router.get('/now-playing', authenticate, async (req, res) => {
|
router.get('/now-playing', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = await tmdb('/movie/now_playing');
|
const [listData, genreMap] = await Promise.all([
|
||||||
res.json(data.results.slice(0, 10));
|
tmdb('/movie/now_playing'),
|
||||||
|
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);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message });
|
res.status(500).json({ error: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/tools/media/upcoming
|
||||||
router.get('/upcoming', authenticate, async (req, res) => {
|
router.get('/upcoming', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = await tmdb('/movie/upcoming');
|
const [listData, genreMap] = await Promise.all([
|
||||||
res.json(data.results.slice(0, 10));
|
tmdb('/movie/upcoming'),
|
||||||
|
getGenreMap(),
|
||||||
|
]);
|
||||||
|
const filtered = listData.results
|
||||||
|
.filter(m => m.original_language !== 'tr')
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
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/movie/:id – Details + 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,
|
||||||
|
original_title: detail.original_title,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tools/media/favorites
|
||||||
router.get('/favorites', authenticate, (req, res) => {
|
router.get('/favorites', authenticate, (req, res) => {
|
||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
'SELECT * FROM movie_favorites WHERE user_id=? ORDER BY added_at DESC'
|
'SELECT * FROM movie_favorites WHERE user_id=? ORDER BY release_date ASC, title ASC'
|
||||||
).all(req.user.id);
|
).all(req.user.id);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/tools/media/favorites
|
||||||
router.post('/favorites', authenticate, (req, res) => {
|
router.post('/favorites', authenticate, (req, res) => {
|
||||||
const { tmdb_id, title, poster_path, release_date_de } = req.body;
|
const { tmdb_id, title, poster_path, release_date_de, release_date, genres, fsk } = req.body;
|
||||||
if (!tmdb_id) return res.status(400).json({ error: 'tmdb_id fehlt' });
|
if (!tmdb_id) return res.status(400).json({ error: 'tmdb_id fehlt' });
|
||||||
try {
|
try {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'INSERT OR IGNORE INTO movie_favorites (user_id,tmdb_id,title,poster_path,release_date_de) VALUES (?,?,?,?,?)'
|
`INSERT OR IGNORE INTO movie_favorites
|
||||||
).run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||'');
|
(user_id,tmdb_id,title,poster_path,release_date_de,release_date,genres,fsk)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)`
|
||||||
|
).run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||'',
|
||||||
|
release_date||'', JSON.stringify(genres||[]), fsk||'');
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message });
|
res.status(500).json({ error: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DELETE /api/tools/media/favorites/:tmdb_id
|
||||||
router.delete('/favorites/:tmdb_id', authenticate, (req, res) => {
|
router.delete('/favorites/:tmdb_id', authenticate, (req, res) => {
|
||||||
db.prepare('DELETE FROM movie_favorites WHERE user_id=? AND tmdb_id=?')
|
db.prepare('DELETE FROM movie_favorites WHERE user_id=? AND tmdb_id=?')
|
||||||
.run(req.user.id, Number(req.params.tmdb_id));
|
.run(req.user.id, Number(req.params.tmdb_id));
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/tools/media/settings
|
||||||
router.get('/settings', authenticate, requireAdmin, (req, res) => {
|
router.get('/settings', authenticate, requireAdmin, (req, res) => {
|
||||||
const token = getToken();
|
res.json({ configured: !!getToken() });
|
||||||
res.json({ configured: !!token });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /api/tools/media/settings
|
||||||
router.put('/settings', authenticate, requireAdmin, (req, res) => {
|
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
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,42 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
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 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 – Top 10 (DE)', ready: true },
|
||||||
{ id: 'demnächst', label: '🗓 Demnächst', desc: 'Bald im Kino – Vorschau (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: 'favoriten', label: '❤ Favoriten', desc: 'Deine gespeicherten Lieblingsfilme', ready: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getIcon = lbl => [...lbl][0] ?? '🎬';
|
const getIcon = lbl => [...lbl][0] ?? '🎬';
|
||||||
const getText = lbl => { const c = [...lbl]; return c.slice(1).join('').trim() || lbl; };
|
const getText = lbl => { const c = [...lbl]; return c.slice(1).join('').trim() || lbl; };
|
||||||
|
|
||||||
export default function Media({ toast, mobile, nav }) {
|
const fmtDE = d => d ? new Date(d).toLocaleDateString('de-DE',
|
||||||
|
{ day:'2-digit', month:'2-digit', year:'numeric' }) : '';
|
||||||
|
|
||||||
|
const FSK_COLOR = fsk => {
|
||||||
|
if (!fsk) return null;
|
||||||
|
const n = parseInt(fsk);
|
||||||
|
if (n === 0) return { bg:'#fff', color:'#000' };
|
||||||
|
if (n === 6) return { bg:'#f9e200', color:'#000' };
|
||||||
|
if (n === 12) return { bg:'#00a859', color:'#fff' };
|
||||||
|
if (n === 16) return { bg:'#0070bb', color:'#fff' };
|
||||||
|
if (n === 18) return { bg:'#e2001a', color:'#fff' };
|
||||||
|
return { bg:'#444', color:'#fff' };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Media({ toast, mobile }) {
|
||||||
const [activeTool, setActiveTool] = useState('kino');
|
const [activeTool, setActiveTool] = useState('kino');
|
||||||
|
const [modal, setModal] = useState(null); // tmdb_id
|
||||||
const chipScrollRef = useRef(null);
|
const chipScrollRef = useRef(null);
|
||||||
const [chipScroll, setChipScroll] = useState({ left: false, right: true });
|
const [chipScroll, setChipScroll] = useState({ left: false, right: true });
|
||||||
|
|
||||||
const onChipScroll = () => {
|
const onChipScroll = () => {
|
||||||
const el = chipScrollRef.current;
|
const el = chipScrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
setChipScroll({
|
setChipScroll({ left: el.scrollLeft > 4, right: el.scrollLeft < el.scrollWidth - el.clientWidth - 4 });
|
||||||
left: el.scrollLeft > 4,
|
|
||||||
right: el.scrollLeft < el.scrollWidth - el.clientWidth - 4,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const active = TOOL_DEFS.find(t => t.id === activeTool);
|
const active = TOOL_DEFS.find(t => t.id === activeTool);
|
||||||
@@ -42,7 +55,7 @@ export default function Media({ toast, mobile, nav }) {
|
|||||||
{chipScroll.left && (
|
{chipScroll.left && (
|
||||||
<button onClick={() => chipScrollRef.current?.scrollBy({ left:-160, behavior:'smooth' })}
|
<button onClick={() => chipScrollRef.current?.scrollBy({ left:-160, behavior:'smooth' })}
|
||||||
style={{ flexShrink:0, background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)',
|
style={{ flexShrink:0, background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)',
|
||||||
borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14, lineHeight:1 }}>‹</button>
|
borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14 }}>‹</button>
|
||||||
)}
|
)}
|
||||||
<div ref={chipScrollRef} onScroll={onChipScroll}
|
<div ref={chipScrollRef} onScroll={onChipScroll}
|
||||||
style={{ display:'flex', gap:6, overflowX:'auto', flex:1,
|
style={{ display:'flex', gap:6, overflowX:'auto', flex:1,
|
||||||
@@ -65,7 +78,7 @@ export default function Media({ toast, mobile, nav }) {
|
|||||||
{chipScroll.right && (
|
{chipScroll.right && (
|
||||||
<button onClick={() => chipScrollRef.current?.scrollBy({ left:160, behavior:'smooth' })}
|
<button onClick={() => chipScrollRef.current?.scrollBy({ left:160, behavior:'smooth' })}
|
||||||
style={{ flexShrink:0, background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)',
|
style={{ flexShrink:0, background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)',
|
||||||
borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14, lineHeight:1 }}>›</button>
|
borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14 }}>›</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,11 +94,8 @@ export default function Media({ toast, mobile, nav }) {
|
|||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize:17, flexShrink:0, lineHeight:1, minWidth:22, textAlign:'center' }}>{getIcon(t.label)}</span>
|
<span style={{ fontSize:17, flexShrink:0, lineHeight:1, minWidth:22, textAlign:'center' }}>{getIcon(t.label)}</span>
|
||||||
<div style={{ minWidth:0, flex:1 }}>
|
<div style={{ minWidth:0, flex:1 }}>
|
||||||
<div style={{
|
<div style={{ color: activeTool===t.id ? '#4ecdc4' : 'rgba(255,255,255,0.85)', fontSize:12,
|
||||||
color: activeTool===t.id ? '#4ecdc4' : 'rgba(255,255,255,0.85)',
|
fontWeight: activeTool===t.id?700:500, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||||||
fontSize:12, fontWeight: activeTool===t.id?700:500,
|
|
||||||
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
|
|
||||||
}}>
|
|
||||||
{getText(t.label)}
|
{getText(t.label)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color:'rgba(255,255,255,0.28)', fontSize:9, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', marginTop:1 }}>
|
<div style={{ color:'rgba(255,255,255,0.28)', fontSize:9, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', marginTop:1 }}>
|
||||||
@@ -97,71 +107,87 @@ export default function Media({ toast, mobile, nav }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Inhalts-Card */}
|
|
||||||
<div style={{ ...S.card }}>
|
<div style={{ ...S.card }}>
|
||||||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:18, flexWrap:'wrap' }}>
|
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:18, flexWrap:'wrap' }}>
|
||||||
<div style={{ ...S.head, marginBottom:0 }}>{active?.label}</div>
|
<div style={{ ...S.head, marginBottom:0 }}>{active?.label}</div>
|
||||||
<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 gefunden" />}
|
{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 gefunden" />}
|
{activeTool === 'demnächst' && <KinoGrid endpoint="/tools/media/upcoming" emptyText="Keine kommenden Filme" onOpenModal={setModal} />}
|
||||||
{activeTool === 'favoriten' && <FavoritenGrid />}
|
{activeTool === 'favoriten' && <FavoritenKalender onOpenModal={setModal} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{modal && <MovieModal tmdbId={modal} onClose={() => setModal(null)} mobile={mobile} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Film-Karte ────────────────────────────────────────────────────────────────
|
// ── Film-Karte ────────────────────────────────────────────────────────────────
|
||||||
function MovieCard({ movie, rank, favIds, onToggleFav }) {
|
function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) {
|
||||||
const isFav = favIds.has(movie.id);
|
const isFav = favIds.has(movie.id);
|
||||||
const releaseDE = movie.release_date
|
const fskStyle = FSK_COLOR(movie.fsk);
|
||||||
? new Date(movie.release_date).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' })
|
|
||||||
: '';
|
|
||||||
|
|
||||||
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:'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',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}>
|
{/* Poster */}
|
||||||
|
<div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}
|
||||||
|
onClick={() => onOpenModal(movie.id)}>
|
||||||
{movie.poster_path
|
{movie.poster_path
|
||||||
? <img src={POSTER + movie.poster_path} alt={movie.title}
|
? <img src={POSTER + movie.poster_path} alt={movie.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',
|
: <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 && (
|
{/* Rank */}
|
||||||
<div style={{
|
{movie.rank && (
|
||||||
position:'absolute', bottom:6, left:6,
|
<div style={{ position:'absolute', bottom:6, left:6, background:'rgba(0,0,0,0.85)',
|
||||||
background:'rgba(0,0,0,0.85)', borderRadius:5,
|
borderRadius:5, padding:'2px 8px', fontSize:13, color:'#fff', fontWeight:900,
|
||||||
padding:'2px 8px', fontSize:13, color:'#fff', fontWeight:900,
|
fontFamily:"'Space Mono',monospace" }}>#{movie.rank}</div>
|
||||||
fontFamily:"'Space Mono',monospace", lineHeight:1.4,
|
|
||||||
}}>#{rank}</div>
|
|
||||||
)}
|
)}
|
||||||
|
{/* Rating */}
|
||||||
{movie.vote_average > 0 && (
|
{movie.vote_average > 0 && (
|
||||||
<div style={{
|
<div style={{ position:'absolute', top:6, left:6, background:'rgba(0,0,0,0.8)',
|
||||||
position:'absolute', top:6, left:6,
|
borderRadius:5, padding:'2px 6px', fontSize:10, color:'#ffe66d', fontWeight:700,
|
||||||
background:'rgba(0,0,0,0.8)', borderRadius:5,
|
fontFamily:"'Space Mono',monospace" }}>★ {movie.vote_average.toFixed(1)}</div>
|
||||||
padding:'2px 6px', fontSize:10, color:'#ffe66d', fontWeight:700,
|
|
||||||
fontFamily:"'Space Mono',monospace",
|
|
||||||
}}>★ {movie.vote_average.toFixed(1)}</div>
|
|
||||||
)}
|
)}
|
||||||
<button onClick={() => onToggleFav(movie, isFav)} style={{
|
{/* FSK */}
|
||||||
|
{fskStyle && (
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Fav */}
|
||||||
|
<button onClick={e => { e.stopPropagation(); onToggleFav(movie, isFav); }} style={{
|
||||||
position:'absolute', top:5, right:5, background:'rgba(0,0,0,0.65)',
|
position:'absolute', top:5, right:5, background:'rgba(0,0,0,0.65)',
|
||||||
border:'none', borderRadius:'50%', width:30, height:30, cursor:'pointer',
|
border:'none', borderRadius:'50%', width:30, height:30, cursor:'pointer',
|
||||||
fontSize:14, display:'flex', alignItems:'center', justifyContent:'center',
|
fontSize:14, display:'flex', alignItems:'center', justifyContent:'center',
|
||||||
}}>{isFav ? '❤' : '🤍'}</button>
|
}}>{isFav ? '❤' : '🤍'}</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}>
|
|
||||||
<div style={{
|
{/* Info */}
|
||||||
fontSize:12, fontWeight:700, color:'#fff', lineHeight:1.3,
|
<div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}
|
||||||
|
onClick={() => onOpenModal(movie.id)}>
|
||||||
|
<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' }}>
|
||||||
}}>{movie.title}</div>
|
{movie.title}
|
||||||
{releaseDE && (
|
</div>
|
||||||
<div style={{ fontSize:10, color:'rgba(255,255,255,0.35)', fontFamily:"'Space Mono',monospace" }}>
|
{movie.genres?.length > 0 && (
|
||||||
{releaseDE}
|
<div style={{ fontSize:9, color:'#4ecdc4', fontFamily:'monospace', overflow:'hidden',
|
||||||
|
textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||||||
|
{movie.genres.slice(0,2).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{movie.release_date && (
|
||||||
|
<div style={{ fontSize:9, color:'rgba(255,255,255,0.3)', fontFamily:'monospace' }}>
|
||||||
|
{fmtDE(movie.release_date)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -170,17 +196,15 @@ function MovieCard({ movie, rank, favIds, onToggleFav }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Kino/Demnächst Grid ───────────────────────────────────────────────────────
|
// ── Kino/Demnächst Grid ───────────────────────────────────────────────────────
|
||||||
function KinoGrid({ endpoint, emptyText }) {
|
function KinoGrid({ endpoint, emptyText, onOpenModal }) {
|
||||||
const [movies, setMovies] = useState([]);
|
const [movies, setMovies] = useState([]);
|
||||||
const [favIds, setFavIds] = useState(new Set());
|
const [favIds, setFavIds] = useState(new Set());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const loadFavs = useCallback(async () => {
|
const loadFavs = useCallback(async () => {
|
||||||
try {
|
try { const d = await api('/tools/media/favorites'); setFavIds(new Set(d.map(f => f.tmdb_id))); }
|
||||||
const data = await api('/tools/media/favorites');
|
catch (_) {}
|
||||||
setFavIds(new Set(data.map(f => f.tmdb_id)));
|
|
||||||
} catch (_) {}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -198,12 +222,13 @@ function KinoGrid({ endpoint, emptyText }) {
|
|||||||
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_de: movie.release_date
|
release_date: movie.release_date || '',
|
||||||
? new Date(movie.release_date).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' })
|
release_date_de: fmtDE(movie.release_date),
|
||||||
: '',
|
genres: movie.genres || [],
|
||||||
|
fsk: movie.fsk || '',
|
||||||
}});
|
}});
|
||||||
setFavIds(prev => new Set([...prev, movie.id]));
|
setFavIds(prev => new Set([...prev, movie.id]));
|
||||||
}
|
}
|
||||||
@@ -224,20 +249,16 @@ function KinoGrid({ endpoint, emptyText }) {
|
|||||||
if (!movies.length) return <StatusBox>{emptyText}</StatusBox>;
|
if (!movies.length) return <StatusBox>{emptyText}</StatusBox>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px, 1fr))', gap:12 }}>
|
||||||
display:'grid',
|
{movies.map(m => (
|
||||||
gridTemplateColumns:'repeat(auto-fill, minmax(130px, 1fr))',
|
<MovieCard key={m.id} movie={m} favIds={favIds} onToggleFav={toggleFav} onOpenModal={onOpenModal} />
|
||||||
gap:12,
|
|
||||||
}}>
|
|
||||||
{movies.map((m, i) => (
|
|
||||||
<MovieCard key={m.id} movie={m} rank={i+1} favIds={favIds} onToggleFav={toggleFav} />
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Favoriten Grid ────────────────────────────────────────────────────────────
|
// ── Favoriten Kalender ────────────────────────────────────────────────────────
|
||||||
function FavoritenGrid() {
|
function FavoritenKalender({ onOpenModal }) {
|
||||||
const [favs, setFavs] = useState([]);
|
const [favs, setFavs] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -258,37 +279,73 @@ function FavoritenGrid() {
|
|||||||
if (error) return <StatusBox color="#ff6b9d">⚠ {error}</StatusBox>;
|
if (error) return <StatusBox color="#ff6b9d">⚠ {error}</StatusBox>;
|
||||||
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 = {};
|
||||||
|
for (const f of favs) {
|
||||||
|
const d = f.release_date ? new Date(f.release_date) : null;
|
||||||
|
const key = d ? `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}` : 'unbekannt';
|
||||||
|
const label = d ? d.toLocaleDateString('de-DE', { month:'long', year:'numeric' }) : 'Unbekannt';
|
||||||
|
if (!groups[key]) groups[key] = { label, items:[] };
|
||||||
|
groups[key].items.push(f);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px, 1fr))', gap:12 }}>
|
<div style={{ display:'flex', flexDirection:'column', gap:24 }}>
|
||||||
{favs.map(f => (
|
{Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
|
||||||
<div key={f.tmdb_id} style={{
|
<div key={key}>
|
||||||
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.08)',
|
{/* Monats-Überschrift */}
|
||||||
borderRadius:10, overflow:'hidden', display:'flex', flexDirection:'column',
|
<div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}>
|
||||||
}}>
|
<span>{group.label.toUpperCase()}</span>
|
||||||
<div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}>
|
<div style={{ flex:1, height:1, background:'rgba(255,255,255,0.07)' }}/>
|
||||||
{f.poster_path
|
<span style={{ fontSize:9 }}>{group.items.length} Film{group.items.length!==1?'e':''}</span>
|
||||||
? <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:36, color:'rgba(255,255,255,0.1)' }}>🎬</div>
|
|
||||||
}
|
|
||||||
<button onClick={() => removeFav(f.tmdb_id)} style={{
|
|
||||||
position:'absolute', top:5, right:5, background:'rgba(0,0,0,0.65)',
|
|
||||||
border:'none', borderRadius:'50%', width:30, height:30, cursor:'pointer',
|
|
||||||
fontSize:14, display:'flex', alignItems:'center', justifyContent:'center',
|
|
||||||
}}>❤</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}>
|
{/* Film-Zeilen */}
|
||||||
<div style={{
|
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
|
||||||
fontSize:12, fontWeight:700, color:'#fff', lineHeight:1.3,
|
{group.items.map(f => {
|
||||||
fontFamily:"'Space Mono',monospace", overflow:'hidden',
|
const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })();
|
||||||
display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical',
|
const fskStyle = FSK_COLOR(f.fsk);
|
||||||
}}>{f.title}</div>
|
return (
|
||||||
{f.release_date_de && (
|
<div key={f.tmdb_id} style={{
|
||||||
<div style={{ fontSize:10, color:'rgba(255,255,255,0.35)', fontFamily:"'Space Mono',monospace" }}>
|
display:'flex', gap:12, alignItems:'flex-start',
|
||||||
🇩🇪 {f.release_date_de}
|
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.06)',
|
||||||
</div>
|
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>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
<div style={{ fontSize:10, color:'rgba(255,255,255,0.4)', marginTop:3, fontFamily:'monospace' }}>
|
||||||
|
🇩🇪 {f.release_date_de || '–'}
|
||||||
|
</div>
|
||||||
|
{genres.length > 0 && (
|
||||||
|
<div style={{ fontSize:10, color:'#4ecdc4', marginTop:2, fontFamily:'monospace' }}>
|
||||||
|
{genres.slice(0,3).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fskStyle && (
|
||||||
|
<div style={{ display:'inline-block', marginTop:4, background:fskStyle.bg,
|
||||||
|
borderRadius:3, padding:'1px 5px', fontSize:9, color:fskStyle.color,
|
||||||
|
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',
|
||||||
|
}}>✕</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -296,13 +353,155 @@ function FavoritenGrid() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Film-Detail-Modal ─────────────────────────────────────────────────────────
|
||||||
|
function MovieModal({ tmdbId, onClose, mobile }) {
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true); setError('');
|
||||||
|
api(`/tools/media/movie/${tmdbId}`)
|
||||||
|
.then(setDetail)
|
||||||
|
.catch(e => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [tmdbId]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
display:'flex', alignItems: mobile ? 'flex-end' : 'center', justifyContent:'center',
|
||||||
|
backdropFilter:'blur(4px)',
|
||||||
|
}}>
|
||||||
|
<div onClick={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',
|
||||||
|
overflowY:'auto', position:'relative',
|
||||||
|
}}>
|
||||||
|
{/* Backdrop */}
|
||||||
|
{detail?.backdrop_path && (
|
||||||
|
<div style={{ position:'relative', height:180, overflow:'hidden',
|
||||||
|
borderRadius: mobile ? '16px 16px 0 0' : '16px 16px 0 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)' }} />
|
||||||
|
</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,
|
||||||
|
}}>✕</button>
|
||||||
|
|
||||||
|
<div style={{ padding:'16px 20px 24px' }}>
|
||||||
|
{loading && <StatusBox>⏳ Lädt…</StatusBox>}
|
||||||
|
{error && <StatusBox color="#ff6b9d">⚠ {error}</StatusBox>}
|
||||||
|
{detail && (
|
||||||
|
<div style={{ display:'flex', gap:16, alignItems:'flex-start' }}>
|
||||||
|
{/* Poster */}
|
||||||
|
<div style={{ width:90, 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>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ flex:1, minWidth:0 }}>
|
||||||
|
<div style={{ fontSize:16, 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>
|
||||||
|
)}
|
||||||
|
{/* 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 && (
|
||||||
|
<div style={{ display:'flex', gap:4, flexWrap:'wrap', marginBottom:10 }}>
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ children, bg, color }) {
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize:10, background:bg, color, borderRadius:4,
|
||||||
|
padding:'2px 7px', fontFamily:"'Space Mono',monospace", fontWeight:700 }}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatusBox({ children, color }) {
|
function StatusBox({ children, color }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{ padding:'28px 20px', textAlign:'center',
|
||||||
padding:'28px 20px', textAlign:'center',
|
color: color || 'rgba(255,255,255,0.35)', fontFamily:"'Space Mono',monospace", fontSize:13 }}>
|
||||||
color: color || 'rgba(255,255,255,0.35)',
|
|
||||||
fontFamily:"'Space Mono',monospace", fontSize:13,
|
|
||||||
}}>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user