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

@@ -130,6 +130,23 @@ export function getTags(categoryId?: string): Tag[] {
.all() as Tag[]
}
export function getTagsSortedByUsage(categoryId?: string): Tag[] {
const db = getDb()
const where = categoryId ? 'WHERE t.category_id = ?' : ''
const params = categoryId ? [categoryId] : []
return db
.prepare(
`SELECT t.id, t.name, t.category_id as categoryId,
COUNT(mt.tag_id) as use_count
FROM tags t
LEFT JOIN media_tags mt ON mt.tag_id = t.id
${where}
GROUP BY t.id
ORDER BY use_count DESC, t.name ASC`
)
.all(...params) as Tag[]
}
export function addTag(name: string, categoryId: string): Tag {
const trimmed = name.trim()
if (!trimmed) throw new Error('Tag name is required.')
@@ -223,6 +240,18 @@ export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categor
return { tags, categories }
}
export function getTagAssignmentsForLibrary(libraryId: string): Record<string, string[]> {
const db = getDb()
const rows = db
.prepare('SELECT media_key, tag_id FROM media_tags WHERE media_key LIKE ?')
.all(`${libraryId}:%`) as { media_key: string; tag_id: string }[]
const result: Record<string, string[]> = {}
for (const row of rows) {
;(result[row.media_key] ??= []).push(row.tag_id)
}
return result
}
export function removeAllAssignmentsForLibrary(libraryId: string): void {
const db = getDb()
db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`)