From c86d5a7d5c852e525c67c590493ae81eaabce15e Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 20 Jun 2026 19:48:18 -0400 Subject: [PATCH] changes --- src/components/AddPageMenu.tsx | 71 ++++++++++ src/components/ConfirmDialog.tsx | 36 +++++ src/components/Dock.tsx | 8 +- src/components/EditorView.tsx | 11 +- src/components/Library.tsx | 29 +++- src/components/RowMenu.tsx | 73 ++++++++++ src/components/Sidebar.tsx | 167 ++++++++++++++++------ src/export/epub.ts | 19 +-- src/export/typst.ts | 21 ++- src/import/epub.ts | 1 + src/model/book.ts | 49 ++++++- src/store/useBook.ts | 33 ++++- src/styles/app.css | 229 ++++++++++++++++++++++++++++--- src/styles/tokens.css | 10 ++ 14 files changed, 671 insertions(+), 86 deletions(-) create mode 100644 src/components/AddPageMenu.tsx create mode 100644 src/components/ConfirmDialog.tsx create mode 100644 src/components/RowMenu.tsx diff --git a/src/components/AddPageMenu.tsx b/src/components/AddPageMenu.tsx new file mode 100644 index 0000000..88f6899 --- /dev/null +++ b/src/components/AddPageMenu.tsx @@ -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(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 ( + <> + + {open && ( +
e.stopPropagation()} + > + {GROUPS.map((g) => ( +
+
{g.label}
+ {PAGE_TYPES.filter((t) => t.group === g.key).map((t) => ( + + ))} +
+ ))} +
+ )} + + ); +} diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..4d90d6c --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -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 ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+
+

{message}

+
+
+ + +
+
+
+ ); +} diff --git a/src/components/Dock.tsx b/src/components/Dock.tsx index 60c4a59..c16368e 100644 --- a/src/components/Dock.tsx +++ b/src/components/Dock.tsx @@ -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 (
@@ -128,7 +132,7 @@ function HtmlDock() {
-
Chapter {idx + 1}
+
{eyebrow}
{chapter.title || "Untitled"}
diff --git a/src/components/EditorView.tsx b/src/components/EditorView.tsx index 17708f9..5af5091 100644 --- a/src/components/EditorView.tsx +++ b/src/components/EditorView.tsx @@ -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 (
@@ -129,11 +132,11 @@ export function EditorView() { <>
-
Chapter {idx + 1}
+
{eyebrow}
setChapterTitle(chapter.id, e.target.value)} /> diff --git a/src/components/Library.tsx b/src/components/Library.tsx index 2a651ea..ba1d403 100644 --- a/src/components/Library.tsx +++ b/src/components/Library.tsx @@ -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([]); const [loaded, setLoaded] = useState(false); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); + const [pendingDelete, setPendingDelete] = useState(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 }) {
loadBook(b.id).then(onOpen)}> {b.title || "Untitled"} {b.author && {b.author}} - + setPendingDelete(b)} />
))}
@@ -89,6 +91,19 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) { {notice}
)} + {pendingDelete && ( + + Delete {pendingDelete.title || "Untitled"}? This permanently removes the book and all its + chapters. + + } + onConfirm={removeBook} + onClose={() => setPendingDelete(null)} + /> + )}
); } diff --git a/src/components/RowMenu.tsx b/src/components/RowMenu.tsx new file mode 100644 index 0000000..f1b6900 --- /dev/null +++ b/src/components/RowMenu.tsx @@ -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(null); + const popRef = useRef(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 ( + <> + + {open && + createPortal( +
+ +
, + document.body + )} + + ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a4ccf00..8985fe2 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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(null); + const [drag, setDrag] = useState<{ kind: ChapterKind; index: number } | null>(null); const [dropIndex, setDropIndex] = useState(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() { Cover -
Chapters
-
    - {chapters.map((chapter, i) => ( -
  • setActiveChapter(chapter.id)} - onDragStart={() => setDragIndex(i)} - onDragOver={(e) => onDragOver(e, i)} - onDrop={onDrop} - onDragEnd={() => { - setDragIndex(null); - setDropIndex(null); - }} - > - - - - {i + 1} - {chapter.title || "Untitled"} -
  • - ))} -
- + + +
+ {groups.map((group) => + group.kind === "body" || group.rows.length ? ( +
+
{group.label}
+
    + {group.rows.map((row, i) => ( +
  • setActiveChapter(row.chapter.id)} + onDragStart={() => setDrag({ kind: group.kind, index: row.index })} + onDragOver={(e) => onDragOver(e, group.kind, row.index)} + onDrop={onDrop} + onDragEnd={reset} + > + + + + {row.num ?? ""} + + {row.chapter.title || "Untitled"} + {row.chapter.id === activeChapterId && ( + Edited {formatEdited(row.chapter.updatedAt, now)} + )} + + setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })} + /> +
  • + ))} +
+ {group.kind === "body" && ( + + )} +
+ ) : null + )} +
+ + {pendingDelete && ( + + Delete {pendingDelete.title}? This can't be undone. + + } + onConfirm={() => { + deleteChapter(pendingDelete.id); + setPendingDelete(null); + }} + onClose={() => setPendingDelete(null)} + /> + )} ); } diff --git a/src/export/epub.ts b/src/export/epub.ts index 34e96e4..8f05858 100644 --- a/src/export/epub.ts +++ b/src/export/epub.ts @@ -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) => - `
  • ${esc(chapter.title || `Chapter ${i + 1}`)}
  • `, - ) + .map((chapter, i) => { + const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled"; + return `
  • ${esc(chapter.title || fallback)}
  • `; + }) .join("\n"); return ` @@ -301,16 +301,19 @@ ${items} function chapterXhtml(book: Book, index: number, paths: Map): 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" ? `

    Chapter ${num}

    ` : ""; return ` ${esc(title)} - +
    -

    Chapter ${index + 1}

    + ${eyebrow}

    ${esc(title)}

    ${chapterBody(chapter.content, paths)} diff --git a/src/export/typst.ts b/src/export/typst.ts index 12c9993..5457ebc 100644 --- a/src/export/typst.ts +++ b/src/export/typst.ts @@ -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 = { @@ -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 = 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 { 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") diff --git a/src/import/epub.ts b/src/import/epub.ts index 4059033..7088d87 100644 --- a/src/import/epub.ts +++ b/src/import/epub.ts @@ -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(), }; } diff --git a/src/model/book.ts b/src/model/book.ts index 622b6a2..bcfc44a 100644 --- a/src/model/book.ts +++ b/src/model/book.ts @@ -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: [ diff --git a/src/store/useBook.ts b/src/store/useBook.ts index 3cac551..2bc3d92 100644 --- a/src/store/useBook.ts +++ b/src/store/useBook.ts @@ -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) => void; setSettings: (patch: Partial) => void; @@ -41,20 +45,43 @@ export const useBook = create((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) => { diff --git a/src/styles/app.css b/src/styles/app.css index 61a0fa9..575aef0 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -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; +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css index a95173d..409c3e6 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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); }