| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286 |
- import { useEffect, useState } from "react";
- import { fetchJson, useApi, postApi } from "../hooks/use-api";
- import type { Theme } from "../hooks/use-theme";
- import type { TFunction } from "../hooks/use-i18n";
- import { useColors } from "../hooks/use-colors";
- interface Nav {
- toDashboard: () => void;
- toBook: (id: string) => void;
- }
- interface GenreInfo {
- readonly id: string;
- readonly name: string;
- readonly source: "project" | "builtin";
- readonly language: "zh" | "en";
- }
- interface PlatformOption {
- readonly value: string;
- readonly label: string;
- }
- const PLATFORMS_ZH: ReadonlyArray<PlatformOption> = [
- { value: "tomato", label: "番茄小说" },
- { value: "qidian", label: "起点中文网" },
- { value: "feilu", label: "飞卢" },
- { value: "other", label: "其他" },
- ];
- const PLATFORMS_EN: ReadonlyArray<PlatformOption> = [
- { value: "royal-road", label: "Royal Road" },
- { value: "kindle-unlimited", label: "Kindle Unlimited" },
- { value: "scribble-hub", label: "Scribble Hub" },
- { value: "other", label: "Other" },
- ];
- export function pickValidValue(current: string, available: ReadonlyArray<string>): string {
- if (current && available.includes(current)) {
- return current;
- }
- return available[0] ?? "";
- }
- export function defaultChapterWordsForLanguage(language: "zh" | "en"): string {
- return language === "en" ? "2000" : "3000";
- }
- export function platformOptionsForLanguage(language: "zh" | "en"): ReadonlyArray<PlatformOption> {
- return language === "en" ? PLATFORMS_EN : PLATFORMS_ZH;
- }
- interface WaitForBookReadyOptions {
- readonly fetchBook?: (bookId: string) => Promise<unknown>;
- readonly fetchStatus?: (bookId: string) => Promise<{ status: string; error?: string }>;
- readonly maxAttempts?: number;
- readonly delayMs?: number;
- readonly waitImpl?: (ms: number) => Promise<void>;
- }
- const DEFAULT_BOOK_READY_MAX_ATTEMPTS = 120;
- const DEFAULT_BOOK_READY_DELAY_MS = 250;
- export async function waitForBookReady(
- bookId: string,
- options: WaitForBookReadyOptions = {},
- ): Promise<void> {
- const fetchBook = options.fetchBook ?? ((id: string) => fetchJson(`/books/${id}`));
- const fetchStatus = options.fetchStatus ?? ((id: string) => fetchJson<{ status: string; error?: string }>(`/books/${id}/create-status`));
- const maxAttempts = options.maxAttempts ?? DEFAULT_BOOK_READY_MAX_ATTEMPTS;
- const delayMs = options.delayMs ?? DEFAULT_BOOK_READY_DELAY_MS;
- const waitImpl = options.waitImpl ?? ((ms: number) => new Promise<void>((resolve) => {
- setTimeout(resolve, ms);
- }));
- let lastError: unknown;
- let lastKnownStatus: string | undefined;
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
- try {
- await fetchBook(bookId);
- return;
- } catch (error) {
- lastError = error;
- try {
- const status = await fetchStatus(bookId);
- lastKnownStatus = status.status;
- if (status.status === "error") {
- throw new Error(status.error ?? `Book "${bookId}" failed to create`);
- }
- } catch (statusError) {
- if (statusError instanceof Error && statusError.message !== "404 Not Found") {
- throw statusError;
- }
- }
- if (attempt === maxAttempts - 1) {
- if (lastKnownStatus === "creating") {
- break;
- }
- throw error;
- }
- await waitImpl(delayMs);
- }
- }
- if (lastKnownStatus === "creating") {
- throw new Error(`Book "${bookId}" is still being created. Wait a moment and refresh.`);
- }
- throw lastError instanceof Error ? lastError : new Error(`Book "${bookId}" was not ready`);
- }
- 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 [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 [creating, setCreating] = useState(false);
- const [error, setError] = 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("|");
- useEffect(() => {
- setGenre((current) => pickValidValue(current, genres.map((g) => g.id)));
- }, [genreSignature]);
- useEffect(() => {
- setPlatform((current) => pickValidValue(current, platforms.map((p) => p.value)));
- }, [platformSignature]);
- useEffect(() => {
- if (!chapterWordsTouched) {
- setChapterWords(defaultChapterWordsForLanguage(projectLang));
- }
- }, [projectLang, chapterWordsTouched]);
- const handleCreate = async () => {
- if (!title.trim()) {
- setError(t("create.titleRequired"));
- return;
- }
- if (!genre) {
- setError(t("create.genreRequired"));
- 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");
- } finally {
- setCreating(false);
- }
- };
- return (
- <div className="max-w-lg 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>
- {error && (
- <div className={`border ${c.error} rounded-md px-4 py-3`}>
- {error}
- </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")}
- />
- </div>
- {/* 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) => (
- <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"
- }`}
- >
- {g.name}
- {g.source === "project" && <span className="text-xs text-muted-foreground ml-1">✦</span>}
- </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"
- }`}
- >
- {p.label}
- </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`}
- />
- </div>
- </div>
- </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>
- );
- }
|