- API reads now serve from media_items cache instead of scanning the filesystem on every request; scans (manual or scheduled) remain the write path - NFO metadata is no longer parsed automatically during scans; title falls back to folder/filename — metadata can be refreshed per-item via the kabob menu - Mixed libraries are now indexed in media_items (new mixed_file item type) with file_path stored; scanMixed walks recursively and upserts all files - Added file_path column to media_items and migrated item_type CHECK constraint to include mixed_file via safe table-recreation migration - New POST /api/nfo-refresh endpoint reads the .nfo for a single item and patches its DB row (supports movie, tv_series, tv_episode) - Added "Refresh metadata" button to movie and TV series kabob menus Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import type { Movie } from '@/types'
|
||
import TagSelector from '@/components/tags/TagSelector'
|
||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||
|
||
interface Props {
|
||
movie: Movie
|
||
libraryId: string
|
||
onClose: () => void
|
||
onPrev?: () => void
|
||
onNext?: () => void
|
||
onTagsChanged?: () => void
|
||
onDeleted: (movieId: string) => void
|
||
onMetadataRefreshed?: () => void
|
||
}
|
||
|
||
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed }: Props) {
|
||
const overlayRef = useRef<HTMLDivElement>(null)
|
||
const menuRef = useRef<HTMLDivElement>(null)
|
||
const [playing, setPlaying] = useState(false)
|
||
const [menuOpen, setMenuOpen] = useState(false)
|
||
const [confirming, setConfirming] = useState(false)
|
||
const [deleting, setDeleting] = useState(false)
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
|
||
useEffect(() => {
|
||
const handleKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
if (menuOpen) { setMenuOpen(false); return }
|
||
if (confirming) { setConfirming(false); return }
|
||
onClose()
|
||
}
|
||
}
|
||
document.addEventListener('keydown', handleKey)
|
||
document.body.style.overflow = 'hidden'
|
||
return () => {
|
||
document.removeEventListener('keydown', handleKey)
|
||
document.body.style.overflow = ''
|
||
}
|
||
}, [onClose, menuOpen, confirming])
|
||
|
||
// Close menu on outside click
|
||
useEffect(() => {
|
||
if (!menuOpen) return
|
||
const handler = (e: MouseEvent) => {
|
||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||
setMenuOpen(false)
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handler)
|
||
return () => document.removeEventListener('mousedown', handler)
|
||
}, [menuOpen])
|
||
|
||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||
if (e.target === overlayRef.current) onClose()
|
||
}
|
||
|
||
const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(movie.videoPath)}`
|
||
|
||
const handleConfirmDelete = () => {
|
||
setDeleting(true)
|
||
fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}&movieId=${encodeURIComponent(movie.id)}`, {
|
||
method: 'DELETE',
|
||
})
|
||
.then(() => onDeleted(movie.id))
|
||
.catch(() => setDeleting(false))
|
||
}
|
||
|
||
const handleRefreshMetadata = () => {
|
||
setRefreshing(true)
|
||
setMenuOpen(false)
|
||
const itemKey = `${libraryId}:movie:${movie.id}`
|
||
fetch(
|
||
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=movie&itemKey=${encodeURIComponent(itemKey)}`,
|
||
{ method: 'POST' }
|
||
)
|
||
.then(() => onMetadataRefreshed?.())
|
||
.finally(() => setRefreshing(false))
|
||
}
|
||
|
||
if (playing) {
|
||
return (
|
||
<VideoPlayerModal
|
||
url={videoUrl}
|
||
name={movie.title}
|
||
mediaKey={`${libraryId}:${movie.id}`}
|
||
onTagsChanged={onTagsChanged}
|
||
onClose={() => setPlaying(false)}
|
||
onPrev={onPrev}
|
||
onNext={onNext}
|
||
context="movies"
|
||
/>
|
||
)
|
||
}
|
||
|
||
const heroUrl = movie.backdropUrl ?? movie.posterUrl
|
||
|
||
return (
|
||
<div
|
||
ref={overlayRef}
|
||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||
onClick={handleOverlayClick}
|
||
>
|
||
<div
|
||
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||
>
|
||
{/* Close button */}
|
||
<button
|
||
onClick={onClose}
|
||
className="absolute top-3 right-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
||
aria-label="Close"
|
||
>
|
||
✕
|
||
</button>
|
||
|
||
{/* Prev / Next buttons on the detail card */}
|
||
{onPrev && (
|
||
<button
|
||
onClick={onPrev}
|
||
className="absolute top-3 left-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
||
aria-label="Previous movie"
|
||
>
|
||
‹
|
||
</button>
|
||
)}
|
||
{onNext && (
|
||
<button
|
||
onClick={onNext}
|
||
className="absolute top-3 z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'var(--text-primary)', right: onPrev ? '3rem' : undefined }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.8)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(0,0,0,0.5)')}
|
||
aria-label="Next movie"
|
||
>
|
||
›
|
||
</button>
|
||
)}
|
||
|
||
{/* Hero image */}
|
||
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
||
{heroUrl ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={heroUrl}
|
||
alt={movie.title}
|
||
className="w-full object-cover max-h-64"
|
||
/>
|
||
) : (
|
||
<div className="h-40 flex items-center justify-center text-5xl">🎬</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info */}
|
||
<div className="p-5">
|
||
{/* Title row with kebab menu */}
|
||
<div className="flex items-start gap-2 mb-1">
|
||
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
|
||
{movie.title}
|
||
</h2>
|
||
{movie.year && (
|
||
<span className="text-sm flex-shrink-0 mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||
{movie.year}
|
||
</span>
|
||
)}
|
||
{/* Kebab menu */}
|
||
<div className="relative flex-shrink-0" ref={menuRef}>
|
||
<button
|
||
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
|
||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
||
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||
aria-label="More options"
|
||
>
|
||
⋮
|
||
</button>
|
||
{menuOpen && (
|
||
<div
|
||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||
>
|
||
<button
|
||
onClick={handleRefreshMetadata}
|
||
disabled={refreshing}
|
||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
|
||
style={{ color: 'var(--text-primary)' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||
>
|
||
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
|
||
</button>
|
||
<button
|
||
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||
style={{ color: '#fca5a5' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||
>
|
||
Delete movie
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Meta row */}
|
||
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
|
||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||
{movie.rating !== null && (
|
||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||
★ {movie.rating.toFixed(1)}
|
||
</span>
|
||
)}
|
||
{movie.runtime !== null && (
|
||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||
{movie.runtime} min
|
||
</span>
|
||
)}
|
||
{movie.genres.map((g) => (
|
||
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||
{g}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{movie.plot && (
|
||
<p className="text-sm mb-4 line-clamp-4" style={{ color: 'var(--text-secondary)' }}>
|
||
{movie.plot}
|
||
</p>
|
||
)}
|
||
|
||
{/* Confirmation banner */}
|
||
{confirming && (
|
||
<div
|
||
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
||
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
|
||
>
|
||
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
|
||
Permanently delete this movie and all its files?
|
||
</p>
|
||
<button
|
||
onClick={() => setConfirming(false)}
|
||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleConfirmDelete}
|
||
disabled={deleting}
|
||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
|
||
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
|
||
>
|
||
{deleting ? 'Deleting…' : 'Yes, delete'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Play button */}
|
||
<button
|
||
onClick={() => setPlaying(true)}
|
||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
||
>
|
||
<span>▶</span>
|
||
Play
|
||
</button>
|
||
|
||
{/* Tags */}
|
||
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||
Tags
|
||
</p>
|
||
<TagSelector mediaKey={`${libraryId}:${movie.id}`} onTagsChanged={onTagsChanged} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|