| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- import { 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";
- import {
- ChevronLeft,
- Check,
- X,
- List,
- RotateCcw,
- BookOpen,
- CheckCircle2,
- XCircle,
- Hash,
- Type,
- Clock,
- Pencil,
- Save,
- Eye,
- } from "lucide-react";
- interface ChapterData {
- readonly chapterNumber: number;
- readonly filename: string;
- readonly content: string;
- }
- interface Nav {
- toBook: (id: string) => void;
- toDashboard: () => void;
- }
- export function ChapterReader({ bookId, chapterNumber, nav, theme, t }: {
- bookId: string;
- chapterNumber: number;
- nav: Nav;
- theme: Theme;
- t: TFunction;
- }) {
- const c = useColors(theme);
- const { data, loading, error, refetch } = useApi<ChapterData>(
- `/books/${bookId}/chapters/${chapterNumber}`,
- );
- const [editing, setEditing] = useState(false);
- const [editContent, setEditContent] = useState("");
- const [saving, setSaving] = useState(false);
- const handleStartEdit = () => {
- if (!data) return;
- setEditContent(data.content);
- setEditing(true);
- };
- const handleCancelEdit = () => {
- setEditing(false);
- setEditContent("");
- };
- const handleSave = async () => {
- setSaving(true);
- try {
- await fetchJson(`/books/${bookId}/chapters/${chapterNumber}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ content: editContent }),
- });
- setEditing(false);
- refetch();
- } catch (e) {
- alert(e instanceof Error ? e.message : "Save failed");
- } finally {
- setSaving(false);
- }
- };
- if (loading) return (
- <div className="flex flex-col items-center justify-center py-32 space-y-4">
- <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
- <span className="text-sm text-muted-foreground">{t("reader.openingManuscript")}</span>
- </div>
- );
- if (error) return <div className="text-destructive p-8 bg-destructive/5 rounded-xl border border-destructive/20">Error: {error}</div>;
- if (!data) return null;
- // Split markdown content into title and body
- const lines = data.content.split("\n");
- const titleLine = lines.find((l) => l.startsWith("# "));
- const title = titleLine?.replace(/^#\s*/, "") ?? `Chapter ${chapterNumber}`;
- const body = lines
- .filter((l) => l !== titleLine)
- .join("\n")
- .trim();
- const handleApprove = async () => {
- try {
- await postApi(`/books/${bookId}/chapters/${chapterNumber}/approve`);
- nav.toBook(bookId);
- } catch (e) {
- alert(e instanceof Error ? e.message : "Approve failed");
- }
- };
- const handleReject = async () => {
- try {
- await postApi(`/books/${bookId}/chapters/${chapterNumber}/reject`);
- nav.toBook(bookId);
- } catch (e) {
- alert(e instanceof Error ? e.message : "Reject failed");
- }
- };
- const paragraphs = body.split(/\n\n+/).filter(Boolean);
- return (
- <div className="max-w-4xl mx-auto space-y-10 fade-in">
- {/* Navigation & Actions */}
- <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
- <nav className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
- <button
- onClick={nav.toDashboard}
- className="hover:text-primary transition-colors flex items-center gap-1"
- >
- {t("bread.books")}
- </button>
- <span className="text-border">/</span>
- <button
- onClick={() => nav.toBook(bookId)}
- className="hover:text-primary transition-colors truncate max-w-[120px]"
- >
- {bookId}
- </button>
- <span className="text-border">/</span>
- <span className="text-foreground flex items-center gap-1">
- <Hash size={12} />
- {chapterNumber}
- </span>
- </nav>
- <div className="flex gap-2">
- <button
- onClick={() => nav.toBook(bookId)}
- 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"
- >
- <List size={14} />
- {t("reader.backToList")}
- </button>
- {/* Edit / Preview toggle */}
- {editing ? (
- <>
- <button
- onClick={handleSave}
- disabled={saving}
- 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"
- >
- {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} />}
- {saving ? t("book.saving") : t("book.save")}
- </button>
- <button
- onClick={handleCancelEdit}
- 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"
- >
- <Eye size={14} />
- {t("reader.preview")}
- </button>
- </>
- ) : (
- <button
- onClick={handleStartEdit}
- 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"
- >
- <Pencil size={14} />
- {t("reader.edit")}
- </button>
- )}
- <button
- onClick={handleApprove}
- 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"
- >
- <CheckCircle2 size={14} />
- {t("reader.approve")}
- </button>
- <button
- onClick={handleReject}
- 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"
- >
- <XCircle size={14} />
- {t("reader.reject")}
- </button>
- </div>
- </div>
- {/* Manuscript Sheet */}
- <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">
- {/* Physical Paper Details */}
- <div className="absolute top-0 left-8 w-px h-full bg-primary/5 hidden md:block" />
- <div className="absolute top-0 right-8 w-px h-full bg-primary/5 hidden md:block" />
- <header className="mb-16 text-center">
- <div className="flex items-center justify-center gap-2 text-muted-foreground/30 mb-8 select-none">
- <div className="h-px w-12 bg-border/40" />
- <BookOpen size={20} />
- <div className="h-px w-12 bg-border/40" />
- </div>
- <h1 className="text-4xl md:text-5xl font-serif font-medium italic text-foreground tracking-tight leading-tight">
- {title}
- </h1>
- <div className="mt-8 flex items-center justify-center gap-4 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground/60">
- <span>{t("reader.manuscriptPage")}</span>
- <span className="text-border">·</span>
- <span>{chapterNumber.toString().padStart(2, '0')}</span>
- </div>
- </header>
- {editing ? (
- <textarea
- value={editContent}
- onChange={(e) => setEditContent(e.target.value)}
- 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"
- autoFocus
- />
- ) : (
- <article className="prose prose-zinc dark:prose-invert max-w-none">
- {paragraphs.map((para, i) => (
- <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">
- {para}
- </p>
- ))}
- </article>
- )}
- <footer className="mt-24 pt-12 border-t border-border/20 flex flex-col items-center gap-6 text-center">
- <div className="flex items-center gap-4 text-xs font-medium text-muted-foreground">
- <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-secondary/50">
- <Type size={14} className="text-primary/60" />
- <span>{body.length.toLocaleString()} {t("reader.characters")}</span>
- </div>
- <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-secondary/50">
- <Clock size={14} className="text-primary/60" />
- <span>{Math.ceil(body.length / 500)} {t("reader.minRead")}</span>
- </div>
- </div>
- <p className="text-[10px] uppercase tracking-widest text-muted-foreground/40 font-bold">{t("reader.endOfChapter")}</p>
- </footer>
- </div>
- {/* Footer Navigation */}
- <div className="flex justify-between items-center py-8">
- {chapterNumber > 1 ? (
- <button
- onClick={() => nav.toBook(bookId)}
- className="flex items-center gap-2 text-sm font-bold text-muted-foreground hover:text-primary transition-all group"
- >
- <RotateCcw size={16} className="group-hover:-rotate-45 transition-transform" />
- {t("reader.chapterList")}
- </button>
- ) : (
- <div />
- )}
- </div>
- </div>
- );
- }
|