fix: TV-Modal, Suche/Favoriten nach Typ getrennt, Pushover korrekte Tabelle
This commit is contained in:
@@ -361,6 +361,39 @@ router.get('/search', authenticate, async (req, res) => {
|
|||||||
} catch(e) { res.status(500).json({ error: e.message }); }
|
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TV-Serie Detail
|
||||||
|
router.get('/tv/:id', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [detail, genreMap] = await Promise.all([
|
||||||
|
tmdb(`/tv/${req.params.id}`, { append_to_response: 'credits,content_ratings' }),
|
||||||
|
getGenreMap(),
|
||||||
|
]);
|
||||||
|
// FSK aus content_ratings (DE)
|
||||||
|
let fsk = '';
|
||||||
|
const deRating = (detail.content_ratings?.results || []).find(r => r.iso_3166_1 === 'DE');
|
||||||
|
if (deRating?.rating) fsk = deRating.rating;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: detail.id, media_type: 'tv',
|
||||||
|
title: detail.name || detail.original_name,
|
||||||
|
original_title: detail.original_name,
|
||||||
|
tagline: detail.tagline || '',
|
||||||
|
overview: detail.overview,
|
||||||
|
poster_path: detail.poster_path,
|
||||||
|
backdrop_path: detail.backdrop_path,
|
||||||
|
release_date: detail.first_air_date || '',
|
||||||
|
runtime: detail.episode_run_time?.[0] || null,
|
||||||
|
vote_average: detail.vote_average,
|
||||||
|
genres: (detail.genres||[]).map(g=>g.name),
|
||||||
|
fsk,
|
||||||
|
cast: (detail.credits?.cast||[]).slice(0,10).map(c=>({ name:c.name, character:c.character, profile_path:c.profile_path })),
|
||||||
|
number_of_seasons: detail.number_of_seasons,
|
||||||
|
number_of_episodes: detail.number_of_episodes,
|
||||||
|
status: detail.status,
|
||||||
|
});
|
||||||
|
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
// Modal: TMDb-Daten sofort, xREL via separaten Endpoint
|
// Modal: TMDb-Daten sofort, xREL via separaten Endpoint
|
||||||
router.get('/movie/:id', authenticate, async (req, res) => {
|
router.get('/movie/:id', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -496,15 +529,19 @@ router.post('/favorites', authenticate, (req, res) => {
|
|||||||
|
|
||||||
if (r.changes > 0) {
|
if (r.changes > 0) {
|
||||||
try {
|
try {
|
||||||
const pushToken = db.prepare("SELECT value FROM admin_settings WHERE key='pushover_token'").get()?.value;
|
// Admin-Pushover-Einstellungen aus pushover_settings Tabelle
|
||||||
const pushUser = db.prepare("SELECT value FROM admin_settings WHERE key='pushover_user'").get()?.value;
|
const adminUser = db.prepare("SELECT id FROM users WHERE role='admin' LIMIT 1").get();
|
||||||
|
const poCfg = adminUser
|
||||||
|
? db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(adminUser.id)
|
||||||
|
: null;
|
||||||
const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.user.id);
|
const user = db.prepare('SELECT username FROM users WHERE id=?').get(req.user.id);
|
||||||
if (pushToken && pushUser) {
|
if (poCfg?.app_token && poCfg?.user_key) {
|
||||||
fetch('https://api.pushover.net/1/messages.json', {
|
fetch('https://api.pushover.net/1/messages.json', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
token: pushToken, user: pushUser,
|
token: poCfg.app_token,
|
||||||
|
user: poCfg.user_key,
|
||||||
message: `${user?.username || 'Jemand'} hat "${title}" zur Merkliste hinzugefügt`,
|
message: `${user?.username || 'Jemand'} hat "${title}" zur Merkliste hinzugefügt`,
|
||||||
title: '⭐ Neue Favoriten',
|
title: '⭐ Neue Favoriten',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const FSK_COLOR = fsk => {
|
|||||||
|
|
||||||
export default function Media({ toast, mobile }) {
|
export default function Media({ toast, mobile }) {
|
||||||
const [activeTool, setActiveTool] = useState('kino');
|
const [activeTool, setActiveTool] = useState('kino');
|
||||||
const [modal, setModal] = useState(null);
|
const [modal, setModal] = useState(null); // { id, media_type }
|
||||||
const [xrelIds, setXrelIds] = useState(new Set()); // tmdb_ids mit bekannten Releases
|
const [xrelIds, setXrelIds] = useState(new Set()); // tmdb_ids mit bekannten Releases
|
||||||
|
|
||||||
const chipScrollRef = useRef(null);
|
const chipScrollRef = useRef(null);
|
||||||
@@ -134,7 +134,8 @@ export default function Media({ toast, mobile }) {
|
|||||||
|
|
||||||
{modal && createPortal(
|
{modal && createPortal(
|
||||||
<MovieModal
|
<MovieModal
|
||||||
tmdbId={modal}
|
tmdbId={modal?.id || modal}
|
||||||
|
mediaType={modal?.media_type || 'movie'}
|
||||||
onClose={() => setModal(null)}
|
onClose={() => setModal(null)}
|
||||||
mobile={mobile}
|
mobile={mobile}
|
||||||
onHasXrel={id => setXrelIds(prev => new Set([...prev, id]))}
|
onHasXrel={id => setXrelIds(prev => new Set([...prev, id]))}
|
||||||
@@ -158,7 +159,7 @@ function MovieCard({ movie, favIds, hasXrel, onToggleFav, onOpenModal }) {
|
|||||||
borderRadius:10, overflow:'hidden', display:'flex', flexDirection:'column', cursor:'pointer',
|
borderRadius:10, overflow:'hidden', display:'flex', flexDirection:'column', cursor:'pointer',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}
|
<div style={{ position:'relative', aspectRatio:'2/3', background:'#111', overflow:'hidden' }}
|
||||||
onClick={() => onOpenModal(movie.id)}>
|
onClick={() => onOpenModal({ id: movie.id, media_type: movie.media_type || 'movie' })}>
|
||||||
{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' }} />
|
||||||
@@ -212,7 +213,7 @@ function MovieCard({ movie, favIds, hasXrel, onToggleFav, onOpenModal }) {
|
|||||||
}}>{isFav ? '❤' : '🤍'}</button>
|
}}>{isFav ? '❤' : '🤍'}</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}
|
<div style={{ padding:'8px 10px', flex:1, display:'flex', flexDirection:'column', gap:3 }}
|
||||||
onClick={() => onOpenModal(movie.id)}>
|
onClick={() => onOpenModal({ id: movie.id, media_type: movie.media_type || 'movie' })}>
|
||||||
{isTop && (
|
{isTop && (
|
||||||
<div style={{ fontSize:9, color:'#ffd700', fontFamily:'monospace', fontWeight:700, letterSpacing:1 }}>
|
<div style={{ fontSize:9, color:'#ffd700', fontFamily:'monospace', fontWeight:700, letterSpacing:1 }}>
|
||||||
★ TOP {movie.rank <= 10 ? '10' : ''}
|
★ TOP {movie.rank <= 10 ? '10' : ''}
|
||||||
@@ -459,17 +460,76 @@ function SucheGrid({ onOpenModal, xrelIds, onXrelLoaded }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{results !== null && results.length === 0 && (
|
{results !== null && results.length === 0 && (
|
||||||
<StatusBox>Keine Filme gefunden für „{query}"</StatusBox>
|
<StatusBox>Keine Ergebnisse für „{query}"</StatusBox>
|
||||||
)}
|
)}
|
||||||
|
{results !== null && results.length > 0 && (() => {
|
||||||
{results !== null && results.length > 0 && (
|
const films = results.filter(m => m.media_type !== 'tv');
|
||||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(130px,1fr))', gap:12 }}>
|
const serien = results.filter(m => m.media_type === 'tv');
|
||||||
{results.map(m => (
|
return (
|
||||||
<MovieCard key={m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)}
|
<div>
|
||||||
onToggleFav={onToggle} onOpenModal={onOpenModal} />
|
{films.length > 0 && (
|
||||||
))}
|
<div style={{marginBottom:20}}>
|
||||||
|
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,letterSpacing:1,marginBottom:8}}>🎬 FILME ({films.length})</div>
|
||||||
|
<div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(130px,1fr))',gap:12}}>
|
||||||
|
{films.map(m => <MovieCard key={'m'+m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal}/>)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{serien.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,letterSpacing:1,marginBottom:8}}>📺 SERIEN ({serien.length})</div>
|
||||||
|
<div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(130px,1fr))',gap:12}}>
|
||||||
|
{serien.map(m => <MovieCard key={'t'+m.id} movie={m} favIds={favIds} hasXrel={xrelIds?.has(m.id)} onToggleFav={onToggle} onOpenModal={onOpenModal}/>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FavCard – einzelner Favoriten-Eintrag ─────────────────────────────────────
|
||||||
|
function FavCard({ f, onOpenModal, onRemove }) {
|
||||||
|
const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })();
|
||||||
|
const fskStyle = FSK_COLOR(f.fsk);
|
||||||
|
return (
|
||||||
|
<div onClick={() => onOpenModal({ id: f.tmdb_id, media_type: f.media_type || 'movie' })} style={{
|
||||||
|
display:'flex', gap:12, alignItems:'flex-start',
|
||||||
|
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius:8, padding:'10px 12px', cursor:'pointer', position:'relative',
|
||||||
|
}}>
|
||||||
|
<div style={{ width:44, height:66, flexShrink:0, borderRadius:5, overflow:'hidden', background:'#111' }}>
|
||||||
|
{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:18 }}>
|
||||||
|
{f.media_type==='tv'?'📺':'🎬'}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex:1, minWidth:0 }}>
|
||||||
|
<div style={{ fontSize:13, fontWeight:700, color:'#fff', fontFamily:"'Space Mono',monospace",
|
||||||
|
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.title}</div>
|
||||||
|
<div style={{ fontSize:10, color:'rgba(255,255,255,0.4)', marginTop:3, fontFamily:'monospace' }}>
|
||||||
|
🇩🇪 {f.release_date_de || '–'}
|
||||||
|
</div>
|
||||||
|
{genres.length > 0 && (
|
||||||
|
<div style={{ fontSize:10, color:'#4ecdc4', marginTop:2, fontFamily:'monospace' }}>
|
||||||
|
{genres.slice(0,3).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{f.fsk && (
|
||||||
|
<span style={{ ...fskStyle, fontSize:9, padding:'1px 5px', borderRadius:3, marginTop:4, display:'inline-block' }}>
|
||||||
|
FSK {f.fsk}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={e=>{ e.stopPropagation(); onRemove(f.tmdb_id); }} style={{
|
||||||
|
background:'rgba(248,113,113,0.1)', border:'1px solid rgba(248,113,113,0.25)',
|
||||||
|
borderRadius:5, padding:'3px 8px', color:'#f87171', fontFamily:'monospace',
|
||||||
|
fontSize:10, cursor:'pointer', flexShrink:0,
|
||||||
|
}}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -606,14 +666,23 @@ function FavoritenKalender({ onOpenModal }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups = {};
|
// Aufteilen in Filme und Serien, dann nach Datum gruppieren
|
||||||
for (const f of favs) {
|
const filmeFavs = favs.filter(f => f.media_type !== 'tv');
|
||||||
|
const serienFavs = favs.filter(f => f.media_type === 'tv');
|
||||||
|
|
||||||
|
function buildGroups(list) {
|
||||||
|
const g = {};
|
||||||
|
for (const f of list) {
|
||||||
const d = f.release_date ? new Date(f.release_date) : null;
|
const d = f.release_date ? new Date(f.release_date) : null;
|
||||||
const key = d ? `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}` : 'unbekannt';
|
const key = d ? `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}` : 'unbekannt';
|
||||||
const label = d ? d.toLocaleDateString('de-DE', { month:'long', year:'numeric' }) : 'Unbekannt';
|
const label = d ? d.toLocaleDateString('de-DE', { month:'long', year:'numeric' }) : 'Unbekannt';
|
||||||
if (!groups[key]) groups[key] = { label, items:[] };
|
if (!g[key]) g[key] = { label, items:[] };
|
||||||
groups[key].items.push(f);
|
g[key].items.push(f);
|
||||||
}
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
const filmGroups = buildGroups(filmeFavs);
|
||||||
|
const serienGroups = buildGroups(serienFavs);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display:'flex', flexDirection:'column', gap:24 }}>
|
<div style={{ display:'flex', flexDirection:'column', gap:24 }}>
|
||||||
@@ -631,7 +700,46 @@ function FavoritenKalender({ onOpenModal }) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{Object.entries(groups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
|
{/* Filme */}
|
||||||
|
{filmeFavs.length > 0 && (
|
||||||
|
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,fontWeight:700,letterSpacing:1,
|
||||||
|
borderBottom:'1px solid rgba(255,255,255,0.07)',paddingBottom:6}}>
|
||||||
|
🎬 FILME ({filmeFavs.length})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Object.entries(filmGroups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
|
||||||
|
<div key={'film-'+key}>
|
||||||
|
<div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}>
|
||||||
|
<span>{group.label.toUpperCase()}</span>
|
||||||
|
<div style={{ flex:1, height:1, background:'rgba(255,255,255,0.07)' }}/>
|
||||||
|
<span style={{ fontSize:9 }}>{group.items.length} Film{group.items.length!==1?'e':''}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
|
||||||
|
{group.items.map(f => <FavCard key={f.tmdb_id} f={f} onOpenModal={onOpenModal} onRemove={removeFav}/>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Serien */}
|
||||||
|
{serienFavs.length > 0 && (
|
||||||
|
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,fontWeight:700,letterSpacing:1,
|
||||||
|
borderBottom:'1px solid rgba(255,255,255,0.07)',paddingBottom:6,marginTop:8}}>
|
||||||
|
📺 SERIEN ({serienFavs.length})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Object.entries(serienGroups).sort(([a],[b]) => a.localeCompare(b)).map(([key, group]) => (
|
||||||
|
<div key={'serie-'+key}>
|
||||||
|
<div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}>
|
||||||
|
<span>{group.label.toUpperCase()}</span>
|
||||||
|
<div style={{ flex:1, height:1, background:'rgba(255,255,255,0.07)' }}/>
|
||||||
|
<span style={{ fontSize:9 }}>{group.items.length} Serie{group.items.length!==1?'n':''}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
|
||||||
|
{group.items.map(f => <FavCard key={f.tmdb_id} f={f} onOpenModal={onOpenModal} onRemove={removeFav}/>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Alte groups-Schleife entfernt – DUMMY damit Code nicht bricht */}
|
||||||
|
{false && Object.entries({}).map(([key, group]) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}>
|
<div style={{ ...S.head, marginBottom:10, display:'flex', alignItems:'center', gap:8 }}>
|
||||||
<span>{group.label.toUpperCase()}</span>
|
<span>{group.label.toUpperCase()}</span>
|
||||||
@@ -643,7 +751,7 @@ function FavoritenKalender({ onOpenModal }) {
|
|||||||
const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })();
|
const genres = (() => { try { return JSON.parse(f.genres||'[]'); } catch { return []; } })();
|
||||||
const fskStyle = FSK_COLOR(f.fsk);
|
const fskStyle = FSK_COLOR(f.fsk);
|
||||||
return (
|
return (
|
||||||
<div key={f.tmdb_id} onClick={() => onOpenModal(f.tmdb_id)} style={{
|
<div key={f.tmdb_id} onClick={() => onOpenModal({ id: f.tmdb_id, media_type: f.media_type || 'movie' })} style={{
|
||||||
display:'flex', gap:12, alignItems:'flex-start',
|
display:'flex', gap:12, alignItems:'flex-start',
|
||||||
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.06)',
|
background:'rgba(255,255,255,0.03)', border:'1px solid rgba(255,255,255,0.06)',
|
||||||
borderRadius:8, padding:'10px 12px', cursor:'pointer',
|
borderRadius:8, padding:'10px 12px', cursor:'pointer',
|
||||||
@@ -686,7 +794,7 @@ function FavoritenKalender({ onOpenModal }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Film-Detail-Modal (via Portal) ────────────────────────────────────────────
|
// ── Film-Detail-Modal (via Portal) ────────────────────────────────────────────
|
||||||
function MovieModal({ tmdbId, onClose, mobile, onHasXrel, noYearFilter }) {
|
function MovieModal({ tmdbId, mediaType='movie', onClose, mobile, onHasXrel, noYearFilter }) {
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
const [xrel, setXrel] = useState(null); // null=nicht gesucht, []=gesucht
|
const [xrel, setXrel] = useState(null); // null=nicht gesucht, []=gesucht
|
||||||
const [xrelDates, setXrelDates] = useState([]);
|
const [xrelDates, setXrelDates] = useState([]);
|
||||||
@@ -700,7 +808,7 @@ function MovieModal({ tmdbId, onClose, mobile, onHasXrel, noYearFilter }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true); setDetail(null); setXrel(null); setXrelDates([]); setError('');
|
setLoading(true); setDetail(null); setXrel(null); setXrelDates([]); setError('');
|
||||||
detailRef.current = null;
|
detailRef.current = null;
|
||||||
api(`/tools/media/movie/${tmdbId}`)
|
api(`/tools/media/${mediaType === 'tv' ? 'tv' : 'movie'}/${tmdbId}`)
|
||||||
.then(d => {
|
.then(d => {
|
||||||
setDetail(d);
|
setDetail(d);
|
||||||
detailRef.current = d;
|
detailRef.current = d;
|
||||||
|
|||||||
Reference in New Issue
Block a user