diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index e530e8a..f986ec1 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -44,6 +44,7 @@ export interface Book { source_url: string; ranking: number; meta_updated: string; + archived?: boolean; } export interface ChapterIdx { diff --git a/ui/src/routes/api/admin/books/[slug]/archive/+server.ts b/ui/src/routes/api/admin/books/[slug]/archive/+server.ts new file mode 100644 index 0000000..32d1023 --- /dev/null +++ b/ui/src/routes/api/admin/books/[slug]/archive/+server.ts @@ -0,0 +1,34 @@ +/** + * PATCH /api/admin/books/[slug]/archive + * PATCH /api/admin/books/[slug]/unarchive (action param: ?action=unarchive) + * + * Admin-only proxy. Soft-deletes (archives) or restores a book. + * Returns { slug, status: "archived" | "active" }. + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +export const PATCH: RequestHandler = async ({ params, url, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const { slug } = params; + const action = url.searchParams.get('action') === 'unarchive' ? 'unarchive' : 'archive'; + + let res: Response; + try { + res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}/${action}`, { + method: 'PATCH' + }); + } catch (e) { + log.error('admin/books/archive', 'backend proxy error', { slug, action, err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +}; diff --git a/ui/src/routes/api/admin/books/[slug]/delete/+server.ts b/ui/src/routes/api/admin/books/[slug]/delete/+server.ts new file mode 100644 index 0000000..344611e --- /dev/null +++ b/ui/src/routes/api/admin/books/[slug]/delete/+server.ts @@ -0,0 +1,34 @@ +/** + * DELETE /api/admin/books/[slug]/delete + * + * Admin-only proxy. Permanently removes a book and all its data: + * PocketBase records, MinIO objects, and the Meilisearch document. + * This operation is irreversible — use the archive endpoint for soft-deletion. + * Returns { slug, status: "deleted" }. + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const { slug } = params; + + let res: Response; + try { + res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}`, { + method: 'DELETE' + }); + } catch (e) { + log.error('admin/books/delete', 'backend proxy error', { slug, err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +}; diff --git a/ui/src/routes/books/[slug]/+page.server.ts b/ui/src/routes/books/[slug]/+page.server.ts index deca427..c7388c0 100644 --- a/ui/src/routes/books/[slug]/+page.server.ts +++ b/ui/src/routes/books/[slug]/+page.server.ts @@ -95,7 +95,8 @@ export const load: PageServerLoad = async ({ params, locals }) => { total_chapters: preview.meta.total_chapters, source_url: preview.meta.source_url, ranking: 0, - meta_updated: '' + meta_updated: '', + archived: false }; return { diff --git a/ui/src/routes/books/[slug]/+page.svelte b/ui/src/routes/books/[slug]/+page.svelte index 5416ba5..ebf71b9 100644 --- a/ui/src/routes/books/[slug]/+page.svelte +++ b/ui/src/routes/books/[slug]/+page.svelte @@ -616,6 +616,54 @@ } } + // ── Admin: archive / delete ─────────────────────────────────────────────── + let archiveStatus = $state<'idle' | 'busy' | 'done' | 'error'>('idle'); + let deleteStatus = $state<'idle' | 'busy' | 'confirm' | 'done' | 'error'>('idle'); + let bookArchived = $state(data.book?.archived ?? false); + + async function toggleArchive() { + const slug = data.book?.slug; + if (!slug) return; + archiveStatus = 'busy'; + const action = bookArchived ? 'unarchive' : 'archive'; + try { + const res = await fetch( + `/api/admin/books/${encodeURIComponent(slug)}/archive?action=${action}`, + { method: 'PATCH' } + ); + if (res.ok) { + bookArchived = !bookArchived; + archiveStatus = 'done'; + setTimeout(() => { archiveStatus = 'idle'; }, 3000); + } else { + archiveStatus = 'error'; + } + } catch { + archiveStatus = 'error'; + } + } + + async function deleteBook() { + const slug = data.book?.slug; + if (!slug) return; + deleteStatus = 'busy'; + try { + const res = await fetch( + `/api/admin/books/${encodeURIComponent(slug)}/delete`, + { method: 'DELETE' } + ); + if (res.ok) { + deleteStatus = 'done'; + // Navigate away — book no longer exists + setTimeout(() => { goto('/admin/catalogue-tools'); }, 1500); + } else { + deleteStatus = 'error'; + } + } catch { + deleteStatus = 'error'; + } + } + // ── "More like this" ───────────────────────────────────────────────────── interface SimilarBook { slug: string; title: string; cover: string | null; author: string | null } let similarBooks = $state([]); @@ -1525,10 +1573,83 @@ {/if} {#if audioError} {audioError} - {/if} - + {/if} + +
+ + +
+

Danger Zone

+ + +
+ + {#if archiveStatus === 'done'} + {bookArchived ? 'Book archived — hidden from search.' : 'Book restored — visible again.'} + {:else if archiveStatus === 'error'} + Action failed. + {:else if bookArchived} + This book is archived and hidden from all users. + {/if} +
+ + +
+ {#if deleteStatus === 'confirm'} + This will permanently delete all chapters, audio, and cover. Cannot be undone. + + + {:else if deleteStatus === 'busy'} + + {:else if deleteStatus === 'done'} + Book deleted. Redirecting… + {:else if deleteStatus === 'error'} + + {:else} + + {/if} +
+
+ + {/if} {/if}