feat: import review step + admin notifications
- Import page: add file upload with review step before committing - Backend: add analyze endpoint to preview chapters before import - Add notifications collection + API for admin alerts - Add bell icon in header with notification dropdown (admin only) - Runner creates notification on import completion - Notifications link to /admin/import for easy review
This commit is contained in:
@@ -15,11 +15,20 @@
|
||||
finished: string;
|
||||
}
|
||||
|
||||
interface PendingImport {
|
||||
file: File;
|
||||
title: string;
|
||||
preview: { chapters: number; firstLines: string[] };
|
||||
}
|
||||
|
||||
let tasks = $state<ImportTask[]>([]);
|
||||
let loading = $state(true);
|
||||
let uploading = $state(false);
|
||||
let title = $state('');
|
||||
let error = $state('');
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let pendingImport = $state<PendingImport | null>(null);
|
||||
let analyzing = $state(false);
|
||||
|
||||
async function loadTasks() {
|
||||
loading = true;
|
||||
@@ -36,35 +45,82 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
async 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;
|
||||
}
|
||||
selectedFile = file;
|
||||
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
async function analyzeFile() {
|
||||
if (!selectedFile || !title.trim()) return;
|
||||
analyzing = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('title', title);
|
||||
formData.append('analyze', 'true');
|
||||
const res = await fetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title })
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
pendingImport = {
|
||||
file: selectedFile,
|
||||
title: title,
|
||||
preview: data.preview || { chapters: 0, firstLines: [] }
|
||||
};
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
error = d.error || 'Failed to analyze file';
|
||||
}
|
||||
} catch (e) {
|
||||
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);
|
||||
const res = await fetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (res.ok) {
|
||||
pendingImport = null;
|
||||
selectedFile = null;
|
||||
title = '';
|
||||
await loadTasks();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
error = data.error || 'Upload failed';
|
||||
const d = await res.json().catch(() => ({}));
|
||||
error = d.error || 'Import failed';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Upload failed';
|
||||
error = 'Import failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelReview() {
|
||||
pendingImport = null;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
@@ -88,30 +144,82 @@
|
||||
<div class="max-w-4xl">
|
||||
<h1 class="text-2xl font-bold mb-6">Import PDF/EPUB</h1>
|
||||
|
||||
<!-- Upload Form -->
|
||||
<form onsubmit={handleSubmit} class="mb-8 p-4 bg-(--color-surface-2) rounded-lg">
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="Book title"
|
||||
class="flex-1 px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
|
||||
/>
|
||||
{#if pendingImport}
|
||||
<!-- Review Step -->
|
||||
<div class="mb-8 p-6 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/30">
|
||||
<h2 class="text-lg font-semibold mb-4">Review Import</h2>
|
||||
<div class="space-y-3 mb-6">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">Title:</span>
|
||||
<span class="font-medium">{pendingImport.title}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">File:</span>
|
||||
<span>{pendingImport.file.name}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">Size:</span>
|
||||
<span>{(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB</span>
|
||||
</div>
|
||||
{#if pendingImport.preview.chapters > 0}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">Detected chapters:</span>
|
||||
<span class="text-green-400">{pendingImport.preview.chapters}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={startImport}
|
||||
disabled={uploading}
|
||||
class="px-4 py-2 bg-green-600 text-white rounded font-medium disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Starting...' : 'Start Import'}
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelReview}
|
||||
class="px-4 py-2 border border-(--color-border) rounded font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Upload Form -->
|
||||
<form onsubmit={(e) => { e.preventDefault(); analyzeFile(); }} class="mb-8 p-4 bg-(--color-surface-2) rounded-lg">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">Select File (PDF or EPUB)</label>
|
||||
<input
|
||||
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)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">Book Title</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="Enter book title"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
|
||||
/>
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="mb-4 text-sm text-red-400">{error}</p>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading || !title.trim()}
|
||||
disabled={analyzing || !selectedFile || !title.trim()}
|
||||
class="px-4 py-2 bg-(--color-brand) text-(--color-surface) rounded font-medium disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Creating...' : 'Import'}
|
||||
{analyzing ? 'Analyzing...' : 'Review & Import'}
|
||||
</button>
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="mt-2 text-sm text-red-400">{error}</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-xs text-(--color-muted)">
|
||||
Upload a PDF or EPUB file to import chapters. The runner will process the file.
|
||||
</p>
|
||||
</form>
|
||||
<p class="mt-2 text-xs text-(--color-muted)">
|
||||
Select a file to preview chapter count before importing.
|
||||
</p>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Task List -->
|
||||
<h2 class="text-lg font-semibold mb-4">Import Tasks</h2>
|
||||
|
||||
Reference in New Issue
Block a user