This commit is contained in:
pj committed 2026-06-20 19:48:18 -04:00
1 parent 734ded73ac
commit c86d5a7d5c
14 files changed
+655 -70

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useRef, useState } from "react";
import { PAGE_TYPES } from "../model/book";
import { Icon } from "./Icon";
interface AddPageMenuProps {
onAdd: (group: "front" | "back", title: string) => void;
}
const GROUPS: { key: "front" | "back"; label: string }[] = [
{ key: "front", label: "Front matter" },
{ key: "back", label: "Back matter" },
];
export function AddPageMenu({ onAdd }: AddPageMenuProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const btnRef = useRef<HTMLButtonElement>(null);
const toggle = () => {
if (!open && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setCoords({ top: r.bottom + 4, left: r.left });
}
setOpen((v) => !v);
};
useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
document.addEventListener("mousedown", close);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("mousedown", close);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [open]);
const pick = (group: "front" | "back", label: string) => {
setOpen(false);
onAdd(group, label);
};
return (
<>
<button ref={btnRef} className="add-page" data-open={open} onClick={toggle}>
<Icon d="M12 5v14M5 12h14" size={15} />
Add page
</button>
{open && (
<div
className="add-page-pop"
style={{ top: coords.top, left: coords.left }}
onMouseDown={(e) => e.stopPropagation()}
>
{GROUPS.map((g) => (
<div key={g.key} className="add-page-group">
<div className="add-page-label">{g.label}</div>
{PAGE_TYPES.filter((t) => t.group === g.key).map((t) => (
<button key={t.id} className="add-page-item" onClick={() => pick(t.group, t.label)}>
{t.label}
</button>
))}
</div>
))}
</div>
)}
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { Icon } from "./Icon";
interface ConfirmDialogProps {
title: string;
message: ReactNode;
confirmLabel?: string;
onConfirm: () => void;
onClose: () => void;
}
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
return (
<div className="overlay" onClick={onClose}>
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>{title}</h2>
<button className="icon-btn" onClick={onClose} title="Close">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
</div>
<div className="panel-body">
<p className="confirm-text">{message}</p>
</div>
<div className="panel-foot">
<button className="btn-ghost" onClick={onClose}>
Cancel
</button>
<button className="btn-danger" onClick={onConfirm}>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+6 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { generateHTML } from "@tiptap/core";
import { COVER_ID, useBook } from "../store/useBook";
import type { TrimSize } from "../model/book";
import { type TrimSize, bodyNumber, chapterKind } from "../model/book";
import { editorExtensions } from "../editor/extensions";
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
import { compilePdf, isDesktop } from "../ipc";
@@ -115,6 +115,10 @@ function HtmlDock() {
if (!chapter) return null;
const kind = chapterKind(chapter);
const eyebrow =
kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
return (
<section className="dock">
<div className="dock-head">
@@ -128,7 +132,7 @@ function HtmlDock() {
</div>
<div className="page">
<div className="p-opener">
<div className="p-num">Chapter {idx + 1}</div>
<div className="p-num">{eyebrow}</div>
<div className="p-title">{chapter.title || "Untitled"}</div>
</div>
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
+7 -4
View File
@@ -9,7 +9,7 @@ import { Editor } from "../editor/Editor";
import { FloatingToolbar } from "../editor/FloatingToolbar";
import { COVER_ID, useBook } from "../store/useBook";
import { useTheme } from "../store/useTheme";
import type { Book } from "../model/book";
import { type Book, bodyNumber, chapterKind } from "../model/book";
import { saveBook } from "../library";
import { isDesktop } from "../ipc";
import { exportEpub, exportPdf } from "../export/exporters";
@@ -80,13 +80,16 @@ export function EditorView() {
const coverActive = activeChapterId === COVER_ID;
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
const chapter = book.chapters[idx] ?? book.chapters[0];
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
const kind = chapter ? chapterKind(chapter) : "body";
const eyebrow =
kind === "body" ? `Chapter ${bodyNumber(book.chapters, realIdx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
return (
<div className="app">
<header className="titlebar" data-tauri-drag-region>
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
{book.metadata.title || "Untitled"}
{book.metadata.author ? ` — ${book.metadata.author}` : ""}
{dirty && <span className="dirty-dot" />}
</button>
<div className="actions">
@@ -129,11 +132,11 @@ export function EditorView() {
<>
<article className="sheet">
<header className="chapter-opener">
<div className="chapter-num">Chapter {idx + 1}</div>
<div className="chapter-num">{eyebrow}</div>
<input
className="chapter-title-input"
value={chapter.title}
placeholder="Chapter title"
placeholder={kind === "body" ? "Chapter title" : "Page title"}
spellCheck={false}
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
/>
+22 -7
View File
@@ -1,15 +1,18 @@
import { useEffect, useState, type MouseEvent } from "react";
import { useEffect, useState } from "react";
import { type BookSummary, deleteBook, exampleBook, listBooks, loadBook, newBook, saveBook } from "../library";
import type { Book } from "../model/book";
import { importEpub } from "../import/epub";
import { isDesktop } from "../ipc";
import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon";
import { RowMenu } from "./RowMenu";
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
const [books, setBooks] = useState<BookSummary[]>([]);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
const refresh = () =>
listBooks()
@@ -32,9 +35,10 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
onOpen(copy);
};
const removeBook = async (e: MouseEvent, id: string) => {
e.stopPropagation();
await deleteBook(id);
const removeBook = async () => {
if (!pendingDelete) return;
await deleteBook(pendingDelete.id);
setPendingDelete(null);
refresh();
};
@@ -78,9 +82,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
<div key={b.id} className="card card-book" onClick={() => loadBook(b.id).then(onOpen)}>
<span className="card-title">{b.title || "Untitled"}</span>
{b.author && <span className="card-author">{b.author}</span>}
<button className="card-delete" title="Delete book" onClick={(e) => removeBook(e, b.id)}>
<Icon d="M6 6l12 12M18 6L6 18" size={13} />
</button>
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
</div>
))}
</div>
@@ -89,6 +91,19 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
{notice}
</div>
)}
{pendingDelete && (
<ConfirmDialog
title="Delete book"
message={
<>
Delete <strong>{pendingDelete.title || "Untitled"}</strong>? This permanently removes the book and all its
chapters.
</>
}
onConfirm={removeBook}
onClose={() => setPendingDelete(null)}
/>
)}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "./Icon";
interface RowMenuProps {
label: string;
onDelete: () => void;
className?: string;
}
export function RowMenu({ label, onDelete, className = "" }: RowMenuProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({ top: 0, right: 0 });
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const toggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (!open && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setCoords({ top: r.bottom + 4, right: window.innerWidth - r.right });
}
setOpen((v) => !v);
};
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (btnRef.current?.contains(e.target as Node) || popRef.current?.contains(e.target as Node)) return;
setOpen(false);
};
const close = () => setOpen(false);
document.addEventListener("mousedown", onDown, true);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [open]);
const remove = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onDelete();
};
return (
<>
<button
ref={btnRef}
className={`row-menu-btn ${className}`}
data-open={open}
title={label}
onMouseDown={(e) => e.stopPropagation()}
onClick={toggle}
>
<Icon d="M12 5h.01M12 12h.01M12 19h.01" />
</button>
{open &&
createPortal(
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
<button className="row-menu-item danger" onClick={remove}>
<Icon d="M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6" size={14} />
Delete
</button>
</div>,
document.body
)}
</>
);
}
+110 -25
View File
@@ -1,32 +1,83 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { COVER_ID, useBook } from "../store/useBook";
import { type Chapter, type ChapterKind, bodyNumber, chapterKind } from "../model/book";
import { AddPageMenu } from "./AddPageMenu";
import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon";
import { RowMenu } from "./RowMenu";
interface Row {
chapter: Chapter;
index: number;
num: number | null;
}
function formatEdited(ts: number, now: number): string {
const diff = Math.max(0, now - ts);
const min = 60_000;
const hour = 60 * min;
const day = 24 * hour;
if (diff < min) return "just now";
if (diff < hour) {
const m = Math.floor(diff / min);
return `${m} minute${m === 1 ? "" : "s"} ago`;
}
if (diff < day) {
const h = Math.floor(diff / hour);
return `${h} hour${h === 1 ? "" : "s"} ago`;
}
if (diff < 2 * day) return "yesterday";
const d = Math.floor(diff / day);
if (d < 7) return `${d} days ago`;
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
export function Sidebar() {
const chapters = useBook((s) => s.book?.chapters ?? []);
const activeChapterId = useBook((s) => s.activeChapterId);
const setActiveChapter = useBook((s) => s.setActiveChapter);
const addChapter = useBook((s) => s.addChapter);
const addPage = useBook((s) => s.addPage);
const deleteChapter = useBook((s) => s.deleteChapter);
const moveChapter = useBook((s) => s.moveChapter);
const closeBook = useBook((s) => s.closeBook);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [drag, setDrag] = useState<{ kind: ChapterKind; index: number } | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
const [now, setNow] = useState(() => Date.now());
const onDragOver = (e: React.DragEvent, i: number) => {
if (dragIndex === null) return;
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(timer);
}, []);
const rows: Row[] = chapters.map((chapter, index) => ({ chapter, index, num: bodyNumber(chapters, index) }));
const groups: { kind: ChapterKind; label: string; rows: Row[] }[] = [
{ kind: "front", label: "Front matter", rows: rows.filter((r) => chapterKind(r.chapter) === "front") },
{ kind: "body", label: "Chapters", rows: rows.filter((r) => chapterKind(r.chapter) === "body") },
{ kind: "back", label: "Back matter", rows: rows.filter((r) => chapterKind(r.chapter) === "back") },
];
const onDragOver = (e: React.DragEvent, kind: ChapterKind, index: number) => {
if (!drag || drag.kind !== kind) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const after = e.clientY > rect.top + rect.height / 2;
setDropIndex(after ? i + 1 : i);
setDropIndex(after ? index + 1 : index);
};
const onDrop = () => {
if (dragIndex !== null && dropIndex !== null) {
const to = dragIndex < dropIndex ? dropIndex - 1 : dropIndex;
moveChapter(dragIndex, to);
if (drag && dropIndex !== null) {
const to = drag.index < dropIndex ? dropIndex - 1 : dropIndex;
moveChapter(drag.index, to);
}
setDragIndex(null);
setDrag(null);
setDropIndex(null);
};
const reset = () => {
setDrag(null);
setDropIndex(null);
};
@@ -44,38 +95,72 @@ export function Sidebar() {
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
<span className="title">Cover</span>
</button>
<div className="nav-label">Chapters</div>
<AddPageMenu onAdd={addPage} />
<div className="nav-scroll">
{groups.map((group) =>
group.kind === "body" || group.rows.length ? (
<div key={group.kind} className="nav-section">
<div className="nav-label">{group.label}</div>
<ul className="chapters">
{chapters.map((chapter, i) => (
{group.rows.map((row, i) => (
<li
key={chapter.id}
key={row.chapter.id}
className="chapter"
data-active={chapter.id === activeChapterId}
data-dragging={dragIndex === i}
data-drop-before={dropIndex === i}
data-drop-after={i === chapters.length - 1 && dropIndex === chapters.length}
data-active={row.chapter.id === activeChapterId}
data-dragging={drag?.index === row.index}
data-drop-before={drag?.kind === group.kind && dropIndex === row.index}
data-drop-after={drag?.kind === group.kind && i === group.rows.length - 1 && dropIndex === row.index + 1}
draggable
onClick={() => setActiveChapter(chapter.id)}
onDragStart={() => setDragIndex(i)}
onDragOver={(e) => onDragOver(e, i)}
onClick={() => setActiveChapter(row.chapter.id)}
onDragStart={() => setDrag({ kind: group.kind, index: row.index })}
onDragOver={(e) => onDragOver(e, group.kind, row.index)}
onDrop={onDrop}
onDragEnd={() => {
setDragIndex(null);
setDropIndex(null);
}}
onDragEnd={reset}
>
<span className="grip" title="Drag to reorder">
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
</span>
<span className="num">{i + 1}</span>
<span className="title">{chapter.title || "Untitled"}</span>
<span className="num">{row.num ?? ""}</span>
<span className="text">
<span className="title">{row.chapter.title || "Untitled"}</span>
{row.chapter.id === activeChapterId && (
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span>
)}
</span>
<RowMenu
label="Page options"
onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })}
/>
</li>
))}
</ul>
{group.kind === "body" && (
<button className="add-chapter" onClick={addChapter}>
<Icon d="M12 5v14M5 12h14" />
New chapter
</button>
)}
</div>
) : null
)}
</div>
{pendingDelete && (
<ConfirmDialog
title="Delete"
message={
<>
Delete <strong>{pendingDelete.title}</strong>? This can't be undone.
</>
}
onConfirm={() => {
deleteChapter(pendingDelete.id);
setPendingDelete(null);
}}
onClose={() => setPendingDelete(null)}
/>
)}
</aside>
);
}
+11 -8
View File
@@ -1,6 +1,6 @@
import type { JSONContent } from "@tiptap/core";
import { invoke } from "@tauri-apps/api/core";
import { type Book, TRIM_DIMS } from "../model/book";
import { type Book, TRIM_DIMS, bodyNumber, chapterKind } from "../model/book";
export interface EpubFile {
path: string;
@@ -277,10 +277,10 @@ function contentOpf(
function navXhtml(book: Book): string {
const items = book.chapters
.map(
(chapter, i) =>
` <li><a href="chapter-${i + 1}.xhtml">${esc(chapter.title || `Chapter ${i + 1}`)}</a></li>`,
)
.map((chapter, i) => {
const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled";
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(chapter.title || fallback)}</a></li>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
@@ -301,16 +301,19 @@ ${items}
function chapterXhtml(book: Book, index: number, paths: Map<string, string>): string {
const chapter = book.chapters[index];
const title = chapter.title || `Chapter ${index + 1}`;
const kind = chapterKind(chapter);
const num = bodyNumber(book.chapters, index);
const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
const eyebrow = kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : "";
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
<head>
<title>${esc(title)}</title>
<link rel="stylesheet" type="text/css" href="style.css"/>
</head>
<body epub:type="chapter">
<body epub:type="${kind === "body" ? "chapter" : kind === "front" ? "frontmatter" : "backmatter"}">
<header class="chapter-opener">
<p class="eyebrow">Chapter ${index + 1}</p>
${eyebrow}
<h1>${esc(title)}</h1>
</header>
${chapterBody(chapter.content, paths)}
+18 -3
View File
@@ -1,5 +1,5 @@
import type { JSONContent } from "@tiptap/core";
import type { Book, TrimSize } from "../model/book";
import { type Book, type TrimSize, bodyNumber, chapterKind } from "../model/book";
import type { ImageInput } from "../ipc";
const TRIM: Record<TrimSize, { w: string; h: string }> = {
@@ -136,6 +136,15 @@ function preamble(book: Book): string {
#heading(level: 1, numbering: none, outlined: true)[#title]
]
v(1.5em)
}
#let pageopener(title) = {
pagebreak(weak: true)
v(2.1in)
align(center)[
#heading(level: 1, numbering: none, outlined: true)[#title]
]
v(1.5em)
}`;
}
@@ -216,7 +225,10 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
const body = book.chapters
.map((chapter, i) => {
const opener = `#chapteropener(${str(String(i + 1))}, [${esc(chapter.title || "Untitled")}])`;
const opener =
chapterKind(chapter) === "body"
? `#chapteropener(${str(String(bodyNumber(book.chapters, i)))}, [${esc(chapter.title || "Untitled")}])`
: `#pageopener([${esc(chapter.title || "Untitled")}])`;
return `${opener}\n\n${chapterBody(chapter.content, paths)}`;
})
.join("\n\n");
@@ -238,7 +250,10 @@ export function coverToPdfInputs(book: Book): { source: string; images: ImageInp
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
const chapter = book.chapters[index];
const opener = `#chapteropener(${str(String(index + 1))}, [${esc(chapter.title || "Untitled")}])`;
const opener =
chapterKind(chapter) === "body"
? `#chapteropener(${str(String(bodyNumber(book.chapters, index)))}, [${esc(chapter.title || "Untitled")}])`
: `#pageopener([${esc(chapter.title || "Untitled")}])`;
return `${preamble(book)}
#set page(numbering: "1")
+1
View File
@@ -354,6 +354,7 @@ function buildChapter(
id: crypto.randomUUID(),
title: title || `Chapter ${index + 1}`,
content: { type: "doc", content: blocks.length ? blocks : [{ type: "paragraph" }] },
updatedAt: Date.now(),
};
}
+47 -2
View File
@@ -52,10 +52,46 @@ export function trimRatio(trim: TrimSize): number {
return w / h;
}
export type ChapterKind = "front" | "body" | "back";
export interface Chapter {
id: string;
title: string;
content: JSONContent;
updatedAt: number;
kind?: ChapterKind;
}
export interface PageType {
id: string;
label: string;
group: "front" | "back";
}
export const PAGE_TYPES: PageType[] = [
{ id: "dedication", label: "Dedication", group: "front" },
{ id: "epigraph", label: "Epigraph", group: "front" },
{ id: "foreword", label: "Foreword", group: "front" },
{ id: "preface", label: "Preface", group: "front" },
{ id: "introduction", label: "Introduction", group: "front" },
{ id: "prologue", label: "Prologue", group: "front" },
{ id: "acknowledgments", label: "Acknowledgments", group: "front" },
{ id: "epilogue", label: "Epilogue", group: "back" },
{ id: "afterword", label: "Afterword", group: "back" },
{ id: "appendix", label: "Appendix", group: "back" },
{ id: "about-author", label: "About the Author", group: "back" },
{ id: "other", label: "New page", group: "front" },
];
export function chapterKind(chapter: Chapter): ChapterKind {
return chapter.kind ?? "body";
}
export function bodyNumber(chapters: Chapter[], index: number): number | null {
if (index < 0 || index >= chapters.length || chapterKind(chapters[index]) !== "body") return null;
let n = 0;
for (let i = 0; i <= index; i++) if (chapterKind(chapters[i]) === "body") n++;
return n;
}
export interface Book {
@@ -77,7 +113,11 @@ function emptyDoc(): JSONContent {
}
export function createChapter(title = "Untitled chapter"): Chapter {
return { id: crypto.randomUUID(), title, content: emptyDoc() };
return { id: crypto.randomUUID(), title, content: emptyDoc(), updatedAt: Date.now() };
}
export function createPage(group: "front" | "back", title: string): Chapter {
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: group, updatedAt: Date.now() };
}
export function createCover(): Cover {
@@ -85,7 +125,11 @@ export function createCover(): Cover {
}
export function normalizeBook(book: Book): Book {
return { ...book, cover: book.cover ? { ...createCover(), ...book.cover } : createCover() };
return {
...book,
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
chapters: book.chapters.map((c) => (c.updatedAt ? c : { ...c, updatedAt: Date.now() })),
};
}
export function createBook(): Book {
@@ -107,6 +151,7 @@ export function starterBook(): Book {
{
id: crypto.randomUUID(),
title: "The Lighthouse",
updatedAt: Date.now(),
content: {
type: "doc",
content: [
+30 -3
View File
@@ -5,7 +5,9 @@ import {
type BookMetadata,
type BookSettings,
type Cover,
chapterKind,
createChapter,
createPage,
normalizeBook,
} from "../model/book";
@@ -21,6 +23,8 @@ interface BookState {
setChapterContent: (id: string, content: JSONContent) => void;
setChapterTitle: (id: string, title: string) => void;
addChapter: () => void;
addPage: (group: "front" | "back", title: string) => void;
deleteChapter: (id: string) => void;
moveChapter: (from: number, to: number) => void;
setMetadata: (patch: Partial<BookMetadata>) => void;
setSettings: (patch: Partial<BookSettings>) => void;
@@ -41,20 +45,43 @@ export const useBook = create<BookState>((set) => ({
setChapterContent: (id, content) =>
set((s) =>
s.book
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, content } : c)) } }
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, content, updatedAt: Date.now() } : c)) } }
: {}
),
setChapterTitle: (id, title) =>
set((s) =>
s.book
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, title } : c)) } }
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, title, updatedAt: Date.now() } : c)) } }
: {}
),
addChapter: () =>
set((s) => {
if (!s.book) return {};
const chapter = createChapter();
return { activeChapterId: chapter.id, dirty: true, book: { ...s.book, chapters: [...s.book.chapters, chapter] } };
const chapters = [...s.book.chapters];
const backCount = chapters.filter((c) => chapterKind(c) === "back").length;
chapters.splice(chapters.length - backCount, 0, chapter);
return { activeChapterId: chapter.id, dirty: true, book: { ...s.book, chapters } };
}),
addPage: (group, title) =>
set((s) => {
if (!s.book) return {};
const page = createPage(group, title);
const chapters = [...s.book.chapters];
const insertAt =
group === "front" ? chapters.filter((c) => chapterKind(c) === "front").length : chapters.length;
chapters.splice(insertAt, 0, page);
return { activeChapterId: page.id, dirty: true, book: { ...s.book, chapters } };
}),
deleteChapter: (id) =>
set((s) => {
if (!s.book) return {};
const index = s.book.chapters.findIndex((c) => c.id === id);
if (index === -1) return {};
const chapters = s.book.chapters.filter((c) => c.id !== id);
const activeChapterId =
s.activeChapterId === id ? (chapters[index] ?? chapters[index - 1])?.id ?? COVER_ID : s.activeChapterId;
return { dirty: true, activeChapterId, book: { ...s.book, chapters } };
}),
moveChapter: (from, to) =>
set((s) => {
+213 -16
View File
@@ -193,6 +193,28 @@ button {
color: var(--accent);
}
.chapter .text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.chapter .title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chapter .meta {
font-family: var(--font-ui);
font-size: var(--t-1);
letter-spacing: 0.01em;
color: var(--ink-faint);
}
.chapter .grip {
display: grid;
place-items: center;
@@ -236,6 +258,69 @@ button {
bottom: -1px;
}
.row-menu-btn {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: var(--r-sm);
color: var(--ink-faint);
transition: opacity 120ms var(--ease), background 120ms var(--ease), color 120ms var(--ease);
}
.row-menu-btn:hover {
background: var(--accent-wash);
color: var(--ink);
}
.chapter .row-menu-btn {
align-self: center;
margin-right: -4px;
opacity: 0;
}
.chapter:hover .row-menu-btn,
.chapter .row-menu-btn[data-open="true"] {
opacity: 0.7;
}
.chapter:hover .row-menu-btn:hover,
.chapter .row-menu-btn[data-open="true"] {
opacity: 1;
}
.row-menu-pop {
position: fixed;
z-index: 50;
min-width: 148px;
padding: 5px;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-pop);
}
.row-menu-item {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border-radius: var(--r-sm);
font-family: var(--font-ui);
font-size: var(--t-3);
color: var(--ink-soft);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.row-menu-item.danger {
color: var(--danger);
}
.row-menu-item.danger:hover {
background: var(--danger-wash);
}
.add-chapter {
margin: 8px 18px 4px;
padding: 9px 12px;
@@ -1021,51 +1106,80 @@ button {
color: var(--accent);
}
.card-delete {
.card-menu {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--ink-faint);
opacity: 0;
transition: opacity 120ms var(--ease), background 120ms var(--ease), color 120ms var(--ease);
}
.card-book:hover .card-delete {
.card-book:hover .card-menu,
.card-menu[data-open="true"] {
opacity: 1;
}
.card-delete:hover {
background: var(--accent-wash);
color: var(--accent);
}
.panel-foot {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 22px;
border-top: 1px solid var(--line);
}
.btn-primary {
.panel-confirm {
width: 420px;
}
.confirm-text {
margin: 0;
font-size: var(--t-4);
line-height: 1.5;
color: var(--ink-soft);
}
.confirm-text strong {
font-weight: 600;
color: var(--ink);
}
.btn-primary,
.btn-ghost,
.btn-danger {
font-family: var(--font-ui);
font-size: var(--t-3);
font-weight: 500;
padding: 9px 20px;
border-radius: var(--r-sm);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.btn-primary {
background: var(--accent);
color: var(--accent-contrast);
transition: background 120ms var(--ease);
}
.btn-primary:hover {
background: var(--accent-ink);
}
.btn-ghost {
color: var(--ink-soft);
}
.btn-ghost:hover {
background: var(--accent-wash);
color: var(--ink);
}
.btn-danger {
background: var(--danger);
color: var(--danger-contrast);
}
.btn-danger:hover {
background: var(--danger-ink);
}
/* Cover */
.cover-item {
position: relative;
@@ -1300,3 +1414,86 @@ button {
color: var(--ink-faint);
text-align: center;
}
/* Add-page menu + front/back matter sections */
.add-page {
margin: 2px 12px 2px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
border-radius: var(--r-sm);
font-family: var(--font-ui);
font-size: var(--t-3);
color: var(--ink-faint);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.add-page:hover,
.add-page[data-open="true"] {
background: var(--accent-wash);
color: var(--ink);
}
.add-page-pop {
position: fixed;
z-index: 50;
min-width: 178px;
max-height: 72vh;
overflow-y: auto;
padding: 6px;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-pop);
}
.add-page-group + .add-page-group {
margin-top: 4px;
border-top: 1px solid var(--line);
padding-top: 4px;
}
.add-page-label {
padding: 6px 10px 3px;
font-family: var(--font-ui);
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-faint);
}
.add-page-item {
display: block;
width: 100%;
text-align: left;
padding: 7px 10px;
border-radius: var(--r-sm);
font-family: var(--font-ui);
font-size: var(--t-3);
color: var(--ink-soft);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.add-page-item:hover {
background: var(--accent-wash);
color: var(--ink);
}
.nav-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.nav-scroll .chapters {
flex: none;
overflow: visible;
}
.nav-section + .nav-section {
margin-top: 6px;
}
+10
View File
@@ -39,6 +39,11 @@
--selection: rgba(35, 32, 27, 0.14);
--glass: rgba(252, 251, 247, 0.86);
--danger: #b4453a;
--danger-ink: #963327;
--danger-wash: rgba(180, 69, 58, 0.1);
--danger-contrast: #faf7f0;
--shadow-page: 0 1px 2px rgba(35, 32, 27, 0.06), 0 14px 30px rgba(35, 32, 27, 0.09);
--shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14);
}
@@ -63,6 +68,11 @@
--selection: rgba(239, 233, 220, 0.18);
--glass: rgba(33, 30, 25, 0.88);
--danger: #d97a6f;
--danger-ink: #e58e84;
--danger-wash: rgba(217, 122, 111, 0.16);
--danger-contrast: #1a1714;
--shadow-page: 0 1px 2px rgba(0, 0, 0, 0.4), 0 14px 34px rgba(0, 0, 0, 0.5);
--shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.55);
}