mapping-tweaks
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m8s

This commit is contained in:
Garret Patti
2026-04-20 19:56:12 -04:00
parent dee9356004
commit 8f8f8c3001
5 changed files with 235 additions and 63 deletions

28
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
"@types/adm-zip": "^0.5.8",
"adm-zip": "^0.5.17",
"archiver": "^7.0.1",
@@ -1658,6 +1659,33 @@
"tailwindcss": "4.2.2"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.14.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",

View File

@@ -12,6 +12,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
"@types/adm-zip": "^0.5.8",
"adm-zip": "^0.5.17",
"archiver": "^7.0.1",

View File

@@ -1,7 +1,8 @@
'use client'
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
import { memo, useEffect, useMemo, useState, useRef, useCallback } from 'react'
import { useParams } from 'next/navigation'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types'
export default function TagMappingsPage() {
@@ -67,13 +68,27 @@ export default function TagMappingsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libraryId])
const tagsByCategory = categories.map((cat) => ({
category: cat,
tags: tags.filter((t) => t.categoryId === cat.id),
})).filter((g) => g.tags.length > 0)
const tagsByCategory = useMemo(() => (
categories
.map((cat) => ({
category: cat,
tags: tags.filter((t) => t.categoryId === cat.id),
}))
.filter((g) => g.tags.length > 0)
), [categories, tags])
const visibleTags = importedTags.filter((t) => !ignoredTags.has(t.name))
const hiddenTags = importedTags.filter((t) => ignoredTags.has(t.name))
const visibleTags = useMemo(() => importedTags.filter((t) => !ignoredTags.has(t.name)), [importedTags, ignoredTags])
const hiddenTags = useMemo(() => importedTags.filter((t) => ignoredTags.has(t.name)), [importedTags, ignoredTags])
const handleIgnoreTag = useCallback((name: string) => {
updateIgnoredTags(new Set([...ignoredTags, name]))
}, [ignoredTags, updateIgnoredTags])
const handleUnignoreTag = useCallback((name: string) => {
const next = new Set(ignoredTags)
next.delete(name)
updateIgnoredTags(next)
}, [ignoredTags, updateIgnoredTags])
return (
<div className="max-w-2xl">
@@ -116,31 +131,22 @@ export default function TagMappingsPage() {
: 'All unmapped tags are hidden. Check the ignored tags section below.'}
</p>
) : (
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{visibleTags.map((it) => (
<ImportedTagRow
key={it.id}
importedTag={it}
libraryId={libraryId}
tagsByCategory={tagsByCategory}
categories={categories}
prefixMappings={prefixMappings}
onMapped={refresh}
onIgnore={() => updateIgnoredTags(new Set([...ignoredTags, it.name]))}
/>
))}
</div>
<VirtualizedImportedTagRows
tags={visibleTags}
libraryId={libraryId}
tagsByCategory={tagsByCategory}
categories={categories}
prefixMappings={prefixMappings}
onMapped={refresh}
onIgnore={handleIgnoreTag}
/>
)}
</Section>
{hiddenTags.length > 0 && (
<IgnoredTagsSection
tags={hiddenTags}
onUnignore={(name) => {
const next = new Set(ignoredTags)
next.delete(name)
updateIgnoredTags(next)
}}
onUnignore={handleUnignoreTag}
/>
)}
@@ -318,7 +324,68 @@ function PrefixMappingsSection({
// ─── Imported Tag Row ─────────────────────────────────────────────────────────
function ImportedTagRow({
function VirtualizedImportedTagRows({
tags,
libraryId,
tagsByCategory,
categories,
prefixMappings,
onMapped,
onIgnore,
}: {
tags: ImportedTag[]
libraryId: string
tagsByCategory: { category: TagCategory; tags: Tag[] }[]
categories: TagCategory[]
prefixMappings: Record<string, string>
onMapped: () => void
onIgnore: (name: string) => void
}) {
const parentRef = useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: tags.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
overscan: 8,
})
return (
<div ref={parentRef} className="max-h-[560px] overflow-auto">
<div style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((row) => {
const importedTag = tags[row.index]
return (
<div
key={importedTag.name}
ref={rowVirtualizer.measureElement}
data-index={row.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${row.start}px)`,
borderTop: row.index === 0 ? 'none' : '1px solid var(--border)',
}}
>
<ImportedTagRow
importedTag={importedTag}
libraryId={libraryId}
tagsByCategory={tagsByCategory}
categories={categories}
prefixMappings={prefixMappings}
onMapped={onMapped}
onIgnore={() => onIgnore(importedTag.name)}
/>
</div>
)
})}
</div>
</div>
)
}
const ImportedTagRow = memo(function ImportedTagRow({
importedTag,
libraryId,
tagsByCategory,
@@ -399,6 +466,8 @@ function ImportedTagRow({
setSaving(false)
return
}
setSaving(false)
setSelectedTagId('')
onMapped()
} catch {
setError('Network error')
@@ -439,6 +508,10 @@ function ImportedTagRow({
setCreatingTag(false)
return
}
setCreatingTag(false)
setCreating(false)
setNewTagName(importedTag.name)
setNewTagCategoryId('')
onMapped()
} catch {
setError('Network error')
@@ -594,7 +667,7 @@ function ImportedTagRow({
)}
</div>
)
}
})
// ─── Ignored Tags Section ─────────────────────────────────────────────────────
@@ -606,6 +679,13 @@ function IgnoredTagsSection({
onUnignore: (name: string) => void
}) {
const [expanded, setExpanded] = useState(false)
const parentRef = useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: tags.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 8,
})
return (
<div className="mb-10">
@@ -626,29 +706,45 @@ function IgnoredTagsSection({
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div className="px-5 py-4">
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{tags.map((t) => (
<div key={t.id} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
<div ref={parentRef} className="px-5 py-4 max-h-[360px] overflow-auto">
<div style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((row) => {
const t = tags[row.index]
return (
<div
key={t.name}
ref={rowVirtualizer.measureElement}
data-index={row.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${row.start}px)`,
borderTop: row.index === 0 ? 'none' : '1px solid var(--border)',
}}
className="flex items-center gap-3 py-2"
>
{t.name}
<span className="text-xs" style={{ opacity: 0.6 }}>({t.itemCount})</span>
</span>
<div className="flex-1" />
<button
onClick={() => onUnignore(t.name)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', 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)')}
>
Unignore
</button>
</div>
))}
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
{t.name}
<span className="text-xs" style={{ opacity: 0.6 }}>({t.itemCount})</span>
</span>
<div className="flex-1" />
<button
onClick={() => onUnignore(t.name)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', 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)')}
>
Unignore
</button>
</div>
)
})}
</div>
</div>
</div>

View File

@@ -543,27 +543,59 @@ function ImportedTagMappingsSection() {
const [libraries, setLibraries] = useState<Library[]>([])
const [tagCounts, setTagCounts] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('/api/libraries')
.then((r) => r.json())
.then(async (libs: Library[]) => {
const comicLibs = libs.filter((l) => l.type === 'comics')
setLibraries(comicLibs)
let cancelled = false
const counts: Record<string, number> = {}
await Promise.all(
const load = async () => {
try {
setError(null)
const libsRes = await fetch('/api/libraries')
const libsJson = await libsRes.json()
if (!Array.isArray(libsJson)) {
throw new Error('Failed to load libraries')
}
const comicLibs = libsJson.filter((l): l is Library => l?.type === 'comics')
if (cancelled) return
setLibraries(comicLibs)
setLoading(false)
if (comicLibs.length === 0) return
const settled = await Promise.allSettled(
comicLibs.map(async (lib) => {
const tags: ImportedTag[] = await fetch(
`/api/imported-tags?libraryId=${encodeURIComponent(lib.id)}`
).then((r) => r.json())
counts[lib.id] = tags.length
const res = await fetch(`/api/imported-tags?libraryId=${encodeURIComponent(lib.id)}`)
if (!res.ok) return { libraryId: lib.id, count: 0 }
const json = await res.json()
const count = Array.isArray(json) ? json.length : 0
return { libraryId: lib.id, count }
})
)
if (cancelled) return
const counts: Record<string, number> = {}
for (const result of settled) {
if (result.status === 'fulfilled') {
counts[result.value.libraryId] = result.value.count
}
}
setTagCounts(counts)
} catch {
if (cancelled) return
setError('Could not load imported tag mappings right now.')
setLoading(false)
})
.catch(() => setLoading(false))
}
}
load()
return () => {
cancelled = true
}
}, [])
if (loading) {
@@ -586,6 +618,11 @@ function ImportedTagMappingsSection() {
return (
<Section title="Imported Tag Mappings">
{error && (
<p className="text-xs mb-3 px-3 py-1.5 rounded-lg" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
{error}
</p>
)}
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{libraries.map((lib) => (
<div key={lib.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">

View File

@@ -111,6 +111,7 @@ function initDb(db: Database.Database): void {
migrateComicItemTypes(db)
migrateImportedTags(db)
migrateComicsIndex(db)
migrateTagMappingsIndexes(db)
seedAppSettings(db)
}
@@ -455,3 +456,12 @@ function migrateComicsIndex(db: Database.Database): void {
ON media_items(library_id, item_type, title);
`)
}
function migrateTagMappingsIndexes(db: Database.Database): void {
db.exec(`
CREATE INDEX IF NOT EXISTS tag_mappings_library_id ON tag_mappings(library_id);
CREATE INDEX IF NOT EXISTS tag_mappings_tag_id ON tag_mappings(tag_id);
CREATE INDEX IF NOT EXISTS imported_tags_library_id ON imported_tags(library_id);
CREATE INDEX IF NOT EXISTS item_imported_tags_imported_tag_id ON item_imported_tags(imported_tag_id);
`)
}