BookCreate.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. import { useEffect, useState } from "react";
  2. import { fetchJson, useApi, postApi } from "../hooks/use-api";
  3. import type { Theme } from "../hooks/use-theme";
  4. import type { TFunction } from "../hooks/use-i18n";
  5. import { useColors } from "../hooks/use-colors";
  6. interface Nav {
  7. toDashboard: () => void;
  8. toBook: (id: string) => void;
  9. }
  10. interface GenreInfo {
  11. readonly id: string;
  12. readonly name: string;
  13. readonly source: "project" | "builtin";
  14. readonly language: "zh" | "en";
  15. }
  16. interface PlatformOption {
  17. readonly value: string;
  18. readonly label: string;
  19. }
  20. const PLATFORMS_ZH: ReadonlyArray<PlatformOption> = [
  21. { value: "tomato", label: "番茄小说" },
  22. { value: "qidian", label: "起点中文网" },
  23. { value: "feilu", label: "飞卢" },
  24. { value: "other", label: "其他" },
  25. ];
  26. const PLATFORMS_EN: ReadonlyArray<PlatformOption> = [
  27. { value: "royal-road", label: "Royal Road" },
  28. { value: "kindle-unlimited", label: "Kindle Unlimited" },
  29. { value: "scribble-hub", label: "Scribble Hub" },
  30. { value: "other", label: "Other" },
  31. ];
  32. export function pickValidValue(current: string, available: ReadonlyArray<string>): string {
  33. if (current && available.includes(current)) {
  34. return current;
  35. }
  36. return available[0] ?? "";
  37. }
  38. export function defaultChapterWordsForLanguage(language: "zh" | "en"): string {
  39. return language === "en" ? "2000" : "3000";
  40. }
  41. export function platformOptionsForLanguage(language: "zh" | "en"): ReadonlyArray<PlatformOption> {
  42. return language === "en" ? PLATFORMS_EN : PLATFORMS_ZH;
  43. }
  44. interface WaitForBookReadyOptions {
  45. readonly fetchBook?: (bookId: string) => Promise<unknown>;
  46. readonly fetchStatus?: (bookId: string) => Promise<{ status: string; error?: string }>;
  47. readonly maxAttempts?: number;
  48. readonly delayMs?: number;
  49. readonly waitImpl?: (ms: number) => Promise<void>;
  50. }
  51. const DEFAULT_BOOK_READY_MAX_ATTEMPTS = 120;
  52. const DEFAULT_BOOK_READY_DELAY_MS = 250;
  53. export async function waitForBookReady(
  54. bookId: string,
  55. options: WaitForBookReadyOptions = {},
  56. ): Promise<void> {
  57. const fetchBook = options.fetchBook ?? ((id: string) => fetchJson(`/books/${id}`));
  58. const fetchStatus = options.fetchStatus ?? ((id: string) => fetchJson<{ status: string; error?: string }>(`/books/${id}/create-status`));
  59. const maxAttempts = options.maxAttempts ?? DEFAULT_BOOK_READY_MAX_ATTEMPTS;
  60. const delayMs = options.delayMs ?? DEFAULT_BOOK_READY_DELAY_MS;
  61. const waitImpl = options.waitImpl ?? ((ms: number) => new Promise<void>((resolve) => {
  62. setTimeout(resolve, ms);
  63. }));
  64. let lastError: unknown;
  65. let lastKnownStatus: string | undefined;
  66. for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
  67. try {
  68. await fetchBook(bookId);
  69. return;
  70. } catch (error) {
  71. lastError = error;
  72. try {
  73. const status = await fetchStatus(bookId);
  74. lastKnownStatus = status.status;
  75. if (status.status === "error") {
  76. throw new Error(status.error ?? `Book "${bookId}" failed to create`);
  77. }
  78. } catch (statusError) {
  79. if (statusError instanceof Error && statusError.message !== "404 Not Found") {
  80. throw statusError;
  81. }
  82. }
  83. if (attempt === maxAttempts - 1) {
  84. if (lastKnownStatus === "creating") {
  85. break;
  86. }
  87. throw error;
  88. }
  89. await waitImpl(delayMs);
  90. }
  91. }
  92. if (lastKnownStatus === "creating") {
  93. throw new Error(`Book "${bookId}" is still being created. Wait a moment and refresh.`);
  94. }
  95. throw lastError instanceof Error ? lastError : new Error(`Book "${bookId}" was not ready`);
  96. }
  97. export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
  98. const c = useColors(theme);
  99. const { data: genreData } = useApi<{ genres: ReadonlyArray<GenreInfo> }>("/genres");
  100. const { data: project } = useApi<{ language: string }>("/project");
  101. const projectLang = (project?.language ?? "zh") as "zh" | "en";
  102. const [title, setTitle] = useState("");
  103. const [genre, setGenre] = useState("");
  104. const [platform, setPlatform] = useState("");
  105. const [chapterWords, setChapterWords] = useState(defaultChapterWordsForLanguage(projectLang));
  106. const [chapterWordsTouched, setChapterWordsTouched] = useState(false);
  107. const [targetChapters, setTargetChapters] = useState("200");
  108. const [creating, setCreating] = useState(false);
  109. const [error, setError] = useState<string | null>(null);
  110. // Filter genres by project language + custom genres (always show)
  111. const allGenres = genreData?.genres ?? [];
  112. const genres = allGenres.filter((g) => g.language === projectLang || g.source === "project");
  113. const platforms = platformOptionsForLanguage(projectLang);
  114. const genreSignature = genres.map((g) => g.id).join("|");
  115. const platformSignature = platforms.map((p) => `${p.value}:${p.label}`).join("|");
  116. useEffect(() => {
  117. setGenre((current) => pickValidValue(current, genres.map((g) => g.id)));
  118. }, [genreSignature]);
  119. useEffect(() => {
  120. setPlatform((current) => pickValidValue(current, platforms.map((p) => p.value)));
  121. }, [platformSignature]);
  122. useEffect(() => {
  123. if (!chapterWordsTouched) {
  124. setChapterWords(defaultChapterWordsForLanguage(projectLang));
  125. }
  126. }, [projectLang, chapterWordsTouched]);
  127. const handleCreate = async () => {
  128. if (!title.trim()) {
  129. setError(t("create.titleRequired"));
  130. return;
  131. }
  132. if (!genre) {
  133. setError(t("create.genreRequired"));
  134. return;
  135. }
  136. setCreating(true);
  137. setError(null);
  138. try {
  139. const result = await postApi<{ bookId: string }>("/books/create", {
  140. title: title.trim(),
  141. genre,
  142. language: projectLang,
  143. platform,
  144. chapterWordCount: parseInt(chapterWords, 10),
  145. targetChapters: parseInt(targetChapters, 10),
  146. });
  147. await waitForBookReady(result.bookId);
  148. nav.toBook(result.bookId);
  149. } catch (e) {
  150. setError(e instanceof Error ? e.message : "Failed to create book");
  151. } finally {
  152. setCreating(false);
  153. }
  154. };
  155. return (
  156. <div className="max-w-lg mx-auto space-y-8">
  157. <div className="flex items-center gap-2 text-sm text-muted-foreground">
  158. <button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
  159. <span className="text-border">/</span>
  160. <span>{t("bread.newBook")}</span>
  161. </div>
  162. <h1 className="font-serif text-3xl">{t("create.title")}</h1>
  163. {error && (
  164. <div className={`border ${c.error} rounded-md px-4 py-3`}>
  165. {error}
  166. </div>
  167. )}
  168. <div className="space-y-5">
  169. {/* Title */}
  170. <div>
  171. <label className="block text-sm text-muted-foreground mb-2">{t("create.bookTitle")}</label>
  172. <input
  173. type="text"
  174. value={title}
  175. onChange={(e) => setTitle(e.target.value)}
  176. className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none text-base`}
  177. placeholder={t("create.placeholder")}
  178. />
  179. </div>
  180. {/* Genre — filtered by language */}
  181. <div>
  182. <label className="block text-sm text-muted-foreground mb-2">{t("create.genre")}</label>
  183. <div className="grid grid-cols-3 gap-2">
  184. {genres.map((g) => (
  185. <button
  186. key={g.id}
  187. onClick={() => setGenre(g.id)}
  188. className={`px-3 py-2.5 rounded-md text-sm text-left transition-all ${
  189. genre === g.id
  190. ? "bg-primary/15 text-primary border border-primary/30 font-medium"
  191. : "bg-secondary text-secondary-foreground border border-transparent hover:border-border"
  192. }`}
  193. >
  194. {g.name}
  195. {g.source === "project" && <span className="text-xs text-muted-foreground ml-1">✦</span>}
  196. </button>
  197. ))}
  198. </div>
  199. </div>
  200. {/* Platform — filtered by language */}
  201. <div>
  202. <label className="block text-sm text-muted-foreground mb-2">
  203. {t("create.platform")}
  204. </label>
  205. <div className="flex gap-2">
  206. {platforms.map((p) => (
  207. <button
  208. key={p.value}
  209. onClick={() => setPlatform(p.value)}
  210. className={`px-3 py-2 rounded-md text-sm transition-all ${
  211. platform === p.value
  212. ? "bg-primary/15 text-primary border border-primary/30"
  213. : "bg-secondary text-secondary-foreground border border-transparent hover:border-border"
  214. }`}
  215. >
  216. {p.label}
  217. </button>
  218. ))}
  219. </div>
  220. </div>
  221. {/* Word count + chapters */}
  222. <div className="grid grid-cols-2 gap-4">
  223. <div>
  224. <label className="block text-sm text-muted-foreground mb-2">{t("create.wordsPerChapter")}</label>
  225. <input
  226. type="number"
  227. value={chapterWords}
  228. onChange={(e) => {
  229. setChapterWordsTouched(true);
  230. setChapterWords(e.target.value);
  231. }}
  232. className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none`}
  233. />
  234. </div>
  235. <div>
  236. <label className="block text-sm text-muted-foreground mb-2">{t("create.targetChapters")}</label>
  237. <input
  238. type="number"
  239. value={targetChapters}
  240. onChange={(e) => setTargetChapters(e.target.value)}
  241. className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none`}
  242. />
  243. </div>
  244. </div>
  245. </div>
  246. <button
  247. onClick={handleCreate}
  248. disabled={creating || !title.trim()}
  249. className={`w-full px-4 py-3 ${c.btnPrimary} rounded-md disabled:opacity-50 font-medium text-base`}
  250. >
  251. {creating ? t("create.creating") : t("create.submit")}
  252. </button>
  253. </div>
  254. );
  255. }