feat: option A — visibility gating + author submission system
Some checks failed
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 1m0s
Release / Docker (push) Successful in 11m19s
Release / Deploy to prod (push) Successful in 2m10s
Release / Deploy to homelab (push) Failing after 7s
Release / Gitea Release (push) Successful in 2m25s

Content visibility:
- Add `visibility` field to books ("public" | "admin_only"); new migration
  backfills all existing scraped books to admin_only
- Meilisearch: add visibility as filterable attribute; catalogue/search
  endpoints filter to public-only for non-admin requests
- Admin users identified by bearer token bypass the filter and see all books
- All PocketBase discovery queries (trending, recommended, recently-updated,
  audio shelf, discover, subscription feed) now filter to visibility=public
- New scraped books default to admin_only; WriteMetadata preserves existing
  visibility on PATCH (never overwrites)

Author submission:
- POST /api/admin/books/submit — creates a public book with submitted_by
- PATCH /api/admin/books/{slug}/publish / unpublish — toggle visibility
- SvelteKit proxies: /api/admin/books/[slug]/publish|unpublish
- /api/books/[slug] endpoint for admin book lookup

Frontend:
- backendFetchAdmin() helper sends admin token on any path
- Catalogue server load uses admin fetch when user is admin
- /submit page: author submission form with genre picker and rights assertion
- "Publish" nav link shown to all logged-in users
- Admin catalogue-tools: visibility management panel (load book by slug, toggle)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-04-16 20:20:16 +05:00
parent 50a13447a4
commit da37b1be88
18 changed files with 769 additions and 23 deletions

View File

@@ -45,6 +45,8 @@ export interface Book {
ranking: number;
meta_updated: string;
archived?: boolean;
visibility?: string;
submitted_by?: string;
}
export interface ChapterIdx {
@@ -380,7 +382,7 @@ export async function getTrendingBooks(limit = 8): Promise<Book[]> {
const key = `books:trending:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, 'ranking>0', '+ranking');
const books = await listN<Book>('books', limit, 'ranking>0&&visibility="public"', '+ranking');
await cache.set(key, books, 15 * 60);
return books;
}
@@ -403,7 +405,8 @@ export async function getRecommendedBooks(
const genreFilter = sortedGenres
.map((g) => `genres~"${g.replace(/"/g, '')}"`)
.join('||');
books = await listN<Book>('books', limit * 4, genreFilter, '+ranking');
const filter = `visibility="public"&&(${genreFilter})`;
books = await listN<Book>('books', limit * 4, filter, '+ranking');
await cache.set(key, books, 10 * 60);
}
return books.filter((b) => !excludeSlugs.has(b.slug)).slice(0, limit);
@@ -417,7 +420,7 @@ export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
const key = `books:recent:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, '', '-meta_updated');
const books = await listN<Book>('books', limit, 'visibility="public"', '-meta_updated');
await cache.set(key, books, 5 * 60);
return books;
}
@@ -451,9 +454,11 @@ export async function recentlyUpdatedBooks(limit = 8): Promise<Book[]> {
if (!slugs.length) return recentlyAddedBooks(limit);
const books = await getBooksBySlugs(new Set(slugs));
// Restore recency order (getBooksBySlugs returns in title sort order)
// Restore recency order and filter to public-only books.
const bookMap = new Map(books.map((b) => [b.slug, b]));
const ordered = slugs.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []));
const ordered = slugs
.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []))
.filter((b) => b.visibility === 'public');
await cache.set(key, ordered, 5 * 60);
return ordered;
@@ -1220,7 +1225,7 @@ export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntr
const entries: AudioBookEntry[] = [];
for (const [slug, chapters] of chapsBySlug) {
const book = bookMap.get(slug);
if (!book) continue;
if (!book || book.visibility !== 'public') continue;
entries.push({ book, audioChapters: chapters.size });
}
// Sort by most chapters narrated first
@@ -2119,7 +2124,7 @@ export async function getSubscriptionFeed(
for (const p of allProgressArrays[i]) {
if (seen.has(p.slug)) continue;
const book = bookMap.get(p.slug);
if (!book) continue;
if (!book || book.visibility !== 'public') continue;
seen.add(p.slug);
feed.push({ book, readerUsername: username, updated: p.updated });
}
@@ -2319,7 +2324,9 @@ export async function getBooksForDiscovery(
getAllRatings(),
]);
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
let candidates = allBooks.filter(
(b) => b.visibility === 'public' && !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug)
);
if (prefs?.genres?.length) {
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));

View File

@@ -47,6 +47,27 @@ export async function backendFetch(path: string, init?: RequestInit): Promise<Re
}
}
/**
* Like backendFetch but always attaches the admin bearer token regardless of path.
* Use this when an admin user should bypass the visibility filter on public endpoints
* (e.g. GET /api/catalogue for the admin catalogue view).
*/
export async function backendFetchAdmin(path: string, init?: RequestInit): Promise<Response> {
const finalInit: RequestInit = {
...init,
headers: {
...(ADMIN_TOKEN ? { Authorization: `Bearer ${ADMIN_TOKEN}` } : {}),
...((init?.headers ?? {}) as Record<string, string>)
}
};
try {
return await fetch(`${BACKEND_URL}${path}`, finalInit);
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
throw error(502, 'Could not reach backend');
}
}
// ─── Cached admin model lists ─────────────────────────────────────────────────
const MODELS_CACHE_TTL = 10 * 60; // 10 minutes — model lists rarely change

View File

@@ -586,6 +586,14 @@
>
{m.nav_catalogue()}
</a>
{#if data.user}
<a
href="/submit"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/submit') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Publish
</a>
{/if}
{#if !data.isPro}
<a
href="/subscribe"
@@ -821,6 +829,15 @@
>
{m.nav_catalogue()}
</a>
{#if data.user}
<a
href="/submit"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/submit') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Publish
</a>
{/if}
<a
href="https://feedback.libnovel.cc"
target="_blank"

View File

@@ -38,6 +38,46 @@
$effect(() => { void imgModel; void numSteps; void width; void height; saveConfig(); });
// ── Visibility management ─────────────────────────────────────────────────────
type VisBook = { slug: string; vis: string; busy: boolean };
let visBooks = $state<VisBook[]>([]);
let visSlugInput = $state('');
let visError = $state('');
async function addVisBook(e: Event) {
e.preventDefault();
const slug = visSlugInput.trim();
if (!slug || visBooks.some((b) => b.slug === slug)) return;
visError = '';
try {
const res = await fetch(`/api/books/${slug}`);
if (!res.ok) { visError = `Book "${slug}" not found.`; return; }
const book = await res.json() as { slug: string; visibility?: string };
visBooks = [...visBooks, { slug: book.slug, vis: book.visibility ?? 'admin_only', busy: false }];
visSlugInput = '';
} catch {
visError = 'Failed to load book.';
}
}
function removeVisBook(slug: string) {
visBooks = visBooks.filter((b) => b.slug !== slug);
}
async function toggleVisibility(slug: string, currentVis: string) {
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, busy: true } : b);
const action = currentVis === 'public' ? 'unpublish' : 'publish';
try {
const res = await fetch(`/api/admin/books/${slug}/${action}`, { method: 'PATCH' });
if (!res.ok) throw new Error(await res.text());
const newVis = action === 'publish' ? 'public' : 'admin_only';
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, vis: newVis, busy: false } : b);
} catch (e) {
visError = String(e);
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, busy: false } : b);
}
}
// ── Batch covers ──────────────────────────────────────────────────────────────
let fromItem = $state(0);
let toItem = $state(0);
@@ -276,4 +316,68 @@
</div>
{/if}
</div>
<!-- ── Visibility management ───────────────────────────────────────────────── -->
<div class="rounded-xl border border-(--color-border) bg-(--color-surface-2) p-5 space-y-4 mt-6">
<div>
<h2 class="text-base font-semibold">Visibility management</h2>
<p class="text-sm text-(--color-muted) mt-0.5">
Scraped books default to <code class="text-xs bg-(--color-surface-3) px-1 py-0.5 rounded">admin_only</code>.
Publish individual books to make them visible to all users, or unpublish to restrict to admins.
</p>
</div>
{#snippet visMsg(msg: string, ok: boolean)}
<p class="text-sm {ok ? 'text-green-400' : 'text-(--color-danger)'}">{msg}</p>
{/snippet}
<div class="flex flex-col gap-3">
{#each visBooks as vb (vb.slug)}
<div class="flex items-center gap-3 rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2">
<span class="font-mono text-sm flex-1 truncate">{vb.slug}</span>
<span class="text-xs px-2 py-0.5 rounded-full {vb.vis === 'public'
? 'bg-green-500/15 text-green-400 border border-green-500/30'
: 'bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)'}">
{vb.vis === 'public' ? 'public' : 'admin only'}
</span>
<button
type="button"
onclick={() => toggleVisibility(vb.slug, vb.vis)}
disabled={vb.busy}
class="text-xs px-2.5 py-1 rounded border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 disabled:opacity-50 transition-colors"
>
{vb.busy ? '…' : vb.vis === 'public' ? 'Unpublish' : 'Publish'}
</button>
<button
type="button"
onclick={() => removeVisBook(vb.slug)}
class="text-(--color-muted) hover:text-(--color-danger) transition-colors"
aria-label="Remove"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{/each}
</div>
<form onsubmit={addVisBook} class="flex gap-2">
<input
type="text"
bind:value={visSlugInput}
placeholder="book-slug"
class="flex-1 rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm font-mono text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
<button
type="submit"
class="px-4 py-2 rounded-lg border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 transition-colors"
>
Load book
</button>
</form>
{#if visError}
{@render visMsg(visError, false)}
{/if}
</div>
</div>

View File

@@ -0,0 +1,17 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
export const PATCH: 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)}/publish`, { method: 'PATCH' });
} catch (e) {
log.error('admin/books/publish', 'backend proxy error', { slug, err: String(e) });
throw error(502, 'Could not reach backend');
}
return json(await res.json().catch(() => ({})), { status: res.status });
};

View File

@@ -0,0 +1,17 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
export const PATCH: 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)}/unpublish`, { method: 'PATCH' });
} catch (e) {
log.error('admin/books/unpublish', 'backend proxy error', { slug, err: String(e) });
throw error(502, 'Could not reach backend');
}
return json(await res.json().catch(() => ({})), { status: res.status });
};

View File

@@ -0,0 +1,15 @@
/**
* GET /api/books/[slug]
* Returns basic book metadata (slug, title, visibility) for admin tooling.
* Requires admin role — not a public endpoint.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getBook } from '$lib/server/pocketbase';
export const GET: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') throw error(403, 'Forbidden');
const book = await getBook(params.slug);
if (!book) throw error(404, 'Book not found');
return json({ slug: book.slug, title: book.title, visibility: book.visibility ?? 'admin_only' });
};

View File

@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import { backendFetch, backendFetchAdmin } from '$lib/server/scraper';
import {
bookToListing,
type CatalogueResponse,
@@ -32,7 +32,10 @@ export const load: PageServerLoad = async ({ url, locals }) => {
let statuses: string[] = [];
try {
const res = await backendFetch(`/api/catalogue?${params.toString()}`);
// Admin users bypass the visibility filter and see all non-archived books.
const isAdminUser = locals.user?.role === 'admin';
const fetchCatalogue = isAdminUser ? backendFetchAdmin : backendFetch;
const res = await fetchCatalogue(`/api/catalogue?${params.toString()}`);
if (!res.ok) {
log.error('catalogue', 'catalogue returned error', { status: res.status });
throw error(502, `Catalogue fetch failed: ${res.status}`);

View File

@@ -0,0 +1,47 @@
import { redirect, error, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { backendFetchAdmin } from '$lib/server/scraper';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(302, '/login?next=/submit');
return { userId: locals.user.id, username: locals.user.username };
};
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.user) error(401, 'Not authenticated');
const form = await request.formData();
const title = (form.get('title') as string | null)?.trim() ?? '';
const author = (form.get('author') as string | null)?.trim() ?? '';
const cover = (form.get('cover') as string | null)?.trim() ?? '';
const summary = (form.get('summary') as string | null)?.trim() ?? '';
const statusField = (form.get('status') as string | null)?.trim() ?? 'ongoing';
const genresRaw = (form.get('genres') as string | null)?.trim() ?? '';
const genres = genresRaw.split(',').map((g) => g.trim()).filter(Boolean);
if (!title) return fail(400, { error: 'Title is required.' });
const res = await backendFetchAdmin('/api/admin/books/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
author: author || locals.user.username,
cover,
summary,
genres,
status: statusField,
submitted_by: locals.user.id
})
});
if (!res.ok) {
const body = await res.text().catch(() => '');
return fail(res.status, { error: `Submission failed: ${body || res.statusText}` });
}
const data = await res.json() as { slug: string };
redirect(302, `/books/${data.slug}`);
}
};

View File

@@ -0,0 +1,161 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const GENRES = [
'Action', 'Fantasy', 'Romance', 'Cultivation', 'System',
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
'Thriller', 'Mystery', 'Drama', 'Comedy', 'Historical',
];
let selectedGenres = $state<string[]>([]);
let submitting = $state(false);
function toggleGenre(genre: string) {
if (selectedGenres.includes(genre)) {
selectedGenres = selectedGenres.filter((g) => g !== genre);
} else {
selectedGenres = [...selectedGenres, genre];
}
}
</script>
<svelte:head>
<title>Publish Your Novel — LibNovel</title>
</svelte:head>
<div class="max-w-2xl mx-auto px-4 py-10">
<!-- Hero header -->
<div class="mb-10">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-(--color-brand)/10 border border-(--color-brand)/30 text-(--color-brand) text-xs font-semibold mb-4">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
Original Content Platform
</div>
<h1 class="text-3xl font-bold text-(--color-text) mb-3">Publish your novel</h1>
<p class="text-(--color-muted) leading-relaxed">
Share your original work with readers worldwide. Your novel will be available immediately after submission and you retain full rights to your content.
</p>
</div>
<!-- Value props -->
<div class="grid grid-cols-3 gap-3 mb-10">
{#each [
{ icon: '📖', label: 'Free to publish', desc: 'No fees to list your work' },
{ icon: '🌍', label: 'Global readers', desc: 'Reach audiences in 5 languages' },
{ icon: '🎧', label: 'Audio narration', desc: 'AI narration for your chapters' },
] as prop}
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) p-3 text-center">
<div class="text-2xl mb-1">{prop.icon}</div>
<p class="text-xs font-semibold text-(--color-text)">{prop.label}</p>
<p class="text-xs text-(--color-muted) mt-0.5">{prop.desc}</p>
</div>
{/each}
</div>
<!-- Submission form -->
<div class="rounded-xl border border-(--color-border) bg-(--color-surface-2) p-6">
<h2 class="text-lg font-semibold mb-5">Book details</h2>
{#if form?.error}
<div class="mb-4 rounded-lg bg-(--color-danger)/10 border border-(--color-danger)/30 text-(--color-danger) text-sm px-4 py-3">
{form.error}
</div>
{/if}
<form method="POST" onsubmit={() => (submitting = true)} class="flex flex-col gap-4">
<!-- Hidden genres field — updated by button clicks -->
<input type="hidden" name="genres" value={selectedGenres.join(',')} />
<div class="flex flex-col gap-1">
<label for="title" class="text-sm font-medium text-(--color-text)">Title <span class="text-(--color-danger)">*</span></label>
<input
id="title" name="title" type="text" required
placeholder="The Wandering Sword"
class="rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
<div class="flex flex-col gap-1">
<label for="author" class="text-sm font-medium text-(--color-text)">
Author name
<span class="text-(--color-muted) font-normal text-xs ml-1">(defaults to your username)</span>
</label>
<input
id="author" name="author" type="text"
placeholder={data.username}
class="rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
<div class="flex flex-col gap-1">
<label for="summary" class="text-sm font-medium text-(--color-text)">Summary</label>
<textarea
id="summary" name="summary" rows="4"
placeholder="A compelling description of your story…"
class="rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors resize-y"
></textarea>
</div>
<div class="flex gap-3">
<div class="flex-1 flex flex-col gap-1">
<label for="status" class="text-sm font-medium text-(--color-text)">Status</label>
<select
id="status" name="status"
class="rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm text-(--color-text) focus:outline-none focus:border-(--color-brand) transition-colors"
>
<option value="ongoing">Ongoing</option>
<option value="completed">Completed</option>
<option value="hiatus">Hiatus</option>
</select>
</div>
<div class="flex-1 flex flex-col gap-1">
<label for="cover" class="text-sm font-medium text-(--color-text)">Cover image URL</label>
<input
id="cover" name="cover" type="url"
placeholder="https://…"
class="rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-(--color-text)">Genres</label>
<div class="flex flex-wrap gap-2">
{#each GENRES as genre}
<button
type="button"
onclick={() => toggleGenre(genre)}
class="px-3 py-1 rounded-full text-sm border transition-colors {selectedGenres.includes(genre)
? 'bg-(--color-brand) border-(--color-brand) text-black font-medium'
: 'border-(--color-border) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
>
{genre}
</button>
{/each}
</div>
</div>
<!-- Rights assertion -->
<div class="rounded-lg bg-(--color-surface-3) border border-(--color-border) p-4 text-xs text-(--color-muted) leading-relaxed">
By submitting you confirm that you are the original author of this work or hold the rights to publish it, and that the content complies with our
<a href="/terms" class="text-(--color-brand) hover:underline">Terms of Service</a>.
You retain full copyright over your submitted content.
</div>
<button
type="submit"
disabled={submitting}
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) disabled:opacity-60 transition-colors"
>
{submitting ? 'Submitting…' : 'Submit novel'}
</button>
</form>
</div>
<p class="mt-6 text-center text-xs text-(--color-muted)">
Want to add chapters after submission? You can upload them from your book's page once it's created.
</p>
</div>