Compare commits

..

4 Commits

Author SHA1 Message Date
root
2571c243c9 perf: stream slow load functions in admin pages to unblock navigation
All checks were successful
Release / Test backend (push) Successful in 50s
Release / Check ui (push) Successful in 2m4s
Release / Docker (push) Successful in 5m42s
Release / Gitea Release (push) Successful in 36s
- image-gen, text-gen: books list streamed (listBooks is expensive on cold cache)
- ai-jobs: jobs list streamed; add 30s cache to listAIJobs (was uncached listAll)
- changelog: Gitea releases streamed on cold cache; cached path stays synchronous
- admin/+layout.svelte: remove duplicate audio/translation/image-gen nav links
2026-04-09 13:02:32 +05:00
root
89f0d6a546 fix: forward multipart/form-data correctly in import API proxy
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m43s
Release / Docker (push) Successful in 5m51s
Release / Gitea Release (push) Successful in 39s
The SvelteKit proxy was calling request.json() unconditionally,
consuming the body before forwarding. File uploads (multipart/form-data)
now use request.formData() and pass the FormData directly to backendFetch
so the fetch API sets the correct Content-Type boundary automatically.
2026-04-09 12:39:20 +05:00
root
8bc9460989 fix: force-add missing admin_nav_import.js paraglide generated file
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 1m47s
Release / Docker (push) Successful in 7m11s
Release / Gitea Release (push) Successful in 41s
The paraglide .gitignore uses '*' to ignore all generated files.
admin_nav_import.js was never force-added after the key was introduced
in v2.6.44, causing svelte-check to fail in CI with 'Cannot find module'.
2026-04-09 12:21:44 +05:00
root
fcd4b3ad7f fix: wire import chapter ingestion, live task polling, a11y labels, notification user targeting
Some checks failed
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Failing after 36s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- NewBookImporter now takes *Store instead of raw *minio.Client (same package access)
- Runner Dependencies gains ChapterIngester interface; storeImportedChapters no-op removed
- store.IngestChapters is now actually called after PDF/EPUB extraction
- BookImport and ChapterIngester wired in runner main.go
- CreateImportTask interface gains initiatorUserID param; threaded through store/asynq/handler
- domain.ImportTask gains InitiatorUserID field; parseImportTask populates it
- Runner notifies initiator (falls back to 'admin' when empty)
- UI: import task list polls every 3s while any task is pending/running
- UI: label[for] + input[id] fix for a11y warnings in admin import page
2026-04-09 11:00:01 +05:00
25 changed files with 346 additions and 173 deletions

View File

@@ -197,6 +197,8 @@ func run() error {
AudioStore: store,
CoverStore: store,
TranslationStore: store,
BookImport: storage.NewBookImporter(store),
ChapterIngester: store,
SearchIndex: searchIndex,
Novel: novel,
Kokoro: kokoroClient,

View File

@@ -88,8 +88,8 @@ func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapt
}
// CreateImportTask creates a PocketBase record then enqueues an Asynq job for PDF/EPUB import.
func (p *Producer) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey string) (string, error) {
id, err := p.pb.CreateImportTask(ctx, slug, title, fileType, objectKey)
func (p *Producer) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) {
id, err := p.pb.CreateImportTask(ctx, slug, title, fileType, objectKey, initiatorUserID)
if err != nil {
return "", err
}

View File

@@ -115,7 +115,7 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
return -1
}, slug)
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey)
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey, "")
if err != nil {
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
return

View File

@@ -0,0 +1,69 @@
package backend
import (
"encoding/json"
"net/http"
"github.com/libnovel/backend/internal/storage"
)
type notification struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Message string `json:"message"`
Link string `json:"link"`
Read bool `json:"read"`
}
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
if userID == "" {
jsonError(w, http.StatusBadRequest, "user_id required")
return
}
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
items, err := store.ListNotifications(r.Context(), userID, 50)
if err != nil {
jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error())
return
}
// Parse each item as notification
notifications := make([]notification, 0, len(items))
for _, item := range items {
b, _ := json.Marshal(item)
var n notification
json.Unmarshal(b, &n)
notifications = append(notifications, n)
}
writeJSON(w, 0, map[string]any{"notifications": notifications})
}
func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
jsonError(w, http.StatusBadRequest, "notification id required")
return
}
store, ok := s.deps.Producer.(*storage.Store)
if !ok {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
if err := store.MarkNotificationRead(r.Context(), id); err != nil {
jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}

View File

@@ -172,18 +172,19 @@ type TranslationResult struct {
// ImportTask represents a PDF/EPUB import job stored in PocketBase.
type ImportTask struct {
ID string `json:"id"`
Slug string `json:"slug"` // derived from filename
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
WorkerID string `json:"worker_id,omitempty"`
Status TaskStatus `json:"status"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
ID string `json:"id"`
Slug string `json:"slug"` // derived from filename
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
WorkerID string `json:"worker_id,omitempty"`
InitiatorUserID string `json:"initiator_user_id,omitempty"` // PocketBase user ID who submitted the import
Status TaskStatus `json:"status"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// ImportResult is the outcome reported by the runner after finishing an ImportTask.

View File

@@ -44,6 +44,11 @@ type Notifier interface {
CreateNotification(ctx context.Context, userID, title, message, link string) error
}
// ChapterIngester persists imported chapters for a book.
type ChapterIngester interface {
IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error
}
// Config tunes the runner behaviour.
type Config struct {
// WorkerID uniquely identifies this runner instance in PocketBase records.
@@ -110,6 +115,8 @@ type Dependencies struct {
CoverStore bookstore.CoverStore
// BookImport handles PDF/EPUB file parsing and chapter extraction.
BookImport bookstore.BookImporter
// ChapterIngester persists extracted chapters into MinIO/PocketBase.
ChapterIngester ChapterIngester
// Notifier creates notifications for users.
Notifier Notifier
// SearchIndex indexes books in Meilisearch after scraping.
@@ -712,9 +719,12 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
})
}
// For now, we'll call a simple store method - in production this would
// go through BookWriter or a dedicated method
if err := r.storeImportedChapters(ctx, task.Slug, domainChapters); err != nil {
// Store chapters via ChapterIngester
if r.deps.ChapterIngester == nil {
fail("chapter ingester not configured")
return
}
if err := r.deps.ChapterIngester.IngestChapters(ctx, task.Slug, domainChapters); err != nil {
fail(fmt.Sprintf("store chapters: %v", err))
return
}
@@ -729,27 +739,15 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
log.Error("runner: FinishImportTask failed", "err", err)
}
// Create notification for admin
// Create notification for the user who initiated the import
if r.deps.Notifier != nil {
msg := fmt.Sprintf("Import completed: %d chapters from %s", len(chapters), task.Title)
_ = r.deps.Notifier.CreateNotification(ctx, "admin", "Import Complete", msg, "/admin/import")
targetUser := task.InitiatorUserID
if targetUser == "" {
targetUser = "admin"
}
_ = r.deps.Notifier.CreateNotification(ctx, targetUser, "Import Complete", msg, "/admin/import")
}
log.Info("runner: import task finished", "chapters", len(chapters))
}
// storeImportedChapters stores imported chapters in MinIO (similar to scraped chapters).
func (r *Runner) storeImportedChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error {
for _, ch := range chapters {
_ = fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
if ch.Title != "" {
_ = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
}
_ = fmt.Sprintf("books/%s/chapters/%d.md", slug, ch.Number)
// Use MinIO client directly since we have access to it via BookWriter/Store
// In a real implementation, this would be abstracted through BookWriter
r.deps.Log.Info("runner: stored chapter", "slug", slug, "chapter", ch.Number)
}
// TODO: Actually store via BookWriter or direct MinIO call
return nil
}

View File

@@ -1,7 +1,6 @@
package storage
import (
"bytes"
"context"
"errors"
"fmt"
@@ -10,7 +9,7 @@ import (
"strings"
"github.com/libnovel/backend/internal/bookstore"
"github.com/minio/minio-go/v7"
minio "github.com/minio/minio-go/v7"
)
var (
@@ -18,12 +17,12 @@ var (
)
type importer struct {
mc *minio.Client
mc *minioClient
}
// NewBookImporter creates a BookImporter that reads files from MinIO.
func NewBookImporter(mc *minio.Client) bookstore.BookImporter {
return &importer{mc: mc}
func NewBookImporter(s *Store) bookstore.BookImporter {
return &importer{mc: s.mc}
}
func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bookstore.Chapter, error) {
@@ -31,7 +30,7 @@ func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bo
return nil, fmt.Errorf("unsupported file type: %s", fileType)
}
obj, err := i.mc.GetObject(ctx, "imports", objectKey, minio.GetObjectOptions{})
obj, err := i.mc.client.GetObject(ctx, "imports", objectKey, minio.GetObjectOptions{})
if err != nil {
return nil, fmt.Errorf("get object from minio: %w", err)
}
@@ -142,23 +141,4 @@ func (s *Store) IngestChapters(ctx context.Context, slug string, chapters []book
// GetImportObjectKey returns the MinIO object key for an uploaded import file.
func GetImportObjectKey(filename string) string {
return fmt.Sprintf("imports/%s", filename)
}
func parsePDFWithPython(data []byte) ([]bookstore.Chapter, error) {
// This would require calling an external Python script or service
// For now, return placeholder - in production, this would integrate with
// the Python pypdf library via subprocess or API call
return nil, errors.New("PDF parsing requires Python integration")
}
// Debug helper - decode a base64-encoded PDF from bytes and extract text
func extractTextFromPDFBytes(data []byte) (string, error) {
// This is a placeholder - in production we'd use a proper Go PDF library
// like github.com/ledongthuc/pdf or the Python approach
var buf bytes.Buffer
_, err := buf.Write(data)
if err != nil {
return "", err
}
return "", errors.New("PDF text extraction not implemented in Go")
}

View File

@@ -647,16 +647,17 @@ func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter
return rec.ID, nil
}
func (s *Store) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey string) (string, error) {
func (s *Store) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) {
payload := map[string]any{
"slug": slug,
"title": title,
"file_name": slug + "." + fileType,
"file_type": fileType,
"status": string(domain.TaskStatusPending),
"chapters_done": 0,
"chapters_total": 0,
"started": time.Now().UTC().Format(time.RFC3339),
"slug": slug,
"title": title,
"file_name": slug + "." + fileType,
"file_type": fileType,
"status": string(domain.TaskStatusPending),
"chapters_done": 0,
"chapters_total": 0,
"started": time.Now().UTC().Format(time.RFC3339),
"initiator_user_id": initiatorUserID,
}
var rec struct {
ID string `json:"id"`
@@ -1134,6 +1135,7 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
FileName string `json:"file_name"`
FileType string `json:"file_type"`
WorkerID string `json:"worker_id"`
InitiatorUserID string `json:"initiator_user_id"`
Status string `json:"status"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
@@ -1153,6 +1155,7 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
FileName: rec.FileName,
FileType: rec.FileType,
WorkerID: rec.WorkerID,
InitiatorUserID: rec.InitiatorUserID,
Status: domain.TaskStatus(rec.Status),
ChaptersDone: rec.ChaptersDone,
ChaptersTotal: rec.ChaptersTotal,

View File

@@ -35,7 +35,8 @@ type Producer interface {
// CreateImportTask inserts a new import task with status=pending and
// returns the assigned PocketBase record ID.
CreateImportTask(ctx context.Context, slug, title, fileType, objectKey string) (string, error)
// initiatorUserID is the PocketBase user ID who submitted the import (may be empty).
CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error)
// CancelTask transitions a pending task to status=cancelled.
// Returns ErrNotFound if the task does not exist.

View File

@@ -26,7 +26,7 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
return "translation-1", nil
}
func (s *stubStore) CreateImportTask(_ context.Context, _, _, _, _ string) (string, error) {
func (s *stubStore) CreateImportTask(_ context.Context, _, _, _, _, _ string) (string, error) {
return "import-1", nil
}
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_ImportInputs */
const en_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const ru_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const id_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const pt_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const fr_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
/**
* | output |
* | --- |
* | "Import" |
*
* @param {Admin_Nav_ImportInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_import = /** @type {((inputs?: Admin_Nav_ImportInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ImportInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_import(inputs)
if (locale === "ru") return ru_admin_nav_import(inputs)
if (locale === "id") return id_admin_nav_import(inputs)
if (locale === "pt") return pt_admin_nav_import(inputs)
return fr_admin_nav_import(inputs)
});

View File

@@ -2287,10 +2287,17 @@ export async function getUserStats(
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
const AI_JOBS_CACHE_KEY = 'admin:ai_jobs';
const AI_JOBS_CACHE_TTL = 30; // 30 seconds — same as other admin job lists
/**
* List all AI jobs from PocketBase, sorted by started descending.
* No caching — admin views always want fresh data.
* Short-lived cache (30s) to avoid hammering PocketBase on every navigation.
*/
export async function listAIJobs(): Promise<AIJob[]> {
return listAll<AIJob>('ai_jobs', '', '-started');
const cached = await cache.get<AIJob[]>(AI_JOBS_CACHE_KEY);
if (cached) return cached;
const jobs = await listAll<AIJob>('ai_jobs', '', '-started');
await cache.set(AI_JOBS_CACHE_KEY, jobs, AI_JOBS_CACHE_TTL);
return jobs;
}

View File

@@ -28,21 +28,6 @@
label: () => m.admin_nav_image_gen(),
icon: `<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" />`
},
{
href: '/admin/audio',
label: () => m.admin_nav_audio(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />`
},
{
href: '/admin/translation',
label: () => m.admin_nav_translation(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />`
},
{
href: '/admin/image-gen',
label: () => m.admin_nav_image_gen(),
icon: `<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" />`
},
{
href: '/admin/text-gen',
label: () => m.admin_nav_text_gen(),

View File

@@ -6,7 +6,8 @@ export type { AIJob };
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
const jobs = await listAIJobs().catch((e): AIJob[] => {
// Stream jobs so navigation is instant; list populates a moment later.
const jobs = listAIJobs().catch((e): AIJob[] => {
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
return [];
});

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { AIJob } from '$lib/server/pocketbase';
@@ -8,11 +7,11 @@
let { data }: { data: PageData } = $props();
let jobs = $state<AIJob[]>(untrack(() => data.jobs));
let jobs = $state<AIJob[]>([]);
// Keep in sync on server reloads
// Resolve streamed promise on load and on server reloads (invalidateAll)
$effect(() => {
jobs = data.jobs;
data.jobs.then((resolved) => { jobs = resolved; });
});
// ── Live-poll while any job is in-flight ─────────────────────────────────────

View File

@@ -18,23 +18,27 @@ const CACHE_KEY = 'admin:changelog:releases';
const CACHE_TTL = 5 * 60; // 5 minutes
export const load: PageServerLoad = async ({ fetch }) => {
// Return cached data synchronously (no streaming needed — already fast).
const cached = await cache.get<Release[]>(CACHE_KEY);
if (cached) {
return { releases: cached };
return { releases: cached, error: undefined as string | undefined };
}
try {
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
});
if (!res.ok) {
return { releases: [], error: `Gitea API returned ${res.status}` };
// Cache miss: stream the external Gitea request so navigation isn't blocked.
const releasesPromise = (async () => {
try {
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
});
if (!res.ok) return [] as Release[];
const releases: Release[] = await res.json();
const filtered = releases.filter((r) => !r.draft);
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
return filtered;
} catch {
return [] as Release[];
}
const releases: Release[] = await res.json();
const filtered = releases.filter((r) => !r.draft);
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
return { releases: filtered };
} catch (e) {
return { releases: [], error: String(e) };
}
})();
return { releases: releasesPromise, error: undefined as string | undefined };
};

View File

@@ -32,29 +32,33 @@
</a>
</div>
{#if data.error}
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: data.error })}</p>
{:else if data.releases.length === 0}
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
{:else}
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each data.releases as release}
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
<div class="flex items-baseline gap-3 flex-wrap">
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
{#if release.name && release.name !== release.tag_name}
<span class="text-sm text-(--color-text)">{release.name}</span>
{#await data.releases}
<p class="text-sm text-(--color-muted) py-8 text-center">Loading releases…</p>
{:then releases}
{#if releases.length === 0}
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
{:else}
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each releases as release}
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
<div class="flex items-baseline gap-3 flex-wrap">
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
{#if release.name && release.name !== release.tag_name}
<span class="text-sm text-(--color-text)">{release.name}</span>
{/if}
{#if release.prerelease}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
{/if}
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
</div>
{#if release.body.trim()}
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
{/if}
{#if release.prerelease}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
{/if}
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
</div>
{#if release.body.trim()}
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
{/if}
</div>
{/each}
</div>
{/if}
{/each}
</div>
{/if}
{:catch}
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: 'Failed to load releases' })}</p>
{/await}
</div>

View File

@@ -20,23 +20,29 @@ export interface BookSummary {
}
export const load: PageServerLoad = async () => {
// parent layout already guards admin role
const [models, booksResult] = await Promise.allSettled([
listImageModels<ImageModelInfo>(),
listBooks()
]);
// Await models immediately — the page is unusable without them and the
// backend returns this list instantly (in-memory, no I/O).
// Books are streamed: the page renders at once and the book selector
// populates a moment later without blocking navigation.
const modelsResult = await listImageModels<ImageModelInfo>().catch((e) => {
log.warn('admin/image-gen', 'failed to load models', { err: String(e) });
return [] as ImageModelInfo[];
});
if (models.status === 'rejected') {
log.warn('admin/image-gen', 'failed to load models', { err: String(models.reason) });
}
const booksPromise = listBooks()
.then((all) =>
all.map((b) => ({
slug: b.slug,
title: b.title,
summary: b.summary ?? '',
cover: b.cover ?? ''
})) as BookSummary[]
)
.catch(() => [] as BookSummary[]);
return {
models: models.status === 'fulfilled' ? models.value : ([] as ImageModelInfo[]),
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
slug: b.slug,
title: b.title,
summary: b.summary ?? '',
cover: b.cover ?? ''
})) as BookSummary[]
models: modelsResult,
// Streamed — SvelteKit resolves this after the initial HTML is sent.
books: booksPromise
};
};

View File

@@ -62,8 +62,11 @@
});
// ── Book autocomplete ────────────────────────────────────────────────────────
// svelte-ignore state_referenced_locally
const books: BookSummary[] = data.books ?? [];
// Books arrive as a streamed promise — start empty and populate on resolve.
let books = $state<BookSummary[]>([]);
$effect(() => {
data.books.then((resolved) => { books = resolved; });
});
let slugInput = $state('');
let slugFocused = $state(false);
let selectedBook = $state<BookSummary | null>(null);

View File

@@ -139,6 +139,16 @@
onMount(() => {
loadTasks();
});
// Poll every 3s while any task is running
$effect(() => {
const hasRunning = tasks.some((t) => t.status === 'running' || t.status === 'pending');
if (!hasRunning) return;
const timer = setInterval(() => {
loadTasks();
}, 3000);
return () => clearInterval(timer);
});
</script>
<div class="max-w-4xl">
@@ -188,8 +198,9 @@
<!-- Upload Form -->
<form onsubmit={(e) => { e.preventDefault(); analyzeFile(); }} class="mb-8 p-4 bg-(--color-surface-2) rounded-lg">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Select File (PDF or EPUB)</label>
<label for="import-file" class="block text-sm font-medium mb-2">Select File (PDF or EPUB)</label>
<input
id="import-file"
type="file"
accept=".pdf,.epub"
onchange={handleFileSelect}
@@ -197,8 +208,9 @@
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Book Title</label>
<label for="import-title" class="block text-sm font-medium mb-2">Book Title</label>
<input
id="import-title"
type="text"
bind:value={title}
placeholder="Enter book title"

View File

@@ -17,21 +17,23 @@ export interface TextModelInfo {
}
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
const [models, booksResult] = await Promise.allSettled([
listTextModels<TextModelInfo>(),
listBooks()
]);
// Await models immediately — in-memory list, no I/O, returns instantly.
// Books are streamed so the page renders at once and the selector
// populates a moment later without blocking navigation.
const modelsResult = await listTextModels<TextModelInfo>().catch((e) => {
log.warn('admin/text-gen', 'failed to load models', { err: String(e) });
return [] as TextModelInfo[];
});
if (models.status === 'rejected') {
log.warn('admin/text-gen', 'failed to load models', { err: String(models.reason) });
}
const booksPromise = listBooks()
.then((all) =>
all.map((b) => ({ slug: b.slug, title: b.title })) as BookSummary[]
)
.catch(() => [] as BookSummary[]);
return {
models: models.status === 'fulfilled' ? models.value : ([] as TextModelInfo[]),
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
slug: b.slug,
title: b.title
})) as BookSummary[]
models: modelsResult,
// Streamed — SvelteKit resolves this after the initial HTML is sent.
books: booksPromise
};
};

View File

@@ -9,8 +9,11 @@
// 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 ?? [];
// Books arrive as a streamed promise — start empty and populate on resolve.
let books = $state<BookSummary[]>([]);
$effect(() => {
data.books.then((resolved) => { books = resolved; });
});
// ── Config persistence ───────────────────────────────────────────────────────
const CONFIG_KEY = 'admin_text_gen_config_v2';

View File

@@ -17,22 +17,41 @@ export const GET: RequestHandler = async ({ locals }) => {
/**
* POST /api/admin/import
* Create a new import task.
* Create a new import task. Supports both multipart/form-data (file upload)
* and application/json (object key reference).
*/
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.json();
const res = await backendFetch('/api/admin/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const ct = request.headers.get('content-type') ?? '';
let res: Response;
if (ct.includes('multipart/form-data')) {
// Forward the raw FormData body; let the browser-set Content-Type
// (which includes the boundary) pass through unchanged.
const formData = await request.formData();
res = await backendFetch('/api/admin/import', {
method: 'POST',
// Do NOT set Content-Type manually — the fetch API sets it
// automatically with the correct boundary when given a FormData body.
body: formData
});
} else {
const body = await request.json();
res = await backendFetch('/api/admin/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Failed to create import task' }));
throw error(res.status, err.error || 'Failed to create import task');
}
const data = await res.json();
return json(data);
};
};

View File

@@ -0,0 +1,11 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
export const GET: RequestHandler = async ({ url }) => {
const userId = url.searchParams.get('user_id');
if (!userId) throw error(400, 'user_id required');
const res = await backendFetch('/api/notifications?user_id=' + userId);
const data = await res.json().catch(() => ({ notifications: [] }));
return json(data);
};

View File

@@ -0,0 +1,19 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
export const GET: RequestHandler = async ({ url }) => {
const userId = url.searchParams.get('user_id');
if (!userId) throw error(400, 'user_id required');
const res = await backendFetch('/api/notifications?user_id=' + userId);
const data = await res.json().catch(() => ({ notifications: [] }));
return json(data);
};
export const PATCH: RequestHandler = async ({ params }) => {
const id = params.id;
if (!id) throw error(400, 'id required');
const res = await backendFetch('/api/notifications/' + id, { method: 'PATCH' });
const data = await res.json().catch(() => ({}));
return json(data);
};