BookCreate.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. import { useState } from "react";
  2. import { 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. }
  15. export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
  16. const c = useColors(theme);
  17. const { data: genreData } = useApi<{ genres: ReadonlyArray<GenreInfo> }>("/genres");
  18. const [title, setTitle] = useState("");
  19. const [genre, setGenre] = useState("");
  20. const [language, setLanguage] = useState<"zh" | "en">("zh");
  21. const [chapterWords, setChapterWords] = useState("3000");
  22. const [targetChapters, setTargetChapters] = useState("200");
  23. const [creating, setCreating] = useState(false);
  24. const [error, setError] = useState<string | null>(null);
  25. const genres = genreData?.genres ?? [];
  26. // Auto-select first genre when data loads
  27. if (genres.length > 0 && !genre) {
  28. setGenre(genres[0]!.id);
  29. }
  30. const handleCreate = async () => {
  31. if (!title.trim()) {
  32. setError(t("create.titleRequired"));
  33. return;
  34. }
  35. if (!genre) {
  36. setError(t("create.genreRequired"));
  37. return;
  38. }
  39. setCreating(true);
  40. setError(null);
  41. try {
  42. const result = await postApi<{ bookId: string }>("/books/create", {
  43. title: title.trim(),
  44. genre,
  45. language,
  46. chapterWordCount: parseInt(chapterWords, 10),
  47. targetChapters: parseInt(targetChapters, 10),
  48. });
  49. nav.toBook(result.bookId);
  50. } catch (e) {
  51. setError(e instanceof Error ? e.message : "Failed to create book");
  52. } finally {
  53. setCreating(false);
  54. }
  55. };
  56. return (
  57. <div className="max-w-lg mx-auto space-y-6">
  58. <div className={`flex items-center gap-2 text-sm ${c.muted}`}>
  59. <button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
  60. <span>/</span>
  61. <span className={c.subtle}>{t("bread.newBook")}</span>
  62. </div>
  63. <h1 className="text-2xl font-semibold">{t("create.title")}</h1>
  64. {error && (
  65. <div className={`border ${c.error} rounded-lg px-4 py-3 text-sm`}>
  66. {error}
  67. </div>
  68. )}
  69. <div className="space-y-4">
  70. <div>
  71. <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.bookTitle")}</label>
  72. <input
  73. type="text"
  74. value={title}
  75. onChange={(e) => setTitle(e.target.value)}
  76. className={`w-full ${c.input} rounded-md px-3 py-2 focus:outline-none`}
  77. placeholder={t("create.placeholder")}
  78. />
  79. </div>
  80. <div>
  81. <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.language")}</label>
  82. <div className="flex gap-2">
  83. <button
  84. onClick={() => setLanguage("zh")}
  85. className={`px-4 py-2 rounded-md text-sm ${language === "zh" ? c.btnPrimary : c.btnSecondary}`}
  86. >
  87. Chinese
  88. </button>
  89. <button
  90. onClick={() => setLanguage("en")}
  91. className={`px-4 py-2 rounded-md text-sm ${language === "en" ? c.btnPrimary : c.btnSecondary}`}
  92. >
  93. English
  94. </button>
  95. </div>
  96. </div>
  97. <div>
  98. <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.genre")}</label>
  99. <select
  100. value={genre}
  101. onChange={(e) => setGenre(e.target.value)}
  102. className={`w-full ${c.input} rounded-md px-3 py-2.5 focus:outline-none appearance-none cursor-pointer`}
  103. >
  104. {genres.map((g) => (
  105. <option key={g.id} value={g.id}>
  106. {g.name} ({g.id}){g.source === "project" ? " [custom]" : ""}
  107. </option>
  108. ))}
  109. </select>
  110. </div>
  111. <div className="grid grid-cols-2 gap-4">
  112. <div>
  113. <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.wordsPerChapter")}</label>
  114. <input
  115. type="number"
  116. value={chapterWords}
  117. onChange={(e) => setChapterWords(e.target.value)}
  118. className={`w-full ${c.input} rounded-md px-3 py-2 focus:outline-none`}
  119. />
  120. </div>
  121. <div>
  122. <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.targetChapters")}</label>
  123. <input
  124. type="number"
  125. value={targetChapters}
  126. onChange={(e) => setTargetChapters(e.target.value)}
  127. className={`w-full ${c.input} rounded-md px-3 py-2 focus:outline-none`}
  128. />
  129. </div>
  130. </div>
  131. </div>
  132. <button
  133. onClick={handleCreate}
  134. disabled={creating || !title.trim()}
  135. className={`w-full px-4 py-2.5 ${c.btnPrimary} rounded-md transition-colors disabled:opacity-50 font-medium`}
  136. >
  137. {creating ? t("create.creating") : t("create.submit")}
  138. </button>
  139. </div>
  140. );
  141. }