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
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:
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user