diff --git a/package-lock.json b/package-lock.json
index 225cb21..748e474 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 7c93d65..81567ea 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/app/manage/tags/mappings/[id]/page.tsx b/src/app/manage/tags/mappings/[id]/page.tsx
index aaf0f4c..b80749d 100644
--- a/src/app/manage/tags/mappings/[id]/page.tsx
+++ b/src/app/manage/tags/mappings/[id]/page.tsx
@@ -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 (
@@ -116,31 +131,22 @@ export default function TagMappingsPage() {
: 'All unmapped tags are hidden. Check the ignored tags section below.'}
) : (
-
- {visibleTags.map((it) => (
- updateIgnoredTags(new Set([...ignoredTags, it.name]))}
- />
- ))}
-
+
)}
{hiddenTags.length > 0 && (
{
- 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
+ onMapped: () => void
+ onIgnore: (name: string) => void
+}) {
+ const parentRef = useRef(null)
+ const rowVirtualizer = useVirtualizer({
+ count: tags.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => 56,
+ overscan: 8,
+ })
+
+ return (
+
+
+ {rowVirtualizer.getVirtualItems().map((row) => {
+ const importedTag = tags[row.index]
+ return (
+
+ onIgnore(importedTag.name)}
+ />
+
+ )
+ })}
+
+
+ )
+}
+
+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({
)}
)
-}
+})
// ─── Ignored Tags Section ─────────────────────────────────────────────────────
@@ -606,6 +679,13 @@ function IgnoredTagsSection({
onUnignore: (name: string) => void
}) {
const [expanded, setExpanded] = useState(false)
+ const parentRef = useRef(null)
+ const rowVirtualizer = useVirtualizer({
+ count: tags.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => 44,
+ overscan: 8,
+ })
return (
@@ -626,29 +706,45 @@ function IgnoredTagsSection({
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
-
-
- {tags.map((t) => (
-
-
+
+ {rowVirtualizer.getVirtualItems().map((row) => {
+ const t = tags[row.index]
+ return (
+
- {t.name}
-
({t.itemCount})
-
-
-
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
-
-
- ))}
+
+ {t.name}
+ ({t.itemCount})
+
+
+
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
+
+
+ )
+ })}
diff --git a/src/app/manage/tags/page.tsx b/src/app/manage/tags/page.tsx
index edfd931..b45263c 100644
--- a/src/app/manage/tags/page.tsx
+++ b/src/app/manage/tags/page.tsx
@@ -543,27 +543,59 @@ function ImportedTagMappingsSection() {
const [libraries, setLibraries] = useState
([])
const [tagCounts, setTagCounts] = useState>({})
const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(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 = {}
- 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 = {}
+ 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 (
+ {error && (
+
+ {error}
+
+ )}
{libraries.map((lib) => (
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 8a0970c..c92bde9 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -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);
+ `)
+}