From 75fe82f0de8758eabdf835e3fc9d84f8b1c061f2 Mon Sep 17 00:00:00 2001 From: Garret Patti Date: Wed, 25 Mar 2026 18:41:37 -0400 Subject: [PATCH 1/3] group selected tags by category in TagSelector Assigned tags now render as a single outer pill per category containing smaller inner tag pills, instead of one pill per tag with a repeated category prefix. Co-Authored-By: Claude Sonnet 4.6 --- src/components/tags/TagSelector.tsx | 61 ++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/src/components/tags/TagSelector.tsx b/src/components/tags/TagSelector.tsx index b6b58ca..3fd3771 100644 --- a/src/components/tags/TagSelector.tsx +++ b/src/components/tags/TagSelector.tsx @@ -87,17 +87,60 @@ export default function TagSelector({ mediaKey }: Props) { return (
- {/* Assigned tags */} + {/* Assigned tags grouped by category */} {assigned.tags.length > 0 && (
- {assigned.tags.map((tag) => ( - toggleTag(tag)} - /> - ))} + {(() => { + const groups = new Map() + const ungrouped: Tag[] = [] + for (const tag of assigned.tags) { + if (tag.categoryId) { + const arr = groups.get(tag.categoryId) ?? [] + arr.push(tag) + groups.set(tag.categoryId, arr) + } else { + ungrouped.push(tag) + } + } + return ( + <> + {Array.from(groups.entries()).map(([catId, tags]) => { + const cat = assignedCategoryMap[catId] + return ( + + {cat?.name}: + {tags.map((tag) => ( + + {tag.name} + + + ))} + + ) + })} + {ungrouped.map((tag) => ( + toggleTag(tag)} /> + ))} + + ) + })()}
)} From 43436f5cae52ff0a600da6eca40850906c216f78 Mon Sep 17 00:00:00 2001 From: Garret Patti Date: Wed, 25 Mar 2026 19:50:28 -0400 Subject: [PATCH 2/3] 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 + ) + })} +
+ + ) + })} + + ) : ( +

+ No tags defined.{' '} + + Manage tags → + +

+ )} + + ) +} diff --git a/src/components/games/GameDetailModal.tsx b/src/components/games/GameDetailModal.tsx index 15d02c3..4777af5 100644 --- a/src/components/games/GameDetailModal.tsx +++ b/src/components/games/GameDetailModal.tsx @@ -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(null) useEffect(() => { @@ -97,7 +98,7 @@ export default function GameDetailModal({ game, libraryId, onClose }: Props) {

Tags

- + diff --git a/src/components/games/GamesView.tsx b/src/components/games/GamesView.tsx index 6a70cd4..9a27e8e 100644 --- a/src/components/games/GamesView.tsx +++ b/src/components/games/GamesView.tsx @@ -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(null) const [selected, setSelected] = useState(null) + const [search, setSearch] = useState('') + const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) + const [assignments, setAssignments] = useState>({}) + 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 - if (error) return - if (games.length === 0) return + 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 ( - <> -
- {games.map((game) => ( - - ))} +
+
+
+
+ {loading ? ( + + ) : error ? ( + + ) : games.length === 0 ? ( + + ) : ( +
+ {filtered.map((game) => ( + + ))} +
+ )} - {selected && ( - setSelected(null)} /> - )} - + {selected && ( + setSelected(null)} + onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} + /> + )} +
+
) } diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx index 4641595..ee344a1 100644 --- a/src/components/mixed/MixedView.tsx +++ b/src/components/mixed/MixedView.tsx @@ -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(null) const [modal, setModal] = useState(null) const [tagPanel, setTagPanel] = useState(null) + const [search, setSearch] = useState('') + const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) + const [assignments, setAssignments] = useState>({}) + 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 ( - <> +
+
+ +
+
{/* Breadcrumb */}
@@ -192,12 +239,16 @@ export default function MixedView({ libraryId, initialPath }: Props) {
- + { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} + />
)} - + + ) } @@ -222,9 +273,12 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil const icon = isDir ? '📁' : isVideo ? '▶' : entry.mediaType === 'image' ? '🖼' : '📄' return ( - - + ) } diff --git a/src/components/tags/TagSelector.tsx b/src/components/tags/TagSelector.tsx index 3fd3771..4f03cc9 100644 --- a/src/components/tags/TagSelector.tsx +++ b/src/components/tags/TagSelector.tsx @@ -6,6 +6,7 @@ import TagBadge from './TagBadge' interface Props { mediaKey: string + onTagsChanged?: () => void } interface AllTags { @@ -13,14 +14,29 @@ interface AllTags { tags: Tag[] } -export default function TagSelector({ mediaKey }: Props) { +export default function TagSelector({ mediaKey, onTagsChanged }: Props) { const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({ tags: [], categories: [], }) const [all, setAll] = useState({ categories: [], tags: [] }) const [loading, setLoading] = useState(true) - const [busy, setBusy] = useState(null) // tagId being toggled + const [busy, setBusy] = useState(null) + + // Per-category search text + const [categorySearches, setCategorySearches] = useState>({}) + + // Inline add-tag flow + const [addingTagTo, setAddingTagTo] = useState(null) + const [newTagName, setNewTagName] = useState('') + const [tagSaveError, setTagSaveError] = useState(null) + const [savingTag, setSavingTag] = useState(false) + + // Inline add-category flow + const [addingCategory, setAddingCategory] = useState(false) + const [newCategoryName, setNewCategoryName] = useState('') + const [categorySaveError, setCategorySaveError] = useState(null) + const [savingCategory, setSavingCategory] = useState(false) const fetchAssigned = useCallback(() => { return fetch(`/api/tags/assignments?mediaKey=${encodeURIComponent(mediaKey)}`) @@ -31,7 +47,7 @@ export default function TagSelector({ mediaKey }: Props) { const fetchAll = useCallback(() => { return Promise.all([ fetch('/api/tags/categories').then((r) => r.json()), - fetch('/api/tags/items').then((r) => r.json()), + fetch('/api/tags/items?sort=usage').then((r) => r.json()), ]).then(([categories, tags]: [TagCategory[], Tag[]]) => { setAll({ categories, tags }) }) @@ -61,11 +77,71 @@ export default function TagSelector({ mediaKey }: Props) { }) } await fetchAssigned() + onTagsChanged?.() } finally { setBusy(null) } } + const submitAddTag = async (categoryId: string) => { + const name = newTagName.trim() + if (!name) return + setSavingTag(true) + setTagSaveError(null) + try { + const res = await fetch('/api/tags/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, categoryId }), + }) + if (!res.ok) { + const { error } = await res.json() + setTagSaveError(error) + return + } + const newTag: Tag = await res.json() + setNewTagName('') + setAddingTagTo(null) + await Promise.all([ + fetch('/api/tags/assignments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mediaKey, tagId: newTag.id }), + }), + fetchAll(), + ]) + await fetchAssigned() + onTagsChanged?.() + } finally { + setSavingTag(false) + } + } + + const submitAddCategory = async () => { + const name = newCategoryName.trim() + if (!name) return + setSavingCategory(true) + setCategorySaveError(null) + try { + const res = await fetch('/api/tags/categories', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) + if (!res.ok) { + const { error } = await res.json() + setCategorySaveError(error) + return + } + setNewCategoryName('') + setAddingCategory(false) + await fetchAll() + onTagsChanged?.() + } finally { + setSavingCategory(false) + } + } + if (loading) { return (
@@ -80,11 +156,8 @@ export default function TagSelector({ mediaKey }: Props) { ) } - const categoryMap = Object.fromEntries(all.categories.map((c) => [c.id, c])) const assignedCategoryMap = Object.fromEntries(assigned.categories.map((c) => [c.id, c])) - const hasAnyTags = all.tags.length > 0 - return (
{/* Assigned tags grouped by category */} @@ -145,61 +218,232 @@ export default function TagSelector({ mediaKey }: Props) { )} {/* Tag picker grouped by category */} - {hasAnyTags ? ( -
- {all.categories.map((category) => { - const categoryTags = all.tags.filter((t) => t.categoryId === category.id) - if (categoryTags.length === 0) return null - return ( -
-

+

+ {all.categories.map((category) => { + const categoryTags = all.tags.filter((t) => t.categoryId === category.id) + const search = categorySearches[category.id] ?? '' + const visibleTags = categoryTags + .filter((t) => !search || t.name.toLowerCase().includes(search.toLowerCase())) + .slice(0, 25) + + return ( +
+ {/* Category header: name + search input */} +
+

{category.name}

-
- {categoryTags.map((tag) => { - const active = isAssigned(tag.id) - const isBusy = busy === tag.id - return ( - - ) - })} -
+ {categoryTags.length > 0 && ( + + setCategorySearches((prev) => ({ ...prev, [category.id]: e.target.value })) + } + className="text-xs px-1.5 py-0.5 rounded outline-none" + style={{ + backgroundColor: 'var(--surface)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + width: 90, + }} + /> + )}
- ) - })} + + {/* Tag pills + add button */} +
+ {visibleTags.map((tag) => { + const active = isAssigned(tag.id) + const isBusy = busy === tag.id + return ( + + ) + })} + + {/* Inline add-tag form or + button */} + {addingTagTo === category.id ? ( + + { + setNewTagName(e.target.value) + setTagSaveError(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') submitAddTag(category.id) + if (e.key === 'Escape') { + setAddingTagTo(null) + setNewTagName('') + setTagSaveError(null) + } + }} + placeholder="tag name" + className="px-1.5 py-0.5 rounded text-xs outline-none" + style={{ + backgroundColor: 'var(--surface)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + width: 90, + }} + disabled={savingTag} + /> + + + {tagSaveError && ( + + {tagSaveError} + + )} + + ) : ( + + )} +
+
+ ) + })} + + {/* Add category */} +
+ {addingCategory ? ( +
+ { + setNewCategoryName(e.target.value) + setCategorySaveError(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') submitAddCategory() + if (e.key === 'Escape') { + setAddingCategory(false) + setNewCategoryName('') + setCategorySaveError(null) + } + }} + placeholder="category name" + className="flex-1 text-xs px-2 py-1 rounded outline-none" + style={{ + backgroundColor: 'var(--surface)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + }} + disabled={savingCategory} + /> + + + {categorySaveError && ( + + {categorySaveError} + + )} +
+ ) : ( + + )}
- ) : ( -

- No tags defined yet.{' '} - - Manage tags → - -

- )} +
) } diff --git a/src/lib/tags.ts b/src/lib/tags.ts index 70d3e56..a794d03 100644 --- a/src/lib/tags.ts +++ b/src/lib/tags.ts @@ -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 { + 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 = {} + 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}:%`) From 8f62fa1935288549fcb52427c8b7705b1568dedc Mon Sep 17 00:00:00 2001 From: Garret Patti Date: Wed, 25 Mar 2026 19:51:57 -0400 Subject: [PATCH 3/3] update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9257e81..557302f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ out/ medialore.db medialore.db-shm medialore.db-wal +tsconfig.tsbuildinfo \ No newline at end of file