- Import task: persist object_key, author, cover_url, genres, summary, book_status in PocketBase so the runner can fetch the file and write book metadata on completion - Runner poll mode: pass task.ObjectKey instead of empty string - Runner: write BookMeta + UpsertBook in Meilisearch after chapter ingest so imported books appear in catalogue and search - Import UI: add author, cover URL, genres, summary, status fields; add AI tasks panel (chapter names, description, image gen, tagline) after import completes; add AI tasks button on each done task in the list - Admin nav: add Notifications entry to sidebar (all 5 locales) - Logout: delete user_sessions row on sign-out so sessions don't accumulate as phantoms after each login/logout cycle
480 lines
15 KiB
Svelte
480 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
|
|
interface ImportTask {
|
|
id: string;
|
|
slug: string;
|
|
title: string;
|
|
file_name: string;
|
|
file_type: string;
|
|
author: string;
|
|
cover_url: string;
|
|
genres: string[];
|
|
summary: string;
|
|
book_status: string;
|
|
status: string;
|
|
chapters_done: number;
|
|
chapters_total: number;
|
|
error_message: string;
|
|
started: string;
|
|
finished: string;
|
|
}
|
|
|
|
interface PendingImport {
|
|
file: File;
|
|
title: string;
|
|
author: string;
|
|
coverUrl: string;
|
|
genres: string;
|
|
summary: string;
|
|
bookStatus: string;
|
|
preview: { chapters: number; firstLines: string[] };
|
|
}
|
|
|
|
let tasks = $state<ImportTask[]>([]);
|
|
let loading = $state(true);
|
|
let uploading = $state(false);
|
|
let analyzing = $state(false);
|
|
let error = $state('');
|
|
|
|
// Form fields
|
|
let selectedFile = $state<File | null>(null);
|
|
let title = $state('');
|
|
let author = $state('');
|
|
let coverUrl = $state('');
|
|
let genres = $state('');
|
|
let summary = $state('');
|
|
let bookStatus = $state('completed');
|
|
|
|
let pendingImport = $state<PendingImport | null>(null);
|
|
|
|
// AI panel: slug of recently completed import
|
|
let aiSlug = $state('');
|
|
let aiTitle = $state('');
|
|
let showAiPanel = $state(false);
|
|
|
|
async function loadTasks() {
|
|
loading = true;
|
|
try {
|
|
const res = await fetch('/api/admin/import');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
tasks = data.tasks || [];
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load tasks:', e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function handleFileSelect(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
if (!input.files?.length) return;
|
|
const file = input.files[0];
|
|
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
|
if (ext !== 'pdf' && ext !== 'epub') {
|
|
error = 'Please select a PDF or EPUB file';
|
|
return;
|
|
}
|
|
error = '';
|
|
selectedFile = file;
|
|
// Auto-fill title from filename if empty
|
|
if (!title.trim()) {
|
|
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
|
|
}
|
|
}
|
|
|
|
async function analyzeFile() {
|
|
if (!selectedFile || !title.trim()) return;
|
|
analyzing = true;
|
|
error = '';
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', selectedFile);
|
|
formData.append('title', title.trim());
|
|
formData.append('analyze', 'true');
|
|
const res = await fetch('/api/admin/import', { method: 'POST', body: formData });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
pendingImport = {
|
|
file: selectedFile,
|
|
title: title.trim(),
|
|
author: author.trim(),
|
|
coverUrl: coverUrl.trim(),
|
|
genres: genres.trim(),
|
|
summary: summary.trim(),
|
|
bookStatus,
|
|
preview: data.preview || { chapters: 0, firstLines: [] }
|
|
};
|
|
} else {
|
|
const d = await res.json().catch(() => ({}));
|
|
error = d.error || 'Failed to analyze file';
|
|
}
|
|
} catch {
|
|
error = 'Failed to analyze file';
|
|
} finally {
|
|
analyzing = false;
|
|
}
|
|
}
|
|
|
|
async function startImport() {
|
|
if (!pendingImport) return;
|
|
uploading = true;
|
|
error = '';
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', pendingImport.file);
|
|
formData.append('title', pendingImport.title);
|
|
formData.append('author', pendingImport.author);
|
|
formData.append('cover_url', pendingImport.coverUrl);
|
|
formData.append('genres', pendingImport.genres);
|
|
formData.append('summary', pendingImport.summary);
|
|
formData.append('book_status', pendingImport.bookStatus);
|
|
const res = await fetch('/api/admin/import', { method: 'POST', body: formData });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
// Save for AI panel before clearing state
|
|
const importedSlug = data.slug || '';
|
|
const importedTitle = pendingImport.title;
|
|
// Reset form
|
|
pendingImport = null;
|
|
selectedFile = null;
|
|
title = '';
|
|
author = '';
|
|
coverUrl = '';
|
|
genres = '';
|
|
summary = '';
|
|
bookStatus = 'completed';
|
|
// Show AI panel for this slug
|
|
aiSlug = importedSlug;
|
|
aiTitle = importedTitle;
|
|
showAiPanel = !!aiSlug;
|
|
await loadTasks();
|
|
} else {
|
|
const d = await res.json().catch(() => ({}));
|
|
error = d.error || 'Import failed';
|
|
}
|
|
} catch {
|
|
error = 'Import failed';
|
|
} finally {
|
|
uploading = false;
|
|
}
|
|
}
|
|
|
|
function cancelReview() {
|
|
pendingImport = null;
|
|
}
|
|
|
|
function formatDate(dateStr: string) {
|
|
if (!dateStr) return '-';
|
|
return new Date(dateStr).toLocaleString();
|
|
}
|
|
|
|
function statusColor(status: string) {
|
|
switch (status) {
|
|
case 'pending': return 'text-yellow-400';
|
|
case 'running': return 'text-blue-400';
|
|
case 'done': return 'text-green-400';
|
|
case 'failed': return 'text-red-400';
|
|
default: return 'text-(--color-muted)';
|
|
}
|
|
}
|
|
|
|
onMount(() => { loadTasks(); });
|
|
|
|
// Poll every 3s while any task is active
|
|
$effect(() => {
|
|
const hasActive = tasks.some((t) => t.status === 'running' || t.status === 'pending');
|
|
if (!hasActive) return;
|
|
const timer = setInterval(() => { loadTasks(); }, 3000);
|
|
return () => clearInterval(timer);
|
|
});
|
|
|
|
// When a running task finishes, surface the AI panel for it
|
|
$effect(() => {
|
|
if (!showAiPanel) {
|
|
const done = tasks.find((t) => t.status === 'done');
|
|
if (done && !aiSlug) {
|
|
aiSlug = done.slug;
|
|
aiTitle = done.title;
|
|
showAiPanel = true;
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div class="max-w-3xl space-y-8">
|
|
<h1 class="text-2xl font-bold">Import PDF/EPUB</h1>
|
|
|
|
{#if pendingImport}
|
|
<!-- ── Review step ── -->
|
|
<div class="p-6 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/30 space-y-4">
|
|
<h2 class="text-lg font-semibold">Review Import</h2>
|
|
<dl class="space-y-2 text-sm">
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-(--color-muted) shrink-0">Title</dt>
|
|
<dd class="font-medium text-right">{pendingImport.title}</dd>
|
|
</div>
|
|
{#if pendingImport.author}
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-(--color-muted) shrink-0">Author</dt>
|
|
<dd class="text-right">{pendingImport.author}</dd>
|
|
</div>
|
|
{/if}
|
|
{#if pendingImport.genres}
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-(--color-muted) shrink-0">Genres</dt>
|
|
<dd class="text-right">{pendingImport.genres}</dd>
|
|
</div>
|
|
{/if}
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-(--color-muted) shrink-0">Status</dt>
|
|
<dd class="capitalize text-right">{pendingImport.bookStatus}</dd>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-(--color-muted) shrink-0">File</dt>
|
|
<dd class="text-right truncate max-w-xs">{pendingImport.file.name}</dd>
|
|
</div>
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-(--color-muted) shrink-0">Size</dt>
|
|
<dd>{(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB</dd>
|
|
</div>
|
|
{#if pendingImport.preview.chapters > 0}
|
|
<div class="flex justify-between gap-4">
|
|
<dt class="text-(--color-muted) shrink-0">Detected chapters</dt>
|
|
<dd class="text-green-400 font-semibold">{pendingImport.preview.chapters}</dd>
|
|
</div>
|
|
{/if}
|
|
</dl>
|
|
{#if pendingImport.preview.firstLines?.length}
|
|
<div class="mt-2 space-y-1">
|
|
<p class="text-xs text-(--color-muted) mb-1">First lines preview:</p>
|
|
{#each pendingImport.preview.firstLines as line}
|
|
<p class="text-xs text-(--color-muted) italic truncate">{line}</p>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
<div class="flex gap-3 pt-2">
|
|
<button
|
|
onclick={startImport}
|
|
disabled={uploading}
|
|
class="px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded font-medium disabled:opacity-50 transition-colors"
|
|
>
|
|
{uploading ? 'Starting…' : 'Start Import'}
|
|
</button>
|
|
<button
|
|
onclick={cancelReview}
|
|
class="px-4 py-2 border border-(--color-border) rounded font-medium hover:bg-(--color-surface-3) transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{:else}
|
|
<!-- ── Upload form ── -->
|
|
<form
|
|
onsubmit={(e) => { e.preventDefault(); analyzeFile(); }}
|
|
class="p-6 bg-(--color-surface-2) rounded-lg space-y-4"
|
|
>
|
|
<!-- File picker -->
|
|
<div>
|
|
<label for="import-file" class="block text-sm font-medium mb-1">File (PDF or EPUB)</label>
|
|
<input
|
|
id="import-file"
|
|
type="file"
|
|
accept=".pdf,.epub"
|
|
onchange={handleFileSelect}
|
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Title -->
|
|
<div>
|
|
<label for="import-title" class="block text-sm font-medium mb-1">Title <span class="text-red-400">*</span></label>
|
|
<input
|
|
id="import-title"
|
|
type="text"
|
|
bind:value={title}
|
|
placeholder="Book title"
|
|
required
|
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Author -->
|
|
<div>
|
|
<label for="import-author" class="block text-sm font-medium mb-1">Author</label>
|
|
<input
|
|
id="import-author"
|
|
type="text"
|
|
bind:value={author}
|
|
placeholder="Author name"
|
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Cover URL -->
|
|
<div>
|
|
<label for="import-cover" class="block text-sm font-medium mb-1">Cover image URL</label>
|
|
<input
|
|
id="import-cover"
|
|
type="url"
|
|
bind:value={coverUrl}
|
|
placeholder="https://…"
|
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Genres -->
|
|
<div>
|
|
<label for="import-genres" class="block text-sm font-medium mb-1">Genres <span class="text-xs text-(--color-muted)">(comma-separated)</span></label>
|
|
<input
|
|
id="import-genres"
|
|
type="text"
|
|
bind:value={genres}
|
|
placeholder="Fantasy, Action, Romance"
|
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Summary -->
|
|
<div>
|
|
<label for="import-summary" class="block text-sm font-medium mb-1">Summary</label>
|
|
<textarea
|
|
id="import-summary"
|
|
bind:value={summary}
|
|
rows={3}
|
|
placeholder="Short description of the book…"
|
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm resize-y"
|
|
></textarea>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div>
|
|
<label for="import-status" class="block text-sm font-medium mb-1">Book status</label>
|
|
<select
|
|
id="import-status"
|
|
bind:value={bookStatus}
|
|
class="px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
|
>
|
|
<option value="completed">Completed</option>
|
|
<option value="ongoing">Ongoing</option>
|
|
<option value="hiatus">Hiatus</option>
|
|
</select>
|
|
</div>
|
|
|
|
{#if error}
|
|
<p class="text-sm text-red-400">{error}</p>
|
|
{/if}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={analyzing || !selectedFile || !title.trim()}
|
|
class="px-5 py-2 bg-(--color-brand) text-(--color-surface) rounded font-semibold disabled:opacity-50 hover:brightness-110 transition-all"
|
|
>
|
|
{analyzing ? 'Analyzing…' : 'Review & Import'}
|
|
</button>
|
|
<p class="text-xs text-(--color-muted)">Detects chapter structure before committing.</p>
|
|
</form>
|
|
{/if}
|
|
|
|
<!-- ── AI Tasks panel (shown after successful import) ── -->
|
|
{#if showAiPanel && aiSlug}
|
|
<div class="p-5 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/20 space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-base font-semibold">AI Tasks for <span class="text-(--color-brand)">{aiTitle || aiSlug}</span></h2>
|
|
<button
|
|
onclick={() => { showAiPanel = false; }}
|
|
class="text-(--color-muted) hover:text-(--color-text) text-lg leading-none"
|
|
aria-label="Dismiss"
|
|
>×</button>
|
|
</div>
|
|
<p class="text-sm text-(--color-muted)">Run AI tasks on the imported book to enrich it:</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<a
|
|
href="/admin/text-gen?slug={aiSlug}&tab=chapters"
|
|
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
|
>
|
|
Generate chapter names
|
|
</a>
|
|
<a
|
|
href="/admin/text-gen?slug={aiSlug}&tab=description"
|
|
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
|
>
|
|
Generate description
|
|
</a>
|
|
<a
|
|
href="/admin/image-gen?slug={aiSlug}"
|
|
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
|
>
|
|
Generate cover image
|
|
</a>
|
|
<a
|
|
href="/admin/text-gen?slug={aiSlug}&tab=tagline"
|
|
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
|
>
|
|
Generate tagline
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- ── Task list ── -->
|
|
<div>
|
|
<h2 class="text-lg font-semibold mb-3">Import Tasks</h2>
|
|
|
|
{#if loading}
|
|
<p class="text-(--color-muted) text-sm">Loading…</p>
|
|
{:else if tasks.length === 0}
|
|
<p class="text-(--color-muted) text-sm">No import tasks yet.</p>
|
|
{:else}
|
|
<div class="overflow-x-auto rounded-lg border border-(--color-border)">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="text-left text-(--color-muted) border-b border-(--color-border) bg-(--color-surface-2)">
|
|
<th class="px-3 py-2 font-medium">Title</th>
|
|
<th class="px-3 py-2 font-medium">Type</th>
|
|
<th class="px-3 py-2 font-medium">Status</th>
|
|
<th class="px-3 py-2 font-medium">Chapters</th>
|
|
<th class="px-3 py-2 font-medium">Started</th>
|
|
<th class="px-3 py-2 font-medium">AI</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each tasks as task}
|
|
<tr class="border-b border-(--color-border)/50 hover:bg-(--color-surface-2)/50">
|
|
<td class="px-3 py-2">
|
|
<div class="font-medium">{task.title}</div>
|
|
<div class="text-xs text-(--color-muted)">{task.slug}</div>
|
|
{#if task.error_message}
|
|
<div class="text-xs text-red-400 mt-0.5 truncate max-w-xs" title={task.error_message}>{task.error_message}</div>
|
|
{/if}
|
|
</td>
|
|
<td class="px-3 py-2 uppercase text-xs">{task.file_type}</td>
|
|
<td class="px-3 py-2 {statusColor(task.status)} font-medium">{task.status}</td>
|
|
<td class="px-3 py-2 text-(--color-muted)">
|
|
{task.chapters_done}/{task.chapters_total}
|
|
</td>
|
|
<td class="px-3 py-2 text-(--color-muted) text-xs whitespace-nowrap">{formatDate(task.started)}</td>
|
|
<td class="px-3 py-2">
|
|
{#if task.status === 'done'}
|
|
<button
|
|
onclick={() => { aiSlug = task.slug; aiTitle = task.title; showAiPanel = true; }}
|
|
class="text-xs px-2 py-1 rounded bg-(--color-brand)/20 hover:bg-(--color-brand)/40 text-(--color-brand) transition-colors"
|
|
>
|
|
AI tasks
|
|
</button>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|