import-comicinfoxml #32
70
src/app/api/libraries/[id]/bulk-rename/route.ts
Normal file
70
src/app/api/libraries/[id]/bulk-rename/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -108,6 +108,7 @@ function LibraryRow({
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [uploadingCover, setUploadingCover] = useState(false)
|
||||
const [importing, setImporting] = useState<'idle' | 'running' | 'done'>('idle')
|
||||
const [showBulkRename, setShowBulkRename] = useState(false)
|
||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -211,6 +212,7 @@ function LibraryRow({
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{library.type === 'comics' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setImporting('running')
|
||||
@@ -233,6 +235,16 @@ function LibraryRow({
|
||||
>
|
||||
{importing === 'running' ? 'Importing…' : importing === 'done' ? 'Imported ✓' : 'Import Metadata'}
|
||||
</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 && (
|
||||
<button
|
||||
@@ -280,6 +292,209 @@ function LibraryRow({
|
||||
{removing ? 'Removing…' : confirming ? 'Confirm?' : 'Remove'}
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types'
|
||||
|
||||
@@ -14,6 +14,34 @@ export default function TagMappingsPage() {
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [categories, setCategories] = useState<TagCategory[]>([])
|
||||
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 = () => {
|
||||
Promise.all([
|
||||
@@ -44,6 +72,9 @@ export default function TagMappingsPage() {
|
||||
tags: tags.filter((t) => t.categoryId === cat.id),
|
||||
})).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 (
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -70,27 +101,49 @@ export default function TagMappingsPage() {
|
||||
</Section>
|
||||
) : (
|
||||
<>
|
||||
<PrefixMappingsSection
|
||||
categories={categories}
|
||||
importedTags={importedTags}
|
||||
prefixMappings={prefixMappings}
|
||||
onUpdate={updatePrefixMappings}
|
||||
/>
|
||||
|
||||
<Section title="Unmapped Tags">
|
||||
{importedTags.length === 0 ? (
|
||||
{visibleTags.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||
{importedTags.map((it) => (
|
||||
{visibleTags.map((it) => (
|
||||
<ImportedTagRow
|
||||
key={it.id}
|
||||
importedTag={it}
|
||||
libraryId={libraryId}
|
||||
tagsByCategory={tagsByCategory}
|
||||
categories={categories}
|
||||
prefixMappings={prefixMappings}
|
||||
onMapped={refresh}
|
||||
onIgnore={() => updateIgnoredTags(new Set([...ignoredTags, it.name]))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{hiddenTags.length > 0 && (
|
||||
<IgnoredTagsSection
|
||||
tags={hiddenTags}
|
||||
onUnignore={(name) => {
|
||||
const next = new Set(ignoredTags)
|
||||
next.delete(name)
|
||||
updateIgnoredTags(next)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section title="Saved Mappings">
|
||||
{mappings.length === 0 ? (
|
||||
<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. "language" in "language: english") 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 ─────────────────────────────────────────────────────────
|
||||
|
||||
function ImportedTagRow({
|
||||
@@ -117,13 +323,17 @@ function ImportedTagRow({
|
||||
libraryId,
|
||||
tagsByCategory,
|
||||
categories,
|
||||
prefixMappings,
|
||||
onMapped,
|
||||
onIgnore,
|
||||
}: {
|
||||
importedTag: ImportedTag
|
||||
libraryId: string
|
||||
tagsByCategory: { category: TagCategory; tags: Tag[] }[]
|
||||
categories: TagCategory[]
|
||||
prefixMappings: Record<string, string>
|
||||
onMapped: () => void
|
||||
onIgnore: () => void
|
||||
}) {
|
||||
const [selectedTagId, setSelectedTagId] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -133,6 +343,24 @@ function ImportedTagRow({
|
||||
const [newTagCategoryId, setNewTagCategoryId] = useState('')
|
||||
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 () => {
|
||||
if (!selectedTagId) return
|
||||
setError(null)
|
||||
@@ -252,7 +480,7 @@ function ImportedTagRow({
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
onClick={startCreating}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={(e) => {
|
||||
@@ -265,6 +493,21 @@ function ImportedTagRow({
|
||||
>
|
||||
+ New
|
||||
</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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function MappingRow({ mapping, onDeleted }: { mapping: TagMapping; onDeleted: () => void }) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import type { ComicIssue } from '@/types'
|
||||
import ImageLightbox from '@/components/mixed/ImageLightbox'
|
||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
||||
|
||||
function fileApiUrl(libraryId: string, relativePath: string): string {
|
||||
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) {
|
||||
const [lightboxPage, setLightboxPage] = useState<number | null>(null)
|
||||
const [showTagPanel, setShowTagPanel] = useState(false)
|
||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||||
const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}`
|
||||
|
||||
// Close on Escape
|
||||
@@ -110,6 +112,50 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
|
||||
</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 */}
|
||||
<div className="overflow-y-auto flex-1 p-4" ref={gridRef}>
|
||||
{pageCount === 0 ? (
|
||||
@@ -155,7 +201,7 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
|
||||
itemKey={issueKey}
|
||||
onHide={() => setShowTagPanel(false)}
|
||||
onClose={onClose}
|
||||
onTagsChanged={onTagsChanged}
|
||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
@@ -170,7 +216,7 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
|
||||
onPrev={lightboxPage > 0 ? () => setLightboxPage((p) => (p ?? 1) - 1) : undefined}
|
||||
onNext={lightboxPage < pageCount - 1 ? () => setLightboxPage((p) => (p ?? 0) + 1) : undefined}
|
||||
itemKey={issueKey}
|
||||
onTagsChanged={onTagsChanged}
|
||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user