comic library improvements #35

Merged
gpatti merged 1 commits from comic-improv into main 2026-04-21 01:42:43 +00:00
5 changed files with 312 additions and 67 deletions

View File

@@ -14,7 +14,10 @@ interface Props {
libraryId: string
issue: ComicIssue
onClose: () => void
onPrev?: () => void
onNext?: () => void
onTagsChanged?: () => void
onDeleted?: () => void
readOnly?: boolean
}
@@ -22,20 +25,52 @@ function pageUrl(libraryId: string, issueKey: string, pageIndex: number): string
return `/api/comics/page?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}&pageIndex=${pageIndex}`
}
export default function ComicIssueView({ libraryId, issue, onClose, onTagsChanged, readOnly }: Props) {
export default function ComicIssueView({ libraryId, issue, onClose, onPrev, onNext, onTagsChanged, onDeleted, readOnly }: Props) {
const [lightboxPage, setLightboxPage] = useState<number | null>(null)
const [showTagPanel, setShowTagPanel] = useState(false)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
const menuRef = useRef<HTMLDivElement>(null)
const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
// Close on Escape
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && lightboxPage === null && !showTagPanel) onClose()
if (lightboxPage !== null) return
if (e.key === 'ArrowLeft') { onPrev?.(); return }
if (e.key === 'ArrowRight') { onNext?.(); return }
if (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return }
if (showTagPanel) { setShowTagPanel(false); return }
onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose, lightboxPage, showTagPanel])
}, [onClose, onPrev, onNext, lightboxPage, showTagPanel, menuOpen, confirming])
// Close menu on outside click
useEffect(() => {
if (!menuOpen) return
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [menuOpen])
const handleDelete = async () => {
setDeleting(true)
try {
await fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&issueKey=${encodeURIComponent(issueKey)}`, { method: 'DELETE' })
onDeleted?.()
} catch {
setDeleting(false)
setConfirming(false)
}
}
const pageCount = issue.pageCount
const downloadUrl = fileApiUrl(libraryId, issue.filePath)
@@ -49,10 +84,31 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
onClick={onClose}
>
{/* Floating prev/next arrows */}
{onPrev && !showTagPanel && (
<button
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full flex items-center justify-center transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
onClick={(e) => { e.stopPropagation(); onPrev() }}
aria-label="Previous issue"
>
</button>
)}
{onNext && !showTagPanel && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full flex items-center justify-center transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: '#fff' }}
onClick={(e) => { e.stopPropagation(); onNext() }}
aria-label="Next issue"
>
</button>
)}
<div className={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
<div
className={`${showTagPanel ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-4xl'}`}
onClick={showTagPanel ? undefined : undefined}
>
<div
className="w-full max-w-4xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
@@ -88,19 +144,43 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
🏷
</button>
)}
<a
href={downloadUrl}
download
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
backgroundColor: 'var(--surface)',
color: 'var(--text-secondary)',
border: '1px solid var(--border)',
}}
onClick={(e) => e.stopPropagation()}
>
Download
</a>
{/* Kebab menu */}
<div className="relative" ref={menuRef}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen((v) => !v) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-base font-bold transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
aria-label="More options"
title="More options"
>
</button>
{menuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-10 min-w-[120px]"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
<a
href={downloadUrl}
download
className="flex items-center px-3 py-2 text-xs transition-colors hover:bg-black/10"
style={{ color: 'var(--text-primary)' }}
onClick={(e) => { e.stopPropagation(); setMenuOpen(false) }}
>
Download
</a>
{!readOnly && (
<button
className="w-full text-left flex items-center px-3 py-2 text-xs transition-colors hover:bg-black/10"
style={{ color: '#fca5a5' }}
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirming(true) }}
>
Delete
</button>
)}
</div>
)}
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
@@ -112,6 +192,33 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
</div>
</div>
{/* Delete confirmation */}
{confirming && (
<div
className="flex items-center gap-3 mx-5 mt-3 px-3 py-2.5 rounded-lg text-sm flex-shrink-0"
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
>
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
Permanently delete this issue and its file?
</p>
<button
onClick={() => setConfirming(false)}
className="px-2 py-1 rounded text-xs transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="px-2 py-1 rounded text-xs font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
)}
{/* Cover + tags */}
<div
className="flex gap-5 px-5 py-4 flex-shrink-0"

View File

@@ -2,7 +2,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ComicIssue, ComicSeries } from '@/types'
import ComicSeriesView from './ComicSeriesView'
import ComicIssueView from './ComicIssueView'
import FilterPanel from '@/components/FilterPanel'
import TagSelector from '@/components/tags/TagSelector'
@@ -22,7 +21,10 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [selectedSeries, setSelectedSeries] = useState<ComicSeries | null>(null)
const [seriesIssues, setSeriesIssues] = useState<ComicIssue[]>([])
const [seriesIssuesLoading, setSeriesIssuesLoading] = useState(false)
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
const [selectedIssueIndex, setSelectedIssueIndex] = useState<number | null>(null)
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
@@ -69,6 +71,16 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
useEffect(() => { fetchItems(1, '', true) }, [fetchItems])
// Fetch issues when a series is selected
useEffect(() => {
if (!selectedSeries) { setSeriesIssues([]); return }
setSeriesIssuesLoading(true)
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`)
.then((r) => r.json())
.then((data: ComicIssue[]) => { setSeriesIssues(data); setSeriesIssuesLoading(false) })
.catch(() => setSeriesIssuesLoading(false))
}, [selectedSeries, libraryId])
// IntersectionObserver: load next page when sentinel scrolls into view
useEffect(() => {
const sentinel = sentinelRef.current
@@ -151,6 +163,11 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
return true
})
// Flat list of issues at the current navigation level for prev/next
const filteredIssues: ComicIssue[] = selectedSeries
? seriesIssues
: filtered.filter((item): item is ComicIssue => !('issueCount' in item))
const filtersActive = search !== '' || selectedTagIds.size > 0
return (
@@ -186,6 +203,23 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
)}
<div className="flex-1 min-w-0">
{/* Breadcrumb when inside a series */}
{selectedSeries && (
<div className="flex items-center gap-2 mb-4 text-sm">
<button
onClick={() => { setSelectedSeries(null); setSeriesIssues([]); setSearch('') }}
className="transition-colors"
style={{ color: 'var(--accent)' }}
>
All Comics
</button>
<span style={{ color: 'var(--text-secondary)' }}>/</span>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{selectedSeries.title}
</span>
</div>
)}
{loading ? (
<LoadingGrid />
) : error ? (
@@ -205,38 +239,63 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
</div>
) : (
<>
{total > PAGE_SIZE && (
{!selectedSeries && total > PAGE_SIZE && (
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
Showing {filtered.length.toLocaleString()} of {total.toLocaleString()}
</p>
)}
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{filtered.map((item) =>
'issueCount' in item ? (
<SeriesCard
key={item.id}
series={item as ComicSeries}
readOnly={readOnly}
onClick={() => setSelectedSeries(item as ComicSeries)}
onTagClick={(item as ComicSeries).item_key && !readOnly
? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title })
: undefined}
/>
) : (
<IssueCard
key={item.id}
issue={item as ComicIssue}
readOnly={readOnly}
onClick={() => setSelectedIssue(item as ComicIssue)}
onTagClick={(item as ComicIssue).item_key && !readOnly
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title })
: undefined}
/>
)
)}
</div>
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden />
{loadingMore && <LoadingMore />}
{seriesIssuesLoading ? (
<LoadingGrid />
) : (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{selectedSeries
? seriesIssues.map((issue) => (
<IssueCard
key={issue.id}
issue={issue}
readOnly={readOnly}
onClick={() => { setSelectedIssue(issue); setSelectedIssueIndex(seriesIssues.indexOf(issue)) }}
onTagClick={issue.item_key && !readOnly
? () => setTagPanel({ itemKey: issue.item_key!, title: issue.title })
: undefined}
/>
))
: filtered.map((item) =>
'issueCount' in item ? (
<SeriesCard
key={item.id}
series={item as ComicSeries}
readOnly={readOnly}
onClick={() => { setSelectedSeries(item as ComicSeries); setSearch('') }}
onTagClick={(item as ComicSeries).item_key && !readOnly
? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title })
: undefined}
/>
) : (
<IssueCard
key={item.id}
issue={item as ComicIssue}
readOnly={readOnly}
onClick={() => {
const issue = item as ComicIssue
setSelectedIssue(issue)
setSelectedIssueIndex(filteredIssues.indexOf(issue))
}}
onTagClick={(item as ComicIssue).item_key && !readOnly
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title })
: undefined}
/>
)
)
}
</div>
)}
{!selectedSeries && (
<>
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden />
{loadingMore && <LoadingMore />}
</>
)}
</>
)}
</div>
@@ -285,22 +344,30 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
</div>
)}
{selectedSeries && (
<ComicSeriesView
libraryId={libraryId}
series={selectedSeries}
onClose={() => setSelectedSeries(null)}
onTagsChanged={onTagsChanged}
readOnly={readOnly}
/>
)}
{selectedIssue && (
<ComicIssueView
libraryId={libraryId}
issue={selectedIssue}
onClose={() => setSelectedIssue(null)}
onClose={() => { setSelectedIssue(null); setSelectedIssueIndex(null) }}
onPrev={selectedIssueIndex !== null && selectedIssueIndex > 0
? () => { setSelectedIssue(filteredIssues[selectedIssueIndex - 1]); setSelectedIssueIndex(selectedIssueIndex - 1) }
: undefined}
onNext={selectedIssueIndex !== null && selectedIssueIndex < filteredIssues.length - 1
? () => { setSelectedIssue(filteredIssues[selectedIssueIndex + 1]); setSelectedIssueIndex(selectedIssueIndex + 1) }
: undefined}
onTagsChanged={onTagsChanged}
onDeleted={() => {
setSelectedIssue(null)
setSelectedIssueIndex(null)
fetchItems(1, search, true)
fetchAssignments()
if (selectedSeries) {
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries.id)}`)
.then((r) => r.json())
.then((data: ComicIssue[]) => setSeriesIssues(data))
.catch(() => {})
}
}}
readOnly={readOnly}
/>
)}

View File

@@ -53,6 +53,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
// Polling ref
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const touchStartX = useRef<number | null>(null)
// Determine if this is an image file (for text extraction controls)
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
@@ -131,10 +132,24 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
if (e.key === 'ArrowLeft') onPrev?.()
if (e.key === 'ArrowRight') onNext?.()
}
const handleTouchStart = (e: TouchEvent) => {
touchStartX.current = e.touches[0].clientX
}
const handleTouchEnd = (e: TouchEvent) => {
if (touchStartX.current === null) return
const delta = touchStartX.current - e.changedTouches[0].clientX
if (delta > 50) onNext?.()
else if (delta < -50) onPrev?.()
touchStartX.current = null
}
document.addEventListener('keydown', handleKey)
document.addEventListener('touchstart', handleTouchStart, { passive: true })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKey)
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleTouchEnd)
document.body.style.overflow = ''
}
}, [onClose, onPrev, onNext])

View File

@@ -18,27 +18,24 @@ export async function importComicMetadata(library: Library): Promise<void> {
const db = getDb()
const libraryRoot = resolveLibraryRoot(library)
// Only process issues that have not had ComicInfo.xml imported yet.
// Issues restored from a previous scan will already have year/genres set.
const issues = db
.prepare(
`SELECT item_key, file_path, metadata FROM media_items
WHERE library_id = ? AND item_type = 'comic_issue' AND file_path IS NOT NULL`
WHERE library_id = ? AND item_type = 'comic_issue' AND file_path IS NOT NULL
AND year IS NULL AND genres IS NULL`
)
.all(library.id) as { item_key: string; file_path: string; metadata: string | null }[]
if (issues.length === 0) return
// Load existing mappings for this library
const mappingRows = db
.prepare('SELECT imported_tag_name, tag_id FROM tag_mappings WHERE library_id = ?')
.all(library.id) as { imported_tag_name: string; tag_id: string }[]
const mappings = new Map(mappingRows.map((r) => [r.imported_tag_name, r.tag_id]))
// Clear existing imported tag associations for this library (they'll be re-created)
db.prepare(
`DELETE FROM item_imported_tags WHERE imported_tag_id IN (
SELECT id FROM imported_tags WHERE library_id = ?
)`
).run(library.id)
db.prepare('DELETE FROM imported_tags WHERE library_id = ?').run(library.id)
const updateItem = db.prepare(`
UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata
WHERE item_key = @item_key

View File

@@ -552,6 +552,36 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
const db = getDb()
const now = Date.now()
// Save ComicInfo metadata for issues that were already imported so we can
// restore it after the clear+upsert without re-reading any CBZ files.
type SavedInfo = { title: string | null; year: number | null; genres: string | null; comicFields: Record<string, unknown> }
const savedComicInfo = new Map<string, SavedInfo>()
{
const rows = db
.prepare(
`SELECT item_key, title, year, genres, metadata FROM media_items
WHERE library_id = ? AND item_type = 'comic_issue'
AND (year IS NOT NULL OR genres IS NOT NULL)`
)
.all(library.id) as { item_key: string; title: string | null; year: number | null; genres: string | null; metadata: string | null }[]
for (const row of rows) {
const meta: Record<string, unknown> = row.metadata ? (JSON.parse(row.metadata) as Record<string, unknown>) : {}
savedComicInfo.set(row.item_key, {
title: row.title,
year: row.year,
genres: row.genres,
comicFields: {
writer: meta.writer,
publisher: meta.publisher,
translator: meta.translator,
web: meta.web,
month: meta.month,
day: meta.day,
},
})
}
}
clearLibraryItems(db, library.id)
const upsertSeries = db.prepare(`
@@ -648,6 +678,18 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
}
}
// Build a map of item_key → fresh scan metadata (needed for ComicInfo restore below).
const freshMetaMap = new Map<string, Record<string, unknown>>()
for (const entry of allRecords) {
if (entry.type === 'issue') {
const rec = entry.rec as { item_key: unknown; metadata: unknown }
freshMetaMap.set(
String(rec.item_key),
JSON.parse(String(rec.metadata)) as Record<string, unknown>
)
}
}
// Insert in batches of 500, yielding the event loop between batches so the app
// remains responsive to HTTP requests during a large scan.
const BATCH_SIZE = 500
@@ -662,6 +704,23 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
await new Promise<void>((r) => setImmediate(r))
}
// Restore previously-imported ComicInfo data for issues that still exist on disk.
// Merges scan-derived fields (pageCount, coverUrl) with the saved ComicInfo fields
// so neither set of data is lost. Title from ComicInfo is also preserved.
if (savedComicInfo.size > 0) {
const restoreStmt = db.prepare(
'UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata WHERE item_key = @item_key'
)
db.transaction(() => {
for (const [item_key, saved] of savedComicInfo) {
const freshMeta = freshMetaMap.get(item_key)
if (!freshMeta) continue // file was removed from disk
const merged = { ...freshMeta, ...saved.comicFields }
restoreStmt.run({ item_key, title: saved.title, year: saved.year, genres: saved.genres, metadata: JSON.stringify(merged) })
}
})()
}
// Prewarm CBZ cover thumbnails — fire-and-forget so they don't block scan completion.
for (const item of items) {
const issuesToWarm: ComicIssue[] = 'issues' in item