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
+ ?

+ :
🎬
+ }
+ {/* Rating Badge */}
+ {movie.vote_average > 0 && (
+
+ ★ {movie.vote_average.toFixed(1)}
+
+ )}
+ {/* Fav Button */}
+
onToggleFav(movie, isFav)} style={{
+ position:'absolute', top:6, right:6, background:'rgba(0,0,0,0.6)',
+ border:'none', borderRadius:'50%', width:32, height:32, cursor:'pointer',
+ fontSize:16, display:'flex', alignItems:'center', justifyContent:'center',
+ transition:'transform 0.1s',
+ }}>
+ {isFav ? '❤' : '🤍'}
+
+
+
+ {/* 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
+ ?

+ :
🎬
+ }
+
removeFav(f.tmdb_id)} style={{
+ position:'absolute',top:6,right:6,background:'rgba(0,0,0,0.6)',
+ border:'none',borderRadius:'50%',width:32,height:32,cursor:'pointer',
+ fontSize:16,display:'flex',alignItems:'center',justifyContent:'center',
+ }}>❤
+
+
+
+ {f.title}
+
+ {f.release_date_de && (
+
+ 🇩🇪 {f.release_date_de}
+
+ )}
+
+
+ ))}
+
+ );
+}
+
+function StatusBox({ children, color }) {
+ return (
+
+ {children}
);
}