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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user