Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa2803c164 | ||
|
|
787942b172 | ||
|
|
cb858bf4c9 | ||
|
|
4c3c160102 | ||
|
|
37deac1eb3 | ||
|
|
6f0069daca | ||
|
|
0fc30d1328 | ||
|
|
40151f2f33 | ||
|
|
ad2d1a2603 | ||
|
|
b0d8c02787 | ||
|
|
5b4c1db931 | ||
|
|
0c54c59586 | ||
|
|
0e5eb84097 | ||
|
|
6ef82a1d12 |
@@ -136,57 +136,55 @@ jobs:
|
||||
cache-to: type=inline
|
||||
|
||||
# ── ui: source map upload ─────────────────────────────────────────────────────
|
||||
# Commented out: GlitchTip project/auth token needs to be recreated after
|
||||
# the GlitchTip DB wipe. Re-enable once GLITCHTIP_AUTH_TOKEN is updated.
|
||||
# upload-sourcemaps:
|
||||
# name: Upload source maps
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [check-ui]
|
||||
# defaults:
|
||||
# run:
|
||||
# working-directory: ui
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
#
|
||||
# - uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: "22"
|
||||
# cache: npm
|
||||
# cache-dependency-path: ui/package-lock.json
|
||||
#
|
||||
# - name: Install dependencies
|
||||
# run: npm ci
|
||||
#
|
||||
# - name: Build with source maps
|
||||
# run: npm run build
|
||||
#
|
||||
# - name: Download glitchtip-cli
|
||||
# run: |
|
||||
# curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
|
||||
# -o /usr/local/bin/glitchtip-cli
|
||||
# chmod +x /usr/local/bin/glitchtip-cli
|
||||
#
|
||||
# - name: Inject debug IDs into build artifacts
|
||||
# run: glitchtip-cli sourcemaps inject ./build
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: libnovel-ui
|
||||
#
|
||||
# - name: Upload source maps to GlitchTip
|
||||
# run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: libnovel-ui
|
||||
upload-sourcemaps:
|
||||
name: Upload source maps
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-ui]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build with source maps
|
||||
run: npm run build
|
||||
|
||||
- name: Download glitchtip-cli
|
||||
run: |
|
||||
curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
|
||||
-o /usr/local/bin/glitchtip-cli
|
||||
chmod +x /usr/local/bin/glitchtip-cli
|
||||
|
||||
- name: Inject debug IDs into build artifacts
|
||||
run: glitchtip-cli sourcemaps inject ./build
|
||||
env:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: libnovel-ui
|
||||
|
||||
- name: Upload source maps to GlitchTip
|
||||
run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
|
||||
env:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: libnovel-ui
|
||||
|
||||
# ── docker: ui ────────────────────────────────────────────────────────────────
|
||||
docker-ui:
|
||||
name: Docker / ui
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-ui]
|
||||
needs: [check-ui, upload-sourcemaps]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -261,7 +259,7 @@ jobs:
|
||||
release:
|
||||
name: Gitea Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker-backend, docker-runner, docker-ui, docker-caddy]
|
||||
needs: [docker-backend, docker-runner, docker-ui, docker-caddy, upload-sourcemaps]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
---
|
||||
name: ios-ux
|
||||
description: iOS/SwiftUI UI & UX review and implementation guidelines for LibNovel. Enforces Apple HIG, iOS 17+ APIs, spring animations, haptics, accessibility, performance, and offline handling. Load this skill for any iOS view work.
|
||||
compatibility: opencode
|
||||
---
|
||||
|
||||
# iOS UI/UX Skill — LibNovel
|
||||
|
||||
Load this skill whenever working on SwiftUI views in `ios/`. It defines design standards, review process for screenshots, and implementation rules.
|
||||
|
||||
---
|
||||
|
||||
## Screenshot Review Process
|
||||
|
||||
When the user provides a screenshot of the app:
|
||||
|
||||
1. **Analyze first** — identify specific UI/UX issues across these categories:
|
||||
- Visual hierarchy and spacing
|
||||
- Typography (size, weight, contrast)
|
||||
- Color and material usage
|
||||
- Animation and interactivity gaps
|
||||
- Accessibility problems
|
||||
- Deprecated or non-native patterns
|
||||
2. **Present a numbered list** of suggested improvements with brief rationale for each.
|
||||
3. **Ask for confirmation** before writing any code: "Should I apply all of these, or only specific ones?"
|
||||
4. Apply only what the user confirms.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
### Colors & Materials
|
||||
- **Accent**: `Color.amber` (project-defined). Use for active state, selection indicators, progress fills, and CTAs.
|
||||
- **Backgrounds**: Prefer `.regularMaterial`, `.ultraThinMaterial`, or `.thinMaterial` over hard-coded `Color.black.opacity(x)` or `Color(.systemBackground)`.
|
||||
- **Dark overlays** (e.g. full-screen players): Use `KFImage` blurred background + `Color.black.opacity(0.5–0.6)` overlay. Never use a flat solid black background.
|
||||
- **Semantic colors**: Use `.primary`, `.secondary`, `.tertiary` foreground styles. Avoid hard-coded `Color.white` except on dark material contexts (full-screen player).
|
||||
- **No hardcoded color literals** — use `Color+App.swift` extensions or system semantic colors.
|
||||
|
||||
### Typography
|
||||
- Use the SF Pro system font via `.font(.title)`, `.font(.body)`, etc. — never hardcode font names except for intentional stylistic accents (e.g. "Snell Roundhand" for voice watermark).
|
||||
- Apply `.fontWeight()` and `.fontDesign()` modifiers rather than custom font families.
|
||||
- Support Dynamic Type — never hardcode a fixed font size as the sole option without a `.minimumScaleFactor` or system font size modifier.
|
||||
- Hierarchy: title3.bold for primary labels, subheadline for secondary, caption/caption2 for metadata.
|
||||
|
||||
### Spacing & Layout
|
||||
- Minimum touch target: **44×44 pt**. Use `.frame(minWidth: 44, minHeight: 44)` or `.contentShape(Rectangle())` on small icons.
|
||||
- Prefer 16–20 pt horizontal padding on full-width containers; 12 pt for compact inner elements.
|
||||
- Use `VStack(spacing:)` and `HStack(spacing:)` explicitly — never rely on default spacing for production UI.
|
||||
- Corner radii: 12–14 pt for cards/chips, 10 pt for small badges, 20–24 pt for large cover art.
|
||||
|
||||
---
|
||||
|
||||
## Animation Rules
|
||||
|
||||
### Spring Animations (default for all interactive transitions)
|
||||
- Use `.spring(response:dampingFraction:)` for state-driven layout changes, selection feedback, and appear/disappear transitions.
|
||||
- Recommended defaults:
|
||||
- Interactive elements: `response: 0.3, dampingFraction: 0.7`
|
||||
- Entrance animations: `response: 0.45–0.5, dampingFraction: 0.7`
|
||||
- Quick snappy feedback: `response: 0.2, dampingFraction: 0.6`
|
||||
- Reserve `.easeInOut` only for non-interactive, ambient animations (e.g. opacity pulses, generating overlays).
|
||||
|
||||
### SF Symbol Transitions
|
||||
- Always use `contentTransition(.symbolEffect(.replace.downUp))` when a symbol name changes based on state (play/pause, checkmark/circle, etc.).
|
||||
- Use `.symbolEffect(.variableColor.cumulative)` for continuous animations (waveform, loading indicators).
|
||||
- Use `.symbolEffect(.bounce)` for one-shot entrance emphasis (e.g. completion checkmark appearing).
|
||||
- Use `.symbolEffect(.pulse)` for error/warning states that need attention.
|
||||
|
||||
### Repeating Animations
|
||||
- Use `phaseAnimator` for any looping animation that previously used manual `@State` + `withAnimation` chains.
|
||||
- Do not use `Timer` publishers for UI animation — prefer `phaseAnimator` or `TimelineView`.
|
||||
|
||||
---
|
||||
|
||||
## Haptic Feedback
|
||||
|
||||
Add `UIImpactFeedbackGenerator` to every user-initiated interactive control:
|
||||
- `.light` — toggle switches, selection chips, secondary actions, slider drag start.
|
||||
- `.medium` — primary transport buttons (play/pause, chapter skip), significant confirmations.
|
||||
- `.heavy` — destructive actions (only if no confirmation dialog).
|
||||
|
||||
Pattern:
|
||||
```swift
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
// action
|
||||
} label: { ... }
|
||||
```
|
||||
|
||||
Do **not** add haptics to:
|
||||
- Programmatic state changes not directly triggered by a tap.
|
||||
- Buttons inside `List` rows that already use swipe actions.
|
||||
- Scroll events.
|
||||
|
||||
---
|
||||
|
||||
## iOS 17+ API Usage
|
||||
|
||||
Flag and replace any of the following deprecated patterns:
|
||||
|
||||
| Deprecated | Replace with |
|
||||
|---|---|
|
||||
| `NavigationView` | `NavigationStack` |
|
||||
| `@StateObject` / `ObservableObject` (new types only) | `@Observable` macro |
|
||||
| `DispatchQueue.main.async` | `await MainActor.run` or `@MainActor` |
|
||||
| Manual `@State` animation chains for repeating loops | `phaseAnimator` |
|
||||
| `.animation(_:)` without `value:` | `.animation(_:value:)` |
|
||||
| `AnyView` wrapping for conditional content | `@ViewBuilder` + `Group` |
|
||||
|
||||
Do **not** refactor existing `ObservableObject` types to `@Observable` unless explicitly asked — only apply `@Observable` to new types.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
Every view must:
|
||||
- Support VoiceOver: add `.accessibilityLabel()` to icon-only buttons and image views.
|
||||
- Support Dynamic Type: test that text doesn't truncate at xxxLarge without a layout adjustment.
|
||||
- Meet contrast ratio: text on tinted backgrounds must be legible — avoid `.opacity(0.25)` or lower for any user-readable text.
|
||||
- Touch targets ≥ 44pt (see Spacing above).
|
||||
- Interactive controls must have `.accessibilityAddTraits(.isButton)` if not using `Button`.
|
||||
- Do not rely solely on color to convey state — pair color with icon or label.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- **Isolate high-frequency observers**: Any view that observes a `PlaybackProgress` (timer-tick updates) must be a separate sub-view that `@ObservedObject`-observes only the progress object — not the parent view. This prevents the entire parent from re-rendering every 0.5 seconds.
|
||||
- **Avoid `id()` overuse**: Only use `.id()` to force view recreation when necessary (e.g. background image on track change). Prefer `onChange(of:)` for side effects.
|
||||
- **Lazy containers**: Use `LazyVStack` / `LazyHStack` inside `ScrollView` for lists of 20+ items. `List` is inherently lazy and does not need this.
|
||||
- **Image loading**: Always use `KFImage` (Kingfisher) with `.placeholder` for remote images. Never use `AsyncImage` for cover art — it has no disk cache.
|
||||
- **Avoid `AnyView`**: It breaks structural identity and hurts diffing. Use `@ViewBuilder` or `Group { }` instead.
|
||||
|
||||
---
|
||||
|
||||
## Offline & Error States
|
||||
|
||||
Every view that makes network calls must:
|
||||
1. Wrap the body in a `VStack` with `OfflineBanner` at the top, gated on `networkMonitor.isConnected`.
|
||||
2. Suppress network errors silently when offline via `ErrorAlertModifier` — do not show an alert when the device is offline.
|
||||
3. Gate `.task` / `.onAppear` network calls: `guard networkMonitor.isConnected else { return }`.
|
||||
4. Show a non-blocking inline empty state (not a full-screen error) for failed loads when online.
|
||||
|
||||
---
|
||||
|
||||
## Component Checklist (before submitting any view change)
|
||||
|
||||
- [ ] All interactive elements ≥ 44pt touch target
|
||||
- [ ] SF Symbol state changes use `contentTransition(.symbolEffect(...))`
|
||||
- [ ] State-driven layout transitions use `.spring(response:dampingFraction:)`
|
||||
- [ ] Tappable controls have haptic feedback
|
||||
- [ ] No `NavigationView`, no `DispatchQueue.main.async`, no `.animation(_:)` without `value:`
|
||||
- [ ] High-frequency observers are isolated sub-views
|
||||
- [ ] Offline state handled with `OfflineBanner` + `NetworkMonitor`
|
||||
- [ ] VoiceOver labels on icon-only buttons
|
||||
- [ ] No hardcoded `Color.black` / `Color.white` / `Color(.systemBackground)` where a material applies
|
||||
728
backend/internal/backend/handlers_catalogue.go
Normal file
728
backend/internal/backend/handlers_catalogue.go
Normal file
@@ -0,0 +1,728 @@
|
||||
package backend
|
||||
|
||||
// Catalogue enrichment handlers: tagline, genre tagging, content warnings,
|
||||
// quality scoring, batch cover regeneration, and per-book metadata refresh.
|
||||
//
|
||||
// All generation endpoints are admin-only (enforced by the SvelteKit proxy layer).
|
||||
// All long-running operations support cancellation via r.Context().Done().
|
||||
// Batch operations use an in-memory cancel registry (cancelJobs map) so the
|
||||
// frontend can send a cancel request by job ID.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// ── Cancel registry ────────────────────────────────────────────────────────
|
||||
|
||||
// cancelJobsMu guards cancelJobs.
|
||||
var cancelJobsMu sync.Mutex
|
||||
|
||||
// cancelJobs maps a job ID to its CancelFunc. Entries are added when a batch
|
||||
// job starts and removed when it finishes or is cancelled.
|
||||
var cancelJobs = map[string]context.CancelFunc{}
|
||||
|
||||
func registerCancelJob(id string, cancel context.CancelFunc) {
|
||||
cancelJobsMu.Lock()
|
||||
cancelJobs[id] = cancel
|
||||
cancelJobsMu.Unlock()
|
||||
}
|
||||
|
||||
func deregisterCancelJob(id string) {
|
||||
cancelJobsMu.Lock()
|
||||
delete(cancelJobs, id)
|
||||
cancelJobsMu.Unlock()
|
||||
}
|
||||
|
||||
// ── Tagline ───────────────────────────────────────────────────────────────
|
||||
|
||||
// textGenTaglineRequest is the JSON body for POST /api/admin/text-gen/tagline.
|
||||
type textGenTaglineRequest struct {
|
||||
Slug string `json:"slug"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenTaglineResponse is returned by POST /api/admin/text-gen/tagline.
|
||||
type textGenTaglineResponse struct {
|
||||
OldTagline string `json:"old_tagline"`
|
||||
NewTagline string `json:"new_tagline"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenTagline handles POST /api/admin/text-gen/tagline.
|
||||
// Generates a 1-sentence marketing hook for a book.
|
||||
func (s *Server) handleAdminTextGenTagline(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenTaglineRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
|
||||
system := `You are a copywriter for a web novel platform. ` +
|
||||
`Given a book's title, genres, and description, write a single punchy tagline ` +
|
||||
`(one sentence, under 20 words) that hooks a reader. ` +
|
||||
`Output ONLY the tagline — no quotes, no labels, no explanation.`
|
||||
|
||||
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
|
||||
meta.Title,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
meta.Summary,
|
||||
)
|
||||
|
||||
s.deps.Log.Info("admin: text-gen tagline requested", "slug", req.Slug, "model", model)
|
||||
|
||||
result, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
|
||||
MaxTokens: 64,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen tagline failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, textGenTaglineResponse{
|
||||
OldTagline: "", // BookMeta has no tagline field yet — always empty
|
||||
NewTagline: strings.TrimSpace(result),
|
||||
Model: string(model),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Genres ────────────────────────────────────────────────────────────────
|
||||
|
||||
// textGenGenresRequest is the JSON body for POST /api/admin/text-gen/genres.
|
||||
type textGenGenresRequest struct {
|
||||
Slug string `json:"slug"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenGenresResponse is returned by POST /api/admin/text-gen/genres.
|
||||
type textGenGenresResponse struct {
|
||||
CurrentGenres []string `json:"current_genres"`
|
||||
ProposedGenres []string `json:"proposed_genres"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenGenres handles POST /api/admin/text-gen/genres.
|
||||
// Suggests a refined genre list based on the book's description.
|
||||
func (s *Server) handleAdminTextGenGenres(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenGenresRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
|
||||
system := `You are a genre classification expert for a web novel platform. ` +
|
||||
`Given a book's title and description, return a JSON array of 2–6 genre tags. ` +
|
||||
`Use only well-known web novel genres such as: ` +
|
||||
`Action, Adventure, Comedy, Drama, Fantasy, Historical, Horror, Isekai, Josei, ` +
|
||||
`Martial Arts, Mature, Mecha, Mystery, Psychological, Romance, School Life, ` +
|
||||
`Sci-fi, Seinen, Shoujo, Shounen, Slice of Life, Supernatural, System, Tragedy, Wuxia, Xianxia. ` +
|
||||
`Output ONLY a raw JSON array of strings — no prose, no markdown, no explanation. ` +
|
||||
`Example: ["Fantasy","Adventure","Action"]`
|
||||
|
||||
user := fmt.Sprintf("Title: %s\nCurrent genres: %s\n\nDescription:\n%s",
|
||||
meta.Title,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
meta.Summary,
|
||||
)
|
||||
|
||||
s.deps.Log.Info("admin: text-gen genres requested", "slug", req.Slug, "model", model)
|
||||
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
|
||||
MaxTokens: 128,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen genres failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
proposed := parseStringArrayJSON(raw)
|
||||
|
||||
writeJSON(w, 0, textGenGenresResponse{
|
||||
CurrentGenres: meta.Genres,
|
||||
ProposedGenres: proposed,
|
||||
Model: string(model),
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminTextGenApplyGenres handles POST /api/admin/text-gen/genres/apply.
|
||||
// Persists the confirmed genre list to PocketBase.
|
||||
func (s *Server) handleAdminTextGenApplyGenres(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.BookWriter == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Slug string `json:"slug"`
|
||||
Genres []string `json:"genres"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
meta.Genres = req.Genres
|
||||
if err := s.deps.BookWriter.WriteMetadata(r.Context(), meta); err != nil {
|
||||
s.deps.Log.Error("admin: apply genres failed", "slug", req.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "write metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: genres applied", "slug", req.Slug, "genres", req.Genres)
|
||||
writeJSON(w, 0, map[string]any{"updated": true})
|
||||
}
|
||||
|
||||
// ── Content warnings ──────────────────────────────────────────────────────
|
||||
|
||||
// textGenContentWarningsRequest is the JSON body for POST /api/admin/text-gen/content-warnings.
|
||||
type textGenContentWarningsRequest struct {
|
||||
Slug string `json:"slug"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenContentWarningsResponse is returned by POST /api/admin/text-gen/content-warnings.
|
||||
type textGenContentWarningsResponse struct {
|
||||
Warnings []string `json:"warnings"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenContentWarnings handles POST /api/admin/text-gen/content-warnings.
|
||||
// Detects mature or sensitive themes in a book's description.
|
||||
func (s *Server) handleAdminTextGenContentWarnings(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenContentWarningsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
|
||||
system := `You are a content moderation assistant for a web novel platform. ` +
|
||||
`Given a book's title, genres, and description, detect any content warnings that should be shown to readers. ` +
|
||||
`Choose only relevant warnings from: Violence, Strong Language, Sexual Content, Mature Themes, ` +
|
||||
`Dark Themes, Gore, Torture, Abuse, Drug Use, Suicide/Self-Harm. ` +
|
||||
`If the book is clean, return an empty array. ` +
|
||||
`Output ONLY a raw JSON array of strings — no prose, no markdown. ` +
|
||||
`Example: ["Violence","Dark Themes"]`
|
||||
|
||||
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
|
||||
meta.Title,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
meta.Summary,
|
||||
)
|
||||
|
||||
s.deps.Log.Info("admin: text-gen content-warnings requested", "slug", req.Slug, "model", model)
|
||||
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
|
||||
MaxTokens: 128,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen content-warnings failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
warnings := parseStringArrayJSON(raw)
|
||||
|
||||
writeJSON(w, 0, textGenContentWarningsResponse{
|
||||
Warnings: warnings,
|
||||
Model: string(model),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Quality score ─────────────────────────────────────────────────────────
|
||||
|
||||
// textGenQualityScoreRequest is the JSON body for POST /api/admin/text-gen/quality-score.
|
||||
type textGenQualityScoreRequest struct {
|
||||
Slug string `json:"slug"`
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenQualityScoreResponse is returned by POST /api/admin/text-gen/quality-score.
|
||||
type textGenQualityScoreResponse struct {
|
||||
Score int `json:"score"` // 1–5
|
||||
Feedback string `json:"feedback"` // brief reasoning
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenQualityScore handles POST /api/admin/text-gen/quality-score.
|
||||
// Rates the book description quality on a 1–5 scale with brief feedback.
|
||||
func (s *Server) handleAdminTextGenQualityScore(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenQualityScoreRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
|
||||
system := `You are a book description quality reviewer for a web novel platform. ` +
|
||||
`Rate the provided description on a scale of 1–5 where: ` +
|
||||
`1=poor (vague/too short), 2=below average, 3=average, 4=good, 5=excellent (engaging/detailed). ` +
|
||||
`Respond with ONLY a JSON object: {"score": <1-5>, "feedback": "<one sentence explanation>"}. ` +
|
||||
`No markdown, no extra text.`
|
||||
|
||||
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
|
||||
meta.Title,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
meta.Summary,
|
||||
)
|
||||
|
||||
s.deps.Log.Info("admin: text-gen quality-score requested", "slug", req.Slug, "model", model)
|
||||
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
|
||||
MaxTokens: 128,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen quality-score failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Score int `json:"score"`
|
||||
Feedback string `json:"feedback"`
|
||||
}
|
||||
// Strip markdown fences if any.
|
||||
clean := extractJSONObject(raw)
|
||||
if err := json.Unmarshal([]byte(clean), &parsed); err != nil {
|
||||
// Fallback: try to extract a digit.
|
||||
parsed.Score = 0
|
||||
for _, ch := range raw {
|
||||
if ch >= '1' && ch <= '5' {
|
||||
parsed.Score = int(ch - '0')
|
||||
break
|
||||
}
|
||||
}
|
||||
parsed.Feedback = strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
writeJSON(w, 0, textGenQualityScoreResponse{
|
||||
Score: parsed.Score,
|
||||
Feedback: parsed.Feedback,
|
||||
Model: string(model),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Batch cover regeneration ──────────────────────────────────────────────
|
||||
|
||||
// batchCoverEvent is one SSE event emitted during batch cover regeneration.
|
||||
type batchCoverEvent struct {
|
||||
// JobID is the opaque identifier clients use to cancel this job.
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
Done int `json:"done"`
|
||||
Total int `json:"total"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
Finish bool `json:"finish,omitempty"`
|
||||
}
|
||||
|
||||
// handleAdminBatchCovers handles POST /api/admin/catalogue/batch-covers.
|
||||
//
|
||||
// Streams SSE events as it generates covers for every book that has no cover
|
||||
// stored in MinIO. Each event carries progress info. The final event has Finish=true.
|
||||
//
|
||||
// The job can be cancelled by calling POST /api/admin/catalogue/batch-covers/cancel
|
||||
// with body {"job_id":"..."}.
|
||||
func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil || s.deps.ImageGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "image/text generation not configured")
|
||||
return
|
||||
}
|
||||
if s.deps.CoverStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "cover store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody struct {
|
||||
Model string `json:"model"`
|
||||
NumSteps int `json:"num_steps"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
// Body is optional — defaults used if absent.
|
||||
json.NewDecoder(r.Body).Decode(&reqBody) //nolint:errcheck
|
||||
|
||||
books, err := s.deps.BookReader.ListBooks(r.Context())
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "list books: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a unique job ID.
|
||||
jobID := randomHex(8)
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
registerCancelJob(jobID, cancel)
|
||||
defer deregisterCancelJob(jobID)
|
||||
defer cancel()
|
||||
|
||||
// SSE headers.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
sseWrite := func(evt batchCoverEvent) {
|
||||
b, _ := json.Marshal(evt)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
total := len(books)
|
||||
done := 0
|
||||
|
||||
// Send initial event with jobID so frontend can store it for cancellation.
|
||||
sseWrite(batchCoverEvent{JobID: jobID, Done: 0, Total: total})
|
||||
|
||||
for _, book := range books {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Check if cover already exists.
|
||||
hasCover := s.deps.CoverStore.CoverExists(ctx, book.Slug)
|
||||
if hasCover {
|
||||
done++
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Skipped: true})
|
||||
continue
|
||||
}
|
||||
|
||||
// Build a prompt from the book metadata.
|
||||
prompt := buildCoverPrompt(book)
|
||||
|
||||
// Generate the image via CF AI.
|
||||
imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{
|
||||
Prompt: prompt,
|
||||
NumSteps: reqBody.NumSteps,
|
||||
Width: reqBody.Width,
|
||||
Height: reqBody.Height,
|
||||
})
|
||||
if genErr != nil {
|
||||
done++
|
||||
s.deps.Log.Error("batch-covers: image gen failed", "slug", book.Slug, "err", genErr)
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: genErr.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
// Save to CoverStore.
|
||||
if saveErr := s.deps.CoverStore.PutCover(ctx, book.Slug, imgBytes, "image/png"); saveErr != nil {
|
||||
done++
|
||||
s.deps.Log.Error("batch-covers: save failed", "slug", book.Slug, "err", saveErr)
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: saveErr.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
done++
|
||||
s.deps.Log.Info("batch-covers: cover generated", "slug", book.Slug)
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug})
|
||||
}
|
||||
|
||||
sseWrite(batchCoverEvent{Done: done, Total: total, Finish: true})
|
||||
}
|
||||
|
||||
// handleAdminBatchCoversCancel handles POST /api/admin/catalogue/batch-covers/cancel.
|
||||
// Cancels an in-progress batch cover job by its job ID.
|
||||
func (s *Server) handleAdminBatchCoversCancel(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
JobID string `json:"job_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.JobID == "" {
|
||||
jsonError(w, http.StatusBadRequest, "job_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
cancelJobsMu.Lock()
|
||||
cancel, ok := cancelJobs[req.JobID]
|
||||
cancelJobsMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("job %q not found", req.JobID))
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
s.deps.Log.Info("batch-covers: job cancelled", "job_id", req.JobID)
|
||||
writeJSON(w, 0, map[string]any{"cancelled": true})
|
||||
}
|
||||
|
||||
// ── Refresh metadata (per-book) ────────────────────────────────────────────
|
||||
|
||||
// refreshMetadataEvent is one SSE event during per-book metadata refresh.
|
||||
type refreshMetadataEvent struct {
|
||||
Step string `json:"step"` // "description" | "tagline" | "cover"
|
||||
Done bool `json:"done"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// handleAdminRefreshMetadata handles POST /api/admin/catalogue/refresh-metadata/{slug}.
|
||||
//
|
||||
// Runs description → tagline → cover generation in sequence for a single book
|
||||
// and streams SSE progress. Interruptable via client disconnect (r.Context()).
|
||||
func (s *Server) handleAdminRefreshMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", slug))
|
||||
return
|
||||
}
|
||||
|
||||
// SSE headers.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
sseWrite := func(evt refreshMetadataEvent) {
|
||||
b, _ := json.Marshal(evt)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Step 1 — description.
|
||||
if s.deps.TextGen != nil {
|
||||
if ctx.Err() == nil {
|
||||
newDesc, genErr := s.deps.TextGen.Generate(ctx, cfai.TextRequest{
|
||||
Model: cfai.DefaultTextModel,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: `You are a book description writer for a web novel platform. Write an improved description. Respond with ONLY the new description text — no title, no labels, no markdown.`},
|
||||
{Role: "user", Content: fmt.Sprintf("Title: %s\nGenres: %s\n\nCurrent description:\n%s\n\nInstructions: Write a compelling 2–4 sentence description. Keep it spoiler-free and engaging.", meta.Title, strings.Join(meta.Genres, ", "), meta.Summary)},
|
||||
},
|
||||
MaxTokens: 512,
|
||||
})
|
||||
if genErr == nil && strings.TrimSpace(newDesc) != "" && s.deps.BookWriter != nil {
|
||||
meta.Summary = strings.TrimSpace(newDesc)
|
||||
if writeErr := s.deps.BookWriter.WriteMetadata(ctx, meta); writeErr != nil {
|
||||
sseWrite(refreshMetadataEvent{Step: "description", Error: writeErr.Error()})
|
||||
} else {
|
||||
sseWrite(refreshMetadataEvent{Step: "description"})
|
||||
}
|
||||
} else if genErr != nil {
|
||||
sseWrite(refreshMetadataEvent{Step: "description", Error: genErr.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2 — cover.
|
||||
if s.deps.ImageGen != nil && s.deps.CoverStore != nil {
|
||||
if ctx.Err() == nil {
|
||||
prompt := buildCoverPrompt(meta)
|
||||
imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{Prompt: prompt})
|
||||
if genErr == nil {
|
||||
if saveErr := s.deps.CoverStore.PutCover(ctx, slug, imgBytes, "image/png"); saveErr != nil {
|
||||
sseWrite(refreshMetadataEvent{Step: "cover", Error: saveErr.Error()})
|
||||
} else {
|
||||
sseWrite(refreshMetadataEvent{Step: "cover"})
|
||||
}
|
||||
} else {
|
||||
sseWrite(refreshMetadataEvent{Step: "cover", Error: genErr.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sseWrite(refreshMetadataEvent{Step: "done", Done: true})
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
// parseStringArrayJSON extracts a JSON string array from model output,
|
||||
// tolerating markdown fences and surrounding prose.
|
||||
func parseStringArrayJSON(raw string) []string {
|
||||
s := raw
|
||||
if idx := strings.Index(s, "```json"); idx >= 0 {
|
||||
s = s[idx+7:]
|
||||
} else if idx := strings.Index(s, "```"); idx >= 0 {
|
||||
s = s[idx+3:]
|
||||
}
|
||||
if idx := strings.LastIndex(s, "```"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
start := strings.Index(s, "[")
|
||||
end := strings.LastIndex(s, "]")
|
||||
if start < 0 || end <= start {
|
||||
return nil
|
||||
}
|
||||
s = s[start : end+1]
|
||||
var out []string
|
||||
json.Unmarshal([]byte(s), &out) //nolint:errcheck
|
||||
return out
|
||||
}
|
||||
|
||||
// extractJSONObject finds the first {...} object in a string.
|
||||
func extractJSONObject(raw string) string {
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start < 0 || end <= start {
|
||||
return raw
|
||||
}
|
||||
return raw[start : end+1]
|
||||
}
|
||||
|
||||
// buildCoverPrompt constructs a prompt string for cover generation from a book.
|
||||
func buildCoverPrompt(meta domain.BookMeta) string {
|
||||
parts := []string{"book cover art"}
|
||||
if meta.Title != "" {
|
||||
parts = append(parts, "titled \""+meta.Title+"\"")
|
||||
}
|
||||
if len(meta.Genres) > 0 {
|
||||
parts = append(parts, strings.Join(meta.Genres, ", ")+" genre")
|
||||
}
|
||||
if meta.Summary != "" {
|
||||
summary := meta.Summary
|
||||
if len(summary) > 200 {
|
||||
summary = summary[:200]
|
||||
}
|
||||
parts = append(parts, summary)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// randomHex returns a random hex string of n bytes.
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// chapterNamesBatchSize is the number of chapters sent per LLM request.
|
||||
// Keeps output well within the 4096-token response limit (~30 tokens/title).
|
||||
const chapterNamesBatchSize = 100
|
||||
|
||||
// handleAdminTextGenModels handles GET /api/admin/text-gen/models.
|
||||
// Returns the list of supported Cloudflare AI text generation models.
|
||||
func (s *Server) handleAdminTextGenModels(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -36,16 +40,6 @@ type textGenChapterNamesRequest struct {
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenChapterNamesResponse is the JSON body returned by POST /api/admin/text-gen/chapter-names.
|
||||
type textGenChapterNamesResponse struct {
|
||||
// Chapters is the list of proposed chapter titles, indexed by chapter number.
|
||||
Chapters []proposedChapterTitle `json:"chapters"`
|
||||
// Model is the model that was used.
|
||||
Model string `json:"model"`
|
||||
// RawResponse is the raw model output for debugging / manual editing.
|
||||
RawResponse string `json:"raw_response"`
|
||||
}
|
||||
|
||||
// proposedChapterTitle is a single chapter with its AI-proposed title.
|
||||
type proposedChapterTitle struct {
|
||||
Number int `json:"number"`
|
||||
@@ -55,12 +49,35 @@ type proposedChapterTitle struct {
|
||||
NewTitle string `json:"new_title"`
|
||||
}
|
||||
|
||||
// chapterNamesBatchEvent is one SSE event emitted per processed batch.
|
||||
type chapterNamesBatchEvent struct {
|
||||
// Batch is the 1-based batch index.
|
||||
Batch int `json:"batch"`
|
||||
// TotalBatches is the total number of batches.
|
||||
TotalBatches int `json:"total_batches"`
|
||||
// ChaptersDone is the cumulative count of chapters processed so far.
|
||||
ChaptersDone int `json:"chapters_done"`
|
||||
// TotalChapters is the total chapter count for this book.
|
||||
TotalChapters int `json:"total_chapters"`
|
||||
// Model is the CF AI model used.
|
||||
Model string `json:"model"`
|
||||
// Chapters contains the proposed titles for this batch.
|
||||
Chapters []proposedChapterTitle `json:"chapters"`
|
||||
// Error is non-empty if this batch failed.
|
||||
Error string `json:"error,omitempty"`
|
||||
// Done is true on the final sentinel event (no Chapters).
|
||||
Done bool `json:"done,omitempty"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenChapterNames handles POST /api/admin/text-gen/chapter-names.
|
||||
//
|
||||
// Reads all chapter titles for the given slug, sends them to the LLM with the
|
||||
// requested naming pattern, and returns proposed replacements. Does NOT persist
|
||||
// anything — the frontend shows a diff and the user must confirm via
|
||||
// POST /api/admin/text-gen/chapter-names/apply.
|
||||
// Splits all chapters into batches of chapterNamesBatchSize, sends each batch
|
||||
// to the LLM sequentially, and streams results back as Server-Sent Events so
|
||||
// the frontend can show live progress. Each SSE data line is a JSON-encoded
|
||||
// chapterNamesBatchEvent. The final event has Done=true.
|
||||
//
|
||||
// Does NOT persist anything — the frontend shows a diff and the user must
|
||||
// confirm via POST /api/admin/text-gen/chapter-names/apply.
|
||||
func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
|
||||
@@ -92,11 +109,29 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
// Build the prompt.
|
||||
var chapterListSB strings.Builder
|
||||
for _, ch := range chapters {
|
||||
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
// 4096 tokens comfortably fits 100 chapter titles (~30 tokens each).
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 4096
|
||||
}
|
||||
|
||||
// Index existing titles for old/new diff.
|
||||
existing := make(map[int]string, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
existing[ch.Number] = ch.Title
|
||||
}
|
||||
|
||||
// Partition chapters into batches.
|
||||
batches := chunkChapters(chapters, chapterNamesBatchSize)
|
||||
totalBatches := len(batches)
|
||||
|
||||
s.deps.Log.Info("admin: text-gen chapter-names requested",
|
||||
"slug", req.Slug, "chapters", len(chapters),
|
||||
"batches", totalBatches, "model", model, "max_tokens", maxTokens)
|
||||
|
||||
systemPrompt := `You are a chapter title editor for a web novel platform. ` +
|
||||
`The user provides a list of chapter numbers with their current titles, ` +
|
||||
@@ -111,64 +146,91 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
`5. Each element: {"number": <int>, "title": <string>}. ` +
|
||||
`6. Output every chapter in the input list, in order. Do not skip any.`
|
||||
|
||||
userPrompt := fmt.Sprintf(
|
||||
"Naming pattern: %s\n\nChapters:\n%s",
|
||||
req.Pattern,
|
||||
chapterListSB.String(),
|
||||
)
|
||||
// Switch to SSE before writing anything.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
sseWrite := func(evt chapterNamesBatchEvent) {
|
||||
b, _ := json.Marshal(evt)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 4096 tokens so large chapter lists are not truncated.
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 4096
|
||||
}
|
||||
chaptersDone := 0
|
||||
for i, batch := range batches {
|
||||
if r.Context().Err() != nil {
|
||||
return // client disconnected
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: text-gen chapter-names requested",
|
||||
"slug", req.Slug, "chapters", len(chapters), "model", model, "max_tokens", maxTokens)
|
||||
var chapterListSB strings.Builder
|
||||
for _, ch := range batch {
|
||||
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
|
||||
}
|
||||
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", req.Pattern, chapterListSB.String())
|
||||
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
MaxTokens: maxTokens,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen chapter-names failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
MaxTokens: maxTokens,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen chapter-names batch failed",
|
||||
"batch", i+1, "err", genErr)
|
||||
sseWrite(chapterNamesBatchEvent{
|
||||
Batch: i + 1,
|
||||
TotalBatches: totalBatches,
|
||||
ChaptersDone: chaptersDone,
|
||||
TotalChapters: len(chapters),
|
||||
Model: string(model),
|
||||
Error: genErr.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the JSON array from the model response.
|
||||
proposed := parseChapterTitlesJSON(raw)
|
||||
proposed := parseChapterTitlesJSON(raw)
|
||||
result := make([]proposedChapterTitle, 0, len(proposed))
|
||||
for _, p := range proposed {
|
||||
result = append(result, proposedChapterTitle{
|
||||
Number: p.Number,
|
||||
OldTitle: existing[p.Number],
|
||||
NewTitle: p.Title,
|
||||
})
|
||||
}
|
||||
chaptersDone += len(batch)
|
||||
|
||||
// Build the response: merge proposed titles with old titles.
|
||||
// Index existing chapters by number for O(1) lookup.
|
||||
existing := make(map[int]string, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
existing[ch.Number] = ch.Title
|
||||
}
|
||||
|
||||
result := make([]proposedChapterTitle, 0, len(proposed))
|
||||
for _, p := range proposed {
|
||||
result = append(result, proposedChapterTitle{
|
||||
Number: p.Number,
|
||||
OldTitle: existing[p.Number],
|
||||
NewTitle: p.Title,
|
||||
sseWrite(chapterNamesBatchEvent{
|
||||
Batch: i + 1,
|
||||
TotalBatches: totalBatches,
|
||||
ChaptersDone: chaptersDone,
|
||||
TotalChapters: len(chapters),
|
||||
Model: string(model),
|
||||
Chapters: result,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, 0, textGenChapterNamesResponse{
|
||||
Chapters: result,
|
||||
Model: string(model),
|
||||
RawResponse: raw,
|
||||
})
|
||||
// Final sentinel event.
|
||||
sseWrite(chapterNamesBatchEvent{Done: true, TotalChapters: len(chapters), Model: string(model)})
|
||||
}
|
||||
|
||||
// chunkChapters splits a chapter slice into batches of at most size n.
|
||||
func chunkChapters(chapters []domain.ChapterInfo, n int) [][]domain.ChapterInfo {
|
||||
var batches [][]domain.ChapterInfo
|
||||
for len(chapters) > 0 {
|
||||
end := n
|
||||
if end > len(chapters) {
|
||||
end = len(chapters)
|
||||
}
|
||||
batches = append(batches, chapters[:end])
|
||||
chapters = chapters[end:]
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
// parseChapterTitlesJSON extracts the JSON array from a model response.
|
||||
|
||||
@@ -204,6 +204,16 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||
|
||||
// Admin catalogue enrichment endpoints
|
||||
mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
|
||||
|
||||
// Admin data repair endpoints
|
||||
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
|
||||
|
||||
@@ -39,8 +39,9 @@ func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, err
|
||||
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
func (m *mockStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
// RankingStore
|
||||
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
|
||||
@@ -52,10 +53,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
|
||||
}
|
||||
|
||||
// AudioStore
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,8 +29,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
_ "image/jpeg" // register JPEG decoder
|
||||
_ "image/png" // register PNG decoder
|
||||
"image/png"
|
||||
_ "image/png" // register PNG decoder
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -189,6 +192,11 @@ func (c *imageGenHTTPClient) GenerateImage(ctx context.Context, req ImageRequest
|
||||
return c.callImageAPI(ctx, req.Model, body)
|
||||
}
|
||||
|
||||
// refImageMaxDim is the maximum dimension (width or height) for reference images
|
||||
// sent to Cloudflare Workers AI. CF's JSON body limit is ~4 MB; a 768px JPEG
|
||||
// stays well under that while preserving enough detail for img2img guidance.
|
||||
const refImageMaxDim = 768
|
||||
|
||||
// GenerateImageFromReference generates an image from a text prompt + reference image.
|
||||
func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req ImageRequest, refImage []byte) ([]byte, error) {
|
||||
if len(refImage) == 0 {
|
||||
@@ -196,6 +204,10 @@ func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req
|
||||
}
|
||||
req = applyImageDefaults(req)
|
||||
|
||||
// Shrink the reference image if it exceeds the safe payload size.
|
||||
// This avoids CF's 4 MB JSON body limit and reduces latency.
|
||||
refImage = resizeRefImage(refImage, refImageMaxDim)
|
||||
|
||||
var body map[string]any
|
||||
if req.Model == ImageModelSD15Img2Img {
|
||||
pixels, err := decodeImageToRGBA(refImage)
|
||||
@@ -286,6 +298,60 @@ func applyImageDefaults(req ImageRequest) ImageRequest {
|
||||
return req
|
||||
}
|
||||
|
||||
// resizeRefImage down-scales an image so that its longest side is at most maxDim
|
||||
// pixels, then re-encodes it as JPEG (quality 85). If the image is already small
|
||||
// enough, or if decoding fails, the original bytes are returned unchanged.
|
||||
// This keeps the JSON payload well under Cloudflare Workers AI's 4 MB body limit.
|
||||
func resizeRefImage(data []byte, maxDim int) []byte {
|
||||
src, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
|
||||
longest := w
|
||||
if h > longest {
|
||||
longest = h
|
||||
}
|
||||
if longest <= maxDim {
|
||||
return data // already fits
|
||||
}
|
||||
|
||||
// Compute target dimensions preserving aspect ratio.
|
||||
scale := float64(maxDim) / float64(longest)
|
||||
newW := int(float64(w)*scale + 0.5)
|
||||
newH := int(float64(h)*scale + 0.5)
|
||||
if newW < 1 {
|
||||
newW = 1
|
||||
}
|
||||
if newH < 1 {
|
||||
newH = 1
|
||||
}
|
||||
|
||||
// Nearest-neighbour downsample (no extra deps, sufficient for reference guidance).
|
||||
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
||||
for y := 0; y < newH; y++ {
|
||||
for x := 0; x < newW; x++ {
|
||||
srcX := b.Min.X + int(float64(x)/scale)
|
||||
srcY := b.Min.Y + int(float64(y)/scale)
|
||||
draw.Draw(dst, image.Rect(x, y, x+1, y+1), src, image.Pt(srcX, srcY), draw.Src)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if format == "jpeg" {
|
||||
if encErr := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); encErr != nil {
|
||||
return data
|
||||
}
|
||||
} else {
|
||||
if encErr := png.Encode(&buf, dst); encErr != nil {
|
||||
return data
|
||||
}
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// decodeImageToRGBA decodes PNG/JPEG bytes to a flat []uint8 RGBA pixel array
|
||||
// required by the stable-diffusion-v1-5-img2img model.
|
||||
func decodeImageToRGBA(data []byte) ([]uint8, error) {
|
||||
|
||||
@@ -217,9 +217,11 @@ func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (stri
|
||||
}
|
||||
|
||||
// CF AI wraps responses: { "result": { "response": "..." }, "success": true }
|
||||
// Some models (e.g. Llama 4 Scout) return response as an array:
|
||||
// { "result": { "response": [{"generated_text":"..."}] } }
|
||||
var wrapper struct {
|
||||
Result struct {
|
||||
Response string `json:"response"`
|
||||
Response json.RawMessage `json:"response"`
|
||||
} `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
Errors []string `json:"errors"`
|
||||
@@ -230,7 +232,19 @@ func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (stri
|
||||
if !wrapper.Success {
|
||||
return "", fmt.Errorf("cfai/text: model %s error: %v", req.Model, wrapper.Errors)
|
||||
}
|
||||
return wrapper.Result.Response, nil
|
||||
// Try plain string first.
|
||||
var text string
|
||||
if err := json.Unmarshal(wrapper.Result.Response, &text); err == nil {
|
||||
return text, nil
|
||||
}
|
||||
// Fall back: array of objects with a "generated_text" field.
|
||||
var arr []struct {
|
||||
GeneratedText string `json:"generated_text"`
|
||||
}
|
||||
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 {
|
||||
return arr[0].GeneratedText, nil
|
||||
}
|
||||
return "", fmt.Errorf("cfai/text: model %s: unrecognised response shape: %s", req.Model, wrapper.Result.Response)
|
||||
}
|
||||
|
||||
// Models returns all supported text generation model metadata.
|
||||
|
||||
@@ -89,6 +89,8 @@ func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.Cha
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -94,6 +94,10 @@ func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.Cha
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *stubBookWriter) DeduplicateChapters(_ context.Context, _ string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
|
||||
type stubBookReader struct {
|
||||
text string
|
||||
|
||||
@@ -130,7 +130,14 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
|
||||
// Set created timestamp on first insert so recentlyUpdatedBooks can sort by it.
|
||||
insertPayload := map[string]any{
|
||||
"slug": slug,
|
||||
"number": ref.Number,
|
||||
"title": ref.Title,
|
||||
"created": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", insertPayload, nil)
|
||||
if postErr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ services:
|
||||
KOKORO_URL: "${KOKORO_URL}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
POCKET_TTS_URL: "${POCKET_TTS_URL}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN_BACKEND}"
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "backend"
|
||||
# Asynq task queue — backend enqueues jobs to local Redis sidecar.
|
||||
@@ -249,7 +249,7 @@ services:
|
||||
KOKORO_URL: "${KOKORO_URL}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
POCKET_TTS_URL: "${POCKET_TTS_URL}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "runner"
|
||||
healthcheck:
|
||||
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
|
||||
|
||||
LOG_LEVEL: "${LOG_LEVEL}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
|
||||
|
||||
# OTel — send runner traces/metrics to the local collector (HTTP)
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318"
|
||||
|
||||
@@ -38,6 +38,8 @@ services:
|
||||
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
|
||||
@@ -100,7 +102,7 @@ services:
|
||||
|
||||
# ── Observability ───────────────────────────────────────────────────────
|
||||
LOG_LEVEL: "${LOG_LEVEL}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
|
||||
|
||||
@@ -62,6 +62,39 @@ create() {
|
||||
esac
|
||||
}
|
||||
|
||||
# add_index COLLECTION INDEX_NAME SQL_EXPR
|
||||
# Fetches current schema, adds index if absent by name, PATCHes collection.
|
||||
add_index() {
|
||||
COLL="$1"; INAME="$2"; ISQL="$3"
|
||||
SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null)
|
||||
PARSED=$(echo "$SCHEMA" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
indexes = d.get('indexes', [])
|
||||
exists = any('$INAME' in idx for idx in indexes)
|
||||
print('exists=' + str(exists))
|
||||
print('id=' + d.get('id', ''))
|
||||
if not exists:
|
||||
indexes.append('$ISQL')
|
||||
print('indexes=' + json.dumps(indexes))
|
||||
" 2>/dev/null)
|
||||
if echo "$PARSED" | grep -q "^exists=True"; then
|
||||
log "index exists (skip): $COLL.$INAME"; return
|
||||
fi
|
||||
COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
|
||||
[ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; }
|
||||
NEW_INDEXES=$(echo "$PARSED" | grep "^indexes=" | sed 's/^indexes=//')
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X PATCH "$PB/api/collections/$COLL_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOK" \
|
||||
-d "{\"indexes\":${NEW_INDEXES}}")
|
||||
case "$STATUS" in
|
||||
200|201) log "added index: $COLL.$INAME" ;;
|
||||
*) log "WARNING: add_index $COLL.$INAME returned $STATUS" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# add_field COLLECTION FIELD_NAME FIELD_TYPE
|
||||
# Fetches current schema, appends field if absent, PATCHes collection.
|
||||
# Requires python3 for safe JSON manipulation.
|
||||
@@ -116,9 +149,10 @@ create "books" '{
|
||||
|
||||
create "chapters_idx" '{
|
||||
"name":"chapters_idx","type":"base","fields":[
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"number","type":"number", "required":true},
|
||||
{"name":"title", "type":"text"}
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"number", "type":"number", "required":true},
|
||||
{"name":"title", "type":"text"},
|
||||
{"name":"created", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "ranking" '{
|
||||
@@ -293,5 +327,12 @@ add_field "app_users" "polar_customer_id" "text"
|
||||
add_field "app_users" "polar_subscription_id" "text"
|
||||
add_field "user_library" "shelf" "text"
|
||||
add_field "user_sessions" "device_fingerprint" "text"
|
||||
add_field "chapters_idx" "created" "date"
|
||||
|
||||
# ── 6. Indexes ────────────────────────────────────────────────────────────────
|
||||
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
|
||||
"CREATE UNIQUE INDEX idx_chapters_idx_slug_number ON chapters_idx (slug, number)"
|
||||
add_index "chapters_idx" "idx_chapters_idx_created" \
|
||||
"CREATE INDEX idx_chapters_idx_created ON chapters_idx (created)"
|
||||
|
||||
log "done"
|
||||
|
||||
@@ -402,6 +402,7 @@
|
||||
"admin_nav_changelog": "Changelog",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Errors",
|
||||
"admin_nav_analytics": "Analytics",
|
||||
|
||||
@@ -402,7 +402,7 @@
|
||||
"admin_nav_changelog": "Modifications",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Retours",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_errors": "Erreurs",
|
||||
"admin_nav_analytics": "Analytique",
|
||||
"admin_nav_logs": "Journaux",
|
||||
|
||||
@@ -402,7 +402,7 @@
|
||||
"admin_nav_changelog": "Perubahan",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Masukan",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_errors": "Kesalahan",
|
||||
"admin_nav_analytics": "Analitik",
|
||||
"admin_nav_logs": "Log",
|
||||
|
||||
@@ -402,7 +402,7 @@
|
||||
"admin_nav_changelog": "Alterações",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_errors": "Erros",
|
||||
"admin_nav_analytics": "Análise",
|
||||
"admin_nav_logs": "Logs",
|
||||
|
||||
@@ -402,7 +402,7 @@
|
||||
"admin_nav_changelog": "Изменения",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Отзывы",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_errors": "Ошибки",
|
||||
"admin_nav_analytics": "Аналитика",
|
||||
"admin_nav_logs": "Логи",
|
||||
|
||||
@@ -875,6 +875,7 @@
|
||||
<!-- Seekable progress bar -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="none"
|
||||
class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer group"
|
||||
onclick={seekFromCompactBar}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropKeyDown(e: KeyboardEvent) {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && e.target === e.currentTarget) {
|
||||
open = false;
|
||||
onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
open = false;
|
||||
@@ -34,7 +41,9 @@
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={handleBackdropKeyDown}
|
||||
>
|
||||
<div class={cn('bg-(--color-surface) rounded-2xl border border-(--color-border) shadow-2xl w-full max-w-sm', className)}>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
/* eslint-disable */
|
||||
export * from './messages/_index.js'
|
||||
// enabling auto-import by exposing all messages as m
|
||||
export * as m from './messages/_index.js'
|
||||
export * from './messages/_index.js'
|
||||
@@ -373,6 +373,7 @@ export * from './admin_nav_translation.js'
|
||||
export * from './admin_nav_changelog.js'
|
||||
export * from './admin_nav_image_gen.js'
|
||||
export * from './admin_nav_text_gen.js'
|
||||
export * from './admin_nav_catalogue_tools.js'
|
||||
export * from './admin_nav_feedback.js'
|
||||
export * from './admin_nav_errors.js'
|
||||
export * from './admin_nav_analytics.js'
|
||||
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_catalogue_tools.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_catalogue_tools.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_Catalogue_ToolsInputs */
|
||||
|
||||
const en_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Catalogue Tools`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Catalogue Tools`)
|
||||
};
|
||||
|
||||
const id_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Catalogue Tools`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Catalogue Tools`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Catalogue Tools`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Catalogue Tools" |
|
||||
*
|
||||
* @param {Admin_Nav_Catalogue_ToolsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_catalogue_tools = /** @type {((inputs?: Admin_Nav_Catalogue_ToolsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Catalogue_ToolsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_catalogue_tools(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_catalogue_tools(inputs)
|
||||
if (locale === "id") return id_admin_nav_catalogue_tools(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_catalogue_tools(inputs)
|
||||
return fr_admin_nav_catalogue_tools(inputs)
|
||||
});
|
||||
@@ -9,21 +9,17 @@ const en_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => L
|
||||
return /** @type {LocalizedString} */ (`Feedback`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Отзывы`)
|
||||
};
|
||||
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
|
||||
const ru_admin_nav_feedback = en_admin_nav_feedback;
|
||||
|
||||
const id_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Masukan`)
|
||||
};
|
||||
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
|
||||
const id_admin_nav_feedback = en_admin_nav_feedback;
|
||||
|
||||
const pt_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Feedback`)
|
||||
};
|
||||
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
|
||||
const pt_admin_nav_feedback = en_admin_nav_feedback;
|
||||
|
||||
const fr_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Retours`)
|
||||
};
|
||||
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
|
||||
const fr_admin_nav_feedback = en_admin_nav_feedback;
|
||||
|
||||
/**
|
||||
* | output |
|
||||
|
||||
@@ -28,7 +28,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
theme: row.theme ?? 'amber',
|
||||
locale: row.locale ?? 'en',
|
||||
fontFamily: row.font_family ?? 'system',
|
||||
fontSize: row.font_size ?? 1.0
|
||||
fontSize: row.font_size || 1.0
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -54,8 +54,11 @@
|
||||
let audioEl = $state<HTMLAudioElement | null>(null);
|
||||
|
||||
// ── Theme ──────────────────────────────────────────────────────────────
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentTheme = $state(data.settings?.theme ?? 'amber');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentFontFamily = $state(data.settings?.fontFamily ?? 'system');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentFontSize = $state(data.settings?.fontSize ?? 1.0);
|
||||
|
||||
// Expose theme + font state to child pages (e.g. profile picker)
|
||||
@@ -100,7 +103,7 @@
|
||||
// Always sync theme + font (profile page calls invalidateAll after saving)
|
||||
currentTheme = data.settings.theme ?? 'amber';
|
||||
currentFontFamily = data.settings.fontFamily ?? 'system';
|
||||
currentFontSize = data.settings.fontSize ?? 1.0;
|
||||
currentFontSize = data.settings.fontSize || 1.0;
|
||||
// Mark dirty only after the synchronous apply is done so the save
|
||||
// effect doesn't fire for this initial load.
|
||||
setTimeout(() => { settingsDirty = true; }, 0);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
{ href: '/admin/translation', label: () => m.admin_nav_translation() },
|
||||
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() },
|
||||
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() },
|
||||
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() }
|
||||
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() },
|
||||
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() }
|
||||
];
|
||||
|
||||
const externalLinks = [
|
||||
|
||||
24
ui/src/routes/admin/catalogue-tools/+page.server.ts
Normal file
24
ui/src/routes/admin/catalogue-tools/+page.server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export interface ImageModelInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
let imgModels: ImageModelInfo[] = [];
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
imgModels = (data.models ?? []) as ImageModelInfo[];
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('admin/catalogue-tools', 'failed to load image models', { err: String(e) });
|
||||
}
|
||||
return { imgModels };
|
||||
};
|
||||
258
ui/src/routes/admin/catalogue-tools/+page.svelte
Normal file
258
ui/src/routes/admin/catalogue-tools/+page.svelte
Normal file
@@ -0,0 +1,258 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const imgModels = data.imgModels ?? [];
|
||||
|
||||
// ── Config persistence ────────────────────────────────────────────────────────
|
||||
const CONFIG_KEY = 'admin_catalogue_tools_v1';
|
||||
|
||||
interface SavedConfig {
|
||||
imgModel: string;
|
||||
numSteps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function loadConfig(): Partial<SavedConfig> {
|
||||
if (!browser) return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
function saveConfig() {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify({ imgModel, numSteps, width, height }));
|
||||
}
|
||||
|
||||
const saved = loadConfig();
|
||||
|
||||
let imgModel = $state(saved.imgModel ?? (imgModels[0]?.id ?? ''));
|
||||
let numSteps = $state(saved.numSteps ?? 20);
|
||||
let width = $state(saved.width ?? 0);
|
||||
let height = $state(saved.height ?? 0);
|
||||
|
||||
$effect(() => { void imgModel; void numSteps; void width; void height; saveConfig(); });
|
||||
|
||||
// ── Batch covers ──────────────────────────────────────────────────────────────
|
||||
let running = $state(false);
|
||||
let jobID = $state('');
|
||||
let done = $state(0);
|
||||
let total = $state(0);
|
||||
let events = $state<{ slug: string; skipped?: boolean; error?: string }[]>([]);
|
||||
let finished = $state(false);
|
||||
let error = $state('');
|
||||
let cancelling = $state(false);
|
||||
|
||||
let progress = $derived(total > 0 ? Math.round((done / total) * 100) : 0);
|
||||
|
||||
async function startBatch() {
|
||||
running = true; finished = false; error = ''; done = 0; total = 0; events = []; jobID = ''; cancelling = false;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/catalogue/batch-covers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: imgModel || undefined,
|
||||
num_steps: numSteps || undefined,
|
||||
width: width || undefined,
|
||||
height: height || undefined,
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
error = body.error ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
outer: while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (!payload) continue;
|
||||
let evt: {
|
||||
job_id?: string;
|
||||
done?: number;
|
||||
total?: number;
|
||||
slug?: string;
|
||||
skipped?: boolean;
|
||||
error?: string;
|
||||
finish?: boolean;
|
||||
};
|
||||
try { evt = JSON.parse(payload); } catch { continue; }
|
||||
|
||||
if (evt.job_id) jobID = evt.job_id;
|
||||
if (evt.total != null) total = evt.total;
|
||||
if (evt.done != null) done = evt.done;
|
||||
if (evt.finish) { finished = true; running = false; break outer; }
|
||||
if (evt.slug) {
|
||||
events = [{ slug: evt.slug, skipped: evt.skipped, error: evt.error }, ...events].slice(0, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelBatch() {
|
||||
if (!jobID) return;
|
||||
cancelling = true;
|
||||
try {
|
||||
await fetch('/api/admin/catalogue/batch-covers/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ job_id: jobID })
|
||||
});
|
||||
} catch { /* ignore */ } finally {
|
||||
cancelling = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Catalogue Tools — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-8 max-w-4xl">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Catalogue Tools</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">Bulk AI operations for your book catalogue.</p>
|
||||
</div>
|
||||
|
||||
<!-- Batch cover generation -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">Batch Cover Generation</h2>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
Generates AI covers for every book that has no cover stored in MinIO.
|
||||
Books with existing covers are skipped. The job can be cancelled at any time.
|
||||
</p>
|
||||
|
||||
<!-- Config -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 bg-(--color-surface) border border-(--color-border) rounded-xl p-4">
|
||||
{#if imgModels.length > 0}
|
||||
<div class="col-span-2 sm:col-span-4 space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="img-model">Image model</label>
|
||||
<select id="img-model" bind:value={imgModel}
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)">
|
||||
{#each imgModels as m}
|
||||
<option value={m.id}>{m.label} — {m.provider}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="steps">Steps</label>
|
||||
<input id="steps" type="number" bind:value={numSteps} min="1" max="50"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="width">Width <span class="font-normal">(0=default)</span></label>
|
||||
<input id="width" type="number" bind:value={width} min="0" step="64"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="height">Height <span class="font-normal">(0=default)</span></label>
|
||||
<input id="height" type="number" bind:value={height} min="0" step="64"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={startBatch}
|
||||
disabled={running}
|
||||
class="px-6 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{#if running}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
Running…
|
||||
{:else}
|
||||
Start batch
|
||||
{/if}
|
||||
</button>
|
||||
{#if running && jobID}
|
||||
<button
|
||||
onclick={cancelBatch}
|
||||
disabled={cancelling}
|
||||
class="px-5 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) text-sm
|
||||
hover:text-(--color-text) transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelling ? 'Cancelling…' : 'Cancel'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Progress -->
|
||||
{#if total > 0 || running}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-(--color-muted)">
|
||||
{done} / {total} books processed
|
||||
{#if finished} — done{/if}
|
||||
</span>
|
||||
<span class="text-(--color-muted)">{progress}%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-(--color-surface-2) rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300 {finished ? 'bg-green-500' : 'bg-(--color-brand)'}"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Event log -->
|
||||
{#if events.length > 0}
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-(--color-border)">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Activity log (newest first)</p>
|
||||
</div>
|
||||
<div class="max-h-80 overflow-y-auto divide-y divide-(--color-border)">
|
||||
{#each events as evt}
|
||||
<div class="px-4 py-2 flex items-center gap-3 text-sm">
|
||||
{#if evt.error}
|
||||
<span class="w-2 h-2 rounded-full bg-(--color-danger) shrink-0"></span>
|
||||
<span class="font-mono text-(--color-muted)">{evt.slug}</span>
|
||||
<span class="text-(--color-danger) text-xs truncate">{evt.error}</span>
|
||||
{:else if evt.skipped}
|
||||
<span class="w-2 h-2 rounded-full bg-(--color-surface-2) shrink-0"></span>
|
||||
<span class="font-mono text-(--color-muted)">{evt.slug}</span>
|
||||
<span class="text-xs text-(--color-muted)">skipped (has cover)</span>
|
||||
{:else}
|
||||
<span class="w-2 h-2 rounded-full bg-green-500 shrink-0"></span>
|
||||
<span class="font-mono text-(--color-text)">{evt.slug}</span>
|
||||
<span class="text-xs text-green-400">generated</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { listBooks } from '$lib/server/pocketbase';
|
||||
|
||||
export interface ImageModelInfo {
|
||||
id: string;
|
||||
@@ -11,18 +12,36 @@ export interface ImageModelInfo {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BookSummary {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
cover: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// parent layout already guards admin role
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (!res.ok) {
|
||||
log.warn('admin/image-gen', 'failed to load models', { status: res.status });
|
||||
return { models: [] as ImageModelInfo[] };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { models: (data.models ?? []) as ImageModelInfo[] };
|
||||
} catch (e) {
|
||||
log.warn('admin/image-gen', 'backend unreachable', { err: String(e) });
|
||||
return { models: [] as ImageModelInfo[] };
|
||||
const [modelsResult, books] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models ?? []) as ImageModelInfo[];
|
||||
})(),
|
||||
listBooks()
|
||||
]);
|
||||
|
||||
if (modelsResult.status === 'rejected') {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(modelsResult.reason) });
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as ImageModelInfo[]),
|
||||
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
summary: b.summary ?? '',
|
||||
cover: b.cover ?? ''
|
||||
})) as BookSummary[]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,26 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
import type { ImageModelInfo } from './+page.server';
|
||||
import type { ImageModelInfo, BookSummary } from './+page.server';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Form state ───────────────────────────────────────────────────────────────
|
||||
type ImageType = 'cover' | 'chapter';
|
||||
|
||||
const CONFIG_KEY = 'admin_image_gen_config_v1';
|
||||
|
||||
interface SavedConfig {
|
||||
selectedModel: string;
|
||||
numSteps: number;
|
||||
guidance: number;
|
||||
strength: number;
|
||||
width: number;
|
||||
height: number;
|
||||
showAdvanced: boolean;
|
||||
}
|
||||
|
||||
function loadConfig(): Partial<SavedConfig> {
|
||||
if (!browser) return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
if (!browser) return;
|
||||
const cfg: SavedConfig = { selectedModel, numSteps, guidance, strength, width, height, showAdvanced };
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
const saved = loadConfig();
|
||||
|
||||
let imageType = $state<ImageType>('cover');
|
||||
let slug = $state('');
|
||||
let chapter = $state<number>(1);
|
||||
let selectedModel = $state('');
|
||||
let selectedModel = $state(saved.selectedModel ?? '');
|
||||
let prompt = $state('');
|
||||
let referenceFile = $state<File | null>(null);
|
||||
let referencePreviewUrl = $state('');
|
||||
let useCoverAsRef = $state(false);
|
||||
|
||||
// Advanced
|
||||
let showAdvanced = $state(false);
|
||||
let numSteps = $state(20);
|
||||
let guidance = $state(7.5);
|
||||
let strength = $state(0.75);
|
||||
let width = $state(1024);
|
||||
let height = $state(1024);
|
||||
let showAdvanced = $state(saved.showAdvanced ?? false);
|
||||
let numSteps = $state(saved.numSteps ?? 20);
|
||||
let guidance = $state(saved.guidance ?? 7.5);
|
||||
let strength = $state(saved.strength ?? 0.75);
|
||||
let width = $state(saved.width ?? 1024);
|
||||
let height = $state(saved.height ?? 1024);
|
||||
|
||||
// Persist config on change
|
||||
$effect(() => {
|
||||
void selectedModel; void numSteps; void guidance; void strength;
|
||||
void width; void height; void showAdvanced;
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
// ── Book autocomplete ────────────────────────────────────────────────────────
|
||||
// svelte-ignore state_referenced_locally
|
||||
const books: BookSummary[] = data.books ?? [];
|
||||
let slugInput = $state('');
|
||||
let slugFocused = $state(false);
|
||||
let selectedBook = $state<BookSummary | null>(null);
|
||||
|
||||
let bookSuggestions = $derived(
|
||||
slugInput.trim().length === 0
|
||||
? []
|
||||
: books
|
||||
.filter((b) =>
|
||||
b.slug.includes(slugInput.toLowerCase()) ||
|
||||
b.title.toLowerCase().includes(slugInput.toLowerCase())
|
||||
)
|
||||
.slice(0, 8)
|
||||
);
|
||||
|
||||
function selectBook(b: BookSummary) {
|
||||
selectedBook = b;
|
||||
slug = b.slug;
|
||||
slugInput = b.slug;
|
||||
slugFocused = false;
|
||||
// Reset cover-as-ref if no cover
|
||||
if (!b.cover) useCoverAsRef = false;
|
||||
}
|
||||
|
||||
function onSlugInput() {
|
||||
slug = slugInput;
|
||||
// If user edits away from selected book slug, deselect
|
||||
if (selectedBook && slugInput !== selectedBook.slug) {
|
||||
selectedBook = null;
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
}
|
||||
|
||||
// When useCoverAsRef toggled on, load the book cover as reference
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (!useCoverAsRef || !selectedBook?.cover) {
|
||||
if (useCoverAsRef) useCoverAsRef = false;
|
||||
return;
|
||||
}
|
||||
// Fetch the cover image and set as referenceFile
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(selectedBook!.cover);
|
||||
const blob = await res.blob();
|
||||
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
|
||||
const file = new File([blob], `${selectedBook!.slug}-cover.${ext}`, { type: blob.type });
|
||||
handleReferenceFile(file);
|
||||
} catch {
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// ── Generation state ─────────────────────────────────────────────────────────
|
||||
let generating = $state(false);
|
||||
@@ -50,18 +145,13 @@
|
||||
let saveSuccess = $state(false);
|
||||
|
||||
// ── Model helpers ────────────────────────────────────────────────────────────
|
||||
const models = data.models as ImageModelInfo[];
|
||||
// svelte-ignore state_referenced_locally
|
||||
const models: ImageModelInfo[] = data.models ?? [];
|
||||
|
||||
let filteredModels = $derived(
|
||||
referenceFile
|
||||
? models // show all; warn on ones without ref support
|
||||
: models
|
||||
);
|
||||
|
||||
let coverModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('cover')));
|
||||
let chapterModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('chapter')));
|
||||
let coverModels = $derived(models.filter((m) => m.recommended_for.includes('cover')));
|
||||
let chapterModels = $derived(models.filter((m) => m.recommended_for.includes('chapter')));
|
||||
let otherModels = $derived(
|
||||
filteredModels.filter(
|
||||
models.filter(
|
||||
(m) => !m.recommended_for.includes('cover') && !m.recommended_for.includes('chapter')
|
||||
)
|
||||
);
|
||||
@@ -74,12 +164,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Reset model selection when type changes if current selection no longer fits
|
||||
$effect(() => {
|
||||
void imageType; // track
|
||||
void imageType;
|
||||
const preferred = imageType === 'cover' ? coverModels : chapterModels;
|
||||
if (preferred.length > 0) {
|
||||
// only auto-switch if current model isn't in preferred list for this type
|
||||
const current = models.find((m) => m.id === selectedModel);
|
||||
if (!current || !current.recommended_for.includes(imageType)) {
|
||||
selectedModel = preferred[0].id;
|
||||
@@ -90,14 +178,69 @@
|
||||
// ── Prompt templates ────────────────────────────────────────────────────────
|
||||
let promptTemplate = $derived(
|
||||
imageType === 'cover'
|
||||
? `Book cover for "${slug || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
|
||||
: `Illustration for chapter ${chapter} of "${slug || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
|
||||
? `Book cover for "${slugInput || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
|
||||
: `Illustration for chapter ${chapter} of "${slugInput || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
|
||||
);
|
||||
|
||||
function applyTemplate() {
|
||||
prompt = promptTemplate;
|
||||
}
|
||||
|
||||
function injectDescription() {
|
||||
const desc = selectedBook?.summary?.trim();
|
||||
if (!desc) return;
|
||||
const snippet = desc.length > 300 ? desc.slice(0, 300) + '…' : desc;
|
||||
prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`;
|
||||
}
|
||||
|
||||
// ── Style presets ────────────────────────────────────────────────────────────
|
||||
const PRESETS_KEY = 'admin_image_gen_presets_v1';
|
||||
|
||||
interface StylePreset {
|
||||
name: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
function loadPresets(): StylePreset[] {
|
||||
if (!browser) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(PRESETS_KEY);
|
||||
return raw ? (JSON.parse(raw) as StylePreset[]) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function savePresets(p: StylePreset[]) {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(PRESETS_KEY, JSON.stringify(p));
|
||||
}
|
||||
|
||||
let presets = $state<StylePreset[]>(loadPresets());
|
||||
let newPresetName = $state('');
|
||||
let showPresets = $state(false);
|
||||
|
||||
function saveCurrentAsPreset() {
|
||||
const name = newPresetName.trim();
|
||||
if (!name || !prompt.trim()) return;
|
||||
const existing = presets.findIndex((p) => p.name === name);
|
||||
const updated = [...presets];
|
||||
if (existing >= 0) updated[existing] = { name, prompt };
|
||||
else updated.push({ name, prompt });
|
||||
presets = updated;
|
||||
savePresets(updated);
|
||||
newPresetName = '';
|
||||
}
|
||||
|
||||
function applyPreset(p: StylePreset) {
|
||||
prompt = p.prompt;
|
||||
showPresets = false;
|
||||
}
|
||||
|
||||
function deletePreset(name: string) {
|
||||
const updated = presets.filter((p) => p.name !== name);
|
||||
presets = updated;
|
||||
savePresets(updated);
|
||||
}
|
||||
|
||||
// ── Reference image handling ─────────────────────────────────────────────────
|
||||
let dragOver = $state(false);
|
||||
|
||||
@@ -110,17 +253,22 @@
|
||||
function onFileInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
handleReferenceFile(input.files?.[0] ?? null);
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file && file.type.startsWith('image/')) handleReferenceFile(file);
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
handleReferenceFile(file);
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearReference() {
|
||||
handleReferenceFile(null);
|
||||
useCoverAsRef = false;
|
||||
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
|
||||
if (input) input.value = '';
|
||||
}
|
||||
@@ -219,7 +367,6 @@
|
||||
saveSuccess = false;
|
||||
|
||||
try {
|
||||
// Extract the raw base64 from the data URL (data:<mime>;base64,<b64>)
|
||||
const b64 = result.imageSrc.split(',')[1];
|
||||
const res = await fetch('/api/admin/image-gen/save-cover', {
|
||||
method: 'POST',
|
||||
@@ -308,13 +455,63 @@
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="slug-input">
|
||||
Book slug
|
||||
</label>
|
||||
<input
|
||||
id="slug-input"
|
||||
type="text"
|
||||
bind:value={slug}
|
||||
placeholder="e.g. shadow-slave"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<!-- Autocomplete wrapper -->
|
||||
<div class="relative">
|
||||
<input
|
||||
id="slug-input"
|
||||
type="text"
|
||||
bind:value={slugInput}
|
||||
oninput={onSlugInput}
|
||||
onfocus={() => (slugFocused = true)}
|
||||
onblur={() => setTimeout(() => { slugFocused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave"
|
||||
autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
{#if slugFocused && bookSuggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each bookSuggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={selectedBook?.slug === b.slug}
|
||||
onmousedown={() => selectBook(b)}
|
||||
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
{#if b.cover}
|
||||
<img src={b.cover} alt="" class="w-8 h-10 object-cover rounded shrink-0" />
|
||||
{:else}
|
||||
<div class="w-8 h-10 rounded bg-(--color-surface-3) shrink-0"></div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) truncate font-mono">{b.slug}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Book info pill when a book is selected -->
|
||||
{#if selectedBook}
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs text-(--color-success) flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{selectedBook.title}
|
||||
</span>
|
||||
{#if selectedBook.summary}
|
||||
<button
|
||||
onclick={injectDescription}
|
||||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||||
title="Append book description to prompt"
|
||||
>
|
||||
+ inject description
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if imageType === 'chapter'}
|
||||
@@ -380,18 +577,39 @@
|
||||
</div>
|
||||
|
||||
<!-- Prompt -->
|
||||
<div class="space-y-1">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
|
||||
Prompt
|
||||
</label>
|
||||
<button
|
||||
onclick={applyTemplate}
|
||||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Use template
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
{#if selectedBook?.summary}
|
||||
<button onclick={injectDescription} class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">
|
||||
Inject description
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={applyTemplate} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Use template
|
||||
</button>
|
||||
{#if presets.length > 0}
|
||||
<button onclick={() => (showPresets = !showPresets)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Presets ({presets.length})
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showPresets && presets.length > 0}
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-lg p-3 space-y-1.5">
|
||||
{#each presets as p}
|
||||
<div class="flex items-center gap-2 group">
|
||||
<button onclick={() => applyPreset(p)} class="flex-1 text-left text-sm text-(--color-text) hover:text-(--color-brand) transition-colors truncate" title={p.prompt}>{p.name}</button>
|
||||
<button onclick={() => deletePreset(p.name)} class="text-xs text-(--color-muted) hover:text-(--color-danger) transition-colors opacity-0 group-hover:opacity-100">delete</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<textarea
|
||||
id="prompt-input"
|
||||
bind:value={prompt}
|
||||
@@ -399,13 +617,39 @@
|
||||
placeholder="Describe the image to generate…"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand) resize-y"
|
||||
></textarea>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input type="text" bind:value={newPresetName} placeholder="Preset name…"
|
||||
class="flex-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-(--color-text) text-xs placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
|
||||
<button onclick={saveCurrentAsPreset} disabled={!newPresetName.trim() || !prompt.trim()}
|
||||
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-xs text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
Save preset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reference image drop zone -->
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
|
||||
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
|
||||
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
|
||||
</p>
|
||||
{#if selectedBook?.cover && selectedModelInfo?.supports_ref}
|
||||
<div class="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={useCoverAsRef}
|
||||
aria-label="Use book cover as reference"
|
||||
onclick={() => (useCoverAsRef = !useCoverAsRef)}
|
||||
class="w-8 h-4 rounded-full transition-colors relative focus:outline-none focus:ring-1 focus:ring-(--color-brand) {useCoverAsRef ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}"
|
||||
>
|
||||
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform {useCoverAsRef ? 'translate-x-4' : ''}"></span>
|
||||
</button>
|
||||
<span class="text-xs text-(--color-muted)">Use book cover</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if referenceFile && referencePreviewUrl}
|
||||
<div class="flex items-start gap-3 p-3 bg-(--color-surface-2) rounded-lg border border-(--color-border)">
|
||||
<img
|
||||
@@ -416,6 +660,9 @@
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<p class="text-sm text-(--color-text) truncate">{referenceFile.name}</p>
|
||||
<p class="text-xs text-(--color-muted)">{fmtBytes(referenceFile.size)}</p>
|
||||
{#if useCoverAsRef}
|
||||
<p class="text-xs text-(--color-brand)">Current book cover</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={clearReference}
|
||||
@@ -474,20 +721,20 @@
|
||||
<!-- num_steps -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<label class="text-xs text-(--color-muted)">Steps</label>
|
||||
<label for="img-steps" class="text-xs text-(--color-muted)">Steps</label>
|
||||
<span class="text-xs text-(--color-text) font-mono">{numSteps}</span>
|
||||
</div>
|
||||
<input type="range" min="1" max="20" step="1" bind:value={numSteps}
|
||||
<input id="img-steps" type="range" min="1" max="20" step="1" bind:value={numSteps}
|
||||
class="w-full accent-(--color-brand)" />
|
||||
</div>
|
||||
|
||||
<!-- guidance -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<label class="text-xs text-(--color-muted)">Guidance</label>
|
||||
<label for="img-guidance" class="text-xs text-(--color-muted)">Guidance</label>
|
||||
<span class="text-xs text-(--color-text) font-mono">{guidance.toFixed(1)}</span>
|
||||
</div>
|
||||
<input type="range" min="1" max="20" step="0.5" bind:value={guidance}
|
||||
<input id="img-guidance" type="range" min="1" max="20" step="0.5" bind:value={guidance}
|
||||
class="w-full accent-(--color-brand)" />
|
||||
</div>
|
||||
|
||||
@@ -495,10 +742,10 @@
|
||||
{#if referenceFile}
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<label class="text-xs text-(--color-muted)">Strength</label>
|
||||
<label for="img-strength" class="text-xs text-(--color-muted)">Strength</label>
|
||||
<span class="text-xs text-(--color-text) font-mono">{strength.toFixed(2)}</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="1" step="0.05" bind:value={strength}
|
||||
<input id="img-strength" type="range" min="0" max="1" step="0.05" bind:value={strength}
|
||||
class="w-full accent-(--color-brand)" />
|
||||
<p class="text-xs text-(--color-muted)">0 = copy reference · 1 = ignore reference</p>
|
||||
</div>
|
||||
@@ -530,7 +777,6 @@
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if generating}
|
||||
<!-- Spinner -->
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
@@ -553,7 +799,8 @@
|
||||
<!-- Image -->
|
||||
<img
|
||||
src={result.imageSrc}
|
||||
alt="Generated image"
|
||||
alt=""
|
||||
aria-label="Generated cover"
|
||||
class="w-full object-contain max-h-[36rem] bg-zinc-950"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { listBooks } from '$lib/server/pocketbase';
|
||||
|
||||
export interface BookSummary {
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TextModelInfo {
|
||||
id: string;
|
||||
@@ -12,16 +18,25 @@ export interface TextModelInfo {
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/text-gen/models');
|
||||
if (!res.ok) {
|
||||
log.warn('admin/text-gen', 'failed to load models', { status: res.status });
|
||||
return { models: [] as TextModelInfo[] };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { models: (data.models ?? []) as TextModelInfo[] };
|
||||
} catch (e) {
|
||||
log.warn('admin/text-gen', 'backend unreachable', { err: String(e) });
|
||||
return { models: [] as TextModelInfo[] };
|
||||
const [modelsResult, books] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const res = await backendFetch('/api/admin/text-gen/models');
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models ?? []) as TextModelInfo[];
|
||||
})(),
|
||||
listBooks()
|
||||
]);
|
||||
|
||||
if (modelsResult.status === 'rejected') {
|
||||
log.warn('admin/text-gen', 'failed to load models', { err: String(modelsResult.reason) });
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as TextModelInfo[]),
|
||||
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title
|
||||
})) as BookSummary[]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,26 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
import type { TextModelInfo } from './+page.server';
|
||||
import type { TextModelInfo, BookSummary } from './+page.server';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const models = data.models as TextModelInfo[];
|
||||
// Server data is static per page load — intentional one-time snapshot.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const models: TextModelInfo[] = data.models ?? [];
|
||||
// svelte-ignore state_referenced_locally
|
||||
const books: BookSummary[] = data.books ?? [];
|
||||
|
||||
// ── Config persistence ───────────────────────────────────────────────────────
|
||||
const CONFIG_KEY = 'admin_text_gen_config_v2';
|
||||
|
||||
interface SavedConfig {
|
||||
selectedModel: string;
|
||||
activeTab: string;
|
||||
chPattern: string;
|
||||
dInstructions: string;
|
||||
}
|
||||
|
||||
function loadConfig(): Partial<SavedConfig> {
|
||||
if (!browser) return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
if (!browser) return;
|
||||
const cfg: SavedConfig = { selectedModel, activeTab, chPattern, dInstructions };
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
const saved = loadConfig();
|
||||
|
||||
// ── Shared ────────────────────────────────────────────────────────────────────
|
||||
type ActiveTab = 'chapters' | 'description';
|
||||
let activeTab = $state<ActiveTab>('chapters');
|
||||
let selectedModel = $state(models[0]?.id ?? '');
|
||||
type ActiveTab = 'chapters' | 'description' | 'tagline' | 'genres' | 'warnings' | 'quality';
|
||||
let activeTab = $state<ActiveTab>((saved.activeTab as ActiveTab) ?? 'chapters');
|
||||
let selectedModel = $state(saved.selectedModel ?? (models[0]?.id ?? ''));
|
||||
|
||||
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
|
||||
|
||||
$effect(() => { void selectedModel; void activeTab; void chPattern; void dInstructions; saveConfig(); });
|
||||
|
||||
function fmtCtx(n: number) {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(0)}k ctx`;
|
||||
return `${n} ctx`;
|
||||
}
|
||||
|
||||
// ── Book autocomplete (shared component logic) ───────────────────────────────
|
||||
function makeBookAC() {
|
||||
let inputVal = $state('');
|
||||
let focused = $state(false);
|
||||
|
||||
const suggestions = $derived(
|
||||
inputVal.trim().length === 0
|
||||
? []
|
||||
: books
|
||||
.filter((b) =>
|
||||
b.slug.includes(inputVal.toLowerCase()) ||
|
||||
b.title.toLowerCase().includes(inputVal.toLowerCase())
|
||||
)
|
||||
.slice(0, 8)
|
||||
);
|
||||
|
||||
return {
|
||||
get inputVal() { return inputVal; },
|
||||
set inputVal(v: string) { inputVal = v; },
|
||||
get focused() { return focused; },
|
||||
set focused(v: boolean) { focused = v; },
|
||||
get suggestions() { return suggestions; }
|
||||
};
|
||||
}
|
||||
|
||||
// ── Chapter names state ───────────────────────────────────────────────────────
|
||||
let chAC = makeBookAC();
|
||||
let chSlug = $state('');
|
||||
let chPattern = $state('Chapter {n}: {scene}');
|
||||
let chPattern = $state(saved.chPattern ?? 'Chapter {n}: {scene}');
|
||||
let chGenerating = $state(false);
|
||||
let chError = $state('');
|
||||
|
||||
@@ -28,13 +87,13 @@
|
||||
number: number;
|
||||
old_title: string;
|
||||
new_title: string;
|
||||
// editable copy
|
||||
edited: string;
|
||||
}
|
||||
let chProposals = $state<ProposedChapter[]>([]);
|
||||
let chRawResponse = $state('');
|
||||
let chUsedModel = $state('');
|
||||
let chShowRaw = $state(false);
|
||||
|
||||
let chBatchProgress = $state('');
|
||||
let chBatchWarnings = $state<string[]>([]);
|
||||
|
||||
let chApplying = $state(false);
|
||||
let chApplyError = $state('');
|
||||
@@ -43,11 +102,24 @@
|
||||
let chCanGenerate = $derived(chSlug.trim().length > 0 && chPattern.trim().length > 0 && !chGenerating);
|
||||
let chCanApply = $derived(chProposals.length > 0 && !chApplying);
|
||||
|
||||
function selectChBook(b: BookSummary) {
|
||||
chSlug = b.slug;
|
||||
chAC.inputVal = b.slug;
|
||||
chAC.focused = false;
|
||||
}
|
||||
|
||||
function onChSlugInput() {
|
||||
chSlug = chAC.inputVal;
|
||||
}
|
||||
|
||||
async function generateChapterNames() {
|
||||
if (!chCanGenerate) return;
|
||||
chGenerating = true;
|
||||
chError = '';
|
||||
chProposals = [];
|
||||
chUsedModel = '';
|
||||
chBatchProgress = '';
|
||||
chBatchWarnings = [];
|
||||
chApplySuccess = false;
|
||||
chApplyError = '';
|
||||
|
||||
@@ -61,20 +133,77 @@
|
||||
model: selectedModel
|
||||
})
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
// Non-SSE error response (e.g. 400/404/502 before streaming started).
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
chError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
chProposals = ((body.chapters ?? []) as { number: number; old_title: string; new_title: string }[]).map(
|
||||
(p) => ({ ...p, edited: p.new_title })
|
||||
);
|
||||
chRawResponse = body.raw_response ?? '';
|
||||
chUsedModel = body.model ?? '';
|
||||
// If backend returned chapters:[] but we have a raw response, the model
|
||||
// output was unparseable (likely truncated). Treat it as an error.
|
||||
if (chProposals.length === 0 && chRawResponse.trim().length > 0) {
|
||||
chError = 'Model response could not be parsed (output may be truncated). Raw response shown below.';
|
||||
|
||||
// Stream SSE events line by line.
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
outer: while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process all complete SSE messages in the buffer.
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? ''; // keep incomplete last line
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (!payload) continue;
|
||||
|
||||
let evt: {
|
||||
batch?: number;
|
||||
total_batches?: number;
|
||||
chapters_done?: number;
|
||||
total_chapters?: number;
|
||||
model?: string;
|
||||
chapters?: { number: number; old_title: string; new_title: string }[];
|
||||
error?: string;
|
||||
done?: boolean;
|
||||
};
|
||||
try {
|
||||
evt = JSON.parse(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (evt.done) {
|
||||
chBatchProgress = `Done — ${evt.total_chapters ?? chProposals.length} chapters`;
|
||||
chGenerating = false;
|
||||
break outer;
|
||||
}
|
||||
|
||||
if (evt.model) chUsedModel = evt.model;
|
||||
|
||||
if (evt.error) {
|
||||
chBatchWarnings = [
|
||||
...chBatchWarnings,
|
||||
`Batch ${evt.batch}/${evt.total_batches} failed: ${evt.error}`
|
||||
];
|
||||
} else if (evt.chapters) {
|
||||
const incoming = (evt.chapters as { number: number; old_title: string; new_title: string }[]).map(
|
||||
(p) => ({ ...p, edited: p.new_title })
|
||||
);
|
||||
chProposals = [...chProposals, ...incoming];
|
||||
}
|
||||
|
||||
if (evt.batch != null && evt.total_batches != null) {
|
||||
chBatchProgress = `Batch ${evt.batch}/${evt.total_batches} · ${evt.chapters_done ?? chProposals.length}/${evt.total_chapters ?? '?'} chapters`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chProposals.length === 0 && chBatchWarnings.length === 0) {
|
||||
chError = 'No proposals returned. The model may have failed to parse the chapters.';
|
||||
}
|
||||
} catch {
|
||||
chError = 'Network error.';
|
||||
@@ -112,8 +241,9 @@
|
||||
}
|
||||
|
||||
// ── Description state ─────────────────────────────────────────────────────────
|
||||
let dAC = makeBookAC();
|
||||
let dSlug = $state('');
|
||||
let dInstructions = $state('');
|
||||
let dInstructions = $state(saved.dInstructions ?? '');
|
||||
let dGenerating = $state(false);
|
||||
let dError = $state('');
|
||||
|
||||
@@ -128,6 +258,16 @@
|
||||
let dCanGenerate = $derived(dSlug.trim().length > 0 && !dGenerating);
|
||||
let dCanApply = $derived(dNewDesc.trim().length > 0 && !dApplying);
|
||||
|
||||
function selectDBook(b: BookSummary) {
|
||||
dSlug = b.slug;
|
||||
dAC.inputVal = b.slug;
|
||||
dAC.focused = false;
|
||||
}
|
||||
|
||||
function onDSlugInput() {
|
||||
dSlug = dAC.inputVal;
|
||||
}
|
||||
|
||||
async function generateDescription() {
|
||||
if (!dCanGenerate) return;
|
||||
dGenerating = true;
|
||||
@@ -189,6 +329,142 @@
|
||||
dApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tagline state ─────────────────────────────────────────────────────────────
|
||||
let tAC = makeBookAC();
|
||||
let tSlug = $state('');
|
||||
let tGenerating = $state(false);
|
||||
let tError = $state('');
|
||||
let tResult = $state('');
|
||||
let tUsedModel = $state('');
|
||||
|
||||
let tCanGenerate = $derived(tSlug.trim().length > 0 && !tGenerating);
|
||||
|
||||
function selectTBook(b: BookSummary) { tSlug = b.slug; tAC.inputVal = b.slug; tAC.focused = false; }
|
||||
function onTSlugInput() { tSlug = tAC.inputVal; }
|
||||
|
||||
async function generateTagline() {
|
||||
if (!tCanGenerate) return;
|
||||
tGenerating = true; tError = ''; tResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/tagline', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: tSlug.trim(), model: selectedModel })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { tError = body.error ?? `Error ${res.status}`; return; }
|
||||
tResult = body.new_tagline ?? '';
|
||||
tUsedModel = body.model ?? '';
|
||||
} catch { tError = 'Network error.'; } finally { tGenerating = false; }
|
||||
}
|
||||
|
||||
// ── Genres state ──────────────────────────────────────────────────────────────
|
||||
let gAC = makeBookAC();
|
||||
let gSlug = $state('');
|
||||
let gGenerating = $state(false);
|
||||
let gError = $state('');
|
||||
let gCurrent = $state<string[]>([]);
|
||||
let gProposed = $state<string[]>([]);
|
||||
let gEdited = $state<string[]>([]);
|
||||
let gUsedModel = $state('');
|
||||
let gApplying = $state(false);
|
||||
let gApplyError = $state('');
|
||||
let gApplySuccess = $state(false);
|
||||
|
||||
let gCanGenerate = $derived(gSlug.trim().length > 0 && !gGenerating);
|
||||
let gCanApply = $derived(gEdited.length > 0 && !gApplying);
|
||||
|
||||
function selectGBook(b: BookSummary) { gSlug = b.slug; gAC.inputVal = b.slug; gAC.focused = false; }
|
||||
function onGSlugInput() { gSlug = gAC.inputVal; }
|
||||
|
||||
async function generateGenres() {
|
||||
if (!gCanGenerate) return;
|
||||
gGenerating = true; gError = ''; gCurrent = []; gProposed = []; gEdited = []; gApplySuccess = false;
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/genres', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: gSlug.trim(), model: selectedModel })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { gError = body.error ?? `Error ${res.status}`; return; }
|
||||
gCurrent = body.current_genres ?? [];
|
||||
gProposed = body.proposed_genres ?? [];
|
||||
gEdited = [...gProposed];
|
||||
gUsedModel = body.model ?? '';
|
||||
} catch { gError = 'Network error.'; } finally { gGenerating = false; }
|
||||
}
|
||||
|
||||
async function applyGenres() {
|
||||
if (!gCanApply) return;
|
||||
gApplying = true; gApplyError = ''; gApplySuccess = false;
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/genres/apply', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: gSlug.trim(), genres: gEdited.filter(Boolean) })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { gApplyError = body.error ?? `Error ${res.status}`; return; }
|
||||
gApplySuccess = true;
|
||||
} catch { gApplyError = 'Network error.'; } finally { gApplying = false; }
|
||||
}
|
||||
|
||||
// ── Content warnings state ────────────────────────────────────────────────────
|
||||
let wAC = makeBookAC();
|
||||
let wSlug = $state('');
|
||||
let wGenerating = $state(false);
|
||||
let wError = $state('');
|
||||
let wWarnings = $state<string[]>([]);
|
||||
let wUsedModel = $state('');
|
||||
|
||||
let wCanGenerate = $derived(wSlug.trim().length > 0 && !wGenerating);
|
||||
|
||||
function selectWBook(b: BookSummary) { wSlug = b.slug; wAC.inputVal = b.slug; wAC.focused = false; }
|
||||
function onWSlugInput() { wSlug = wAC.inputVal; }
|
||||
|
||||
async function generateWarnings() {
|
||||
if (!wCanGenerate) return;
|
||||
wGenerating = true; wError = ''; wWarnings = [];
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/content-warnings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: wSlug.trim(), model: selectedModel })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { wError = body.error ?? `Error ${res.status}`; return; }
|
||||
wWarnings = body.warnings ?? [];
|
||||
wUsedModel = body.model ?? '';
|
||||
} catch { wError = 'Network error.'; } finally { wGenerating = false; }
|
||||
}
|
||||
|
||||
// ── Quality score state ───────────────────────────────────────────────────────
|
||||
let qAC = makeBookAC();
|
||||
let qSlug = $state('');
|
||||
let qGenerating = $state(false);
|
||||
let qError = $state('');
|
||||
let qScore = $state(0);
|
||||
let qFeedback = $state('');
|
||||
let qUsedModel = $state('');
|
||||
|
||||
let qCanGenerate = $derived(qSlug.trim().length > 0 && !qGenerating);
|
||||
|
||||
function selectQBook(b: BookSummary) { qSlug = b.slug; qAC.inputVal = b.slug; qAC.focused = false; }
|
||||
function onQSlugInput() { qSlug = qAC.inputVal; }
|
||||
|
||||
async function generateQualityScore() {
|
||||
if (!qCanGenerate) return;
|
||||
qGenerating = true; qError = ''; qScore = 0; qFeedback = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/quality-score', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: qSlug.trim(), model: selectedModel })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { qError = body.error ?? `Error ${res.status}`; return; }
|
||||
qScore = body.score ?? 0;
|
||||
qFeedback = body.feedback ?? '';
|
||||
qUsedModel = body.model ?? '';
|
||||
} catch { qError = 'Network error.'; } finally { qGenerating = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -224,16 +500,23 @@
|
||||
</div>
|
||||
|
||||
<!-- Tab toggle -->
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
|
||||
{#each (['chapters', 'description'] as const) as t}
|
||||
<div class="flex flex-wrap gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
|
||||
{#each ([
|
||||
['chapters', 'Chapter Names'],
|
||||
['description', 'Description'],
|
||||
['tagline', 'Tagline'],
|
||||
['genres', 'Genres'],
|
||||
['warnings', 'Warnings'],
|
||||
['quality', 'Quality'],
|
||||
] as const) as [t, label]}
|
||||
<button
|
||||
onclick={() => (activeTab = t)}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === t
|
||||
? 'bg-(--color-surface-3) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{t === 'chapters' ? 'Chapter Names' : 'Description'}
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -247,13 +530,37 @@
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-slug">
|
||||
Book slug
|
||||
</label>
|
||||
<input
|
||||
id="ch-slug"
|
||||
type="text"
|
||||
bind:value={chSlug}
|
||||
placeholder="e.g. shadow-slave"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="ch-slug"
|
||||
type="text"
|
||||
bind:value={chAC.inputVal}
|
||||
oninput={onChSlugInput}
|
||||
onfocus={() => (chAC.focused = true)}
|
||||
onblur={() => setTimeout(() => { chAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave"
|
||||
autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
{#if chAC.focused && chAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each chAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={chSlug === b.slug}
|
||||
onmousedown={() => selectChBook(b)}
|
||||
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
@@ -292,9 +599,6 @@
|
||||
|
||||
{#if chError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chError}</p>
|
||||
{#if chRawResponse}
|
||||
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-48 text-(--color-muted) whitespace-pre-wrap break-words">{chRawResponse}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -309,16 +613,25 @@
|
||||
<span class="normal-case font-normal">· {chUsedModel.split('/').pop()}</span>
|
||||
{/if}
|
||||
</p>
|
||||
<button
|
||||
onclick={() => (chShowRaw = !chShowRaw)}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
{chShowRaw ? 'Hide raw' : 'Show raw'}
|
||||
</button>
|
||||
{#if chBatchProgress}
|
||||
<span class="text-xs text-(--color-muted) flex items-center gap-1.5">
|
||||
{#if chGenerating}
|
||||
<svg class="w-3 h-3 animate-spin shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
{/if}
|
||||
{chBatchProgress}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chShowRaw}
|
||||
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-40 text-(--color-muted)">{chRawResponse}</pre>
|
||||
{#if chBatchWarnings.length > 0}
|
||||
<div class="space-y-1">
|
||||
{#each chBatchWarnings as w}
|
||||
<p class="text-xs text-amber-400 bg-amber-400/10 rounded-lg px-3 py-2">{w}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="max-h-[28rem] overflow-y-auto space-y-2 pr-1">
|
||||
@@ -349,7 +662,7 @@
|
||||
{chApplying ? 'Saving…' : chApplySuccess ? 'Saved ✓' : `Apply ${chProposals.length} titles`}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { chProposals = []; chRawResponse = ''; chApplySuccess = false; }}
|
||||
onclick={() => { chProposals = []; chBatchProgress = ''; chBatchWarnings = []; chApplySuccess = false; }}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm
|
||||
hover:text-(--color-text) transition-colors border border-(--color-border)"
|
||||
>
|
||||
@@ -373,7 +686,9 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
<p class="text-sm text-(--color-muted)">Generating chapter titles…</p>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
{chBatchProgress || 'Generating chapter titles…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -394,13 +709,37 @@
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-slug">
|
||||
Book slug
|
||||
</label>
|
||||
<input
|
||||
id="d-slug"
|
||||
type="text"
|
||||
bind:value={dSlug}
|
||||
placeholder="e.g. shadow-slave"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="d-slug"
|
||||
type="text"
|
||||
bind:value={dAC.inputVal}
|
||||
oninput={onDSlugInput}
|
||||
onfocus={() => (dAC.focused = true)}
|
||||
onblur={() => setTimeout(() => { dAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave"
|
||||
autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
{#if dAC.focused && dAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each dAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={dSlug === b.slug}
|
||||
onmousedown={() => selectDBook(b)}
|
||||
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
@@ -513,4 +852,305 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Tagline panel ──────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'tagline'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="t-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="t-slug" type="text" bind:value={tAC.inputVal} oninput={onTSlugInput}
|
||||
onfocus={() => (tAC.focused = true)} onblur={() => setTimeout(() => { tAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave" autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
{#if tAC.focused && tAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each tAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={tSlug === b.slug} onmousedown={() => selectTBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateTagline} disabled={!tCanGenerate}
|
||||
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
{#if tGenerating}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>Generating…
|
||||
{:else}Generate tagline{/if}
|
||||
</button>
|
||||
{#if tError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{tError}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if tResult}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Tagline{#if tUsedModel}<span class="normal-case font-normal"> · {tUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="bg-(--color-surface) border border-(--color-brand)/40 rounded-xl p-4">
|
||||
<p class="text-base italic text-(--color-text)">{tResult}</p>
|
||||
</div>
|
||||
<button onclick={() => { tResult = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
</div>
|
||||
{:else if tGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Tagline will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Genres panel ────────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'genres'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="g-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="g-slug" type="text" bind:value={gAC.inputVal} oninput={onGSlugInput}
|
||||
onfocus={() => (gAC.focused = true)} onblur={() => setTimeout(() => { gAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave" autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
{#if gAC.focused && gAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each gAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={gSlug === b.slug} onmousedown={() => selectGBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateGenres} disabled={!gCanGenerate}
|
||||
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
{#if gGenerating}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>Generating…
|
||||
{:else}Suggest genres{/if}
|
||||
</button>
|
||||
{#if gError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{gError}</p>{/if}
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
{#if gProposed.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#if gCurrent.length > 0}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-1.5">Current genres</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each gCurrent as g}
|
||||
<span class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{g}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-1.5">
|
||||
Proposed{#if gUsedModel}<span class="normal-case font-normal"> · {gUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each gEdited as g, i}
|
||||
<input type="text" bind:value={gEdited[i]}
|
||||
class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-brand)/10 border border-(--color-brand)/30 text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
|
||||
{/each}
|
||||
<button onclick={() => { gEdited = [...gEdited, '']; }}
|
||||
class="px-2.5 py-0.5 rounded-full text-xs bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) transition-colors">+ add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button onclick={applyGenres} disabled={!gCanApply}
|
||||
class="flex-1 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{gApplying ? 'Saving…' : gApplySuccess ? 'Saved ✓' : 'Apply genres'}
|
||||
</button>
|
||||
<button onclick={() => { gProposed = []; gEdited = []; gApplySuccess = false; }}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm hover:text-(--color-text) transition-colors border border-(--color-border)">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{#if gApplyError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{gApplyError}</p>{/if}
|
||||
{#if gApplySuccess}<p class="text-sm text-green-400 bg-green-400/10 rounded-lg px-3 py-2">Genres saved successfully.</p>{/if}
|
||||
</div>
|
||||
{:else if gGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-32">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-32">
|
||||
<p class="text-sm text-(--color-muted)">Genre suggestions will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Content warnings panel ─────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'warnings'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="w-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="w-slug" type="text" bind:value={wAC.inputVal} oninput={onWSlugInput}
|
||||
onfocus={() => (wAC.focused = true)} onblur={() => setTimeout(() => { wAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave" autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
{#if wAC.focused && wAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each wAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={wSlug === b.slug} onmousedown={() => selectWBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateWarnings} disabled={!wCanGenerate}
|
||||
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
{#if wGenerating}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>Detecting…
|
||||
{:else}Detect content warnings{/if}
|
||||
</button>
|
||||
{#if wError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{wError}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if wWarnings.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Detected warnings{#if wUsedModel}<span class="normal-case font-normal"> · {wUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each wWarnings as w}
|
||||
<span class="px-3 py-1 rounded-full text-sm bg-amber-400/10 text-amber-400 border border-amber-400/30">{w}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button onclick={() => { wWarnings = []; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
</div>
|
||||
{:else if wWarnings.length === 0 && wUsedModel}
|
||||
<div class="bg-green-400/10 border border-green-400/30 rounded-xl p-4">
|
||||
<p class="text-sm text-green-400">No content warnings detected.</p>
|
||||
</div>
|
||||
{:else if wGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Warnings will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Quality score panel ────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'quality'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="q-slug">Book slug</label>
|
||||
<div class="relative">
|
||||
<input id="q-slug" type="text" bind:value={qAC.inputVal} oninput={onQSlugInput}
|
||||
onfocus={() => (qAC.focused = true)} onblur={() => setTimeout(() => { qAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave" autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
|
||||
{#if qAC.focused && qAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each qAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li role="option" aria-selected={qSlug === b.slug} onmousedown={() => selectQBook(b)}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={generateQualityScore} disabled={!qCanGenerate}
|
||||
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
{#if qGenerating}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>Scoring…
|
||||
{:else}Score description quality{/if}
|
||||
</button>
|
||||
{#if qError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{qError}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if qScore > 0}
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Quality score{#if qUsedModel}<span class="normal-case font-normal"> · {qUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl p-4 space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-4xl font-bold text-(--color-brand)">{qScore}</span>
|
||||
<div class="flex gap-1">
|
||||
{#each [1,2,3,4,5] as star}
|
||||
<span class="text-xl {star <= qScore ? 'text-amber-400' : 'text-(--color-border)'}">★</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if qFeedback}
|
||||
<p class="text-sm text-(--color-muted)">{qFeedback}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button onclick={() => { qScore = 0; qFeedback = ''; qUsedModel = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
</div>
|
||||
{:else if qGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Quality score will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
43
ui/src/routes/api/admin/catalogue/batch-covers/+server.ts
Normal file
43
ui/src/routes/api/admin/catalogue/batch-covers/+server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* POST /api/admin/catalogue/batch-covers
|
||||
*
|
||||
* Admin-only SSE proxy to the Go backend's batch cover generation endpoint.
|
||||
* Pipes the stream straight through so the browser can consume it without buffering.
|
||||
*/
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/catalogue/batch-covers', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/catalogue/batch-covers', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
return new Response(res.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/catalogue/batch-covers/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/catalogue/batch-covers/cancel', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* POST /api/admin/catalogue/refresh-metadata/[slug]
|
||||
*
|
||||
* Admin-only SSE proxy — re-generates description + cover for a single book.
|
||||
*/
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/catalogue/refresh-metadata/${params.slug}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/catalogue/refresh-metadata', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
return new Response(res.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
33
ui/src/routes/api/admin/dedup-chapters/[slug]/+server.ts
Normal file
33
ui/src/routes/api/admin/dedup-chapters/[slug]/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* POST /api/admin/dedup-chapters/[slug]
|
||||
*
|
||||
* Admin-only proxy to the Go backend's dedup endpoint.
|
||||
* Removes duplicate chapters_idx records for a book, keeping the latest
|
||||
* record per chapter number. Returns { slug, deleted }.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: 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/dedup-chapters/${encodeURIComponent(slug)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/dedup-chapters', 'backend proxy error', { slug, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
@@ -2,10 +2,11 @@
|
||||
* POST /api/admin/text-gen/chapter-names
|
||||
*
|
||||
* Admin-only proxy to the Go backend's chapter-name generation endpoint.
|
||||
* Returns AI-proposed chapter titles; does NOT persist anything.
|
||||
* The backend streams SSE events; this handler pipes the stream through
|
||||
* directly so the browser can consume it without buffering.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
@@ -28,6 +29,22 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
// Non-2xx: the backend returned a JSON error before switching to SSE.
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Pipe the SSE stream straight through — do not buffer.
|
||||
return new Response(res.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
24
ui/src/routes/api/admin/text-gen/content-warnings/+server.ts
Normal file
24
ui/src/routes/api/admin/text-gen/content-warnings/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/content-warnings', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/content-warnings', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
24
ui/src/routes/api/admin/text-gen/genres/+server.ts
Normal file
24
ui/src/routes/api/admin/text-gen/genres/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/genres', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/genres', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
24
ui/src/routes/api/admin/text-gen/genres/apply/+server.ts
Normal file
24
ui/src/routes/api/admin/text-gen/genres/apply/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/genres/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/genres/apply', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
24
ui/src/routes/api/admin/text-gen/quality-score/+server.ts
Normal file
24
ui/src/routes/api/admin/text-gen/quality-score/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/quality-score', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/quality-score', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
24
ui/src/routes/api/admin/text-gen/tagline/+server.ts
Normal file
24
ui/src/routes/api/admin/text-gen/tagline/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/tagline', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/tagline', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
theme: settings?.theme ?? 'amber',
|
||||
locale: settings?.locale ?? 'en',
|
||||
fontFamily: settings?.font_family ?? 'system',
|
||||
fontSize: settings?.font_size ?? 1.0
|
||||
fontSize: settings?.font_size || 1.0
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('settings', 'GET failed', { err: String(e) });
|
||||
@@ -61,9 +61,9 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`);
|
||||
}
|
||||
|
||||
// fontSize is optional — if provided (and non-zero) it must be one of the valid steps
|
||||
// fontSize is optional — if provided it must be one of the valid steps (0 is not valid)
|
||||
const validFontSizes = [0.9, 1.0, 1.15, 1.3];
|
||||
if (body.fontSize !== undefined && body.fontSize !== 0 && !validFontSizes.includes(body.fontSize)) {
|
||||
if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) {
|
||||
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
let saving = $state(false);
|
||||
|
||||
// ── Ratings ───────────────────────────────────────────────────────────────
|
||||
// svelte-ignore state_referenced_locally
|
||||
let userRating = $state(data.userRating ?? 0);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let ratingAvg = $state(data.ratingAvg ?? { avg: 0, count: 0 });
|
||||
|
||||
async function rate(r: number) {
|
||||
@@ -138,6 +140,21 @@
|
||||
let coverPreview = $state<string | null>(null);
|
||||
let coverSaving = $state(false);
|
||||
let coverResult = $state<'saved' | 'error' | ''>('');
|
||||
let coverPromptOpen = $state(false);
|
||||
|
||||
function buildCoverPrompt(): string {
|
||||
const title = data.book?.title ?? '';
|
||||
const summary = data.book?.summary ?? '';
|
||||
const excerpt = summary.length > 200 ? summary.slice(0, 200) + '…' : summary;
|
||||
return `Book cover art for "${title}"${excerpt ? ` — ${excerpt}` : ''}. Epic scene with dramatic lighting, professional book cover, highly detailed, 4K.`;
|
||||
}
|
||||
|
||||
let coverPrompt = $state('');
|
||||
let coverUseAsRef = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (coverPromptOpen && !coverPrompt) coverPrompt = buildCoverPrompt();
|
||||
});
|
||||
|
||||
async function generateCover() {
|
||||
const slug = data.book?.slug;
|
||||
@@ -145,12 +162,34 @@
|
||||
coverGenerating = true;
|
||||
coverPreview = null;
|
||||
coverResult = '';
|
||||
const promptToUse = coverPrompt.trim() || buildCoverPrompt();
|
||||
try {
|
||||
const res = await fetch('/api/admin/image-gen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, type: 'cover', prompt: data.book?.title ?? slug })
|
||||
});
|
||||
let res: Response;
|
||||
if (coverUseAsRef && data.book?.cover) {
|
||||
try {
|
||||
const imgRes = await fetch(data.book.cover);
|
||||
const blob = await imgRes.blob();
|
||||
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
|
||||
const file = new File([blob], `${slug}-cover-ref.${ext}`, { type: blob.type });
|
||||
const fd = new FormData();
|
||||
fd.append('json', JSON.stringify({ slug, type: 'cover', prompt: promptToUse, strength: 0.65 }));
|
||||
fd.append('reference', file);
|
||||
res = await fetch('/api/admin/image-gen', { method: 'POST', body: fd });
|
||||
} catch {
|
||||
// Fall back to text-only if cover fetch fails
|
||||
res = await fetch('/api/admin/image-gen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse })
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res = await fetch('/api/admin/image-gen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse })
|
||||
});
|
||||
}
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
|
||||
@@ -195,6 +234,7 @@
|
||||
let chapterCoverGenerating = $state(false);
|
||||
let chapterCoverPreview = $state<string | null>(null);
|
||||
let chapterCoverResult = $state<'error' | ''>('');
|
||||
let chapterCoverPrompt = $state('');
|
||||
|
||||
async function generateChapterCover() {
|
||||
const slug = data.book?.slug;
|
||||
@@ -204,11 +244,12 @@
|
||||
chapterCoverGenerating = true;
|
||||
chapterCoverPreview = null;
|
||||
chapterCoverResult = '';
|
||||
const promptToUse = chapterCoverPrompt.trim() || `Chapter ${n} illustration for "${data.book?.title ?? slug}". Dramatic scene, vivid colors, detailed art, cinematic lighting.`;
|
||||
try {
|
||||
const res = await fetch('/api/admin/image-gen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, type: 'chapter', chapter: n, prompt: data.book?.title ?? slug })
|
||||
body: JSON.stringify({ slug, type: 'chapter', chapter: n, prompt: promptToUse })
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
@@ -228,6 +269,7 @@
|
||||
let descPreview = $state('');
|
||||
let descApplying = $state(false);
|
||||
let descResult = $state<'applied' | 'error' | ''>('');
|
||||
let descInstructions = $state('');
|
||||
|
||||
async function generateDesc() {
|
||||
const slug = data.book?.slug;
|
||||
@@ -239,7 +281,7 @@
|
||||
const res = await fetch('/api/admin/text-gen/description', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug })
|
||||
body: JSON.stringify({ slug, instructions: descInstructions.trim() || undefined })
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
@@ -281,9 +323,12 @@
|
||||
|
||||
// ── Admin: chapter names generation ───────────────────────────────────────
|
||||
let chapNamesGenerating = $state(false);
|
||||
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string }[]>([]);
|
||||
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string; edited: string }[]>([]);
|
||||
let chapNamesApplying = $state(false);
|
||||
let chapNamesResult = $state<'applied' | 'error' | ''>('');
|
||||
let chapNamesPattern = $state('Chapter {n}: {scene}');
|
||||
let chapNamesBatchProgress = $state('');
|
||||
let chapNamesBatchWarnings = $state<string[]>([]);
|
||||
|
||||
async function generateChapNames() {
|
||||
const slug = data.book?.slug;
|
||||
@@ -291,18 +336,46 @@
|
||||
chapNamesGenerating = true;
|
||||
chapNamesPreview = [];
|
||||
chapNamesResult = '';
|
||||
chapNamesBatchProgress = '';
|
||||
chapNamesBatchWarnings = [];
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/chapter-names', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, pattern: 'Chapter {n}: {scene}' })
|
||||
body: JSON.stringify({ slug, pattern: chapNamesPattern.trim() || 'Chapter {n}: {scene}' })
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
chapNamesPreview = d.chapters ?? [];
|
||||
} else {
|
||||
if (!res.ok) {
|
||||
chapNamesResult = 'error';
|
||||
return;
|
||||
}
|
||||
// SSE streaming response
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
outer: while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (!payload) continue;
|
||||
let evt: { batch?: number; total_batches?: number; chapters_done?: number; total_chapters?: number; chapters?: { number: number; old_title: string; new_title: string }[]; error?: string; done?: boolean };
|
||||
try { evt = JSON.parse(payload); } catch { continue; }
|
||||
if (evt.done) { chapNamesBatchProgress = `Done — ${evt.total_chapters ?? chapNamesPreview.length} chapters`; chapNamesGenerating = false; break outer; }
|
||||
if (evt.error) {
|
||||
chapNamesBatchWarnings = [...chapNamesBatchWarnings, `Batch ${evt.batch}/${evt.total_batches}: ${evt.error}`];
|
||||
} else if (evt.chapters) {
|
||||
chapNamesPreview = [...chapNamesPreview, ...evt.chapters.map((c) => ({ ...c, edited: c.new_title }))];
|
||||
}
|
||||
if (evt.batch != null && evt.total_batches != null) {
|
||||
chapNamesBatchProgress = `Batch ${evt.batch}/${evt.total_batches} · ${evt.chapters_done ?? chapNamesPreview.length}/${evt.total_chapters ?? '?'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chapNamesPreview.length === 0 && chapNamesBatchWarnings.length === 0) chapNamesResult = 'error';
|
||||
} catch {
|
||||
chapNamesResult = 'error';
|
||||
} finally {
|
||||
@@ -316,7 +389,7 @@
|
||||
chapNamesApplying = true;
|
||||
chapNamesResult = '';
|
||||
try {
|
||||
const chapters = chapNamesPreview.map((c) => ({ number: c.number, title: c.new_title }));
|
||||
const chapters = chapNamesPreview.map((c) => ({ number: c.number, title: c.edited?.trim() || c.new_title }));
|
||||
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -890,7 +963,32 @@
|
||||
|
||||
<!-- Book cover generation -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_book_cover()}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_book_cover()}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => (coverPromptOpen = !coverPromptOpen)}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
{coverPromptOpen ? 'Hide prompt' : 'Edit prompt'}
|
||||
</button>
|
||||
<a href="/admin/image-gen" class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors">Full editor ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
{#if coverPromptOpen}
|
||||
<textarea
|
||||
bind:value={coverPrompt}
|
||||
rows="3"
|
||||
placeholder={buildCoverPrompt()}
|
||||
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
|
||||
></textarea>
|
||||
{#if book.cover}
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none w-fit">
|
||||
<input type="checkbox" bind:checked={coverUseAsRef} class="accent-(--color-brand)" />
|
||||
<span class="text-xs text-(--color-muted)">Use current cover as reference (img2img)</span>
|
||||
</label>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={generateCover}
|
||||
@@ -903,7 +1001,7 @@
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
{/if}
|
||||
{m.book_detail_admin_generate()}
|
||||
{m.book_detail_admin_generate()}{coverUseAsRef ? ' (img2img)' : ''}
|
||||
</button>
|
||||
{#if coverResult === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||
@@ -932,6 +1030,12 @@
|
||||
<!-- Chapter cover generation -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_cover()}</p>
|
||||
<textarea
|
||||
bind:value={chapterCoverPrompt}
|
||||
rows="2"
|
||||
placeholder="Chapter {chapterCoverN} illustration for "{data.book?.title}". Dramatic scene, vivid colors…"
|
||||
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
|
||||
></textarea>
|
||||
<div class="flex items-end gap-3 flex-wrap">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="ch-cover-n" class="text-xs text-(--color-muted)">{m.book_detail_admin_chapter_n()}</label>
|
||||
@@ -973,7 +1077,16 @@
|
||||
|
||||
<!-- Description generation -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_description()}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_description()}</p>
|
||||
<a href="/admin/text-gen" class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors">Full editor ↗</a>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={descInstructions}
|
||||
placeholder="e.g. 3-sentence blurb, avoid spoilers, dramatic tone"
|
||||
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
/>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={generateDesc}
|
||||
@@ -1019,6 +1132,12 @@
|
||||
<!-- Chapter names generation -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_names()}</p>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={chapNamesPattern}
|
||||
placeholder="Chapter {'{n}'}: {'{scene}'}"
|
||||
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
/>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={generateChapNames}
|
||||
@@ -1033,18 +1152,30 @@
|
||||
{/if}
|
||||
{m.book_detail_admin_generate()}
|
||||
</button>
|
||||
{#if chapNamesBatchProgress && chapNamesGenerating}
|
||||
<span class="text-xs text-(--color-muted)">{chapNamesBatchProgress}</span>
|
||||
{/if}
|
||||
{#if chapNamesResult === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||
{:else if chapNamesResult === 'applied'}
|
||||
<span class="text-xs text-green-400">{m.book_detail_admin_applied()} ({chapNamesPreview.length > 0 ? chapNamesPreview.length : ''})</span>
|
||||
<span class="text-xs text-green-400">{m.book_detail_admin_applied()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if chapNamesBatchWarnings.length > 0}
|
||||
{#each chapNamesBatchWarnings as w}
|
||||
<p class="text-xs text-amber-400">{w}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if chapNamesPreview.length > 0}
|
||||
<div class="flex flex-col gap-1.5 max-h-48 overflow-y-auto rounded border border-(--color-border) p-2 bg-(--color-surface-3)">
|
||||
{#each chapNamesPreview as ch}
|
||||
<div class="flex gap-2 text-xs">
|
||||
<div class="flex gap-2 text-xs items-center">
|
||||
<span class="text-(--color-muted) flex-shrink-0 w-6 text-right">{ch.number}.</span>
|
||||
<span class="text-(--color-text) truncate">{ch.new_title}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={ch.edited}
|
||||
class="flex-1 min-w-0 bg-transparent border-b border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) py-0.5"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1057,7 +1188,7 @@
|
||||
>
|
||||
{chapNamesApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()} ({chapNamesPreview.length})
|
||||
</button>
|
||||
<button onclick={() => (chapNamesPreview = [])} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
|
||||
<button onclick={() => { chapNamesPreview = []; chapNamesBatchProgress = ''; chapNamesBatchWarnings = []; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -204,7 +204,9 @@
|
||||
{ code: 'pt', label: 'PT' },
|
||||
{ code: 'fr', label: 'FR' }
|
||||
];
|
||||
// svelte-ignore state_referenced_locally
|
||||
let translationStatus = $state(data.translationStatus ?? 'idle');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let translatingLang = $state(data.lang ?? '');
|
||||
let pollingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -505,6 +507,7 @@
|
||||
<!-- ── Paginated reader ─────────────────────────────────────────────── -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="none"
|
||||
bind:this={paginatedContainerEl}
|
||||
class="paginated-container mt-8"
|
||||
style="height: {layout.focusMode ? 'calc(100svh - 8rem)' : 'calc(100svh - 26rem)'};"
|
||||
@@ -611,7 +614,7 @@
|
||||
{#if settingsPanelOpen}
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
|
||||
<div role="none" class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
|
||||
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) rounded-t-2xl shadow-2xl flex flex-col max-h-[80dvh]">
|
||||
|
||||
|
||||
@@ -34,10 +34,13 @@
|
||||
}
|
||||
|
||||
let prefs = $state<Prefs>(loadPrefs());
|
||||
// svelte-ignore state_referenced_locally
|
||||
let showOnboarding = $state(!prefs.onboarded);
|
||||
|
||||
// Onboarding temp state
|
||||
// svelte-ignore state_referenced_locally
|
||||
let tempGenres = $state<string[]>([...prefs.genres]);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let tempStatus = $state<Prefs['status']>(prefs.status);
|
||||
|
||||
function finishOnboarding(skip = false) {
|
||||
@@ -85,6 +88,7 @@
|
||||
let voted = $state<{ slug: string; action: string } | null>(null); // last voted, for undo
|
||||
let activeTab = $state<'discover' | 'history'>('discover');
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let votedBooks = $state<VotedBook[]>(data.votedBooks ?? []);
|
||||
|
||||
// Keep in sync if server data refreshes
|
||||
@@ -356,6 +360,7 @@
|
||||
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -529,8 +534,10 @@
|
||||
|
||||
<!-- Active card -->
|
||||
{#if currentBook}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={cardEl}
|
||||
role="none"
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
|
||||
style="
|
||||
transform: {activeTransform};
|
||||
|
||||
@@ -556,6 +556,7 @@
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoNext}
|
||||
aria-label="Auto-advance to next chapter"
|
||||
onclick={() => (autoNext = !autoNext)}
|
||||
class="shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'}"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user