'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 onDeleted?: (gameId: string) => void } export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) { const overlayRef = useRef(null) const menuRef = useRef(null) const [menuOpen, setMenuOpen] = useState(false) const [editingImages, setEditingImages] = useState(false) const [confirming, setConfirming] = useState(false) const [deleting, setDeleting] = useState(false) const [renaming, setRenaming] = useState(false) const [renameName, setRenameName] = useState('') const [renameError, setRenameError] = useState(null) const [renameSaving, setRenameSaving] = useState(false) useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (menuOpen) { setMenuOpen(false); return } if (confirming) { setConfirming(false); return } if (renaming) { setRenaming(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, confirming, renaming]) // 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 (
{editingImages ? ( setEditingImages(false)} onUploaded={onCoverUploaded} /> ) : ( <> {/* Close button */} {/* Hero image */}
{heroImage ? ( // eslint-disable-next-line @next/next/no-img-element {`${game.title} ) : (
๐ŸŽฎ
)}
{/* Info */}
{/* Title row with kebab menu */}

{game.title}

{/* Kebab menu */}
{menuOpen && (
{onDeleted && ( )}
)}
{/* Rename inline input */} {renaming && (
setRenameName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { 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(game.id), newName: trimmed, itemType: 'game' }), }) .then(async (res) => { if (res.status === 409) { setRenameError((await res.json()).error); return } if (!res.ok) throw new Error() setRenaming(false) onCoverUploaded?.() // triggers refetch }) .catch(() => setRenameError('Rename failed')) .finally(() => setRenameSaving(false)) } 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 />
{renameError &&

{renameError}

}
)} {/* Delete confirmation banner */} {confirming && (

Permanently delete this game and all its files?

)} {/* Tags */}

Tags

)}
) } // โ”€โ”€โ”€ Download Button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function DownloadButton({ zipFiles, downloadUrl, }: { zipFiles: string[] downloadUrl: (zipPath: string) => string }) { const [open, setOpen] = useState(false) const ref = useRef(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 ( ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')} > โ†“ Download .zip ) } return (
{/* Primary download */} ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')} onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')} > โ†“ {primaryName} {/* Divider */}
{/* Dropdown toggle */}
{open && ( )}
) } // โ”€โ”€โ”€ Image Editor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ interface ImageEditorProps { game: Game libraryId: string onBack: () => void onUploaded?: () => void } function ImageEditor({ game, libraryId, onBack, onUploaded }: ImageEditorProps) { return (
{/* Header */}

Edit Images

) } // โ”€โ”€โ”€ 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(null) const [preview, setPreview] = useState(null) const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) const displayUrl = preview ?? currentUrl const handleChange = async (e: React.ChangeEvent) => { 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 (

{label}

{description}

{/* Preview */}
{displayUrl ? ( // eslint-disable-next-line @next/next/no-img-element {label} ) : (
{fallback}
)} {uploading && (
Savingโ€ฆ
)}
{/* Controls */}
{error && (

{error}

)}
) }