Implement CHUNK #2: server layer, core schemas/adapters, contract tests, seed
Complete the server-backed storage layer for PubScript so the app can run against a real Fastify + SQLite backend instead of only local IndexedDB. Server (@pubscript/server): - db.ts: better-sqlite3 schema + migrations (users, shares, projects, books, chapters, entities, boards, ai_sessions, audit); WAL mode; idempotent admin bootstrap via PUBSCRIPT_ADMIN_EMAIL/PASSWORD. - auth.ts: argon2 hashing, JWT-ish session tokens, guards, auth routes. - routes.ts: REST CRUD for projects/books/chapters/entities/boards/ai_sessions with multi-tenant isolation and optimistic concurrency (baseRev). - ws.ts: Fastify websocket hub for autosave + presence events. - mapping.ts: row <-> core type conversion. Core (@pubscript/core): - Zod domain schemas (projects, books, chapters, entities, boards, ai_sessions) exported from index. - storage-local.ts (IndexedDB/Dexie) and storage-server.ts (REST) adapters implementing the StorageAdapter contract. Tests & tooling: - storage.contract.test.ts: parameterized over both adapters; wired as `pnpm contract` and added to CI. - seed.ts (`pnpm --filter server seed`): creates demo project "The Glass Harbor" — 3 chapters of prose, 4 linked characters, 1 world location, 6-node board. - Dockerfile + compose wiring for the server runtime. Acceptance: seed project opens with 3 chapters, 4 linked characters, 1 board.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>PubScript</title>
|
||||
<meta name="description" content="A local-first writing environment for authors." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@pubscript/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 4173",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pubscript/core": "workspace:*",
|
||||
"@pubscript/ui": "workspace:*",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^2.1.8",
|
||||
"@types/node": "^22.10.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { Sidebar, Topbar, CommandPalette } from "./components/index.js";
|
||||
import { ToastViewport } from "@pubscript/ui";
|
||||
import { Write } from "./routes/Write.js";
|
||||
import { Characters } from "./routes/Characters.js";
|
||||
import { Plot } from "./routes/Plot.js";
|
||||
import { Whiteboard } from "./routes/Whiteboard.js";
|
||||
import { AI } from "./routes/AI.js";
|
||||
import { Projects } from "./routes/Projects.js";
|
||||
import { Settings } from "./routes/Settings.js";
|
||||
import { Admin } from "./routes/Admin.js";
|
||||
|
||||
const NAV = [
|
||||
{ to: "/", label: "Write", icon: "✎", end: true },
|
||||
{ to: "/characters", label: "Characters", icon: "👤", end: false },
|
||||
{ to: "/plot", label: "Plot", icon: "🗺", end: false },
|
||||
{ to: "/whiteboard", label: "Whiteboard", icon: "🧩", end: false },
|
||||
{ to: "/ai", label: "AI", icon: "✨", end: false },
|
||||
{ to: "/projects", label: "Projects", icon: "📁", end: false },
|
||||
{ to: "/settings", label: "Settings", icon: "⚙", end: false },
|
||||
{ to: "/admin", label: "Admin", icon: "🛠", end: false },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setPaletteOpen((o) => !o);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="ps-app">
|
||||
<Sidebar nav={NAV} />
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<Topbar onOpenPalette={() => setPaletteOpen(true)} />
|
||||
<main className="ps-main" style={{ flex: 1, overflow: "hidden" }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Write />} />
|
||||
<Route path="/characters" element={<Characters />} />
|
||||
<Route path="/plot" element={<Plot />} />
|
||||
<Route path="/whiteboard" element={<Whiteboard />} />
|
||||
<Route path="/ai" element={<AI />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="*" element={<Write />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
|
||||
<ToastViewport />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { NavItem } from "./Sidebar.js";
|
||||
|
||||
export interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
nav: NavItem[];
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onClose, nav }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const commands = useMemo(
|
||||
() =>
|
||||
nav.map((n) => ({
|
||||
id: n.to,
|
||||
label: n.label,
|
||||
icon: n.icon,
|
||||
path: n.to,
|
||||
kbd: "",
|
||||
})),
|
||||
[nav],
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
query
|
||||
? commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase()))
|
||||
: commands,
|
||||
[commands, query],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(a + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(a - 1, 0));
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, filtered, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const run = (id: string) => {
|
||||
navigate(id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-command-palette-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="ps-command-palette"
|
||||
role="dialog"
|
||||
aria-label="Command palette"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="ps-command-input"
|
||||
placeholder="Type a command or search…"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
/>
|
||||
<div className="ps-command-list">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="ps-command-empty">No matches</div>
|
||||
) : (
|
||||
filtered.map((c, i) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`ps-command-item${i === active ? " is-active" : ""}`}
|
||||
onClick={() => run(c.id)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
>
|
||||
<span className="ps-command-icon">{c.icon}</span>
|
||||
<span style={{ flex: 1, textAlign: "left" }}>{c.label}</span>
|
||||
<span className="ps-command-kbd">{c.path}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useSettings, type Density } from "../store/settings.js";
|
||||
import { Select, Button, toast } from "@pubscript/ui";
|
||||
import { THEME_IDS, THEME_LABELS } from "@pubscript/ui";
|
||||
|
||||
export function Settings() {
|
||||
const s = useSettings();
|
||||
const set = useSettings((st) => st.set);
|
||||
const reset = useSettings((st) => st.reset);
|
||||
|
||||
return (
|
||||
<div className="ps-content">
|
||||
<div className="ps-settings-group">
|
||||
<h3>Appearance</h3>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Theme</div>
|
||||
<div className="ps-settings-desc">Four themes ship out of the box.</div>
|
||||
</div>
|
||||
<div className="ps-swatches">
|
||||
{THEME_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
className={`ps-swatch${s.theme === id ? " is-active" : ""}`}
|
||||
style={{ background: `var(--ps-${id === "ink" ? "accent" : "bg"})` }}
|
||||
title={THEME_LABELS[id]}
|
||||
onClick={() => set({ theme: id })}
|
||||
>
|
||||
{s.theme === id && <span style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", color: "var(--ps-accent-contrast)" }}>✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Density</div>
|
||||
<div className="ps-settings-desc">Spacing between UI elements.</div>
|
||||
</div>
|
||||
<Select
|
||||
value={s.density}
|
||||
onChange={(e) => set({ density: e.target.value as Density })}
|
||||
options={[
|
||||
{ value: "compact", label: "Compact" },
|
||||
{ value: "comfortable", label: "Comfortable" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Font size</div>
|
||||
<div className="ps-settings-desc">Base UI font size in pixels.</div>
|
||||
</div>
|
||||
<Select
|
||||
value={String(s.fontSize)}
|
||||
onChange={(e) => set({ fontSize: Number(e.target.value) })}
|
||||
options={[13, 14, 15, 16, 17, 18].map((n) => ({ value: String(n), label: `${n}px` }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-group">
|
||||
<h3>Writing</h3>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Word count goal</div>
|
||||
<div className="ps-settings-desc">Target length for your manuscript.</div>
|
||||
</div>
|
||||
<Select
|
||||
value={String(s.wordCountGoal)}
|
||||
onChange={(e) => set({ wordCountGoal: Number(e.target.value) })}
|
||||
options={[20_000, 40_000, 60_000, 80_000, 100_000].map((n) => ({ value: String(n), label: `${(n / 1000).toFixed(0)}k` }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Show line numbers</div>
|
||||
<div className="ps-settings-desc">Display line numbers in the editor gutter.</div>
|
||||
</div>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.showLineNumbers}
|
||||
onChange={(e) => set({ showLineNumbers: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Auto-save</div>
|
||||
<div className="ps-settings-desc">Persist changes automatically.</div>
|
||||
</div>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.autoSave}
|
||||
onChange={(e) => set({ autoSave: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Reset to defaults</div>
|
||||
</div>
|
||||
<Button variant="danger" size="sm" onClick={() => { reset(); toast.info("Settings reset"); }}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
export interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
nav: NavItem[];
|
||||
}
|
||||
|
||||
export function Sidebar({ nav }: SidebarProps) {
|
||||
return (
|
||||
<aside className="ps-sidebar">
|
||||
<div className="ps-logo">
|
||||
<span className="ps-logo-mark">✒</span> PubScript
|
||||
</div>
|
||||
|
||||
<div className="ps-nav-section-title">Project</div>
|
||||
<button className="ps-nav-item">
|
||||
<span className="ps-nav-icon">📖</span>
|
||||
<span>
|
||||
<div>The Silent Cartographer</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.6 }}>12 chapters · 47k words</div>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="ps-nav-section-title" style={{ marginTop: 16 }}>Navigate</div>
|
||||
{nav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end ?? false}
|
||||
className={({ isActive }) => `ps-nav-item${isActive ? " is-active" : ""}`}
|
||||
>
|
||||
<span className="ps-nav-icon">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useLocation, NavLink } from "react-router-dom";
|
||||
import { useSettings } from "../store/settings.js";
|
||||
import { Select, Badge } from "@pubscript/ui";
|
||||
import { THEME_IDS, THEME_LABELS } from "@pubscript/ui";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
"/": "Write",
|
||||
"/characters": "Characters",
|
||||
"/plot": "Plot",
|
||||
"/whiteboard": "Whiteboard",
|
||||
"/ai": "AI",
|
||||
"/projects": "Projects",
|
||||
"/settings": "Settings",
|
||||
"/admin": "Admin",
|
||||
};
|
||||
|
||||
export interface TopbarProps {
|
||||
onOpenPalette: () => void;
|
||||
}
|
||||
|
||||
export function Topbar({ onOpenPalette }: TopbarProps) {
|
||||
const location = useLocation();
|
||||
const theme = useSettings((s) => s.theme);
|
||||
const setTheme = useSettings((s) => s.setTheme);
|
||||
const autoSave = useSettings((s) => s.autoSave);
|
||||
|
||||
const title = TITLES[location.pathname] ?? "PubScript";
|
||||
|
||||
return (
|
||||
<header className="ps-topbar">
|
||||
<h1 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>{title}</h1>
|
||||
|
||||
{autoSave && <Badge variant="success">✓ autosave on</Badge>}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<button
|
||||
className="ps-nav-item"
|
||||
onClick={onOpenPalette}
|
||||
style={{ width: "auto", padding: "6px 10px" }}
|
||||
>
|
||||
<span>Search</span>
|
||||
<span className="ps-command-kbd" style={{ marginLeft: 8 }}>⌘K</span>
|
||||
</button>
|
||||
|
||||
<Select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as never)}
|
||||
aria-label="Theme"
|
||||
options={THEME_IDS.map((id) => ({ value: id, label: THEME_LABELS[id] }))}
|
||||
/>
|
||||
|
||||
<NavLink to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings">
|
||||
⚙
|
||||
</NavLink>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { Sidebar } from "./Sidebar.js";
|
||||
export type { SidebarProps, NavItem } from "./Sidebar.js";
|
||||
export { Topbar } from "./Topbar.js";
|
||||
export type { TopbarProps } from "./Topbar.js";
|
||||
export { CommandPalette } from "./CommandPalette.js";
|
||||
export type { CommandPaletteProps } from "./CommandPalette.js";
|
||||
export { Settings } from "./Settings.js";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.js";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Button, Input, toast } from "@pubscript/ui";
|
||||
|
||||
export function AI() {
|
||||
return (
|
||||
<div className="ps-content">
|
||||
<h1 style={{ margin: "0 0 16px", fontSize: 24 }}>AI Assistant</h1>
|
||||
<p className="ps-settings-desc" style={{ marginBottom: 20 }}>
|
||||
Local-first AI: prompts run against your configured provider. Nothing leaves your machine unless you allow it.
|
||||
</p>
|
||||
|
||||
<Input
|
||||
label="Prompt"
|
||||
placeholder="Summarize Mara's arc so far…"
|
||||
style={{ width: "100%", marginBottom: 12 }}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<Button variant="primary" onClick={() => toast.success("Prompt queued locally.")}>Run locally</Button>
|
||||
<Button variant="secondary">Refine character</Button>
|
||||
<Button variant="secondary">Outline next chapter</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24, padding: 16, background: "var(--ps-surface)", border: "1px solid var(--ps-border)", borderRadius: 8 }}>
|
||||
<h3 style={{ marginTop: 0, fontSize: 14 }}>Privacy</h3>
|
||||
<ul style={{ margin: 0, paddingLeft: 18, lineHeight: 1.8, fontSize: 14 }}>
|
||||
<li>Documents are never uploaded without explicit consent.</li>
|
||||
<li>Context is chunked to stay within the token budget.</li>
|
||||
<li>Session history is stored locally and deletable.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Badge, Button } from "@pubscript/ui";
|
||||
|
||||
export function Admin() {
|
||||
return (
|
||||
<div className="ps-content">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 16 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 24 }}>Admin</h1>
|
||||
<Badge variant="danger">Beta</Badge>
|
||||
</div>
|
||||
<p className="ps-settings-desc" style={{ marginBottom: 20 }}>
|
||||
Local administration: manage the device token, inspect the store, and reset persisted state.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Device token</div>
|
||||
<div className="ps-settings-desc">Unique per-device identifier for the storage adapter.</div>
|
||||
</div>
|
||||
<Button variant="secondary">Regenerate</Button>
|
||||
</div>
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Store diagnostics</div>
|
||||
<div className="ps-settings-desc">Inspect the active storage backend and its health.</div>
|
||||
</div>
|
||||
<Button variant="secondary">Run</Button>
|
||||
</div>
|
||||
<div className="ps-settings-row">
|
||||
<div>
|
||||
<div className="ps-settings-label">Clear local data</div>
|
||||
<div className="ps-settings-desc">Delete all cached documents and settings.</div>
|
||||
</div>
|
||||
<Button variant="danger">Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Tabs, Badge, Button, EmptyState } from "@pubscript/ui";
|
||||
|
||||
const CHARACTERS = [
|
||||
{ id: "c1", name: "Mara Vell", role: "Protagonist", kind: "Human" },
|
||||
{ id: "c2", name: "Old Corvin", role: "Mentor", kind: "Human" },
|
||||
{ id: "c3", name: "The Cartographer", role: "Antagonist", kind: "Unknown" },
|
||||
];
|
||||
|
||||
export function Characters() {
|
||||
return (
|
||||
<div className="ps-content">
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 24 }}>Characters</h1>
|
||||
<Button variant="primary">+ New character</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultActive="characters"
|
||||
tabs={[
|
||||
{
|
||||
id: "characters",
|
||||
label: "All",
|
||||
badge: CHARACTERS.length,
|
||||
content: (
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
{CHARACTERS.map((c) => (
|
||||
<div key={c.id} className="ps-settings-row" style={{ cursor: "pointer" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{c.name}</div>
|
||||
<div className="ps-settings-desc">{c.role}</div>
|
||||
</div>
|
||||
<Badge variant="accent">{c.kind}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "locations",
|
||||
label: "Locations",
|
||||
content: <EmptyState icon="📍" title="Locations" description="Locations appear here once added to a chapter." />,
|
||||
},
|
||||
{
|
||||
id: "factions",
|
||||
label: "Factions",
|
||||
content: <EmptyState icon="🏳️" title="No factions yet" description="Group characters into factions to track loyalties." />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Badge } from "@pubscript/ui";
|
||||
|
||||
const ACTS = [
|
||||
{ act: "Act I — Setup", beats: ["The world as it is", "The inciting incident", "Crossing the threshold"] },
|
||||
{ act: "Act II — Confrontation", beats: ["Rising action", "The midpoint reversal", "All is lost"] },
|
||||
{ act: "Act III — Resolution", beats: ["The climax", "The new equilibrium"] },
|
||||
];
|
||||
|
||||
export function Plot() {
|
||||
return (
|
||||
<div className="ps-content">
|
||||
<h1 style={{ margin: "0 0 16px", fontSize: 24 }}>Plot</h1>
|
||||
{ACTS.map((a) => (
|
||||
<section key={a.act} style={{ marginBottom: 28 }}>
|
||||
<h2 style={{ fontSize: 18, display: "flex", alignItems: "center", gap: 10 }}>
|
||||
{a.act} <Badge variant="neutral">{String(a.beats.length)}</Badge>
|
||||
</h2>
|
||||
<ul style={{ fontFamily: "var(--ps-font-body)", lineHeight: 2, margin: 0, paddingLeft: 20 }}>
|
||||
{a.beats.map((b) => (
|
||||
<li key={b}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Badge, Button, EmptyState } from "@pubscript/ui";
|
||||
|
||||
const PROJECTS = [
|
||||
{ id: "p1", title: "The Silent Cartographer", words: 47_200, status: "Draft" },
|
||||
{ id: "p2", title: "Anthology of Tides", words: 12_400, status: "Planning" },
|
||||
];
|
||||
|
||||
export function Projects() {
|
||||
return (
|
||||
<div className="ps-content">
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 24 }}>Projects</h1>
|
||||
<Button variant="primary">+ New project</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
{PROJECTS.map((p) => (
|
||||
<div key={p.id} className="ps-settings-row" style={{ cursor: "pointer" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 16 }}>{p.title}</div>
|
||||
<div className="ps-settings-desc">{p.words.toLocaleString()} words</div>
|
||||
</div>
|
||||
<Badge variant="accent">{p.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
icon="📚"
|
||||
title="No more projects"
|
||||
description="Start a new manuscript or open an existing one."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Settings as SettingsPanel } from "../components/Settings.js";
|
||||
|
||||
export function Settings() {
|
||||
return <SettingsPanel />;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Button } from "@pubscript/ui";
|
||||
|
||||
const CARDS = [
|
||||
{ id: "w1", title: "The lost map", note: "Mara inherits a map with no destination.", x: 0 },
|
||||
{ id: "w2", title: "The harbor", note: "Where the story begins.", x: 240 },
|
||||
{ id: "w3", title: "The Cartographer", note: "Knows more than they say.", x: 480 },
|
||||
];
|
||||
|
||||
export function Whiteboard() {
|
||||
return (
|
||||
<div className="ps-content">
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 24 }}>Whiteboard</h1>
|
||||
<Button variant="primary">+ Add card</Button>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16, overflowX: "auto", paddingBottom: 12 }}>
|
||||
{CARDS.map((c) => (
|
||||
<div key={c.id} className="ps-modal" style={{ width: 220, height: 160, flexShrink: 0, cursor: "grab", position: "relative", transform: "none", top: "auto", left: "auto", boxShadow: "0 4px 16px rgba(0,0,0,0.2)" }}>
|
||||
<div className="ps-modal-body" style={{ padding: 14 }}>
|
||||
<h3 style={{ margin: "0 0 6px", fontSize: 15 }}>{c.title}</h3>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)" }}>{c.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSettings } from "../store/settings.js";
|
||||
|
||||
export function Write() {
|
||||
const wordCountGoal = useSettings((s) => s.wordCountGoal);
|
||||
const showLineNumbers = useSettings((s) => s.showLineNumbers);
|
||||
|
||||
const content = `# Chapter One\n\nThe harbor mist clung to the docks like a held breath. Mara set down her satchel and watched the gulls wheel against the grey sky…\n\n(This is a placeholder for the ProseMirror editor. CHUNK #1 establishes the shell; the rich-text editor lands in a later chunk.)`;
|
||||
|
||||
const lines = useMemo(() => content.split("\n"), [content]);
|
||||
const words = useMemo(() => content.trim().split(/\s+/).filter(Boolean).length, [content]);
|
||||
const pct = Math.min(100, Math.round((words / wordCountGoal) * 100));
|
||||
|
||||
return (
|
||||
<div className="ps-content">
|
||||
<input
|
||||
type="text"
|
||||
defaultValue="Chapter One"
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "var(--ps-text)",
|
||||
marginBottom: 16,
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
|
||||
{showLineNumbers && (
|
||||
<div style={{ display: "flex", textAlign: "right", color: "var(--ps-text-muted)", userSelect: "none", paddingRight: 8, marginBottom: 4 }}>
|
||||
{lines.map((_, i) => (
|
||||
<span key={i} style={{ fontFamily: "var(--ps-font-mono)", fontSize: 12 }}>{i + 1}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
defaultValue={content}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 480,
|
||||
resize: "vertical",
|
||||
background: "var(--ps-surface)",
|
||||
color: "var(--ps-text)",
|
||||
border: "1px solid var(--ps-border)",
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
fontFamily: "var(--ps-font-body)",
|
||||
fontSize: 17,
|
||||
lineHeight: 1.75,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 16, fontSize: 13, color: "var(--ps-text-muted)" }}>
|
||||
<span>{words.toLocaleString()} words</span>
|
||||
<div style={{ flex: 1, height: 6, background: "var(--ps-surface-2)", borderRadius: 3, overflow: "hidden" }}>
|
||||
<div style={{ width: `${pct}%`, height: "100%", background: "var(--ps-accent)", transition: "width 0.3s" }} />
|
||||
</div>
|
||||
<span>{pct}% of {wordCountGoal.toLocaleString()} goal</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { create } from "zustand";
|
||||
import { applyTheme, type ThemeId } from "@pubscript/ui";
|
||||
|
||||
export type Density = "compact" | "comfortable";
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeId;
|
||||
accentColor: string;
|
||||
density: Density;
|
||||
fontSize: number;
|
||||
showLineNumbers: boolean;
|
||||
wordCountGoal: number;
|
||||
autoSave: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
theme: "ink",
|
||||
accentColor: "#7c8bf5",
|
||||
density: "comfortable",
|
||||
fontSize: 15,
|
||||
showLineNumbers: true,
|
||||
wordCountGoal: 80_000,
|
||||
autoSave: true,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "pubscript.settings.v1";
|
||||
|
||||
function loadSettings(): AppSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||
const parsed = JSON.parse(raw) as Partial<AppSettings>;
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsStore extends AppSettings {
|
||||
set: (patch: Partial<AppSettings>) => void;
|
||||
setTheme: (theme: ThemeId) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useSettings = create<SettingsStore>((set, get) => {
|
||||
const initial = loadSettings();
|
||||
// Apply the persisted theme on boot.
|
||||
applyTheme(initial.theme);
|
||||
|
||||
return {
|
||||
...initial,
|
||||
set: (patch) => {
|
||||
const next = { ...get(), ...patch };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
set(next);
|
||||
},
|
||||
setTheme: (theme) => {
|
||||
applyTheme(theme);
|
||||
get().set({ theme });
|
||||
},
|
||||
reset: () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
applyTheme(DEFAULT_SETTINGS.theme);
|
||||
set({ ...DEFAULT_SETTINGS });
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
strictPort: false,
|
||||
},
|
||||
preview: {
|
||||
host: "0.0.0.0",
|
||||
port: 4173,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user