ChapterReader.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import { 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. import {
  7. ChevronLeft,
  8. Check,
  9. X,
  10. List,
  11. RotateCcw,
  12. BookOpen,
  13. CheckCircle2,
  14. XCircle,
  15. Hash,
  16. Type,
  17. Clock,
  18. Pencil,
  19. Save,
  20. Eye,
  21. } from "lucide-react";
  22. interface ChapterData {
  23. readonly chapterNumber: number;
  24. readonly filename: string;
  25. readonly content: string;
  26. }
  27. interface Nav {
  28. toBook: (id: string) => void;
  29. toDashboard: () => void;
  30. }
  31. export function ChapterReader({ bookId, chapterNumber, nav, theme, t }: {
  32. bookId: string;
  33. chapterNumber: number;
  34. nav: Nav;
  35. theme: Theme;
  36. t: TFunction;
  37. }) {
  38. const c = useColors(theme);
  39. const { data, loading, error, refetch } = useApi<ChapterData>(
  40. `/books/${bookId}/chapters/${chapterNumber}`,
  41. );
  42. const [editing, setEditing] = useState(false);
  43. const [editContent, setEditContent] = useState("");
  44. const [saving, setSaving] = useState(false);
  45. const handleStartEdit = () => {
  46. if (!data) return;
  47. setEditContent(data.content);
  48. setEditing(true);
  49. };
  50. const handleCancelEdit = () => {
  51. setEditing(false);
  52. setEditContent("");
  53. };
  54. const handleSave = async () => {
  55. setSaving(true);
  56. try {
  57. await fetchJson(`/books/${bookId}/chapters/${chapterNumber}`, {
  58. method: "PUT",
  59. headers: { "Content-Type": "application/json" },
  60. body: JSON.stringify({ content: editContent }),
  61. });
  62. setEditing(false);
  63. refetch();
  64. } catch (e) {
  65. alert(e instanceof Error ? e.message : "Save failed");
  66. } finally {
  67. setSaving(false);
  68. }
  69. };
  70. if (loading) return (
  71. <div className="flex flex-col items-center justify-center py-32 space-y-4">
  72. <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
  73. <span className="text-sm text-muted-foreground">{t("reader.openingManuscript")}</span>
  74. </div>
  75. );
  76. if (error) return <div className="text-destructive p-8 bg-destructive/5 rounded-xl border border-destructive/20">Error: {error}</div>;
  77. if (!data) return null;
  78. // Split markdown content into title and body
  79. const lines = data.content.split("\n");
  80. const titleLine = lines.find((l) => l.startsWith("# "));
  81. const title = titleLine?.replace(/^#\s*/, "") ?? `Chapter ${chapterNumber}`;
  82. const body = lines
  83. .filter((l) => l !== titleLine)
  84. .join("\n")
  85. .trim();
  86. const handleApprove = async () => {
  87. try {
  88. await postApi(`/books/${bookId}/chapters/${chapterNumber}/approve`);
  89. nav.toBook(bookId);
  90. } catch (e) {
  91. alert(e instanceof Error ? e.message : "Approve failed");
  92. }
  93. };
  94. const handleReject = async () => {
  95. try {
  96. await postApi(`/books/${bookId}/chapters/${chapterNumber}/reject`);
  97. nav.toBook(bookId);
  98. } catch (e) {
  99. alert(e instanceof Error ? e.message : "Reject failed");
  100. }
  101. };
  102. const paragraphs = body.split(/\n\n+/).filter(Boolean);
  103. return (
  104. <div className="max-w-4xl mx-auto space-y-10 fade-in">
  105. {/* Navigation & Actions */}
  106. <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
  107. <nav className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
  108. <button
  109. onClick={nav.toDashboard}
  110. className="hover:text-primary transition-colors flex items-center gap-1"
  111. >
  112. {t("bread.books")}
  113. </button>
  114. <span className="text-border">/</span>
  115. <button
  116. onClick={() => nav.toBook(bookId)}
  117. className="hover:text-primary transition-colors truncate max-w-[120px]"
  118. >
  119. {bookId}
  120. </button>
  121. <span className="text-border">/</span>
  122. <span className="text-foreground flex items-center gap-1">
  123. <Hash size={12} />
  124. {chapterNumber}
  125. </span>
  126. </nav>
  127. <div className="flex gap-2">
  128. <button
  129. onClick={() => nav.toBook(bookId)}
  130. className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary text-muted-foreground rounded-xl hover:text-foreground hover:bg-secondary/80 transition-all border border-border/50"
  131. >
  132. <List size={14} />
  133. {t("reader.backToList")}
  134. </button>
  135. {/* Edit / Preview toggle */}
  136. {editing ? (
  137. <>
  138. <button
  139. onClick={handleSave}
  140. disabled={saving}
  141. className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-primary text-primary-foreground rounded-xl hover:scale-105 active:scale-95 transition-all shadow-sm disabled:opacity-50"
  142. >
  143. {saving ? <div className="w-3.5 h-3.5 border-2 border-primary-foreground/20 border-t-primary-foreground rounded-full animate-spin" /> : <Save size={14} />}
  144. {saving ? t("book.saving") : t("book.save")}
  145. </button>
  146. <button
  147. onClick={handleCancelEdit}
  148. className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary text-muted-foreground rounded-xl hover:text-foreground transition-all border border-border/50"
  149. >
  150. <Eye size={14} />
  151. {t("reader.preview")}
  152. </button>
  153. </>
  154. ) : (
  155. <button
  156. onClick={handleStartEdit}
  157. className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary text-muted-foreground rounded-xl hover:text-primary hover:bg-primary/10 transition-all border border-border/50"
  158. >
  159. <Pencil size={14} />
  160. {t("reader.edit")}
  161. </button>
  162. )}
  163. <button
  164. onClick={handleApprove}
  165. className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-emerald-500/10 text-emerald-600 rounded-xl hover:bg-emerald-500 hover:text-white transition-all border border-emerald-500/20 shadow-sm"
  166. >
  167. <CheckCircle2 size={14} />
  168. {t("reader.approve")}
  169. </button>
  170. <button
  171. onClick={handleReject}
  172. className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-destructive/10 text-destructive rounded-xl hover:bg-destructive hover:text-white transition-all border border-destructive/20 shadow-sm"
  173. >
  174. <XCircle size={14} />
  175. {t("reader.reject")}
  176. </button>
  177. </div>
  178. </div>
  179. {/* Manuscript Sheet */}
  180. <div className="paper-sheet rounded-2xl p-8 md:p-16 lg:p-24 shadow-2xl shadow-primary/5 min-h-[80vh] relative overflow-hidden">
  181. {/* Physical Paper Details */}
  182. <div className="absolute top-0 left-8 w-px h-full bg-primary/5 hidden md:block" />
  183. <div className="absolute top-0 right-8 w-px h-full bg-primary/5 hidden md:block" />
  184. <header className="mb-16 text-center">
  185. <div className="flex items-center justify-center gap-2 text-muted-foreground/30 mb-8 select-none">
  186. <div className="h-px w-12 bg-border/40" />
  187. <BookOpen size={20} />
  188. <div className="h-px w-12 bg-border/40" />
  189. </div>
  190. <h1 className="text-4xl md:text-5xl font-serif font-medium italic text-foreground tracking-tight leading-tight">
  191. {title}
  192. </h1>
  193. <div className="mt-8 flex items-center justify-center gap-4 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground/60">
  194. <span>{t("reader.manuscriptPage")}</span>
  195. <span className="text-border">·</span>
  196. <span>{chapterNumber.toString().padStart(2, '0')}</span>
  197. </div>
  198. </header>
  199. {editing ? (
  200. <textarea
  201. value={editContent}
  202. onChange={(e) => setEditContent(e.target.value)}
  203. className="w-full min-h-[60vh] bg-transparent font-serif text-lg leading-[1.8] text-foreground/90 focus:outline-none resize-none border border-border/30 rounded-lg p-6 focus:border-primary/40 focus:ring-2 focus:ring-primary/10 transition-all"
  204. autoFocus
  205. />
  206. ) : (
  207. <article className="prose prose-zinc dark:prose-invert max-w-none">
  208. {paragraphs.map((para, i) => (
  209. <p key={i} className="font-serif text-lg md:text-xl leading-[1.8] text-foreground/90 mb-8 first-letter:text-2xl first-letter:font-bold first-letter:text-primary/40">
  210. {para}
  211. </p>
  212. ))}
  213. </article>
  214. )}
  215. <footer className="mt-24 pt-12 border-t border-border/20 flex flex-col items-center gap-6 text-center">
  216. <div className="flex items-center gap-4 text-xs font-medium text-muted-foreground">
  217. <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-secondary/50">
  218. <Type size={14} className="text-primary/60" />
  219. <span>{body.length.toLocaleString()} {t("reader.characters")}</span>
  220. </div>
  221. <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-secondary/50">
  222. <Clock size={14} className="text-primary/60" />
  223. <span>{Math.ceil(body.length / 500)} {t("reader.minRead")}</span>
  224. </div>
  225. </div>
  226. <p className="text-[10px] uppercase tracking-widest text-muted-foreground/40 font-bold">{t("reader.endOfChapter")}</p>
  227. </footer>
  228. </div>
  229. {/* Footer Navigation */}
  230. <div className="flex justify-between items-center py-8">
  231. {chapterNumber > 1 ? (
  232. <button
  233. onClick={() => nav.toBook(bookId)}
  234. className="flex items-center gap-2 text-sm font-bold text-muted-foreground hover:text-primary transition-all group"
  235. >
  236. <RotateCcw size={16} className="group-hover:-rotate-45 transition-transform" />
  237. {t("reader.chapterList")}
  238. </button>
  239. ) : (
  240. <div />
  241. )}
  242. </div>
  243. </div>
  244. );
  245. }