media_key was a lossy shortening of item_key (libraryId:lastSegment) that introduced a real collision bug: two TV episodes from different series with the same filename would share the same media_key and each other's tags. - DB migration converts existing media_tags rows from short format to full item_key by joining against media_items; ambiguous/orphaned rows are dropped - media_tags column renamed media_key → item_key - Removed itemKeyToMediaKey() from scanner; reconcileAndPrune now passes item_key directly to reKeyMediaItem - DB reader functions (tv, movies, games) now expose item_key on returned entities; frontend components use entity.item_key instead of constructing the short libraryId:id form - MixedView now constructs the full mixed_file: item_key format - Tag API renamed mediaKey param → itemKey throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
|
import type { Game } from '@/types'
|
|
import TagSelector from '@/components/tags/TagSelector'
|
|
|
|
interface Props {
|
|
game: Game
|
|
libraryId: string
|
|
onClose: () => void
|
|
onTagsChanged?: () => void
|
|
onCoverUploaded?: () => void
|
|
}
|
|
|
|
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded }: Props) {
|
|
const overlayRef = useRef<HTMLDivElement>(null)
|
|
const menuRef = useRef<HTMLDivElement>(null)
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const [editingImages, setEditingImages] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
if (menuOpen) { setMenuOpen(false); return }
|
|
if (editingImages) { setEditingImages(false); return }
|
|
onClose()
|
|
}
|
|
}
|
|
document.addEventListener('keydown', handleKey)
|
|
document.body.style.overflow = 'hidden'
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKey)
|
|
document.body.style.overflow = ''
|
|
}
|
|
}, [onClose, menuOpen, editingImages])
|
|
|
|
// 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 zipDownloadUrl = (zipPath: string) =>
|
|
`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(zipPath)}`
|
|
const heroImage = game.wideCoverUrl ?? game.coverUrl
|
|
|
|
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)' }}
|
|
>
|
|
{editingImages ? (
|
|
<ImageEditor
|
|
game={game}
|
|
libraryId={libraryId}
|
|
onBack={() => setEditingImages(false)}
|
|
onUploaded={onCoverUploaded}
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* 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)' }}>
|
|
{heroImage ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={heroImage} alt={`${game.title} cover`} 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-center gap-2 mb-4">
|
|
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
|
|
{game.title}
|
|
</h2>
|
|
|
|
{/* Kebab menu */}
|
|
<div className="relative flex-shrink-0" ref={menuRef}>
|
|
<button
|
|
onClick={() => setMenuOpen((o) => !o)}
|
|
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={() => { setMenuOpen(false); setEditingImages(true) }}
|
|
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 images
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DownloadButton zipFiles={game.zipFiles} downloadUrl={zipDownloadUrl} />
|
|
|
|
{/* 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 itemKey={game.item_key!} onTagsChanged={onTagsChanged} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Download Button ──────────────────────────────────────────────────────────
|
|
|
|
function DownloadButton({
|
|
zipFiles,
|
|
downloadUrl,
|
|
}: {
|
|
zipFiles: string[]
|
|
downloadUrl: (zipPath: string) => string
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
|
|
const close = useCallback(() => setOpen(false), [])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const handler = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) close()
|
|
}
|
|
document.addEventListener('mousedown', handler)
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
}, [open, close])
|
|
|
|
const primary = zipFiles[0]
|
|
const primaryName = primary.split('/').pop() ?? primary
|
|
|
|
if (zipFiles.length === 1) {
|
|
return (
|
|
<a
|
|
href={downloadUrl(primary)}
|
|
download
|
|
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>
|
|
Download .zip
|
|
</a>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<div className="flex rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--accent)' }}>
|
|
{/* Primary download */}
|
|
<a
|
|
href={downloadUrl(primary)}
|
|
download
|
|
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors"
|
|
style={{ color: '#fff' }}
|
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
|
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
>
|
|
<span>↓</span>
|
|
{primaryName}
|
|
</a>
|
|
|
|
{/* Divider */}
|
|
<div style={{ width: '1px', backgroundColor: 'rgba(255,255,255,0.25)' }} />
|
|
|
|
{/* Dropdown toggle */}
|
|
<button
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="px-3 flex items-center justify-center text-sm transition-colors"
|
|
style={{ color: '#fff' }}
|
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
|
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
|
aria-label="Show all downloads"
|
|
>
|
|
▾
|
|
</button>
|
|
</div>
|
|
|
|
{open && (
|
|
<div
|
|
className="absolute left-0 right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20"
|
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
|
>
|
|
{zipFiles.map((zipPath) => {
|
|
const name = zipPath.split('/').pop() ?? zipPath
|
|
return (
|
|
<a
|
|
key={zipPath}
|
|
href={downloadUrl(zipPath)}
|
|
download
|
|
onClick={close}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm 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')}
|
|
>
|
|
<span style={{ color: 'var(--text-secondary)' }}>↓</span>
|
|
{name}
|
|
</a>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Image Editor ─────────────────────────────────────────────────────────────
|
|
|
|
interface ImageEditorProps {
|
|
game: Game
|
|
libraryId: string
|
|
onBack: () => void
|
|
onUploaded?: () => void
|
|
}
|
|
|
|
function ImageEditor({ game, libraryId, onBack, onUploaded }: ImageEditorProps) {
|
|
return (
|
|
<div className="p-5">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<button
|
|
onClick={onBack}
|
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors flex-shrink-0"
|
|
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="Back"
|
|
>
|
|
←
|
|
</button>
|
|
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
Edit Images
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-6">
|
|
<ImageSlot
|
|
label="Cover"
|
|
description="Portrait artwork (3:4)"
|
|
currentUrl={game.coverUrl}
|
|
fallback="🎮"
|
|
aspectClass="aspect-[3/4]"
|
|
libraryId={libraryId}
|
|
itemId={game.id}
|
|
coverType="cover"
|
|
onUploaded={onUploaded}
|
|
/>
|
|
<ImageSlot
|
|
label="Wide Cover"
|
|
description="Landscape hero image"
|
|
currentUrl={game.wideCoverUrl}
|
|
fallback="🎮"
|
|
aspectClass="aspect-video"
|
|
libraryId={libraryId}
|
|
itemId={game.id}
|
|
coverType="widecover"
|
|
onUploaded={onUploaded}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Image Slot ───────────────────────────────────────────────────────────────
|
|
|
|
interface ImageSlotProps {
|
|
label: string
|
|
description: string
|
|
currentUrl: string | null
|
|
fallback: string
|
|
aspectClass: string
|
|
libraryId: string
|
|
itemId: string
|
|
coverType: 'cover' | 'widecover'
|
|
onUploaded?: () => void
|
|
}
|
|
|
|
function ImageSlot({
|
|
label, description, currentUrl, fallback, aspectClass,
|
|
libraryId, itemId, coverType, onUploaded,
|
|
}: ImageSlotProps) {
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const [preview, setPreview] = useState<string | null>(null)
|
|
const [uploading, setUploading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const displayUrl = preview ?? currentUrl
|
|
|
|
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
|
|
// Show local preview immediately
|
|
const objectUrl = URL.createObjectURL(file)
|
|
setPreview(objectUrl)
|
|
setError(null)
|
|
setUploading(true)
|
|
|
|
const form = new FormData()
|
|
form.append('cover', file)
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`/api/game-cover?libraryId=${encodeURIComponent(libraryId)}&itemId=${encodeURIComponent(itemId)}&coverType=${coverType}`,
|
|
{ method: 'POST', body: form }
|
|
)
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
setError(data.error ?? 'Upload failed.')
|
|
setPreview(null)
|
|
} else {
|
|
onUploaded?.()
|
|
}
|
|
} catch {
|
|
setError('Network error.')
|
|
setPreview(null)
|
|
} finally {
|
|
setUploading(false)
|
|
e.target.value = ''
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-baseline justify-between mb-2">
|
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>{description}</p>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-4">
|
|
{/* Preview */}
|
|
<div
|
|
className={`${aspectClass} rounded-lg overflow-hidden flex-shrink-0 relative`}
|
|
style={{
|
|
width: coverType === 'cover' ? '80px' : '160px',
|
|
backgroundColor: 'var(--border)',
|
|
border: '1px solid var(--border)',
|
|
}}
|
|
>
|
|
{displayUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={displayUrl} alt={label} className="w-full h-full object-cover" />
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-2xl">{fallback}</div>
|
|
)}
|
|
{uploading && (
|
|
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
<span className="text-xs text-white">Saving…</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="flex flex-col gap-2 flex-1 min-w-0">
|
|
<button
|
|
onClick={() => inputRef.current?.click()}
|
|
disabled={uploading}
|
|
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 text-left"
|
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
|
>
|
|
{currentUrl || preview ? 'Replace image' : 'Upload image'}
|
|
</button>
|
|
{error && (
|
|
<p className="text-xs" style={{ color: '#fca5a5' }}>{error}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
className="hidden"
|
|
onChange={handleChange}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|