add manga library
This commit is contained in:
390
src/components/comics/ComicsView.tsx
Normal file
390
src/components/comics/ComicsView.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, 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'
|
||||
|
||||
interface Props {
|
||||
libraryId: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function ComicsView({ libraryId, readOnly }: Props) {
|
||||
const [items, setItems] = useState<(ComicIssue | ComicSeries)[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedSeries, setSelectedSeries] = useState<ComicSeries | null>(null)
|
||||
const [selectedIssue, setSelectedIssue] = useState<ComicIssue | null>(null)
|
||||
const [tagPanel, setTagPanel] = useState<{ itemKey: string; title: string } | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||
const [seriesIssueMeta, setSeriesIssueMeta] = useState<
|
||||
Record<string, { tagIds: string[]; issueTitles: string[] }>
|
||||
>({})
|
||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||
const [showFilters, setShowFilters] = useState(
|
||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||
)
|
||||
|
||||
const toggleTag = (tagId: string) =>
|
||||
setSelectedTagIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.has(tagId) ? next.delete(tagId) : next.add(tagId)
|
||||
return next
|
||||
})
|
||||
|
||||
const fetchItems = useCallback(() => {
|
||||
fetch(`/api/comics?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: (ComicIssue | ComicSeries)[]) => {
|
||||
setItems(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to load comics')
|
||||
setLoading(false)
|
||||
})
|
||||
}, [libraryId])
|
||||
|
||||
useEffect(() => { fetchItems() }, [fetchItems])
|
||||
|
||||
const fetchAssignments = useCallback(() => {
|
||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
.then((r) => r.json())
|
||||
.then(setAssignments)
|
||||
.catch(() => {})
|
||||
}, [libraryId])
|
||||
|
||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||
|
||||
const fetchSeriesIssueMeta = useCallback(() => {
|
||||
fetch(`/api/comics/series-issue-tags?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
.then((r) => r.json())
|
||||
.then(setSeriesIssueMeta)
|
||||
.catch(() => {})
|
||||
}, [libraryId])
|
||||
|
||||
useEffect(() => { fetchSeriesIssueMeta() }, [fetchSeriesIssueMeta])
|
||||
|
||||
const onTagsChanged = useCallback(() => {
|
||||
setFilterRefreshKey((k) => k + 1)
|
||||
fetchAssignments()
|
||||
fetchSeriesIssueMeta()
|
||||
}, [fetchAssignments, fetchSeriesIssueMeta])
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
const isSeries = 'issueCount' in item
|
||||
|
||||
if (isSeries) {
|
||||
const meta = seriesIssueMeta[item.item_key ?? ''] ?? { tagIds: [], issueTitles: [] }
|
||||
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
const titleMatch = item.title.toLowerCase().includes(q)
|
||||
const issueMatch = meta.issueTitles.some((t) => t.toLowerCase().includes(q))
|
||||
if (!titleMatch && !issueMatch) return false
|
||||
}
|
||||
|
||||
if (selectedTagIds.size > 0) {
|
||||
const seriesTags = assignments[item.item_key ?? ''] ?? []
|
||||
const allTags = [...new Set([...seriesTags, ...meta.tagIds])]
|
||||
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Standalone issue
|
||||
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||
if (selectedTagIds.size > 0) {
|
||||
const tags = assignments[item.item_key ?? ''] ?? []
|
||||
if (![...selectedTagIds].every((id) => tags.includes(id))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: (showFilters || filtersActive) ? 'var(--accent)' : 'var(--surface)',
|
||||
color: (showFilters || filtersActive) ? '#fff' : 'var(--text-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
aria-label={showFilters ? 'Hide filters' : 'Show filters'}
|
||||
>
|
||||
Filters{filtersActive ? ' ●' : ''}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6 md:items-start">
|
||||
{showFilters && (
|
||||
<div className="w-full md:w-52 md:flex-shrink-0">
|
||||
<FilterPanel
|
||||
libraryId={libraryId}
|
||||
assignments={assignments}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
selectedTagIds={selectedTagIds}
|
||||
onTagToggle={toggleTag}
|
||||
refreshKey={filterRefreshKey}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{loading ? (
|
||||
<LoadingGrid />
|
||||
) : error ? (
|
||||
<div
|
||||
className="rounded-lg border p-8 text-center"
|
||||
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg border p-12 text-center"
|
||||
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<p className="text-lg mb-1">No comics found</p>
|
||||
<p className="text-sm">Add .cbz files or folders of .cbz files to this library and scan.</p>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Tag panel modal */}
|
||||
{tagPanel && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setTagPanel(null) }}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-4"
|
||||
style={{ borderBottom: '1px solid var(--border)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
Tags
|
||||
</p>
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
||||
{tagPanel.title}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTagPanel(null)}
|
||||
className="ml-4 w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<TagSelector
|
||||
itemKey={tagPanel.itemKey}
|
||||
onTagsChanged={onTagsChanged}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSeries && (
|
||||
<ComicSeriesView
|
||||
libraryId={libraryId}
|
||||
series={selectedSeries}
|
||||
onClose={() => setSelectedSeries(null)}
|
||||
onTagsChanged={onTagsChanged}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedIssue && (
|
||||
<ComicIssueView
|
||||
libraryId={libraryId}
|
||||
issue={selectedIssue}
|
||||
onClose={() => setSelectedIssue(null)}
|
||||
onTagsChanged={onTagsChanged}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SeriesCard({
|
||||
series,
|
||||
onClick,
|
||||
onTagClick,
|
||||
readOnly,
|
||||
}: {
|
||||
series: ComicSeries
|
||||
onClick: () => void
|
||||
onTagClick?: () => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-xl overflow-hidden group"
|
||||
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
|
||||
>
|
||||
<button className="text-left w-full focus:outline-none focus:ring-2" onClick={onClick}>
|
||||
<div
|
||||
className="relative w-full overflow-hidden"
|
||||
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
|
||||
>
|
||||
{series.coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={series.coverUrl}
|
||||
alt={series.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-4xl">📚</div>
|
||||
)}
|
||||
<div
|
||||
className="absolute top-1 right-1 px-1.5 py-0.5 rounded text-xs font-bold leading-none"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
|
||||
>
|
||||
{series.issueCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 pt-1.5 pb-1">
|
||||
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
|
||||
{series.title}
|
||||
</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{series.issueCount} {series.issueCount === 1 ? 'issue' : 'issues'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{onTagClick && !readOnly && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onTagClick() }}
|
||||
className="absolute top-1 left-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
|
||||
title="Tag series"
|
||||
aria-label="Tag series"
|
||||
>
|
||||
🏷
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IssueCard({
|
||||
issue,
|
||||
onClick,
|
||||
onTagClick,
|
||||
readOnly,
|
||||
}: {
|
||||
issue: ComicIssue
|
||||
onClick: () => void
|
||||
onTagClick?: () => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-xl overflow-hidden group"
|
||||
style={{ border: '1px solid var(--border)', background: 'var(--surface)' }}
|
||||
>
|
||||
<button className="text-left w-full focus:outline-none focus:ring-2" onClick={onClick}>
|
||||
<div
|
||||
className="relative w-full overflow-hidden"
|
||||
style={{ aspectRatio: '2/3', background: 'var(--border)' }}
|
||||
>
|
||||
{issue.coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={issue.coverUrl}
|
||||
alt={issue.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-4xl">📖</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 pt-1.5 pb-1">
|
||||
<p className="text-xs font-medium leading-tight truncate" style={{ color: 'var(--text-primary)' }}>
|
||||
{issue.title}
|
||||
</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{issue.pageCount} {issue.pageCount === 1 ? 'pg' : 'pgs'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{onTagClick && !readOnly && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onTagClick() }}
|
||||
className="absolute top-1 left-1 w-6 h-6 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)', color: '#fff' }}
|
||||
title="Tag issue"
|
||||
aria-label="Tag issue"
|
||||
>
|
||||
🏷
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingGrid() {
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<div key={i} className="rounded-xl overflow-hidden animate-pulse" style={{ border: '1px solid var(--border)' }}>
|
||||
<div style={{ aspectRatio: '2/3', background: 'var(--border)' }} />
|
||||
<div className="p-2 space-y-1">
|
||||
<div className="h-3 rounded" style={{ background: 'var(--border)', width: '75%' }} />
|
||||
<div className="h-2 rounded" style={{ background: 'var(--border)', width: '40%' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user