fix(import): persist object_key + metadata; add nav + logout session cleanup
- Import task: persist object_key, author, cover_url, genres, summary, book_status in PocketBase so the runner can fetch the file and write book metadata on completion - Runner poll mode: pass task.ObjectKey instead of empty string - Runner: write BookMeta + UpsertBook in Meilisearch after chapter ingest so imported books appear in catalogue and search - Import UI: add author, cover URL, genres, summary, status fields; add AI tasks panel (chapter names, description, image gen, tagline) after import completes; add AI tasks button on each done task in the list - Admin nav: add Notifications entry to sidebar (all 5 locales) - Logout: delete user_sessions row on sign-out so sessions don't accumulate as phantoms after each login/logout cycle
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
|
"github.com/libnovel/backend/internal/domain"
|
||||||
"github.com/libnovel/backend/internal/taskqueue"
|
"github.com/libnovel/backend/internal/taskqueue"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,18 +89,18 @@ func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateImportTask creates a PocketBase record then enqueues an Asynq job for PDF/EPUB import.
|
// 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, initiatorUserID string) (string, error) {
|
func (p *Producer) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
|
||||||
id, err := p.pb.CreateImportTask(ctx, slug, title, fileType, objectKey, initiatorUserID)
|
id, err := p.pb.CreateImportTask(ctx, task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := ImportPayload{
|
payload := ImportPayload{
|
||||||
PBTaskID: id,
|
PBTaskID: id,
|
||||||
Slug: slug,
|
Slug: task.Slug,
|
||||||
Title: title,
|
Title: task.Title,
|
||||||
FileType: fileType,
|
FileType: task.FileType,
|
||||||
ObjectKey: objectKey,
|
ObjectKey: task.ObjectKey,
|
||||||
}
|
}
|
||||||
if err := p.enqueue(ctx, TypeImportBook, payload); err != nil {
|
if err := p.enqueue(ctx, TypeImportBook, payload); err != nil {
|
||||||
// Non-fatal: PB record exists; runner will pick it up on next poll.
|
// Non-fatal: PB record exists; runner will pick it up on next poll.
|
||||||
|
|||||||
@@ -9,14 +9,20 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/libnovel/backend/internal/domain"
|
||||||
"github.com/libnovel/backend/internal/storage"
|
"github.com/libnovel/backend/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
type importRequest struct {
|
type importRequest struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
FileName string `json:"file_name"`
|
Author string `json:"author"`
|
||||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
CoverURL string `json:"cover_url"`
|
||||||
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
Genres []string `json:"genres"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
BookStatus string `json:"book_status"` // "ongoing" | "completed" | "hiatus"
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||||
|
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
||||||
}
|
}
|
||||||
|
|
||||||
type importResponse struct {
|
type importResponse struct {
|
||||||
@@ -46,6 +52,17 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Title = r.FormValue("title")
|
req.Title = r.FormValue("title")
|
||||||
|
req.Author = r.FormValue("author")
|
||||||
|
req.CoverURL = r.FormValue("cover_url")
|
||||||
|
req.Summary = r.FormValue("summary")
|
||||||
|
req.BookStatus = r.FormValue("book_status")
|
||||||
|
if g := r.FormValue("genres"); g != "" {
|
||||||
|
for _, s := range strings.Split(g, ",") {
|
||||||
|
if s = strings.TrimSpace(s); s != "" {
|
||||||
|
req.Genres = append(req.Genres, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
req.FileName = r.FormValue("file_name")
|
req.FileName = r.FormValue("file_name")
|
||||||
req.FileType = r.FormValue("file_type")
|
req.FileType = r.FormValue("file_type")
|
||||||
analyzeOnly := r.FormValue("analyze") == "true"
|
analyzeOnly := r.FormValue("analyze") == "true"
|
||||||
@@ -115,7 +132,18 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
return -1
|
return -1
|
||||||
}, slug)
|
}, slug)
|
||||||
|
|
||||||
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey, "")
|
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), domain.ImportTask{
|
||||||
|
Slug: slug,
|
||||||
|
Title: req.Title,
|
||||||
|
Author: req.Author,
|
||||||
|
CoverURL: req.CoverURL,
|
||||||
|
Genres: req.Genres,
|
||||||
|
Summary: req.Summary,
|
||||||
|
BookStatus: req.BookStatus,
|
||||||
|
FileType: req.FileType,
|
||||||
|
ObjectKey: objectKey,
|
||||||
|
InitiatorUserID: "",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
|
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -177,6 +177,12 @@ type ImportTask struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
FileName string `json:"file_name"`
|
FileName string `json:"file_name"`
|
||||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||||
|
ObjectKey string `json:"object_key,omitempty"` // MinIO path to uploaded file
|
||||||
|
Author string `json:"author,omitempty"`
|
||||||
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
|
Genres []string `json:"genres,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
BookStatus string `json:"book_status,omitempty"` // "ongoing" | "completed" | "hiatus"
|
||||||
WorkerID string `json:"worker_id,omitempty"`
|
WorkerID string `json:"worker_id,omitempty"`
|
||||||
InitiatorUserID string `json:"initiator_user_id,omitempty"` // PocketBase user ID who submitted the import
|
InitiatorUserID string `json:"initiator_user_id,omitempty"` // PocketBase user ID who submitted the import
|
||||||
Status TaskStatus `json:"status"`
|
Status TaskStatus `json:"status"`
|
||||||
|
|||||||
@@ -432,9 +432,7 @@ importLoop:
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-importSem }()
|
defer func() { <-importSem }()
|
||||||
defer r.tasksRunning.Add(-1)
|
defer r.tasksRunning.Add(-1)
|
||||||
// Import tasks need object key - we'll need to fetch it from the task record
|
r.runImportTask(ctx, t, t.ObjectKey)
|
||||||
// For now, assume it's stored in a field or we need to add it
|
|
||||||
r.runImportTask(ctx, t, "")
|
|
||||||
}(task)
|
}(task)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -753,6 +751,31 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write book metadata so the book appears in PocketBase catalogue.
|
||||||
|
if r.deps.BookWriter != nil {
|
||||||
|
meta := domain.BookMeta{
|
||||||
|
Slug: task.Slug,
|
||||||
|
Title: task.Title,
|
||||||
|
Author: task.Author,
|
||||||
|
Cover: task.CoverURL,
|
||||||
|
Status: task.BookStatus,
|
||||||
|
Genres: task.Genres,
|
||||||
|
Summary: task.Summary,
|
||||||
|
TotalChapters: len(chapters),
|
||||||
|
}
|
||||||
|
if meta.Status == "" {
|
||||||
|
meta.Status = "completed"
|
||||||
|
}
|
||||||
|
if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil {
|
||||||
|
log.Warn("runner: import task WriteMetadata failed (non-fatal)", "err", err)
|
||||||
|
} else {
|
||||||
|
// Index in Meilisearch so the book is searchable.
|
||||||
|
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
|
||||||
|
log.Warn("runner: import task meilisearch upsert failed (non-fatal)", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
r.tasksCompleted.Add(1)
|
r.tasksCompleted.Add(1)
|
||||||
span.SetStatus(codes.Ok, "")
|
span.SetStatus(codes.Ok, "")
|
||||||
result := domain.ImportResult{
|
result := domain.ImportResult{
|
||||||
|
|||||||
@@ -647,17 +647,25 @@ func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter
|
|||||||
return rec.ID, nil
|
return rec.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) {
|
func (s *Store) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"slug": slug,
|
"slug": task.Slug,
|
||||||
"title": title,
|
"title": task.Title,
|
||||||
"file_name": slug + "." + fileType,
|
"file_name": task.Slug + "." + task.FileType,
|
||||||
"file_type": fileType,
|
"file_type": task.FileType,
|
||||||
|
"object_key": task.ObjectKey,
|
||||||
|
"author": task.Author,
|
||||||
|
"cover_url": task.CoverURL,
|
||||||
|
"summary": task.Summary,
|
||||||
|
"book_status": task.BookStatus,
|
||||||
"status": string(domain.TaskStatusPending),
|
"status": string(domain.TaskStatusPending),
|
||||||
"chapters_done": 0,
|
"chapters_done": 0,
|
||||||
"chapters_total": 0,
|
"chapters_total": 0,
|
||||||
"started": time.Now().UTC().Format(time.RFC3339),
|
"started": time.Now().UTC().Format(time.RFC3339),
|
||||||
"initiator_user_id": initiatorUserID,
|
"initiator_user_id": task.InitiatorUserID,
|
||||||
|
}
|
||||||
|
if len(task.Genres) > 0 {
|
||||||
|
payload["genres"] = strings.Join(task.Genres, ",")
|
||||||
}
|
}
|
||||||
var rec struct {
|
var rec struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -1176,6 +1184,12 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
FileName string `json:"file_name"`
|
FileName string `json:"file_name"`
|
||||||
FileType string `json:"file_type"`
|
FileType string `json:"file_type"`
|
||||||
|
ObjectKey string `json:"object_key"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
Genres string `json:"genres"` // stored as comma-separated
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
BookStatus string `json:"book_status"`
|
||||||
WorkerID string `json:"worker_id"`
|
WorkerID string `json:"worker_id"`
|
||||||
InitiatorUserID string `json:"initiator_user_id"`
|
InitiatorUserID string `json:"initiator_user_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -1190,12 +1204,26 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
|
|||||||
}
|
}
|
||||||
started, _ := time.Parse(time.RFC3339, rec.Started)
|
started, _ := time.Parse(time.RFC3339, rec.Started)
|
||||||
finished, _ := time.Parse(time.RFC3339, rec.Finished)
|
finished, _ := time.Parse(time.RFC3339, rec.Finished)
|
||||||
|
var genres []string
|
||||||
|
if rec.Genres != "" {
|
||||||
|
for _, g := range strings.Split(rec.Genres, ",") {
|
||||||
|
if g = strings.TrimSpace(g); g != "" {
|
||||||
|
genres = append(genres, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return domain.ImportTask{
|
return domain.ImportTask{
|
||||||
ID: rec.ID,
|
ID: rec.ID,
|
||||||
Slug: rec.Slug,
|
Slug: rec.Slug,
|
||||||
Title: rec.Title,
|
Title: rec.Title,
|
||||||
FileName: rec.FileName,
|
FileName: rec.FileName,
|
||||||
FileType: rec.FileType,
|
FileType: rec.FileType,
|
||||||
|
ObjectKey: rec.ObjectKey,
|
||||||
|
Author: rec.Author,
|
||||||
|
CoverURL: rec.CoverURL,
|
||||||
|
Genres: genres,
|
||||||
|
Summary: rec.Summary,
|
||||||
|
BookStatus: rec.BookStatus,
|
||||||
WorkerID: rec.WorkerID,
|
WorkerID: rec.WorkerID,
|
||||||
InitiatorUserID: rec.InitiatorUserID,
|
InitiatorUserID: rec.InitiatorUserID,
|
||||||
Status: domain.TaskStatus(rec.Status),
|
Status: domain.TaskStatus(rec.Status),
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ type Producer interface {
|
|||||||
|
|
||||||
// CreateImportTask inserts a new import task with status=pending and
|
// CreateImportTask inserts a new import task with status=pending and
|
||||||
// returns the assigned PocketBase record ID.
|
// returns the assigned PocketBase record ID.
|
||||||
// initiatorUserID is the PocketBase user ID who submitted the import (may be empty).
|
// The task struct must have at minimum Slug, Title, FileType, and ObjectKey set.
|
||||||
CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error)
|
CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error)
|
||||||
|
|
||||||
// CancelTask transitions a pending task to status=cancelled.
|
// CancelTask transitions a pending task to status=cancelled.
|
||||||
// Returns ErrNotFound if the task does not exist.
|
// Returns ErrNotFound if the task does not exist.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
|
|||||||
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
||||||
return "translation-1", nil
|
return "translation-1", nil
|
||||||
}
|
}
|
||||||
func (s *stubStore) CreateImportTask(_ context.Context, _, _, _, _, _ string) (string, error) {
|
func (s *stubStore) CreateImportTask(_ context.Context, _ domain.ImportTask) (string, error) {
|
||||||
return "import-1", nil
|
return "import-1", nil
|
||||||
}
|
}
|
||||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||||
|
|||||||
@@ -408,6 +408,7 @@
|
|||||||
"admin_nav_text_gen": "Text Gen",
|
"admin_nav_text_gen": "Text Gen",
|
||||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||||
"admin_nav_ai_jobs": "AI Jobs",
|
"admin_nav_ai_jobs": "AI Jobs",
|
||||||
|
"admin_nav_notifications": "Notifications",
|
||||||
"admin_nav_feedback": "Feedback",
|
"admin_nav_feedback": "Feedback",
|
||||||
"admin_nav_errors": "Errors",
|
"admin_nav_errors": "Errors",
|
||||||
"admin_nav_analytics": "Analytics",
|
"admin_nav_analytics": "Analytics",
|
||||||
|
|||||||
@@ -378,6 +378,7 @@
|
|||||||
"admin_nav_text_gen": "Text Gen",
|
"admin_nav_text_gen": "Text Gen",
|
||||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||||
"admin_nav_ai_jobs": "Tâches IA",
|
"admin_nav_ai_jobs": "Tâches IA",
|
||||||
|
"admin_nav_notifications": "Notifications",
|
||||||
"admin_nav_errors": "Erreurs",
|
"admin_nav_errors": "Erreurs",
|
||||||
"admin_nav_analytics": "Analytique",
|
"admin_nav_analytics": "Analytique",
|
||||||
"admin_nav_logs": "Journaux",
|
"admin_nav_logs": "Journaux",
|
||||||
|
|||||||
@@ -378,6 +378,7 @@
|
|||||||
"admin_nav_text_gen": "Text Gen",
|
"admin_nav_text_gen": "Text Gen",
|
||||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||||
"admin_nav_ai_jobs": "Tugas AI",
|
"admin_nav_ai_jobs": "Tugas AI",
|
||||||
|
"admin_nav_notifications": "Notifikasi",
|
||||||
"admin_nav_errors": "Kesalahan",
|
"admin_nav_errors": "Kesalahan",
|
||||||
"admin_nav_analytics": "Analitik",
|
"admin_nav_analytics": "Analitik",
|
||||||
"admin_nav_logs": "Log",
|
"admin_nav_logs": "Log",
|
||||||
|
|||||||
@@ -378,6 +378,7 @@
|
|||||||
"admin_nav_text_gen": "Text Gen",
|
"admin_nav_text_gen": "Text Gen",
|
||||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||||
"admin_nav_ai_jobs": "Tarefas de IA",
|
"admin_nav_ai_jobs": "Tarefas de IA",
|
||||||
|
"admin_nav_notifications": "Notificações",
|
||||||
"admin_nav_errors": "Erros",
|
"admin_nav_errors": "Erros",
|
||||||
"admin_nav_analytics": "Análise",
|
"admin_nav_analytics": "Análise",
|
||||||
"admin_nav_logs": "Logs",
|
"admin_nav_logs": "Logs",
|
||||||
|
|||||||
@@ -378,6 +378,7 @@
|
|||||||
"admin_nav_text_gen": "Text Gen",
|
"admin_nav_text_gen": "Text Gen",
|
||||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||||
"admin_nav_ai_jobs": "Задачи ИИ",
|
"admin_nav_ai_jobs": "Задачи ИИ",
|
||||||
|
"admin_nav_notifications": "Уведомления",
|
||||||
"admin_nav_errors": "Ошибки",
|
"admin_nav_errors": "Ошибки",
|
||||||
"admin_nav_analytics": "Аналитика",
|
"admin_nav_analytics": "Аналитика",
|
||||||
"admin_nav_logs": "Логи",
|
"admin_nav_logs": "Логи",
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ export * from './admin_nav_image_gen.js'
|
|||||||
export * from './admin_nav_text_gen.js'
|
export * from './admin_nav_text_gen.js'
|
||||||
export * from './admin_nav_catalogue_tools.js'
|
export * from './admin_nav_catalogue_tools.js'
|
||||||
export * from './admin_nav_ai_jobs.js'
|
export * from './admin_nav_ai_jobs.js'
|
||||||
|
export * from './admin_nav_notifications.js'
|
||||||
export * from './admin_nav_feedback.js'
|
export * from './admin_nav_feedback.js'
|
||||||
export * from './admin_nav_errors.js'
|
export * from './admin_nav_errors.js'
|
||||||
export * from './admin_nav_analytics.js'
|
export * from './admin_nav_analytics.js'
|
||||||
|
|||||||
@@ -1383,6 +1383,20 @@ export async function revokeUserSession(recordId: string, userId: string): Promi
|
|||||||
return del.ok || del.status === 204;
|
return del.ok || del.status === 204;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a session by its auth session ID (the value stored in the cookie).
|
||||||
|
* Used on logout so the row doesn't linger as a phantom active session.
|
||||||
|
*/
|
||||||
|
export async function deleteSessionByAuthId(authSessionId: string): Promise<void> {
|
||||||
|
const row = await listOne<UserSession>('user_sessions', `session_id="${authSessionId}"`);
|
||||||
|
if (!row) return;
|
||||||
|
const token = await getToken();
|
||||||
|
await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke all sessions for a user (used on password change etc).
|
* Revoke all sessions for a user (used on password change etc).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
label: () => m.admin_nav_ai_jobs(),
|
label: () => m.admin_nav_ai_jobs(),
|
||||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />`
|
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />`
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/notifications',
|
||||||
|
label: () => m.admin_nav_notifications(),
|
||||||
|
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />`
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/admin/catalogue-tools',
|
href: '/admin/catalogue-tools',
|
||||||
label: () => m.admin_nav_catalogue_tools(),
|
label: () => m.admin_nav_catalogue_tools(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
interface ImportTask {
|
interface ImportTask {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -7,6 +8,11 @@
|
|||||||
title: string;
|
title: string;
|
||||||
file_name: string;
|
file_name: string;
|
||||||
file_type: string;
|
file_type: string;
|
||||||
|
author: string;
|
||||||
|
cover_url: string;
|
||||||
|
genres: string[];
|
||||||
|
summary: string;
|
||||||
|
book_status: string;
|
||||||
status: string;
|
status: string;
|
||||||
chapters_done: number;
|
chapters_done: number;
|
||||||
chapters_total: number;
|
chapters_total: number;
|
||||||
@@ -18,17 +24,35 @@
|
|||||||
interface PendingImport {
|
interface PendingImport {
|
||||||
file: File;
|
file: File;
|
||||||
title: string;
|
title: string;
|
||||||
|
author: string;
|
||||||
|
coverUrl: string;
|
||||||
|
genres: string;
|
||||||
|
summary: string;
|
||||||
|
bookStatus: string;
|
||||||
preview: { chapters: number; firstLines: string[] };
|
preview: { chapters: number; firstLines: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
let tasks = $state<ImportTask[]>([]);
|
let tasks = $state<ImportTask[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
let title = $state('');
|
|
||||||
let error = $state('');
|
|
||||||
let selectedFile = $state<File | null>(null);
|
|
||||||
let pendingImport = $state<PendingImport | null>(null);
|
|
||||||
let analyzing = $state(false);
|
let analyzing = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
let selectedFile = $state<File | null>(null);
|
||||||
|
let title = $state('');
|
||||||
|
let author = $state('');
|
||||||
|
let coverUrl = $state('');
|
||||||
|
let genres = $state('');
|
||||||
|
let summary = $state('');
|
||||||
|
let bookStatus = $state('completed');
|
||||||
|
|
||||||
|
let pendingImport = $state<PendingImport | null>(null);
|
||||||
|
|
||||||
|
// AI panel: slug of recently completed import
|
||||||
|
let aiSlug = $state('');
|
||||||
|
let aiTitle = $state('');
|
||||||
|
let showAiPanel = $state(false);
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -45,7 +69,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileSelect(e: Event) {
|
function handleFileSelect(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
if (!input.files?.length) return;
|
if (!input.files?.length) return;
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
@@ -54,8 +78,12 @@
|
|||||||
error = 'Please select a PDF or EPUB file';
|
error = 'Please select a PDF or EPUB file';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
error = '';
|
||||||
selectedFile = file;
|
selectedFile = file;
|
||||||
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
|
// Auto-fill title from filename if empty
|
||||||
|
if (!title.trim()) {
|
||||||
|
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeFile() {
|
async function analyzeFile() {
|
||||||
@@ -65,24 +93,26 @@
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', selectedFile);
|
formData.append('file', selectedFile);
|
||||||
formData.append('title', title);
|
formData.append('title', title.trim());
|
||||||
formData.append('analyze', 'true');
|
formData.append('analyze', 'true');
|
||||||
const res = await fetch('/api/admin/import', {
|
const res = await fetch('/api/admin/import', { method: 'POST', body: formData });
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
pendingImport = {
|
pendingImport = {
|
||||||
file: selectedFile,
|
file: selectedFile,
|
||||||
title: title,
|
title: title.trim(),
|
||||||
|
author: author.trim(),
|
||||||
|
coverUrl: coverUrl.trim(),
|
||||||
|
genres: genres.trim(),
|
||||||
|
summary: summary.trim(),
|
||||||
|
bookStatus,
|
||||||
preview: data.preview || { chapters: 0, firstLines: [] }
|
preview: data.preview || { chapters: 0, firstLines: [] }
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const d = await res.json().catch(() => ({}));
|
const d = await res.json().catch(() => ({}));
|
||||||
error = d.error || 'Failed to analyze file';
|
error = d.error || 'Failed to analyze file';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
error = 'Failed to analyze file';
|
error = 'Failed to analyze file';
|
||||||
} finally {
|
} finally {
|
||||||
analyzing = false;
|
analyzing = false;
|
||||||
@@ -97,20 +127,36 @@
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', pendingImport.file);
|
formData.append('file', pendingImport.file);
|
||||||
formData.append('title', pendingImport.title);
|
formData.append('title', pendingImport.title);
|
||||||
const res = await fetch('/api/admin/import', {
|
formData.append('author', pendingImport.author);
|
||||||
method: 'POST',
|
formData.append('cover_url', pendingImport.coverUrl);
|
||||||
body: formData
|
formData.append('genres', pendingImport.genres);
|
||||||
});
|
formData.append('summary', pendingImport.summary);
|
||||||
|
formData.append('book_status', pendingImport.bookStatus);
|
||||||
|
const res = await fetch('/api/admin/import', { method: 'POST', body: formData });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
// Save for AI panel before clearing state
|
||||||
|
const importedSlug = data.slug || '';
|
||||||
|
const importedTitle = pendingImport.title;
|
||||||
|
// Reset form
|
||||||
pendingImport = null;
|
pendingImport = null;
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
title = '';
|
title = '';
|
||||||
|
author = '';
|
||||||
|
coverUrl = '';
|
||||||
|
genres = '';
|
||||||
|
summary = '';
|
||||||
|
bookStatus = 'completed';
|
||||||
|
// Show AI panel for this slug
|
||||||
|
aiSlug = importedSlug;
|
||||||
|
aiTitle = importedTitle;
|
||||||
|
showAiPanel = !!aiSlug;
|
||||||
await loadTasks();
|
await loadTasks();
|
||||||
} else {
|
} else {
|
||||||
const d = await res.json().catch(() => ({}));
|
const d = await res.json().catch(() => ({}));
|
||||||
error = d.error || 'Import failed';
|
error = d.error || 'Import failed';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
error = 'Import failed';
|
error = 'Import failed';
|
||||||
} finally {
|
} finally {
|
||||||
uploading = false;
|
uploading = false;
|
||||||
@@ -126,149 +172,308 @@
|
|||||||
return new Date(dateStr).toLocaleString();
|
return new Date(dateStr).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColor(status: string) {
|
function statusColor(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending': return 'text-yellow-400';
|
case 'pending': return 'text-yellow-400';
|
||||||
case 'running': return 'text-blue-400';
|
case 'running': return 'text-blue-400';
|
||||||
case 'done': return 'text-green-400';
|
case 'done': return 'text-green-400';
|
||||||
case 'failed': return 'text-red-400';
|
case 'failed': return 'text-red-400';
|
||||||
default: return 'text-gray-400';
|
default: return 'text-(--color-muted)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => { loadTasks(); });
|
||||||
loadTasks();
|
|
||||||
|
// Poll every 3s while any task is active
|
||||||
|
$effect(() => {
|
||||||
|
const hasActive = tasks.some((t) => t.status === 'running' || t.status === 'pending');
|
||||||
|
if (!hasActive) return;
|
||||||
|
const timer = setInterval(() => { loadTasks(); }, 3000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Poll every 3s while any task is running
|
// When a running task finishes, surface the AI panel for it
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const hasRunning = tasks.some((t) => t.status === 'running' || t.status === 'pending');
|
if (!showAiPanel) {
|
||||||
if (!hasRunning) return;
|
const done = tasks.find((t) => t.status === 'done');
|
||||||
const timer = setInterval(() => {
|
if (done && !aiSlug) {
|
||||||
loadTasks();
|
aiSlug = done.slug;
|
||||||
}, 3000);
|
aiTitle = done.title;
|
||||||
return () => clearInterval(timer);
|
showAiPanel = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-4xl">
|
<div class="max-w-3xl space-y-8">
|
||||||
<h1 class="text-2xl font-bold mb-6">Import PDF/EPUB</h1>
|
<h1 class="text-2xl font-bold">Import PDF/EPUB</h1>
|
||||||
|
|
||||||
{#if pendingImport}
|
{#if pendingImport}
|
||||||
<!-- Review Step -->
|
<!-- ── Review step ── -->
|
||||||
<div class="mb-8 p-6 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/30">
|
<div class="p-6 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/30 space-y-4">
|
||||||
<h2 class="text-lg font-semibold mb-4">Review Import</h2>
|
<h2 class="text-lg font-semibold">Review Import</h2>
|
||||||
<div class="space-y-3 mb-6">
|
<dl class="space-y-2 text-sm">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between gap-4">
|
||||||
<span class="text-(--color-muted)">Title:</span>
|
<dt class="text-(--color-muted) shrink-0">Title</dt>
|
||||||
<span class="font-medium">{pendingImport.title}</span>
|
<dd class="font-medium text-right">{pendingImport.title}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
{#if pendingImport.author}
|
||||||
<span class="text-(--color-muted)">File:</span>
|
<div class="flex justify-between gap-4">
|
||||||
<span>{pendingImport.file.name}</span>
|
<dt class="text-(--color-muted) shrink-0">Author</dt>
|
||||||
</div>
|
<dd class="text-right">{pendingImport.author}</dd>
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-(--color-muted)">Size:</span>
|
|
||||||
<span>{(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB</span>
|
|
||||||
</div>
|
|
||||||
{#if pendingImport.preview.chapters > 0}
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-(--color-muted)">Detected chapters:</span>
|
|
||||||
<span class="text-green-400">{pendingImport.preview.chapters}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{#if pendingImport.genres}
|
||||||
<div class="flex gap-3">
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-(--color-muted) shrink-0">Genres</dt>
|
||||||
|
<dd class="text-right">{pendingImport.genres}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-(--color-muted) shrink-0">Status</dt>
|
||||||
|
<dd class="capitalize text-right">{pendingImport.bookStatus}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-(--color-muted) shrink-0">File</dt>
|
||||||
|
<dd class="text-right truncate max-w-xs">{pendingImport.file.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-(--color-muted) shrink-0">Size</dt>
|
||||||
|
<dd>{(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB</dd>
|
||||||
|
</div>
|
||||||
|
{#if pendingImport.preview.chapters > 0}
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-(--color-muted) shrink-0">Detected chapters</dt>
|
||||||
|
<dd class="text-green-400 font-semibold">{pendingImport.preview.chapters}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
{#if pendingImport.preview.firstLines?.length}
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<p class="text-xs text-(--color-muted) mb-1">First lines preview:</p>
|
||||||
|
{#each pendingImport.preview.firstLines as line}
|
||||||
|
<p class="text-xs text-(--color-muted) italic truncate">{line}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onclick={startImport}
|
onclick={startImport}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
class="px-4 py-2 bg-green-600 text-white rounded font-medium disabled:opacity-50"
|
class="px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded font-medium disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{uploading ? 'Starting...' : 'Start Import'}
|
{uploading ? 'Starting…' : 'Start Import'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={cancelReview}
|
onclick={cancelReview}
|
||||||
class="px-4 py-2 border border-(--color-border) rounded font-medium"
|
class="px-4 py-2 border border-(--color-border) rounded font-medium hover:bg-(--color-surface-3) transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Upload Form -->
|
<!-- ── Upload form ── -->
|
||||||
<form onsubmit={(e) => { e.preventDefault(); analyzeFile(); }} class="mb-8 p-4 bg-(--color-surface-2) rounded-lg">
|
<form
|
||||||
<div class="mb-4">
|
onsubmit={(e) => { e.preventDefault(); analyzeFile(); }}
|
||||||
<label for="import-file" class="block text-sm font-medium mb-2">Select File (PDF or EPUB)</label>
|
class="p-6 bg-(--color-surface-2) rounded-lg space-y-4"
|
||||||
|
>
|
||||||
|
<!-- File picker -->
|
||||||
|
<div>
|
||||||
|
<label for="import-file" class="block text-sm font-medium mb-1">File (PDF or EPUB)</label>
|
||||||
<input
|
<input
|
||||||
id="import-file"
|
id="import-file"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".pdf,.epub"
|
accept=".pdf,.epub"
|
||||||
onchange={handleFileSelect}
|
onchange={handleFileSelect}
|
||||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
|
||||||
<label for="import-title" class="block text-sm font-medium mb-2">Book Title</label>
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<label for="import-title" class="block text-sm font-medium mb-1">Title <span class="text-red-400">*</span></label>
|
||||||
<input
|
<input
|
||||||
id="import-title"
|
id="import-title"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
placeholder="Enter book title"
|
placeholder="Book title"
|
||||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
|
required
|
||||||
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Author -->
|
||||||
|
<div>
|
||||||
|
<label for="import-author" class="block text-sm font-medium mb-1">Author</label>
|
||||||
|
<input
|
||||||
|
id="import-author"
|
||||||
|
type="text"
|
||||||
|
bind:value={author}
|
||||||
|
placeholder="Author name"
|
||||||
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cover URL -->
|
||||||
|
<div>
|
||||||
|
<label for="import-cover" class="block text-sm font-medium mb-1">Cover image URL</label>
|
||||||
|
<input
|
||||||
|
id="import-cover"
|
||||||
|
type="url"
|
||||||
|
bind:value={coverUrl}
|
||||||
|
placeholder="https://…"
|
||||||
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Genres -->
|
||||||
|
<div>
|
||||||
|
<label for="import-genres" class="block text-sm font-medium mb-1">Genres <span class="text-xs text-(--color-muted)">(comma-separated)</span></label>
|
||||||
|
<input
|
||||||
|
id="import-genres"
|
||||||
|
type="text"
|
||||||
|
bind:value={genres}
|
||||||
|
placeholder="Fantasy, Action, Romance"
|
||||||
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<div>
|
||||||
|
<label for="import-summary" class="block text-sm font-medium mb-1">Summary</label>
|
||||||
|
<textarea
|
||||||
|
id="import-summary"
|
||||||
|
bind:value={summary}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Short description of the book…"
|
||||||
|
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm resize-y"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div>
|
||||||
|
<label for="import-status" class="block text-sm font-medium mb-1">Book status</label>
|
||||||
|
<select
|
||||||
|
id="import-status"
|
||||||
|
bind:value={bookStatus}
|
||||||
|
class="px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||||
|
>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="ongoing">Ongoing</option>
|
||||||
|
<option value="hiatus">Hiatus</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="mb-4 text-sm text-red-400">{error}</p>
|
<p class="text-sm text-red-400">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={analyzing || !selectedFile || !title.trim()}
|
disabled={analyzing || !selectedFile || !title.trim()}
|
||||||
class="px-4 py-2 bg-(--color-brand) text-(--color-surface) rounded font-medium disabled:opacity-50"
|
class="px-5 py-2 bg-(--color-brand) text-(--color-surface) rounded font-semibold disabled:opacity-50 hover:brightness-110 transition-all"
|
||||||
>
|
>
|
||||||
{analyzing ? 'Analyzing...' : 'Review & Import'}
|
{analyzing ? 'Analyzing…' : 'Review & Import'}
|
||||||
</button>
|
</button>
|
||||||
<p class="mt-2 text-xs text-(--color-muted)">
|
<p class="text-xs text-(--color-muted)">Detects chapter structure before committing.</p>
|
||||||
Select a file to preview chapter count before importing.
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Task List -->
|
<!-- ── AI Tasks panel (shown after successful import) ── -->
|
||||||
<h2 class="text-lg font-semibold mb-4">Import Tasks</h2>
|
{#if showAiPanel && aiSlug}
|
||||||
|
<div class="p-5 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/20 space-y-3">
|
||||||
{#if loading}
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-(--color-muted)">Loading...</p>
|
<h2 class="text-base font-semibold">AI Tasks for <span class="text-(--color-brand)">{aiTitle || aiSlug}</span></h2>
|
||||||
{:else if tasks.length === 0}
|
<button
|
||||||
<p class="text-(--color-muted)">No import tasks yet.</p>
|
onclick={() => { showAiPanel = false; }}
|
||||||
{:else}
|
class="text-(--color-muted) hover:text-(--color-text) text-lg leading-none"
|
||||||
<div class="overflow-x-auto">
|
aria-label="Dismiss"
|
||||||
<table class="w-full text-sm">
|
>×</button>
|
||||||
<thead>
|
</div>
|
||||||
<tr class="text-left text-(--color-muted) border-b border-(--color-border)">
|
<p class="text-sm text-(--color-muted)">Run AI tasks on the imported book to enrich it:</p>
|
||||||
<th class="pb-2">Title</th>
|
<div class="flex flex-wrap gap-2">
|
||||||
<th class="pb-2">Type</th>
|
<a
|
||||||
<th class="pb-2">Status</th>
|
href="/admin/text-gen?slug={aiSlug}&tab=chapters"
|
||||||
<th class="pb-2">Chapters</th>
|
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||||
<th class="pb-2">Started</th>
|
>
|
||||||
</tr>
|
Generate chapter names
|
||||||
</thead>
|
</a>
|
||||||
<tbody>
|
<a
|
||||||
{#each tasks as task}
|
href="/admin/text-gen?slug={aiSlug}&tab=description"
|
||||||
<tr class="border-b border-(--color-border)/50">
|
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||||
<td class="py-2">
|
>
|
||||||
<div class="font-medium">{task.title}</div>
|
Generate description
|
||||||
<div class="text-xs text-(--color-muted)">{task.slug}</div>
|
</a>
|
||||||
</td>
|
<a
|
||||||
<td class="py-2 uppercase text-xs">{task.file_type}</td>
|
href="/admin/image-gen?slug={aiSlug}"
|
||||||
<td class="py-2 {getStatusColor(task.status)}">{task.status}</td>
|
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||||
<td class="py-2 text-(--color-muted)">
|
>
|
||||||
{task.chapters_done}/{task.chapters_total}
|
Generate cover image
|
||||||
</td>
|
</a>
|
||||||
<td class="py-2 text-(--color-muted)">{formatDate(task.started)}</td>
|
<a
|
||||||
</tr>
|
href="/admin/text-gen?slug={aiSlug}&tab=tagline"
|
||||||
{/each}
|
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||||
</tbody>
|
>
|
||||||
</table>
|
Generate tagline
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
<!-- ── Task list ── -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold mb-3">Import Tasks</h2>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-(--color-muted) text-sm">Loading…</p>
|
||||||
|
{:else if tasks.length === 0}
|
||||||
|
<p class="text-(--color-muted) text-sm">No import tasks yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto rounded-lg border border-(--color-border)">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-(--color-muted) border-b border-(--color-border) bg-(--color-surface-2)">
|
||||||
|
<th class="px-3 py-2 font-medium">Title</th>
|
||||||
|
<th class="px-3 py-2 font-medium">Type</th>
|
||||||
|
<th class="px-3 py-2 font-medium">Status</th>
|
||||||
|
<th class="px-3 py-2 font-medium">Chapters</th>
|
||||||
|
<th class="px-3 py-2 font-medium">Started</th>
|
||||||
|
<th class="px-3 py-2 font-medium">AI</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tasks as task}
|
||||||
|
<tr class="border-b border-(--color-border)/50 hover:bg-(--color-surface-2)/50">
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<div class="font-medium">{task.title}</div>
|
||||||
|
<div class="text-xs text-(--color-muted)">{task.slug}</div>
|
||||||
|
{#if task.error_message}
|
||||||
|
<div class="text-xs text-red-400 mt-0.5 truncate max-w-xs" title={task.error_message}>{task.error_message}</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 uppercase text-xs">{task.file_type}</td>
|
||||||
|
<td class="px-3 py-2 {statusColor(task.status)} font-medium">{task.status}</td>
|
||||||
|
<td class="px-3 py-2 text-(--color-muted)">
|
||||||
|
{task.chapters_done}/{task.chapters_total}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-(--color-muted) text-xs whitespace-nowrap">{formatDate(task.started)}</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{#if task.status === 'done'}
|
||||||
|
<button
|
||||||
|
onclick={() => { aiSlug = task.slug; aiTitle = task.title; showAiPanel = true; }}
|
||||||
|
class="text-xs px-2 py-1 rounded bg-(--color-brand)/20 hover:bg-(--color-brand)/40 text-(--color-brand) transition-colors"
|
||||||
|
>
|
||||||
|
AI tasks
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
import { parseAuthToken } from '../../../../hooks.server.js';
|
||||||
|
import { deleteSessionByAuthId } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
const AUTH_COOKIE = 'libnovel_auth';
|
const AUTH_COOKIE = 'libnovel_auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/logout
|
* POST /api/auth/logout
|
||||||
* Clears the auth cookie and returns { ok: true }.
|
* Deletes the session row from PocketBase AND clears the auth cookie, so the
|
||||||
* Does not revoke the session record from PocketBase —
|
* session doesn't linger as a phantom "active session" after sign-out.
|
||||||
* for full revocation use DELETE /api/sessions/[id] first.
|
|
||||||
*/
|
*/
|
||||||
export const POST: RequestHandler = async ({ cookies }) => {
|
export const POST: RequestHandler = async ({ cookies }) => {
|
||||||
|
const token = cookies.get(AUTH_COOKIE);
|
||||||
|
if (token) {
|
||||||
|
const user = parseAuthToken(token);
|
||||||
|
if (user?.authSessionId) {
|
||||||
|
// Best-effort — non-fatal if PocketBase is unreachable.
|
||||||
|
deleteSessionByAuthId(user.authSessionId).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
cookies.delete(AUTH_COOKIE, { path: '/' });
|
cookies.delete(AUTH_COOKIE, { path: '/' });
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user