Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1064c784d4 | ||
|
|
ed9eeb6262 |
@@ -44,6 +44,7 @@ export interface Book {
|
||||
source_url: string;
|
||||
ranking: number;
|
||||
meta_updated: string;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export interface ChapterIdx {
|
||||
|
||||
@@ -145,9 +145,9 @@
|
||||
ontouchstart={onSwipeStart}
|
||||
ontouchend={onSwipeEnd}
|
||||
>
|
||||
<!-- Cover -->
|
||||
<!-- Cover — drives card height via aspect-[2/3] -->
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
|
||||
class="w-32 sm:w-44 shrink-0 self-stretch overflow-hidden block">
|
||||
{#if heroBook.book.cover}
|
||||
{#key heroIndex}
|
||||
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
||||
@@ -162,19 +162,20 @@
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1">
|
||||
<div>
|
||||
<!-- Info — fixed height matching cover, overflow hidden so text never expands the card -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1 overflow-hidden
|
||||
h-[calc(128px*3/2)] sm:h-[calc(176px*3/2)]">
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
|
||||
{#if heroBook.book.author}
|
||||
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
|
||||
<p class="text-sm text-(--color-muted) truncate">{heroBook.book.author}</p>
|
||||
{/if}
|
||||
{#if heroBook.book.summary}
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-3 max-w-prose">{heroBook.book.summary}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap shrink-0">
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
|
||||
34
ui/src/routes/api/admin/books/[slug]/archive/+server.ts
Normal file
34
ui/src/routes/api/admin/books/[slug]/archive/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
34
ui/src/routes/api/admin/books/[slug]/delete/+server.ts
Normal file
34
ui/src/routes/api/admin/books/[slug]/delete/+server.ts
Normal file
@@ -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 });
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<SimilarBook[]>([]);
|
||||
@@ -1525,10 +1573,83 @@
|
||||
{/if}
|
||||
{#if audioError}
|
||||
<span class="text-xs text-(--color-muted)">{audioError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-(--color-border)" />
|
||||
|
||||
<!-- Archive / Delete -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Danger Zone</p>
|
||||
|
||||
<!-- Archive / Unarchive -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={toggleArchive}
|
||||
disabled={archiveStatus === 'busy'}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium border transition-colors
|
||||
{archiveStatus === 'busy'
|
||||
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed border-(--color-border)'
|
||||
: bookArchived
|
||||
? 'bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 border-amber-500/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2) border-(--color-border)'}"
|
||||
>
|
||||
{#if archiveStatus === 'busy'}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
{:else if bookArchived}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
|
||||
{/if}
|
||||
{bookArchived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
{#if archiveStatus === 'done'}
|
||||
<span class="text-xs text-green-400">{bookArchived ? 'Book archived — hidden from search.' : 'Book restored — visible again.'}</span>
|
||||
{:else if archiveStatus === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">Action failed.</span>
|
||||
{:else if bookArchived}
|
||||
<span class="text-xs text-amber-400/70">This book is archived and hidden from all users.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hard delete -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
{#if deleteStatus === 'confirm'}
|
||||
<span class="text-xs text-(--color-danger)">This will permanently delete all chapters, audio, and cover. Cannot be undone.</span>
|
||||
<button
|
||||
onclick={deleteBook}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 border border-red-600/30 transition-colors"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Confirm delete
|
||||
</button>
|
||||
<button onclick={() => { deleteStatus = 'idle'; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Cancel</button>
|
||||
{:else if deleteStatus === 'busy'}
|
||||
<button disabled class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed border border-(--color-border)">
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
Deleting…
|
||||
</button>
|
||||
{:else if deleteStatus === 'done'}
|
||||
<span class="text-xs text-green-400">Book deleted. Redirecting…</span>
|
||||
{:else if deleteStatus === 'error'}
|
||||
<button onclick={() => { deleteStatus = 'confirm'; }} class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-danger) hover:bg-(--color-surface-2) border border-(--color-border) transition-colors">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Delete failed — retry?
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => { deleteStatus = 'confirm'; }}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-danger) hover:bg-red-600/10 border border-(--color-border) transition-colors"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Delete book
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user