Compare commits

...

2 Commits

Author SHA1 Message Date
root
1064c784d4 fix: clamp hero carousel card height to cover aspect ratio, prevent text overflow
All checks were successful
Release / Test backend (push) Successful in 58s
Release / Check ui (push) Successful in 1m59s
Release / Docker (push) Successful in 6m24s
Release / Gitea Release (push) Successful in 23s
2026-04-13 10:32:51 +05:00
root
ed9eeb6262 feat: admin archive/delete UI for books (Danger Zone panel)
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 1m48s
Release / Docker (push) Successful in 6m54s
Release / Gitea Release (push) Successful in 28s
2026-04-12 22:49:15 +05:00
6 changed files with 203 additions and 11 deletions

View File

@@ -44,6 +44,7 @@ export interface Book {
source_url: string;
ranking: number;
meta_updated: string;
archived?: boolean;
}
export interface ChapterIdx {

View File

@@ -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>

View 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 });
};

View 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 });
};

View File

@@ -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 {

View File

@@ -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}