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 diff --git a/src/app/api/tags/items/route.ts b/src/app/api/tags/items/route.ts index 59173cb..1ddc6ee 100644 --- a/src/app/api/tags/items/route.ts +++ b/src/app/api/tags/items/route.ts @@ -1,11 +1,14 @@ import { NextRequest, NextResponse } from 'next/server' -import { getTags, addTag } from '@/lib/tags' +import { getTags, getTagsSortedByUsage, addTag } from '@/lib/tags' export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) const categoryId = searchParams.get('categoryId') ?? undefined - return NextResponse.json(getTags(categoryId)) + const sort = searchParams.get('sort') + return NextResponse.json( + sort === 'usage' ? getTagsSortedByUsage(categoryId) : getTags(categoryId) + ) } catch (err) { return NextResponse.json({ error: (err as Error).message }, { status: 500 }) } diff --git a/src/app/api/tags/library-assignments/route.ts b/src/app/api/tags/library-assignments/route.ts new file mode 100644 index 0000000..c4b2c1d --- /dev/null +++ b/src/app/api/tags/library-assignments/route.ts @@ -0,0 +1,8 @@ +import { getTagAssignmentsForLibrary } from '@/lib/tags' + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const libraryId = searchParams.get('libraryId') + if (!libraryId) return Response.json({ error: 'libraryId required' }, { status: 400 }) + return Response.json(getTagAssignmentsForLibrary(libraryId)) +} diff --git a/src/components/FilterPanel.tsx b/src/components/FilterPanel.tsx new file mode 100644 index 0000000..00cf4d2 --- /dev/null +++ b/src/components/FilterPanel.tsx @@ -0,0 +1,130 @@ +'use client' + +import { useEffect, useState } from 'react' +import type { Tag, TagCategory } from '@/types' + +interface Props { + libraryId: string + assignments: Record + search: string + onSearchChange: (q: string) => void + selectedTagIds: Set + onTagToggle: (tagId: string) => void + refreshKey?: number +} + +export default function FilterPanel({ assignments, search, onSearchChange, selectedTagIds, onTagToggle, refreshKey }: Props) { + const [categories, setCategories] = useState([]) + const [tags, setTags] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + Promise.all([ + fetch('/api/tags/categories').then((r) => r.json()), + fetch('/api/tags/items').then((r) => r.json()), + ]) + .then(([cats, ts]: [TagCategory[], Tag[]]) => { + setCategories(cats) + setTags(ts) + }) + .finally(() => setLoading(false)) + }, [refreshKey]) + + const usedTagIds = new Set(Object.values(assignments).flat()) + const usedTags = tags.filter((t) => usedTagIds.has(t.id)) + const usedCategoryIds = new Set(usedTags.map((t) => t.categoryId)) + const usedCategories = categories.filter((c) => usedCategoryIds.has(c.id)) + + const hasTags = usedTags.length > 0 + + return ( +
+ {/* Search */} + onSearchChange(e.target.value)} + className="w-full rounded-lg px-3 py-2 text-sm outline-none" + style={{ + backgroundColor: 'var(--surface)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + }} + /> + + {/* Tag filters */} + {loading ? ( +
+ {[0, 1].map((i) => ( +
+
+
+ {[50, 65, 42].map((w) => ( +
+ ))} +
+
+ ))} +
+ ) : hasTags ? ( +
+ {usedCategories.map((cat) => { + const catTags = usedTags.filter((t) => t.categoryId === cat.id) + if (catTags.length === 0) return null + return ( +
+

+ {cat.name} +

+
+ {catTags.map((tag) => { + const active = selectedTagIds.has(tag.id) + return ( + + ) + })} +
+
+ ) + })} +
+ ) : ( +

+ 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 b6b58ca..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,83 +156,294 @@ 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 */} + {/* 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)} /> + ))} + + ) + })()}
)} {/* 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}:%`)