feat: Kino-Feature mit TMDb-Proxy, Favoriten, Admin-Token-Einstellung
This commit is contained in:
@@ -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');
|
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;
|
module.exports = db;
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ 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
|
||||||
|
|||||||
90
backend/src/tools/media/routes.js
Normal file
90
backend/src/tools/media/routes.js
Normal file
@@ -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;
|
||||||
@@ -2586,6 +2586,41 @@ const Field = ({ label, type='text', value, onChange, placeholder='', autoCapita
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Admin: Upload-Freigabe Berechtigungen ─────────────────────────────────────
|
// ── 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 (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize:12, color:'rgba(255,255,255,0.45)', fontFamily:'monospace', marginBottom:10, lineHeight:1.6 }}>
|
||||||
|
API Read Access Token von <a href="https://www.themoviedb.org/settings/api" target="_blank"
|
||||||
|
rel="noopener noreferrer" style={{ color:'#4ecdc4' }}>themoviedb.org</a> eintragen.
|
||||||
|
{configured && <span style={{ color:'#4ecdc4', marginLeft:6 }}>✓ Konfiguriert</span>}
|
||||||
|
</div>
|
||||||
|
<Field label="BEARER TOKEN (API READ ACCESS TOKEN)" value={token} onChange={e=>setToken(e.target.value)}
|
||||||
|
type="password" placeholder={configured ? '••• Token ersetzen •••' : 'eyJhbGciOi…'} />
|
||||||
|
<button onClick={save} disabled={busy} style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'11px 0' }}>
|
||||||
|
{busy ? '…' : '✓ Token speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UploadShareAdminPanel({ toast }) {
|
function UploadShareAdminPanel({ toast }) {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
@@ -2805,6 +2840,9 @@ function AdminPanel({ toast, mobile, user, nav }) {
|
|||||||
<Sec title="UPLOAD-FREIGABE">
|
<Sec title="UPLOAD-FREIGABE">
|
||||||
<UploadShareAdminPanel toast={toast}/>
|
<UploadShareAdminPanel toast={toast}/>
|
||||||
</Sec>
|
</Sec>
|
||||||
|
<Sec title="TMDB API (KINO-FEATURE)">
|
||||||
|
<TmdbSettings toast={toast}/>
|
||||||
|
</Sec>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { S } from '../lib.js';
|
import { api, S } from '../lib.js';
|
||||||
|
|
||||||
|
const POSTER = 'https://image.tmdb.org/t/p/w342';
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'kino', label: '🎬 Kino' },
|
{ id: 'kino', label: '🎬 Kino' },
|
||||||
|
{ id: 'demnächst', label: '🗓 Demnächst' },
|
||||||
|
{ id: 'favoriten', label: '❤ Favoriten' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Media() {
|
export default function Media() {
|
||||||
@@ -10,50 +13,224 @@ export default function Media() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '0 0 40px' }}>
|
<div style={{ padding: '0 0 40px' }}>
|
||||||
{/* Tab-Bar */}
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 24, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 24, flexWrap: 'wrap' }}>
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
<button
|
<button key={t.id} onClick={() => setTab(t.id)} style={{
|
||||||
key={t.id}
|
padding: '7px 16px', borderRadius: 8, cursor: 'pointer',
|
||||||
onClick={() => setTab(t.id)}
|
border: tab === t.id ? '1px solid #4ecdc4' : '1px solid rgba(255,255,255,0.12)',
|
||||||
style={{
|
background: tab === t.id ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.04)',
|
||||||
padding: '7px 16px',
|
color: tab === t.id ? '#4ecdc4' : 'rgba(255,255,255,0.6)',
|
||||||
borderRadius: 8,
|
fontFamily: "'Space Mono',monospace", fontSize: 13,
|
||||||
border: tab === t.id ? '1px solid #4ecdc4' : '1px solid rgba(255,255,255,0.12)',
|
fontWeight: tab === t.id ? 700 : 400, transition: 'all 0.15s',
|
||||||
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)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontFamily: "'Courier New', monospace",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: tab === t.id ? 700 : 400,
|
|
||||||
transition: 'all 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'kino' && <KinoTab />}
|
{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 />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'rgba(255,255,255,0.03)',
|
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)',
|
||||||
border: '1px solid rgba(255,255,255,0.08)',
|
borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||||
borderRadius: 12,
|
transition: 'border-color 0.15s',
|
||||||
padding: '32px 24px',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: 'rgba(255,255,255,0.35)',
|
|
||||||
fontFamily: "'Courier New', monospace",
|
|
||||||
fontSize: 14,
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 40, marginBottom: 12 }}>🎬</div>
|
{/* Poster */}
|
||||||
<div style={{ fontSize: 15, color: 'rgba(255,255,255,0.5)', marginBottom: 8 }}>Kino-Feature</div>
|
<div style={{ position: 'relative', aspectRatio: '2/3', background: '#111', overflow: 'hidden' }}>
|
||||||
<div style={{ fontSize: 12 }}>Wird implementiert – TMDb API-Key erforderlich</div>
|
{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>
|
||||||
|
}
|
||||||
|
{/* Rating Badge */}
|
||||||
|
{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,
|
||||||
|
fontFamily:"'Space Mono',monospace",
|
||||||
|
}}>
|
||||||
|
★ {movie.vote_average.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Fav Button */}
|
||||||
|
<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',
|
||||||
|
transition:'transform 0.1s',
|
||||||
|
}}>
|
||||||
|
{isFav ? '❤' : '🤍'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ padding: '10px 12px', flex: 1, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<div style={{ fontSize:13, fontWeight:700, color:'#fff', lineHeight:1.3,
|
||||||
|
fontFamily:"'Space Mono',monospace", overflow:'hidden',
|
||||||
|
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" }}>
|
||||||
|
{releaseDE}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 <StatusBox>⏳ Lädt…</StatusBox>;
|
||||||
|
if (error) return <StatusBox color="#ff6b9d">⚠ {error}</StatusBox>;
|
||||||
|
if (!movies.length) return <StatusBox>{emptyText}</StatusBox>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||||
|
gap: 14,
|
||||||
|
}}>
|
||||||
|
{movies.map(m => (
|
||||||
|
<MovieCard key={m.id} movie={m} favIds={favIds} onToggleFav={toggleFav} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 <StatusBox>⏳ Lädt…</StatusBox>;
|
||||||
|
if (error) return <StatusBox color="#ff6b9d">⚠ {error}</StatusBox>;
|
||||||
|
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,
|
||||||
|
}}>
|
||||||
|
{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',
|
||||||
|
}}>
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
<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',
|
||||||
|
}}>❤</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,
|
||||||
|
fontFamily:"'Space Mono',monospace",overflow:'hidden',
|
||||||
|
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" }}>
|
||||||
|
🇩🇪 {f.release_date_de}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user