From 9969079bdd1e8b8443df1e019f3e37ab22131b1a Mon Sep 17 00:00:00 2001 From: Dicken Date: Sat, 6 Jun 2026 16:08:54 +0200 Subject: [PATCH] feat: Kino-Feature mit TMDb-Proxy, Favoriten, Admin-Token-Einstellung --- backend/src/db.js | 21 +++ backend/src/index.js | 1 + backend/src/tools/media/routes.js | 90 +++++++++++ frontend/src/App.jsx | 38 +++++ frontend/src/tools/media.jsx | 243 ++++++++++++++++++++++++++---- 5 files changed, 360 insertions(+), 33 deletions(-) create mode 100644 backend/src/tools/media/routes.js diff --git a/backend/src/db.js b/backend/src/db.js index 221723e..6535389 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -528,4 +528,25 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cal db.exec('ALTER TABLE upload_shares ADD COLUMN short_url TEXT DEFAULT NULL'); } + +// ── Movie-Favoriten ─────────────────────────────────────────────────────────── +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='movie_favorites'").get()) { + db.exec(` + CREATE TABLE movie_favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tmdb_id INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT '', + poster_path TEXT NOT NULL DEFAULT '', + release_date_de TEXT NOT NULL DEFAULT '', + added_at DATETIME DEFAULT NULL, + UNIQUE(user_id, tmdb_id) + ) + `); +} + +// 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(); + module.exports = db; diff --git a/backend/src/index.js b/backend/src/index.js index c16df2f..8842041 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -69,6 +69,7 @@ fs.readdirSync(toolsDir, { withFileTypes: true }) app.use('/api/tools/snippets', require('./tools/snippets/routes')); app.use('/api/tools/qrcodes', require('./tools/qrcodes/routes')); app.use('/api/upload-shares', require('./tools/dateien/upload-share')); +app.use('/api/tools/media', require('./tools/media/routes')); app.use('/api/search', require('./routes/search')); // Öffentliche Upload-Seite: React-App ausliefern – App erkennt /u/ Pfad selbst diff --git a/backend/src/tools/media/routes.js b/backend/src/tools/media/routes.js new file mode 100644 index 0000000..2ad612c --- /dev/null +++ b/backend/src/tools/media/routes.js @@ -0,0 +1,90 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../../db'); + +const TMDB_BASE = 'https://api.themoviedb.org/3'; + +function getToken() { + return db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()?.value || ''; +} + +async function tmdb(path, params = {}) { + const token = getToken(); + if (!token) throw new Error('Kein TMDb API-Token konfiguriert'); + const url = new URL(TMDB_BASE + path); + url.searchParams.set('language', 'de-DE'); + url.searchParams.set('region', 'DE'); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } + }); + if (!res.ok) throw new Error(`TMDb Fehler: ${res.status}`); + return res.json(); +} + +// GET /api/tools/media/now-playing +router.get('/now-playing', async (req, res) => { + try { + const data = await tmdb('/movie/now_playing'); + res.json(data.results.slice(0, 10)); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/tools/media/upcoming +router.get('/upcoming', async (req, res) => { + try { + const data = await tmdb('/movie/upcoming'); + res.json(data.results.slice(0, 10)); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/tools/media/favorites +router.get('/favorites', (req, res) => { + const rows = db.prepare( + 'SELECT * FROM movie_favorites WHERE user_id=? ORDER BY added_at DESC' + ).all(req.user.id); + res.json(rows); +}); + +// POST /api/tools/media/favorites +router.post('/favorites', (req, res) => { + const { tmdb_id, title, poster_path, release_date_de } = 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||''); + 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', (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 (admin only – gibt Token maskiert zurück) +router.get('/settings', (req, res) => { + if (!req.user.is_admin) return res.status(403).json({ error: 'Nur Admin' }); + const token = getToken(); + res.json({ configured: !!token }); +}); + +// PUT /api/tools/media/settings (admin only) +router.put('/settings', (req, res) => { + if (!req.user.is_admin) return res.status(403).json({ error: 'Nur Admin' }); + 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()); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2c23f07..a2bff3d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2586,6 +2586,41 @@ const Field = ({ label, type='text', value, onChange, placeholder='', autoCapita ); // ── Admin: Upload-Freigabe Berechtigungen ───────────────────────────────────── +function TmdbSettings({ toast }) { + const [token, setToken] = useState(''); + const [configured, setConfigured] = useState(false); + const [busy, setBusy] = useState(false); + + useEffect(() => { + api('/tools/media/settings').then(d => setConfigured(d.configured)).catch(()=>{}); + }, []); + + const save = async () => { + if (!token.trim()) { toast('Token eingeben', 'error'); return; } + setBusy(true); + try { + await api('/tools/media/settings', { method:'PUT', body:{ tmdb_token: token.trim() } }); + toast('TMDb Token gespeichert ✓'); + setConfigured(true); setToken(''); + } catch(e) { toast(e.message, 'error'); } finally { setBusy(false); } + }; + + return ( +
+
+ API Read Access Token von themoviedb.org eintragen. + {configured && ✓ Konfiguriert} +
+ setToken(e.target.value)} + type="password" placeholder={configured ? '••• Token ersetzen •••' : 'eyJhbGciOi…'} /> + +
+ ); +} + function UploadShareAdminPanel({ toast }) { const [users, setUsers] = useState([]); const [logs, setLogs] = useState([]); @@ -2805,6 +2840,9 @@ function AdminPanel({ toast, mobile, user, nav }) { + + + )} diff --git a/frontend/src/tools/media.jsx b/frontend/src/tools/media.jsx index 0374a3f..95d638a 100644 --- a/frontend/src/tools/media.jsx +++ b/frontend/src/tools/media.jsx @@ -1,8 +1,11 @@ -import { useState } from 'react'; -import { S } from '../lib.js'; +import { useState, useEffect, useCallback } from 'react'; +import { api, S } from '../lib.js'; +const POSTER = 'https://image.tmdb.org/t/p/w342'; const TABS = [ - { id: 'kino', label: '🎬 Kino' }, + { id: 'kino', label: '🎬 Kino' }, + { id: 'demnächst', label: '🗓 Demnächst' }, + { id: 'favoriten', label: '❤ Favoriten' }, ]; export default function Media() { @@ -10,50 +13,224 @@ export default function Media() { return (
- {/* Tab-Bar */}
{TABS.map(t => ( - ))}
- {tab === 'kino' && } + {tab === 'kino' && } + {tab === 'demnächst' && } + {tab === 'favoriten' && }
); } -function KinoTab() { +// ── Gemeinsame Film-Karte ───────────────────────────────────────────────────── +function MovieCard({ movie, favIds, onToggleFav }) { + 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' }) + : ''; + return (
-
🎬
-
Kino-Feature
-
Wird implementiert – TMDb API-Key erforderlich
+ {/* Poster */} +
+ {movie.poster_path + ? {movie.title} + :
🎬
+ } + {/* Rating Badge */} + {movie.vote_average > 0 && ( +
+ ★ {movie.vote_average.toFixed(1)} +
+ )} + {/* Fav Button */} + +
+ + {/* Info */} +
+
+ {movie.title} +
+ {releaseDE && ( +
+ {releaseDE} +
+ )} +
+
+ ); +} + +// ── Grid-Wrapper (now-playing / upcoming) ───────────────────────────────────── +function MovieGrid({ endpoint, emptyText }) { + 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 (_) {} + }, []); + + useEffect(() => { + 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) { + 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_de: movie.release_date + ? new Date(movie.release_date).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }) + : '', + }}); + setFavIds(prev => new Set([...prev, movie.id])); + } + } catch (e) { alert(e.message); } + }; + + if (loading) return ⏳ Lädt…; + if (error) return ⚠ {error}; + if (!movies.length) return {emptyText}; + + return ( +
+ {movies.map(m => ( + + ))} +
+ ); +} + +// ── Favoriten-Tab ───────────────────────────────────────────────────────────── +function FavoritenTab() { + const [favs, setFavs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const load = useCallback(async () => { + setLoading(true); setError(''); + try { + const data = await api('/tools/media/favorites'); + setFavs(data); + } catch (e) { setError(e.message); } + finally { setLoading(false); } + }, []); + + useEffect(() => { load(); }, []); + + const removeFav = async (tmdbId) => { + try { + await api(`/tools/media/favorites/${tmdbId}`, { method: 'DELETE' }); + setFavs(prev => prev.filter(f => f.tmdb_id !== tmdbId)); + } catch (e) { alert(e.message); } + }; + + if (loading) return ⏳ Lädt…; + if (error) return ⚠ {error}; + if (!favs.length) return Noch keine Favoriten – markiere Filme mit ❤; + + return ( +
+ {favs.map(f => ( +
+
+ {f.poster_path + ? {f.title} + :
🎬
+ } + +
+
+
+ {f.title} +
+ {f.release_date_de && ( +
+ 🇩🇪 {f.release_date_de} +
+ )} +
+
+ ))} +
+ ); +} + +function StatusBox({ children, color }) { + return ( +
+ {children}
); }