Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a4008bd9c | ||
|
|
f4834f968a | ||
|
|
32ee3c302d | ||
|
|
f5650a98ec | ||
|
|
9c3b235382 | ||
|
|
da37b1be88 | ||
|
|
50a13447a4 | ||
|
|
ce34d2c75f | ||
|
|
d394ac454b | ||
|
|
f24720b087 |
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
# ── ui: type-check & build ────────────────────────────────────────────────────
|
||||
check-ui:
|
||||
name: Check ui
|
||||
name: Test UI
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# ── docker: build + push all images via docker bake ──────────────────────────
|
||||
docker:
|
||||
name: Docker
|
||||
name: Build and push images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-backend, check-ui]
|
||||
steps:
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
# PROD_USER — SSH login user (typically root)
|
||||
# PROD_SSH_KEY — private key whose public half is in authorized_keys
|
||||
# PROD_SSH_KNOWN_HOSTS — output of: ssh-keyscan -H <PROD_HOST>
|
||||
deploy:
|
||||
deploy-prod:
|
||||
name: Deploy to prod
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
@@ -132,6 +132,46 @@ jobs:
|
||||
doppler run -- docker compose pull backend runner ui caddy pocketbase
|
||||
doppler run -- docker compose up -d --remove-orphans'
|
||||
|
||||
# ── deploy homelab runner ─────────────────────────────────────────────────────
|
||||
# Syncs the homelab runner compose file and restarts the runner service.
|
||||
#
|
||||
# Required Gitea secrets:
|
||||
# HOMELAB_HOST — homelab server IP (192.168.0.109)
|
||||
# HOMELAB_USER — SSH login user (typically root)
|
||||
# HOMELAB_SSH_KEY — private key whose public half is in authorized_keys
|
||||
# HOMELAB_SSH_KNOWN_HOSTS — output of: ssh-keyscan -H <HOMELAB_HOST>
|
||||
deploy-homelab:
|
||||
name: Deploy to homelab
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.HOMELAB_SSH_KEY }}" > ~/.ssh/homelab_key
|
||||
chmod 600 ~/.ssh/homelab_key
|
||||
|
||||
- name: Copy docker-compose.yml to homelab
|
||||
run: |
|
||||
scp -i ~/.ssh/homelab_key \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
homelab/docker-compose.yml \
|
||||
"${{ secrets.HOMELAB_USER }}@${{ secrets.HOMELAB_HOST }}:/opt/libnovel-runner/docker-compose.yml"
|
||||
|
||||
- name: Pull new runner image and restart
|
||||
run: |
|
||||
ssh -i ~/.ssh/homelab_key \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
"${{ secrets.HOMELAB_USER }}@${{ secrets.HOMELAB_HOST }}" \
|
||||
'set -euo pipefail
|
||||
cd /opt/libnovel-runner
|
||||
doppler run --project libnovel --config prd_homelab -- docker compose pull runner
|
||||
doppler run --project libnovel --config prd_homelab -- docker compose up -d runner'
|
||||
|
||||
# ── Gitea release ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: Gitea Release
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.opencode/
|
||||
|
||||
60
HOMELAB_SECRETS_SETUP.md
Normal file
60
HOMELAB_SECRETS_SETUP.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Homelab Deployment Secrets Setup
|
||||
|
||||
The release workflow now includes automatic deployment to the homelab runner server. You need to add these secrets to Gitea.
|
||||
|
||||
## Required Secrets
|
||||
|
||||
Go to: `https://gitea.kalekber.cc/kamil/libnovel/settings/secrets/actions`
|
||||
|
||||
### 1. HOMELAB_HOST
|
||||
```
|
||||
192.168.0.109
|
||||
```
|
||||
|
||||
### 2. HOMELAB_USER
|
||||
```
|
||||
root
|
||||
```
|
||||
|
||||
### 3. HOMELAB_SSH_KEY
|
||||
|
||||
If you want to use the same SSH key as prod:
|
||||
- Copy the value from `PROD_SSH_KEY` secret
|
||||
|
||||
If you want a separate key:
|
||||
```bash
|
||||
# On your local machine or CI runner
|
||||
cat ~/.ssh/id_rsa # or your preferred key
|
||||
```
|
||||
|
||||
### 4. HOMELAB_SSH_KNOWN_HOSTS
|
||||
|
||||
Run this when the homelab server is reachable:
|
||||
```bash
|
||||
ssh-keyscan -H 192.168.0.109 2>/dev/null
|
||||
```
|
||||
|
||||
Expected output format:
|
||||
```
|
||||
|1|base64hash...|192.168.0.109 ssh-rsa AAAAB3NzaC...
|
||||
|1|base64hash...|192.168.0.109 ecdsa-sha2-nistp256 AAAAE2...
|
||||
|1|base64hash...|192.168.0.109 ssh-ed25519 AAAAC3...
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
After adding the secrets, the next release (e.g., v4.1.10) will automatically:
|
||||
1. Build all Docker images
|
||||
2. Deploy to prod (165.22.70.138) ✅
|
||||
3. Deploy to homelab (192.168.0.109) ✅ NEW
|
||||
4. Create a Gitea release
|
||||
|
||||
Both deployments run in parallel for faster releases.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the homelab deployment fails:
|
||||
- Check that the secrets are set correctly
|
||||
- Verify SSH access: `ssh root@192.168.0.109`
|
||||
- Check Doppler config exists: `doppler configs --project libnovel`
|
||||
- Manually test: `cd /opt/libnovel-runner && doppler run --project libnovel --config prd_homelab -- docker compose pull runner`
|
||||
@@ -1869,13 +1869,19 @@ func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Admin users (identified by bearer token) see all non-archived books
|
||||
// including those marked admin_only.
|
||||
isAdmin := s.cfg.AdminToken != "" &&
|
||||
r.Header.Get("Authorization") == "Bearer "+s.cfg.AdminToken
|
||||
|
||||
cq := meili.CatalogueQuery{
|
||||
Q: q.Get("q"),
|
||||
Genre: genre,
|
||||
Status: status,
|
||||
Sort: sort,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
Q: q.Get("q"),
|
||||
Genre: genre,
|
||||
Status: status,
|
||||
Sort: sort,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
AdminAll: isAdmin,
|
||||
}
|
||||
|
||||
books, total, facets, err := s.deps.SearchIndex.Catalogue(r.Context(), cq)
|
||||
|
||||
161
backend/internal/backend/handlers_submit.go
Normal file
161
backend/internal/backend/handlers_submit.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// handleAdminPublishBook handles PATCH /api/admin/books/{slug}/publish.
|
||||
// Sets visibility=public so the book is visible to all users.
|
||||
func (s *Server) handleAdminPublishBook(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
if err := s.deps.BookAdminStore.PublishBook(r.Context(), slug); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
s.deps.Log.Error("publish book failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Sync the visibility change to Meilisearch immediately.
|
||||
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
|
||||
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
|
||||
s.deps.Log.Warn("publish book: meili upsert failed", "slug", slug, "err", upsertErr)
|
||||
}
|
||||
}
|
||||
s.deps.Log.Info("book published", "slug", slug)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "visibility": domain.VisibilityPublic})
|
||||
}
|
||||
|
||||
// handleAdminUnpublishBook handles PATCH /api/admin/books/{slug}/unpublish.
|
||||
// Sets visibility=admin_only, hiding the book from regular users.
|
||||
func (s *Server) handleAdminUnpublishBook(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
if err := s.deps.BookAdminStore.UnpublishBook(r.Context(), slug); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
s.deps.Log.Error("unpublish book failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Sync to Meilisearch.
|
||||
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
|
||||
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
|
||||
s.deps.Log.Warn("unpublish book: meili upsert failed", "slug", slug, "err", upsertErr)
|
||||
}
|
||||
}
|
||||
s.deps.Log.Info("book unpublished", "slug", slug)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "visibility": domain.VisibilityAdminOnly})
|
||||
}
|
||||
|
||||
// handleAdminSubmitBook handles POST /api/admin/books/submit.
|
||||
// Creates a new author-submitted book with visibility=public.
|
||||
// The book starts with zero chapters; chapters are added via the import pipeline.
|
||||
func (s *Server) handleAdminSubmitBook(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Cover string `json:"cover"`
|
||||
Summary string `json:"summary"`
|
||||
Genres []string `json:"genres"`
|
||||
Status string `json:"status"`
|
||||
SubmittedBy string `json:"submitted_by"` // app_users ID of submitting author
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
req.Title = strings.TrimSpace(req.Title)
|
||||
if req.Title == "" {
|
||||
jsonError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "ongoing"
|
||||
}
|
||||
|
||||
slug := slugifyTitle(req.Title)
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "could not derive a slug from title")
|
||||
return
|
||||
}
|
||||
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
meta := domain.BookMeta{
|
||||
Slug: slug,
|
||||
Title: req.Title,
|
||||
Author: req.Author,
|
||||
Cover: req.Cover,
|
||||
Summary: req.Summary,
|
||||
Genres: req.Genres,
|
||||
Status: req.Status,
|
||||
Visibility: domain.VisibilityPublic,
|
||||
SubmittedBy: req.SubmittedBy,
|
||||
}
|
||||
if err := s.deps.BookAdminStore.CreateSubmittedBook(r.Context(), meta); err != nil {
|
||||
s.deps.Log.Error("submit book: create failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to create book")
|
||||
return
|
||||
}
|
||||
|
||||
// Index in Meilisearch immediately so it appears in search/catalogue.
|
||||
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
|
||||
s.deps.Log.Warn("submit book: meili upsert failed", "slug", slug, "err", upsertErr)
|
||||
}
|
||||
|
||||
s.deps.Log.Info("book submitted", "slug", slug, "title", req.Title, "by", req.SubmittedBy)
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"slug": slug})
|
||||
}
|
||||
|
||||
// slugifyTitle converts a book title into a URL-safe slug.
|
||||
// e.g. "The Wandering Sword" → "the-wandering-sword"
|
||||
var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
func slugifyTitle(title string) string {
|
||||
// Fold to lower-case ASCII, replace non-alphanum runs with hyphens.
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(title) {
|
||||
if r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
slug := nonAlnum.ReplaceAllString(b.String(), "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
if len(slug) > 80 {
|
||||
slug = slug[:80]
|
||||
slug = strings.TrimRight(slug, "-")
|
||||
}
|
||||
return slug
|
||||
}
|
||||
@@ -283,10 +283,15 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
// Admin data repair endpoints
|
||||
admin("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
|
||||
// Admin book management (soft-delete / hard-delete)
|
||||
// Admin book management (soft-delete / hard-delete / publish visibility)
|
||||
admin("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook)
|
||||
admin("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook)
|
||||
admin("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook)
|
||||
admin("PATCH /api/admin/books/{slug}/publish", s.handleAdminPublishBook)
|
||||
admin("PATCH /api/admin/books/{slug}/unpublish", s.handleAdminUnpublishBook)
|
||||
|
||||
// Author book submission (creates a public book with no scraped content)
|
||||
admin("POST /api/admin/books/submit", s.handleAdminSubmitBook)
|
||||
|
||||
// Admin chapter split (imported books)
|
||||
admin("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
|
||||
|
||||
@@ -235,6 +235,15 @@ type BookAdminStore interface {
|
||||
// - MinIO cover image (covers/{slug}.jpg)
|
||||
// The caller is responsible for also deleting the Meilisearch document.
|
||||
DeleteBook(ctx context.Context, slug string) error
|
||||
|
||||
// PublishBook sets visibility=public, making the book visible to all users.
|
||||
PublishBook(ctx context.Context, slug string) error
|
||||
|
||||
// UnpublishBook sets visibility=admin_only, hiding the book from regular users.
|
||||
UnpublishBook(ctx context.Context, slug string) error
|
||||
|
||||
// CreateSubmittedBook creates a new author-submitted book with visibility=public.
|
||||
CreateSubmittedBook(ctx context.Context, meta domain.BookMeta) error
|
||||
}
|
||||
|
||||
// ImportFileStore uploads raw import files to object storage.
|
||||
|
||||
@@ -7,6 +7,12 @@ import "time"
|
||||
|
||||
// ── Book types ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Visibility values for BookMeta.Visibility.
|
||||
const (
|
||||
VisibilityPublic = "public" // visible to all users
|
||||
VisibilityAdminOnly = "admin_only" // visible only to admin users (e.g. scraped content)
|
||||
)
|
||||
|
||||
// BookMeta carries all bibliographic information about a novel.
|
||||
type BookMeta struct {
|
||||
Slug string `json:"slug"`
|
||||
@@ -27,6 +33,12 @@ type BookMeta struct {
|
||||
// Archived is true when the book has been soft-deleted by an admin.
|
||||
// Archived books are excluded from all public search and catalogue responses.
|
||||
Archived bool `json:"archived,omitempty"`
|
||||
// Visibility controls who can see this book.
|
||||
// "public" = all users; "admin_only" = admin only (default for scraped content).
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
// SubmittedBy is the app_users ID of the author who submitted this book,
|
||||
// or empty for scraped books.
|
||||
SubmittedBy string `json:"submitted_by,omitempty"`
|
||||
}
|
||||
|
||||
// CatalogueEntry is a lightweight book reference returned by catalogue pages.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -63,7 +63,8 @@ var _ taskqueue.Reader = (*Store)(nil)
|
||||
// ── BookWriter ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
|
||||
payload := map[string]any{
|
||||
// patchPayload does NOT include visibility or submitted_by — preserve existing values.
|
||||
patchPayload := map[string]any{
|
||||
"slug": meta.Slug,
|
||||
"title": meta.Title,
|
||||
"author": meta.Author,
|
||||
@@ -85,7 +86,13 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
|
||||
return fmt.Errorf("WriteMetadata: %w", err)
|
||||
}
|
||||
if err == ErrNotFound {
|
||||
postErr := s.pb.post(ctx, "/api/collections/books/records", payload, nil)
|
||||
// New scraped book — default to admin_only visibility.
|
||||
postPayload := make(map[string]any, len(patchPayload)+1)
|
||||
for k, v := range patchPayload {
|
||||
postPayload[k] = v
|
||||
}
|
||||
postPayload["visibility"] = domain.VisibilityAdminOnly
|
||||
postErr := s.pb.post(ctx, "/api/collections/books/records", postPayload, nil)
|
||||
if postErr == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -96,7 +103,28 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
|
||||
return postErr // original POST error is more informative
|
||||
}
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload)
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), patchPayload)
|
||||
}
|
||||
|
||||
// CreateSubmittedBook creates a new author-submitted book with visibility=public.
|
||||
// Unlike WriteMetadata this always POSTs (no upsert) and sets the submitted_by field.
|
||||
func (s *Store) CreateSubmittedBook(ctx context.Context, meta domain.BookMeta) error {
|
||||
payload := map[string]any{
|
||||
"slug": meta.Slug,
|
||||
"title": meta.Title,
|
||||
"author": meta.Author,
|
||||
"cover": meta.Cover,
|
||||
"status": meta.Status,
|
||||
"genres": meta.Genres,
|
||||
"summary": meta.Summary,
|
||||
"total_chapters": 0,
|
||||
"source_url": "",
|
||||
"ranking": 0,
|
||||
"rating": 0,
|
||||
"visibility": domain.VisibilityPublic,
|
||||
"submitted_by": meta.SubmittedBy,
|
||||
}
|
||||
return s.pb.post(ctx, "/api/collections/books/records", payload, nil)
|
||||
}
|
||||
|
||||
func (s *Store) WriteChapter(ctx context.Context, slug string, chapter domain.Chapter) error {
|
||||
@@ -228,6 +256,8 @@ type pbBook struct {
|
||||
Rating float64 `json:"rating"`
|
||||
Updated string `json:"updated"`
|
||||
Archived bool `json:"archived"`
|
||||
Visibility string `json:"visibility"`
|
||||
SubmittedBy string `json:"submitted_by"`
|
||||
}
|
||||
|
||||
func (b pbBook) toDomain() domain.BookMeta {
|
||||
@@ -249,6 +279,8 @@ func (b pbBook) toDomain() domain.BookMeta {
|
||||
Rating: b.Rating,
|
||||
MetaUpdated: metaUpdated,
|
||||
Archived: b.Archived,
|
||||
Visibility: b.Visibility,
|
||||
SubmittedBy: b.SubmittedBy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +439,32 @@ func (s *Store) UnarchiveBook(ctx context.Context, slug string) error {
|
||||
map[string]any{"archived": false})
|
||||
}
|
||||
|
||||
// PublishBook sets visibility=public on the book record for slug.
|
||||
func (s *Store) PublishBook(ctx context.Context, slug string) error {
|
||||
book, err := s.getBookBySlug(ctx, slug)
|
||||
if err == ErrNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("PublishBook: %w", err)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
|
||||
map[string]any{"visibility": domain.VisibilityPublic})
|
||||
}
|
||||
|
||||
// UnpublishBook sets visibility=admin_only on the book record for slug.
|
||||
func (s *Store) UnpublishBook(ctx context.Context, slug string) error {
|
||||
book, err := s.getBookBySlug(ctx, slug)
|
||||
if err == ErrNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("UnpublishBook: %w", err)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
|
||||
map[string]any{"visibility": domain.VisibilityAdminOnly})
|
||||
}
|
||||
|
||||
// DeleteBook permanently removes all data for a book:
|
||||
// - PocketBase books record
|
||||
// - All PocketBase chapters_idx records for the slug
|
||||
|
||||
75
backend/migrations/20260414000003_visibility.go
Normal file
75
backend/migrations/20260414000003_visibility.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Migration 3 — add visibility + submitted_by fields to books.
|
||||
//
|
||||
// visibility: "public" | "admin_only"
|
||||
// All existing (scraped) books are backfilled to "admin_only".
|
||||
// New author-submitted books are created with "public".
|
||||
//
|
||||
// submitted_by: optional app_users ID for books submitted by a registered author.
|
||||
// Empty for scraped books.
|
||||
//
|
||||
// The backfill iterates books in pages of 200. It is idempotent: books whose
|
||||
// visibility is already set are skipped.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
coll, err := app.FindCollectionByNameOrId("books")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changed := false
|
||||
if coll.Fields.GetByName("visibility") == nil {
|
||||
coll.Fields.Add(&core.TextField{Name: "visibility"})
|
||||
changed = true
|
||||
}
|
||||
if coll.Fields.GetByName("submitted_by") == nil {
|
||||
coll.Fields.Add(&core.TextField{Name: "submitted_by"})
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := app.Save(coll); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill: mark all existing books as admin_only where visibility is empty.
|
||||
// These are scraped books that pre-date this migration.
|
||||
const perPage = 200
|
||||
for page := 1; ; page++ {
|
||||
records, err := app.FindRecordsByFilter(
|
||||
"books", `visibility=""`, "+id", perPage, (page-1)*perPage, nil,
|
||||
)
|
||||
if err != nil || len(records) == 0 {
|
||||
break
|
||||
}
|
||||
for _, rec := range records {
|
||||
rec.Set("visibility", "admin_only")
|
||||
// Best-effort: ignore individual save errors (don't abort migration).
|
||||
_ = app.Save(rec)
|
||||
}
|
||||
if len(records) < perPage {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
coll, err := app.FindCollectionByNameOrId("books")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, name := range []string{"visibility", "submitted_by"} {
|
||||
f := coll.Fields.GetByName(name)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
coll.Fields.RemoveById(f.GetId())
|
||||
}
|
||||
return app.Save(coll)
|
||||
})
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
# LibNovel homelab runner
|
||||
#
|
||||
# Connects to production PocketBase and MinIO via public subdomains.
|
||||
# All secrets come from Doppler (project=libnovel, config=prd_homelab).
|
||||
# Run with: doppler run -- docker compose up -d
|
||||
#
|
||||
# Differs from prod runner:
|
||||
# - RUNNER_WORKER_ID=homelab-runner-1 (unique, avoids task claiming conflicts)
|
||||
# - MINIO_ENDPOINT/USE_SSL → storage.libnovel.cc over HTTPS
|
||||
# - POCKETBASE_URL → https://pb.libnovel.cc
|
||||
# - MEILI_URL → https://search.libnovel.cc (Caddy-proxied)
|
||||
# - VALKEY_ADDR → unset (not exposed publicly)
|
||||
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
|
||||
# - REDIS_ADDR → rediss://redis.libnovel.cc:6380 (prod Redis via Caddy TLS proxy)
|
||||
# - LibreTranslate service for machine translation (internal network only)
|
||||
#
|
||||
# extra_hosts pins storage.libnovel.cc and pb.libnovel.cc to the prod server IP
|
||||
# (165.22.70.138) so that large PutObject uploads and PocketBase writes bypass
|
||||
# Cloudflare's 100-second proxy timeout entirely. TLS still terminates at Caddy
|
||||
# on prod; the TLS certificate is valid for the domain names so SNI works fine.
|
||||
|
||||
services:
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LT_API_KEYS: "true"
|
||||
LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
|
||||
# Limit to source→target pairs the runner actually uses
|
||||
LT_LOAD_ONLY: "en,ru,id,pt,fr"
|
||||
LT_DISABLE_WEB_UI: "true"
|
||||
LT_UPDATE_MODELS: "false"
|
||||
volumes:
|
||||
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
|
||||
- libretranslate_db:/app/db
|
||||
|
||||
runner:
|
||||
image: kalekber/libnovel-runner:latest
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 135s
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
depends_on:
|
||||
- libretranslate
|
||||
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s
|
||||
# proxy timeout. Large MP3 PutObject uploads and PocketBase writes go
|
||||
# directly to Caddy on prod; TLS and SNI still work normally.
|
||||
extra_hosts:
|
||||
- "storage.libnovel.cc:165.22.70.138"
|
||||
- "pb.libnovel.cc:165.22.70.138"
|
||||
environment:
|
||||
# ── PocketBase ──────────────────────────────────────────────────────────
|
||||
POCKETBASE_URL: "https://pb.libnovel.cc"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
|
||||
# ── MinIO (S3 API via public subdomain) ─────────────────────────────────
|
||||
MINIO_ENDPOINT: "storage.libnovel.cc"
|
||||
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}"
|
||||
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}"
|
||||
MINIO_USE_SSL: "true"
|
||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}"
|
||||
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}"
|
||||
|
||||
# ── Meilisearch (via search.libnovel.cc Caddy proxy) ────────────────────
|
||||
MEILI_URL: "${MEILI_URL}"
|
||||
MEILI_API_KEY: "${MEILI_API_KEY}"
|
||||
VALKEY_ADDR: ""
|
||||
# Force IPv4 DNS resolution — homelab has no IPv6 route to search.libnovel.cc
|
||||
GODEBUG: "preferIPv4=1"
|
||||
|
||||
# ── Kokoro TTS ──────────────────────────────────────────────────────────
|
||||
KOKORO_URL: "${KOKORO_URL}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
|
||||
# ── Pocket TTS ──────────────────────────────────────────────────────────
|
||||
POCKET_TTS_URL: "${POCKET_TTS_URL}"
|
||||
|
||||
# ── Cloudflare Workers AI TTS ────────────────────────────────────────────
|
||||
CFAI_ACCOUNT_ID: "${CFAI_ACCOUNT_ID}"
|
||||
CFAI_API_TOKEN: "${CFAI_API_TOKEN}"
|
||||
|
||||
# ── LibreTranslate (internal Docker network) ────────────────────────────
|
||||
LIBRETRANSLATE_URL: "http://libretranslate:5000"
|
||||
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
|
||||
|
||||
# ── Asynq / Redis (prod Redis via Caddy TLS proxy) ──────────────────────
|
||||
# The runner connects to prod Redis over TLS: rediss://redis.libnovel.cc:6380.
|
||||
# Caddy on prod terminates TLS and proxies to the local redis:6379 sidecar.
|
||||
REDIS_ADDR: "${REDIS_ADDR}"
|
||||
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
||||
|
||||
# ── Runner tuning ───────────────────────────────────────────────────────
|
||||
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}"
|
||||
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
|
||||
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
|
||||
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
|
||||
RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
|
||||
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
|
||||
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
|
||||
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
|
||||
|
||||
# ── Observability ───────────────────────────────────────────────────────
|
||||
LOG_LEVEL: "${LOG_LEVEL}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
libretranslate_models:
|
||||
libretranslate_db:
|
||||
@@ -16,7 +16,7 @@
|
||||
const display = $derived(hovered || rating || 0);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each [1,2,3,4,5] as star}
|
||||
<button
|
||||
@@ -44,10 +44,13 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if avg && count}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg} ({count})</span>
|
||||
{:else if avg}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg}</span>
|
||||
{#if count > 0}
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-semibold text-(--color-text)">{avg.toFixed(1)}</span>
|
||||
<span class="text-xs text-(--color-muted) leading-none">{count} {count === 1 ? 'rating' : 'ratings'}</span>
|
||||
</div>
|
||||
{:else if !readonly}
|
||||
<span class="text-xs text-(--color-muted)">Rate this book</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
ui/src/routes/api/admin/books/[slug]/publish/+server.ts
Normal file
17
ui/src/routes/api/admin/books/[slug]/publish/+server.ts
Normal 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 });
|
||||
};
|
||||
17
ui/src/routes/api/admin/books/[slug]/unpublish/+server.ts
Normal file
17
ui/src/routes/api/admin/books/[slug]/unpublish/+server.ts
Normal 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 });
|
||||
};
|
||||
15
ui/src/routes/api/books/[slug]/+server.ts
Normal file
15
ui/src/routes/api/books/[slug]/+server.ts
Normal 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' });
|
||||
};
|
||||
@@ -780,7 +780,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="flex flex-col gap-2 min-w-0 flex-1">
|
||||
<div class="flex flex-col gap-3 min-w-0 flex-1">
|
||||
<!-- Title + "not in library" badge -->
|
||||
<div class="flex items-start gap-2 flex-wrap">
|
||||
<h1 class="text-xl sm:text-3xl font-bold text-(--color-text) leading-tight">{book.title}</h1>
|
||||
@@ -794,28 +794,57 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
{#if book.author}
|
||||
<p class="text-(--color-muted) text-sm">{book.author}</p>
|
||||
{/if}
|
||||
<!-- Author + Quick Stats -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#if book.author}
|
||||
<p class="text-(--color-text) text-sm font-medium">{book.author}</p>
|
||||
{/if}
|
||||
<!-- Quick Stats Row -->
|
||||
<div class="flex items-center gap-3 flex-wrap text-xs text-(--color-muted)">
|
||||
{#if book.total_chapters}
|
||||
<span class="flex items-center gap-1">
|
||||
<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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
{book.total_chapters} chapters
|
||||
</span>
|
||||
{/if}
|
||||
{#if ratingAvg.count > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5 fill-amber-400" viewBox="0 0 24 24">
|
||||
<path d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
|
||||
</svg>
|
||||
{ratingAvg.avg.toFixed(1)} ({ratingAvg.count} {ratingAvg.count === 1 ? 'rating' : 'ratings'})
|
||||
</span>
|
||||
{/if}
|
||||
{#if data.readersThisWeek && data.readersThisWeek > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{data.readersThisWeek} this week
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status + genres -->
|
||||
<div class="flex flex-wrap gap-1.5 mt-0.5">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#if book.status}
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) border border-(--color-border)">{book.status}</span>
|
||||
<span class="text-xs px-2.5 py-1 rounded-full font-medium
|
||||
{book.status.toLowerCase() === 'ongoing'
|
||||
? 'bg-green-500/15 text-green-400 border border-green-500/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'}">
|
||||
{book.status}
|
||||
</span>
|
||||
{/if}
|
||||
{#each genres as genre}
|
||||
<a
|
||||
href="/catalogue?genre={encodeURIComponent(genre)}"
|
||||
class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-text) transition-colors"
|
||||
class="text-xs px-2.5 py-1 rounded-full bg-(--color-brand)/10 text-(--color-brand) border border-(--color-brand)/20 hover:bg-(--color-brand)/20 hover:border-(--color-brand)/40 transition-colors"
|
||||
>{genre}</a>
|
||||
{/each}
|
||||
{#if data.readersThisWeek && data.readersThisWeek > 0}
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
|
||||
{data.readersThisWeek} reading this week
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1020,7 +1049,8 @@
|
||||
|
||||
<!-- ── Book description ──────────────────────────────────────────────────────── -->
|
||||
{#if book.summary}
|
||||
<div class="mb-6">
|
||||
<div class="mb-6 bg-(--color-surface-2)/40 rounded-xl p-5 border border-(--color-border)/50">
|
||||
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-wide mb-3">Summary</h2>
|
||||
<div class="relative">
|
||||
<p
|
||||
class="text-(--color-muted) text-sm leading-7 break-words whitespace-pre-line {summaryExpanded ? '' : 'line-clamp-5'}"
|
||||
@@ -1029,13 +1059,13 @@
|
||||
</p>
|
||||
{#if !summaryExpanded && book.summary.length > 300}
|
||||
<!-- gradient fade over the last line when collapsed -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-(--color-surface) to-transparent pointer-events-none"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-(--color-surface-2)/40 to-transparent pointer-events-none"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if book.summary.length > 300}
|
||||
<button
|
||||
onclick={() => (summaryExpanded = !summaryExpanded)}
|
||||
class="mt-2 text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors inline-flex items-center gap-1"
|
||||
class="mt-3 px-3 py-1.5 text-xs font-medium text-(--color-brand) hover:text-(--color-brand-dim) bg-(--color-brand)/10 hover:bg-(--color-brand)/20 border border-(--color-brand)/20 rounded-lg transition-colors inline-flex items-center gap-1.5"
|
||||
>
|
||||
{summaryExpanded ? m.book_detail_less() : m.book_detail_more()}
|
||||
<svg class="w-3 h-3 transition-transform {summaryExpanded ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
let prefs = $state<Prefs>(loadPrefs());
|
||||
// svelte-ignore state_referenced_locally
|
||||
let showOnboarding = $state(!prefs.onboarded);
|
||||
let isEditingPrefs = $state(false);
|
||||
|
||||
// Onboarding temp state
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -46,11 +47,10 @@
|
||||
function finishOnboarding(skip = false) {
|
||||
if (!skip) {
|
||||
prefs = { genres: tempGenres, status: tempStatus, onboarded: true };
|
||||
} else {
|
||||
prefs = { ...prefs, onboarded: true };
|
||||
savePrefs(prefs);
|
||||
}
|
||||
savePrefs(prefs);
|
||||
showOnboarding = false;
|
||||
isEditingPrefs = false;
|
||||
}
|
||||
|
||||
// ── Book deck (client-side filtered) ───────────────────────────────────────
|
||||
@@ -60,6 +60,14 @@
|
||||
try { return JSON.parse(genres) as string[]; } catch { return []; }
|
||||
}
|
||||
|
||||
function cleanSummary(text: string): string {
|
||||
return text.replace(/^summary\s*/i, '').trim();
|
||||
}
|
||||
|
||||
function capitalizeFirst(s: string): string {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
|
||||
// Resolved books from streamed promises — populated via $effect once promises settle
|
||||
let resolvedBooks = $state<Book[]>([]);
|
||||
let resolvedVotedBooks = $state<VotedBook[]>([]);
|
||||
@@ -237,6 +245,8 @@
|
||||
read_now: { x: 30, y: -1300 },
|
||||
};
|
||||
|
||||
let votedToastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function doAction(action: VoteAction) {
|
||||
if (animating || !currentBook) return;
|
||||
animating = true;
|
||||
@@ -271,6 +281,10 @@
|
||||
animating = false;
|
||||
showPreview = false;
|
||||
|
||||
// Auto-clear toast after 4 s
|
||||
if (votedToastTimer) clearTimeout(votedToastTimer);
|
||||
votedToastTimer = setTimeout(() => { voted = null; }, 4000);
|
||||
|
||||
if (action === 'read_now') {
|
||||
goto(`/books/${book.slug}`);
|
||||
} else {
|
||||
@@ -278,6 +292,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function undoLast() {
|
||||
if (!voted || animating) return;
|
||||
const { slug } = voted;
|
||||
voted = null;
|
||||
if (votedToastTimer) { clearTimeout(votedToastTimer); votedToastTimer = null; }
|
||||
votedBooks = votedBooks.filter((v) => v.slug !== slug);
|
||||
if (idx > 0) idx--;
|
||||
await fetch(`/api/discover/vote?slug=${encodeURIComponent(slug)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async function resetDeck() {
|
||||
await fetch('/api/discover/vote', { method: 'DELETE' });
|
||||
votedBooks = [];
|
||||
@@ -301,6 +325,10 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Discover — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Onboarding modal ───────────────────────────────────────────────────────── -->
|
||||
{#if showOnboarding}
|
||||
<div class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
@@ -349,11 +377,11 @@
|
||||
<div class="flex gap-3">
|
||||
<button type="button" onclick={() => finishOnboarding(true)}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Skip
|
||||
{isEditingPrefs ? 'Cancel' : 'Skip'}
|
||||
</button>
|
||||
<button type="button" onclick={() => finishOnboarding(false)}
|
||||
class="flex-[2] py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) transition-colors">
|
||||
Start Discovering
|
||||
{isEditingPrefs ? 'Save Preferences' : 'Start Discovering'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -395,11 +423,11 @@
|
||||
<p class="text-sm text-(--color-muted) mb-3">{previewBook.author}</p>
|
||||
{/if}
|
||||
{#if previewBook.summary}
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{previewBook.summary}</p>
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{cleanSummary(previewBook.summary)}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2 mb-5">
|
||||
{#each parseBookGenres(previewBook.genres).slice(0, 4) as genre}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{capitalizeFirst(genre)}</span>
|
||||
{/each}
|
||||
{#if previewBook.status}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{previewBook.status}</span>
|
||||
@@ -486,6 +514,37 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Voted toast ─────────────────────────────────────────────────────────────── -->
|
||||
{#if voted}
|
||||
{@const toastBg = voted.action === 'like' ? 'bg-green-500/15 border-green-500/30' : voted.action === 'read_now' ? 'bg-blue-500/15 border-blue-500/30' : 'bg-(--color-surface-2) border-(--color-border)'}
|
||||
{@const toastColor = voted.action === 'like' ? 'text-green-400' : voted.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'}
|
||||
<div class="fixed bottom-24 left-1/2 -translate-x-1/2 z-30 flex items-center gap-3 px-4 py-3
|
||||
rounded-2xl {toastBg} border shadow-xl text-sm whitespace-nowrap pointer-events-auto animate-in slide-in-from-bottom-2 duration-200">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if voted.action === 'like'}
|
||||
<svg class="w-4 h-4 {toastColor}" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
{:else if voted.action === 'read_now'}
|
||||
<svg class="w-4 h-4 {toastColor}" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4 {toastColor}" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="font-semibold {toastColor}">
|
||||
{voted.action === 'like' ? 'Added to Library' : voted.action === 'read_now' ? 'Opening Book...' : 'Skipped'}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onclick={undoLast}
|
||||
class="px-2 py-1 rounded-lg text-xs font-bold text-(--color-text) hover:bg-(--color-surface-3)/50 transition-colors">
|
||||
Undo
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Page layout ────────────────────────────────────────────────────────────── -->
|
||||
<div class="select-none -mx-4 -my-8 lg:min-h-[calc(100svh-3.5rem)]
|
||||
lg:grid lg:grid-cols-[1fr_380px] xl:grid-cols-[1fr_420px]">
|
||||
@@ -496,34 +555,43 @@
|
||||
min-h-[calc(100svh-3.5rem)] lg:border-r lg:border-(--color-border)">
|
||||
|
||||
<!-- Header row -->
|
||||
<div class="w-full max-w-sm lg:max-w-none flex items-center justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
|
||||
{#if !loading && !deckEmpty}
|
||||
<p class="text-xs text-(--color-muted)">{totalRemaining} books left</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" onclick={() => (showHistory = true)} title="History"
|
||||
class="relative w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{#if votedBooks.length}
|
||||
<span class="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[9px] font-bold flex items-center justify-center leading-none">
|
||||
{votedBooks.length > 9 ? '9+' : votedBooks.length}
|
||||
</span>
|
||||
<div class="w-full max-w-sm lg:max-w-none flex flex-col gap-3 mb-4 shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
|
||||
{#if !loading && !deckEmpty}
|
||||
<p class="text-xs text-(--color-muted)">{totalRemaining} of {deck.length} books remaining</p>
|
||||
{/if}
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
|
||||
title="Preferences"
|
||||
class="w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" onclick={() => (showHistory = true)} title="History"
|
||||
class="relative w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{#if votedBooks.length}
|
||||
<span class="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[9px] font-bold flex items-center justify-center leading-none">
|
||||
{votedBooks.length > 9 ? '9+' : votedBooks.length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick={() => { showOnboarding = true; isEditingPrefs = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
|
||||
title="Preferences"
|
||||
class="w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress bar -->
|
||||
{#if !loading && !deckEmpty && deck.length > 0}
|
||||
<div class="w-full h-1.5 bg-(--color-surface-2) rounded-full overflow-hidden">
|
||||
<div class="h-full bg-(--color-brand) transition-all duration-500 ease-out rounded-full"
|
||||
style="width: {((idx / deck.length) * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
@@ -539,23 +607,41 @@
|
||||
|
||||
{:else if deckEmpty}
|
||||
<!-- ── Empty state ───────────────────────────────────────────────── -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
|
||||
<div class="w-20 h-20 rounded-full bg-(--color-surface-2) flex items-center justify-center text-4xl">📚</div>
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-sm px-4">
|
||||
<div class="relative">
|
||||
<div class="w-24 h-24 rounded-full bg-(--color-brand)/10 flex items-center justify-center text-5xl border-4 border-(--color-brand)/20">
|
||||
🎉
|
||||
</div>
|
||||
<div class="absolute -top-1 -right-1 w-8 h-8 rounded-full bg-green-500/20 border-2 border-green-500/40 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-(--color-text) mb-2">All caught up!</h2>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
You've seen all available books.
|
||||
{#if prefs.genres.length > 0}Try adjusting your preferences to see more.
|
||||
{:else}Check your library for books you liked.{/if}
|
||||
<h2 class="text-xl font-bold text-(--color-text) mb-2">All Caught Up!</h2>
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed">
|
||||
You've explored all {deck.length} books in your discover queue.
|
||||
{#if prefs.genres.length > 0}
|
||||
<br />Try adjusting your genre preferences to discover more.
|
||||
{:else}
|
||||
<br />Set your preferences to get personalized recommendations.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<a href="/books" class="py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) text-center hover:bg-(--color-brand-dim) transition-colors">
|
||||
My Library
|
||||
</a>
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
{#if votedBooks.filter(v => v.action === 'like' || v.action === 'read_now').length > 0}
|
||||
<a href="/books" class="py-3 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) text-center hover:bg-(--color-brand-dim) transition-colors shadow-lg shadow-(--color-brand)/20">
|
||||
View My Library
|
||||
</a>
|
||||
{/if}
|
||||
<button type="button" onclick={() => { showOnboarding = true; isEditingPrefs = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
|
||||
class="py-3 rounded-xl text-sm font-semibold bg-(--color-surface-2) text-(--color-text) hover:bg-(--color-surface-3) transition-colors border border-(--color-border)">
|
||||
Change Preferences
|
||||
</button>
|
||||
<button type="button" onclick={resetDeck}
|
||||
class="py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Start over
|
||||
class="py-2 rounded-xl text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Start Over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -619,7 +705,7 @@
|
||||
{#if book.author}<p class="text-white/70 text-sm mb-2">{book.author}</p>{/if}
|
||||
<div class="flex flex-wrap gap-1.5 items-center">
|
||||
{#each parseBookGenres(book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{genre}</span>
|
||||
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{capitalizeFirst(genre)}</span>
|
||||
{/each}
|
||||
{#if book.status}
|
||||
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{book.status}</span>
|
||||
@@ -674,8 +760,27 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Swipe hint (mobile only, shown while cards remain) -->
|
||||
<p class="lg:hidden text-xs text-(--color-muted)/60 mt-2 shrink-0 text-center">Swipe ← skip · swipe → like · tap for details</p>
|
||||
<!-- Keyboard hint (desktop only) -->
|
||||
<p class="hidden lg:block text-xs text-(--color-muted)/40 mt-2 shrink-0">← Skip · ↑ Read now · → Like · Space for details</p>
|
||||
<div class="hidden lg:flex items-center justify-center gap-4 mt-3 shrink-0">
|
||||
<div class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/30">
|
||||
<kbd class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)">←</kbd>
|
||||
<span class="text-xs text-(--color-muted)">Skip</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/30">
|
||||
<kbd class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)">↑</kbd>
|
||||
<span class="text-xs text-(--color-muted)">Read</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/30">
|
||||
<kbd class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)">→</kbd>
|
||||
<span class="text-xs text-(--color-muted)">Like</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/30">
|
||||
<kbd class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)">Space</kbd>
|
||||
<span class="text-xs text-(--color-muted)">Details</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -711,7 +816,7 @@
|
||||
<!-- Metadata pills -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each parseBookGenres(book.genres).slice(0, 5) as genre}
|
||||
<span class="text-xs px-2.5 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
<span class="text-xs px-2.5 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{capitalizeFirst(genre)}</span>
|
||||
{/each}
|
||||
{#if book.status}
|
||||
<span class="text-xs px-2.5 py-1 rounded-full bg-(--color-surface-3) text-(--color-text) font-medium">{book.status}</span>
|
||||
@@ -735,38 +840,9 @@
|
||||
{#if book.summary}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Summary</p>
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed">{book.summary}</p>
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed">{cleanSummary(book.summary)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons (duplicated for desktop convenience) -->
|
||||
<div class="mt-auto flex flex-col gap-2.5 pt-4 border-t border-(--color-border)">
|
||||
<button type="button" onclick={() => doAction('read_now')} disabled={animating}
|
||||
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-blue-500 text-white font-bold text-sm hover:bg-blue-400 active:scale-95 transition-all disabled:opacity-40 shadow-lg shadow-blue-500/20">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
Read Now
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick={() => doAction('skip')} disabled={animating}
|
||||
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl bg-red-500/15 border border-red-500/30 text-red-400 text-sm font-semibold hover:bg-red-500/25 active:scale-95 transition-all disabled:opacity-40">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
Skip
|
||||
</button>
|
||||
<button type="button" onclick={() => doAction('like')} disabled={animating}
|
||||
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl bg-green-500/15 border border-green-500/30 text-green-400 text-sm font-semibold hover:bg-green-500/25 active:scale-95 transition-all disabled:opacity-40">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
Like
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onclick={() => { showPreview = true; }}
|
||||
class="w-full py-2 rounded-xl text-xs text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors">
|
||||
More details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if deckEmpty}
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-4 text-center p-8 text-(--color-muted)">
|
||||
|
||||
47
ui/src/routes/submit/+page.server.ts
Normal file
47
ui/src/routes/submit/+page.server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
161
ui/src/routes/submit/+page.svelte
Normal file
161
ui/src/routes/submit/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user