mapping-tweaks
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m8s
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m8s
This commit is contained in:
28
package-lock.json
generated
28
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user