616 lines
27 KiB
TypeScript
616 lines
27 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import type { Movie } from '@/types'
|
||
import TagSelector from '@/components/tags/TagSelector'
|
||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
||
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)
|
||
const [editing, setEditing] = useState(false)
|
||
const [saving, setSaving] = useState(false)
|
||
const [editForm, setEditForm] = useState({ title: '', year: '', plot: '', genres: '' })
|
||
const [warnRefresh, setWarnRefresh] = useState(false)
|
||
const [renaming, setRenaming] = useState(false)
|
||
const [renameName, setRenameName] = useState('')
|
||
const [renameError, setRenameError] = useState<string | null>(null)
|
||
const [renameSaving, setRenameSaving] = useState(false)
|
||
const [showTagPanel, setShowTagPanel] = useState(false)
|
||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||
|
||
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||
|
||
useEffect(() => {
|
||
const handleKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
if (menuOpen) { setMenuOpen(false); return }
|
||
if (confirming) { setConfirming(false); return }
|
||
if (warnRefresh) { setWarnRefresh(false); return }
|
||
if (editing) { setEditing(false); return }
|
||
if (renaming) { setRenaming(false); return }
|
||
if (showTagPanel) { setShowTagPanel(false); return }
|
||
onClose()
|
||
}
|
||
}
|
||
document.addEventListener('keydown', handleKey)
|
||
document.body.style.overflow = 'hidden'
|
||
return () => {
|
||
document.removeEventListener('keydown', handleKey)
|
||
document.body.style.overflow = ''
|
||
}
|
||
}, [onClose, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel])
|
||
|
||
// 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 doRefreshMetadata = () => {
|
||
setRefreshing(true)
|
||
setWarnRefresh(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))
|
||
}
|
||
|
||
const handleRefreshMetadata = () => {
|
||
setMenuOpen(false)
|
||
if (movie.manuallyEdited) {
|
||
setWarnRefresh(true)
|
||
} else {
|
||
doRefreshMetadata()
|
||
}
|
||
}
|
||
|
||
const handleStartEditing = () => {
|
||
setMenuOpen(false)
|
||
setEditForm({
|
||
title: movie.title,
|
||
year: movie.year?.toString() ?? '',
|
||
plot: movie.plot ?? '',
|
||
genres: movie.genres.join(', '),
|
||
})
|
||
setEditing(true)
|
||
}
|
||
|
||
const handleSaveMetadata = () => {
|
||
setSaving(true)
|
||
const genres = editForm.genres.split(',').map((g) => g.trim()).filter(Boolean)
|
||
const yearNum = editForm.year ? parseInt(editForm.year, 10) : null
|
||
fetch('/api/metadata', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
itemKey: movie.item_key,
|
||
title: editForm.title,
|
||
year: isNaN(yearNum as number) ? null : yearNum,
|
||
plot: editForm.plot || null,
|
||
genres,
|
||
}),
|
||
})
|
||
.then(() => { setEditing(false); onMetadataRefreshed?.() })
|
||
.finally(() => setSaving(false))
|
||
}
|
||
|
||
const handleStartRename = () => {
|
||
setMenuOpen(false)
|
||
setRenameName(decodeURIComponent(movie.id))
|
||
setRenameError(null)
|
||
setRenaming(true)
|
||
}
|
||
|
||
const handleRename = () => {
|
||
const trimmed = renameName.trim()
|
||
if (!trimmed) return
|
||
setRenameSaving(true)
|
||
setRenameError(null)
|
||
fetch('/api/rename', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
libraryId,
|
||
oldPath: decodeURIComponent(movie.id),
|
||
newName: trimmed,
|
||
itemType: 'movie',
|
||
}),
|
||
})
|
||
.then(async (res) => {
|
||
if (res.status === 409) {
|
||
const data = await res.json()
|
||
setRenameError(data.error)
|
||
return
|
||
}
|
||
if (!res.ok) throw new Error()
|
||
setRenaming(false)
|
||
onMetadataRefreshed?.()
|
||
})
|
||
.catch(() => setRenameError('Rename failed'))
|
||
.finally(() => setRenameSaving(false))
|
||
}
|
||
|
||
if (playing) {
|
||
return (
|
||
<VideoPlayerModal
|
||
url={videoUrl}
|
||
name={movie.title}
|
||
itemKey={movie.item_key!}
|
||
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 overflow-hidden"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.75)', height: '100vh' }}
|
||
onClick={handleOverlayClick}
|
||
>
|
||
{/* Outer flex — row on md+, col on mobile when panel open */}
|
||
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : ''}`}>
|
||
|
||
{/* ── Left pane — relative container for floating controls ── */}
|
||
<div className="flex-1 min-h-0 min-w-0 relative" onClick={() => onClose()}>
|
||
{/* Scrollable card area */}
|
||
<div className="h-full overflow-y-auto flex items-center justify-center p-4">
|
||
<div
|
||
className="w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
|
||
{/* 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={handleStartEditing}
|
||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||
style={{ color: 'var(--text-primary)' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||
>
|
||
Edit metadata
|
||
</button>
|
||
<button
|
||
onClick={handleStartRename}
|
||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||
style={{ color: 'var(--text-primary)' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||
>
|
||
Rename folder
|
||
</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>
|
||
|
||
{/* Rename inline input */}
|
||
{renaming && (
|
||
<div className="flex flex-col gap-2 mb-3">
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={renameName}
|
||
onChange={(e) => setRenameName(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }}
|
||
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
|
||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||
autoFocus
|
||
/>
|
||
<button
|
||
onClick={() => setRenaming(false)}
|
||
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
|
||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleRename}
|
||
disabled={renameSaving}
|
||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||
>
|
||
{renameSaving ? '…' : 'Rename'}
|
||
</button>
|
||
</div>
|
||
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
|
||
</div>
|
||
)}
|
||
|
||
{editing ? (
|
||
<div className="flex flex-col gap-3 mb-4">
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label>
|
||
<input
|
||
type="text"
|
||
value={editForm.title}
|
||
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
|
||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Year</label>
|
||
<input
|
||
type="number"
|
||
value={editForm.year}
|
||
onChange={(e) => setEditForm((f) => ({ ...f, year: e.target.value }))}
|
||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Plot</label>
|
||
<textarea
|
||
rows={3}
|
||
value={editForm.plot}
|
||
onChange={(e) => setEditForm((f) => ({ ...f, plot: e.target.value }))}
|
||
className="w-full px-3 py-1.5 rounded-lg text-sm resize-none"
|
||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Genres (comma-separated)</label>
|
||
<input
|
||
type="text"
|
||
value={editForm.genres}
|
||
onChange={(e) => setEditForm((f) => ({ ...f, genres: e.target.value }))}
|
||
className="w-full px-3 py-1.5 rounded-lg text-sm"
|
||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2 justify-end">
|
||
<button
|
||
onClick={() => setEditing(false)}
|
||
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleSaveMetadata}
|
||
disabled={saving}
|
||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||
>
|
||
{saving ? 'Saving…' : 'Save'}
|
||
</button>
|
||
</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>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* NFO refresh warning */}
|
||
{warnRefresh && (
|
||
<div
|
||
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
||
style={{ backgroundColor: '#78350f33', border: '1px solid #78350f' }}
|
||
>
|
||
<p className="flex-1 text-xs" style={{ color: '#fbbf24' }}>
|
||
Refreshing from NFO will overwrite your manual edits.
|
||
</p>
|
||
<button
|
||
onClick={() => setWarnRefresh(false)}
|
||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={doRefreshMetadata}
|
||
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
|
||
>
|
||
Overwrite
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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>
|
||
)}
|
||
|
||
{/* Assigned tags (read-only) above action buttons */}
|
||
{movie.item_key && (
|
||
<div className="mb-3">
|
||
<AssignedTagBadges itemKey={movie.item_key} refreshKey={tagRefreshKey} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Action buttons row: Play + Download */}
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setPlaying(true)}
|
||
className="flex-1 flex items-center justify-center gap-2 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>
|
||
<a
|
||
href={videoUrl}
|
||
download
|
||
className="flex items-center justify-center px-3 py-2.5 rounded-lg text-sm font-medium transition-colors"
|
||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||
onClick={(e) => e.stopPropagation()}
|
||
title="Download"
|
||
aria-label="Download"
|
||
>
|
||
↓
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Floating controls — tag + close */}
|
||
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||
{movie.item_key && !showTagPanel && (
|
||
<button
|
||
onClick={() => setShowTagPanel(true)}
|
||
className={smallBtn}
|
||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
||
aria-label="Show tags"
|
||
title="Tags"
|
||
>
|
||
🏷
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={onClose}
|
||
className={smallBtn}
|
||
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-primary)' }}
|
||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)')}
|
||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)')}
|
||
aria-label="Close"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Prev / Next */}
|
||
{onPrev && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onPrev() }}
|
||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||
aria-label="Previous"
|
||
>
|
||
‹
|
||
</button>
|
||
)}
|
||
{onNext && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onNext() }}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-12 h-12 rounded-full flex items-center justify-center text-lg transition-opacity hover:opacity-100 opacity-70"
|
||
style={{ backgroundColor: 'rgba(0,0,0,0.4)', color: '#fff' }}
|
||
aria-label="Next"
|
||
>
|
||
›
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||
{showTagPanel && (
|
||
<div
|
||
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Panel header — ‹ hide | ✕ close */}
|
||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
||
<button
|
||
onClick={() => setShowTagPanel(false)}
|
||
className={smallBtn}
|
||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||
aria-label="Hide panel"
|
||
title="Hide panel"
|
||
>
|
||
›
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className={smallBtn}
|
||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||
aria-label="Close"
|
||
title="Close"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Tags */}
|
||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||
Tags
|
||
</p>
|
||
<TagSelector
|
||
itemKey={movie.item_key!}
|
||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||
refreshKey={tagRefreshKey}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|