diff --git a/src/components/comics/ComicIssueView.tsx b/src/components/comics/ComicIssueView.tsx index 9e6dadf..256187b 100644 --- a/src/components/comics/ComicIssueView.tsx +++ b/src/components/comics/ComicIssueView.tsx @@ -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(null) const [showTagPanel, setShowTagPanel] = useState(false) const [tagRefreshKey, setTagRefreshKey] = useState(0) + const menuRef = useRef(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 && ( + + )} + {onNext && !showTagPanel && ( + + )} +
)} - e.stopPropagation()} - > - Download - + {/* Kebab menu */} +
+ + {menuOpen && ( +
+ { e.stopPropagation(); setMenuOpen(false) }} + > + Download + + {!readOnly && ( + + )} +
+ )} +
+ {/* Delete confirmation */} + {confirming && ( +
+

+ Permanently delete this issue and its file? +

+ + +
+ )} + {/* Cover + tags */}
(null) + const [seriesIssues, setSeriesIssues] = useState([]) + const [seriesIssuesLoading, setSeriesIssuesLoading] = useState(false) const [selectedIssue, setSelectedIssue] = useState(null) + const [selectedIssueIndex, setSelectedIssueIndex] = useState(null) const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null) const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(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) { )}
+ {/* Breadcrumb when inside a series */} + {selectedSeries && ( +
+ + / + + {selectedSeries.title} + +
+ )} + {loading ? ( ) : error ? ( @@ -205,38 +239,63 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
) : ( <> - {total > PAGE_SIZE && ( + {!selectedSeries && total > PAGE_SIZE && (

Showing {filtered.length.toLocaleString()} of {total.toLocaleString()}

)} -
- {filtered.map((item) => - 'issueCount' in item ? ( - setSelectedSeries(item as ComicSeries)} - onTagClick={(item as ComicSeries).item_key && !readOnly - ? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title }) - : undefined} - /> - ) : ( - setSelectedIssue(item as ComicIssue)} - onTagClick={(item as ComicIssue).item_key && !readOnly - ? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title }) - : undefined} - /> - ) - )} -
-
- {loadingMore && } + {seriesIssuesLoading ? ( + + ) : ( +
+ {selectedSeries + ? seriesIssues.map((issue) => ( + { 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 ? ( + { setSelectedSeries(item as ComicSeries); setSearch('') }} + onTagClick={(item as ComicSeries).item_key && !readOnly + ? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title }) + : undefined} + /> + ) : ( + { + 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} + /> + ) + ) + } +
+ )} + {!selectedSeries && ( + <> +
+ {loadingMore && } + + )} )}
@@ -285,22 +344,30 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
)} - {selectedSeries && ( - setSelectedSeries(null)} - onTagsChanged={onTagsChanged} - readOnly={readOnly} - /> - )} - {selectedIssue && ( 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} /> )} diff --git a/src/components/mixed/ImageLightbox.tsx b/src/components/mixed/ImageLightbox.tsx index 35900ec..2715ef1 100644 --- a/src/components/mixed/ImageLightbox.tsx +++ b/src/components/mixed/ImageLightbox.tsx @@ -53,6 +53,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item // Polling ref const pollRef = useRef | null>(null) + const touchStartX = useRef(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]) diff --git a/src/lib/comic-metadata.ts b/src/lib/comic-metadata.ts index 9dce45f..e0c8889 100644 --- a/src/lib/comic-metadata.ts +++ b/src/lib/comic-metadata.ts @@ -18,27 +18,24 @@ export async function importComicMetadata(library: Library): Promise { 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 diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 691d0c5..c5ff615 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -552,6 +552,36 @@ async function scanComics(library: Library, libraryRoot: string): Promise 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 } + const savedComicInfo = new Map() + { + 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 = row.metadata ? (JSON.parse(row.metadata) as Record) : {} + 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 } } + // Build a map of item_key → fresh scan metadata (needed for ComicInfo restore below). + const freshMetaMap = new Map>() + 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 + ) + } + } + // 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 await new Promise((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