From 8f8f8c300193ca23b0df1345ee561dc22895dbaf Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:56:12 -0400 Subject: [PATCH] mapping-tweaks --- package-lock.json | 28 +++ package.json | 1 + src/app/manage/tags/mappings/[id]/page.tsx | 196 +++++++++++++++------ src/app/manage/tags/page.tsx | 63 +++++-- src/lib/db.ts | 10 ++ 5 files changed, 235 insertions(+), 63 deletions(-) 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}) - -
- -
- ))} + + {t.name} + ({t.itemCount}) + +
+ +
+ ) + })}
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); + `) +}