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:
2026-03-25 19:50:28 -04:00
parent 75fe82f0de
commit 43436f5cae
8 changed files with 643 additions and 122 deletions

View File

@@ -5,6 +5,7 @@ import type { DirectoryListing, FileEntry } from '@/types'
import VideoPlayerModal from './VideoPlayerModal'
import ImageLightbox from './ImageLightbox'
import TagSelector from '@/components/tags/TagSelector'
import FilterPanel from '@/components/FilterPanel'
interface Props {
libraryId: string
@@ -25,6 +26,17 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const [error, setError] = useState<string | null>(null)
const [modal, setModal] = useState<ModalState>(null)
const [tagPanel, setTagPanel] = useState<TagPanelState>(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
})
const loadPath = useCallback(
(path: string) => {
@@ -51,6 +63,15 @@ export default function MixedView({ libraryId, initialPath }: Props) {
loadPath(initialPath)
}, [loadPath, initialPath])
const fetchAssignments = useCallback(() => {
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json())
.then(setAssignments)
.catch(() => {})
}, [libraryId])
useEffect(() => { fetchAssignments() }, [fetchAssignments])
const handleEntry = (entry: FileEntry) => {
if (entry.type === 'directory') {
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
@@ -85,8 +106,34 @@ export default function MixedView({ libraryId, initialPath }: Props) {
? currentPath.split('/').filter(Boolean)
: []
const mediaKeyFor = (entry: FileEntry) => {
const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name
return `${libraryId}:${encodeURIComponent(rel)}`
}
const filteredEntries = (listing?.entries ?? []).filter((entry) => {
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
const entryTags = assignments[mediaKeyFor(entry)] ?? []
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
}
return true
})
return (
<>
<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">
{/* Breadcrumb */}
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
<button
@@ -126,7 +173,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
{!loading && !error && listing && (
<>
{listing.entries.length === 0 ? (
{filteredEntries.length === 0 ? (
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
This folder is empty.
</div>
@@ -145,7 +192,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
<span>Up</span>
</button>
)}
{listing.entries.map((entry) => (
{filteredEntries.map((entry) => (
<EntryTile key={entry.name} entry={entry} onOpen={handleEntry} onTag={handleTagEntry} />
))}
</div>
@@ -192,12 +239,16 @@ export default function MixedView({ libraryId, initialPath }: Props) {
</button>
</div>
<div className="px-5 py-4">
<TagSelector mediaKey={tagPanel.mediaKey} />
<TagSelector
mediaKey={tagPanel.mediaKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
/>
</div>
</div>
</div>
)}
</>
</div>
</div>
)
}
@@ -222,9 +273,12 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil
const icon = isDir ? '📁' : isVideo ? '▶' : entry.mediaType === 'image' ? '🖼' : '📄'
return (
<button
<div
role="button"
tabIndex={0}
onClick={() => onOpen(entry)}
className="group relative flex flex-col rounded-xl border overflow-hidden text-xs transition-all focus:outline-none text-left"
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(entry) } }}
className="group relative flex flex-col rounded-xl border overflow-hidden text-xs transition-all cursor-pointer"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
@@ -303,7 +357,7 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil
>
🏷
</button>
</button>
</div>
)
}