tag mapping improvements

This commit is contained in:
Garret Patti
2026-04-19 23:00:10 -04:00
parent 0842769125
commit 6c6a35433c
4 changed files with 642 additions and 7 deletions

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getLibrary } from '@/lib/libraries'
import { getDb } from '@/lib/db'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id: libraryId } = await params
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'comics') {
return NextResponse.json({ error: 'Only comics libraries support bulk rename' }, { status: 400 })
}
const body = await request.json()
const { pattern, preview } = body as { pattern: string; preview?: boolean }
if (!pattern || typeof pattern !== 'string') {
return NextResponse.json({ error: 'Pattern is required' }, { status: 400 })
}
// Validate regex
let regex: RegExp
try {
regex = new RegExp(pattern, 'g')
} catch {
return NextResponse.json({ error: 'Invalid regex pattern' }, { status: 400 })
}
const db = getDb()
const rows = db
.prepare(
`SELECT item_key, title FROM media_items
WHERE library_id = ? AND item_type IN ('comic_series', 'comic_issue')`
)
.all(libraryId) as { item_key: string; title: string }[]
const changes: { itemKey: string; oldTitle: string; newTitle: string }[] = []
for (const row of rows) {
// Reset lastIndex since we reuse the regex with 'g' flag
regex.lastIndex = 0
const newTitle = row.title.replace(regex, '').trim()
if (newTitle && newTitle !== row.title) {
changes.push({ itemKey: row.item_key, oldTitle: row.title, newTitle })
}
}
if (preview) {
return NextResponse.json({ changes })
}
// Apply
const stmt = db.prepare('UPDATE media_items SET title = ? WHERE item_key = ?')
db.transaction(() => {
for (const c of changes) {
stmt.run(c.newTitle, c.itemKey)
}
})()
return NextResponse.json({ updated: changes.length })
}

View File

@@ -108,6 +108,7 @@ function LibraryRow({
const [removing, setRemoving] = useState(false) const [removing, setRemoving] = useState(false)
const [uploadingCover, setUploadingCover] = useState(false) const [uploadingCover, setUploadingCover] = useState(false)
const [importing, setImporting] = useState<'idle' | 'running' | 'done'>('idle') const [importing, setImporting] = useState<'idle' | 'running' | 'done'>('idle')
const [showBulkRename, setShowBulkRename] = useState(false)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null) const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -211,6 +212,7 @@ function LibraryRow({
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{library.type === 'comics' && ( {library.type === 'comics' && (
<>
<button <button
onClick={() => { onClick={() => {
setImporting('running') setImporting('running')
@@ -233,6 +235,16 @@ function LibraryRow({
> >
{importing === 'running' ? 'Importing…' : importing === 'done' ? 'Imported ✓' : 'Import Metadata'} {importing === 'running' ? 'Importing…' : importing === 'done' ? 'Imported ✓' : 'Import Metadata'}
</button> </button>
<button
onClick={() => setShowBulkRename(true)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Bulk Rename
</button>
</>
)} )}
{library.coverExt && ( {library.coverExt && (
<button <button
@@ -280,6 +292,209 @@ function LibraryRow({
{removing ? 'Removing…' : confirming ? 'Confirm?' : 'Remove'} {removing ? 'Removing…' : confirming ? 'Confirm?' : 'Remove'}
</button> </button>
</div> </div>
{showBulkRename && (
<BulkRenameModal libraryId={library.id} onClose={() => setShowBulkRename(false)} />
)}
</div>
)
}
// ─── Bulk Rename Modal ────────────────────────────────────────────────────────
function BulkRenameModal({ libraryId, onClose }: { libraryId: string; onClose: () => void }) {
const [pattern, setPattern] = useState('')
const [preview, setPreview] = useState<{ itemKey: string; oldTitle: string; newTitle: string }[] | null>(null)
const [loading, setLoading] = useState(false)
const [applying, setApplying] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<string | null>(null)
const handlePreview = async () => {
if (!pattern.trim()) return
setError(null)
setPreview(null)
setResult(null)
setLoading(true)
try {
const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: pattern.trim(), preview: true }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Failed to preview')
} else {
setPreview(data.changes ?? [])
}
} catch {
setError('Network error')
} finally {
setLoading(false)
}
}
const handleApply = async () => {
if (!pattern.trim()) return
setError(null)
setApplying(true)
try {
const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: pattern.trim() }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Failed to apply')
} else {
setResult(`Updated ${data.updated} title${data.updated === 1 ? '' : 's'}`)
setPreview(null)
}
} catch {
setError('Network error')
} finally {
setApplying(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={onClose}
>
<div
className="w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col"
style={{
backgroundColor: 'var(--surface)',
border: '1px solid var(--border)',
maxHeight: '80vh',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-4 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div>
<p className="font-medium" style={{ color: 'var(--text-primary)' }}>Bulk Rename</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
Enter a regex pattern to remove from comic titles
</p>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
</button>
</div>
{/* Body */}
<div className="px-5 py-4 overflow-y-auto flex-1">
{/* Pattern input */}
<div className="flex gap-2 mb-4">
<input
type="text"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handlePreview() }}
placeholder="e.g. \[English\]|\{doujin-moe\.us\}"
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none font-mono"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
autoFocus
/>
<button
onClick={handlePreview}
disabled={!pattern.trim() || loading}
className="text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{loading ? 'Loading…' : 'Preview'}
</button>
</div>
{error && (
<p
className="text-xs mb-3 px-3 py-2 rounded-lg"
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
>
{error}
</p>
)}
{result && (
<p
className="text-xs mb-3 px-3 py-2 rounded-lg"
style={{ backgroundColor: '#14532d33', color: '#86efac' }}
>
{result}
</p>
)}
{/* Preview list */}
{preview !== null && (
preview.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
No titles match this pattern.
</p>
) : (
<div>
<p className="text-xs mb-2" style={{ color: 'var(--text-secondary)' }}>
{preview.length} title{preview.length === 1 ? '' : 's'} will be updated:
</p>
<div
className="rounded-lg border divide-y overflow-hidden"
style={{ borderColor: 'var(--border)' }}
>
{preview.map((c) => (
<div key={c.itemKey} className="px-3 py-2">
<p className="text-xs line-through" style={{ color: 'var(--text-secondary)' }}>
{c.oldTitle}
</p>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>
{c.newTitle}
</p>
</div>
))}
</div>
</div>
)
)}
</div>
{/* Footer */}
{preview && preview.length > 0 && (
<div
className="flex items-center justify-end gap-2 px-5 py-3 flex-shrink-0"
style={{ borderTop: '1px solid var(--border)' }}
>
<button
onClick={onClose}
className="text-xs px-3 py-2 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
<button
onClick={handleApply}
disabled={applying}
className="text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
>
{applying ? 'Applying…' : `Apply to ${preview.length} title${preview.length === 1 ? '' : 's'}`}
</button>
</div>
)}
</div>
</div> </div>
) )
} }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types' import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types'
@@ -14,6 +14,34 @@ export default function TagMappingsPage() {
const [tags, setTags] = useState<Tag[]>([]) const [tags, setTags] = useState<Tag[]>([])
const [categories, setCategories] = useState<TagCategory[]>([]) const [categories, setCategories] = useState<TagCategory[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [prefixMappings, setPrefixMappings] = useState<Record<string, string>>({})
const [ignoredTags, setIgnoredTags] = useState<Set<string>>(new Set())
// Load prefix mappings and ignored tags from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(`prefix-mappings-${libraryId}`)
if (stored) setPrefixMappings(JSON.parse(stored))
} catch { /* ignore */ }
try {
const stored = localStorage.getItem(`ignored-tags-${libraryId}`)
if (stored) setIgnoredTags(new Set(JSON.parse(stored)))
} catch { /* ignore */ }
}, [libraryId])
const updatePrefixMappings = useCallback((next: Record<string, string>) => {
setPrefixMappings(next)
try {
localStorage.setItem(`prefix-mappings-${libraryId}`, JSON.stringify(next))
} catch { /* ignore */ }
}, [libraryId])
const updateIgnoredTags = useCallback((next: Set<string>) => {
setIgnoredTags(next)
try {
localStorage.setItem(`ignored-tags-${libraryId}`, JSON.stringify([...next]))
} catch { /* ignore */ }
}, [libraryId])
const refresh = () => { const refresh = () => {
Promise.all([ Promise.all([
@@ -44,6 +72,9 @@ export default function TagMappingsPage() {
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)
const visibleTags = importedTags.filter((t) => !ignoredTags.has(t.name))
const hiddenTags = importedTags.filter((t) => ignoredTags.has(t.name))
return ( return (
<div className="max-w-2xl"> <div className="max-w-2xl">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@@ -70,27 +101,49 @@ export default function TagMappingsPage() {
</Section> </Section>
) : ( ) : (
<> <>
<PrefixMappingsSection
categories={categories}
importedTags={importedTags}
prefixMappings={prefixMappings}
onUpdate={updatePrefixMappings}
/>
<Section title="Unmapped Tags"> <Section title="Unmapped Tags">
{importedTags.length === 0 ? ( {visibleTags.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
No unmapped imported tags. All tags have been mapped or no ComicInfo.xml tags were found. {importedTags.length === 0
? 'No unmapped imported tags. All tags have been mapped or no ComicInfo.xml tags were found.'
: 'All unmapped tags are hidden. Check the ignored tags section below.'}
</p> </p>
) : ( ) : (
<div className="divide-y" style={{ borderColor: 'var(--border)' }}> <div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{importedTags.map((it) => ( {visibleTags.map((it) => (
<ImportedTagRow <ImportedTagRow
key={it.id} key={it.id}
importedTag={it} importedTag={it}
libraryId={libraryId} libraryId={libraryId}
tagsByCategory={tagsByCategory} tagsByCategory={tagsByCategory}
categories={categories} categories={categories}
prefixMappings={prefixMappings}
onMapped={refresh} onMapped={refresh}
onIgnore={() => updateIgnoredTags(new Set([...ignoredTags, it.name]))}
/> />
))} ))}
</div> </div>
)} )}
</Section> </Section>
{hiddenTags.length > 0 && (
<IgnoredTagsSection
tags={hiddenTags}
onUnignore={(name) => {
const next = new Set(ignoredTags)
next.delete(name)
updateIgnoredTags(next)
}}
/>
)}
<Section title="Saved Mappings"> <Section title="Saved Mappings">
{mappings.length === 0 ? ( {mappings.length === 0 ? (
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
@@ -110,6 +163,159 @@ export default function TagMappingsPage() {
) )
} }
// ─── Prefix Mappings Section ──────────────────────────────────────────────────
function PrefixMappingsSection({
categories,
importedTags,
prefixMappings,
onUpdate,
}: {
categories: TagCategory[]
importedTags: ImportedTag[]
prefixMappings: Record<string, string>
onUpdate: (next: Record<string, string>) => void
}) {
const [newPrefix, setNewPrefix] = useState('')
const [newCategoryId, setNewCategoryId] = useState('')
// Detect prefixes from imported tags that aren't yet mapped
const detectedPrefixes = Array.from(
new Set(
importedTags
.map((t) => {
const idx = t.name.indexOf(': ')
return idx > 0 ? t.name.slice(0, idx).trim().toLowerCase() : null
})
.filter((p): p is string => p !== null)
)
).filter((p) => !(p in prefixMappings)).sort()
const catMap = new Map(categories.map((c) => [c.id, c.name]))
const entries = Object.entries(prefixMappings)
const handleAdd = () => {
const key = newPrefix.trim().toLowerCase()
if (!key || !newCategoryId) return
onUpdate({ ...prefixMappings, [key]: newCategoryId })
setNewPrefix('')
setNewCategoryId('')
}
const handleRemove = (key: string) => {
const next = { ...prefixMappings }
delete next[key]
onUpdate(next)
}
return (
<Section title="Prefix Mappings">
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
Map tag prefixes (e.g. &quot;language&quot; in &quot;language: english&quot;) to categories.
When creating a new tag, the category and name will auto-fill.
</p>
{/* Existing mappings */}
{entries.length > 0 && (
<div className="divide-y mb-3" style={{ borderColor: 'var(--border)' }}>
{entries.map(([prefix, catId]) => (
<div key={prefix} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-mono"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
>
{prefix}:
</span>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}></span>
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>
{catMap.get(catId) ?? catId}
</span>
<div className="flex-1" />
<button
onClick={() => handleRemove(prefix)}
className="text-xs px-2 py-1 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
Remove
</button>
</div>
))}
</div>
)}
{/* Add row */}
<div className="flex items-center gap-2">
<input
type="text"
value={newPrefix}
onChange={(e) => setNewPrefix(e.target.value)}
placeholder="prefix"
className="rounded-lg px-2 py-1.5 text-xs font-mono outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
width: 100,
}}
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd() }}
/>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}></span>
<select
value={newCategoryId}
onChange={(e) => setNewCategoryId(e.target.value)}
className="rounded-lg px-2 py-1.5 text-xs outline-none"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
minWidth: 130,
}}
>
<option value="">Category</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<button
onClick={handleAdd}
disabled={!newPrefix.trim() || !newCategoryId}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
Add
</button>
</div>
{/* Suggestions */}
{detectedPrefixes.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Detected:</span>
{detectedPrefixes.map((p) => (
<button
key={p}
onClick={() => setNewPrefix(p)}
className="text-xs px-2 py-0.5 rounded-full transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
{p}
</button>
))}
</div>
)}
</Section>
)
}
// ─── Imported Tag Row ───────────────────────────────────────────────────────── // ─── Imported Tag Row ─────────────────────────────────────────────────────────
function ImportedTagRow({ function ImportedTagRow({
@@ -117,13 +323,17 @@ function ImportedTagRow({
libraryId, libraryId,
tagsByCategory, tagsByCategory,
categories, categories,
prefixMappings,
onMapped, onMapped,
onIgnore,
}: { }: {
importedTag: ImportedTag importedTag: ImportedTag
libraryId: string libraryId: string
tagsByCategory: { category: TagCategory; tags: Tag[] }[] tagsByCategory: { category: TagCategory; tags: Tag[] }[]
categories: TagCategory[] categories: TagCategory[]
prefixMappings: Record<string, string>
onMapped: () => void onMapped: () => void
onIgnore: () => void
}) { }) {
const [selectedTagId, setSelectedTagId] = useState('') const [selectedTagId, setSelectedTagId] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -133,6 +343,24 @@ function ImportedTagRow({
const [newTagCategoryId, setNewTagCategoryId] = useState('') const [newTagCategoryId, setNewTagCategoryId] = useState('')
const [creatingTag, setCreatingTag] = useState(false) const [creatingTag, setCreatingTag] = useState(false)
const startCreating = () => {
// Apply prefix mapping defaults if the imported tag has a colon prefix
const colonIdx = importedTag.name.indexOf(': ')
if (colonIdx > 0) {
const prefix = importedTag.name.slice(0, colonIdx).trim().toLowerCase()
const mappedCategoryId = prefixMappings[prefix]
if (mappedCategoryId) {
setNewTagCategoryId(mappedCategoryId)
setNewTagName(importedTag.name.slice(colonIdx + 2).trim())
setCreating(true)
return
}
}
setNewTagName(importedTag.name)
setNewTagCategoryId('')
setCreating(true)
}
const handleMap = async () => { const handleMap = async () => {
if (!selectedTagId) return if (!selectedTagId) return
setError(null) setError(null)
@@ -252,7 +480,7 @@ function ImportedTagRow({
</button> </button>
<button <button
onClick={() => setCreating(true)} onClick={startCreating}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors" className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
@@ -265,6 +493,21 @@ function ImportedTagRow({
> >
+ New + New
</button> </button>
<button
onClick={onIgnore}
className="text-xs px-2 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
title="Hide this tag"
>
Ignore
</button>
</> </>
) : ( ) : (
<> <>
@@ -335,6 +578,67 @@ function ImportedTagRow({
) )
} }
// ─── Ignored Tags Section ─────────────────────────────────────────────────────
function IgnoredTagsSection({
tags,
onUnignore,
}: {
tags: ImportedTag[]
onUnignore: (name: string) => void
}) {
const [expanded, setExpanded] = useState(false)
return (
<div className="mb-10">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 mb-3 group"
style={{ color: 'var(--text-secondary)' }}
>
<span className="text-xs transition-transform" style={{ display: 'inline-block', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
</span>
<span className="text-xs font-semibold uppercase tracking-wider">
Ignored Tags ({tags.length})
</span>
</button>
{expanded && (
<div
className="rounded-xl border"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
>
<div className="px-5 py-4">
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
{tags.map((t) => (
<div key={t.id} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
<span
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)' }}
>
{t.name}
<span className="text-xs" style={{ opacity: 0.6 }}>({t.itemCount})</span>
</span>
<div className="flex-1" />
<button
onClick={() => onUnignore(t.name)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
>
Unignore
</button>
</div>
))}
</div>
</div>
</div>
)}
</div>
)
}
// ─── Mapping Row ────────────────────────────────────────────────────────────── // ─── Mapping Row ──────────────────────────────────────────────────────────────
function MappingRow({ mapping, onDeleted }: { mapping: TagMapping; onDeleted: () => void }) { function MappingRow({ mapping, onDeleted }: { mapping: TagMapping; onDeleted: () => void }) {

View File

@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
import type { ComicIssue } from '@/types' import type { ComicIssue } from '@/types'
import ImageLightbox from '@/components/mixed/ImageLightbox' import ImageLightbox from '@/components/mixed/ImageLightbox'
import MediaTagPanel from '@/components/tags/MediaTagPanel' import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
function fileApiUrl(libraryId: string, relativePath: string): string { function fileApiUrl(libraryId: string, relativePath: string): string {
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
@@ -24,6 +25,7 @@ function pageUrl(libraryId: string, issueKey: string, pageIndex: number): string
export default function ComicIssueView({ libraryId, issue, onClose, onTagsChanged, readOnly }: Props) { export default function ComicIssueView({ libraryId, issue, onClose, onTagsChanged, 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 issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}` const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
// Close on Escape // Close on Escape
@@ -110,6 +112,50 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
</div> </div>
</div> </div>
{/* Cover + tags */}
<div
className="flex gap-5 px-5 py-4 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div
className="flex-shrink-0 rounded-lg overflow-hidden"
style={{ width: 140, 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"
/>
) : pageCount > 0 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={pageUrl(libraryId, issueKey, 0)}
alt={issue.title}
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full flex items-center justify-center text-3xl"
style={{ color: 'var(--text-secondary)' }}
>
📖
</div>
)}
</div>
<div className="flex-1 min-w-0 pt-1">
<p className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
{issue.item_key ? (
<AssignedTagBadges itemKey={issueKey} refreshKey={tagRefreshKey} />
) : (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No tags</p>
)}
</div>
</div>
{/* Page grid */} {/* Page grid */}
<div className="overflow-y-auto flex-1 p-4" ref={gridRef}> <div className="overflow-y-auto flex-1 p-4" ref={gridRef}>
{pageCount === 0 ? ( {pageCount === 0 ? (
@@ -155,7 +201,7 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
itemKey={issueKey} itemKey={issueKey}
onHide={() => setShowTagPanel(false)} onHide={() => setShowTagPanel(false)}
onClose={onClose} onClose={onClose}
onTagsChanged={onTagsChanged} onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly} readOnly={readOnly}
/> />
)} )}
@@ -170,7 +216,7 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
onPrev={lightboxPage > 0 ? () => setLightboxPage((p) => (p ?? 1) - 1) : undefined} onPrev={lightboxPage > 0 ? () => setLightboxPage((p) => (p ?? 1) - 1) : undefined}
onNext={lightboxPage < pageCount - 1 ? () => setLightboxPage((p) => (p ?? 0) + 1) : undefined} onNext={lightboxPage < pageCount - 1 ? () => setLightboxPage((p) => (p ?? 0) + 1) : undefined}
itemKey={issueKey} itemKey={issueKey}
onTagsChanged={onTagsChanged} onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
readOnly={readOnly} readOnly={readOnly}
/> />
)} )}