comic library improvements
This commit is contained in:
@@ -14,7 +14,10 @@ interface Props {
|
|||||||
libraryId: string
|
libraryId: string
|
||||||
issue: ComicIssue
|
issue: ComicIssue
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onPrev?: () => void
|
||||||
|
onNext?: () => void
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
|
onDeleted?: () => void
|
||||||
readOnly?: boolean
|
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}`
|
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 [lightboxPage, setLightboxPage] = useState<number | null>(null)
|
||||||
const [showTagPanel, setShowTagPanel] = useState(false)
|
const [showTagPanel, setShowTagPanel] = useState(false)
|
||||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
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}`
|
const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
|
||||||
|
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
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)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('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 pageCount = issue.pageCount
|
||||||
const downloadUrl = fileApiUrl(libraryId, issue.filePath)
|
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)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||||
onClick={onClose}
|
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={`flex h-full w-full ${showTagPanel ? 'flex-col md:flex-row' : 'items-center justify-center p-4'}`}>
|
||||||
<div
|
<div
|
||||||
className={`${showTagPanel ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-4xl'}`}
|
className={`${showTagPanel ? 'flex-1 min-h-0 flex items-center justify-center p-4' : 'w-full max-w-4xl'}`}
|
||||||
onClick={showTagPanel ? undefined : undefined}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-4xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* 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
|
<a
|
||||||
href={downloadUrl}
|
href={downloadUrl}
|
||||||
download
|
download
|
||||||
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
className="flex items-center px-3 py-2 text-xs transition-colors hover:bg-black/10"
|
||||||
style={{
|
style={{ color: 'var(--text-primary)' }}
|
||||||
backgroundColor: 'var(--surface)',
|
onClick={(e) => { e.stopPropagation(); setMenuOpen(false) }}
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</a>
|
</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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
|
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>
|
||||||
</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 */}
|
{/* Cover + tags */}
|
||||||
<div
|
<div
|
||||||
className="flex gap-5 px-5 py-4 flex-shrink-0"
|
className="flex gap-5 px-5 py-4 flex-shrink-0"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import type { ComicIssue, ComicSeries } from '@/types'
|
import type { ComicIssue, ComicSeries } from '@/types'
|
||||||
import ComicSeriesView from './ComicSeriesView'
|
|
||||||
import ComicIssueView from './ComicIssueView'
|
import ComicIssueView from './ComicIssueView'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
@@ -22,7 +21,10 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [selectedSeries, setSelectedSeries] = useState<ComicSeries | null>(null)
|
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 [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
|
||||||
|
const [selectedIssueIndex, setSelectedIssueIndex] = useState<number | null>(null)
|
||||||
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
@@ -69,6 +71,16 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { fetchItems(1, '', true) }, [fetchItems])
|
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
|
// IntersectionObserver: load next page when sentinel scrolls into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sentinel = sentinelRef.current
|
const sentinel = sentinelRef.current
|
||||||
@@ -151,6 +163,11 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
return true
|
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
|
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -186,6 +203,23 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<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 ? (
|
{loading ? (
|
||||||
<LoadingGrid />
|
<LoadingGrid />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
@@ -205,19 +239,34 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{total > PAGE_SIZE && (
|
{!selectedSeries && total > PAGE_SIZE && (
|
||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Showing {filtered.length.toLocaleString()} of {total.toLocaleString()}
|
Showing {filtered.length.toLocaleString()} of {total.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{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">
|
<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) =>
|
{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 ? (
|
'issueCount' in item ? (
|
||||||
<SeriesCard
|
<SeriesCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
series={item as ComicSeries}
|
series={item as ComicSeries}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onClick={() => setSelectedSeries(item as ComicSeries)}
|
onClick={() => { setSelectedSeries(item as ComicSeries); setSearch('') }}
|
||||||
onTagClick={(item as ComicSeries).item_key && !readOnly
|
onTagClick={(item as ComicSeries).item_key && !readOnly
|
||||||
? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title })
|
? () => setTagPanel({ itemKey: (item as ComicSeries).item_key!, title: item.title })
|
||||||
: undefined}
|
: undefined}
|
||||||
@@ -227,18 +276,28 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
issue={item as ComicIssue}
|
issue={item as ComicIssue}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onClick={() => setSelectedIssue(item as ComicIssue)}
|
onClick={() => {
|
||||||
|
const issue = item as ComicIssue
|
||||||
|
setSelectedIssue(issue)
|
||||||
|
setSelectedIssueIndex(filteredIssues.indexOf(issue))
|
||||||
|
}}
|
||||||
onTagClick={(item as ComicIssue).item_key && !readOnly
|
onTagClick={(item as ComicIssue).item_key && !readOnly
|
||||||
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title })
|
? () => setTagPanel({ itemKey: (item as ComicIssue).item_key!, title: item.title })
|
||||||
: undefined}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{!selectedSeries && (
|
||||||
|
<>
|
||||||
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden />
|
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden />
|
||||||
{loadingMore && <LoadingMore />}
|
{loadingMore && <LoadingMore />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -285,22 +344,30 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedSeries && (
|
|
||||||
<ComicSeriesView
|
|
||||||
libraryId={libraryId}
|
|
||||||
series={selectedSeries}
|
|
||||||
onClose={() => setSelectedSeries(null)}
|
|
||||||
onTagsChanged={onTagsChanged}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedIssue && (
|
{selectedIssue && (
|
||||||
<ComicIssueView
|
<ComicIssueView
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
issue={selectedIssue}
|
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}
|
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}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
|
|
||||||
// Polling ref
|
// Polling ref
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
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)
|
// Determine if this is an image file (for text extraction controls)
|
||||||
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
|
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 === 'ArrowLeft') onPrev?.()
|
||||||
if (e.key === 'ArrowRight') onNext?.()
|
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('keydown', handleKey)
|
||||||
|
document.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||||
|
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKey)
|
document.removeEventListener('keydown', handleKey)
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart)
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [onClose, onPrev, onNext])
|
}, [onClose, onPrev, onNext])
|
||||||
|
|||||||
@@ -18,27 +18,24 @@ export async function importComicMetadata(library: Library): Promise<void> {
|
|||||||
const db = getDb()
|
const db = getDb()
|
||||||
const libraryRoot = resolveLibraryRoot(library)
|
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
|
const issues = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT item_key, file_path, metadata FROM media_items
|
`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 }[]
|
.all(library.id) as { item_key: string; file_path: string; metadata: string | null }[]
|
||||||
|
|
||||||
|
if (issues.length === 0) return
|
||||||
|
|
||||||
// Load existing mappings for this library
|
// Load existing mappings for this library
|
||||||
const mappingRows = db
|
const mappingRows = db
|
||||||
.prepare('SELECT imported_tag_name, tag_id FROM tag_mappings WHERE library_id = ?')
|
.prepare('SELECT imported_tag_name, tag_id FROM tag_mappings WHERE library_id = ?')
|
||||||
.all(library.id) as { imported_tag_name: string; tag_id: string }[]
|
.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]))
|
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(`
|
const updateItem = db.prepare(`
|
||||||
UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata
|
UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata
|
||||||
WHERE item_key = @item_key
|
WHERE item_key = @item_key
|
||||||
|
|||||||
@@ -552,6 +552,36 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
|||||||
const db = getDb()
|
const db = getDb()
|
||||||
const now = Date.now()
|
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)
|
clearLibraryItems(db, library.id)
|
||||||
|
|
||||||
const upsertSeries = db.prepare(`
|
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
|
// Insert in batches of 500, yielding the event loop between batches so the app
|
||||||
// remains responsive to HTTP requests during a large scan.
|
// remains responsive to HTTP requests during a large scan.
|
||||||
const BATCH_SIZE = 500
|
const BATCH_SIZE = 500
|
||||||
@@ -662,6 +704,23 @@ async function scanComics(library: Library, libraryRoot: string): Promise<void>
|
|||||||
await new Promise<void>((r) => setImmediate(r))
|
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.
|
// Prewarm CBZ cover thumbnails — fire-and-forget so they don't block scan completion.
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const issuesToWarm: ComicIssue[] = 'issues' in item
|
const issuesToWarm: ComicIssue[] = 'issues' in item
|
||||||
|
|||||||
Reference in New Issue
Block a user