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/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

View File

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

View File

@@ -1,40 +1,113 @@
import { useState, useEffect, useCallback } from 'react';
import { api, S } from '../lib.js';
import { useState, useEffect, useCallback, useRef } from 'react';
import { S, api } from '../lib.js';
const POSTER = 'https://image.tmdb.org/t/p/w342';
const TABS = [
{ id: 'kino', label: '🎬 Kino' },
{ id: 'demnächst', label: '🗓 Demnächst' },
{ id: 'favoriten', label: '❤ Favoriten' },
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: 'favoriten', label: '❤ Favoriten', desc: 'Deine gespeicherten Lieblingsfilme', ready: true },
];
export default function Media() {
const [tab, setTab] = useState('kino');
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 [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 (
<div style={{ padding: '0 0 40px' }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:20, margin:'0 0 20px' }}>
🎬 Media
</h1>
<div style={{ display:'flex', gap:8, marginBottom:24, flexWrap:'wrap' }}>
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)} style={{
padding:'7px 16px', borderRadius:8, cursor:'pointer',
border: tab===t.id ? '1px solid #4ecdc4' : '1px solid rgba(255,255,255,0.12)',
background: tab===t.id ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)',
color: tab===t.id ? '#4ecdc4' : 'rgba(255,255,255,0.6)',
fontFamily:"'Space Mono',monospace", fontSize:13,
fontWeight: tab===t.id ? 700 : 400, transition:'all 0.15s',
}}>
{t.label}
</button>
))}
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth: 860 }}>
<div style={{ display:'flex', alignItems:'center', gap:12, marginBottom:16, flexWrap:'wrap' }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize: mobile?17:22, margin:0 }}>Media</h1>
<span style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10 }}>Kino · Vorschau · Favoriten</span>
</div>
{tab === 'kino' && <MovieGrid endpoint="/tools/media/now-playing" emptyText="Keine aktuellen Kinofilme" />}
{tab === 'demnächst' && <MovieGrid endpoint="/tools/media/upcoming" emptyText="Keine kommenden Filme" />}
{tab === 'favoriten' && <FavoritenTab />}
{/* Tool-Auswahl */}
{mobile ? (
<div style={{ marginBottom:16 }}>
<div style={{ display:'flex', alignItems:'center', gap:4 }}>
{chipScroll.left && (
<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 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,
}}>
<span style={{ fontSize:15 }}>{getIcon(t.label)}</span>
<span>{getText(t.label)}</span>
</button>
))}
</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>
)}
{/* Inhalts-Card */}
<div style={{ ...S.card }}>
<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>
);
}
@@ -49,41 +122,37 @@ function MovieCard({ movie, favIds, onToggleFav }) {
return (
<div style={{
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' }}>
{movie.poster_path
? <img src={POSTER + movie.poster_path} alt={movie.title}
style={{ width:'100%', height:'100%', objectFit:'cover', display:'block' }} />
: <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 && (
<div style={{
position:'absolute', top:8, left:8,
background:'rgba(0,0,0,0.75)', borderRadius:6,
padding:'2px 7px', fontSize:11, color:'#ffe66d', fontWeight:700,
position:'absolute', top:6, left:6,
background:'rgba(0,0,0,0.8)', borderRadius:5,
padding:'2px 6px', fontSize:10, color:'#ffe66d', fontWeight:700,
fontFamily:"'Space Mono',monospace",
}}>
{movie.vote_average.toFixed(1)}
</div>
}}> {movie.vote_average.toFixed(1)}</div>
)}
<button onClick={() => 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',
}}>
{isFav ? '❤' : '🤍'}
</button>
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',
}}>{isFav ? '❤' : '🤍'}</button>
</div>
<div style={{ padding:'10px 12px', flex:1, display:'flex', flexDirection:'column', gap:4 }}>
<div style={{ fontSize:13, fontWeight:700, color:'#fff', lineHeight:1.3,
<div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}>
<div style={{
fontSize:12, fontWeight:700, color:'#fff', lineHeight:1.3,
fontFamily:"'Space Mono',monospace", overflow:'hidden',
display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical' }}>
{movie.title}
</div>
display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical',
}}>{movie.title}</div>
{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}
</div>
)}
@@ -92,8 +161,8 @@ function MovieCard({ movie, favIds, onToggleFav }) {
);
}
// ── Grid (now-playing / upcoming) ─────────────────────────────────────────────
function MovieGrid({ endpoint, emptyText }) {
// ── Kino/Demnächst Grid ───────────────────────────────────────────────────────
function KinoGrid({ endpoint, emptyText }) {
const [movies, setMovies] = useState([]);
const [favIds, setFavIds] = useState(new Set());
const [loading, setLoading] = useState(true);
@@ -134,21 +203,23 @@ function MovieGrid({ endpoint, emptyText }) {
};
if (loading) return <StatusBox> Lädt</StatusBox>;
if (error) return <StatusBox color="#ff6b9d">
{error}
{error.includes('Token') && (
<div style={{ marginTop:8, fontSize:12, color:'rgba(255,255,255,0.4)' }}>
Einstellungen Benutzer <span style={{ color:'#4ecdc4' }}>TMDB API (KINO-FEATURE)</span>
</div>
)}
</StatusBox>;
if (error) return (
<StatusBox color="#ff6b9d">
<div> {error}</div>
{error.includes('Token') && (
<div style={{ marginTop:8, fontSize:11, color:'rgba(255,255,255,0.4)' }}>
Token eintragen: Einstellungen Benutzer <span style={{ color:'#4ecdc4' }}>TMDB API</span>
</div>
)}
</StatusBox>
);
if (!movies.length) return <StatusBox>{emptyText}</StatusBox>;
return (
<div style={{
display:'grid',
gridTemplateColumns:'repeat(auto-fill, minmax(140px, 1fr))',
gap:14,
gridTemplateColumns:'repeat(auto-fill, minmax(130px, 1fr))',
gap:12,
}}>
{movies.map(m => (
<MovieCard key={m.id} movie={m} favIds={favIds} onToggleFav={toggleFav} />
@@ -157,8 +228,8 @@ function MovieGrid({ endpoint, emptyText }) {
);
}
// ── Favoriten ─────────────────────────────────────────────────────────────────
function FavoritenTab() {
// ── Favoriten Grid ────────────────────────────────────────────────────────────
function FavoritenGrid() {
const [favs, setFavs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -180,33 +251,33 @@ function FavoritenTab() {
if (!favs.length) return <StatusBox>Noch keine Favoriten markiere Filme mit </StatusBox>;
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 => (
<div key={f.tmdb_id} style={{
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' }}>
{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: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={{
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',
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 style={{ padding:'10px 12px', flex:1, display:'flex', flexDirection:'column', gap:4 }}>
<div style={{ fontSize:13, fontWeight:700, color:'#fff', lineHeight:1.3,
<div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}>
<div style={{
fontSize:12, fontWeight:700, color:'#fff', lineHeight:1.3,
fontFamily:"'Space Mono',monospace", overflow:'hidden',
display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical' }}>
{f.title}
</div>
display:'-webkit-box', WebkitLineClamp:2, WebkitBoxOrient:'vertical',
}}>{f.title}</div>
{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}
</div>
)}
@@ -220,9 +291,9 @@ function FavoritenTab() {
function StatusBox({ children, color }) {
return (
<div style={{
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.08)',
borderRadius:12, padding:'32px 24px', textAlign:'center',
color: color || 'rgba(255,255,255,0.35)', fontFamily:"'Space Mono',monospace", fontSize:14,
padding:'28px 20px', textAlign:'center',
color: color || 'rgba(255,255,255,0.35)',
fontFamily:"'Space Mono',monospace", fontSize:13,
}}>
{children}
</div>