feat: async image-gen and description jobs with review panels
Some checks failed
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Failing after 38s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m44s
Release / Docker / runner (push) Successful in 2m45s
Release / Gitea Release (push) Has been skipped
Some checks failed
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Failing after 38s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m44s
Release / Docker / runner (push) Successful in 2m45s
Release / Gitea Release (push) Has been skipped
- Add POST /api/admin/image-gen/async: fire-and-forget image generation that stores the result (base64) in an ai_job payload and returns 202 immediately — no more 60-120s blocking on FLUX models - Add POST /api/admin/text-gen/description/async: same pattern for book description generation - Register both new routes in server.go - Rewrite image-gen admin page to use the async path (submit → redirect to AI Jobs for monitoring) - Extend ai-jobs page with Review panels for image-gen jobs (show image, Save as cover / Download / Discard) and description jobs (diff old vs new, editable textarea, Apply / Discard)
This commit is contained in:
@@ -1,14 +1,17 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/libnovel/backend/internal/cfai"
|
"github.com/libnovel/backend/internal/cfai"
|
||||||
|
"github.com/libnovel/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleAdminImageGenModels handles GET /api/admin/image-gen/models.
|
// handleAdminImageGenModels handles GET /api/admin/image-gen/models.
|
||||||
@@ -288,3 +291,231 @@ func sniffImageContentType(data []byte) string {
|
|||||||
}
|
}
|
||||||
return "image/png"
|
return "image/png"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAdminImageGenAsync handles POST /api/admin/image-gen/async.
|
||||||
|
//
|
||||||
|
// Fire-and-forget variant: validates the request, creates an ai_job record of
|
||||||
|
// kind "image-gen", spawns a background goroutine, and returns HTTP 202 with
|
||||||
|
// {job_id} immediately. The goroutine calls Cloudflare AI, stores the result
|
||||||
|
// as base64 in the job payload, and marks the job done/failed when finished.
|
||||||
|
//
|
||||||
|
// The admin can then review the result via the ai-jobs page and approve
|
||||||
|
// (save as cover) or reject (discard) the image.
|
||||||
|
func (s *Server) handleAdminImageGenAsync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.deps.ImageGen == nil {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.deps.AIJobStore == nil {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req imageGenRequest
|
||||||
|
var refImageData []byte
|
||||||
|
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if jsonPart := r.FormValue("json"); jsonPart != "" {
|
||||||
|
if err := json.Unmarshal([]byte(jsonPart), &req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "parse json field: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f, _, err := r.FormFile("reference"); err == nil {
|
||||||
|
defer f.Close()
|
||||||
|
refImageData, _ = io.ReadAll(f)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ReferenceImageB64 != "" {
|
||||||
|
var decErr error
|
||||||
|
refImageData, decErr = base64.StdEncoding.DecodeString(req.ReferenceImageB64)
|
||||||
|
if decErr != nil {
|
||||||
|
refImageData, decErr = base64.RawStdEncoding.DecodeString(req.ReferenceImageB64)
|
||||||
|
if decErr != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "decode reference_image_b64: "+decErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.Prompt) == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "prompt is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Type != "cover" && req.Type != "chapter" {
|
||||||
|
jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Slug == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Type == "chapter" && req.Chapter <= 0 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "chapter must be > 0 when type is chapter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve model.
|
||||||
|
model := cfai.ImageModel(req.Model)
|
||||||
|
if model == "" {
|
||||||
|
if req.Type == "cover" {
|
||||||
|
model = cfai.DefaultImageModel
|
||||||
|
} else {
|
||||||
|
model = cfai.ImageModelFlux2Klein4B
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode request params as job payload so the UI can reconstruct context.
|
||||||
|
type jobParams struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Chapter int `json:"chapter,omitempty"`
|
||||||
|
NumSteps int `json:"num_steps,omitempty"`
|
||||||
|
Width int `json:"width,omitempty"`
|
||||||
|
Height int `json:"height,omitempty"`
|
||||||
|
Guidance float64 `json:"guidance,omitempty"`
|
||||||
|
Strength float64 `json:"strength,omitempty"`
|
||||||
|
HasRef bool `json:"has_ref,omitempty"`
|
||||||
|
}
|
||||||
|
paramsJSON, _ := json.Marshal(jobParams{
|
||||||
|
Prompt: req.Prompt,
|
||||||
|
Type: req.Type,
|
||||||
|
Chapter: req.Chapter,
|
||||||
|
NumSteps: req.NumSteps,
|
||||||
|
Width: req.Width,
|
||||||
|
Height: req.Height,
|
||||||
|
Guidance: req.Guidance,
|
||||||
|
Strength: req.Strength,
|
||||||
|
HasRef: len(refImageData) > 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
|
||||||
|
Kind: "image-gen",
|
||||||
|
Slug: req.Slug,
|
||||||
|
Status: domain.TaskStatusPending,
|
||||||
|
Model: string(model),
|
||||||
|
Payload: string(paramsJSON),
|
||||||
|
Started: time.Now(),
|
||||||
|
})
|
||||||
|
if createErr != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "create ai job: "+createErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobCtx, jobCancel := context.WithCancel(context.Background())
|
||||||
|
registerCancelJob(jobID, jobCancel)
|
||||||
|
|
||||||
|
// Mark running before returning.
|
||||||
|
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
|
||||||
|
"status": string(domain.TaskStatusRunning),
|
||||||
|
})
|
||||||
|
|
||||||
|
s.deps.Log.Info("admin: image-gen async started",
|
||||||
|
"job_id", jobID, "slug", req.Slug, "type", req.Type, "model", model)
|
||||||
|
|
||||||
|
// Capture locals for the goroutine.
|
||||||
|
store := s.deps.AIJobStore
|
||||||
|
imageGen := s.deps.ImageGen
|
||||||
|
coverStore := s.deps.CoverStore
|
||||||
|
logger := s.deps.Log
|
||||||
|
capturedReq := req
|
||||||
|
capturedModel := model
|
||||||
|
capturedRefImage := refImageData
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer deregisterCancelJob(jobID)
|
||||||
|
defer jobCancel()
|
||||||
|
|
||||||
|
if jobCtx.Err() != nil {
|
||||||
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"status": string(domain.TaskStatusCancelled),
|
||||||
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imgReq := cfai.ImageRequest{
|
||||||
|
Prompt: capturedReq.Prompt,
|
||||||
|
Model: capturedModel,
|
||||||
|
NumSteps: capturedReq.NumSteps,
|
||||||
|
Width: capturedReq.Width,
|
||||||
|
Height: capturedReq.Height,
|
||||||
|
Guidance: capturedReq.Guidance,
|
||||||
|
Strength: capturedReq.Strength,
|
||||||
|
}
|
||||||
|
|
||||||
|
var imgData []byte
|
||||||
|
var genErr error
|
||||||
|
if len(capturedRefImage) > 0 {
|
||||||
|
imgData, genErr = imageGen.GenerateImageFromReference(jobCtx, imgReq, capturedRefImage)
|
||||||
|
} else {
|
||||||
|
imgData, genErr = imageGen.GenerateImage(jobCtx, imgReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
if genErr != nil {
|
||||||
|
logger.Error("admin: image-gen async failed", "job_id", jobID, "err", genErr)
|
||||||
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"status": string(domain.TaskStatusFailed),
|
||||||
|
"error_message": genErr.Error(),
|
||||||
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := sniffImageContentType(imgData)
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(imgData)
|
||||||
|
|
||||||
|
// Build result payload: include the original params + the generated image.
|
||||||
|
type resultPayload struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Chapter int `json:"chapter,omitempty"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
ImageB64 string `json:"image_b64"`
|
||||||
|
Bytes int `json:"bytes"`
|
||||||
|
NumSteps int `json:"num_steps,omitempty"`
|
||||||
|
Width int `json:"width,omitempty"`
|
||||||
|
Height int `json:"height,omitempty"`
|
||||||
|
Guidance float64 `json:"guidance,omitempty"`
|
||||||
|
}
|
||||||
|
resultJSON, _ := json.Marshal(resultPayload{
|
||||||
|
Prompt: capturedReq.Prompt,
|
||||||
|
Type: capturedReq.Type,
|
||||||
|
Chapter: capturedReq.Chapter,
|
||||||
|
ContentType: contentType,
|
||||||
|
ImageB64: b64,
|
||||||
|
Bytes: len(imgData),
|
||||||
|
NumSteps: capturedReq.NumSteps,
|
||||||
|
Width: capturedReq.Width,
|
||||||
|
Height: capturedReq.Height,
|
||||||
|
Guidance: capturedReq.Guidance,
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"status": string(domain.TaskStatusDone),
|
||||||
|
"items_done": 1,
|
||||||
|
"items_total": 1,
|
||||||
|
"payload": string(resultJSON),
|
||||||
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.Info("admin: image-gen async done",
|
||||||
|
"job_id", jobID, "slug", capturedReq.Slug,
|
||||||
|
"bytes", len(imgData), "content_type", contentType)
|
||||||
|
|
||||||
|
// Suppress unused variable warning for coverStore when SaveToCover is false.
|
||||||
|
_ = coverStore
|
||||||
|
}()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
|
||||||
|
}
|
||||||
|
|||||||
@@ -801,3 +801,161 @@ func (s *Server) handleAdminTextGenApplyDescription(w http.ResponseWriter, r *ht
|
|||||||
s.deps.Log.Info("admin: book description applied", "slug", req.Slug)
|
s.deps.Log.Info("admin: book description applied", "slug", req.Slug)
|
||||||
writeJSON(w, 0, map[string]any{"updated": true})
|
writeJSON(w, 0, map[string]any{"updated": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAdminTextGenDescriptionAsync handles POST /api/admin/text-gen/description/async.
|
||||||
|
//
|
||||||
|
// Fire-and-forget variant: validates inputs, creates an ai_job record of kind
|
||||||
|
// "description", spawns a background goroutine that calls the LLM, stores the
|
||||||
|
// old/new description in the job payload, and marks the job done/failed.
|
||||||
|
// Returns HTTP 202 with {job_id} immediately.
|
||||||
|
func (s *Server) handleAdminTextGenDescriptionAsync(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)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.deps.AIJobStore == nil {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req textGenDescriptionRequest
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load current metadata eagerly so we can fail fast if the book is missing.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
instructions := strings.TrimSpace(req.Instructions)
|
||||||
|
if instructions == "" {
|
||||||
|
instructions = "Write a compelling 2–4 sentence description. Keep it spoiler-free and engaging."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode the initial params (without result) as the starting payload.
|
||||||
|
type initPayload struct {
|
||||||
|
Instructions string `json:"instructions"`
|
||||||
|
OldDescription string `json:"old_description"`
|
||||||
|
}
|
||||||
|
initJSON, _ := json.Marshal(initPayload{
|
||||||
|
Instructions: instructions,
|
||||||
|
OldDescription: meta.Summary,
|
||||||
|
})
|
||||||
|
|
||||||
|
jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
|
||||||
|
Kind: "description",
|
||||||
|
Slug: req.Slug,
|
||||||
|
Status: domain.TaskStatusPending,
|
||||||
|
Model: string(model),
|
||||||
|
Payload: string(initJSON),
|
||||||
|
Started: time.Now(),
|
||||||
|
})
|
||||||
|
if createErr != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "create ai job: "+createErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobCtx, jobCancel := context.WithCancel(context.Background())
|
||||||
|
registerCancelJob(jobID, jobCancel)
|
||||||
|
|
||||||
|
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
|
||||||
|
"status": string(domain.TaskStatusRunning),
|
||||||
|
})
|
||||||
|
|
||||||
|
s.deps.Log.Info("admin: text-gen description async started",
|
||||||
|
"job_id", jobID, "slug", req.Slug, "model", model)
|
||||||
|
|
||||||
|
// Capture locals.
|
||||||
|
store := s.deps.AIJobStore
|
||||||
|
textGen := s.deps.TextGen
|
||||||
|
logger := s.deps.Log
|
||||||
|
capturedMeta := meta
|
||||||
|
capturedModel := model
|
||||||
|
capturedInstructions := instructions
|
||||||
|
capturedMaxTokens := req.MaxTokens
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer deregisterCancelJob(jobID)
|
||||||
|
defer jobCancel()
|
||||||
|
|
||||||
|
if jobCtx.Err() != nil {
|
||||||
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"status": string(domain.TaskStatusCancelled),
|
||||||
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt := `You are a book description writer for a web novel platform. ` +
|
||||||
|
`Given a book's title, author, genres, and current description, write an improved ` +
|
||||||
|
`description that accurately captures the story. ` +
|
||||||
|
`Respond with ONLY the new description text — no title, no labels, no markdown, no quotes.`
|
||||||
|
|
||||||
|
userPrompt := fmt.Sprintf(
|
||||||
|
"Title: %s\nAuthor: %s\nGenres: %s\nStatus: %s\n\nCurrent description:\n%s\n\nInstructions: %s",
|
||||||
|
capturedMeta.Title,
|
||||||
|
capturedMeta.Author,
|
||||||
|
strings.Join(capturedMeta.Genres, ", "),
|
||||||
|
capturedMeta.Status,
|
||||||
|
capturedMeta.Summary,
|
||||||
|
capturedInstructions,
|
||||||
|
)
|
||||||
|
|
||||||
|
newDesc, genErr := textGen.Generate(jobCtx, cfai.TextRequest{
|
||||||
|
Model: capturedModel,
|
||||||
|
Messages: []cfai.TextMessage{
|
||||||
|
{Role: "system", Content: systemPrompt},
|
||||||
|
{Role: "user", Content: userPrompt},
|
||||||
|
},
|
||||||
|
MaxTokens: capturedMaxTokens,
|
||||||
|
})
|
||||||
|
if genErr != nil {
|
||||||
|
logger.Error("admin: text-gen description async failed", "job_id", jobID, "err", genErr)
|
||||||
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"status": string(domain.TaskStatusFailed),
|
||||||
|
"error_message": genErr.Error(),
|
||||||
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type resultPayload struct {
|
||||||
|
Instructions string `json:"instructions"`
|
||||||
|
OldDescription string `json:"old_description"`
|
||||||
|
NewDescription string `json:"new_description"`
|
||||||
|
}
|
||||||
|
resultJSON, _ := json.Marshal(resultPayload{
|
||||||
|
Instructions: capturedInstructions,
|
||||||
|
OldDescription: capturedMeta.Summary,
|
||||||
|
NewDescription: strings.TrimSpace(newDesc),
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"status": string(domain.TaskStatusDone),
|
||||||
|
"items_done": 1,
|
||||||
|
"items_total": 1,
|
||||||
|
"payload": string(resultJSON),
|
||||||
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
logger.Info("admin: text-gen description async done", "job_id", jobID, "slug", capturedMeta.Slug)
|
||||||
|
}()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
|
||||||
|
}
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
// Admin image generation endpoints
|
// Admin image generation endpoints
|
||||||
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
|
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
|
||||||
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
|
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
|
||||||
|
mux.HandleFunc("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
|
||||||
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
|
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
|
||||||
|
|
||||||
// Admin text generation endpoints (chapter names + book description)
|
// Admin text generation endpoints (chapter names + book description)
|
||||||
@@ -209,6 +210,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
|
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
|
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||||
|
mux.HandleFunc("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||||
|
|
||||||
// Admin catalogue enrichment endpoints
|
// Admin catalogue enrichment endpoints
|
||||||
|
|||||||
@@ -85,7 +85,8 @@
|
|||||||
new_title: string;
|
new_title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReviewState {
|
interface ChapterNamesReview {
|
||||||
|
kind: 'chapter-names';
|
||||||
jobId: string;
|
jobId: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
pattern: string;
|
pattern: string;
|
||||||
@@ -97,39 +98,160 @@
|
|||||||
applyDone: boolean;
|
applyDone: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Review (image-gen jobs) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ImageGenReview {
|
||||||
|
kind: 'image-gen';
|
||||||
|
jobId: string;
|
||||||
|
slug: string;
|
||||||
|
imageType: string;
|
||||||
|
prompt: string;
|
||||||
|
imageSrc: string;
|
||||||
|
contentType: string;
|
||||||
|
bytes: number;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
saving: boolean;
|
||||||
|
saveError: string;
|
||||||
|
savedUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Review (description jobs) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DescriptionReview {
|
||||||
|
kind: 'description';
|
||||||
|
jobId: string;
|
||||||
|
slug: string;
|
||||||
|
instructions: string;
|
||||||
|
oldDescription: string;
|
||||||
|
newDescription: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
applying: boolean;
|
||||||
|
applyError: string;
|
||||||
|
applyDone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewState = ChapterNamesReview | ImageGenReview | DescriptionReview;
|
||||||
|
|
||||||
let review = $state<ReviewState | null>(null);
|
let review = $state<ReviewState | null>(null);
|
||||||
|
|
||||||
|
// ── Open review ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function openReview(job: AIJob) {
|
async function openReview(job: AIJob) {
|
||||||
review = {
|
if (job.kind === 'chapter-names') {
|
||||||
jobId: job.id,
|
const r: ChapterNamesReview = {
|
||||||
slug: job.slug,
|
kind: 'chapter-names',
|
||||||
pattern: '',
|
jobId: job.id,
|
||||||
titles: [],
|
slug: job.slug,
|
||||||
loading: true,
|
pattern: '',
|
||||||
error: '',
|
titles: [],
|
||||||
applying: false,
|
loading: true,
|
||||||
applyError: '',
|
error: '',
|
||||||
applyDone: false
|
applying: false,
|
||||||
};
|
applyError: '',
|
||||||
|
applyDone: false
|
||||||
|
};
|
||||||
|
review = r;
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
|
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(data.payload ?? '{}');
|
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||||
} catch {
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
// ignore
|
const data = await res.json();
|
||||||
}
|
|
||||||
|
|
||||||
review.pattern = payload.pattern ?? '';
|
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
|
||||||
review.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t }));
|
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||||
review.loading = false;
|
|
||||||
} catch (e) {
|
r.pattern = payload.pattern ?? '';
|
||||||
review.loading = false;
|
r.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t }));
|
||||||
review.error = String(e);
|
r.loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
r.loading = false;
|
||||||
|
r.error = String(e);
|
||||||
|
}
|
||||||
|
} else if (job.kind === 'image-gen') {
|
||||||
|
const r: ImageGenReview = {
|
||||||
|
kind: 'image-gen',
|
||||||
|
jobId: job.id,
|
||||||
|
slug: job.slug,
|
||||||
|
imageType: '',
|
||||||
|
prompt: '',
|
||||||
|
imageSrc: '',
|
||||||
|
contentType: 'image/png',
|
||||||
|
bytes: 0,
|
||||||
|
loading: true,
|
||||||
|
error: '',
|
||||||
|
saving: false,
|
||||||
|
saveError: '',
|
||||||
|
savedUrl: ''
|
||||||
|
};
|
||||||
|
review = r;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
let payload: {
|
||||||
|
prompt?: string;
|
||||||
|
type?: string;
|
||||||
|
content_type?: string;
|
||||||
|
image_b64?: string;
|
||||||
|
bytes?: number;
|
||||||
|
} = {};
|
||||||
|
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||||
|
|
||||||
|
if (!payload.image_b64) {
|
||||||
|
r.error = 'No image in job payload.';
|
||||||
|
r.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r.imageType = payload.type ?? 'cover';
|
||||||
|
r.prompt = payload.prompt ?? '';
|
||||||
|
r.contentType = payload.content_type ?? 'image/png';
|
||||||
|
r.bytes = payload.bytes ?? 0;
|
||||||
|
r.imageSrc = `data:${r.contentType};base64,${payload.image_b64}`;
|
||||||
|
r.loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
r.loading = false;
|
||||||
|
r.error = String(e);
|
||||||
|
}
|
||||||
|
} else if (job.kind === 'description') {
|
||||||
|
const r: DescriptionReview = {
|
||||||
|
kind: 'description',
|
||||||
|
jobId: job.id,
|
||||||
|
slug: job.slug,
|
||||||
|
instructions: '',
|
||||||
|
oldDescription: '',
|
||||||
|
newDescription: '',
|
||||||
|
loading: true,
|
||||||
|
error: '',
|
||||||
|
applying: false,
|
||||||
|
applyError: '',
|
||||||
|
applyDone: false
|
||||||
|
};
|
||||||
|
review = r;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
let payload: {
|
||||||
|
instructions?: string;
|
||||||
|
old_description?: string;
|
||||||
|
new_description?: string;
|
||||||
|
} = {};
|
||||||
|
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||||
|
|
||||||
|
r.instructions = payload.instructions ?? '';
|
||||||
|
r.oldDescription = payload.old_description ?? '';
|
||||||
|
r.newDescription = payload.new_description ?? '';
|
||||||
|
r.loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
r.loading = false;
|
||||||
|
r.error = String(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,8 +259,10 @@
|
|||||||
review = null;
|
review = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyReview() {
|
// ── Apply chapter names ───────────────────────────────────────────────────────
|
||||||
if (!review || review.applying) return;
|
|
||||||
|
async function applyChapterNames() {
|
||||||
|
if (review?.kind !== 'chapter-names' || review.applying) return;
|
||||||
review.applying = true;
|
review.applying = true;
|
||||||
review.applyError = '';
|
review.applyError = '';
|
||||||
review.applyDone = false;
|
review.applyDone = false;
|
||||||
@@ -163,16 +287,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Save image as cover ───────────────────────────────────────────────────────
|
||||||
function statusColor(status: string) {
|
|
||||||
if (status === 'done') return 'text-green-400';
|
async function saveImageAsCover() {
|
||||||
if (status === 'running') return 'text-(--color-brand) animate-pulse';
|
if (review?.kind !== 'image-gen' || review.saving) return;
|
||||||
if (status === 'pending') return 'text-sky-400 animate-pulse';
|
review.saving = true;
|
||||||
if (status === 'failed') return 'text-(--color-danger)';
|
review.saveError = '';
|
||||||
if (status === 'cancelled') return 'text-(--color-muted)';
|
|
||||||
return 'text-(--color-text)';
|
const b64 = review.imageSrc.split(',')[1];
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/image-gen/save-cover', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slug: review.slug, image_b64: b64 })
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
review.saveError = body.error ?? `Error ${res.status}`;
|
||||||
|
} else {
|
||||||
|
review.savedUrl = body.cover_url ?? `/api/cover/novelfire.net/${review.slug}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
review.saveError = 'Network error.';
|
||||||
|
} finally {
|
||||||
|
review.saving = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadImage() {
|
||||||
|
if (review?.kind !== 'image-gen') return;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = review.imageSrc;
|
||||||
|
const ext = review.contentType === 'image/jpeg' ? 'jpg' : 'png';
|
||||||
|
a.download = `${review.slug}-${review.imageType}.${ext}`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply description ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function applyDescription() {
|
||||||
|
if (review?.kind !== 'description' || review.applying) return;
|
||||||
|
review.applying = true;
|
||||||
|
review.applyError = '';
|
||||||
|
review.applyDone = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/text-gen/description/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slug: review.slug, description: review.newDescription })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
review.applyError = body.error ?? `Error ${res.status}`;
|
||||||
|
} else {
|
||||||
|
review.applyDone = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
review.applyError = 'Network error.';
|
||||||
|
} finally {
|
||||||
|
review.applying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
function statusBg(status: string) {
|
function statusBg(status: string) {
|
||||||
if (status === 'done') return 'bg-green-400/10 text-green-400';
|
if (status === 'done') return 'bg-green-400/10 text-green-400';
|
||||||
if (status === 'running') return 'bg-(--color-brand)/10 text-(--color-brand)';
|
if (status === 'running') return 'bg-(--color-brand)/10 text-(--color-brand)';
|
||||||
@@ -185,6 +363,8 @@
|
|||||||
function kindLabel(kind: string) {
|
function kindLabel(kind: string) {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
'chapter-names': 'Chapter Names',
|
'chapter-names': 'Chapter Names',
|
||||||
|
'image-gen': 'Image Gen',
|
||||||
|
'description': 'Description',
|
||||||
'batch-covers': 'Batch Covers',
|
'batch-covers': 'Batch Covers',
|
||||||
'chapter-covers': 'Chapter Covers',
|
'chapter-covers': 'Chapter Covers',
|
||||||
'refresh-metadata': 'Refresh Metadata'
|
'refresh-metadata': 'Refresh Metadata'
|
||||||
@@ -216,6 +396,14 @@
|
|||||||
if (!job.items_total) return null;
|
if (!job.items_total) return null;
|
||||||
return Math.round((job.items_done / job.items_total) * 100);
|
return Math.round((job.items_done / job.items_total) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtBytes(b: number) {
|
||||||
|
if (b < 1024) return `${b} B`;
|
||||||
|
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(b / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVIEWABLE_KINDS = new Set(['chapter-names', 'image-gen', 'description']);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-6xl mx-auto space-y-6">
|
<div class="max-w-6xl mx-auto space-y-6">
|
||||||
@@ -390,7 +578,7 @@
|
|||||||
{cancellingId === job.id ? 'Cancelling…' : 'Cancel'}
|
{cancellingId === job.id ? 'Cancelling…' : 'Cancel'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if job.kind === 'chapter-names' && job.status === 'done'}
|
{#if REVIEWABLE_KINDS.has(job.kind) && job.status === 'done'}
|
||||||
<button
|
<button
|
||||||
onclick={() => openReview(job)}
|
onclick={() => openReview(job)}
|
||||||
class="px-2 py-1 rounded text-xs font-medium bg-green-400/10 text-green-400 hover:bg-green-400/20 transition-colors"
|
class="px-2 py-1 rounded text-xs font-medium bg-green-400/10 text-green-400 hover:bg-green-400/20 transition-colors"
|
||||||
@@ -419,7 +607,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Review & Apply panel ──────────────────────────────────────────────────── -->
|
<!-- ── Review panel (shared backdrop + modal shell) ─────────────────────────── -->
|
||||||
{#if review}
|
{#if review}
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<div
|
<div
|
||||||
@@ -428,102 +616,241 @@
|
|||||||
role="presentation"
|
role="presentation"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Panel -->
|
<!-- Modal -->
|
||||||
<div class="fixed inset-x-0 bottom-0 z-50 max-h-[85vh] overflow-hidden flex flex-col rounded-t-2xl bg-(--color-surface) border-t border-(--color-border) shadow-2xl sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 sm:w-[min(900px,95vw)] sm:max-h-[85vh] sm:rounded-xl sm:border sm:shadow-2xl">
|
<div class="fixed inset-x-0 bottom-0 z-50 max-h-[90vh] overflow-hidden flex flex-col rounded-t-2xl bg-(--color-surface) border-t border-(--color-border) shadow-2xl sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 sm:w-[min(960px,95vw)] sm:max-h-[88vh] sm:rounded-xl sm:border sm:shadow-2xl">
|
||||||
<!-- Panel header -->
|
|
||||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-(--color-border) shrink-0">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-base font-semibold text-(--color-text)">Review Chapter Names</h2>
|
|
||||||
<p class="text-xs text-(--color-muted) mt-0.5">
|
|
||||||
<span class="font-mono">{review.slug}</span>
|
|
||||||
{#if review.pattern}
|
|
||||||
· pattern: <span class="font-mono">{review.pattern}</span>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onclick={closeReview}
|
|
||||||
class="p-1.5 rounded-md text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- ── Chapter names review ─────────────────────────────────────────────── -->
|
||||||
<div class="flex-1 overflow-y-auto">
|
{#if review.kind === 'chapter-names'}
|
||||||
{#if review.loading}
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">
|
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-(--color-border) shrink-0">
|
||||||
Loading results…
|
<div>
|
||||||
|
<h2 class="text-base font-semibold text-(--color-text)">Review Chapter Names</h2>
|
||||||
|
<p class="text-xs text-(--color-muted) mt-0.5">
|
||||||
|
<span class="font-mono">{review.slug}</span>
|
||||||
|
{#if review.pattern}
|
||||||
|
· pattern: <span class="font-mono">{review.pattern}</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if review.error}
|
<button onclick={closeReview} class="p-1.5 rounded-md text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors" aria-label="Close">
|
||||||
<div class="px-5 py-8 text-center">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<p class="text-(--color-danger) text-sm">{review.error}</p>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</div>
|
</svg>
|
||||||
{:else if review.titles.length === 0}
|
</button>
|
||||||
<div class="px-5 py-8 text-center">
|
</div>
|
||||||
<p class="text-(--color-muted) text-sm">No results found in this job's payload.</p>
|
|
||||||
</div>
|
<!-- Body -->
|
||||||
{:else}
|
<div class="flex-1 overflow-y-auto">
|
||||||
<table class="w-full text-sm">
|
{#if review.loading}
|
||||||
<thead class="sticky top-0 bg-(--color-surface) border-b border-(--color-border)">
|
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">Loading results…</div>
|
||||||
<tr>
|
{:else if review.error}
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-16">#</th>
|
<div class="px-5 py-8 text-center"><p class="text-(--color-danger) text-sm">{review.error}</p></div>
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-1/2">Old Title</th>
|
{:else if review.titles.length === 0}
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">New Title (editable)</th>
|
<div class="px-5 py-8 text-center"><p class="text-(--color-muted) text-sm">No results found in this job's payload.</p></div>
|
||||||
</tr>
|
{:else}
|
||||||
</thead>
|
<table class="w-full text-sm">
|
||||||
<tbody class="divide-y divide-(--color-border)">
|
<thead class="sticky top-0 bg-(--color-surface) border-b border-(--color-border)">
|
||||||
{#each review.titles as title (title.number)}
|
<tr>
|
||||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/40 transition-colors">
|
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-16">#</th>
|
||||||
<td class="px-4 py-2 text-xs text-(--color-muted) tabular-nums">{title.number}</td>
|
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-1/2">Old Title</th>
|
||||||
<td class="px-4 py-2 text-xs text-(--color-muted) max-w-0">
|
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">New Title (editable)</th>
|
||||||
<span class="block truncate" title={title.old_title}>{title.old_title || '—'}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={title.new_title}
|
|
||||||
class="w-full px-2 py-1 rounded bg-(--color-surface-2) border border-(--color-border) text-xs text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
</thead>
|
||||||
</tbody>
|
<tbody class="divide-y divide-(--color-border)">
|
||||||
</table>
|
{#each review.titles as title (title.number)}
|
||||||
{/if}
|
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/40 transition-colors">
|
||||||
</div>
|
<td class="px-4 py-2 text-xs text-(--color-muted) tabular-nums">{title.number}</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-(--color-muted) max-w-0">
|
||||||
<!-- Footer -->
|
<span class="block truncate" title={title.old_title}>{title.old_title || '—'}</span>
|
||||||
{#if !review.loading && !review.error && review.titles.length > 0}
|
</td>
|
||||||
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4">
|
<td class="px-4 py-2">
|
||||||
<div class="text-xs text-(--color-muted)">
|
<input
|
||||||
{review.titles.length} chapters
|
type="text"
|
||||||
</div>
|
bind:value={title.new_title}
|
||||||
<div class="flex items-center gap-3">
|
class="w-full px-2 py-1 rounded bg-(--color-surface-2) border border-(--color-border) text-xs text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||||
{#if review.applyError}
|
/>
|
||||||
<p class="text-xs text-(--color-danger)">{review.applyError}</p>
|
</td>
|
||||||
{/if}
|
</tr>
|
||||||
{#if review.applyDone}
|
{/each}
|
||||||
<p class="text-xs text-green-400">Applied successfully.</p>
|
</tbody>
|
||||||
{/if}
|
</table>
|
||||||
<button
|
{/if}
|
||||||
onclick={closeReview}
|
|
||||||
class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={applyReview}
|
|
||||||
disabled={review.applying || review.applyDone}
|
|
||||||
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{review.applying ? 'Applying…' : review.applyDone ? 'Applied' : 'Apply All'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
{#if !review.loading && !review.error && review.titles.length > 0}
|
||||||
|
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4">
|
||||||
|
<div class="text-xs text-(--color-muted)">{review.titles.length} chapters</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if review.applyError}<p class="text-xs text-(--color-danger)">{review.applyError}</p>{/if}
|
||||||
|
{#if review.applyDone}<p class="text-xs text-green-400">Applied successfully.</p>{/if}
|
||||||
|
<button onclick={closeReview} class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">Close</button>
|
||||||
|
<button
|
||||||
|
onclick={applyChapterNames}
|
||||||
|
disabled={review.applying || review.applyDone}
|
||||||
|
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{review.applying ? 'Applying…' : review.applyDone ? 'Applied' : 'Apply All'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Image-gen review ─────────────────────────────────────────────────── -->
|
||||||
|
{:else if review.kind === 'image-gen'}
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-(--color-border) shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-semibold text-(--color-text)">Review Generated Image</h2>
|
||||||
|
<p class="text-xs text-(--color-muted) mt-0.5">
|
||||||
|
<span class="font-mono">{review.slug}</span>
|
||||||
|
{#if review.imageType} · {review.imageType}{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick={closeReview} class="p-1.5 rounded-md text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors" aria-label="Close">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-5">
|
||||||
|
{#if review.loading}
|
||||||
|
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">Loading image…</div>
|
||||||
|
{:else if review.error}
|
||||||
|
<div class="text-center py-8"><p class="text-(--color-danger) text-sm">{review.error}</p></div>
|
||||||
|
{:else if review.imageSrc}
|
||||||
|
<div class="flex flex-col sm:flex-row gap-5 items-start">
|
||||||
|
<!-- Image -->
|
||||||
|
<div class="flex-shrink-0 w-full sm:w-64">
|
||||||
|
<img
|
||||||
|
src={review.imageSrc}
|
||||||
|
alt="Generated"
|
||||||
|
class="w-full rounded-lg border border-(--color-border) object-contain max-h-80 bg-zinc-950"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="flex-1 space-y-3 text-sm">
|
||||||
|
{#if review.prompt}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-(--color-muted) mb-1">Prompt</p>
|
||||||
|
<p class="text-(--color-text) leading-relaxed">{review.prompt}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if review.bytes > 0}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-(--color-muted)">Size</p>
|
||||||
|
<p class="text-(--color-text)">{fmtBytes(review.bytes)}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if review.savedUrl}
|
||||||
|
<p class="text-xs text-green-400">
|
||||||
|
Saved as cover →
|
||||||
|
<a href={review.savedUrl} target="_blank" rel="noopener noreferrer" class="underline hover:text-green-300">{review.savedUrl}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if review.saveError}
|
||||||
|
<p class="text-xs text-(--color-danger)">{review.saveError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
{#if !review.loading && !review.error && review.imageSrc}
|
||||||
|
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<button onclick={closeReview} class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">Discard</button>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onclick={downloadImage}
|
||||||
|
class="px-3 py-1.5 rounded-md text-sm bg-(--color-surface-3) text-(--color-text) hover:bg-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
{#if review.imageType === 'cover' && !review.savedUrl}
|
||||||
|
<button
|
||||||
|
onclick={saveImageAsCover}
|
||||||
|
disabled={review.saving}
|
||||||
|
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{review.saving ? 'Saving…' : 'Save as cover'}
|
||||||
|
</button>
|
||||||
|
{:else if review.savedUrl}
|
||||||
|
<span class="text-sm text-green-400 font-medium">Saved ✓</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Description review ──────────────────────────────────────────────── -->
|
||||||
|
{:else if review.kind === 'description'}
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-(--color-border) shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-semibold text-(--color-text)">Review Description</h2>
|
||||||
|
<p class="text-xs text-(--color-muted) mt-0.5">
|
||||||
|
<span class="font-mono">{review.slug}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick={closeReview} class="p-1.5 rounded-md text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors" aria-label="Close">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-5 space-y-5">
|
||||||
|
{#if review.loading}
|
||||||
|
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">Loading…</div>
|
||||||
|
{:else if review.error}
|
||||||
|
<div class="text-center py-8"><p class="text-(--color-danger) text-sm">{review.error}</p></div>
|
||||||
|
{:else}
|
||||||
|
<!-- Old description -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wide">Current description</p>
|
||||||
|
<p class="text-sm text-(--color-muted) leading-relaxed bg-(--color-surface-2) rounded-lg px-4 py-3 border border-(--color-border)">
|
||||||
|
{review.oldDescription || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New description (editable) -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-xs font-semibold text-(--color-text) uppercase tracking-wide">Proposed description <span class="normal-case font-normal text-(--color-muted)">(editable)</span></p>
|
||||||
|
<textarea
|
||||||
|
bind:value={review.newDescription}
|
||||||
|
rows="6"
|
||||||
|
class="w-full px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/50 text-sm text-(--color-text) leading-relaxed resize-y focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if review.instructions}
|
||||||
|
<p class="text-xs text-(--color-muted)">
|
||||||
|
<span class="font-medium text-(--color-text)">Instructions:</span> {review.instructions}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
{#if !review.loading && !review.error}
|
||||||
|
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4">
|
||||||
|
<button onclick={closeReview} class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">Discard</button>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if review.applyError}<p class="text-xs text-(--color-danger)">{review.applyError}</p>{/if}
|
||||||
|
{#if review.applyDone}<p class="text-xs text-green-400">Applied successfully.</p>{/if}
|
||||||
|
<button
|
||||||
|
onclick={applyDescription}
|
||||||
|
disabled={review.applying || review.applyDone || !review.newDescription.trim()}
|
||||||
|
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{review.applying ? 'Applying…' : review.applyDone ? 'Applied' : 'Apply'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { ImageModelInfo, BookSummary } from './+page.server';
|
import type { ImageModelInfo, BookSummary } from './+page.server';
|
||||||
|
|
||||||
@@ -120,30 +121,58 @@
|
|||||||
// ── Generation state ─────────────────────────────────────────────────────────
|
// ── Generation state ─────────────────────────────────────────────────────────
|
||||||
let generating = $state(false);
|
let generating = $state(false);
|
||||||
let genError = $state('');
|
let genError = $state('');
|
||||||
let elapsedMs = $state(0);
|
|
||||||
let elapsedInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
// ── Result state ─────────────────────────────────────────────────────────────
|
// ── Generate (async: fire-and-forget → redirect to ai-jobs) ─────────────────
|
||||||
interface GenResult {
|
let canGenerate = $derived(prompt.trim().length > 0 && slug.trim().length > 0 && !generating);
|
||||||
imageSrc: string;
|
|
||||||
model: string;
|
async function generate() {
|
||||||
bytes: number;
|
if (!canGenerate) return;
|
||||||
contentType: string;
|
generating = true;
|
||||||
saved: boolean;
|
genError = '';
|
||||||
coverUrl: string;
|
|
||||||
elapsedMs: number;
|
try {
|
||||||
slug: string;
|
const payload = {
|
||||||
imageType: ImageType;
|
prompt: prompt.trim(),
|
||||||
chapter: number;
|
model: selectedModel,
|
||||||
|
type: imageType,
|
||||||
|
slug: slug.trim(),
|
||||||
|
chapter: imageType === 'chapter' ? chapter : 0,
|
||||||
|
num_steps: numSteps,
|
||||||
|
guidance,
|
||||||
|
strength,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
};
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
if (referenceFile && selectedModelInfo?.supports_ref) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('json', JSON.stringify(payload));
|
||||||
|
fd.append('reference', referenceFile);
|
||||||
|
res = await fetch('/api/admin/image-gen/async', { method: 'POST', body: fd });
|
||||||
|
} else {
|
||||||
|
res = await fetch('/api/admin/image-gen/async', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
genError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to ai-jobs so the admin can monitor progress and review.
|
||||||
|
await goto('/admin/ai-jobs');
|
||||||
|
} catch {
|
||||||
|
genError = 'Network error.';
|
||||||
|
} finally {
|
||||||
|
generating = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = $state<GenResult | null>(null);
|
|
||||||
let history = $state<GenResult[]>([]);
|
|
||||||
|
|
||||||
let saving = $state(false);
|
|
||||||
let saveError = $state('');
|
|
||||||
let saveSuccess = $state(false);
|
|
||||||
|
|
||||||
// ── Model helpers ────────────────────────────────────────────────────────────
|
// ── Model helpers ────────────────────────────────────────────────────────────
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
const models: ImageModelInfo[] = data.models ?? [];
|
const models: ImageModelInfo[] = data.models ?? [];
|
||||||
@@ -307,147 +336,6 @@
|
|||||||
? `${selectedModelInfo.label} does not support reference images. The reference will be ignored.`
|
? `${selectedModelInfo.label} does not support reference images. The reference will be ignored.`
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Generate ────────────────────────────────────────────────────────────────
|
|
||||||
let canGenerate = $derived(prompt.trim().length > 0 && slug.trim().length > 0 && !generating);
|
|
||||||
|
|
||||||
async function generate() {
|
|
||||||
if (!canGenerate) return;
|
|
||||||
generating = true;
|
|
||||||
genError = '';
|
|
||||||
result = null;
|
|
||||||
elapsedMs = 0;
|
|
||||||
saveSuccess = false;
|
|
||||||
saveError = '';
|
|
||||||
|
|
||||||
const startTs = Date.now();
|
|
||||||
elapsedInterval = setInterval(() => {
|
|
||||||
elapsedMs = Date.now() - startTs;
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
prompt: prompt.trim(),
|
|
||||||
model: selectedModel,
|
|
||||||
type: imageType,
|
|
||||||
slug: slug.trim(),
|
|
||||||
chapter: imageType === 'chapter' ? chapter : 0,
|
|
||||||
num_steps: numSteps,
|
|
||||||
guidance,
|
|
||||||
strength,
|
|
||||||
width,
|
|
||||||
height
|
|
||||||
};
|
|
||||||
|
|
||||||
let res: Response;
|
|
||||||
if (referenceFile && selectedModelInfo?.supports_ref) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('json', JSON.stringify(payload));
|
|
||||||
fd.append('reference', referenceFile);
|
|
||||||
res = await fetch('/api/admin/image-gen', { method: 'POST', body: fd });
|
|
||||||
} else {
|
|
||||||
res = await fetch('/api/admin/image-gen', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 502 || res.status === 504) {
|
|
||||||
genError =
|
|
||||||
body.error ??
|
|
||||||
`Generation timed out (${res.status}). FLUX models can take 60–120 s on Cloudflare Workers AI — try reducing steps or switching to a faster model.`;
|
|
||||||
} else {
|
|
||||||
genError = body.error ?? body.message ?? `Error ${res.status}`;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalMs = Date.now() - startTs;
|
|
||||||
const newResult: GenResult = {
|
|
||||||
imageSrc: `data:${body.content_type};base64,${body.image_b64}`,
|
|
||||||
model: body.model,
|
|
||||||
bytes: body.bytes,
|
|
||||||
contentType: body.content_type,
|
|
||||||
saved: body.saved ?? false,
|
|
||||||
coverUrl: body.cover_url ?? '',
|
|
||||||
elapsedMs: totalMs,
|
|
||||||
slug: slug.trim(),
|
|
||||||
imageType,
|
|
||||||
chapter
|
|
||||||
};
|
|
||||||
|
|
||||||
result = newResult;
|
|
||||||
history = [newResult, ...history].slice(0, 5);
|
|
||||||
} catch {
|
|
||||||
genError = 'Network error.';
|
|
||||||
} finally {
|
|
||||||
generating = false;
|
|
||||||
if (elapsedInterval) {
|
|
||||||
clearInterval(elapsedInterval);
|
|
||||||
elapsedInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Save as cover ────────────────────────────────────────────────────────────
|
|
||||||
async function saveAsCover() {
|
|
||||||
if (!result || saving) return;
|
|
||||||
saving = true;
|
|
||||||
saveError = '';
|
|
||||||
saveSuccess = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const b64 = result.imageSrc.split(',')[1];
|
|
||||||
const res = await fetch('/api/admin/image-gen/save-cover', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ slug: result.slug, image_b64: b64 })
|
|
||||||
});
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) {
|
|
||||||
saveError = body.error ?? body.message ?? `Error ${res.status}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (body.saved) {
|
|
||||||
saveSuccess = true;
|
|
||||||
result = { ...result, saved: true, coverUrl: body.cover_url ?? result.coverUrl };
|
|
||||||
} else {
|
|
||||||
saveError = 'Backend did not save the cover.';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
saveError = 'Network error.';
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Download ─────────────────────────────────────────────────────────────────
|
|
||||||
function download() {
|
|
||||||
if (!result) return;
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = result.imageSrc;
|
|
||||||
const ext = result.contentType === 'image/jpeg' ? 'jpg' : 'png';
|
|
||||||
a.download =
|
|
||||||
result.imageType === 'cover'
|
|
||||||
? `${result.slug}-cover.${ext}`
|
|
||||||
: `${result.slug}-ch${result.chapter}.${ext}`;
|
|
||||||
a.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Formatting helpers ───────────────────────────────────────────────────────
|
|
||||||
function fmtElapsed(ms: number) {
|
|
||||||
if (ms < 1000) return `${ms}ms`;
|
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtBytes(b: number) {
|
|
||||||
if (b < 1024) return `${b} B`;
|
|
||||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(b / 1024 / 1024).toFixed(2)} MB`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -830,9 +718,9 @@
|
|||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
<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" />
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
</svg>
|
</svg>
|
||||||
Generating… {fmtElapsed(elapsedMs)}
|
Queuing…
|
||||||
{:else}
|
{:else}
|
||||||
Generate
|
Generate (async)
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -841,116 +729,37 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Right: Result panel ────────────────────────────────────────────────── -->
|
<!-- ── Right: Info panel ──────────────────────────────────────────────────── -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#if result}
|
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl p-5 space-y-3">
|
||||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl overflow-hidden">
|
<h2 class="text-sm font-semibold text-(--color-text)">How it works</h2>
|
||||||
<!-- Image -->
|
<ol class="space-y-2 text-sm text-(--color-muted) list-decimal list-inside">
|
||||||
<img
|
<li>Fill in the form and click <strong class="text-(--color-text)">Generate (async)</strong>.</li>
|
||||||
src={result.imageSrc}
|
<li>The job is queued in the background — no waiting on this page.</li>
|
||||||
alt=""
|
<li>You'll be taken to <strong class="text-(--color-text)">AI Jobs</strong> to monitor progress.</li>
|
||||||
aria-label="Generated cover"
|
<li>When done, click <strong class="text-(--color-text)">Review</strong> to see the image and approve or discard it.</li>
|
||||||
class="w-full object-contain max-h-[36rem] bg-zinc-950"
|
</ol>
|
||||||
/>
|
<a
|
||||||
|
href="/admin/ai-jobs"
|
||||||
|
class="mt-3 flex items-center gap-1.5 text-sm text-(--color-brand) hover:underline"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
Go to AI Jobs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Meta bar -->
|
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl p-5 space-y-2">
|
||||||
<div class="px-4 py-3 border-t border-(--color-border) space-y-3">
|
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wide">Tips</p>
|
||||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
<ul class="space-y-1.5 text-xs text-(--color-muted)">
|
||||||
<div>
|
<li>• Use <strong class="text-(--color-text)">Auto-prompt</strong> to generate a prompt from the book's description.</li>
|
||||||
<p class="text-(--color-muted)">Model</p>
|
<li>• FLUX models produce high-quality covers but take 60–120 s — the async path prevents timeouts.</li>
|
||||||
<p class="text-(--color-text) font-mono truncate" title={result.model}>
|
<li>• Keep steps ≤ 20 on Cloudflare Workers AI to stay within the ~100 s limit.</li>
|
||||||
{models.find((m) => m.id === result!.model)?.label ?? result.model}
|
<li>• Reference images (img2img) only work with models that show ★ref.</li>
|
||||||
</p>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p class="text-(--color-muted)">Size</p>
|
|
||||||
<p class="text-(--color-text)">{fmtBytes(result.bytes)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-(--color-muted)">Time</p>
|
|
||||||
<p class="text-(--color-text)">{fmtElapsed(result.elapsedMs)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if result.saved}
|
|
||||||
<p class="text-xs text-green-400">
|
|
||||||
Cover saved →
|
|
||||||
<a href={result.coverUrl} target="_blank" rel="noopener noreferrer"
|
|
||||||
class="underline hover:text-green-300">{result.coverUrl}</a>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if saveSuccess && !result.saved}
|
|
||||||
<p class="text-xs text-green-400">Cover saved successfully.</p>
|
|
||||||
{/if}
|
|
||||||
{#if saveError}
|
|
||||||
<p class="text-xs text-(--color-danger)">{saveError}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="flex gap-2 flex-wrap">
|
|
||||||
<button
|
|
||||||
onclick={download}
|
|
||||||
class="flex-1 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) text-xs font-medium hover:bg-zinc-600 transition-colors"
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if result.imageType === 'cover'}
|
|
||||||
<button
|
|
||||||
onclick={saveAsCover}
|
|
||||||
disabled={saving || result.saved}
|
|
||||||
class="flex-1 px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) text-xs font-semibold
|
|
||||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? 'Saving…' : result.saved ? 'Saved ✓' : 'Save as cover'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if generating}
|
|
||||||
<!-- Placeholder while generating -->
|
|
||||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-80">
|
|
||||||
<div class="text-center space-y-3">
|
|
||||||
<svg class="w-8 h-8 animate-spin mx-auto 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>
|
|
||||||
<p class="text-sm text-(--color-muted)">Generating… {fmtElapsed(elapsedMs)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Empty state -->
|
|
||||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-80">
|
|
||||||
<p class="text-sm text-(--color-muted)">Generated image will appear here</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- History thumbnails -->
|
|
||||||
{#if history.length > 0}
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Session history</p>
|
|
||||||
<div class="flex gap-2 flex-wrap">
|
|
||||||
{#each history as h, i}
|
|
||||||
<button
|
|
||||||
onclick={() => result = h}
|
|
||||||
class="relative rounded-md overflow-hidden border transition-colors shrink-0
|
|
||||||
{result === h ? 'border-(--color-brand)' : 'border-(--color-border) hover:border-(--color-brand)/50'}"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={h.imageSrc}
|
|
||||||
alt="History {i + 1}"
|
|
||||||
class="w-16 h-16 object-cover"
|
|
||||||
/>
|
|
||||||
{#if h.saved}
|
|
||||||
<span class="absolute bottom-0.5 right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border border-(--color-surface)"></span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user