mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
changes
This commit is contained in:
1 parent
734ded73ac
commit
c86d5a7d5c
14 files changed
+671
-86
No files matched your search
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { generateHTML } from "@tiptap/core";
|
import { generateHTML } from "@tiptap/core";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
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 { editorExtensions } from "../editor/extensions";
|
||||||
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst";
|
||||||
import { compilePdf, isDesktop } from "../ipc";
|
import { compilePdf, isDesktop } from "../ipc";
|
||||||
@@ -115,6 +115,10 @@ function HtmlDock() {
|
|||||||
|
|
||||||
if (!chapter) return null;
|
if (!chapter) return null;
|
||||||
|
|
||||||
|
const kind = chapterKind(chapter);
|
||||||
|
const eyebrow =
|
||||||
|
kind === "body" ? `Chapter ${bodyNumber(chapters, idx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dock">
|
<section className="dock">
|
||||||
<div className="dock-head">
|
<div className="dock-head">
|
||||||
@@ -128,7 +132,7 @@ function HtmlDock() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="p-opener">
|
<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 className="p-title">{chapter.title || "Untitled"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
|
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Editor } from "../editor/Editor";
|
|||||||
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
import { useTheme } from "../store/useTheme";
|
import { useTheme } from "../store/useTheme";
|
||||||
import type { Book } from "../model/book";
|
import { type Book, bodyNumber, chapterKind } from "../model/book";
|
||||||
import { saveBook } from "../library";
|
import { saveBook } from "../library";
|
||||||
import { isDesktop } from "../ipc";
|
import { isDesktop } from "../ipc";
|
||||||
import { exportEpub, exportPdf } from "../export/exporters";
|
import { exportEpub, exportPdf } from "../export/exporters";
|
||||||
@@ -80,13 +80,16 @@ export function EditorView() {
|
|||||||
const coverActive = activeChapterId === COVER_ID;
|
const coverActive = activeChapterId === COVER_ID;
|
||||||
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
|
||||||
const chapter = book.chapters[idx] ?? book.chapters[0];
|
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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="titlebar" data-tauri-drag-region>
|
<header className="titlebar" data-tauri-drag-region>
|
||||||
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
|
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
|
||||||
{book.metadata.title || "Untitled"}
|
{book.metadata.title || "Untitled"}
|
||||||
{book.metadata.author ? ` — ${book.metadata.author}` : ""}
|
|
||||||
{dirty && <span className="dirty-dot" />}
|
{dirty && <span className="dirty-dot" />}
|
||||||
</button>
|
</button>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
@@ -129,11 +132,11 @@ export function EditorView() {
|
|||||||
<>
|
<>
|
||||||
<article className="sheet">
|
<article className="sheet">
|
||||||
<header className="chapter-opener">
|
<header className="chapter-opener">
|
||||||
<div className="chapter-num">Chapter {idx + 1}</div>
|
<div className="chapter-num">{eyebrow}</div>
|
||||||
<input
|
<input
|
||||||
className="chapter-title-input"
|
className="chapter-title-input"
|
||||||
value={chapter.title}
|
value={chapter.title}
|
||||||
placeholder="Chapter title"
|
placeholder={kind === "body" ? "Chapter title" : "Page title"}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
|
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 BookSummary, deleteBook, exampleBook, listBooks, loadBook, newBook, saveBook } from "../library";
|
||||||
import type { Book } from "../model/book";
|
import type { Book } from "../model/book";
|
||||||
import { importEpub } from "../import/epub";
|
import { importEpub } from "../import/epub";
|
||||||
import { isDesktop } from "../ipc";
|
import { isDesktop } from "../ipc";
|
||||||
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
import { RowMenu } from "./RowMenu";
|
||||||
|
|
||||||
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||||
const [books, setBooks] = useState<BookSummary[]>([]);
|
const [books, setBooks] = useState<BookSummary[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
||||||
|
|
||||||
const refresh = () =>
|
const refresh = () =>
|
||||||
listBooks()
|
listBooks()
|
||||||
@@ -32,9 +35,10 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
onOpen(copy);
|
onOpen(copy);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeBook = async (e: MouseEvent, id: string) => {
|
const removeBook = async () => {
|
||||||
e.stopPropagation();
|
if (!pendingDelete) return;
|
||||||
await deleteBook(id);
|
await deleteBook(pendingDelete.id);
|
||||||
|
setPendingDelete(null);
|
||||||
refresh();
|
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)}>
|
<div key={b.id} className="card card-book" onClick={() => loadBook(b.id).then(onOpen)}>
|
||||||
<span className="card-title">{b.title || "Untitled"}</span>
|
<span className="card-title">{b.title || "Untitled"}</span>
|
||||||
{b.author && <span className="card-author">{b.author}</span>}
|
{b.author && <span className="card-author">{b.author}</span>}
|
||||||
<button className="card-delete" title="Delete book" onClick={(e) => removeBook(e, b.id)}>
|
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
||||||
<Icon d="M6 6l12 12M18 6L6 18" size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,6 +91,19 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
{notice}
|
{notice}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+126
-41
@@ -1,32 +1,83 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
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 { 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() {
|
export function Sidebar() {
|
||||||
const chapters = useBook((s) => s.book?.chapters ?? []);
|
const chapters = useBook((s) => s.book?.chapters ?? []);
|
||||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||||
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
||||||
const addChapter = useBook((s) => s.addChapter);
|
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 moveChapter = useBook((s) => s.moveChapter);
|
||||||
const closeBook = useBook((s) => s.closeBook);
|
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 [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) => {
|
useEffect(() => {
|
||||||
if (dragIndex === null) return;
|
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();
|
e.preventDefault();
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const after = e.clientY > rect.top + rect.height / 2;
|
const after = e.clientY > rect.top + rect.height / 2;
|
||||||
setDropIndex(after ? i + 1 : i);
|
setDropIndex(after ? index + 1 : index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDrop = () => {
|
const onDrop = () => {
|
||||||
if (dragIndex !== null && dropIndex !== null) {
|
if (drag && dropIndex !== null) {
|
||||||
const to = dragIndex < dropIndex ? dropIndex - 1 : dropIndex;
|
const to = drag.index < dropIndex ? dropIndex - 1 : dropIndex;
|
||||||
moveChapter(dragIndex, to);
|
moveChapter(drag.index, to);
|
||||||
}
|
}
|
||||||
setDragIndex(null);
|
setDrag(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setDrag(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,38 +95,72 @@ export function Sidebar() {
|
|||||||
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
|
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
|
||||||
<span className="title">Cover</span>
|
<span className="title">Cover</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="nav-label">Chapters</div>
|
<AddPageMenu onAdd={addPage} />
|
||||||
<ul className="chapters">
|
|
||||||
{chapters.map((chapter, i) => (
|
<div className="nav-scroll">
|
||||||
<li
|
{groups.map((group) =>
|
||||||
key={chapter.id}
|
group.kind === "body" || group.rows.length ? (
|
||||||
className="chapter"
|
<div key={group.kind} className="nav-section">
|
||||||
data-active={chapter.id === activeChapterId}
|
<div className="nav-label">{group.label}</div>
|
||||||
data-dragging={dragIndex === i}
|
<ul className="chapters">
|
||||||
data-drop-before={dropIndex === i}
|
{group.rows.map((row, i) => (
|
||||||
data-drop-after={i === chapters.length - 1 && dropIndex === chapters.length}
|
<li
|
||||||
draggable
|
key={row.chapter.id}
|
||||||
onClick={() => setActiveChapter(chapter.id)}
|
className="chapter"
|
||||||
onDragStart={() => setDragIndex(i)}
|
data-active={row.chapter.id === activeChapterId}
|
||||||
onDragOver={(e) => onDragOver(e, i)}
|
data-dragging={drag?.index === row.index}
|
||||||
onDrop={onDrop}
|
data-drop-before={drag?.kind === group.kind && dropIndex === row.index}
|
||||||
onDragEnd={() => {
|
data-drop-after={drag?.kind === group.kind && i === group.rows.length - 1 && dropIndex === row.index + 1}
|
||||||
setDragIndex(null);
|
draggable
|
||||||
setDropIndex(null);
|
onClick={() => setActiveChapter(row.chapter.id)}
|
||||||
}}
|
onDragStart={() => setDrag({ kind: group.kind, index: row.index })}
|
||||||
>
|
onDragOver={(e) => onDragOver(e, group.kind, row.index)}
|
||||||
<span className="grip" title="Drag to reorder">
|
onDrop={onDrop}
|
||||||
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
|
onDragEnd={reset}
|
||||||
</span>
|
>
|
||||||
<span className="num">{i + 1}</span>
|
<span className="grip" title="Drag to reorder">
|
||||||
<span className="title">{chapter.title || "Untitled"}</span>
|
<Icon d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01" size={14} />
|
||||||
</li>
|
</span>
|
||||||
))}
|
<span className="num">{row.num ?? ""}</span>
|
||||||
</ul>
|
<span className="text">
|
||||||
<button className="add-chapter" onClick={addChapter}>
|
<span className="title">{row.chapter.title || "Untitled"}</span>
|
||||||
<Icon d="M12 5v14M5 12h14" />
|
{row.chapter.id === activeChapterId && (
|
||||||
New chapter
|
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span>
|
||||||
</button>
|
)}
|
||||||
|
</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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+11
-8
@@ -1,6 +1,6 @@
|
|||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { invoke } from "@tauri-apps/api/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 {
|
export interface EpubFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -277,10 +277,10 @@ function contentOpf(
|
|||||||
|
|
||||||
function navXhtml(book: Book): string {
|
function navXhtml(book: Book): string {
|
||||||
const items = book.chapters
|
const items = book.chapters
|
||||||
.map(
|
.map((chapter, i) => {
|
||||||
(chapter, i) =>
|
const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled";
|
||||||
` <li><a href="chapter-${i + 1}.xhtml">${esc(chapter.title || `Chapter ${i + 1}`)}</a></li>`,
|
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(chapter.title || fallback)}</a></li>`;
|
||||||
)
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
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")}">
|
<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 {
|
function chapterXhtml(book: Book, index: number, paths: Map<string, string>): string {
|
||||||
const chapter = book.chapters[index];
|
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"?>
|
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")}">
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
|
||||||
<head>
|
<head>
|
||||||
<title>${esc(title)}</title>
|
<title>${esc(title)}</title>
|
||||||
<link rel="stylesheet" type="text/css" href="style.css"/>
|
<link rel="stylesheet" type="text/css" href="style.css"/>
|
||||||
</head>
|
</head>
|
||||||
<body epub:type="chapter">
|
<body epub:type="${kind === "body" ? "chapter" : kind === "front" ? "frontmatter" : "backmatter"}">
|
||||||
<header class="chapter-opener">
|
<header class="chapter-opener">
|
||||||
<p class="eyebrow">Chapter ${index + 1}</p>
|
${eyebrow}
|
||||||
<h1>${esc(title)}</h1>
|
<h1>${esc(title)}</h1>
|
||||||
</header>
|
</header>
|
||||||
${chapterBody(chapter.content, paths)}
|
${chapterBody(chapter.content, paths)}
|
||||||
|
|||||||
+18
-3
@@ -1,5 +1,5 @@
|
|||||||
import type { JSONContent } from "@tiptap/core";
|
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";
|
import type { ImageInput } from "../ipc";
|
||||||
|
|
||||||
const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
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]
|
#heading(level: 1, numbering: none, outlined: true)[#title]
|
||||||
]
|
]
|
||||||
v(1.5em)
|
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
|
const body = book.chapters
|
||||||
.map((chapter, i) => {
|
.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)}`;
|
return `${opener}\n\n${chapterBody(chapter.content, paths)}`;
|
||||||
})
|
})
|
||||||
.join("\n\n");
|
.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 {
|
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
||||||
const chapter = book.chapters[index];
|
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)}
|
return `${preamble(book)}
|
||||||
|
|
||||||
#set page(numbering: "1")
|
#set page(numbering: "1")
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ function buildChapter(
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: title || `Chapter ${index + 1}`,
|
title: title || `Chapter ${index + 1}`,
|
||||||
content: { type: "doc", content: blocks.length ? blocks : [{ type: "paragraph" }] },
|
content: { type: "doc", content: blocks.length ? blocks : [{ type: "paragraph" }] },
|
||||||
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-2
@@ -52,10 +52,46 @@ export function trimRatio(trim: TrimSize): number {
|
|||||||
return w / h;
|
return w / h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChapterKind = "front" | "body" | "back";
|
||||||
|
|
||||||
export interface Chapter {
|
export interface Chapter {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: JSONContent;
|
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 {
|
export interface Book {
|
||||||
@@ -77,7 +113,11 @@ function emptyDoc(): JSONContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createChapter(title = "Untitled chapter"): Chapter {
|
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 {
|
export function createCover(): Cover {
|
||||||
@@ -85,7 +125,11 @@ export function createCover(): Cover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeBook(book: Book): Book {
|
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 {
|
export function createBook(): Book {
|
||||||
@@ -107,6 +151,7 @@ export function starterBook(): Book {
|
|||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: "The Lighthouse",
|
title: "The Lighthouse",
|
||||||
|
updatedAt: Date.now(),
|
||||||
content: {
|
content: {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
+30
-3
@@ -5,7 +5,9 @@ import {
|
|||||||
type BookMetadata,
|
type BookMetadata,
|
||||||
type BookSettings,
|
type BookSettings,
|
||||||
type Cover,
|
type Cover,
|
||||||
|
chapterKind,
|
||||||
createChapter,
|
createChapter,
|
||||||
|
createPage,
|
||||||
normalizeBook,
|
normalizeBook,
|
||||||
} from "../model/book";
|
} from "../model/book";
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ interface BookState {
|
|||||||
setChapterContent: (id: string, content: JSONContent) => void;
|
setChapterContent: (id: string, content: JSONContent) => void;
|
||||||
setChapterTitle: (id: string, title: string) => void;
|
setChapterTitle: (id: string, title: string) => void;
|
||||||
addChapter: () => void;
|
addChapter: () => void;
|
||||||
|
addPage: (group: "front" | "back", title: string) => void;
|
||||||
|
deleteChapter: (id: string) => void;
|
||||||
moveChapter: (from: number, to: number) => void;
|
moveChapter: (from: number, to: number) => void;
|
||||||
setMetadata: (patch: Partial<BookMetadata>) => void;
|
setMetadata: (patch: Partial<BookMetadata>) => void;
|
||||||
setSettings: (patch: Partial<BookSettings>) => void;
|
setSettings: (patch: Partial<BookSettings>) => void;
|
||||||
@@ -41,20 +45,43 @@ export const useBook = create<BookState>((set) => ({
|
|||||||
setChapterContent: (id, content) =>
|
setChapterContent: (id, content) =>
|
||||||
set((s) =>
|
set((s) =>
|
||||||
s.book
|
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) =>
|
setChapterTitle: (id, title) =>
|
||||||
set((s) =>
|
set((s) =>
|
||||||
s.book
|
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: () =>
|
addChapter: () =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (!s.book) return {};
|
if (!s.book) return {};
|
||||||
const chapter = createChapter();
|
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) =>
|
moveChapter: (from, to) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
|
|||||||
+213
-16
@@ -193,6 +193,28 @@ button {
|
|||||||
color: var(--accent);
|
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 {
|
.chapter .grip {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -236,6 +258,69 @@ button {
|
|||||||
bottom: -1px;
|
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 {
|
.add-chapter {
|
||||||
margin: 8px 18px 4px;
|
margin: 8px 18px 4px;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
@@ -1021,51 +1106,80 @@ button {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-delete {
|
.card-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: var(--ink-faint);
|
|
||||||
opacity: 0;
|
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;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-delete:hover {
|
|
||||||
background: var(--accent-wash);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-foot {
|
.panel-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
padding: 14px 22px;
|
padding: 14px 22px;
|
||||||
border-top: 1px solid var(--line);
|
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-family: var(--font-ui);
|
||||||
font-size: var(--t-3);
|
font-size: var(--t-3);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 9px 20px;
|
padding: 9px 20px;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
|
transition: background 120ms var(--ease), color 120ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--accent-contrast);
|
color: var(--accent-contrast);
|
||||||
transition: background 120ms var(--ease);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: var(--accent-ink);
|
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 */
|
||||||
.cover-item {
|
.cover-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1300,3 +1414,86 @@ button {
|
|||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
text-align: center;
|
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;
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@
|
|||||||
--selection: rgba(35, 32, 27, 0.14);
|
--selection: rgba(35, 32, 27, 0.14);
|
||||||
--glass: rgba(252, 251, 247, 0.86);
|
--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-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);
|
--shadow-pop: 0 8px 24px rgba(35, 32, 27, 0.14);
|
||||||
}
|
}
|
||||||
@@ -63,6 +68,11 @@
|
|||||||
--selection: rgba(239, 233, 220, 0.18);
|
--selection: rgba(239, 233, 220, 0.18);
|
||||||
--glass: rgba(33, 30, 25, 0.88);
|
--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-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);
|
--shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user