This repository has been archived on 2026-06-15. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MediaLore/src/components/games/GameDetailModal.tsx
2026-04-11 18:33:03 -04:00

574 lines
23 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
onDeleted?: (gameId: string) => void
}
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(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<string | null>(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 (
<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>
<button
onClick={() => {
setMenuOpen(false)
setRenameName(decodeURIComponent(game.id))
setRenameError(null)
setRenaming(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')}
>
Rename folder
</button>
{onDeleted && (
<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 game
</button>
)}
</div>
)}
</div>
</div>
{/* Rename inline input */}
{renaming && (
<div className="flex flex-col gap-2 mb-4">
<div className="flex gap-2">
<input
type="text"
value={renameName}
onChange={(e) => 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
/>
<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={() => {
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?.()
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}}
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>
)}
{/* Delete 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 game 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={() => {
setDeleting(true)
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`, { method: 'DELETE' })
.then(() => onDeleted!(game.id))
.catch(() => setDeleting(false))
}}
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>
)}
<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>
)
}