add library filter panel and tag selector enhancements
- 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>
This commit is contained in:
@@ -8,9 +8,10 @@ interface Props {
|
||||
game: Game
|
||||
libraryId: string
|
||||
onClose: () => void
|
||||
onTagsChanged?: () => void
|
||||
}
|
||||
|
||||
export default function GameDetailModal({ game, libraryId, onClose }: Props) {
|
||||
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,7 +98,7 @@ export default function GameDetailModal({ game, libraryId, onClose }: Props) {
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
Tags
|
||||
</p>
|
||||
<TagSelector mediaKey={`${libraryId}:${game.id}`} />
|
||||
<TagSelector mediaKey={`${libraryId}:${game.id}`} onTagsChanged={onTagsChanged} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import type { Game } from '@/types'
|
||||
import GameDetailModal from './GameDetailModal'
|
||||
import FilterPanel from '@/components/FilterPanel'
|
||||
|
||||
interface Props {
|
||||
libraryId: string
|
||||
@@ -13,6 +14,17 @@ export default function GamesView({ libraryId }: Props) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<Game | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||
|
||||
const toggleTag = (tagId: string) =>
|
||||
setSelectedTagIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
|
||||
return next
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
@@ -27,62 +39,102 @@ export default function GamesView({ libraryId }: Props) {
|
||||
})
|
||||
}, [libraryId])
|
||||
|
||||
if (loading) return <LoadingGrid />
|
||||
if (error) return <ErrorMessage message={error} />
|
||||
if (games.length === 0) return <EmptyState />
|
||||
const fetchAssignments = useCallback(() => {
|
||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
.then((r) => r.json())
|
||||
.then(setAssignments)
|
||||
.catch(() => {})
|
||||
}, [libraryId])
|
||||
|
||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||
|
||||
const filtered = games.filter((game) => {
|
||||
if (search && !game.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||
if (selectedTagIds.size > 0) {
|
||||
const gameTags = assignments[`${libraryId}:${game.id}`] ?? []
|
||||
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{games.map((game) => (
|
||||
<button
|
||||
key={game.id}
|
||||
onClick={() => setSelected(game)}
|
||||
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
backgroundColor: 'var(--surface)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||||
}}
|
||||
>
|
||||
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||||
{game.coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={game.coverUrl}
|
||||
alt={game.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl">
|
||||
🎮
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p
|
||||
className="text-xs font-medium truncate leading-tight"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
title={game.title}
|
||||
>
|
||||
{game.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="w-52 flex-shrink-0">
|
||||
<FilterPanel
|
||||
libraryId={libraryId}
|
||||
assignments={assignments}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
selectedTagIds={selectedTagIds}
|
||||
onTagToggle={toggleTag}
|
||||
refreshKey={filterRefreshKey}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{loading ? (
|
||||
<LoadingGrid />
|
||||
) : error ? (
|
||||
<ErrorMessage message={error} />
|
||||
) : games.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{filtered.map((game) => (
|
||||
<button
|
||||
key={game.id}
|
||||
onClick={() => setSelected(game)}
|
||||
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
backgroundColor: 'var(--surface)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||||
}}
|
||||
>
|
||||
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||||
{game.coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={game.coverUrl}
|
||||
alt={game.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl">
|
||||
🎮
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p
|
||||
className="text-xs font-medium truncate leading-tight"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
title={game.title}
|
||||
>
|
||||
{game.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<GameDetailModal game={selected} libraryId={libraryId} onClose={() => setSelected(null)} />
|
||||
)}
|
||||
</>
|
||||
{selected && (
|
||||
<GameDetailModal
|
||||
game={selected}
|
||||
libraryId={libraryId}
|
||||
onClose={() => setSelected(null)}
|
||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user