feat(ui): connect shell to store with live preview dock

This commit is contained in:
pj committed 2026-06-20 12:28:44 -04:00
1 parent 3cf07d4ef6
commit 29c412849e
5 files changed
+197 -71

No files matched your search

+34 -71
View File
@@ -1,35 +1,29 @@
import { useState } from "react";
const CHAPTERS = [
{ n: "1", title: "The Lighthouse" },
{ n: "2", title: "Salt and Iron" },
{ n: "3", title: "What the Tide Left" },
{ n: "4", title: "Homecoming" },
];
const BODY = [
"The lamp had not been lit in thirty years, and yet the islanders still set their clocks by a light that no longer came. Mara climbed the iron stair each evening out of a habit older than memory, and each evening she found the same dark glass waiting, patient as the sea.",
"Her grandfather had kept the flame. Her mother had let it die. Between those two facts lay the whole of the family's quarrel with the water, and Mara had inherited both the quarrel and the key that opened the lantern room.",
"She did not light it that night either. She only stood at the gallery rail and counted the boats coming in, the way she had been taught to count them, as though arithmetic alone could keep them safe until morning.",
];
function Icon({ d }: { d: string }) {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
}
import { Sidebar } from "./components/Sidebar";
import { Dock } from "./components/Dock";
import { Icon } from "./components/Icon";
import { Editor } from "./editor/Editor";
import { useBook } from "./store/useBook";
function App() {
const [active, setActive] = useState("1");
const [dock, setDock] = useState(true);
const current = CHAPTERS.find((c) => c.n === active)!;
const title = useBook((s) => s.book.metadata.title);
const author = useBook((s) => s.book.metadata.author);
const chapters = useBook((s) => s.book.chapters);
const activeChapterId = useBook((s) => s.activeChapterId);
const setChapterContent = useBook((s) => s.setChapterContent);
const setChapterTitle = useBook((s) => s.setChapterTitle);
const idx = chapters.findIndex((c) => c.id === activeChapterId);
const chapter = chapters[idx];
return (
<div className="app">
<header className="titlebar" data-tauri-drag-region>
<span className="doc-title">Untitled — a novel</span>
<span className="doc-title">
{title || "Untitled"}
{author ? ` — ${author}` : ""}
</span>
<div className="actions">
<button className="icon-btn" data-on={dock} onClick={() => setDock(!dock)} title="Toggle preview">
<Icon d="M3 4.5h18v15H3zM14 4.5v15" />
@@ -38,58 +32,27 @@ function App() {
</header>
<div className="body">
<aside className="sidebar">
<div className="brand">
<span className="mark">margin</span>
<span className="dot" />
</div>
<div className="nav-label">Chapters</div>
<ul className="chapters">
{CHAPTERS.map((c) => (
<li key={c.n} className="chapter" data-active={c.n === active} onClick={() => setActive(c.n)}>
<span className="num">{c.n}</span>
<span className="title">{c.title}</span>
</li>
))}
</ul>
<button className="add-chapter">
<Icon d="M12 5v14M5 12h14" />
New chapter
</button>
</aside>
<Sidebar />
<main className="editor-pane">
<article className="sheet prose">
<article className="sheet">
<header className="chapter-opener">
<div className="chapter-num">Chapter {current.n}</div>
<h1 className="chapter-title">{current.title}</h1>
<div className="chapter-num">Chapter {idx + 1}</div>
<input
className="chapter-title-input"
value={chapter.title}
placeholder="Chapter title"
spellCheck={false}
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
/>
</header>
{BODY.map((t, i) => (
<p key={i} className={i === 0 ? "first" : undefined}>{t}</p>
))}
<div className="scene-break">⁂</div>
<p>Morning came the color of pewter. The tide had left its usual accounting along the shingle — a torn net, a single boot, the pale architecture of a gull — and Mara walked the length of it without hurry, naming each thing as her grandfather had named them to her.</p>
<Editor
chapterId={chapter.id}
content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)}
/>
</article>
</main>
{dock && (
<section className="dock">
<div className="dock-head">
<span className="label">Preview</span>
<span className="meta">6 × 9 in · 1 of 1</span>
</div>
<div className="page">
<div className="p-opener">
<div className="p-num">Chapter {current.n}</div>
<div className="p-title">{current.title}</div>
</div>
{BODY.map((t, i) => (
<p key={i}>{t}</p>
))}
<div className="folio">7</div>
</div>
</section>
)}
{dock && <Dock />}
</div>
</div>
);
+40
View File
@@ -0,0 +1,40 @@
import { useMemo } from "react";
import { generateHTML } from "@tiptap/core";
import { useBook } from "../store/useBook";
import { editorExtensions } from "../editor/extensions";
const TRIM_LABEL: Record<string, string> = {
"6x9": "6 × 9 in",
"5.5x8.5": "5.5 × 8.5 in",
"5x8": "5 × 8 in",
a5: "A5",
};
export function Dock() {
const chapters = useBook((s) => s.book.chapters);
const trim = useBook((s) => s.book.settings.trim);
const activeChapterId = useBook((s) => s.activeChapterId);
const idx = chapters.findIndex((c) => c.id === activeChapterId);
const chapter = chapters[idx];
const html = useMemo(() => generateHTML(chapter.content, editorExtensions), [chapter.content]);
return (
<section className="dock">
<div className="dock-head">
<span className="label">Preview</span>
<span className="meta">
{TRIM_LABEL[trim]} · {idx + 1} of {chapters.length}
</span>
</div>
<div className="page">
<div className="p-opener">
<div className="p-num">Chapter {idx + 1}</div>
<div className="p-title">{chapter.title || "Untitled"}</div>
</div>
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
<div className="folio">{idx * 8 + 7}</div>
</div>
</section>
);
}
+21
View File
@@ -0,0 +1,21 @@
interface IconProps {
d: string;
size?: number;
}
export function Icon({ d, size = 16 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={d} />
</svg>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useBook } from "../store/useBook";
import { Icon } from "./Icon";
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);
return (
<aside className="sidebar">
<div className="brand">
<span className="mark">margin</span>
<span className="dot" />
</div>
<div className="nav-label">Chapters</div>
<ul className="chapters">
{chapters.map((chapter, i) => (
<li
key={chapter.id}
className="chapter"
data-active={chapter.id === activeChapterId}
onClick={() => setActiveChapter(chapter.id)}
>
<span className="num">{i + 1}</span>
<span className="title">{chapter.title || "Untitled"}</span>
</li>
))}
</ul>
<button className="add-chapter" onClick={addChapter}>
<Icon d="M12 5v14M5 12h14" />
New chapter
</button>
</aside>
);
}
+66
View File
@@ -383,3 +383,69 @@ button {
::-webkit-scrollbar-track {
background: transparent;
}
.chapter-title-input {
width: 100%;
margin: 15px 0 0;
padding: 0;
border: 0;
background: transparent;
text-align: center;
font-family: var(--font-book);
font-weight: 500;
font-size: 31px;
line-height: 1.16;
letter-spacing: -0.01em;
color: var(--ink);
}
.chapter-title-input:focus {
outline: none;
}
.chapter-title-input::placeholder {
color: var(--ink-faint);
}
.editor-host,
.ProseMirror {
outline: none;
}
.ProseMirror {
min-height: 62vh;
}
.ProseMirror > p:first-child {
text-indent: 0;
}
.ProseMirror > p:first-child::first-letter {
float: left;
font-size: 3.1em;
line-height: 0.82;
padding: 0.05em 0.09em 0 0;
font-weight: 500;
}
.ProseMirror hr {
border: none;
border-top: 1px solid var(--line-strong);
width: 56px;
margin: 2rem auto;
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--ink-faint);
float: left;
height: 0;
pointer-events: none;
}
.page-body hr {
border: none;
border-top: 1px solid var(--line-strong);
width: 22px;
margin: 9px auto;
}