fix: media – authenticate middleware, doppelter Route-Load, DevTools-Layout

This commit is contained in:
2026-06-06 17:57:59 +02:00
parent 5717e67b6d
commit 3bcc1cd9e6
3 changed files with 157 additions and 94 deletions

View File

@@ -69,7 +69,6 @@ fs.readdirSync(toolsDir, { withFileTypes: true })
app.use('/api/tools/snippets', require('./tools/snippets/routes')); app.use('/api/tools/snippets', require('./tools/snippets/routes'));
app.use('/api/tools/qrcodes', require('./tools/qrcodes/routes')); app.use('/api/tools/qrcodes', require('./tools/qrcodes/routes'));
app.use('/api/upload-shares', require('./tools/dateien/upload-share')); 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')); app.use('/api/search', require('./routes/search'));
// Öffentliche Upload-Seite: React-App ausliefern App erkennt /u/ Pfad selbst // Öffentliche Upload-Seite: React-App ausliefern App erkennt /u/ Pfad selbst

View File

@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const db = require('../../db'); const db = require('../../db');
const { requireAdmin } = require('../../middleware/auth'); const { authenticate, requireAdmin } = require('../../middleware/auth');
const TMDB_BASE = 'https://api.themoviedb.org/3'; const TMDB_BASE = 'https://api.themoviedb.org/3';
@@ -23,8 +23,7 @@ async function tmdb(path, params = {}) {
return res.json(); return res.json();
} }
// GET /api/tools/media/now-playing router.get('/now-playing', authenticate, async (req, res) => {
router.get('/now-playing', async (req, res) => {
try { try {
const data = await tmdb('/movie/now_playing'); const data = await tmdb('/movie/now_playing');
res.json(data.results.slice(0, 10)); res.json(data.results.slice(0, 10));
@@ -33,8 +32,7 @@ router.get('/now-playing', async (req, res) => {
} }
}); });
// GET /api/tools/media/upcoming router.get('/upcoming', authenticate, async (req, res) => {
router.get('/upcoming', async (req, res) => {
try { try {
const data = await tmdb('/movie/upcoming'); const data = await tmdb('/movie/upcoming');
res.json(data.results.slice(0, 10)); res.json(data.results.slice(0, 10));
@@ -43,16 +41,14 @@ router.get('/upcoming', async (req, res) => {
} }
}); });
// GET /api/tools/media/favorites router.get('/favorites', authenticate, (req, res) => {
router.get('/favorites', (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 added_at DESC'
).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', (req, res) => {
const { tmdb_id, title, poster_path, release_date_de } = req.body; const { tmdb_id, title, poster_path, release_date_de } = 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 {
@@ -65,21 +61,18 @@ router.post('/favorites', (req, res) => {
} }
}); });
// DELETE /api/tools/media/favorites/:tmdb_id router.delete('/favorites/:tmdb_id', authenticate, (req, res) => {
router.delete('/favorites/:tmdb_id', (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 (admin only) router.get('/settings', authenticate, requireAdmin, (req, res) => {
router.get('/settings', requireAdmin, (req, res) => {
const token = getToken(); const token = getToken();
res.json({ configured: !!token }); res.json({ configured: !!token });
}); });
// PUT /api/tools/media/settings (admin only) router.put('/settings', authenticate, requireAdmin, (req, res) => {
router.put('/settings', 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());

View File

@@ -1,40 +1,113 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { api, S } 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 TABS = [
{ id: 'kino', label: '🎬 Kino' }, const TOOL_DEFS = [
{ id: 'demnächst', label: '🗓 Demnächst' }, { id: 'kino', label: '🎬 Kino', desc: 'Aktuell im Kino Top 10 (DE)', ready: true },
{ id: 'favoriten', label: '❤ Favoriten' }, { id: 'demnächst', label: '🗓 Demnächst', desc: 'Bald im Kino Vorschau (DE)', ready: true },
{ id: 'favoriten', label: '❤ Favoriten', desc: 'Deine gespeicherten Lieblingsfilme', ready: true },
]; ];
export default function Media() { const getIcon = lbl => [...lbl][0] ?? '🎬';
const [tab, setTab] = useState('kino'); const getText = lbl => { const c = [...lbl]; return c.slice(1).join('').trim() || lbl; };
export default function Media({ toast, mobile, nav }) {
const [activeTool, setActiveTool] = useState('kino');
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,
});
};
const active = TOOL_DEFS.find(t => t.id === activeTool);
return ( return (
<div style={{ padding: '0 0 40px' }}> <div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth: 860 }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:20, margin:'0 0 20px' }}> <div style={{ display:'flex', alignItems:'center', gap:12, marginBottom:16, flexWrap:'wrap' }}>
🎬 Media <h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize: mobile?17:22, margin:0 }}>Media</h1>
</h1> <span style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10 }}>Kino · Vorschau · Favoriten</span>
</div>
<div style={{ display:'flex', gap:8, marginBottom:24, flexWrap:'wrap' }}> {/* Tool-Auswahl */}
{TABS.map(t => ( {mobile ? (
<button key={t.id} onClick={() => setTab(t.id)} style={{ <div style={{ marginBottom:16 }}>
padding:'7px 16px', borderRadius:8, cursor:'pointer', <div style={{ display:'flex', alignItems:'center', gap:4 }}>
border: tab===t.id ? '1px solid #4ecdc4' : '1px solid rgba(255,255,255,0.12)', {chipScroll.left && (
background: tab===t.id ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)', <button onClick={() => chipScrollRef.current?.scrollBy({ left:-160, behavior:'smooth' })}
color: tab===t.id ? '#4ecdc4' : 'rgba(255,255,255,0.6)', style={{ flexShrink:0, background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)',
fontFamily:"'Space Mono',monospace", fontSize:13, borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14, lineHeight:1 }}></button>
fontWeight: tab===t.id ? 700 : 400, transition:'all 0.15s', )}
<div ref={chipScrollRef} onScroll={onChipScroll}
style={{ display:'flex', gap:6, overflowX:'auto', flex:1,
padding:'3px 2px 6px', scrollbarWidth:'none', WebkitOverflowScrolling:'touch' }}>
{TOOL_DEFS.map(t => (
<button key={t.id} onClick={() => setActiveTool(t.id)} style={{
display:'flex', alignItems:'center', gap:6, flexShrink:0,
padding:'7px 12px', borderRadius:20, fontFamily:'monospace', fontSize:11,
cursor:'pointer', whiteSpace:'nowrap',
background: activeTool===t.id ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.05)',
border: activeTool===t.id ? '1px solid #4ecdc4' : '1px solid rgba(255,255,255,0.1)',
color: activeTool===t.id ? '#4ecdc4' : 'rgba(255,255,255,0.75)',
fontWeight: activeTool===t.id ? 700 : 400,
}}> }}>
{t.label} <span style={{ fontSize:15 }}>{getIcon(t.label)}</span>
<span>{getText(t.label)}</span>
</button> </button>
))} ))}
</div> </div>
{chipScroll.right && (
<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)',
borderRadius:20, color:'rgba(255,255,255,0.6)', cursor:'pointer', padding:'6px 8px', fontSize:14, lineHeight:1 }}></button>
)}
</div>
</div>
) : (
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:5, marginBottom:16 }}>
{TOOL_DEFS.map(t => (
<button key={t.id} title={t.desc} onClick={() => setActiveTool(t.id)} style={{
display:'flex', alignItems:'center', gap:9,
padding:'8px 11px', borderRadius:9, fontFamily:'monospace',
cursor:'pointer', textAlign:'left', border:'none',
background: activeTool===t.id ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)',
outline: activeTool===t.id ? '1px solid #4ecdc4' : 'none',
}}>
<span style={{ fontSize:17, flexShrink:0, lineHeight:1, minWidth:22, textAlign:'center' }}>{getIcon(t.label)}</span>
<div style={{ minWidth:0, flex:1 }}>
<div style={{
color: activeTool===t.id ? '#4ecdc4' : 'rgba(255,255,255,0.85)',
fontSize:12, fontWeight: activeTool===t.id?700:500,
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap',
}}>
{getText(t.label)}
</div>
<div style={{ color:'rgba(255,255,255,0.28)', fontSize:9, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', marginTop:1 }}>
{t.desc}
</div>
</div>
</button>
))}
</div>
)}
{tab === 'kino' && <MovieGrid endpoint="/tools/media/now-playing" emptyText="Keine aktuellen Kinofilme" />} {/* Inhalts-Card */}
{tab === 'demnächst' && <MovieGrid endpoint="/tools/media/upcoming" emptyText="Keine kommenden Filme" />} <div style={{ ...S.card }}>
{tab === 'favoriten' && <FavoritenTab />} <div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:18, flexWrap:'wrap' }}>
<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>
</div>
{activeTool === 'kino' && <KinoGrid endpoint="/tools/media/now-playing" emptyText="Keine aktuellen Kinofilme gefunden" />}
{activeTool === 'demnächst' && <KinoGrid endpoint="/tools/media/upcoming" emptyText="Keine kommenden Filme gefunden" />}
{activeTool === 'favoriten' && <FavoritenGrid />}
</div>
</div> </div>
); );
} }
@@ -49,41 +122,37 @@ function MovieCard({ movie, favIds, onToggleFav }) {
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:12, overflow:'hidden', display:'flex', flexDirection:'column', borderRadius:10, overflow:'hidden', display:'flex', flexDirection:'column',
}}> }}>
<div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}> <div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}>
{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:40, color:'rgba(255,255,255,0.15)' }}>🎬</div> justifyContent:'center', fontSize:36, color:'rgba(255,255,255,0.1)' }}>🎬</div>
} }
{movie.vote_average > 0 && ( {movie.vote_average > 0 && (
<div style={{ <div style={{
position:'absolute', top:8, left:8, position:'absolute', top:6, left:6,
background:'rgba(0,0,0,0.75)', borderRadius:6, background:'rgba(0,0,0,0.8)', borderRadius:5,
padding:'2px 7px', fontSize:11, color:'#ffe66d', fontWeight:700, padding:'2px 6px', fontSize:10, color:'#ffe66d', fontWeight:700,
fontFamily:"'Space Mono',monospace", fontFamily:"'Space Mono',monospace",
}}> }}> {movie.vote_average.toFixed(1)}</div>
{movie.vote_average.toFixed(1)}
</div>
)} )}
<button onClick={() => onToggleFav(movie, isFav)} style={{ <button onClick={() => onToggleFav(movie, isFav)} style={{
position:'absolute', top:6, right:6, background:'rgba(0,0,0,0.6)', position:'absolute', top:5, right:5, background:'rgba(0,0,0,0.65)',
border:'none', borderRadius:'50%', width:32, height:32, cursor:'pointer', border:'none', borderRadius:'50%', width:30, height:30, cursor:'pointer',
fontSize:16, display:'flex', alignItems:'center', justifyContent:'center', fontSize:14, display:'flex', alignItems:'center', justifyContent:'center',
}}> }}>{isFav ? '❤' : '🤍'}</button>
{isFav ? '❤' : '🤍'}
</button>
</div> </div>
<div style={{ padding:'10px 12px', flex:1, display:'flex', flexDirection:'column', gap:4 }}> <div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}>
<div style={{ fontSize:13, fontWeight:700, color:'#fff', lineHeight:1.3, <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} }}>{movie.title}</div>
</div>
{releaseDE && ( {releaseDE && (
<div style={{ fontSize:11, color:'rgba(255,255,255,0.4)', fontFamily:"'Space Mono',monospace" }}> <div style={{ fontSize:10, color:'rgba(255,255,255,0.35)', fontFamily:"'Space Mono',monospace" }}>
{releaseDE} {releaseDE}
</div> </div>
)} )}
@@ -92,8 +161,8 @@ function MovieCard({ movie, favIds, onToggleFav }) {
); );
} }
// ── Grid (now-playing / upcoming) ───────────────────────────────────────────── // ── Kino/Demnächst Grid ───────────────────────────────────────────────────────
function MovieGrid({ endpoint, emptyText }) { function KinoGrid({ endpoint, emptyText }) {
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);
@@ -134,21 +203,23 @@ function MovieGrid({ endpoint, emptyText }) {
}; };
if (loading) return <StatusBox> Lädt</StatusBox>; if (loading) return <StatusBox> Lädt</StatusBox>;
if (error) return <StatusBox color="#ff6b9d"> if (error) return (
{error} <StatusBox color="#ff6b9d">
<div> {error}</div>
{error.includes('Token') && ( {error.includes('Token') && (
<div style={{ marginTop:8, fontSize:12, color:'rgba(255,255,255,0.4)' }}> <div style={{ marginTop:8, fontSize:11, color:'rgba(255,255,255,0.4)' }}>
Einstellungen Benutzer <span style={{ color:'#4ecdc4' }}>TMDB API (KINO-FEATURE)</span> Token eintragen: Einstellungen Benutzer <span style={{ color:'#4ecdc4' }}>TMDB API</span>
</div> </div>
)} )}
</StatusBox>; </StatusBox>
);
if (!movies.length) return <StatusBox>{emptyText}</StatusBox>; if (!movies.length) return <StatusBox>{emptyText}</StatusBox>;
return ( return (
<div style={{ <div style={{
display:'grid', display:'grid',
gridTemplateColumns:'repeat(auto-fill, minmax(140px, 1fr))', gridTemplateColumns:'repeat(auto-fill, minmax(130px, 1fr))',
gap:14, gap:12,
}}> }}>
{movies.map(m => ( {movies.map(m => (
<MovieCard key={m.id} movie={m} favIds={favIds} onToggleFav={toggleFav} /> <MovieCard key={m.id} movie={m} favIds={favIds} onToggleFav={toggleFav} />
@@ -157,8 +228,8 @@ function MovieGrid({ endpoint, emptyText }) {
); );
} }
// ── Favoriten ───────────────────────────────────────────────────────────────── // ── Favoriten Grid ────────────────────────────────────────────────────────────
function FavoritenTab() { function FavoritenGrid() {
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('');
@@ -180,33 +251,33 @@ function FavoritenTab() {
if (!favs.length) return <StatusBox>Noch keine Favoriten markiere Filme mit </StatusBox>; if (!favs.length) return <StatusBox>Noch keine Favoriten markiere Filme mit </StatusBox>;
return ( return (
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(140px, 1fr))', gap:14 }}> <div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px, 1fr))', gap:12 }}>
{favs.map(f => ( {favs.map(f => (
<div key={f.tmdb_id} style={{ <div key={f.tmdb_id} 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:12, overflow:'hidden', display:'flex', flexDirection:'column', borderRadius:10, overflow:'hidden', display:'flex', flexDirection:'column',
}}> }}>
<div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}> <div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}>
{f.poster_path {f.poster_path
? <img src={POSTER + f.poster_path} alt={f.title} ? <img src={POSTER + f.poster_path} alt={f.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:40, color:'rgba(255,255,255,0.15)' }}>🎬</div> justifyContent:'center', fontSize:36, color:'rgba(255,255,255,0.1)' }}>🎬</div>
} }
<button onClick={() => removeFav(f.tmdb_id)} style={{ <button onClick={() => removeFav(f.tmdb_id)} style={{
position:'absolute', top:6, right:6, background:'rgba(0,0,0,0.6)', position:'absolute', top:5, right:5, background:'rgba(0,0,0,0.65)',
border:'none', borderRadius:'50%', width:32, height:32, cursor:'pointer', border:'none', borderRadius:'50%', width:30, height:30, cursor:'pointer',
fontSize:16, display:'flex', alignItems:'center', justifyContent:'center', fontSize:14, display:'flex', alignItems:'center', justifyContent:'center',
}}></button> }}></button>
</div> </div>
<div style={{ padding:'10px 12px', flex:1, display:'flex', flexDirection:'column', gap:4 }}> <div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}>
<div style={{ fontSize:13, fontWeight:700, color:'#fff', lineHeight:1.3, <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',
{f.title} }}>{f.title}</div>
</div>
{f.release_date_de && ( {f.release_date_de && (
<div style={{ fontSize:11, color:'rgba(255,255,255,0.4)', fontFamily:"'Space Mono',monospace" }}> <div style={{ fontSize:10, color:'rgba(255,255,255,0.35)', fontFamily:"'Space Mono',monospace" }}>
🇩🇪 {f.release_date_de} 🇩🇪 {f.release_date_de}
</div> </div>
)} )}
@@ -220,9 +291,9 @@ function FavoritenTab() {
function StatusBox({ children, color }) { function StatusBox({ children, color }) {
return ( return (
<div style={{ <div style={{
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.08)', padding:'28px 20px', textAlign:'center',
borderRadius:12, padding:'32px 24px', textAlign:'center', color: color || 'rgba(255,255,255,0.35)',
color: color || 'rgba(255,255,255,0.35)', fontFamily:"'Space Mono',monospace", fontSize:14, fontFamily:"'Space Mono',monospace", fontSize:13,
}}> }}>
{children} {children}
</div> </div>