|
|
@@ -1,8 +1,30 @@
|
|
|
-import { useApi, postApi } from "../hooks/use-api";
|
|
|
-import { useState } from "react";
|
|
|
+import { fetchJson, useApi, postApi } from "../hooks/use-api";
|
|
|
+import { useEffect, useMemo, useState } from "react";
|
|
|
import type { Theme } from "../hooks/use-theme";
|
|
|
import type { TFunction } from "../hooks/use-i18n";
|
|
|
+import type { SSEMessage } from "../hooks/use-sse";
|
|
|
import { useColors } from "../hooks/use-colors";
|
|
|
+import { deriveBookActivity, shouldRefetchBookView } from "../hooks/use-book-activity";
|
|
|
+import { ConfirmDialog } from "../components/ConfirmDialog";
|
|
|
+import {
|
|
|
+ ChevronLeft,
|
|
|
+ Zap,
|
|
|
+ FileText,
|
|
|
+ CheckCheck,
|
|
|
+ BarChart2,
|
|
|
+ Download,
|
|
|
+ Search,
|
|
|
+ Wand2,
|
|
|
+ Eye,
|
|
|
+ Database,
|
|
|
+ Check,
|
|
|
+ X,
|
|
|
+ ShieldCheck,
|
|
|
+ RotateCcw,
|
|
|
+ Sparkles,
|
|
|
+ Trash2,
|
|
|
+ Save
|
|
|
+} from "lucide-react";
|
|
|
|
|
|
interface ChapterMeta {
|
|
|
readonly number: number;
|
|
|
@@ -18,6 +40,7 @@ interface BookData {
|
|
|
readonly genre: string;
|
|
|
readonly status: string;
|
|
|
readonly chapterWordCount: number;
|
|
|
+ readonly targetChapters?: number;
|
|
|
readonly language?: string;
|
|
|
readonly fanficMode?: string;
|
|
|
};
|
|
|
@@ -25,47 +48,174 @@ interface BookData {
|
|
|
readonly nextChapter: number;
|
|
|
}
|
|
|
|
|
|
+type ReviseMode = "spot-fix" | "polish" | "rewrite" | "rework" | "anti-detect";
|
|
|
+type ExportFormat = "txt" | "md" | "epub";
|
|
|
+type BookStatus = "active" | "paused" | "outlining" | "completed" | "dropped";
|
|
|
+
|
|
|
interface Nav {
|
|
|
toDashboard: () => void;
|
|
|
toChapter: (bookId: string, num: number) => void;
|
|
|
toAnalytics: (bookId: string) => void;
|
|
|
}
|
|
|
|
|
|
-const STATUS_COLORS: Record<string, string> = {
|
|
|
- "ready-for-review": "text-amber-400",
|
|
|
- approved: "text-emerald-400",
|
|
|
- drafted: "text-zinc-400",
|
|
|
- "needs-revision": "text-red-400",
|
|
|
- imported: "text-blue-400",
|
|
|
+function translateChapterStatus(status: string, t: TFunction): string {
|
|
|
+ const map: Record<string, () => string> = {
|
|
|
+ "ready-for-review": () => t("chapter.readyForReview"),
|
|
|
+ "approved": () => t("chapter.approved"),
|
|
|
+ "drafted": () => t("chapter.drafted"),
|
|
|
+ "needs-revision": () => t("chapter.needsRevision"),
|
|
|
+ "imported": () => t("chapter.imported"),
|
|
|
+ "audit-failed": () => t("chapter.auditFailed"),
|
|
|
+ };
|
|
|
+ return map[status]?.() ?? status;
|
|
|
+}
|
|
|
+
|
|
|
+const STATUS_CONFIG: Record<string, { color: string; icon: React.ReactNode }> = {
|
|
|
+ "ready-for-review": { color: "text-amber-500 bg-amber-500/10", icon: <Eye size={12} /> },
|
|
|
+ approved: { color: "text-emerald-500 bg-emerald-500/10", icon: <Check size={12} /> },
|
|
|
+ drafted: { color: "text-muted-foreground bg-muted/20", icon: <FileText size={12} /> },
|
|
|
+ "needs-revision": { color: "text-destructive bg-destructive/10", icon: <RotateCcw size={12} /> },
|
|
|
+ imported: { color: "text-blue-500 bg-blue-500/10", icon: <Download size={12} /> },
|
|
|
};
|
|
|
|
|
|
-export function BookDetail({ bookId, nav, theme, t }: { bookId: string; nav: Nav; theme: Theme; t: TFunction }) {
|
|
|
+export function BookDetail({
|
|
|
+ bookId,
|
|
|
+ nav,
|
|
|
+ theme,
|
|
|
+ t,
|
|
|
+ sse,
|
|
|
+}: {
|
|
|
+ bookId: string;
|
|
|
+ nav: Nav;
|
|
|
+ theme: Theme;
|
|
|
+ t: TFunction;
|
|
|
+ sse: { messages: ReadonlyArray<SSEMessage> };
|
|
|
+}) {
|
|
|
const c = useColors(theme);
|
|
|
const { data, loading, error, refetch } = useApi<BookData>(`/books/${bookId}`);
|
|
|
- const [writing, setWriting] = useState(false);
|
|
|
- const [drafting, setDrafting] = useState(false);
|
|
|
+ const [writeRequestPending, setWriteRequestPending] = useState(false);
|
|
|
+ const [draftRequestPending, setDraftRequestPending] = useState(false);
|
|
|
+ const [deleting, setDeleting] = useState(false);
|
|
|
+ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
|
+ const [rewritingChapters, setRewritingChapters] = useState<ReadonlyArray<number>>([]);
|
|
|
+ const [revisingChapters, setRevisingChapters] = useState<ReadonlyArray<number>>([]);
|
|
|
+ const [savingSettings, setSavingSettings] = useState(false);
|
|
|
+ const [settingsWordCount, setSettingsWordCount] = useState<number | null>(null);
|
|
|
+ const [settingsTargetChapters, setSettingsTargetChapters] = useState<number | null>(null);
|
|
|
+ const [settingsStatus, setSettingsStatus] = useState<BookStatus | null>(null);
|
|
|
+ const [exportFormat, setExportFormat] = useState<ExportFormat>("txt");
|
|
|
+ const [exportApprovedOnly, setExportApprovedOnly] = useState(false);
|
|
|
+ const activity = useMemo(() => deriveBookActivity(sse.messages, bookId), [bookId, sse.messages]);
|
|
|
+ const writing = writeRequestPending || activity.writing;
|
|
|
+ const drafting = draftRequestPending || activity.drafting;
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const recent = sse.messages.at(-1);
|
|
|
+ if (!recent) return;
|
|
|
+
|
|
|
+ const data = recent.data as { bookId?: string } | null;
|
|
|
+ if (data?.bookId !== bookId) return;
|
|
|
+
|
|
|
+ if (recent.event === "write:start") {
|
|
|
+ setWriteRequestPending(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (recent.event === "draft:start") {
|
|
|
+ setDraftRequestPending(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (shouldRefetchBookView(recent, bookId)) {
|
|
|
+ setWriteRequestPending(false);
|
|
|
+ setDraftRequestPending(false);
|
|
|
+ refetch();
|
|
|
+ }
|
|
|
+ }, [bookId, refetch, sse.messages]);
|
|
|
|
|
|
const handleWriteNext = async () => {
|
|
|
- setWriting(true);
|
|
|
+ setWriteRequestPending(true);
|
|
|
try {
|
|
|
await postApi(`/books/${bookId}/write-next`);
|
|
|
- refetch();
|
|
|
} catch (e) {
|
|
|
+ setWriteRequestPending(false);
|
|
|
alert(e instanceof Error ? e.message : "Failed");
|
|
|
- } finally {
|
|
|
- setWriting(false);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const handleDraft = async () => {
|
|
|
- setDrafting(true);
|
|
|
+ setDraftRequestPending(true);
|
|
|
try {
|
|
|
await postApi(`/books/${bookId}/draft`);
|
|
|
- refetch();
|
|
|
} catch (e) {
|
|
|
+ setDraftRequestPending(false);
|
|
|
alert(e instanceof Error ? e.message : "Failed");
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleDeleteBook = async () => {
|
|
|
+ setConfirmDeleteOpen(false);
|
|
|
+ setDeleting(true);
|
|
|
+ try {
|
|
|
+ const res = await fetch(`/api/books/${bookId}`, { method: "DELETE" });
|
|
|
+ if (!res.ok) {
|
|
|
+ const json = await res.json().catch(() => ({}));
|
|
|
+ throw new Error((json as { error?: string }).error ?? `${res.status}`);
|
|
|
+ }
|
|
|
+ nav.toDashboard();
|
|
|
+ } catch (e) {
|
|
|
+ alert(e instanceof Error ? e.message : "Delete failed");
|
|
|
+ } finally {
|
|
|
+ setDeleting(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleRewrite = async (chapterNum: number) => {
|
|
|
+ setRewritingChapters((prev) => [...prev, chapterNum]);
|
|
|
+ try {
|
|
|
+ await postApi(`/books/${bookId}/rewrite/${chapterNum}`);
|
|
|
+ refetch();
|
|
|
+ } catch (e) {
|
|
|
+ alert(e instanceof Error ? e.message : "Rewrite failed");
|
|
|
+ } finally {
|
|
|
+ setRewritingChapters((prev) => prev.filter((n) => n !== chapterNum));
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleRevise = async (chapterNum: number, mode: ReviseMode) => {
|
|
|
+ setRevisingChapters((prev) => [...prev, chapterNum]);
|
|
|
+ try {
|
|
|
+ await fetchJson(`/books/${bookId}/revise/${chapterNum}`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify({ mode }),
|
|
|
+ });
|
|
|
+ refetch();
|
|
|
+ } catch (e) {
|
|
|
+ alert(e instanceof Error ? e.message : "Revision failed");
|
|
|
+ } finally {
|
|
|
+ setRevisingChapters((prev) => prev.filter((n) => n !== chapterNum));
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleSaveSettings = async () => {
|
|
|
+ if (!data) return;
|
|
|
+ setSavingSettings(true);
|
|
|
+ try {
|
|
|
+ const body: Record<string, unknown> = {};
|
|
|
+ if (settingsWordCount !== null) body.chapterWordCount = settingsWordCount;
|
|
|
+ if (settingsTargetChapters !== null) body.targetChapters = settingsTargetChapters;
|
|
|
+ if (settingsStatus !== null) body.status = settingsStatus;
|
|
|
+ await fetchJson(`/books/${bookId}`, {
|
|
|
+ method: "PUT",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify(body),
|
|
|
+ });
|
|
|
+ refetch();
|
|
|
+ } catch (e) {
|
|
|
+ alert(e instanceof Error ? e.message : "Save failed");
|
|
|
} finally {
|
|
|
- setDrafting(false);
|
|
|
+ setSavingSettings(false);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
@@ -78,172 +228,352 @@ export function BookDetail({ bookId, nav, theme, t }: { bookId: string; nav: Nav
|
|
|
refetch();
|
|
|
};
|
|
|
|
|
|
- if (loading) return <div className={c.muted}>Loading...</div>;
|
|
|
- if (error) return <div className="text-red-400">Error: {error}</div>;
|
|
|
+ if (loading) return (
|
|
|
+ <div className="flex flex-col items-center justify-center py-32 space-y-4">
|
|
|
+ <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
|
|
|
+ <span className="text-sm text-muted-foreground">{t("common.loading")}</span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ if (error) return <div className="text-destructive p-8 bg-destructive/5 rounded-xl border border-destructive/20">Error: {error}</div>;
|
|
|
if (!data) return null;
|
|
|
|
|
|
const { book, chapters } = data;
|
|
|
const totalWords = chapters.reduce((sum, ch) => sum + (ch.wordCount ?? 0), 0);
|
|
|
const reviewCount = chapters.filter((ch) => ch.status === "ready-for-review").length;
|
|
|
|
|
|
+ const currentWordCount = settingsWordCount ?? book.chapterWordCount;
|
|
|
+ const currentTargetChapters = settingsTargetChapters ?? book.targetChapters ?? 0;
|
|
|
+ const currentStatus = settingsStatus ?? (book.status as BookStatus);
|
|
|
+
|
|
|
+ const exportHref = `/api/books/${bookId}/export?format=${exportFormat}${exportApprovedOnly ? "&approvedOnly=true" : ""}`;
|
|
|
+
|
|
|
return (
|
|
|
- <div className="space-y-6">
|
|
|
- <div className={`flex items-center gap-2 text-sm ${c.muted}`}>
|
|
|
- <button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
|
|
|
- <span>/</span>
|
|
|
- <span className={c.subtle}>{book.title}</span>
|
|
|
- </div>
|
|
|
+ <div className="space-y-8 fade-in">
|
|
|
+ {/* Breadcrumbs */}
|
|
|
+ <nav className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
|
|
|
+ <button
|
|
|
+ onClick={nav.toDashboard}
|
|
|
+ className="hover:text-primary transition-colors flex items-center gap-1"
|
|
|
+ >
|
|
|
+ <ChevronLeft size={14} />
|
|
|
+ {t("bread.books")}
|
|
|
+ </button>
|
|
|
+ <span className="text-border">/</span>
|
|
|
+ <span className="text-foreground">{book.title}</span>
|
|
|
+ </nav>
|
|
|
|
|
|
- <div className="flex items-start justify-between">
|
|
|
- <div>
|
|
|
- <h1 className="text-2xl font-semibold">{book.title}</h1>
|
|
|
- <div className={`flex gap-3 mt-1 text-sm ${c.muted}`}>
|
|
|
- <span>{book.genre}</span>
|
|
|
- <span>{chapters.length} chapters</span>
|
|
|
- <span>{totalWords.toLocaleString()} words</span>
|
|
|
- {book.language === "en" && <span className="text-blue-400">EN</span>}
|
|
|
- {book.fanficMode && <span className="text-purple-400">fanfic:{book.fanficMode}</span>}
|
|
|
+ {/* Header Section */}
|
|
|
+ <div className="flex flex-col md:flex-row md:items-end justify-between gap-6 border-b border-border/40 pb-8">
|
|
|
+ <div className="space-y-2">
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <h1 className="text-4xl font-serif font-medium">{book.title}</h1>
|
|
|
+ {book.language === "en" && (
|
|
|
+ <span className="px-1.5 py-0.5 rounded border border-primary/20 text-primary text-[10px] font-bold">EN</span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground font-medium">
|
|
|
+ <span className="px-2 py-0.5 rounded bg-secondary/50 text-foreground/70 uppercase tracking-wider text-xs">{book.genre}</span>
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
+ <FileText size={14} />
|
|
|
+ <span>{chapters.length} {t("dash.chapters")}</span>
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
+ <Zap size={14} />
|
|
|
+ <span>{totalWords.toLocaleString()} {t("book.words")}</span>
|
|
|
+ </div>
|
|
|
+ {book.fanficMode && (
|
|
|
+ <span className="flex items-center gap-1 text-purple-500">
|
|
|
+ <Sparkles size={12} />
|
|
|
+ <span className="italic">fanfic:{book.fanficMode}</span>
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div className="flex gap-2">
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
<button
|
|
|
onClick={handleWriteNext}
|
|
|
disabled={writing || drafting}
|
|
|
- className={`px-4 py-2 text-sm ${c.btnPrimary} rounded-md transition-colors disabled:opacity-50`}
|
|
|
+ className="flex items-center gap-2 px-5 py-2.5 text-sm font-bold bg-primary text-primary-foreground rounded-xl hover:scale-105 active:scale-95 transition-all shadow-lg shadow-primary/20 disabled:opacity-50"
|
|
|
>
|
|
|
- {writing ? "Writing..." : t("book.writeNext")}
|
|
|
+ {writing ? <div className="w-4 h-4 border-2 border-primary-foreground/20 border-t-primary-foreground rounded-full animate-spin" /> : <Zap size={16} />}
|
|
|
+ {writing ? t("dash.writing") : t("book.writeNext")}
|
|
|
</button>
|
|
|
<button
|
|
|
onClick={handleDraft}
|
|
|
disabled={writing || drafting}
|
|
|
- className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors disabled:opacity-50`}
|
|
|
+ className="flex items-center gap-2 px-5 py-2.5 text-sm font-bold bg-secondary text-foreground rounded-xl hover:bg-secondary/80 transition-all border border-border/50 disabled:opacity-50"
|
|
|
>
|
|
|
- {drafting ? "Drafting..." : t("book.draftOnly")}
|
|
|
+ {drafting ? <div className="w-4 h-4 border-2 border-muted-foreground/20 border-t-muted-foreground rounded-full animate-spin" /> : <Wand2 size={16} />}
|
|
|
+ {drafting ? t("book.drafting") : t("book.draftOnly")}
|
|
|
</button>
|
|
|
+ <button
|
|
|
+ onClick={() => setConfirmDeleteOpen(true)}
|
|
|
+ disabled={deleting}
|
|
|
+ className="flex items-center gap-2 px-5 py-2.5 text-sm font-bold bg-destructive/10 text-destructive rounded-xl hover:bg-destructive hover:text-white transition-all border border-destructive/20 disabled:opacity-50"
|
|
|
+ >
|
|
|
+ {deleting ? <div className="w-4 h-4 border-2 border-destructive/20 border-t-destructive rounded-full animate-spin" /> : <Trash2 size={16} />}
|
|
|
+ {deleting ? t("common.loading") : t("book.deleteBook")}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {(writing || drafting || activity.lastError) && (
|
|
|
+ <div
|
|
|
+ className={`rounded-2xl border px-4 py-3 text-sm ${
|
|
|
+ activity.lastError
|
|
|
+ ? "border-destructive/30 bg-destructive/5 text-destructive"
|
|
|
+ : "border-primary/20 bg-primary/[0.04] text-foreground"
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {activity.lastError ? (
|
|
|
+ <span>
|
|
|
+ {t("book.pipelineFailed")}: {activity.lastError}
|
|
|
+ </span>
|
|
|
+ ) : writing ? (
|
|
|
+ <span>{t("book.pipelineWriting")}</span>
|
|
|
+ ) : (
|
|
|
+ <span>{t("book.pipelineDrafting")}</span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Tool Strip */}
|
|
|
+ <div className="flex flex-wrap items-center gap-2 py-1">
|
|
|
{reviewCount > 0 && (
|
|
|
<button
|
|
|
onClick={handleApproveAll}
|
|
|
- className={`px-4 py-2 text-sm ${c.btnSuccess} rounded-md transition-colors`}
|
|
|
+ className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-emerald-500/10 text-emerald-600 rounded-lg hover:bg-emerald-500/20 transition-all border border-emerald-500/20"
|
|
|
>
|
|
|
+ <CheckCheck size={14} />
|
|
|
{t("book.approveAll")} ({reviewCount})
|
|
|
</button>
|
|
|
)}
|
|
|
<button
|
|
|
onClick={() => (nav as { toTruth?: (id: string) => void }).toTruth?.(bookId)}
|
|
|
- className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors`}
|
|
|
+ className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
|
|
|
>
|
|
|
- Truth Files
|
|
|
+ <Database size={14} />
|
|
|
+ {t("book.truthFiles")}
|
|
|
</button>
|
|
|
<button
|
|
|
onClick={() => nav.toAnalytics(bookId)}
|
|
|
- className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors`}
|
|
|
+ className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
|
|
|
>
|
|
|
+ <BarChart2 size={14} />
|
|
|
{t("book.analytics")}
|
|
|
</button>
|
|
|
- <a
|
|
|
- href={`/api/books/${bookId}/export?format=txt`}
|
|
|
- download
|
|
|
- className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors inline-flex items-center`}
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <select
|
|
|
+ value={exportFormat}
|
|
|
+ onChange={(e) => setExportFormat(e.target.value as ExportFormat)}
|
|
|
+ className="px-2 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg border border-border/50 outline-none"
|
|
|
+ >
|
|
|
+ <option value="txt">TXT</option>
|
|
|
+ <option value="md">MD</option>
|
|
|
+ <option value="epub">EPUB</option>
|
|
|
+ </select>
|
|
|
+ <label className="flex items-center gap-1.5 text-xs font-bold text-muted-foreground cursor-pointer select-none">
|
|
|
+ <input
|
|
|
+ type="checkbox"
|
|
|
+ checked={exportApprovedOnly}
|
|
|
+ onChange={(e) => setExportApprovedOnly(e.target.checked)}
|
|
|
+ className="rounded border-border/50"
|
|
|
+ />
|
|
|
+ {t("book.approvedOnly")}
|
|
|
+ </label>
|
|
|
+ <button
|
|
|
+ onClick={async () => {
|
|
|
+ try {
|
|
|
+ const data = await fetchJson<{ path?: string; chapters?: number }>(`/books/${bookId}/export-save`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify({ format: exportFormat, approvedOnly: exportApprovedOnly }),
|
|
|
+ });
|
|
|
+ alert(`${t("common.exportSuccess")}\n${data.path}\n(${data.chapters} ${t("dash.chapters")})`);
|
|
|
+ } catch (e) {
|
|
|
+ alert(e instanceof Error ? e.message : "Export failed");
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
|
|
|
+ >
|
|
|
+ <Download size={14} />
|
|
|
+ {t("book.export")}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Book Settings */}
|
|
|
+ <div className="paper-sheet rounded-2xl border border-border/40 shadow-sm p-6">
|
|
|
+ <h2 className="text-sm font-bold uppercase tracking-widest text-muted-foreground mb-4">{t("book.settings")}</h2>
|
|
|
+ <div className="flex flex-wrap items-end gap-4">
|
|
|
+ <div className="flex flex-col gap-1">
|
|
|
+ <label className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground">{t("create.wordsPerChapter")}</label>
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ value={currentWordCount}
|
|
|
+ onChange={(e) => setSettingsWordCount(Number(e.target.value))}
|
|
|
+ className="px-3 py-2 text-sm rounded-lg border border-border/50 bg-secondary/30 outline-none focus:border-primary/50 w-32"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="flex flex-col gap-1">
|
|
|
+ <label className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground">{t("create.targetChapters")}</label>
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ value={currentTargetChapters}
|
|
|
+ onChange={(e) => setSettingsTargetChapters(Number(e.target.value))}
|
|
|
+ className="px-3 py-2 text-sm rounded-lg border border-border/50 bg-secondary/30 outline-none focus:border-primary/50 w-32"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="flex flex-col gap-1">
|
|
|
+ <label className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground">{t("book.status")}</label>
|
|
|
+ <select
|
|
|
+ value={currentStatus}
|
|
|
+ onChange={(e) => setSettingsStatus(e.target.value as BookStatus)}
|
|
|
+ className="px-3 py-2 text-sm rounded-lg border border-border/50 bg-secondary/30 outline-none focus:border-primary/50"
|
|
|
+ >
|
|
|
+ <option value="active">{t("book.statusActive")}</option>
|
|
|
+ <option value="paused">{t("book.statusPaused")}</option>
|
|
|
+ <option value="outlining">{t("book.statusOutlining")}</option>
|
|
|
+ <option value="completed">{t("book.statusCompleted")}</option>
|
|
|
+ <option value="dropped">{t("book.statusDropped")}</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={handleSaveSettings}
|
|
|
+ disabled={savingSettings}
|
|
|
+ className="flex items-center gap-2 px-4 py-2 text-sm font-bold bg-primary text-primary-foreground rounded-lg hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
|
|
|
>
|
|
|
- Export
|
|
|
- </a>
|
|
|
+ {savingSettings ? <div className="w-4 h-4 border-2 border-primary-foreground/20 border-t-primary-foreground rounded-full animate-spin" /> : <Save size={14} />}
|
|
|
+ {savingSettings ? t("book.saving") : t("book.save")}
|
|
|
+ </button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div className={`border ${c.cardStatic} rounded-lg overflow-hidden`}>
|
|
|
- <table className="w-full text-sm">
|
|
|
- <thead className={c.tableHeader}>
|
|
|
- <tr>
|
|
|
- <th className="text-left px-4 py-3 font-medium w-16">#</th>
|
|
|
- <th className="text-left px-4 py-3 font-medium">Title</th>
|
|
|
- <th className="text-left px-4 py-3 font-medium w-24">Words</th>
|
|
|
- <th className="text-left px-4 py-3 font-medium w-32">Status</th>
|
|
|
- <th className="text-right px-4 py-3 font-medium w-32">Actions</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <tbody className={`divide-y ${c.tableDivide}`}>
|
|
|
- {chapters.map((ch) => (
|
|
|
- <tr key={ch.number} className={`${c.tableHover} transition-colors`}>
|
|
|
- <td className={`px-4 py-3 ${c.muted}`}>{ch.number}</td>
|
|
|
- <td className="px-4 py-3">
|
|
|
- <button
|
|
|
- onClick={() => nav.toChapter(bookId, ch.number)}
|
|
|
- className={`${c.link} transition-colors`}
|
|
|
- >
|
|
|
- {ch.title || `Chapter ${ch.number}`}
|
|
|
- </button>
|
|
|
- </td>
|
|
|
- <td className={`px-4 py-3 ${c.subtle} tabular-nums`}>{(ch.wordCount ?? 0).toLocaleString()}</td>
|
|
|
- <td className={`px-4 py-3 ${STATUS_COLORS[ch.status] ?? c.subtle}`}>
|
|
|
- {ch.status}
|
|
|
- </td>
|
|
|
- <td className="px-4 py-3 text-right">
|
|
|
- <div className="flex gap-1 justify-end">
|
|
|
- {ch.status === "ready-for-review" && (
|
|
|
- <>
|
|
|
- <button
|
|
|
- onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/approve`); refetch(); }}
|
|
|
- className={`px-2 py-1 text-xs ${c.btnSuccess} rounded`}
|
|
|
- >
|
|
|
- {t("book.approve")}
|
|
|
- </button>
|
|
|
- <button
|
|
|
- onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/reject`); refetch(); }}
|
|
|
- className={`px-2 py-1 text-xs ${c.btnDanger} rounded`}
|
|
|
- >
|
|
|
- {t("book.reject")}
|
|
|
- </button>
|
|
|
- </>
|
|
|
- )}
|
|
|
- <button
|
|
|
- onClick={async () => {
|
|
|
- const r = await fetch(`/api/books/${bookId}/audit/${ch.number}`, { method: "POST" });
|
|
|
- const data = await r.json();
|
|
|
- alert(data.passed ? "Audit passed" : `Audit failed: ${data.issues?.length ?? 0} issues`);
|
|
|
- refetch();
|
|
|
- }}
|
|
|
- className={`px-2 py-1 text-xs ${c.btnSecondary} rounded`}
|
|
|
- >
|
|
|
- Audit
|
|
|
- </button>
|
|
|
- <button
|
|
|
- onClick={async () => {
|
|
|
- await fetch(`/api/books/${bookId}/revise/${ch.number}`, {
|
|
|
- method: "POST",
|
|
|
- headers: { "Content-Type": "application/json" },
|
|
|
- body: JSON.stringify({ mode: "spot-fix" }),
|
|
|
- });
|
|
|
- alert("Revision started");
|
|
|
- refetch();
|
|
|
- }}
|
|
|
- className={`px-2 py-1 text-xs ${c.btnSecondary} rounded`}
|
|
|
- >
|
|
|
- Revise
|
|
|
- </button>
|
|
|
+ {/* Chapters Table */}
|
|
|
+ <div className="paper-sheet rounded-2xl overflow-hidden border border-border/40 shadow-xl shadow-primary/5">
|
|
|
+ <div className="overflow-x-auto">
|
|
|
+ <table className="w-full text-sm border-collapse">
|
|
|
+ <thead>
|
|
|
+ <tr className="bg-muted/30 border-b border-border/50">
|
|
|
+ <th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground w-16">#</th>
|
|
|
+ <th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground">{t("book.manuscriptTitle")}</th>
|
|
|
+ <th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground w-28">{t("book.words")}</th>
|
|
|
+ <th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground w-36">{t("book.status")}</th>
|
|
|
+ <th className="text-right px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground">{t("book.curate")}</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody className="divide-y divide-border/30">
|
|
|
+ {chapters.map((ch, index) => {
|
|
|
+ const staggerClass = `stagger-${Math.min(index + 1, 5)}`;
|
|
|
+ return (
|
|
|
+ <tr key={ch.number} className={`group hover:bg-primary/[0.02] transition-colors fade-in ${staggerClass}`}>
|
|
|
+ <td className="px-6 py-4 text-muted-foreground/60 font-mono text-xs">{ch.number.toString().padStart(2, '0')}</td>
|
|
|
+ <td className="px-6 py-4">
|
|
|
<button
|
|
|
- onClick={async () => {
|
|
|
- const r = await fetch(`/api/books/${bookId}/detect/${ch.number}`, { method: "POST" });
|
|
|
- const data = await r.json();
|
|
|
- alert(`AI-tell: ${data.issues?.length ?? 0} issues found`);
|
|
|
- }}
|
|
|
- className={`px-2 py-1 text-xs ${c.btnSecondary} rounded`}
|
|
|
+ onClick={() => nav.toChapter(bookId, ch.number)}
|
|
|
+ className="font-serif text-lg font-medium hover:text-primary transition-colors text-left"
|
|
|
>
|
|
|
- Detect
|
|
|
+ {ch.title || t("chapter.label").replace("{n}", String(ch.number))}
|
|
|
</button>
|
|
|
- </div>
|
|
|
- </td>
|
|
|
- </tr>
|
|
|
- ))}
|
|
|
- </tbody>
|
|
|
- </table>
|
|
|
+ </td>
|
|
|
+ <td className="px-6 py-4 text-muted-foreground font-medium tabular-nums text-xs">{(ch.wordCount ?? 0).toLocaleString()}</td>
|
|
|
+ <td className="px-6 py-4">
|
|
|
+ <div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-tight ${STATUS_CONFIG[ch.status]?.color ?? "bg-muted text-muted-foreground"}`}>
|
|
|
+ {STATUS_CONFIG[ch.status]?.icon}
|
|
|
+ {translateChapterStatus(ch.status, t)}
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td className="px-6 py-4 text-right">
|
|
|
+ <div className="flex gap-1.5 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
|
|
+ {ch.status === "ready-for-review" && (
|
|
|
+ <>
|
|
|
+ <button
|
|
|
+ onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/approve`); refetch(); }}
|
|
|
+ className="p-2 rounded-lg bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500 hover:text-white transition-all shadow-sm"
|
|
|
+ title={t("book.approve")}
|
|
|
+ >
|
|
|
+ <Check size={14} />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/reject`); refetch(); }}
|
|
|
+ className="p-2 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-white transition-all shadow-sm"
|
|
|
+ title={t("book.reject")}
|
|
|
+ >
|
|
|
+ <X size={14} />
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ <button
|
|
|
+ onClick={async () => {
|
|
|
+ const auditResult = await fetchJson<{ passed?: boolean; issues?: unknown[] }>(`/books/${bookId}/audit/${ch.number}`, { method: "POST" });
|
|
|
+ alert(auditResult.passed ? "Audit passed" : `Audit failed: ${auditResult.issues?.length ?? 0} issues`);
|
|
|
+ refetch();
|
|
|
+ }}
|
|
|
+ className="p-2 rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm"
|
|
|
+ title={t("book.audit")}
|
|
|
+ >
|
|
|
+ <ShieldCheck size={14} />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => handleRewrite(ch.number)}
|
|
|
+ disabled={rewritingChapters.includes(ch.number)}
|
|
|
+ className="p-2 rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm disabled:opacity-50"
|
|
|
+ title={t("book.rewrite")}
|
|
|
+ >
|
|
|
+ {rewritingChapters.includes(ch.number)
|
|
|
+ ? <div className="w-3.5 h-3.5 border-2 border-muted-foreground/20 border-t-muted-foreground rounded-full animate-spin" />
|
|
|
+ : <RotateCcw size={14} />}
|
|
|
+ </button>
|
|
|
+ <select
|
|
|
+ disabled={revisingChapters.includes(ch.number)}
|
|
|
+ value=""
|
|
|
+ onChange={(e) => {
|
|
|
+ const mode = e.target.value as ReviseMode;
|
|
|
+ if (mode) handleRevise(ch.number, mode);
|
|
|
+ }}
|
|
|
+ className="px-2 py-1.5 text-[11px] font-bold rounded-lg bg-secondary text-muted-foreground border border-border/50 outline-none hover:text-primary hover:bg-primary/10 transition-all disabled:opacity-50 cursor-pointer"
|
|
|
+ title="Revise with AI"
|
|
|
+ >
|
|
|
+ <option value="" disabled>{revisingChapters.includes(ch.number) ? t("common.loading") : t("book.curate")}</option>
|
|
|
+ <option value="spot-fix">{t("book.spotFix")}</option>
|
|
|
+ <option value="polish">{t("book.polish")}</option>
|
|
|
+ <option value="rewrite">{t("book.rewrite")}</option>
|
|
|
+ <option value="rework">{t("book.rework")}</option>
|
|
|
+ <option value="anti-detect">{t("book.antiDetect")}</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
|
|
|
{chapters.length === 0 && (
|
|
|
- <div className={`text-center py-12 ${c.muted}`}>
|
|
|
- {t("book.noChapters")}
|
|
|
+ <div className="flex flex-col items-center justify-center py-20 text-center">
|
|
|
+ <div className="w-12 h-12 rounded-full bg-muted/20 flex items-center justify-center mb-4">
|
|
|
+ <FileText size={20} className="text-muted-foreground/40" />
|
|
|
+ </div>
|
|
|
+ <p className="text-sm italic font-serif text-muted-foreground">
|
|
|
+ {t("book.noChapters")}
|
|
|
+ </p>
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
+
|
|
|
+ <ConfirmDialog
|
|
|
+ open={confirmDeleteOpen}
|
|
|
+ title={t("book.deleteBook")}
|
|
|
+ message={t("book.confirmDelete")}
|
|
|
+ confirmLabel={t("common.delete")}
|
|
|
+ cancelLabel={t("common.cancel")}
|
|
|
+ variant="danger"
|
|
|
+ onConfirm={handleDeleteBook}
|
|
|
+ onCancel={() => setConfirmDeleteOpen(false)}
|
|
|
+ />
|
|
|
</div>
|
|
|
);
|
|
|
}
|