tagging #3

Merged
gpatti merged 8 commits from tagging into main 2026-03-25 23:53:53 +00:00
9 changed files with 696 additions and 131 deletions
Showing only changes of commit 3e2662f64c - Show all commits

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ out/
medialore.db medialore.db
medialore.db-shm medialore.db-shm
medialore.db-wal medialore.db-wal
tsconfig.tsbuildinfo

View File

@@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from 'next/server' 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) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const categoryId = searchParams.get('categoryId') ?? undefined 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) { } catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 }) return NextResponse.json({ error: (err as Error).message }, { status: 500 })
} }

View File

@@ -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))
}

View File

@@ -0,0 +1,130 @@
'use client'
import { useEffect, useState } from 'react'
import type { Tag, TagCategory } from '@/types'
interface Props {
libraryId: string
assignments: Record<string, string[]>
search: string
onSearchChange: (q: string) => void
selectedTagIds: Set<string>
onTagToggle: (tagId: string) => void
refreshKey?: number
}
export default function FilterPanel({ assignments, search, onSearchChange, selectedTagIds, onTagToggle, refreshKey }: Props) {
const [categories, setCategories] = useState<TagCategory[]>([])
const [tags, setTags] = useState<Tag[]>([])
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 (
<div className="flex flex-col gap-4">
{/* Search */}
<input
type="text"
placeholder="Search…"
value={search}
onChange={(e) => 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 ? (
<div className="flex flex-col gap-3">
{[0, 1].map((i) => (
<div key={i} className="flex flex-col gap-1.5">
<div
className="h-3 w-16 rounded animate-pulse"
style={{ backgroundColor: 'var(--border)' }}
/>
<div className="flex flex-wrap gap-1.5">
{[50, 65, 42].map((w) => (
<div
key={w}
className="h-5 rounded-full animate-pulse"
style={{ width: w, backgroundColor: 'var(--border)' }}
/>
))}
</div>
</div>
))}
</div>
) : hasTags ? (
<div className="flex flex-col gap-3">
{usedCategories.map((cat) => {
const catTags = usedTags.filter((t) => t.categoryId === cat.id)
if (catTags.length === 0) return null
return (
<div key={cat.id}>
<p className="text-xs mb-1.5" style={{ color: 'var(--text-secondary)' }}>
{cat.name}
</p>
<div className="flex flex-wrap gap-1.5">
{catTags.map((tag) => {
const active = selectedTagIds.has(tag.id)
return (
<button
key={tag.id}
onClick={() => onTagToggle(tag.id)}
className="px-2 py-0.5 rounded-full text-xs font-medium transition-colors"
style={{
backgroundColor: active ? 'var(--accent)' : 'var(--border)',
color: active ? '#fff' : 'var(--text-secondary)',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
const el = e.currentTarget as HTMLElement
el.style.backgroundColor = active ? 'var(--accent-hover)' : 'var(--text-secondary)'
if (!active) el.style.color = 'var(--background)'
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement
el.style.backgroundColor = active ? 'var(--accent)' : 'var(--border)'
el.style.color = active ? '#fff' : 'var(--text-secondary)'
}}
>
{tag.name}
</button>
)
})}
</div>
</div>
)
})}
</div>
) : (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
No tags defined.{' '}
<a href="/manage/tags" style={{ color: 'var(--accent)' }}>
Manage tags
</a>
</p>
)}
</div>
)
}

View File

@@ -8,9 +8,10 @@ interface Props {
game: Game game: Game
libraryId: string libraryId: string
onClose: () => void onClose: () => void
onTagsChanged?: () => void
} }
export default function GameDetailModal({ game, libraryId, onClose }: Props) { export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
@@ -97,7 +98,7 @@ export default function GameDetailModal({ game, libraryId, onClose }: Props) {
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags Tags
</p> </p>
<TagSelector mediaKey={`${libraryId}:${game.id}`} /> <TagSelector mediaKey={`${libraryId}:${game.id}`} onTagsChanged={onTagsChanged} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,9 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState, useCallback } from 'react'
import type { Game } from '@/types' import type { Game } from '@/types'
import GameDetailModal from './GameDetailModal' import GameDetailModal from './GameDetailModal'
import FilterPanel from '@/components/FilterPanel'
interface Props { interface Props {
libraryId: string libraryId: string
@@ -13,6 +14,17 @@ export default function GamesView({ libraryId }: Props) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [selected, setSelected] = useState<Game | null>(null) const [selected, setSelected] = useState<Game | null>(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
})
useEffect(() => { useEffect(() => {
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`) fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`)
@@ -27,62 +39,102 @@ export default function GamesView({ libraryId }: Props) {
}) })
}, [libraryId]) }, [libraryId])
if (loading) return <LoadingGrid /> const fetchAssignments = useCallback(() => {
if (error) return <ErrorMessage message={error} /> fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
if (games.length === 0) return <EmptyState /> .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 ( return (
<> <div className="flex gap-6 items-start">
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> <div className="w-52 flex-shrink-0">
{games.map((game) => ( <FilterPanel
<button libraryId={libraryId}
key={game.id} assignments={assignments}
onClick={() => setSelected(game)} search={search}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2" onSearchChange={setSearch}
style={{ selectedTagIds={selectedTagIds}
borderColor: 'var(--border)', onTagToggle={toggleTag}
backgroundColor: 'var(--surface)', refreshKey={filterRefreshKey}
}} />
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}}
>
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{game.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={game.coverUrl}
alt={game.title}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">
🎮
</div>
)}
</div>
<div className="p-2">
<p
className="text-xs font-medium truncate leading-tight"
style={{ color: 'var(--text-primary)' }}
title={game.title}
>
{game.title}
</p>
</div>
</button>
))}
</div> </div>
<div className="flex-1 min-w-0">
{loading ? (
<LoadingGrid />
) : error ? (
<ErrorMessage message={error} />
) : games.length === 0 ? (
<EmptyState />
) : (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{filtered.map((game) => (
<button
key={game.id}
onClick={() => setSelected(game)}
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
style={{
borderColor: 'var(--border)',
backgroundColor: 'var(--surface)',
}}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
}}
>
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
{game.coverUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={game.coverUrl}
alt={game.title}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-4xl">
🎮
</div>
)}
</div>
<div className="p-2">
<p
className="text-xs font-medium truncate leading-tight"
style={{ color: 'var(--text-primary)' }}
title={game.title}
>
{game.title}
</p>
</div>
</button>
))}
</div>
)}
{selected && ( {selected && (
<GameDetailModal game={selected} libraryId={libraryId} onClose={() => setSelected(null)} /> <GameDetailModal
)} game={selected}
</> libraryId={libraryId}
onClose={() => setSelected(null)}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
/>
)}
</div>
</div>
) )
} }

View File

@@ -5,6 +5,7 @@ import type { DirectoryListing, FileEntry } from '@/types'
import VideoPlayerModal from './VideoPlayerModal' import VideoPlayerModal from './VideoPlayerModal'
import ImageLightbox from './ImageLightbox' import ImageLightbox from './ImageLightbox'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
import FilterPanel from '@/components/FilterPanel'
interface Props { interface Props {
libraryId: string libraryId: string
@@ -25,6 +26,17 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [modal, setModal] = useState<ModalState>(null) const [modal, setModal] = useState<ModalState>(null)
const [tagPanel, setTagPanel] = useState<TagPanelState>(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( const loadPath = useCallback(
(path: string) => { (path: string) => {
@@ -51,6 +63,15 @@ export default function MixedView({ libraryId, initialPath }: Props) {
loadPath(initialPath) loadPath(initialPath)
}, [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) => { const handleEntry = (entry: FileEntry) => {
if (entry.type === 'directory') { if (entry.type === 'directory') {
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name
@@ -85,8 +106,34 @@ export default function MixedView({ libraryId, initialPath }: Props) {
? currentPath.split('/').filter(Boolean) ? 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 ( 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 */} {/* Breadcrumb */}
<nav className="flex items-center gap-1 mb-6 flex-wrap text-sm"> <nav className="flex items-center gap-1 mb-6 flex-wrap text-sm">
<button <button
@@ -126,7 +173,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
{!loading && !error && listing && ( {!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)' }}> <div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
This folder is empty. This folder is empty.
</div> </div>
@@ -145,7 +192,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
<span>Up</span> <span>Up</span>
</button> </button>
)} )}
{listing.entries.map((entry) => ( {filteredEntries.map((entry) => (
<EntryTile key={entry.name} entry={entry} onOpen={handleEntry} onTag={handleTagEntry} /> <EntryTile key={entry.name} entry={entry} onOpen={handleEntry} onTag={handleTagEntry} />
))} ))}
</div> </div>
@@ -192,12 +239,16 @@ export default function MixedView({ libraryId, initialPath }: Props) {
</button> </button>
</div> </div>
<div className="px-5 py-4"> <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> </div>
)} )}
</> </div>
</div>
) )
} }
@@ -222,9 +273,12 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil
const icon = isDir ? '📁' : isVideo ? '▶' : entry.mediaType === 'image' ? '🖼' : '📄' const icon = isDir ? '📁' : isVideo ? '▶' : entry.mediaType === 'image' ? '🖼' : '📄'
return ( return (
<button <div
role="button"
tabIndex={0}
onClick={() => onOpen(entry)} 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' }} style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)' ;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
@@ -303,7 +357,7 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil
> >
🏷 🏷
</button> </button>
</button> </div>
) )
} }

View File

@@ -6,6 +6,7 @@ import TagBadge from './TagBadge'
interface Props { interface Props {
mediaKey: string mediaKey: string
onTagsChanged?: () => void
} }
interface AllTags { interface AllTags {
@@ -13,14 +14,29 @@ interface AllTags {
tags: Tag[] tags: Tag[]
} }
export default function TagSelector({ mediaKey }: Props) { export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({ const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
tags: [], tags: [],
categories: [], categories: [],
}) })
const [all, setAll] = useState<AllTags>({ categories: [], tags: [] }) const [all, setAll] = useState<AllTags>({ categories: [], tags: [] })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null) // tagId being toggled const [busy, setBusy] = useState<string | null>(null)
// Per-category search text
const [categorySearches, setCategorySearches] = useState<Record<string, string>>({})
// Inline add-tag flow
const [addingTagTo, setAddingTagTo] = useState<string | null>(null)
const [newTagName, setNewTagName] = useState('')
const [tagSaveError, setTagSaveError] = useState<string | null>(null)
const [savingTag, setSavingTag] = useState(false)
// Inline add-category flow
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [categorySaveError, setCategorySaveError] = useState<string | null>(null)
const [savingCategory, setSavingCategory] = useState(false)
const fetchAssigned = useCallback(() => { const fetchAssigned = useCallback(() => {
return fetch(`/api/tags/assignments?mediaKey=${encodeURIComponent(mediaKey)}`) return fetch(`/api/tags/assignments?mediaKey=${encodeURIComponent(mediaKey)}`)
@@ -31,7 +47,7 @@ export default function TagSelector({ mediaKey }: Props) {
const fetchAll = useCallback(() => { const fetchAll = useCallback(() => {
return Promise.all([ return Promise.all([
fetch('/api/tags/categories').then((r) => r.json()), 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[]]) => { ]).then(([categories, tags]: [TagCategory[], Tag[]]) => {
setAll({ categories, tags }) setAll({ categories, tags })
}) })
@@ -61,11 +77,71 @@ export default function TagSelector({ mediaKey }: Props) {
}) })
} }
await fetchAssigned() await fetchAssigned()
onTagsChanged?.()
} finally { } finally {
setBusy(null) 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) { if (loading) {
return ( return (
<div className="flex gap-1.5 flex-wrap"> <div className="flex gap-1.5 flex-wrap">
@@ -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 assignedCategoryMap = Object.fromEntries(assigned.categories.map((c) => [c.id, c]))
const hasAnyTags = all.tags.length > 0
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* Assigned tags */} {/* Assigned tags grouped by category */}
{assigned.tags.length > 0 && ( {assigned.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{assigned.tags.map((tag) => ( {(() => {
<TagBadge const groups = new Map<string, Tag[]>()
key={tag.id} const ungrouped: Tag[] = []
tag={tag} for (const tag of assigned.tags) {
category={assignedCategoryMap[tag.categoryId]} if (tag.categoryId) {
onRemove={() => toggleTag(tag)} 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 (
<span
key={catId}
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
>
<span style={{ color: 'var(--text-secondary)' }}>{cat?.name}:</span>
{tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full"
style={{ backgroundColor: 'var(--surface-hover)' }}
>
{tag.name}
<button
onClick={() => toggleTag(tag)}
className="ml-0.5 leading-none transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
aria-label={`Remove tag ${tag.name}`}
>
</button>
</span>
))}
</span>
)
})}
{ungrouped.map((tag) => (
<TagBadge key={tag.id} tag={tag} onRemove={() => toggleTag(tag)} />
))}
</>
)
})()}
</div> </div>
)} )}
{/* Tag picker grouped by category */} {/* Tag picker grouped by category */}
{hasAnyTags ? ( <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2"> {all.categories.map((category) => {
{all.categories.map((category) => { const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
const categoryTags = all.tags.filter((t) => t.categoryId === category.id) const search = categorySearches[category.id] ?? ''
if (categoryTags.length === 0) return null const visibleTags = categoryTags
return ( .filter((t) => !search || t.name.toLowerCase().includes(search.toLowerCase()))
<div key={category.id}> .slice(0, 25)
<p className="text-xs mb-1" style={{ color: 'var(--text-secondary)' }}>
return (
<div key={category.id}>
{/* Category header: name + search input */}
<div className="flex items-center justify-between gap-2 mb-1">
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{category.name} {category.name}
</p> </p>
<div className="flex flex-wrap gap-1.5"> {categoryTags.length > 0 && (
{categoryTags.map((tag) => { <input
const active = isAssigned(tag.id) type="text"
const isBusy = busy === tag.id placeholder="search…"
return ( value={search}
<button onChange={(e) =>
key={tag.id} setCategorySearches((prev) => ({ ...prev, [category.id]: e.target.value }))
onClick={() => toggleTag(tag)} }
disabled={!!busy} className="text-xs px-1.5 py-0.5 rounded outline-none"
className="px-2 py-0.5 rounded-full text-xs font-medium transition-colors disabled:opacity-60" style={{
style={{ backgroundColor: 'var(--surface)',
backgroundColor: active ? 'var(--accent)' : 'var(--border)', border: '1px solid var(--border)',
color: active ? '#fff' : 'var(--text-secondary)', color: 'var(--text-primary)',
cursor: busy ? 'wait' : 'pointer', width: 90,
}} }}
onMouseEnter={(e) => { />
if (!busy) { )}
const el = e.currentTarget as HTMLElement
el.style.backgroundColor = active ? 'var(--accent-hover)' : 'var(--text-secondary)'
if (!active) el.style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement
el.style.backgroundColor = active ? 'var(--accent)' : 'var(--border)'
el.style.color = active ? '#fff' : 'var(--text-secondary)'
}}
>
{isBusy ? '…' : tag.name}
</button>
)
})}
</div>
</div> </div>
)
})} {/* Tag pills + add button */}
<div className="flex flex-wrap gap-1.5">
{visibleTags.map((tag) => {
const active = isAssigned(tag.id)
const isBusy = busy === tag.id
return (
<button
key={tag.id}
onClick={() => toggleTag(tag)}
disabled={!!busy}
className="px-2 py-0.5 rounded-full text-xs font-medium transition-colors disabled:opacity-60"
style={{
backgroundColor: active ? 'var(--accent)' : 'var(--border)',
color: active ? '#fff' : 'var(--text-secondary)',
cursor: busy ? 'wait' : 'pointer',
}}
onMouseEnter={(e) => {
if (!busy) {
const el = e.currentTarget as HTMLElement
el.style.backgroundColor = active ? 'var(--accent-hover)' : 'var(--text-secondary)'
if (!active) el.style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement
el.style.backgroundColor = active ? 'var(--accent)' : 'var(--border)'
el.style.color = active ? '#fff' : 'var(--text-secondary)'
}}
>
{isBusy ? '…' : tag.name}
</button>
)
})}
{/* Inline add-tag form or + button */}
{addingTagTo === category.id ? (
<span className="inline-flex items-center gap-1">
<input
autoFocus
type="text"
value={newTagName}
onChange={(e) => {
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}
/>
<button
onClick={() => submitAddTag(category.id)}
disabled={savingTag}
className="px-1.5 py-0.5 rounded text-xs transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
title="Save"
>
{savingTag ? '…' : '✓'}
</button>
<button
onClick={() => {
setAddingTagTo(null)
setNewTagName('')
setTagSaveError(null)
}}
className="px-1.5 py-0.5 rounded text-xs transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
title="Cancel"
>
</button>
{tagSaveError && (
<span className="text-xs" style={{ color: '#f87171' }}>
{tagSaveError}
</span>
)}
</span>
) : (
<button
onClick={() => {
setAddingTagTo(category.id)
setNewTagName('')
setTagSaveError(null)
}}
className="px-2 py-0.5 rounded-full text-xs font-medium transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
title={`Add tag to ${category.name}`}
onMouseEnter={(e) => {
const el = e.currentTarget as HTMLElement
el.style.backgroundColor = 'var(--text-secondary)'
el.style.color = 'var(--background)'
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement
el.style.backgroundColor = 'var(--border)'
el.style.color = 'var(--text-secondary)'
}}
>
+
</button>
)}
</div>
</div>
)
})}
{/* Add category */}
<div className="mt-1">
{addingCategory ? (
<div className="flex items-center gap-1.5">
<input
autoFocus
type="text"
value={newCategoryName}
onChange={(e) => {
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}
/>
<button
onClick={submitAddCategory}
disabled={savingCategory}
className="px-1.5 py-0.5 rounded text-xs transition-colors"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
title="Save"
>
{savingCategory ? '…' : '✓'}
</button>
<button
onClick={() => {
setAddingCategory(false)
setNewCategoryName('')
setCategorySaveError(null)
}}
className="px-1.5 py-0.5 rounded text-xs transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
title="Cancel"
>
</button>
{categorySaveError && (
<span className="text-xs" style={{ color: '#f87171' }}>
{categorySaveError}
</span>
)}
</div>
) : (
<button
onClick={() => {
setAddingCategory(true)
setNewCategoryName('')
setCategorySaveError(null)
}}
className="text-xs transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
+ Add category
</button>
)}
</div> </div>
) : ( </div>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
No tags defined yet.{' '}
<a href="/manage/tags" style={{ color: 'var(--accent)' }}>
Manage tags
</a>
</p>
)}
</div> </div>
) )
} }

View File

@@ -130,6 +130,23 @@ export function getTags(categoryId?: string): Tag[] {
.all() as 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 { export function addTag(name: string, categoryId: string): Tag {
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed) throw new Error('Tag name is required.') if (!trimmed) throw new Error('Tag name is required.')
@@ -223,6 +240,18 @@ export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categor
return { tags, categories } 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 { export function removeAllAssignmentsForLibrary(libraryId: string): void {
const db = getDb() const db = getDb()
db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`) db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`)