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",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@types/adm-zip": "^0.5.8",
|
"@types/adm-zip": "^0.5.8",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
@@ -1658,6 +1659,33 @@
|
|||||||
"tailwindcss": "4.2.2"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@types/adm-zip": "^0.5.8",
|
"@types/adm-zip": "^0.5.8",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'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 { useParams } from 'next/navigation'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types'
|
import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types'
|
||||||
|
|
||||||
export default function TagMappingsPage() {
|
export default function TagMappingsPage() {
|
||||||
@@ -67,13 +68,27 @@ export default function TagMappingsPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [libraryId])
|
}, [libraryId])
|
||||||
|
|
||||||
const tagsByCategory = categories.map((cat) => ({
|
const tagsByCategory = useMemo(() => (
|
||||||
|
categories
|
||||||
|
.map((cat) => ({
|
||||||
category: cat,
|
category: cat,
|
||||||
tags: tags.filter((t) => t.categoryId === cat.id),
|
tags: tags.filter((t) => t.categoryId === cat.id),
|
||||||
})).filter((g) => g.tags.length > 0)
|
}))
|
||||||
|
.filter((g) => g.tags.length > 0)
|
||||||
|
), [categories, tags])
|
||||||
|
|
||||||
const visibleTags = importedTags.filter((t) => !ignoredTags.has(t.name))
|
const visibleTags = useMemo(() => importedTags.filter((t) => !ignoredTags.has(t.name)), [importedTags, ignoredTags])
|
||||||
const hiddenTags = importedTags.filter((t) => ignoredTags.has(t.name))
|
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 (
|
return (
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
@@ -116,31 +131,22 @@ export default function TagMappingsPage() {
|
|||||||
: 'All unmapped tags are hidden. Check the ignored tags section below.'}
|
: 'All unmapped tags are hidden. Check the ignored tags section below.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
<VirtualizedImportedTagRows
|
||||||
{visibleTags.map((it) => (
|
tags={visibleTags}
|
||||||
<ImportedTagRow
|
|
||||||
key={it.id}
|
|
||||||
importedTag={it}
|
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
tagsByCategory={tagsByCategory}
|
tagsByCategory={tagsByCategory}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
prefixMappings={prefixMappings}
|
prefixMappings={prefixMappings}
|
||||||
onMapped={refresh}
|
onMapped={refresh}
|
||||||
onIgnore={() => updateIgnoredTags(new Set([...ignoredTags, it.name]))}
|
onIgnore={handleIgnoreTag}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{hiddenTags.length > 0 && (
|
{hiddenTags.length > 0 && (
|
||||||
<IgnoredTagsSection
|
<IgnoredTagsSection
|
||||||
tags={hiddenTags}
|
tags={hiddenTags}
|
||||||
onUnignore={(name) => {
|
onUnignore={handleUnignoreTag}
|
||||||
const next = new Set(ignoredTags)
|
|
||||||
next.delete(name)
|
|
||||||
updateIgnoredTags(next)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -318,7 +324,68 @@ function PrefixMappingsSection({
|
|||||||
|
|
||||||
// ─── Imported Tag Row ─────────────────────────────────────────────────────────
|
// ─── 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,
|
importedTag,
|
||||||
libraryId,
|
libraryId,
|
||||||
tagsByCategory,
|
tagsByCategory,
|
||||||
@@ -399,6 +466,8 @@ function ImportedTagRow({
|
|||||||
setSaving(false)
|
setSaving(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setSaving(false)
|
||||||
|
setSelectedTagId('')
|
||||||
onMapped()
|
onMapped()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError('Network error')
|
||||||
@@ -439,6 +508,10 @@ function ImportedTagRow({
|
|||||||
setCreatingTag(false)
|
setCreatingTag(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setCreatingTag(false)
|
||||||
|
setCreating(false)
|
||||||
|
setNewTagName(importedTag.name)
|
||||||
|
setNewTagCategoryId('')
|
||||||
onMapped()
|
onMapped()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error')
|
setError('Network error')
|
||||||
@@ -594,7 +667,7 @@ function ImportedTagRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
// ─── Ignored Tags Section ─────────────────────────────────────────────────────
|
// ─── Ignored Tags Section ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -606,6 +679,13 @@ function IgnoredTagsSection({
|
|||||||
onUnignore: (name: string) => void
|
onUnignore: (name: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
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 (
|
return (
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
@@ -626,10 +706,25 @@ function IgnoredTagsSection({
|
|||||||
className="rounded-xl border"
|
className="rounded-xl border"
|
||||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
>
|
>
|
||||||
<div className="px-5 py-4">
|
<div ref={parentRef} className="px-5 py-4 max-h-[360px] overflow-auto">
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
<div style={{ height: rowVirtualizer.getTotalSize(), width: '100%', position: 'relative' }}>
|
||||||
{tags.map((t) => (
|
{rowVirtualizer.getVirtualItems().map((row) => {
|
||||||
<div key={t.id} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
|
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"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
|
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)' }}
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
@@ -648,7 +743,8 @@ function IgnoredTagsSection({
|
|||||||
Unignore
|
Unignore
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -543,27 +543,59 @@ function ImportedTagMappingsSection() {
|
|||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
const [tagCounts, setTagCounts] = useState<Record<string, number>>({})
|
const [tagCounts, setTagCounts] = useState<Record<string, number>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/libraries')
|
let cancelled = false
|
||||||
.then((r) => r.json())
|
|
||||||
.then(async (libs: Library[]) => {
|
|
||||||
const comicLibs = libs.filter((l) => l.type === 'comics')
|
|
||||||
setLibraries(comicLibs)
|
|
||||||
|
|
||||||
const counts: Record<string, number> = {}
|
const load = async () => {
|
||||||
await Promise.all(
|
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) => {
|
comicLibs.map(async (lib) => {
|
||||||
const tags: ImportedTag[] = await fetch(
|
const res = await fetch(`/api/imported-tags?libraryId=${encodeURIComponent(lib.id)}`)
|
||||||
`/api/imported-tags?libraryId=${encodeURIComponent(lib.id)}`
|
if (!res.ok) return { libraryId: lib.id, count: 0 }
|
||||||
).then((r) => r.json())
|
const json = await res.json()
|
||||||
counts[lib.id] = tags.length
|
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)
|
setTagCounts(counts)
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return
|
||||||
|
setError('Could not load imported tag mappings right now.')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
}
|
||||||
.catch(() => setLoading(false))
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -586,6 +618,11 @@ function ImportedTagMappingsSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Imported Tag Mappings">
|
<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)' }}>
|
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
<div key={lib.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
<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)
|
migrateComicItemTypes(db)
|
||||||
migrateImportedTags(db)
|
migrateImportedTags(db)
|
||||||
migrateComicsIndex(db)
|
migrateComicsIndex(db)
|
||||||
|
migrateTagMappingsIndexes(db)
|
||||||
seedAppSettings(db)
|
seedAppSettings(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,3 +456,12 @@ function migrateComicsIndex(db: Database.Database): void {
|
|||||||
ON media_items(library_id, item_type, title);
|
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