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

@@ -52,6 +52,9 @@ type CatalogueQuery struct {
Sort string // sort field: "popular", "new", "update", "top-rated", "rank", ""
Page int // 1-indexed
Limit int // items per page, default 20
// AdminAll disables the visibility filter so admin users see all non-archived
// books including those marked admin_only.
AdminAll bool
}
// FacetResult holds the available filter values discovered from the index.
@@ -103,7 +106,7 @@ func Configure(host, apiKey string) error {
return fmt.Errorf("meili: update searchable attributes: %w", err)
}
filterable := []interface{}{"status", "genres", "archived"}
filterable := []interface{}{"status", "genres", "archived", "visibility"}
if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil {
return fmt.Errorf("meili: update filterable attributes: %w", err)
}
@@ -135,6 +138,9 @@ type bookDoc struct {
// Archived is true when the book has been soft-deleted by an admin.
// Used as a filter to exclude archived books from all search results.
Archived bool `json:"archived"`
// Visibility is "public" or "admin_only". Only public books are shown to
// non-admin users. Empty string is treated as admin_only for safety.
Visibility string `json:"visibility"`
}
func toDoc(b domain.BookMeta) bookDoc {
@@ -152,6 +158,7 @@ func toDoc(b domain.BookMeta) bookDoc {
Rating: b.Rating,
MetaUpdated: b.MetaUpdated,
Archived: b.Archived,
Visibility: b.Visibility,
}
}
@@ -170,6 +177,7 @@ func fromDoc(d bookDoc) domain.BookMeta {
Rating: d.Rating,
MetaUpdated: d.MetaUpdated,
Archived: d.Archived,
Visibility: d.Visibility,
}
}
@@ -210,7 +218,7 @@ func (c *MeiliClient) Search(_ context.Context, query string, limit int) ([]doma
}
res, err := c.idx.Search(query, &meilisearch.SearchRequest{
Limit: int64(limit),
Filter: "archived = false",
Filter: `archived = false AND visibility = "public"`,
})
if err != nil {
return nil, fmt.Errorf("meili: search %q: %w", query, err)
@@ -251,8 +259,11 @@ func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.B
Facets: []string{"genres", "status"},
}
// Build filter — always exclude archived books
// Build filter — always exclude archived books; restrict to public unless admin.
filters := []string{"archived = false"}
if !q.AdminAll {
filters = append(filters, `visibility = "public"`)
}
if q.Genre != "" && q.Genre != "all" {
filters = append(filters, fmt.Sprintf("genres = %q", q.Genre))
}