tagging #3
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ out/
|
|||||||
medialore.db
|
medialore.db
|
||||||
medialore.db-shm
|
medialore.db-shm
|
||||||
medialore.db-wal
|
medialore.db-wal
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}:%`)
|
||||||
|
|||||||
Reference in New Issue
Block a user