diff --git a/backend/src/db.js b/backend/src/db.js index 6535389..2a71f4b 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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 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(); diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js index 029268b..7fcb382 100644 --- a/backend/src/tools/media/routes.js +++ b/backend/src/tools/media/routes.js @@ -23,59 +23,168 @@ async function tmdb(path, params = {}) { 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) => { try { - const data = await tmdb('/movie/now_playing'); - res.json(data.results.slice(0, 10)); + const [listData, genreMap] = await Promise.all([ + 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) { res.status(500).json({ error: e.message }); } }); +// GET /api/tools/media/upcoming router.get('/upcoming', authenticate, async (req, res) => { try { - const data = await tmdb('/movie/upcoming'); - res.json(data.results.slice(0, 10)); + const [listData, genreMap] = await Promise.all([ + 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) { 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) => { 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); res.json(rows); }); +// POST /api/tools/media/favorites 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' }); try { db.prepare( - 'INSERT OR IGNORE INTO movie_favorites (user_id,tmdb_id,title,poster_path,release_date_de) VALUES (?,?,?,?,?)' - ).run(req.user.id, tmdb_id, title||'', poster_path||'', release_date_de||''); + `INSERT OR IGNORE INTO movie_favorites + (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 }); } catch (e) { res.status(500).json({ error: e.message }); } }); +// DELETE /api/tools/media/favorites/:tmdb_id router.delete('/favorites/:tmdb_id', authenticate, (req, res) => { db.prepare('DELETE FROM movie_favorites WHERE user_id=? AND tmdb_id=?') .run(req.user.id, Number(req.params.tmdb_id)); res.json({ ok: true }); }); +// GET /api/tools/media/settings router.get('/settings', authenticate, requireAdmin, (req, res) => { - const token = getToken(); - res.json({ configured: !!token }); + res.json({ configured: !!getToken() }); }); +// PUT /api/tools/media/settings router.put('/settings', authenticate, requireAdmin, (req, res) => { const { tmdb_token } = req.body; if (typeof tmdb_token !== 'string') return res.status(400).json({ error: 'tmdb_token fehlt' }); db.prepare('INSERT OR REPLACE INTO admin_settings (key,value) VALUES (?,?)').run('tmdb_token', tmdb_token.trim()); + genreCache = null; // Cache invalidieren res.json({ ok: true }); }); diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index 2115b75..c4a259f 100644 --- a/frontend/src/tools/media.jsx +++ b/frontend/src/tools/media.jsx @@ -1,29 +1,42 @@ import { useState, useEffect, useCallback, useRef } from 'react'; 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 = [ - { 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: '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 }, ]; const getIcon = lbl => [...lbl][0] ?? '🎬'; 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 [modal, setModal] = useState(null); // tmdb_id const chipScrollRef = useRef(null); const [chipScroll, setChipScroll] = useState({ left: false, right: true }); const onChipScroll = () => { const el = chipScrollRef.current; if (!el) return; - setChipScroll({ - left: el.scrollLeft > 4, - right: el.scrollLeft < el.scrollWidth - el.clientWidth - 4, - }); + setChipScroll({ left: el.scrollLeft > 4, right: el.scrollLeft < el.scrollWidth - el.clientWidth - 4 }); }; const active = TOOL_DEFS.find(t => t.id === activeTool); @@ -42,7 +55,7 @@ export default function Media({ toast, mobile, nav }) { {chipScroll.left && ( + borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14 }}>‹ )}
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)', - borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14, lineHeight:1 }}>› + borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14 }}>› )}
@@ -81,11 +94,8 @@ export default function Media({ toast, mobile, nav }) { }}> {getIcon(t.label)}
-
+
{getText(t.label)}
@@ -97,71 +107,87 @@ export default function Media({ toast, mobile, nav }) {
)} - {/* Inhalts-Card */}
{active?.label}
{active?.desc}
- {activeTool === 'kino' && } - {activeTool === 'demnächst' && } - {activeTool === 'favoriten' && } + {activeTool === 'kino' && } + {activeTool === 'demnächst' && } + {activeTool === 'favoriten' && }
+ + {modal && setModal(null)} mobile={mobile} />}
); } // ── Film-Karte ──────────────────────────────────────────────────────────────── -function MovieCard({ movie, rank, favIds, onToggleFav }) { +function MovieCard({ movie, favIds, onToggleFav, onOpenModal }) { const isFav = favIds.has(movie.id); - const releaseDE = movie.release_date - ? new Date(movie.release_date).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }) - : ''; + const fskStyle = FSK_COLOR(movie.fsk); return (
-
+ {/* Poster */} +
onOpenModal(movie.id)}> {movie.poster_path ? {movie.title} :
🎬
} - {rank && ( -
#{rank}
+ {/* Rank */} + {movie.rank && ( +
#{movie.rank}
)} + {/* Rating */} {movie.vote_average > 0 && ( -
★ {movie.vote_average.toFixed(1)}
+
★ {movie.vote_average.toFixed(1)}
)} -
-
-
onOpenModal(movie.id)}> +
{movie.title}
- {releaseDE && ( -
- {releaseDE} + display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical' }}> + {movie.title} +
+ {movie.genres?.length > 0 && ( +
+ {movie.genres.slice(0,2).join(' · ')} +
+ )} + {movie.release_date && ( +
+ {fmtDE(movie.release_date)}
)}
@@ -170,17 +196,15 @@ function MovieCard({ movie, rank, favIds, onToggleFav }) { } // ── Kino/Demnächst Grid ─────────────────────────────────────────────────────── -function KinoGrid({ endpoint, emptyText }) { +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 () => { - try { - const data = await api('/tools/media/favorites'); - setFavIds(new Set(data.map(f => f.tmdb_id))); - } catch (_) {} + try { const d = await api('/tools/media/favorites'); setFavIds(new Set(d.map(f => f.tmdb_id))); } + catch (_) {} }, []); useEffect(() => { @@ -198,12 +222,13 @@ function KinoGrid({ endpoint, emptyText }) { 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_de: movie.release_date - ? new Date(movie.release_date).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }) - : '', + 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])); } @@ -224,20 +249,16 @@ function KinoGrid({ endpoint, emptyText }) { if (!movies.length) return {emptyText}; return ( -
- {movies.map((m, i) => ( - +
+ {movies.map(m => ( + ))}
); } -// ── Favoriten Grid ──────────────────────────────────────────────────────────── -function FavoritenGrid() { +// ── Favoriten Kalender ──────────────────────────────────────────────────────── +function FavoritenKalender({ onOpenModal }) { const [favs, setFavs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -258,37 +279,73 @@ function FavoritenGrid() { if (error) return ⚠ {error}; if (!favs.length) return Noch keine Favoriten – markiere Filme mit ❤; + // Gruppieren nach Jahr+Monat + const groups = {}; + for (const f of favs) { + const d = f.release_date ? new Date(f.release_date) : null; + const key = d ? `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}` : 'unbekannt'; + const label = d ? d.toLocaleDateString('de-DE', { month:'long', year:'numeric' }) : 'Unbekannt'; + if (!groups[key]) groups[key] = { label, items:[] }; + groups[key].items.push(f); + } + return ( -
- {favs.map(f => ( -
-
- {f.poster_path - ? {f.title} - :
🎬
- } - +
+ {Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => ( +
+ {/* Monats-Überschrift */} +
+ {group.label.toUpperCase()} +
+ {group.items.length} Film{group.items.length!==1?'e':''}
-
-
{f.title}
- {f.release_date_de && ( -
- 🇩🇪 {f.release_date_de} -
- )} + {/* Film-Zeilen */} +
+ {group.items.map(f => { + const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })(); + const fskStyle = FSK_COLOR(f.fsk); + return ( +
onOpenModal(f.tmdb_id)}> + {/* Mini-Poster */} +
+ {f.poster_path + ? {f.title} + :
🎬
+ } +
+ {/* Text */} +
+
{f.title}
+
+ 🇩🇪 {f.release_date_de || '–'} +
+ {genres.length > 0 && ( +
+ {genres.slice(0,3).join(' · ')} +
+ )} + {fskStyle && ( +
FSK {f.fsk}
+ )} +
+ {/* Entfernen */} + +
+ ); + })}
))} @@ -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 ( +
+
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 && ( +
+ +
+
+ )} + + {/* Schließen */} + + +
+ {loading && ⏳ Lädt…} + {error && ⚠ {error}} + {detail && ( +
+ {/* Poster */} +
+ {detail.poster_path + ? {detail.title} + :
🎬
+ } +
+ + {/* Info */} +
+
{detail.title}
+ {detail.tagline && ( +
+ {detail.tagline} +
+ )} + {/* Badges */} +
+ {detail.vote_average > 0 && ( + ★ {detail.vote_average.toFixed(1)} + )} + {detail.runtime > 0 && ( + ⏱ {detail.runtime} Min + )} + {fskStyle && ( + FSK {detail.fsk} + )} + {detail.release_date && ( + 🇩🇪 {fmtDE(detail.release_date)} + )} +
+ {/* Genres */} + {detail.genres?.length > 0 && ( +
+ {detail.genres.map(g => ( + {g} + ))} +
+ )} +
+
+ )} + + {/* Plot */} + {detail?.overview && ( +
+
HANDLUNG
+
{detail.overview}
+
+ )} + + {/* Regie */} + {detail?.director && ( +
+
REGIE
+
{detail.director}
+
+ )} + + {/* Cast */} + {detail?.cast?.length > 0 && ( +
+
BESETZUNG
+
+ {detail.cast.map(name => ( + {name} + ))} +
+
+ )} +
+
+
+ ); +} + +function Badge({ children, bg, color }) { + return ( + + {children} + + ); +} + function StatusBox({ children, color }) { return ( -
+
{children}
);