tagging #3
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ out/
|
||||
medialore.db
|
||||
medialore.db-shm
|
||||
medialore.db-wal
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
8
src/app/api/tags/library-assignments/route.ts
Normal file
8
src/app/api/tags/library-assignments/route.ts
Normal 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))
|
||||
}
|
||||
130
src/components/FilterPanel.tsx
Normal file
130
src/components/FilterPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
|
||||
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)' }}>
|
||||
Tags
|
||||
</p>
|
||||
<TagSelector mediaKey={`${libraryId}:${game.id}`} />
|
||||
<TagSelector mediaKey={`${libraryId}:${game.id}`} onTagsChanged={onTagsChanged} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string | 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(() => {
|
||||
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
@@ -27,14 +39,47 @@ export default function GamesView({ libraryId }: Props) {
|
||||
})
|
||||
}, [libraryId])
|
||||
|
||||
if (loading) return <LoadingGrid />
|
||||
if (error) return <ErrorMessage message={error} />
|
||||
if (games.length === 0) return <EmptyState />
|
||||
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 (
|
||||
<>
|
||||
<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">
|
||||
{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">
|
||||
{games.map((game) => (
|
||||
{filtered.map((game) => (
|
||||
<button
|
||||
key={game.id}
|
||||
onClick={() => setSelected(game)}
|
||||
@@ -78,11 +123,18 @@ export default function GamesView({ libraryId }: Props) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AllTags>({ categories: [], tags: [] })
|
||||
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(() => {
|
||||
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 (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
@@ -80,40 +156,105 @@ 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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Assigned tags */}
|
||||
{/* Assigned tags grouped by category */}
|
||||
{assigned.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{assigned.tags.map((tag) => (
|
||||
<TagBadge
|
||||
{(() => {
|
||||
const groups = new Map<string, Tag[]>()
|
||||
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 (
|
||||
<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}
|
||||
tag={tag}
|
||||
category={assignedCategoryMap[tag.categoryId]}
|
||||
onRemove={() => toggleTag(tag)}
|
||||
/>
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Tag picker grouped by category */}
|
||||
{hasAnyTags ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{all.categories.map((category) => {
|
||||
const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
|
||||
if (categoryTags.length === 0) return null
|
||||
const search = categorySearches[category.id] ?? ''
|
||||
const visibleTags = categoryTags
|
||||
.filter((t) => !search || t.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.slice(0, 25)
|
||||
|
||||
return (
|
||||
<div key={category.id}>
|
||||
<p className="text-xs mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{/* 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}
|
||||
</p>
|
||||
{categoryTags.length > 0 && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="search…"
|
||||
value={search}
|
||||
onChange={(e) =>
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag pills + add button */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{categoryTags.map((tag) => {
|
||||
{visibleTags.map((tag) => {
|
||||
const active = isAssigned(tag.id)
|
||||
const isBusy = busy === tag.id
|
||||
return (
|
||||
@@ -144,19 +285,165 @@ export default function TagSelector({ mediaKey }: Props) {
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}:%`)
|
||||
|
||||
Reference in New Issue
Block a user