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