- Add left sidebar filter panel to MixedView and GamesView with name search and tag toggles; only shows tags/categories used in the current library; AND logic when multiple tags are selected - Add GET /api/tags/library-assignments endpoint returning all tag assignments for a library keyed by mediaKey - Add getTagAssignmentsForLibrary() and getTagsSortedByUsage() to tags lib - Support ?sort=usage on GET /api/tags/items to order by assignment count - Tag selector: per-category search, top-25-by-usage display, inline add tag (auto-assigned to current item) and add category flows - Tag selector: group assigned tags by category into nested pills - Fix nested <button> hydration error in EntryTile (outer element is now a div with role="button") - Keep filter panel assignments in sync when tags are toggled or created Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
108 lines
3.8 KiB
TypeScript
108 lines
3.8 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef } from 'react'
|
|
import type { Game } from '@/types'
|
|
import TagSelector from '@/components/tags/TagSelector'
|
|
|
|
interface Props {
|
|
game: Game
|
|
libraryId: string
|
|
onClose: () => void
|
|
onTagsChanged?: () => void
|
|
}
|
|
|
|
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged }: Props) {
|
|
const overlayRef = useRef<HTMLDivElement>(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 downloadHref = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(game.zipPath)}`
|
|
|
|
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>
|
|
|
|
{/* Wide cover / cover hero */}
|
|
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
|
{game.wideCoverUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={game.wideCoverUrl}
|
|
alt={`${game.title} wide cover`}
|
|
className="w-full object-cover max-h-64"
|
|
/>
|
|
) : game.coverUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={game.coverUrl}
|
|
alt={`${game.title} cover`}
|
|
className="w-full object-contain max-h-64"
|
|
/>
|
|
) : (
|
|
<div className="h-40 flex items-center justify-center text-5xl">🎮</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="p-5">
|
|
<h2 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
|
{game.title}
|
|
</h2>
|
|
<a
|
|
href={downloadHref}
|
|
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>
|
|
|
|
{/* 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}:${game.id}`} onTagsChanged={onTagsChanged} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|