add movies and tv show library types with Jellyfin NFO support
- Add `movies` type: per-movie folders with video files, poster/backdrop images, and optional Jellyfin NFO metadata (title, year, plot, rating, genres, runtime). Grid view with 2:3 poster art, detail modal with play and two-click delete of the movie folder. - Add `tv` type: Series -> Season -> Episode hierarchy with lazy loading at each level. Reads tvshow.nfo and episodedetails NFO files for metadata. Episode grid with video thumbnails, streams via existing video player. Delete is limited to the entire series folder to avoid breaking Jellyfin. - Add fast-xml-parser dependency for Kodi/Jellyfin NFO parsing (lib/nfo.ts) - Migrate existing DB to expand the libraries CHECK constraint to include the two new types; migration is idempotent and preserves existing data. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
208
src/components/movies/MovieDetailModal.tsx
Normal file
208
src/components/movies/MovieDetailModal.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'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
|
||||
onTagsChanged?: () => void
|
||||
onDeleted: (movieId: string) => void
|
||||
}
|
||||
|
||||
export default function MovieDetailModal({ movie, libraryId, onClose, onTagsChanged, onDeleted }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) onClose()
|
||||
}
|
||||
|
||||
const videoUrl = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(movie.videoPath)}`
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (!confirming) {
|
||||
setConfirming(true)
|
||||
cancelRef.current = setTimeout(() => setConfirming(false), 4000)
|
||||
return
|
||||
}
|
||||
if (cancelRef.current) clearTimeout(cancelRef.current)
|
||||
setDeleting(true)
|
||||
fetch(`/api/movies?libraryId=${encodeURIComponent(libraryId)}&movieId=${encodeURIComponent(movie.id)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(() => onDeleted(movie.id))
|
||||
.catch(() => setDeleting(false))
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
if (cancelRef.current) clearTimeout(cancelRef.current)
|
||||
setConfirming(false)
|
||||
}
|
||||
|
||||
if (playing) {
|
||||
return <VideoPlayerModal url={videoUrl} name={movie.title} onClose={() => setPlaying(false)} />
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* 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">
|
||||
<div className="flex items-start justify-between gap-3 mb-1">
|
||||
<h2 className="text-lg font-semibold" 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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="mt-4 pt-4 flex items-center gap-2" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
{confirming && (
|
||||
<p className="text-xs flex-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
This will permanently delete the folder and all its contents.
|
||||
</p>
|
||||
)}
|
||||
{confirming && (
|
||||
<button
|
||||
onClick={handleCancelDelete}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
disabled={deleting}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg flex-shrink-0 transition-colors disabled:opacity-50 ml-auto"
|
||||
style={{
|
||||
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
|
||||
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!confirming) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
|
||||
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!confirming) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deleting ? 'Deleting…' : confirming ? 'Confirm delete?' : 'Delete movie'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user