|
|
@@ -1,5 +1,6 @@
|
|
|
-import { useEffect, useState } from "react";
|
|
|
-import { fetchJson, useApi, postApi } from "../hooks/use-api";
|
|
|
+import { useEffect, useMemo, useState } from "react";
|
|
|
+import type { BookCreationDraft } from "@actalk/inkos-core";
|
|
|
+import { fetchJson, useApi } from "../hooks/use-api";
|
|
|
import type { Theme } from "../hooks/use-theme";
|
|
|
import type { TFunction } from "../hooks/use-i18n";
|
|
|
import { useColors } from "../hooks/use-colors";
|
|
|
@@ -21,6 +22,48 @@ interface PlatformOption {
|
|
|
readonly label: string;
|
|
|
}
|
|
|
|
|
|
+export interface DraftSummaryRow {
|
|
|
+ readonly key: string;
|
|
|
+ readonly label: string;
|
|
|
+ readonly value: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface InteractionSessionResponse {
|
|
|
+ readonly session?: {
|
|
|
+ readonly activeBookId?: string;
|
|
|
+ readonly creationDraft?: BookCreationDraft;
|
|
|
+ };
|
|
|
+ readonly activeBookId?: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface AgentResponse {
|
|
|
+ readonly response?: string;
|
|
|
+ readonly error?: string;
|
|
|
+ readonly session?: {
|
|
|
+ readonly activeBookId?: string;
|
|
|
+ readonly creationDraft?: BookCreationDraft;
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+interface PlatformCopy {
|
|
|
+ readonly idleTitle: string;
|
|
|
+ readonly idleBody: string;
|
|
|
+ readonly promptLabel: string;
|
|
|
+ readonly promptPlaceholder: string;
|
|
|
+ readonly promptPlaceholderFollowup: string;
|
|
|
+ readonly submit: string;
|
|
|
+ readonly submitting: string;
|
|
|
+ readonly create: string;
|
|
|
+ readonly creating: string;
|
|
|
+ readonly discard: string;
|
|
|
+ readonly draftHeading: string;
|
|
|
+ readonly missingHeading: string;
|
|
|
+ readonly missingHint: string;
|
|
|
+ readonly syncedHint: string;
|
|
|
+ readonly helperTitle: string;
|
|
|
+ readonly helperBody: string;
|
|
|
+}
|
|
|
+
|
|
|
const PLATFORMS_ZH: ReadonlyArray<PlatformOption> = [
|
|
|
{ value: "tomato", label: "番茄小说" },
|
|
|
{ value: "qidian", label: "起点中文网" },
|
|
|
@@ -35,6 +78,45 @@ const PLATFORMS_EN: ReadonlyArray<PlatformOption> = [
|
|
|
{ value: "other", label: "Other" },
|
|
|
];
|
|
|
|
|
|
+const PAGE_COPY: Record<"zh" | "en", PlatformCopy> = {
|
|
|
+ zh: {
|
|
|
+ idleTitle: "从一句模糊想法开始",
|
|
|
+ idleBody: "直接描述题材、世界观、主角、核心冲突,或告诉我你想先改哪一块。共享草案会在 TUI 和 Studio Chat 之间同步。",
|
|
|
+ promptLabel: "继续打磨这本书",
|
|
|
+ promptPlaceholder: "例如:我想写个港风商战悬疑,主角先做灰产再洗白。",
|
|
|
+ promptPlaceholderFollowup: "例如:世界观改成近未来港口城;女主不要太早出场;卷一先查账再砸场。",
|
|
|
+ submit: "更新草案",
|
|
|
+ submitting: "处理中…",
|
|
|
+ create: "按当前草案建书",
|
|
|
+ creating: "创建中…",
|
|
|
+ discard: "丢弃草案",
|
|
|
+ draftHeading: "当前 foundation 草案",
|
|
|
+ missingHeading: "还缺这些关键信息",
|
|
|
+ missingHint: "这些字段未必都要一次填满,但缺得太多时不要急着建书。",
|
|
|
+ syncedHint: "这份草案和 TUI / Studio Chat 共享。",
|
|
|
+ helperTitle: "建议这样推进",
|
|
|
+ helperBody: "先定世界观和主角,再定核心冲突、简介和卷一方向。想看当前草案时,可以在 TUI 里用 /draft。",
|
|
|
+ },
|
|
|
+ en: {
|
|
|
+ idleTitle: "Start from a rough idea",
|
|
|
+ idleBody: "Describe the genre, world, protagonist, and core conflict. The shared draft stays in sync across TUI and Studio Chat.",
|
|
|
+ promptLabel: "Refine this book",
|
|
|
+ promptPlaceholder: "Example: I want a harbor-noir business thriller about a fixer trying to go legit.",
|
|
|
+ promptPlaceholderFollowup: "Example: move the world to a near-future port city; delay the heroine; make volume one about chasing ledgers first.",
|
|
|
+ submit: "Update draft",
|
|
|
+ submitting: "Working…",
|
|
|
+ create: "Create book from draft",
|
|
|
+ creating: "Creating…",
|
|
|
+ discard: "Discard draft",
|
|
|
+ draftHeading: "Current foundation draft",
|
|
|
+ missingHeading: "Still missing",
|
|
|
+ missingHint: "You do not need every field immediately, but do not create the book while the foundation is still vague.",
|
|
|
+ syncedHint: "This draft is shared with TUI and Studio Chat.",
|
|
|
+ helperTitle: "Recommended flow",
|
|
|
+ helperBody: "Lock the world and protagonist first, then settle the conflict, blurb, and volume-one direction. In TUI, use /draft to inspect the same draft.",
|
|
|
+ },
|
|
|
+};
|
|
|
+
|
|
|
export function pickValidValue(current: string, available: ReadonlyArray<string>): string {
|
|
|
if (current && available.includes(current)) {
|
|
|
return current;
|
|
|
@@ -50,6 +132,56 @@ export function platformOptionsForLanguage(language: "zh" | "en"): ReadonlyArray
|
|
|
return language === "en" ? PLATFORMS_EN : PLATFORMS_ZH;
|
|
|
}
|
|
|
|
|
|
+export function resolveDraftInstruction(input: string, hasDraft: boolean): string {
|
|
|
+ const trimmed = input.trim();
|
|
|
+ if (!trimmed) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ return hasDraft ? trimmed : `/new ${trimmed}`;
|
|
|
+}
|
|
|
+
|
|
|
+export function canCreateFromDraft(draft?: BookCreationDraft): boolean {
|
|
|
+ if (!draft) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (draft.readyToCreate) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return Boolean(
|
|
|
+ draft.title?.trim()
|
|
|
+ && draft.genre?.trim()
|
|
|
+ && typeof draft.targetChapters === "number"
|
|
|
+ && typeof draft.chapterWordCount === "number",
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export function buildCreationDraftSummary(
|
|
|
+ draft: BookCreationDraft,
|
|
|
+ language: "zh" | "en",
|
|
|
+): ReadonlyArray<DraftSummaryRow> {
|
|
|
+ const rows = language === "en"
|
|
|
+ ? [
|
|
|
+ draft.title ? { key: "title", label: "Title", value: draft.title } : undefined,
|
|
|
+ draft.worldPremise ? { key: "worldPremise", label: "World", value: draft.worldPremise } : undefined,
|
|
|
+ draft.protagonist ? { key: "protagonist", label: "Protagonist", value: draft.protagonist } : undefined,
|
|
|
+ draft.conflictCore ? { key: "conflictCore", label: "Core Conflict", value: draft.conflictCore } : undefined,
|
|
|
+ draft.volumeOutline ? { key: "volumeOutline", label: "Volume Direction", value: draft.volumeOutline } : undefined,
|
|
|
+ draft.blurb ? { key: "blurb", label: "Blurb", value: draft.blurb } : undefined,
|
|
|
+ draft.nextQuestion ? { key: "nextQuestion", label: "Next", value: draft.nextQuestion } : undefined,
|
|
|
+ ]
|
|
|
+ : [
|
|
|
+ draft.title ? { key: "title", label: "书名", value: draft.title } : undefined,
|
|
|
+ draft.worldPremise ? { key: "worldPremise", label: "世界观", value: draft.worldPremise } : undefined,
|
|
|
+ draft.protagonist ? { key: "protagonist", label: "主角", value: draft.protagonist } : undefined,
|
|
|
+ draft.conflictCore ? { key: "conflictCore", label: "核心冲突", value: draft.conflictCore } : undefined,
|
|
|
+ draft.volumeOutline ? { key: "volumeOutline", label: "卷纲方向", value: draft.volumeOutline } : undefined,
|
|
|
+ draft.blurb ? { key: "blurb", label: "简介", value: draft.blurb } : undefined,
|
|
|
+ draft.nextQuestion ? { key: "nextQuestion", label: "下一步", value: draft.nextQuestion } : undefined,
|
|
|
+ ];
|
|
|
+
|
|
|
+ return rows.filter((row): row is DraftSummaryRow => Boolean(row));
|
|
|
+}
|
|
|
+
|
|
|
interface WaitForBookReadyOptions {
|
|
|
readonly fetchBook?: (bookId: string) => Promise<unknown>;
|
|
|
readonly fetchStatus?: (bookId: string) => Promise<{ status: string; error?: string }>;
|
|
|
@@ -60,6 +192,7 @@ interface WaitForBookReadyOptions {
|
|
|
|
|
|
const DEFAULT_BOOK_READY_MAX_ATTEMPTS = 120;
|
|
|
const DEFAULT_BOOK_READY_DELAY_MS = 250;
|
|
|
+const CREATION_DRAFT_SYNC_INTERVAL_MS = 2500;
|
|
|
|
|
|
export async function waitForBookReady(
|
|
|
bookId: string,
|
|
|
@@ -112,79 +245,143 @@ export async function waitForBookReady(
|
|
|
|
|
|
export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
|
|
|
const c = useColors(theme);
|
|
|
- const { data: genreData } = useApi<{ genres: ReadonlyArray<GenreInfo> }>("/genres");
|
|
|
const { data: project } = useApi<{ language: string }>("/project");
|
|
|
-
|
|
|
const projectLang = (project?.language ?? "zh") as "zh" | "en";
|
|
|
+ const copy = PAGE_COPY[projectLang];
|
|
|
|
|
|
- const [title, setTitle] = useState("");
|
|
|
- const [genre, setGenre] = useState("");
|
|
|
- const [platform, setPlatform] = useState("");
|
|
|
- const [chapterWords, setChapterWords] = useState(defaultChapterWordsForLanguage(projectLang));
|
|
|
- const [chapterWordsTouched, setChapterWordsTouched] = useState(false);
|
|
|
- const [targetChapters, setTargetChapters] = useState("200");
|
|
|
+ const [draft, setDraft] = useState<BookCreationDraft | undefined>();
|
|
|
+ const [input, setInput] = useState("");
|
|
|
+ const [loadingDraft, setLoadingDraft] = useState(true);
|
|
|
+ const [submitting, setSubmitting] = useState(false);
|
|
|
const [creating, setCreating] = useState(false);
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
+ const [status, setStatus] = useState<string | null>(null);
|
|
|
|
|
|
- // Filter genres by project language + custom genres (always show)
|
|
|
- const allGenres = genreData?.genres ?? [];
|
|
|
- const genres = allGenres.filter((g) => g.language === projectLang || g.source === "project");
|
|
|
- const platforms = platformOptionsForLanguage(projectLang);
|
|
|
- const genreSignature = genres.map((g) => g.id).join("|");
|
|
|
- const platformSignature = platforms.map((p) => `${p.value}:${p.label}`).join("|");
|
|
|
+ const summaryRows = useMemo(
|
|
|
+ () => (draft ? buildCreationDraftSummary(draft, projectLang) : []),
|
|
|
+ [draft, projectLang],
|
|
|
+ );
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- setGenre((current) => pickValidValue(current, genres.map((g) => g.id)));
|
|
|
- }, [genreSignature]);
|
|
|
+ const refreshDraft = async (): Promise<BookCreationDraft | undefined> => {
|
|
|
+ const data = await fetchJson<InteractionSessionResponse>("/interaction/session");
|
|
|
+ const nextDraft = data.session?.creationDraft;
|
|
|
+ setDraft(nextDraft);
|
|
|
+ return nextDraft;
|
|
|
+ };
|
|
|
|
|
|
useEffect(() => {
|
|
|
- setPlatform((current) => pickValidValue(current, platforms.map((p) => p.value)));
|
|
|
- }, [platformSignature]);
|
|
|
+ let cancelled = false;
|
|
|
+ setLoadingDraft(true);
|
|
|
+ void refreshDraft()
|
|
|
+ .catch((cause) => {
|
|
|
+ if (!cancelled) {
|
|
|
+ setError(cause instanceof Error ? cause.message : String(cause));
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .finally(() => {
|
|
|
+ if (!cancelled) {
|
|
|
+ setLoadingDraft(false);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return () => {
|
|
|
+ cancelled = true;
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
|
|
|
useEffect(() => {
|
|
|
- if (!chapterWordsTouched) {
|
|
|
- setChapterWords(defaultChapterWordsForLanguage(projectLang));
|
|
|
+ if (submitting || creating) {
|
|
|
+ return;
|
|
|
}
|
|
|
- }, [projectLang, chapterWordsTouched]);
|
|
|
|
|
|
- const handleCreate = async () => {
|
|
|
- if (!title.trim()) {
|
|
|
- setError(t("create.titleRequired"));
|
|
|
+ const timer = setInterval(() => {
|
|
|
+ void refreshDraft().catch(() => undefined);
|
|
|
+ }, CREATION_DRAFT_SYNC_INTERVAL_MS);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ clearInterval(timer);
|
|
|
+ };
|
|
|
+ }, [submitting, creating]);
|
|
|
+
|
|
|
+ const runAgentInstruction = async (instruction: string): Promise<AgentResponse> => {
|
|
|
+ return fetchJson<AgentResponse>("/agent", {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify({ instruction }),
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleDraftSubmit = async () => {
|
|
|
+ const instruction = resolveDraftInstruction(input, Boolean(draft));
|
|
|
+ if (!instruction) {
|
|
|
return;
|
|
|
}
|
|
|
- if (!genre) {
|
|
|
- setError(t("create.genreRequired"));
|
|
|
+
|
|
|
+ setSubmitting(true);
|
|
|
+ setError(null);
|
|
|
+ try {
|
|
|
+ const data = await runAgentInstruction(instruction);
|
|
|
+ setInput("");
|
|
|
+ setStatus(data.response ?? null);
|
|
|
+ setDraft(data.session?.creationDraft);
|
|
|
+ } catch (cause) {
|
|
|
+ setError(cause instanceof Error ? cause.message : String(cause));
|
|
|
+ } finally {
|
|
|
+ setSubmitting(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleCreate = async () => {
|
|
|
+ if (!canCreateFromDraft(draft)) {
|
|
|
return;
|
|
|
}
|
|
|
+
|
|
|
setCreating(true);
|
|
|
setError(null);
|
|
|
try {
|
|
|
- const result = await postApi<{ bookId: string }>("/books/create", {
|
|
|
- title: title.trim(),
|
|
|
- genre,
|
|
|
- language: projectLang,
|
|
|
- platform,
|
|
|
- chapterWordCount: parseInt(chapterWords, 10),
|
|
|
- targetChapters: parseInt(targetChapters, 10),
|
|
|
- });
|
|
|
- await waitForBookReady(result.bookId);
|
|
|
- nav.toBook(result.bookId);
|
|
|
- } catch (e) {
|
|
|
- setError(e instanceof Error ? e.message : "Failed to create book");
|
|
|
+ const data = await runAgentInstruction("/create");
|
|
|
+ const bookId = data.session?.activeBookId;
|
|
|
+ if (!bookId) {
|
|
|
+ throw new Error(projectLang === "zh" ? "创建完成后没有返回书籍 ID。" : "Create succeeded but no book id was returned.");
|
|
|
+ }
|
|
|
+ setStatus(data.response ?? null);
|
|
|
+ setDraft(undefined);
|
|
|
+ await waitForBookReady(bookId);
|
|
|
+ nav.toBook(bookId);
|
|
|
+ } catch (cause) {
|
|
|
+ setError(cause instanceof Error ? cause.message : String(cause));
|
|
|
} finally {
|
|
|
setCreating(false);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+ const handleDiscard = async () => {
|
|
|
+ setSubmitting(true);
|
|
|
+ setError(null);
|
|
|
+ try {
|
|
|
+ const data = await runAgentInstruction("/discard");
|
|
|
+ setStatus(data.response ?? null);
|
|
|
+ setDraft(undefined);
|
|
|
+ setInput("");
|
|
|
+ await refreshDraft().catch(() => undefined);
|
|
|
+ } catch (cause) {
|
|
|
+ setError(cause instanceof Error ? cause.message : String(cause));
|
|
|
+ } finally {
|
|
|
+ setSubmitting(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
return (
|
|
|
- <div className="max-w-lg mx-auto space-y-8">
|
|
|
+ <div className="max-w-4xl mx-auto space-y-8">
|
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
|
<button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
|
|
|
<span className="text-border">/</span>
|
|
|
<span>{t("bread.newBook")}</span>
|
|
|
</div>
|
|
|
|
|
|
- <h1 className="font-serif text-3xl">{t("create.title")}</h1>
|
|
|
+ <div className="space-y-3">
|
|
|
+ <h1 className="font-serif text-3xl">{t("create.title")}</h1>
|
|
|
+ <p className="text-sm text-muted-foreground leading-7">{copy.idleBody}</p>
|
|
|
+ </div>
|
|
|
|
|
|
{error && (
|
|
|
<div className={`border ${c.error} rounded-md px-4 py-3`}>
|
|
|
@@ -192,95 +389,112 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
- <div className="space-y-5">
|
|
|
- {/* Title */}
|
|
|
- <div>
|
|
|
- <label className="block text-sm text-muted-foreground mb-2">{t("create.bookTitle")}</label>
|
|
|
- <input
|
|
|
- type="text"
|
|
|
- value={title}
|
|
|
- onChange={(e) => setTitle(e.target.value)}
|
|
|
- className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none text-base`}
|
|
|
- placeholder={t("create.placeholder")}
|
|
|
- />
|
|
|
+ {status && (
|
|
|
+ <div className="border border-primary/20 bg-primary/5 rounded-md px-4 py-3 text-sm text-primary">
|
|
|
+ {status}
|
|
|
</div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
|
|
+ <section className="space-y-4">
|
|
|
+ <div className="rounded-2xl border border-border/60 bg-card/70 p-5 space-y-4">
|
|
|
+ <div className="space-y-1">
|
|
|
+ <div className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold">
|
|
|
+ {copy.draftHeading}
|
|
|
+ </div>
|
|
|
+ <div className="text-xs text-muted-foreground">
|
|
|
+ {copy.syncedHint}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {loadingDraft ? (
|
|
|
+ <div className="text-sm text-muted-foreground">{projectLang === "zh" ? "读取共享草案中…" : "Loading shared draft…"}</div>
|
|
|
+ ) : draft ? (
|
|
|
+ <div className="space-y-4">
|
|
|
+ {summaryRows.length > 0 ? (
|
|
|
+ <div className="space-y-3">
|
|
|
+ {summaryRows.map((row) => (
|
|
|
+ <div key={row.key} className="rounded-xl border border-border/50 bg-background/70 px-4 py-3">
|
|
|
+ <div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground font-semibold">{row.label}</div>
|
|
|
+ <div className="mt-1 text-sm leading-7 whitespace-pre-wrap">{row.value}</div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
|
|
|
- {/* Genre — filtered by language */}
|
|
|
- <div>
|
|
|
- <label className="block text-sm text-muted-foreground mb-2">{t("create.genre")}</label>
|
|
|
- <div className="grid grid-cols-3 gap-2">
|
|
|
- {genres.map((g) => (
|
|
|
+ {draft.missingFields.length > 0 ? (
|
|
|
+ <div className="space-y-2">
|
|
|
+ <div className="text-xs font-medium text-foreground">{copy.missingHeading}</div>
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
+ {draft.missingFields.map((field) => (
|
|
|
+ <span
|
|
|
+ key={field}
|
|
|
+ className="rounded-full border border-border/70 bg-secondary/50 px-3 py-1 text-xs text-muted-foreground"
|
|
|
+ >
|
|
|
+ {field}
|
|
|
+ </span>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ <p className="text-xs text-muted-foreground">{copy.missingHint}</p>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="rounded-xl border border-dashed border-border/70 bg-background/50 px-5 py-6">
|
|
|
+ <div className="font-medium">{copy.idleTitle}</div>
|
|
|
+ <p className="mt-2 text-sm text-muted-foreground leading-7">
|
|
|
+ {copy.helperBody}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section className="space-y-4">
|
|
|
+ <div className="rounded-2xl border border-border/60 bg-card/70 p-5 space-y-4">
|
|
|
+ <div className="space-y-1">
|
|
|
+ <div className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold">
|
|
|
+ {copy.promptLabel}
|
|
|
+ </div>
|
|
|
+ <div className="text-xs text-muted-foreground">
|
|
|
+ {copy.helperTitle}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <textarea
|
|
|
+ value={input}
|
|
|
+ onChange={(event) => setInput(event.target.value)}
|
|
|
+ rows={10}
|
|
|
+ className={`w-full ${c.input} rounded-xl px-4 py-3 focus:outline-none text-sm leading-7 resize-y`}
|
|
|
+ placeholder={draft ? copy.promptPlaceholderFollowup : copy.promptPlaceholder}
|
|
|
+ />
|
|
|
+
|
|
|
+ <div className="flex flex-wrap gap-3">
|
|
|
<button
|
|
|
- key={g.id}
|
|
|
- onClick={() => setGenre(g.id)}
|
|
|
- className={`px-3 py-2.5 rounded-md text-sm text-left transition-all ${
|
|
|
- genre === g.id
|
|
|
- ? "bg-primary/15 text-primary border border-primary/30 font-medium"
|
|
|
- : "bg-secondary text-secondary-foreground border border-transparent hover:border-border"
|
|
|
- }`}
|
|
|
+ onClick={handleDraftSubmit}
|
|
|
+ disabled={submitting || creating || !input.trim()}
|
|
|
+ className={`px-4 py-3 ${c.btnPrimary} rounded-md disabled:opacity-50 font-medium text-sm`}
|
|
|
>
|
|
|
- {g.name}
|
|
|
- {g.source === "project" && <span className="text-xs text-muted-foreground ml-1">✦</span>}
|
|
|
+ {submitting ? copy.submitting : copy.submit}
|
|
|
</button>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Platform — filtered by language */}
|
|
|
- <div>
|
|
|
- <label className="block text-sm text-muted-foreground mb-2">
|
|
|
- {t("create.platform")}
|
|
|
- </label>
|
|
|
- <div className="flex gap-2">
|
|
|
- {platforms.map((p) => (
|
|
|
<button
|
|
|
- key={p.value}
|
|
|
- onClick={() => setPlatform(p.value)}
|
|
|
- className={`px-3 py-2 rounded-md text-sm transition-all ${
|
|
|
- platform === p.value
|
|
|
- ? "bg-primary/15 text-primary border border-primary/30"
|
|
|
- : "bg-secondary text-secondary-foreground border border-transparent hover:border-border"
|
|
|
- }`}
|
|
|
+ onClick={handleCreate}
|
|
|
+ disabled={!canCreateFromDraft(draft) || creating || submitting}
|
|
|
+ className={`px-4 py-3 rounded-md border border-border bg-secondary text-secondary-foreground disabled:opacity-50 font-medium text-sm`}
|
|
|
>
|
|
|
- {p.label}
|
|
|
+ {creating ? copy.creating : copy.create}
|
|
|
</button>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Word count + chapters */}
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
- <div>
|
|
|
- <label className="block text-sm text-muted-foreground mb-2">{t("create.wordsPerChapter")}</label>
|
|
|
- <input
|
|
|
- type="number"
|
|
|
- value={chapterWords}
|
|
|
- onChange={(e) => {
|
|
|
- setChapterWordsTouched(true);
|
|
|
- setChapterWords(e.target.value);
|
|
|
- }}
|
|
|
- className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none`}
|
|
|
- />
|
|
|
- </div>
|
|
|
- <div>
|
|
|
- <label className="block text-sm text-muted-foreground mb-2">{t("create.targetChapters")}</label>
|
|
|
- <input
|
|
|
- type="number"
|
|
|
- value={targetChapters}
|
|
|
- onChange={(e) => setTargetChapters(e.target.value)}
|
|
|
- className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none`}
|
|
|
- />
|
|
|
+ <button
|
|
|
+ onClick={handleDiscard}
|
|
|
+ disabled={!draft || submitting || creating}
|
|
|
+ className="px-4 py-3 rounded-md border border-border text-muted-foreground hover:text-foreground hover:border-foreground/20 disabled:opacity-50 font-medium text-sm"
|
|
|
+ >
|
|
|
+ {copy.discard}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ </section>
|
|
|
</div>
|
|
|
-
|
|
|
- <button
|
|
|
- onClick={handleCreate}
|
|
|
- disabled={creating || !title.trim()}
|
|
|
- className={`w-full px-4 py-3 ${c.btnPrimary} rounded-md disabled:opacity-50 font-medium text-base`}
|
|
|
- >
|
|
|
- {creating ? t("create.creating") : t("create.submit")}
|
|
|
- </button>
|
|
|
</div>
|
|
|
);
|
|
|
}
|