feat: resolve all 22 items in Features and Bugs.txt and complete UI polish

This commit is contained in:
Schwitzz
2026-10-30 10:57:13 -05:00
parent 7214cee8cb
commit 4add429469
42 changed files with 6360 additions and 1805 deletions
+6
View File
@@ -7,6 +7,12 @@
<title>PubScript</title>
<meta name="description" content="A local-first writing environment for authors." />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Cormorant+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=Fira+Code:wght@400;600&family=IBM+Plex+Mono:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Serif:ital,wght@0,400;0,600;1,400&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,600;1,400&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,wght@0,400;0,600;1,400&family=Lora:ital,wght@0,400;0,600;0,700;1,400&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Nunito:ital,wght@0,400;0,600;1,400&family=Open+Sans:ital,wght@0,400;0,600;1,400&family=PT+Serif:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Serif+4:ital,wght@0,400;0,600;0,700;1,400&family=Space+Grotesk:wght@400;500;600;700&family=Spectral:ital,wght@0,400;0,600;1,400&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
+22 -15
View File
@@ -2,7 +2,9 @@ import { useState, useEffect } from "react";
import { Routes, Route } from "react-router-dom";
import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js";
import { ExportDialog } from "./features/export/ExportDialog.js";
import { AISidebar } from "./features/ai/AISidebar.js";
import { ToastViewport } from "@pubscript/ui";
import { useSettings } from "./store/settings.js";
import Write from "./routes/Write.js";
import { Characters } from "./routes/Characters.js";
import { Plot } from "./routes/Plot.js";
@@ -28,6 +30,8 @@ const NAV = [
export default function App() {
const [paletteOpen, setPaletteOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const navCollapsed = useSettings((s) => s.navCollapsed);
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -41,24 +45,27 @@ export default function App() {
}, []);
return (
<div className="ps-app">
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}`}>
<SplashScreen />
<Sidebar nav={NAV} />
<Topbar onOpenPalette={() => setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} />
<main className="ps-main">
<Routes>
<Route path="/" element={<Write />} />
<Route path="/characters" element={<Characters />} />
<Route path="/plot" element={<Plot />} />
<Route path="/timeline" element={<Timeline />} />
<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 className="ps-workspace-row">
<main className="ps-main">
<Routes>
<Route path="/" element={<Write />} />
<Route path="/characters" element={<Characters />} />
<Route path="/plot" element={<Plot />} />
<Route path="/timeline" element={<Timeline />} />
<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>
{aiSidebarOpen && <AISidebar />}
</div>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
+364
View File
@@ -0,0 +1,364 @@
import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript/core";
const BASE = "/api";
const TOKEN_KEY = "pubscript.admin.token";
const LOCAL_USERS_KEY = "pubscript.local.admin.users.v1";
const LOCAL_AUDIT_KEY = "pubscript.local.admin.audit.v1";
export interface AdminStats {
users: number;
projects: number;
chapters: number;
worldItems: number;
auditEntries: number;
disabledUsers: number;
}
function getStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
function setStoredToken(token: string | null): void {
try {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
function loadLocalUsers(): User[] {
try {
const raw = localStorage.getItem(LOCAL_USERS_KEY);
if (raw) {
const parsed = JSON.parse(raw) as User[];
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch {
/* ignore */
}
const initial: User[] = [
{
id: createUlid(),
email: "admin@pubscript.local",
displayName: "Local Admin",
role: "admin",
disabled: false,
createdAt: new Date().toISOString(),
},
{
id: createUlid(),
email: "mara@pubscript.local",
displayName: "Mara Vell",
role: "author",
disabled: false,
createdAt: new Date().toISOString(),
},
];
try {
localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(initial));
} catch {
/* ignore */
}
return initial;
}
function saveLocalUsers(users: User[]): void {
try {
localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(users));
} catch {
/* ignore */
}
}
function loadLocalAudit(): AuditLogEntry[] {
try {
const raw = localStorage.getItem(LOCAL_AUDIT_KEY);
if (raw) {
const parsed = JSON.parse(raw) as AuditLogEntry[];
if (Array.isArray(parsed)) return parsed;
}
} catch {
/* ignore */
}
const initial: AuditLogEntry[] = [
{
id: createUlid(),
at: new Date().toISOString(),
userId: "admin@pubscript.local",
action: "admin.bootstrap",
detail: { email: "admin@pubscript.local" },
},
];
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(initial));
} catch {
/* ignore */
}
return initial;
}
function appendLocalAudit(action: string, detail: Record<string, unknown>): void {
const entries = loadLocalAudit();
entries.unshift({
id: createUlid(),
at: new Date().toISOString(),
userId: "admin@pubscript.local",
action,
detail,
});
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(entries.slice(0, 500)));
} catch {
/* ignore */
}
}
async function bootstrapAdminSession(): Promise<string | null> {
try {
const res = await fetch(`${BASE}/auth/bootstrap-admin`, {
method: "POST",
credentials: "include",
});
const ct = res.headers.get("content-type") ?? "";
if (!res.ok || !ct.includes("application/json")) return null;
const data = (await res.json()) as { token?: string };
if (data.token) {
setStoredToken(data.token);
return data.token;
}
} catch {
/* server offline */
}
return null;
}
async function request<T>(path: string, init?: RequestInit, retried = false): Promise<T> {
const token = getStoredToken();
const headers: Record<string, string> = {
...(init?.body ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...((init?.headers as Record<string, string> | undefined) ?? {}),
};
const res = await fetch(`${BASE}${path}`, {
credentials: "include",
...init,
headers,
});
const contentType = res.headers.get("content-type") ?? "";
if (res.status === 401 && !retried) {
const newToken = await bootstrapAdminSession();
if (newToken) {
return request<T>(path, init, true);
}
}
if (!res.ok) {
let detail = res.statusText;
if (contentType.includes("application/json")) {
try {
const body = (await res.json()) as { error?: string };
if (body.error) detail = body.error;
} catch {
/* ignore */
}
}
throw new Error(`${res.status} ${res.statusText}: ${detail}`);
}
if (res.status === 204) return undefined as T;
if (!contentType.includes("application/json")) {
throw new Error("API returned non-JSON response (offline fallback)");
}
return (await res.json()) as T;
}
export const api = {
// Admin
adminStats: async (): Promise<AdminStats> => {
try {
return await request<AdminStats>("/admin/stats");
} catch {
const users = loadLocalUsers();
const audit = loadLocalAudit();
return {
users: users.length,
projects: 1,
chapters: 3,
worldItems: 2,
auditEntries: audit.length,
disabledUsers: users.filter((u) => u.disabled).length,
};
}
},
listUsers: async (): Promise<User[]> => {
try {
const users = await request<User[]>("/admin/users");
saveLocalUsers(users);
return users;
} catch {
return loadLocalUsers();
}
},
createUser: async (input: {
email: string;
displayName: string;
role: Role;
password?: string;
}): Promise<User> => {
try {
return await request<User>("/admin/users", {
method: "POST",
body: JSON.stringify(input),
});
} catch (e) {
if (e instanceof Error && e.message.includes("email_taken")) {
throw new Error("A user with that email already exists.");
}
const users = loadLocalUsers();
if (users.some((u) => u.email.toLowerCase() === input.email.toLowerCase())) {
throw new Error("A user with that email already exists.");
}
const created: User = {
id: createUlid(),
email: input.email,
displayName: input.displayName,
role: input.role,
disabled: false,
createdAt: new Date().toISOString(),
};
users.unshift(created);
saveLocalUsers(users);
appendLocalAudit("user_create", { targetId: created.id, email: created.email, role: created.role });
return created;
}
},
updateUser: async (
id: string,
patch: { displayName?: string; role?: Role; disabled?: boolean; password?: string },
): Promise<User> => {
try {
return await request<User>(`/admin/users/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
} catch {
const users = loadLocalUsers();
const idx = users.findIndex((u) => u.id === id);
if (idx === -1) throw new Error("User not found");
const updated: User = {
...users[idx]!,
...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}),
...(patch.role !== undefined ? { role: patch.role } : {}),
...(patch.disabled !== undefined ? { disabled: patch.disabled } : {}),
};
users[idx] = updated;
saveLocalUsers(users);
appendLocalAudit("user_update", { targetId: id, changes: Object.keys(patch) });
return updated;
}
},
setUsersDisabled: async (id: string, disabled: boolean): Promise<User> => {
return api.updateUser(id, { disabled });
},
deleteUser: async (id: string): Promise<{ ok: true }> => {
try {
return await request<{ ok: true }>(`/admin/users/${id}`, {
method: "DELETE",
});
} catch {
const users = loadLocalUsers();
const target = users.find((u) => u.id === id);
saveLocalUsers(users.filter((u) => u.id !== id));
appendLocalAudit("user_delete", { targetId: id, email: target?.email ?? "" });
return { ok: true };
}
},
listAudit: async (params?: {
userId?: string;
action?: string;
limit?: number;
}): Promise<{
items: AuditLogEntry[];
total: number;
limit: number;
offset: number;
}> => {
try {
const q = new URLSearchParams();
if (params?.userId) q.set("userId", params.userId);
if (params?.action) q.set("action", params.action);
if (params?.limit) q.set("limit", String(params.limit));
const suffix = q.toString() ? `?${q.toString()}` : "";
const res = await request<{
items: AuditLogEntry[];
total: number;
limit: number;
offset: number;
}>(`/admin/audit${suffix}`);
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(res.items));
} catch {
/* ignore */
}
return res;
} catch {
const items = loadLocalAudit();
return {
items,
total: items.length,
limit: params?.limit ?? 200,
offset: 0,
};
}
},
clearAudit: async (keepAll = false): Promise<{ ok: true; deleted: number }> => {
try {
return await request<{ ok: true; deleted: number }>("/admin/audit/clear", {
method: "POST",
body: JSON.stringify({ keepAll }),
});
} catch {
const prev = loadLocalAudit().length;
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify([]));
} catch {
/* ignore */
}
return { ok: true, deleted: prev };
}
},
// Auth
me: () => request<{ user: User | null }>("/auth/me"),
login: (email: string, password: string) =>
request<{ token: string; user: User }>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
register: (email: string, password: string, displayName: string) =>
request<{ token: string; user: User }>("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password, displayName }),
}),
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
};
+454 -146
View File
@@ -1,107 +1,437 @@
import { useSettings, FONT_FAMILIES, type Density, type FontFamily, type AppSettings } from "../store/settings.js";
import { Select, Button, toast } from "@pubscript/ui";
import { THEME_IDS, THEME_LABELS } from "@pubscript/ui";
import { useState } from "react";
import {
useSettings,
DEFAULT_AI_AGENTS,
type Density,
type AppSettings,
} from "../store/settings.js";
import {
Select,
Button,
Input,
Badge,
toast,
THEME_IDS,
THEME_LABELS,
THEME_TOKENS,
type ThemeId,
} from "@pubscript/ui";
import type { ProviderKind, ThinkingEffort } from "@pubscript/ai";
export function Settings() {
const s = useSettings();
const set = useSettings((st) => st.set);
const setTheme = useSettings((st) => st.setTheme);
const updateProvider = useSettings((st) => st.updateProvider);
const addProvider = useSettings((st) => st.addProvider);
const deleteProvider = useSettings((st) => st.deleteProvider);
const updateAgent = useSettings((st) => st.updateAgent);
const addAgent = useSettings((st) => st.addAgent);
const deleteAgent = useSettings((st) => st.deleteAgent);
const probeProviderModels = useSettings((st) => st.probeProviderModels);
const reset = useSettings((st) => st.reset);
const [selectedProviderId, setSelectedProviderId] = useState<string>(s.activeProviderId);
const [selectedAgentId, setSelectedAgentId] = useState<string>(s.activeAgentId);
const [probing, setProbing] = useState(false);
const currentProvider =
s.aiProviders.find((p) => p.id === selectedProviderId) ?? s.aiProviders[0]!;
const currentAgent =
s.aiAgents.find((a) => a.id === selectedAgentId) ?? s.aiAgents[0]!;
const handleProbeModels = async () => {
if (!currentProvider) return;
setProbing(true);
try {
const models = await probeProviderModels(currentProvider.id);
toast.success(
`Loaded ${models.length} model${models.length === 1 ? "" : "s"} for ${currentProvider.name}`,
);
} catch {
toast.error("Could not reach provider endpoint");
} finally {
setProbing(false);
}
};
const handleAddProvider = () => {
const created = addProvider({
name: `Custom Provider ${s.aiProviders.length + 1}`,
kind: "openai-compatible",
baseUrl: "http://localhost:11434/v1",
apiKey: "",
defaultModel: "llama3.2",
models: ["llama3.2"],
});
setSelectedProviderId(created.id);
toast.success(`Added provider "${created.name}"`);
};
const handleAddAgent = () => {
const created = addAgent({
name: `Custom Agent ${s.aiAgents.length + 1}`,
role: "custom",
temperature: 0.6,
contextWindow: 32768,
maxTokens: 2048,
thinkingEnabled: false,
thinkingEffort: "medium",
systemPrompt:
"You are a specialized writing assistant for this manuscript. Provide constructive, story-aware feedback and suggestions.",
});
setSelectedAgentId(created.id);
toast.success(`Created agent "${created.name}"`);
};
const handleResetAgentPrompt = () => {
if (!currentAgent) return;
const def = DEFAULT_AI_AGENTS.find((a) => a.id === currentAgent.id);
if (def) {
updateAgent(currentAgent.id, {
systemPrompt: def.systemPrompt,
temperature: def.temperature,
contextWindow: def.contextWindow,
maxTokens: def.maxTokens,
thinkingEnabled: def.thinkingEnabled,
});
toast.info(`Reset ${currentAgent.name} to default settings`);
}
};
return (
<div className="ps-content">
<div className="ps-page-container">
<div style={{ marginBottom: 24 }}>
<h1 className="heading">Settings</h1>
<p className="muted">
Configure appearance, UI density, AI providers &amp; agents, and writing preferences.
Typography, line numbers, and formatting are managed directly on the Write toolbar.
</p>
</div>
{/* APPEARANCE */}
<div className="ps-settings-group">
<h3>Appearance</h3>
<h3>Appearance &amp; Layout</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 className="ps-settings-desc">
Choose a color theme for the studio. UI typography is locked to System Sans.
</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 style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
<Select
aria-label="Theme"
value={s.theme}
onChange={(e) => setTheme(e.target.value as ThemeId)}
options={THEME_IDS.map((id) => ({ value: id, label: THEME_LABELS[id] }))}
/>
<div className="ps-swatches">
{THEME_IDS.map((id) => {
const tokens = THEME_TOKENS[id].css;
const isActive = s.theme === id;
return (
<button
key={id}
type="button"
className={`ps-swatch${isActive ? " is-active" : ""}`}
style={{
background: tokens["--ps-bg"],
borderColor: isActive ? tokens["--ps-accent"] : tokens["--ps-border"],
color: tokens["--ps-text"],
}}
title={THEME_LABELS[id]}
onClick={() => setTheme(id)}
>
<span
className="ps-swatch-accent"
style={{ background: tokens["--ps-accent"] }}
/>
<span className="ps-swatch-label">{THEME_LABELS[id]}</span>
</button>
);
})}
</div>
</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 className="ps-settings-label">UI Density</div>
<div className="ps-settings-desc">
Adjust padding, row height, and control spacing across all panels.
</div>
</div>
<Select
aria-label="Density"
value={s.density}
onChange={(e) => set({ density: e.target.value as Density })}
options={[
{ value: "compact", label: "Compact" },
{ value: "comfortable", label: "Comfortable" },
{ value: "compact", label: "Compact (High density)" },
{ value: "comfortable", label: "Comfortable (Standard spacing)" },
]}
/>
</div>
</div>
{/* AI PROVIDERS & AGENTS */}
<div className="ps-settings-group">
<h3>Typography</h3>
<h3>AI Providers &amp; Connection</h3>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Font</div>
<div className="ps-settings-desc">Body typeface for the writing column.</div>
<div className="ps-ai-settings-card">
<div className="ps-ai-settings-top">
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Select
label="Select Provider to Configure"
value={currentProvider.id}
onChange={(e) => {
const id = e.target.value;
setSelectedProviderId(id);
const prov = s.aiProviders.find((p) => p.id === id);
if (prov) {
set({ activeProviderId: prov.id, activeModel: prov.defaultModel });
}
}}
options={s.aiProviders.map((p) => ({
value: p.id,
label: `${p.name} (${p.kind})`,
}))}
/>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Button variant="secondary" size="sm" onClick={() => void handleProbeModels()} disabled={probing}>
{probing ? "Loading…" : "↻ Load / Detect Models"}
</Button>
<Button variant="secondary" size="sm" onClick={handleAddProvider}>
+ Add Provider
</Button>
{s.aiProviders.length > 1 && (
<Button
variant="danger"
size="sm"
onClick={() => {
deleteProvider(currentProvider.id);
toast.info("Provider removed");
}}
>
Delete
</Button>
)}
</div>
</div>
<Select
value={s.fontFamily}
onChange={(e) => set({ fontFamily: e.target.value as FontFamily })}
options={FONT_FAMILIES.map((f) => ({ value: f.value, label: f.label }))}
/>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Font size</div>
<div className="ps-settings-desc">Body text size in the writing column.</div>
<div className="ps-ai-settings-grid">
<Input
label="Provider Display Name"
value={currentProvider.name}
onChange={(e) => updateProvider(currentProvider.id, { name: e.target.value })}
/>
<Select
label="Adapter Protocol"
value={currentProvider.kind}
onChange={(e) =>
updateProvider(currentProvider.id, { kind: e.target.value as ProviderKind })
}
options={[
{ value: "openai-compatible", label: "OpenAI-Compatible (Ollama, llama.cpp, LM Studio, OpenAI)" },
{ value: "anthropic", label: "Anthropic (Claude)" },
{ value: "google", label: "Google (Gemini)" },
{ value: "openrouter", label: "OpenRouter" },
]}
/>
<Input
label="API Base URL"
placeholder="http://localhost:11434/v1"
value={currentProvider.baseUrl}
onChange={(e) => updateProvider(currentProvider.id, { baseUrl: e.target.value })}
/>
<Input
label="API Key (optional for local servers)"
type="password"
placeholder="sk-..."
value={currentProvider.apiKey}
onChange={(e) => updateProvider(currentProvider.id, { apiKey: e.target.value })}
/>
<Input
label="Default Model ID"
placeholder="e.g. llama3.2, gpt-4o, claude-3-7-sonnet-latest"
value={currentProvider.defaultModel}
onChange={(e) => {
const model = e.target.value;
const models = currentProvider.models.includes(model)
? currentProvider.models
: [model, ...currentProvider.models].filter(Boolean);
updateProvider(currentProvider.id, { defaultModel: model, models });
if (s.activeProviderId === currentProvider.id) {
set({ activeModel: model });
}
}}
/>
<Input
label="Available Models (comma-separated)"
value={currentProvider.models.join(", ")}
onChange={(e) => {
const models = e.target.value
.split(",")
.map((m) => m.trim())
.filter(Boolean);
updateProvider(currentProvider.id, {
models: models.length > 0 ? models : [currentProvider.defaultModel],
});
}}
/>
</div>
<Select
value={String(s.fontSize)}
onChange={(e) => set({ fontSize: Number(e.target.value) })}
options={[14, 15, 16, 17, 18, 19, 20, 21, 22].map((n) => ({ value: String(n), label: `${n}px` }))}
/>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Line spacing</div>
<div className="ps-settings-desc">Leading between body lines.</div>
</div>
<Select
value={String(s.leading)}
onChange={(e) => set({ leading: Number(e.target.value) })}
options={[1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0].map((n) => ({ value: String(n), label: n.toFixed(1) }))}
/>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Column width</div>
<div className="ps-settings-desc">Max width of the writing column.</div>
</div>
<Select
value={String(s.measure)}
onChange={(e) => set({ measure: Number(e.target.value) })}
options={[600, 660, 720, 780, 840].map((n) => ({ value: String(n), label: `${n}px` }))}
/>
</div>
</div>
{/* AI AGENTS */}
<div className="ps-settings-group">
<h3>Writing</h3>
<h3>AI Agents &amp; Role Prompts</h3>
<div className="ps-ai-settings-card">
<div className="ps-ai-settings-top">
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<Select
label="Select Agent to Edit"
value={currentAgent.id}
onChange={(e) => {
const id = e.target.value;
setSelectedAgentId(id);
set({ activeAgentId: id });
}}
options={s.aiAgents.map((a) => ({
value: a.id,
label: `${a.name} (${a.role})`,
}))}
/>
<Badge variant="accent">{`Temp: ${currentAgent.temperature}`}</Badge>
<Badge variant="neutral">{`Ctx: ${currentAgent.contextWindow.toLocaleString()} tok`}</Badge>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && (
<Button variant="secondary" size="sm" onClick={handleResetAgentPrompt}>
Reset Defaults
</Button>
)}
<Button variant="secondary" size="sm" onClick={handleAddAgent}>
+ New Agent
</Button>
{!DEFAULT_AI_AGENTS.some((a) => a.id === currentAgent.id) && (
<Button
variant="danger"
size="sm"
onClick={() => {
deleteAgent(currentAgent.id);
toast.info("Agent deleted");
}}
>
Delete Agent
</Button>
)}
</div>
</div>
<div className="ps-ai-settings-grid">
<Input
label="Agent Name"
value={currentAgent.name}
onChange={(e) => updateAgent(currentAgent.id, { name: e.target.value })}
/>
<div className="ps-field">
<label className="ps-field-label">
Temperature ({currentAgent.temperature.toFixed(2)})
</label>
<input
type="range"
min="0"
max="2"
step="0.05"
value={currentAgent.temperature}
onChange={(e) =>
updateAgent(currentAgent.id, { temperature: Number(e.target.value) })
}
/>
<span className="ps-field-hint">
Lower (0.2) = precise editing · Higher (0.8+) = creative brainstorming
</span>
</div>
<Select
label="Context Window (ctx tokens)"
value={String(currentAgent.contextWindow)}
onChange={(e) =>
updateAgent(currentAgent.id, { contextWindow: Number(e.target.value) })
}
options={[
{ value: "4096", label: "4,096 tokens (4K)" },
{ value: "8192", label: "8,192 tokens (8K)" },
{ value: "16384", label: "16,384 tokens (16K)" },
{ value: "32768", label: "32,768 tokens (32K)" },
{ value: "65536", label: "65,536 tokens (64K)" },
{ value: "131072", label: "131,072 tokens (128K)" },
]}
/>
<Select
label="Max Output Tokens"
value={String(currentAgent.maxTokens)}
onChange={(e) =>
updateAgent(currentAgent.id, { maxTokens: Number(e.target.value) })
}
options={[
{ value: "512", label: "512 tokens" },
{ value: "1024", label: "1,024 tokens" },
{ value: "2048", label: "2,048 tokens" },
{ value: "4096", label: "4,096 tokens" },
{ value: "8192", label: "8,192 tokens" },
]}
/>
<div className="ps-field">
<label className="ps-field-label">Extended Thinking / Reasoning</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, paddingTop: 6 }}>
<input
type="checkbox"
checked={currentAgent.thinkingEnabled}
onChange={(e) =>
updateAgent(currentAgent.id, { thinkingEnabled: e.target.checked })
}
/>
<span>Enable reasoning tokens for supported models</span>
</label>
</div>
{currentAgent.thinkingEnabled && (
<Select
label="Reasoning Effort"
value={currentAgent.thinkingEffort}
onChange={(e) =>
updateAgent(currentAgent.id, {
thinkingEffort: e.target.value as ThinkingEffort,
})
}
options={[
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
]}
/>
)}
</div>
<div className="ps-field" style={{ marginTop: 12, marginBottom: 0 }}>
<label className="ps-field-label">System Prompt &amp; Instructions</label>
<textarea
className="ps-input"
rows={5}
value={currentAgent.systemPrompt}
onChange={(e) => updateAgent(currentAgent.id, { systemPrompt: e.target.value })}
placeholder="System instructions for this agent role…"
/>
</div>
</div>
</div>
{/* WRITING & PROOFING */}
<div className="ps-settings-group">
<h3>Writing &amp; Proofing</h3>
<div className="ps-settings-row">
<div>
@@ -111,28 +441,17 @@ export function Settings() {
<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` }))}
options={[20_000, 40_000, 60_000, 80_000, 100_000, 120_000, 150_000].map((n) => ({
value: String(n),
label: `${(n / 1000).toFixed(0)},000 words`,
}))}
/>
</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 className="ps-settings-desc">Persist manuscript changes automatically.</div>
</div>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
@@ -143,38 +462,12 @@ export function Settings() {
</label>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Focus mode</div>
<div className="ps-settings-desc">Dim everything but the block under the cursor. Toggle with F2.</div>
</div>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={s.focusMode}
onChange={(e) => set({ focusMode: e.target.checked })}
/>
</label>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Typewriter mode</div>
<div className="ps-settings-desc">Keep the active block centered as you type. Toggle with ⌘⇧T.</div>
</div>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={s.typewriterMode}
onChange={(e) => set({ typewriterMode: e.target.checked })}
/>
</label>
</div>
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Spellcheck</div>
<div className="ps-settings-desc">Underline misspelled words as you write. Uses a bundled English dictionary locally.</div>
<div className="ps-settings-desc">
Underline misspelled words as you write using the bundled offline dictionary.
</div>
</div>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
@@ -187,17 +480,19 @@ export function Settings() {
{s.enableSpellcheck && (
<div className="ps-settings-row">
<label className="ps-settings-field">
<span className="ps-settings-label">Language</span>
<select
value={s.spellcheckLanguage}
onChange={(e) => set({ spellcheckLanguage: e.target.value })}
>
<option value="en_US">English (US)</option>
<option value="en_GB">English (UK)</option>
<option value="ca_ES">Català (Espanya)</option>
</select>
</label>
<div>
<div className="ps-settings-label">Spellcheck Language</div>
<div className="ps-settings-desc">Active dictionary for spelling checks.</div>
</div>
<Select
value={s.spellcheckLanguage}
onChange={(e) => set({ spellcheckLanguage: e.target.value })}
options={[
{ value: "en_US", label: "English (US)" },
{ value: "en_GB", label: "English (UK)" },
{ value: "ca_ES", label: "Català (Espanya)" },
]}
/>
</div>
)}
@@ -215,32 +510,37 @@ export function Settings() {
</label>
</div>
{s.enableGrammarCheck && s.grammarProvider === "languagetool" && (
{s.enableGrammarCheck && (
<div className="ps-settings-row">
<label className="ps-settings-field">
<span className="ps-settings-label">LanguageTool API key</span>
<input
type="password"
placeholder="Optional — uses the public endpoint without a key"
value={s.grammarApiKey}
onChange={(e) => set({ grammarApiKey: e.target.value })}
/>
</label>
<div>
<div className="ps-settings-label">Grammar provider</div>
<div className="ps-settings-desc">Local built-in rules or LanguageTool server.</div>
</div>
<Select
value={s.grammarProvider}
onChange={(e) =>
set({ grammarProvider: e.target.value as AppSettings["grammarProvider"] })
}
options={[
{ value: "local", label: "Built-in (Local)" },
{ value: "languagetool", label: "LanguageTool (Self-hosted / Cloud)" },
]}
/>
</div>
)}
{s.enableGrammarCheck && (
{s.enableGrammarCheck && s.grammarProvider === "languagetool" && (
<div className="ps-settings-row">
<label className="ps-settings-field">
<span className="ps-settings-label">Grammar provider</span>
<select
value={s.grammarProvider}
onChange={(e) => set({ grammarProvider: e.target.value as AppSettings["grammarProvider"] })}
>
<option value="local">Built-in (local)</option>
<option value="languagetool">LanguageTool (cloud)</option>
</select>
</label>
<div style={{ flex: 1 }}>
<div className="ps-settings-label">LanguageTool API key</div>
<div className="ps-settings-desc">Optional — uses the public or local endpoint without a key.</div>
</div>
<Input
type="password"
placeholder="Optional API key"
value={s.grammarApiKey}
onChange={(e) => set({ grammarApiKey: e.target.value })}
/>
</div>
)}
</div>
@@ -248,9 +548,17 @@ export function Settings() {
<div className="ps-settings-row">
<div>
<div className="ps-settings-label">Reset to defaults</div>
<div className="ps-settings-desc">Restore default theme, providers, agents, and preferences.</div>
</div>
<Button variant="danger" size="sm" onClick={() => { reset(); toast.info("Settings reset"); }}>
Reset
<Button
variant="danger"
size="sm"
onClick={() => {
reset();
toast.info("Settings reset to defaults");
}}
>
Reset All Settings
</Button>
</div>
</div>
+106 -35
View File
@@ -1,5 +1,6 @@
import { NavLink } from "react-router-dom";
import { NavLink, useNavigate } from "react-router-dom";
import { useProjectStore } from "../store/project-store.js";
import { useSettings } from "../store/settings.js";
export interface NavItem {
to: string;
@@ -14,64 +15,134 @@ export interface SidebarProps {
/** A minimal view of a chapter document for word counting. */
interface ChapterDoc {
doc: { content?: Array<{ text?: string }> };
doc: { content?: Array<{ text?: string; content?: Array<{ text?: string }> }> };
}
/** Total word count across every chapter document in the active project. */
function totalWords(chapters: ChapterDoc[]): number {
let total = 0;
const walk = (node: { text?: string; content?: Array<{ text?: string }> }) => {
if (node.text) {
total += node.text.trim().split(/\s+/).filter(Boolean).length;
}
if (Array.isArray(node.content)) {
for (const child of node.content) walk(child);
}
};
for (const chapter of chapters) {
for (const node of chapter.doc.content ?? []) {
if (node.text) total += node.text.trim().split(/\s+/).filter(Boolean).length;
walk(node);
}
}
return total;
}
export function Sidebar({ nav }: SidebarProps) {
const navigate = useNavigate();
const project = useProjectStore((s) => s.project);
const chapters = useProjectStore((s) => s.chapters);
const navCollapsed = useSettings((s) => s.navCollapsed);
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
const setSettings = useSettings((s) => s.set);
const title = project?.title ?? "Untitled project";
const wordCount = totalWords(chapters);
return (
<aside className="ps-sidebar">
<div className="ps-logo">
<svg className="ps-logo-mark" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 16c1.5-1.5 3-2 4.5-1.5L16 6l3 3-6.5 6.5c.5 1.5 0 3-1.5 4-.5.5-1.2.7-1.8.5-.6-.2-1-.7-1-1.4 0-.6.2-1.2.8-1.8L14 9l-2-2-3.5 3.5C7.5 9.5 5 10 4 16Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M14.5 6.5l3 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
PubScript
<aside className={`ps-sidebar${navCollapsed ? " is-collapsed" : ""}`}>
<div className="ps-sidebar-header">
<div
className="ps-logo"
onClick={() => navigate("/")}
style={{ cursor: "pointer" }}
title="PubScript"
>
{navCollapsed ? (
<img
src="/logo.svg"
alt="PubScript"
className="ps-sidebar-logo-icon"
/>
) : (
<div className="ps-sidebar-brand">
<img
src="/logo.svg"
alt="PubScript"
className="ps-sidebar-logo-img"
/>
<img
src="/logo-lockup.svg"
alt="PubScript"
className="ps-sidebar-logo-lockup"
/>
</div>
)}
</div>
<button
type="button"
className="ps-sidebar-toggle"
title={navCollapsed ? "Expand navigation sidebar" : "Collapse navigation sidebar (icons only)"}
aria-label={navCollapsed ? "Expand navigation sidebar" : "Collapse navigation sidebar"}
aria-expanded={!navCollapsed}
onClick={() => setSettings({ navCollapsed: !navCollapsed })}
>
{navCollapsed ? "»" : "«"}
</button>
</div>
<div className="ps-nav-section-title">Project</div>
<button className="ps-nav-item" title={title}>
{!navCollapsed && <div className="ps-nav-section-title">Project</div>}
<button
type="button"
className="ps-nav-item"
title={`${title} (${chapters.length} chapters · ${wordCount.toLocaleString()} words)`}
onClick={() => navigate("/projects")}
>
<span className="ps-nav-icon">📖</span>
<span>
<div>{title}</div>
<div style={{ fontSize: 12, opacity: 0.6 }}>
{chapters.length} chapter{chapters.length === 1 ? "" : "s"} · {(Math.round(wordCount / 100) * 100).toLocaleString()} words
</div>
</span>
{!navCollapsed && (
<span className="ps-nav-label">
<div style={{ fontWeight: 600 }}>{title}</div>
<div style={{ fontSize: 12, opacity: 0.65 }}>
{chapters.length} chapter{chapters.length === 1 ? "" : "s"} · {wordCount.toLocaleString()} 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>
))}
{!navCollapsed && (
<div className="ps-nav-section-title" style={{ marginTop: 12 }}>
Navigate
</div>
)}
<nav className="ps-sidebar-nav">
{nav.map((item) => {
if (item.to === "/ai") {
return (
<button
key={item.to}
type="button"
title={`${item.label} (Toggle AI Sidebar)`}
className={`ps-nav-item${aiSidebarOpen ? " is-active" : ""}`}
onClick={() => setSettings({ aiSidebarOpen: !aiSidebarOpen })}
>
<span className="ps-nav-icon">{item.icon}</span>
{!navCollapsed && <span className="ps-nav-label">{item.label}</span>}
</button>
);
}
return (
<NavLink
key={item.to}
to={item.to}
end={item.end ?? false}
title={item.label}
className={({ isActive }) => `ps-nav-item${isActive ? " is-active" : ""}`}
>
<span className="ps-nav-icon">{item.icon}</span>
{!navCollapsed && <span className="ps-nav-label">{item.label}</span>}
</NavLink>
);
})}
</nav>
</aside>
);
}
+145 -28
View File
@@ -1,12 +1,15 @@
import { useLocation, NavLink } from "react-router-dom";
import { useMemo, useRef, useState, useEffect } from "react";
import { useLocation, NavLink, useNavigate } from "react-router-dom";
import { useSettings } from "../store/settings.js";
import { Select, Badge } from "@pubscript/ui";
import { THEME_IDS, THEME_LABELS } from "@pubscript/ui";
import { useProjectStore } from "../store/project-store.js";
import { Badge } from "@pubscript/ui";
import type { Entity } from "@pubscript/core";
const TITLES: Record<string, string> = {
"/": "Write",
"/characters": "Characters",
"/characters": "Entities",
"/plot": "Plot",
"/timeline": "Timeline",
"/whiteboard": "Whiteboard",
"/ai": "AI",
"/projects": "Projects",
@@ -15,43 +18,164 @@ const TITLES: Record<string, string> = {
};
export interface TopbarProps {
onOpenPalette: () => void;
onOpenPalette?: () => void;
onOpenExport: () => void;
}
export function Topbar({ onOpenPalette, onOpenExport }: TopbarProps) {
function entityName(e: Entity): string {
if ("name" in e) return e.name || "Untitled";
return (e.body || "Untitled note").slice(0, 48);
}
export function Topbar({ onOpenExport }: TopbarProps) {
const location = useLocation();
const theme = useSettings((s) => s.theme);
const setTheme = useSettings((s) => s.setTheme);
const navigate = useNavigate();
const autoSave = useSettings((s) => s.autoSave);
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
const setSettings = useSettings((s) => s.set);
const chapters = useProjectStore((s) => s.chapters);
const selectChapter = useProjectStore((s) => s.selectChapter);
const entities = useProjectStore((s) => s.entities);
const plotBeats = useProjectStore((s) => s.plotBeats);
const [searchQuery, setSearchQuery] = useState("");
const [searchFocused, setSearchFocused] = useState(false);
const searchWrapRef = useRef<HTMLDivElement>(null);
const title = TITLES[location.pathname] ?? "PubScript";
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (searchWrapRef.current && !searchWrapRef.current.contains(e.target as Node)) {
setSearchFocused(false);
}
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, []);
const searchResults = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return [];
const items: Array<{
id: string;
kind: "Chapter" | "Entity" | "Beat";
label: string;
onSelect: () => void;
}> = [];
for (const ch of chapters) {
if (ch.title.toLowerCase().includes(q)) {
items.push({
id: `ch-${ch.id}`,
kind: "Chapter",
label: ch.title,
onSelect: () => {
void selectChapter(ch.id);
navigate("/");
},
});
}
}
for (const ent of entities) {
const label = entityName(ent);
if (label.toLowerCase().includes(q)) {
items.push({
id: `ent-${ent.id}`,
kind: "Entity",
label,
onSelect: () => {
navigate(`/characters?entity=${encodeURIComponent(ent.id)}`);
},
});
}
}
for (const beat of plotBeats) {
if (beat.title.toLowerCase().includes(q) || beat.body.toLowerCase().includes(q)) {
items.push({
id: `beat-${beat.id}`,
kind: "Beat",
label: `Act ${beat.act}: ${beat.title || "Untitled beat"}`,
onSelect: () => {
navigate("/plot");
},
});
}
}
return items.slice(0, 10);
}, [searchQuery, chapters, entities, plotBeats, selectChapter, navigate]);
return (
<header className="ps-topbar">
<img
src="/logo-mark.svg"
alt="PubScript"
className="ps-topbar-logo"
style={{ height: 26, width: "auto", marginRight: 12, display: "inline-block", verticalAlign: "middle" }}
/>
<h1 style={{ margin: 0, fontSize: 15, fontWeight: 600, display: "inline-flex", verticalAlign: "middle" }}>{title}</h1>
<h1 style={{ margin: 0, fontSize: 15, fontWeight: 600, display: "inline-flex", verticalAlign: "middle" }}>
{title}
</h1>
{autoSave && <Badge variant="success">✓ autosave on</Badge>}
<div style={{ flex: 1 }} />
<div ref={searchWrapRef} className="ps-topbar-search">
<input
type="search"
className="ps-input ps-topbar-search-input"
placeholder="Search chapters, entities, beats…"
aria-label="Search manuscript"
value={searchQuery}
onFocus={() => setSearchFocused(true)}
onChange={(e) => {
setSearchQuery(e.target.value);
setSearchFocused(true);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setSearchFocused(false);
} else if (e.key === "Enter" && searchResults[0]) {
e.preventDefault();
searchResults[0].onSelect();
setSearchQuery("");
setSearchFocused(false);
}
}}
/>
{searchFocused && searchQuery.trim() !== "" && (
<div className="ps-topbar-search-results">
{searchResults.length === 0 ? (
<div className="ps-topbar-search-empty">No matches for “{searchQuery}”</div>
) : (
searchResults.map((res) => (
<button
key={res.id}
type="button"
className="ps-topbar-search-item"
onClick={() => {
res.onSelect();
setSearchQuery("");
setSearchFocused(false);
}}
>
<span className="ps-badge ps-badge-neutral">{res.kind}</span>
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{res.label}</span>
</button>
))
)}
</div>
)}
</div>
<button
className="ps-nav-item"
onClick={onOpenPalette}
type="button"
className={`ps-nav-item${aiSidebarOpen ? " is-active" : ""}`}
onClick={() => setSettings({ aiSidebarOpen: !aiSidebarOpen })}
style={{ width: "auto", padding: "6px 10px" }}
title="Toggle AI Chat Sidebar"
>
<span>Search</span>
<span className="ps-command-kbd" style={{ marginLeft: 8 }}>⌘K</span>
<span>✨ AI</span>
</button>
<button
type="button"
className="ps-nav-item"
onClick={onOpenExport}
style={{ width: "auto", padding: "6px 10px" }}
@@ -59,14 +183,7 @@ export function Topbar({ onOpenPalette, onOpenExport }: TopbarProps) {
<span>Export</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 to="/settings" className="ps-icon-btn" style={{ fontSize: 18 }} aria-label="Settings" title="Settings">
⚙
</NavLink>
</header>
+736 -98
View File
@@ -1,26 +1,25 @@
/**
* WritingView — the editor surface for a single chapter.
*
* Wires the TipTap editor (`packages/editor`) to the project store:
* - loads the active chapter's document into the editor,
* - syncs content when the chapter changes,
* - debounces every edit through the storage adapter via `scheduleSave`,
* - shows word count, scene count, and an autosave indicator.
* WritingView — the manuscript editor surface with an OnlyOffice / Word-style
* formatting toolbar, typography suite, line numbers, and focus mode.
*/
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { EditorContent, usePubScriptEditor, type Editor, SCENE_BREAK } from "@pubscript/editor";
import {
EditorContent,
usePubScriptEditor,
type Editor,
SCENE_BREAK,
FOOTNOTE,
} from "@pubscript/editor";
import type { DocumentRef, AutosaveState } from "@pubscript/core";
import { Modal, Input, Button, toast } from "@pubscript/ui";
import { useProjectStore } from "../store/project-store.js";
import { useSettings } from "../store/settings.js";
import { useSettings, FONT_FAMILIES, type FontFamily } from "../store/settings.js";
import { ContextRail } from "../features/entities/ContextRail.js";
import { MentionPopover } from "../features/entities/MentionPopover.js";
import { CheckPanel } from "./CheckPanel.js";
import { useCheckService } from "../hooks/useCheckService.js";
/** Block-level tags that can contain the cursor and be highlighted in focus mode. */
const BLOCK_TAGS = new Set(["P", "H1", "H2", "H3", "BLOCKQUOTE", "PRE", "LI", "DIV"]);
/** Count words in the editor document. */
function countWords(editor: Editor | null): number {
if (!editor) return 0;
@@ -42,10 +41,13 @@ function countNode(editor: Editor | null, nodeType: string): number {
/** Format the autosave status for the indicator. */
function autosaveLabel(status: AutosaveState): string {
if (status.saving) return "Saving…";
if (status.dirty) return "Unsaved changes";
if (!status.lastSavedAt) return "Not saved yet";
const seconds = Math.max(0, Math.round((Date.now() - new Date(status.lastSavedAt).getTime()) / 1000));
return `Saved ${seconds}s ago`;
if (status.dirty) return "Unsaved";
if (!status.lastSavedAt) return "Saved";
const seconds = Math.max(
0,
Math.round((Date.now() - new Date(status.lastSavedAt).getTime()) / 1000),
);
return seconds < 5 ? "Saved just now" : `Saved ${seconds}s ago`;
}
interface WritingViewProps {
@@ -53,123 +55,160 @@ interface WritingViewProps {
onChapterChange?: (id: string) => void;
}
const FONT_SIZES = [11, 12, 13, 14, 15, 16, 17, 18, 20, 22, 24, 28, 32, 36];
const LINE_SPACINGS = [1.0, 1.15, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 2.0, 2.2, 2.5];
const COLUMN_WIDTHS = [
{ value: 600, label: "Narrow (600px)" },
{ value: 660, label: "Book (660px)" },
{ value: 720, label: "Standard (720px)" },
{ value: 780, label: "Wide (780px)" },
{ value: 860, label: "Manuscript (860px)" },
{ value: 980, label: "Full Page (980px)" },
];
export function WritingView({ onChapterChange }: WritingViewProps) {
const navigate = useNavigate();
const project = useProjectStore((s) => s.project);
const chapters = useProjectStore((s) => s.chapters);
const activeChapterId = useProjectStore((s) => s.activeChapterId);
const selectChapter = useProjectStore((s) => s.selectChapter);
const createChapter = useProjectStore((s) => s.createChapter);
const renameChapter = useProjectStore((s) => s.renameChapter);
const deleteChapter = useProjectStore((s) => s.deleteChapter);
const scheduleSave = useProjectStore((s) => s.scheduleSave);
const flushAutosave = useProjectStore((s) => s.flushAutosave);
const autosave = useProjectStore((s) => s.autosave);
const entities = useProjectStore((s) => s.entities);
const fontFamily = useSettings((st) => st.fontFamily);
const fontSize = useSettings((st) => st.fontSize);
const leading = useSettings((st) => st.leading);
const measure = useSettings((st) => st.measure);
const showLineNumbers = useSettings((st) => st.showLineNumbers);
const focusMode = useSettings((st) => st.focusMode);
const typewriterMode = useSettings((st) => st.typewriterMode);
const setSettings = useSettings((st) => st.set);
const [firstLineIndent, setFirstLineIndent] = useState(false);
const [showContextRail, setShowContextRail] = useState(true);
const [renamingChapter, setRenamingChapter] = useState(false);
const [chapterTitleDraft, setChapterTitleDraft] = useState("");
const [, setToolbarTick] = useState(0);
const editorDomRef = useRef<HTMLDivElement | null>(null);
const chapter = chapters.find((c) => c.id === activeChapterId) ?? null;
const projectId = project?.id ?? null;
const { editor } = usePubScriptEditor({
// Pass the full ProseMirror doc (`{ type: "doc", content }`), not the bare
// `content` array — TipTap/ProseMirror cannot build a doc node from a bare
// array, which would leave `doc.type` undefined and crash `EditorState.create`.
...(chapter ? { content: chapter.doc } : {}),
onUpdate: (ed) => {
setToolbarTick((t) => t + 1);
if (!chapter || !projectId) return;
const json = ed.getJSON().content;
const ref: DocumentRef = {
id: chapter.id,
projectId,
bookId: chapter.bookId,
title: chapter.title,
doc: { type: "doc", content: json },
doc: { type: "doc", content: json ?? [] },
rev: chapter.rev,
};
scheduleSave(ref);
},
});
// Re-render toolbar active states on cursor selection change.
useEffect(() => {
if (!editor) return;
const onSel = () => setToolbarTick((t) => t + 1);
editor.on("selectionUpdate", onSel);
return () => {
editor.off("selectionUpdate", onSel);
};
}, [editor]);
const check = useCheckService({ editor });
// Sync content when the active chapter changes. emitUpdate:false keeps this
// from re-entering onUpdate (which would otherwise re-save what we just loaded).
// Sync content when the active chapter changes.
useEffect(() => {
if (!editor || !chapter) return;
editor.commands.setContent(chapter.doc, { emitUpdate: false });
editor.commands.focus();
}, [editor, chapter?.id]);
// Focus mode: dim every block except the one holding the cursor. Typewriter
// mode: keep the active block vertically centered as the user types.
// Focus mode: dim every top-level block in .ProseMirror except the one
// containing the active cursor selection.
useEffect(() => {
if (!editor) return;
const editorDom = editorDomRef.current;
if (!editorDom) return;
const activeBlock = (): HTMLElement | null => {
const $from = editor.state.selection.$from;
const dom = editor.view.nodeDOM($from.pos) as HTMLElement | null;
let el: HTMLElement | null = dom;
while (el && editorDom.contains(el)) {
if (BLOCK_TAGS.has(el.tagName)) return el;
el = el.parentElement;
}
return null;
const clearFocusClasses = () => {
const pmRoot = editorDom.querySelector(".ProseMirror");
if (!pmRoot) return;
Array.from(pmRoot.children).forEach((child) => {
child.classList.remove("ps-focus-active");
});
};
if (focusMode) {
const apply = () => {
const block = activeBlock();
const blocks = editorDom.querySelectorAll<HTMLElement>("p, h1, h2, h3, blockquote, pre, li, div");
blocks.forEach((b) => b.classList.toggle("ps-focus-active", b === block));
};
editor.on("selectionUpdate", apply);
editor.on("update", apply);
apply();
return () => {
editor.off("selectionUpdate", apply);
editor.off("update", apply);
};
if (!focusMode) {
clearFocusClasses();
return undefined;
}
if (typewriterMode) {
const scroll = () => {
const block = activeBlock();
block?.scrollIntoView({ block: "center" });
};
editor.on("selectionUpdate", scroll);
editor.on("update", scroll);
scroll();
return () => {
editor.off("selectionUpdate", scroll);
editor.off("update", scroll);
};
}
const applyFocus = () => {
const pmRoot = editorDom.querySelector(".ProseMirror");
if (!pmRoot) return;
let activeEl: HTMLElement | null = null;
try {
const pos = editor.state.selection.$from.pos;
const domInfo = editor.view.domAtPos(pos);
let node: Node | null = domInfo.node;
if (node && node.nodeType === Node.TEXT_NODE) {
node = node.parentElement;
}
while (node && node instanceof HTMLElement && node !== pmRoot) {
if (node.parentElement === pmRoot) {
activeEl = node;
break;
}
node = node.parentElement;
}
} catch {
/* ignore */
}
const children = Array.from(pmRoot.children) as HTMLElement[];
if (!activeEl && children.length > 0) {
activeEl = children[0]!;
}
children.forEach((child) => {
child.classList.toggle("ps-focus-active", child === activeEl);
});
};
return undefined;
}, [editor, focusMode, typewriterMode]);
editor.on("selectionUpdate", applyFocus);
editor.on("update", applyFocus);
applyFocus();
return () => {
editor.off("selectionUpdate", applyFocus);
editor.off("update", applyFocus);
clearFocusClasses();
};
}, [editor, focusMode]);
// Keyboard shortcuts: F2 toggles focus mode, Ctrl/Cmd+Shift+T toggles
// typewriter mode. Only active while editing inside the writing surface.
// Keyboard shortcut: F2 toggles focus mode.
useEffect(() => {
const dom = editorDomRef.current;
if (!dom) return;
const onKey = (e: KeyboardEvent) => {
const isMod = e.metaKey || e.ctrlKey;
if (e.key === "F2") {
e.preventDefault();
setSettings({ focusMode: !focusMode });
} else if (isMod && (e.key === "t" || e.key === "T") && e.shiftKey) {
e.preventDefault();
setSettings({ typewriterMode: !typewriterMode });
}
};
dom.addEventListener("keydown", onKey);
return () => dom.removeEventListener("keydown", onKey);
}, [editorDomRef, focusMode, typewriterMode, setSettings]);
}, [focusMode, setSettings]);
// Flush pending edits when the user closes or navigates away.
useEffect(() => {
@@ -187,13 +226,87 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const wordCount = countWords(editor);
const sceneCount = countNode(editor, SCENE_BREAK);
const readingMinutes = Math.max(1, Math.ceil(wordCount / 250));
// Helpers for block style & alignment
const currentBlockStyle = (): string => {
if (!editor) return "paragraph";
if (editor.isActive("heading", { level: 1 })) return "h1";
if (editor.isActive("heading", { level: 2 })) return "h2";
if (editor.isActive("heading", { level: 3 })) return "h3";
if (editor.isActive("blockquote")) return "blockquote";
if (editor.isActive("codeBlock")) return "codeBlock";
return "paragraph";
};
const applyBlockStyle = (val: string) => {
if (!editor) return;
const chain = editor.chain().focus();
if (val === "paragraph") chain.setParagraph().run();
else if (val === "h1") chain.toggleHeading({ level: 1 }).run();
else if (val === "h2") chain.toggleHeading({ level: 2 }).run();
else if (val === "h3") chain.toggleHeading({ level: 3 }).run();
else if (val === "blockquote") chain.toggleBlockquote().run();
else if (val === "codeBlock") chain.toggleCodeBlock().run();
};
const currentTextAlign = (): string => {
if (!editor) return "left";
const attrs =
editor.getAttributes("paragraph").textAlign ||
editor.getAttributes("heading").textAlign ||
"left";
return String(attrs);
};
const setTextAlign = (align: "left" | "center" | "right" | "justify") => {
if (!editor) return;
editor
.chain()
.focus()
.updateAttributes("paragraph", { textAlign: align })
.updateAttributes("heading", { textAlign: align })
.updateAttributes("blockquote", { textAlign: align })
.run();
setToolbarTick((t) => t + 1);
};
const insertSceneBreak = () => {
if (!editor) return;
editor
.chain()
.focus()
.insertContent({ type: SCENE_BREAK })
.run();
};
const insertFootnote = () => {
if (!editor) return;
const num = countNode(editor, FOOTNOTE) + 1;
editor
.chain()
.focus()
.insertContent({
type: FOOTNOTE,
attrs: { id: `fn-${num}`, number: String(num) },
})
.run();
};
const handleStepFontSize = (delta: number) => {
const next = Math.max(10, Math.min(36, fontSize + delta));
setSettings({ fontSize: next });
};
return (
<div className="ps-writing">
<div className="ps-writing-toolbar">
<label className="ps-chapter-select">
<span className="ps-chapter-select-label">Chapter</span>
{/* OnlyOffice / Word-style Formatting Toolbar */}
<div className="ps-writing-toolbar" role="toolbar" aria-label="Formatting Toolbar">
{/* Chapter Selector & Actions */}
<div className="ps-toolbar-group ps-toolbar-chapter-group">
<select
className="ps-toolbar-select ps-toolbar-chapter-select"
aria-label="Active Chapter"
value={activeChapterId ?? ""}
onChange={(e) => {
const id = e.target.value;
@@ -207,49 +320,539 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
</option>
))}
</select>
</label>
<button
type="button"
className="ps-tool-icon"
title="New Chapter"
aria-label="New Chapter"
onClick={async () => {
const ch = await createChapter();
if (ch) toast.success(`Created ${ch.title}`);
}}
>
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M8 3v10M3 8h10" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className="ps-tool-icon"
title="Rename Chapter"
aria-label="Rename Chapter"
onClick={() => {
setChapterTitleDraft(chapter.title);
setRenamingChapter(true);
}}
>
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M11.5 2.5l2 2-8 8H3.5v-2l8-8z" strokeLinejoin="round" />
</svg>
</button>
{chapters.length > 1 && (
<button
type="button"
className="ps-tool-icon"
title="Delete Current Chapter"
aria-label="Delete Current Chapter"
onClick={() => {
void deleteChapter(chapter.id);
toast.info(`Deleted ${chapter.title}`);
}}
>
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 4.5h10M6 4.5V3h4v1.5M4.5 4.5l.7 8.5h5.6l.7-8.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
</div>
<span className="ps-toolbar-sep" />
{/* History: Undo / Redo */}
<div className="ps-toolbar-group">
<button
type="button"
className={`ps-tool${focusMode ? " is-active" : ""}`}
title="Focus mode (F2)"
aria-pressed={focusMode}
onClick={() => setSettings({ focusMode: !focusMode })}
className="ps-tool-icon"
title="Undo (Ctrl+Z)"
aria-label="Undo"
onClick={() => editor?.chain().focus().undo().run()}
>
Focus
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M6 4L2.5 7.5 6 11" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3 7.5h6.5a3.5 3.5 0 0 1 0 7H8" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool${typewriterMode ? " is-active" : ""}`}
title="Typewriter mode (⌘⇧T)"
aria-pressed={typewriterMode}
onClick={() => setSettings({ typewriterMode: !typewriterMode })}
className="ps-tool-icon"
title="Redo (Ctrl+Y)"
aria-label="Redo"
onClick={() => editor?.chain().focus().redo().run()}
>
Typewriter
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M10 4l3.5 3.5L10 11" strokeLinecap="round" strokeLinejoin="round" />
<path d="M13 7.5H6.5a3.5 3.5 0 0 0 0 7H8" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="ps-autosave-indicator" data-status={autosave.saving ? "saving" : autosave.dirty ? "dirty" : "saved"}>
<span className="ps-toolbar-sep" />
{/* Typography: Paragraph Style, Font Family, Font Size */}
<div className="ps-toolbar-group">
<select
className="ps-toolbar-select"
title="Paragraph / Heading Style"
aria-label="Paragraph Style"
value={currentBlockStyle()}
onChange={(e) => applyBlockStyle(e.target.value)}
>
<option value="paragraph">Normal Text</option>
<option value="h1">Heading 1</option>
<option value="h2">Heading 2</option>
<option value="h3">Heading 3</option>
<option value="blockquote">Blockquote</option>
<option value="codeBlock">Code Block</option>
</select>
<select
className="ps-toolbar-select ps-toolbar-font-select"
title="Font Family"
aria-label="Font Family"
value={fontFamily}
onChange={(e) => setSettings({ fontFamily: e.target.value as FontFamily })}
>
{(["Serif", "Display", "Sans-Serif", "Monospace"] as const).map((cat) => (
<optgroup key={cat} label={cat}>
{FONT_FAMILIES.filter((f) => f.category === cat).map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</optgroup>
))}
</select>
<select
className="ps-toolbar-select ps-toolbar-size-select"
title="Font Size"
aria-label="Font Size"
value={String(fontSize)}
onChange={(e) => setSettings({ fontSize: Number(e.target.value) })}
>
{FONT_SIZES.map((sz) => (
<option key={sz} value={String(sz)}>
{sz} pt
</option>
))}
</select>
<button
type="button"
className="ps-tool-icon"
title="Decrease Font Size"
aria-label="Decrease Font Size"
onClick={() => handleStepFontSize(-1)}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2.5 12L6 4l3.5 8M3.7 9.5h4.6M11 8h3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
className="ps-tool-icon"
title="Increase Font Size"
aria-label="Increase Font Size"
onClick={() => handleStepFontSize(1)}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 12L5.5 4 9 12M3.2 9.5h4.6M12.2 6v4M10.2 8h4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
<span className="ps-toolbar-sep" />
{/* Character Marks: Bold, Italic, Underline, Strike, Highlight, Sup, Sub, Color, Clear */}
<div className="ps-toolbar-group">
<button
type="button"
className={`ps-tool-icon${editor?.isActive("bold") ? " is-active" : ""}`}
title="Bold (Ctrl+B)"
aria-label="Bold"
onClick={() => editor?.chain().focus().toggleBold().run()}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.9">
<path d="M4.5 3h4.2a2.3 2.3 0 0 1 0 4.6H4.5V3zM4.5 7.6h4.8a2.4 2.4 0 0 1 0 4.8H4.5V7.6z" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${editor?.isActive("italic") ? " is-active" : ""}`}
title="Italic (Ctrl+I)"
aria-label="Italic"
onClick={() => editor?.chain().focus().toggleItalic().run()}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M6.5 3h5M4.5 13h5M9.5 3l-3 10" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${editor?.isActive("underline") ? " is-active" : ""}`}
title="Underline (Ctrl+U)"
aria-label="Underline"
onClick={() => editor?.chain().focus().toggleUnderline().run()}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M4.5 3v4.5a3.5 3.5 0 0 0 7 0V3M3.5 13.5h9" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${editor?.isActive("strike") ? " is-active" : ""}`}
title="Strikethrough"
aria-label="Strikethrough"
onClick={() => editor?.chain().focus().toggleStrike().run()}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M2.5 8h11M10.5 4.5c0-1-1.1-1.8-2.5-1.8s-2.5.8-2.5 1.8c0 2 5 1.8 5 4.2 0 1.2-1.1 2-2.5 2s-2.5-.8-2.5-2" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${editor?.isActive("highlight") ? " is-active" : ""}`}
title="Highlight Text"
aria-label="Highlight Text"
onClick={() => editor?.chain().focus().toggleMark("highlight").run()}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9.5 3l3.5 3.5-5.5 5.5H4V8.5L9.5 3zM3 13.5h10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${editor?.isActive("superscript") ? " is-active" : ""}`}
title="Superscript"
aria-label="Superscript"
onClick={() => editor?.chain().focus().toggleMark("superscript").run()}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 6l5 6M8 6l-5 6M11 3.5h2.5v2H11v2h2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${editor?.isActive("subscript") ? " is-active" : ""}`}
title="Subscript"
aria-label="Subscript"
onClick={() => editor?.chain().focus().toggleMark("subscript").run()}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 4l5 6M8 4l-5 6M11 9.5h2.5v2H11v2h2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<label className="ps-tool-icon ps-tool-color" title="Text Color" aria-label="Text Color">
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4.5 11L8 3l3.5 8M5.7 8.5h4.6" strokeLinecap="round" strokeLinejoin="round" />
<line x1="3" y1="13.5" x2="13" y2="13.5" stroke="var(--ps-accent)" strokeWidth="2.4" strokeLinecap="round" />
</svg>
<input
type="color"
className="ps-tool-color-input"
onChange={(e) =>
editor?.chain().focus().setMark("textStyle", { color: e.target.value }).run()
}
/>
</label>
<button
type="button"
className="ps-tool-icon"
title="Clear Formatting"
aria-label="Clear Formatting"
onClick={() => editor?.chain().focus().unsetAllMarks().clearNodes().run()}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 3h8M8 3l-2.5 9M3 13l10-10" strokeLinecap="round" />
</svg>
</button>
</div>
<span className="ps-toolbar-sep" />
{/* Alignment & Justification: Left, Center, Right, Justify */}
<div className="ps-toolbar-group">
<button
type="button"
className={`ps-tool-icon${currentTextAlign() === "left" ? " is-active" : ""}`}
title="Align Left"
aria-label="Align Left"
onClick={() => setTextAlign("left")}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M2.5 3.5h11M2.5 6.5h7.5M2.5 9.5h11M2.5 12.5h6.5" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${currentTextAlign() === "center" ? " is-active" : ""}`}
title="Align Center"
aria-label="Align Center"
onClick={() => setTextAlign("center")}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M2.5 3.5h11M4.5 6.5h7M2.5 9.5h11M5 12.5h6" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${currentTextAlign() === "right" ? " is-active" : ""}`}
title="Align Right"
aria-label="Align Right"
onClick={() => setTextAlign("right")}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M2.5 3.5h11M6 6.5h7.5M2.5 9.5h11M7 12.5h6.5" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${currentTextAlign() === "justify" ? " is-active" : ""}`}
title="Justify Text"
aria-label="Justify Text"
onClick={() => setTextAlign("justify")}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M2.5 3.5h11M2.5 6.5h11M2.5 9.5h11M2.5 12.5h11" strokeLinecap="round" />
</svg>
</button>
</div>
<span className="ps-toolbar-sep" />
{/* Lists, First-line Indent, Line Spacing, Column Width */}
<div className="ps-toolbar-group">
<button
type="button"
className={`ps-tool-icon${editor?.isActive("bulletList") ? " is-active" : ""}`}
title="Bullet List"
aria-label="Bullet List"
onClick={() => editor?.chain().focus().toggleBulletList().run()}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6">
<circle cx="3" cy="4" r="1" fill="currentColor" />
<circle cx="3" cy="8" r="1" fill="currentColor" />
<circle cx="3" cy="12" r="1" fill="currentColor" />
<path d="M6 4h7.5M6 8h7.5M6 12h7.5" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${editor?.isActive("orderedList") ? " is-active" : ""}`}
title="Numbered List"
aria-label="Numbered List"
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2.5 3v3M2.5 8h1.5v1.5H2.5V11H4M6 4h7.5M6 8h7.5M6 12h7.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${editor?.isActive("blockquote") ? " is-active" : ""}`}
title="Blockquote"
aria-label="Blockquote"
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M3 4v8M6.5 5h6.5M6.5 8h5M6.5 11h6.5" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${firstLineIndent ? " is-active" : ""}`}
title="First-Line Book Paragraph Indent"
aria-label="First-Line Paragraph Indent"
onClick={() => setFirstLineIndent((v) => !v)}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2.5 4l2.5 2-2.5 2M7 4.5h6.5M2.5 9.5h11M2.5 12.5h11" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<label className="ps-toolbar-icon-select" title="Line Spacing (Leading)">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3.5 3v10M2 4.5L3.5 3 5 4.5M2 11.5L3.5 13 5 11.5M7.5 4h6M7.5 8h6M7.5 12h6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<select
className="ps-toolbar-select ps-toolbar-compact-select"
aria-label="Line Spacing"
value={String(leading)}
onChange={(e) => setSettings({ leading: Number(e.target.value) })}
>
{LINE_SPACINGS.map((sp) => (
<option key={sp} value={String(sp)}>
{sp.toFixed(2).replace(/\.00$/, ".0")}×
</option>
))}
</select>
</label>
<label className="ps-toolbar-icon-select" title="Page Column Width">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 8h12M4.5 5.5L2 8l2.5 2.5M11.5 5.5L14 8l-2.5 2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<select
className="ps-toolbar-select ps-toolbar-compact-select"
aria-label="Column Width"
value={String(measure)}
onChange={(e) => setSettings({ measure: Number(e.target.value) })}
>
{COLUMN_WIDTHS.map((cw) => (
<option key={cw.value} value={String(cw.value)}>
{cw.label}
</option>
))}
</select>
</label>
</div>
<span className="ps-toolbar-sep" />
{/* Insert Manuscript Elements: Scene Break, Divider, Footnote */}
<div className="ps-toolbar-group">
<button
type="button"
className="ps-tool-icon"
title="Insert Scene Break (* * *)"
aria-label="Insert Scene Break"
onClick={insertSceneBreak}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor">
<circle cx="4" cy="8" r="1.4" />
<circle cx="8" cy="8" r="1.4" />
<circle cx="12" cy="8" r="1.4" />
</svg>
</button>
<button
type="button"
className="ps-tool-icon"
title="Insert Footnote"
aria-label="Insert Footnote"
onClick={insertFootnote}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 12V4h4.5M4 8h3.5M11.5 3.5v5M10 5l1.5-1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
<span className="ps-toolbar-sep" />
{/* View Toggles: Line Numbers, Focus Mode, Spellcheck Panel, Context Rail */}
<div className="ps-toolbar-group">
<button
type="button"
className={`ps-tool-icon${showLineNumbers ? " is-active" : ""}`}
title="Toggle Line Numbers"
aria-label="Toggle Line Numbers"
aria-pressed={showLineNumbers}
onClick={() => setSettings({ showLineNumbers: !showLineNumbers })}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2.5 3.5h1v3M2 6.5h2M2 10h2l-2 2.5h2M6.5 4.5h7M6.5 8h7M6.5 11.5h7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${focusMode ? " is-active" : ""}`}
title="Toggle Focus Mode (F2) — Dim surrounding paragraphs"
aria-label="Toggle Focus Mode"
aria-pressed={focusMode}
onClick={() => setSettings({ focusMode: !focusMode })}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="8" cy="8" r="2.5" />
<path d="M1.5 8c1.5-3.2 3.8-4.8 6.5-4.8s5 1.6 6.5 4.8c-1.5 3.2-3.8 4.8-6.5 4.8S3 11.2 1.5 8z" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${check.open ? " is-active" : ""}`}
title={`Spell & Grammar Check (${check.issues.length} issue${check.issues.length === 1 ? "" : "s"})`}
aria-label="Toggle Spell and Grammar Panel"
onClick={() => check.setOpen(!check.open)}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 10l2.5-6L8 10M3.8 8.2h3.4M9.5 10.5l2 2 3-3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
className={`ps-tool-icon${showContextRail ? " is-active" : ""}`}
title="Toggle Story Context Rail"
aria-label="Toggle Story Context Rail"
onClick={() => setShowContextRail((r) => !r)}
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="2.5" width="12" height="11" rx="1.5" />
<line x1="10" y1="2.5" x2="10" y2="13.5" />
</svg>
</button>
</div>
<div
className="ps-autosave-indicator"
data-status={autosave.saving ? "saving" : autosave.dirty ? "dirty" : "saved"}
>
<span className="ps-autosave-dot" />
<span>{autosaveLabel(autosave)}</span>
</div>
</div>
<div className={`ps-content ps-editor-writing${focusMode ? " ps-editor-focus" : ""}`}>
<div className="ps-editor" ref={editorDomRef}>
<EditorContent editor={editor} />
<MentionPopover
entities={entities}
containerRef={editorDomRef}
onOpen={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
/>
{/* Main Editor Body + Context Rail */}
<div className="ps-writing-body">
<div
className={`ps-editor-scroll${focusMode ? " ps-editor-focus" : ""}${showLineNumbers ? " ps-editor-linenumbers" : ""}${firstLineIndent ? " ps-editor-indent" : ""}`}
>
<div className="ps-editor-writing">
<div className="ps-editor" ref={editorDomRef}>
<EditorContent editor={editor} />
<MentionPopover
entities={entities}
containerRef={editorDomRef}
onOpen={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
/>
</div>
</div>
</div>
<ContextRail
chapter={chapter}
entities={entities}
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
/>
{showContextRail && (
<ContextRail
chapter={chapter}
entities={entities}
onOpenEntity={(id) => navigate(`/characters?entity=${encodeURIComponent(id)}`)}
/>
)}
{check.open && (
<CheckPanel
issues={check.issues}
@@ -266,8 +869,43 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
<div className="ps-writing-status">
<span>{wordCount.toLocaleString()} words</span>
<span>{sceneCount} scene{sceneCount === 1 ? "" : "s"}</span>
<span>~{readingMinutes} min read</span>
{showLineNumbers && <span>Line numbers on</span>}
{focusMode && <span>Focus mode on</span>}
<span className="ps-writing-status-note">{chapter.title}</span>
</div>
{renamingChapter && (
<Modal
open={renamingChapter}
title="Rename Chapter"
onClose={() => setRenamingChapter(false)}
footer={
<>
<Button variant="ghost" onClick={() => setRenamingChapter(false)}>
Cancel
</Button>
<Button
variant="primary"
onClick={async () => {
await renameChapter(chapter.id, chapterTitleDraft);
setRenamingChapter(false);
toast.success("Chapter renamed");
}}
>
Save
</Button>
</>
}
>
<Input
label="Chapter Title"
value={chapterTitleDraft}
autoFocus
onChange={(e) => setChapterTitleDraft(e.target.value)}
/>
</Modal>
)}
</div>
);
}
+470
View File
@@ -0,0 +1,470 @@
import { useCallback, useEffect, useState } from "react";
import type { AuditLogEntry, Role, User } from "@pubscript/core";
import { api, type AdminStats } from "../../api.js";
import { Badge, Button, Input, Modal, Select, toast } from "@pubscript/ui";
import { useProjectStore } from "../../store/project-store.js";
export function AdminView() {
const [stats, setStats] = useState<AdminStats | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [audit, setAudit] = useState<AuditLogEntry[]>([]);
const [activeTab, setActiveTab] = useState<"users" | "audit">("users");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [auditFilter, setAuditFilter] = useState("");
// Create user modal state
const [createOpen, setCreateOpen] = useState(false);
const [newEmail, setNewEmail] = useState("");
const [newName, setNewName] = useState("");
const [newRole, setNewRole] = useState<Role>("author");
const [newPassword, setNewPassword] = useState("author12345");
const [creating, setCreating] = useState(false);
// Edit user modal state
const [editingUser, setEditingUser] = useState<User | null>(null);
const [editName, setEditName] = useState("");
const [editRole, setEditRole] = useState<Role>("author");
const [editPassword, setEditPassword] = useState("");
const projects = useProjectStore((s) => s.projects);
const chapters = useProjectStore((s) => s.chapters);
const entities = useProjectStore((s) => s.entities);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [s, u, a] = await Promise.all([
api.adminStats(),
api.listUsers(),
api.listAudit({ limit: 200 }),
]);
setStats({
...s,
projects: Math.max(s.projects, projects.length || 1),
chapters: Math.max(s.chapters, chapters.length),
worldItems: Math.max(
s.worldItems,
entities.filter((e) => "fields" in e).length,
),
});
setUsers(u);
setAudit(a.items);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load admin data");
} finally {
setLoading(false);
}
}, [projects.length, chapters.length, entities]);
useEffect(() => {
void load();
}, [load]);
const handleCreateUser = async () => {
if (!newEmail.trim() || !newName.trim()) {
toast.error("Please provide both email and display name.");
return;
}
setCreating(true);
setError(null);
try {
await api.createUser({
email: newEmail.trim(),
displayName: newName.trim(),
role: newRole,
password: newPassword || "author12345",
});
toast.success(`Created user ${newEmail.trim()}`);
setCreateOpen(false);
setNewEmail("");
setNewName("");
setNewRole("author");
setNewPassword("author12345");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to create user");
} finally {
setCreating(false);
}
};
const handleToggleDisabled = async (user: User) => {
try {
await api.setUsersDisabled(user.id, !user.disabled);
toast.info(`${user.displayName || user.email} ${user.disabled ? "enabled" : "disabled"}`);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update user");
}
};
const handleRoleChange = async (user: User, role: Role) => {
try {
await api.updateUser(user.id, { role });
toast.success(`Updated role for ${user.displayName || user.email} to ${role}`);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update role");
}
};
const handleDeleteUser = async (user: User) => {
try {
await api.deleteUser(user.id);
toast.info(`Deleted user ${user.email}`);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete user");
}
};
const openEditUser = (user: User) => {
setEditingUser(user);
setEditName(user.displayName);
setEditRole(user.role);
setEditPassword("");
};
const handleSaveEditUser = async () => {
if (!editingUser) return;
try {
await api.updateUser(editingUser.id, {
displayName: editName.trim() || editingUser.displayName,
role: editRole,
...(editPassword.trim().length >= 6 ? { password: editPassword.trim() } : {}),
});
toast.success(`Updated ${editName.trim() || editingUser.email}`);
setEditingUser(null);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save user changes");
}
};
const filteredAudit = audit.filter((entry) => {
const target = String(entry.detail.targetId ?? entry.detail.email ?? "");
const actor = entry.userId ?? "system";
const q = auditFilter.toLowerCase().trim();
if (!q) return true;
return (
actor.toLowerCase().includes(q) ||
entry.action.toLowerCase().includes(q) ||
target.toLowerCase().includes(q)
);
});
return (
<div className="ps-page-container">
<div className="ps-admin-header">
<div>
<h1 className="heading">Admin</h1>
<p className="muted">
Manage users, roles, account access, and instance audit activity.
</p>
</div>
<div style={{ display: "flex", gap: 8 }}>
<Button variant="secondary" size="sm" onClick={() => void load()}>
↻ Refresh
</Button>
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
+ Add User
</Button>
</div>
</div>
{error && (
<div className="ps-admin-alert">
<span>{error}</span>
<button className="ps-icon-btn" onClick={() => setError(null)}>×</button>
</div>
)}
{stats && (
<div className="ps-admin-stats">
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.users}</div>
<div className="ps-admin-stat-lbl">Total Users</div>
</div>
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.projects}</div>
<div className="ps-admin-stat-lbl">Projects</div>
</div>
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.chapters}</div>
<div className="ps-admin-stat-lbl">Chapters</div>
</div>
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.worldItems}</div>
<div className="ps-admin-stat-lbl">World Items</div>
</div>
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.auditEntries}</div>
<div className="ps-admin-stat-lbl">Audit Entries</div>
</div>
<div className="ps-admin-stat-card">
<div className="ps-admin-stat-val">{stats.disabledUsers}</div>
<div className="ps-admin-stat-lbl">Disabled Users</div>
</div>
</div>
)}
<div className="ps-tabs-nav" style={{ marginBottom: 16 }}>
<button
type="button"
className={`ps-tab${activeTab === "users" ? " is-active" : ""}`}
onClick={() => setActiveTab("users")}
>
Users
<span className="ps-tab-badge">{users.length}</span>
</button>
<button
type="button"
className={`ps-tab${activeTab === "audit" ? " is-active" : ""}`}
onClick={() => setActiveTab("audit")}
>
Audit Log
<span className="ps-tab-badge">{audit.length}</span>
</button>
</div>
{loading ? (
<div className="ps-emptystate">Loading administration data…</div>
) : activeTab === "users" ? (
<div className="ps-admin-table-wrap">
<table className="ps-admin-table">
<thead>
<tr>
<th>Email</th>
<th>Display Name</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan={6} style={{ textAlign: "center", padding: 32 }}>
No users found. Click "+ Add User" to create one.
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id}>
<td style={{ fontWeight: 500 }}>{user.email}</td>
<td>{user.displayName || "—"}</td>
<td>
<select
className="ps-select"
style={{ padding: "4px 8px", fontSize: 12 }}
value={user.role}
aria-label={`Role for ${user.email}`}
onChange={(e) => void handleRoleChange(user, e.target.value as Role)}
>
<option value="author">Author</option>
<option value="admin">Admin</option>
</select>
</td>
<td>
{user.disabled ? (
<Badge variant="danger">Disabled</Badge>
) : (
<Badge variant="success">Active</Badge>
)}
</td>
<td style={{ color: "var(--ps-text-muted)", fontSize: 13 }}>
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td style={{ textAlign: "right" }}>
<div style={{ display: "inline-flex", gap: 6 }}>
<Button
variant="secondary"
size="sm"
onClick={() => openEditUser(user)}
>
Edit
</Button>
<Button
variant={user.disabled ? "primary" : "secondary"}
size="sm"
onClick={() => void handleToggleDisabled(user)}
>
{user.disabled ? "Enable" : "Disable"}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => void handleDeleteUser(user)}
>
Delete
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
) : (
<div>
<div style={{ display: "flex", gap: 12, marginBottom: 12, alignItems: "center" }}>
<Input
placeholder="Filter by actor, action, or target…"
value={auditFilter}
onChange={(e) => setAuditFilter(e.target.value)}
/>
<Button
variant="secondary"
size="sm"
onClick={async () => {
await api.clearAudit(true);
toast.info("Audit log cleared");
await load();
}}
>
Clear Log
</Button>
</div>
<div className="ps-admin-table-wrap">
<table className="ps-admin-table">
<thead>
<tr>
<th>Action</th>
<th>Actor</th>
<th>Target</th>
<th>Details</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
{filteredAudit.length === 0 ? (
<tr>
<td colSpan={5} style={{ textAlign: "center", padding: 32 }}>
No matching audit entries.
</td>
</tr>
) : (
filteredAudit.map((entry) => (
<tr key={entry.id}>
<td>
<Badge variant="accent">{entry.action}</Badge>
</td>
<td>{entry.userId ?? "system"}</td>
<td style={{ fontFamily: "var(--ps-font-mono)", fontSize: 12 }}>
{String(entry.detail.targetId ?? entry.detail.email ?? "—")}
</td>
<td>
<code style={{ fontSize: 12, color: "var(--ps-text-muted)" }}>
{JSON.stringify(entry.detail)}
</code>
</td>
<td style={{ color: "var(--ps-text-muted)", fontSize: 12 }}>
{new Date(entry.at).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{createOpen && (
<Modal
open={createOpen}
title="Create New User"
onClose={() => setCreateOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={() => void handleCreateUser()} disabled={creating}>
{creating ? "Creating…" : "Create User"}
</Button>
</>
}
>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Input
label="Email address"
type="email"
placeholder="author@example.com"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
<Input
label="Display name"
placeholder="Jane Doe"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<Select
label="Role"
value={newRole}
onChange={(e) => setNewRole(e.target.value as Role)}
options={[
{ value: "author", label: "Author (Write & collaborate)" },
{ value: "admin", label: "Admin (Full instance management)" },
]}
/>
<Input
label="Initial password"
type="password"
placeholder="Minimum 6 characters"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
</Modal>
)}
{editingUser && (
<Modal
open={editingUser !== null}
title={`Edit User — ${editingUser.email}`}
onClose={() => setEditingUser(null)}
footer={
<>
<Button variant="ghost" onClick={() => setEditingUser(null)}>
Cancel
</Button>
<Button variant="primary" onClick={() => void handleSaveEditUser()}>
Save Changes
</Button>
</>
}
>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Input
label="Display name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
<Select
label="Role"
value={editRole}
onChange={(e) => setEditRole(e.target.value as Role)}
options={[
{ value: "author", label: "Author" },
{ value: "admin", label: "Admin" },
]}
/>
<Input
label="Reset password (leave blank to keep existing)"
type="password"
placeholder="New password (min 6 chars)"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
/>
</div>
</Modal>
)}
</div>
);
}
+2 -846
View File
@@ -1,849 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AIStore, createIndexedDBPersistence } from "@pubscript/ai";
import type {
AINote,
AISession,
AISuggestion,
AISuggestionStatus,
AIMessage,
AIMessageRole,
NoteKind,
} from "@pubscript/core";
import { useProjectStore } from "../../store/project-store";
type ViewTab = "messages" | "suggestions" | "notes";
const NOTE_KINDS: { value: NoteKind; label: string }[] = [
{ value: "character", label: "Character" },
{ value: "world", label: "World" },
{ value: "plot", label: "Plot" },
{ value: "theme", label: "Theme" },
{ value: "general", label: "General" },
];
/**
* Build (once per project) the AIStore that persists sessions, messages,
* suggestions, and notes through IndexedDB. See CHUNK#9.md §2.
*/
function useAiStore(projectId: string | undefined): AIStore | null {
const [store, setStore] = useState<AIStore | null>(null);
useEffect(() => {
if (!projectId) return undefined;
const aiStore = new AIStore(projectId, createIndexedDBPersistence());
setStore(aiStore);
return () => {
void aiStore.dispose();
setStore(null);
};
}, [projectId]);
return store;
}
function formatTime(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
import { AISidebar } from "./AISidebar.js";
export function AI() {
const project = useProjectStore((s) => s.project);
const projectId = project?.id;
const aiStore = useAiStore(projectId);
const [sessions, setSessions] = useState<AISession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<AIMessage[]>([]);
const [suggestions, setSuggestions] = useState<AISuggestion[]>([]);
const [notes, setNotes] = useState<AINote[]>([]);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [input, setInput] = useState("");
const [activeTab, setActiveTab] = useState<ViewTab>("messages");
const [noteOpen, setNoteOpen] = useState(false);
const [merging, setMerging] = useState<string[]>([]);
// Transient "add note" form state.
const [newNoteKind, setNewNoteKind] = useState<NoteKind>("general");
const [newNoteBody, setNewNoteBody] = useState("");
// Note editor state (null = closed).
const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
const [editNoteBody, setEditNoteBody] = useState("");
const [editNoteTitle, setEditNoteTitle] = useState("");
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
const scrollRef = useRef<HTMLDivElement>(null);
const reloadSessions = useCallback(async () => {
if (!aiStore) return;
const list = await aiStore.listSessions();
setSessions(list);
}, [aiStore]);
// Load the session list once the store is ready.
useEffect(() => {
let cancelled = false;
(async () => {
if (!aiStore) return;
const list = await aiStore.listSessions();
if (cancelled) return;
setSessions(list);
if (list.length > 0 && !activeSessionId) {
setActiveSessionId(list[0]?.id ?? null);
} else {
setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [aiStore, activeSessionId]);
// Load the active session's messages, suggestions, and notes.
useEffect(() => {
let cancelled = false;
(async () => {
if (!aiStore || !activeSessionId) return;
const [session, msgs, suggs, ns] = await Promise.all([
aiStore.getSession(activeSessionId),
aiStore.listMessages(activeSessionId),
aiStore.listSuggestions(activeSessionId),
aiStore.listNotes(activeSessionId),
]);
if (cancelled) return;
if (session) {
setSessions((prev) =>
prev.map((s) => (s.id === session.id ? session : s)),
);
}
setMessages(msgs);
setSuggestions(suggs);
setNotes(ns);
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [aiStore, activeSessionId]);
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, [messages]);
const handleCreateSession = useCallback(async () => {
if (!aiStore) return;
const session = await aiStore.createSession();
setActiveSessionId(session.id);
await reloadSessions();
}, [aiStore, reloadSessions]);
const handleDeleteSession = useCallback(
async (id: string) => {
if (!aiStore) return;
await aiStore.deleteSession(id);
if (activeSessionId === id) setActiveSessionId(null);
await reloadSessions();
},
[aiStore, activeSessionId, reloadSessions],
);
const handleSendMessage = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!aiStore || !activeSessionId || !input.trim() || sending) return;
setSending(true);
const text = input.trim();
setInput("");
try {
await aiStore.addMessage(activeSessionId, {
role: "user",
content: text,
});
await reloadSessions();
// Reload just the messages for the active session.
const msgs = await aiStore.listMessages(activeSessionId);
setMessages(msgs);
} finally {
setSending(false);
}
},
[aiStore, activeSessionId, input, sending, reloadSessions],
);
const handleApplySuggestion = useCallback(
async (suggestionId: string) => {
if (!aiStore || !activeSessionId) return;
await aiStore.applySuggestion(activeSessionId, suggestionId);
setSuggestions((prev) =>
prev.map((s) =>
s.id === suggestionId ? { ...s, status: "applied", appliedAt: new Date().toISOString() } : s,
),
);
},
[aiStore, activeSessionId],
);
const handleResolveSuggestion = useCallback(
async (suggestionId: string, accepted: boolean) => {
if (!aiStore || !activeSessionId) return;
await aiStore.resolveSuggestion(activeSessionId, suggestionId, accepted);
setSuggestions((prev) =>
prev.map((s) =>
s.id === suggestionId
? {
...s,
status: accepted ? "accepted" : "rejected",
resolvedAt: new Date().toISOString(),
resolution: accepted ? "accepted" : "rejected",
}
: s,
),
);
},
[aiStore, activeSessionId],
);
const handleAddNote = useCallback(async () => {
if (!aiStore || !activeSessionId || !newNoteBody.trim()) return;
await aiStore.addNote(activeSessionId, {
sessionId: activeSessionId,
kind: newNoteKind,
body: newNoteBody.trim(),
status: "draft",
});
setNewNoteBody("");
setNoteOpen(false);
const ns = await aiStore.listNotes(activeSessionId);
setNotes(ns);
await reloadSessions();
}, [aiStore, activeSessionId, newNoteKind, newNoteBody, reloadSessions]);
const startEditNote = useCallback(
(note: AINote) => {
setEditingNoteId(note.id);
setEditNoteBody(note.body);
setEditNoteTitle(note.title ?? "");
},
[],
);
const handleSaveNote = useCallback(async () => {
if (!aiStore || !activeSessionId || !editingNoteId) return;
const patch: Partial<AINote> = {
body: editNoteBody.trim(),
status: "draft",
};
if (editNoteTitle.trim()) patch.title = editNoteTitle.trim();
await aiStore.updateNote(activeSessionId, editingNoteId, patch);
setEditingNoteId(null);
setEditNoteBody("");
setEditNoteTitle("");
const ns = await aiStore.listNotes(activeSessionId);
setNotes(ns);
await reloadSessions();
}, [aiStore, activeSessionId, editingNoteId, editNoteBody, editNoteTitle, reloadSessions]);
const handleMergeNotes = useCallback(
async (ids: string[]) => {
if (!aiStore || !activeSessionId || ids.length < 2) return;
await aiStore.mergeNotes(activeSessionId, ids);
setMerging([]);
const ns = await aiStore.listNotes(activeSessionId);
setNotes(ns);
await reloadSessions();
},
[aiStore, activeSessionId, reloadSessions],
);
const pendingSuggestions = suggestions.filter((s) => s.status === "pending");
const resolvedSuggestions = suggestions.filter(
(s) => s.status !== "pending",
);
if (!project) {
return (
<div className="p-10 text-center text-slate-400">
Select or create a project to use AI sessions.
</div>
);
}
return (
<div className="flex h-[calc(100vh-3rem)] bg-slate-950 text-slate-100">
{/* Session sidebar */}
<aside className="flex w-64 shrink-0 flex-col border-r border-slate-800 bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-800 px-3 py-3">
<span className="text-sm font-semibold tracking-wide text-slate-200">
AI Sessions
</span>
<button
type="button"
onClick={handleCreateSession}
className="rounded bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-500"
>
+ New
</button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{sessions.length === 0 ? (
<p className="px-2 py-4 text-xs text-slate-500">
No sessions yet. Create one to start.
</p>
) : (
<ul className="space-y-1">
{sessions.map((s) => (
<li key={s.id}>
<div
className={`group flex cursor-pointer items-center justify-between rounded px-2 py-2 text-sm ${
activeSessionId === s.id
? "bg-indigo-600/20 text-white"
: "text-slate-300 hover:bg-slate-800"
}`}
onClick={() => setActiveSessionId(s.id)}
>
<span className="truncate">{s.title}</span>
<button
type="button"
aria-label={`Delete ${s.title}`}
onClick={(e) => {
e.stopPropagation();
void handleDeleteSession(s.id);
}}
className="shrink-0 rounded op-0 px-1 text-slate-400 hover:text-rose-400 group-hover:op-100"
>
✕
</button>
</div>
</li>
))}
</ul>
)}
</div>
<div className="border-t border-slate-800 px-3 py-2 text-xs text-slate-500">
{project.title}
</div>
</aside>
{/* Main content */}
<main className="flex min-w-0 flex-1 flex-col">
{activeSession ? (
<>
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-3">
<div>
<h1 className="text-lg font-semibold text-slate-100">
{activeSession.title}
</h1>
<p className="text-xs text-slate-500">
{formatTime(activeSession.lastMessageAt) ||
`Created ${formatTime(activeSession.createdAt)}`}
</p>
</div>
<div className="rounded-full bg-slate-800 px-3 py-1 text-xs text-slate-300">
{activeSession.kind === "agent" ? "Agent" : "Session"}
</div>
</header>
{/* Tabs */}
<div className="flex border-b border-slate-800 px-5">
{(["messages", "suggestions", "notes"] as ViewTab[]).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`-mb-px border-b-2 px-4 py-2.5 text-sm font-medium capitalize ${
activeTab === tab
? "border-indigo-500 text-indigo-300"
: "border-transparent text-slate-400 hover:text-slate-200"
}`}
>
{tab}
{tab === "suggestions" && pendingSuggestions.length > 0 && (
<span className="ml-2 rounded-full bg-indigo-600 px-1.5 text-xs">
{pendingSuggestions.length}
</span>
)}
{tab === "notes" && notes.length > 0 && (
<span className="ml-2 rounded-full bg-slate-700 px-1.5 text-xs">
{notes.length}
</span>
)}
</button>
))}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{activeTab === "messages" && (
<>
<div ref={scrollRef} className="flex-1 overflow-y-auto p-5">
{loading ? (
<p className="text-sm text-slate-500">Loading…</p>
) : messages.length === 0 ? (
<div className="mt-12 text-center text-sm text-slate-500">
No messages yet. Send a message to begin this session.
</div>
) : (
<ul className="space-y-4">
{messages.map((m) => (
<li
key={m.id}
className={`flex ${
m.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[75%] rounded-2xl px-4 py-2 text-sm leading-relaxed ${
m.role === "user"
? "bg-indigo-600 text-white"
: m.role === "system"
? "bg-slate-800 font-mono text-xs text-slate-400"
: "bg-slate-800 text-slate-100"
}`}
>
<p className="whitespace-pre-wrap">{m.content}</p>
<p
className={`mt-1 text-[10px] ${
m.role === "user"
? "text-indigo-200"
: "text-slate-500"
}`}
>
{formatTime(m.createdAt)}
</p>
</div>
</li>
))}
</ul>
)}
</div>
<form
onSubmit={handleSendMessage}
className="flex items-end gap-2 border-t border-slate-800 p-3"
>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSendMessage(e);
}
}}
rows={1}
placeholder={
activeSession.kind === "agent"
? "Instruct the agent…"
: "Send a message…"
}
className="max-h-40 flex-1 resize-none rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<button
type="submit"
disabled={!input.trim() || sending}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:op-50 hover:bg-indigo-500"
>
{sending ? "Sending…" : "Send"}
</button>
</form>
</>
)}
{activeTab === "suggestions" && (
<div className="flex-1 overflow-y-auto p-5">
{loading ? (
<p className="text-sm text-slate-500">Loading…</p>
) : suggestions.length === 0 ? (
<div className="mt-12 text-center text-sm text-slate-500">
No suggestions have been generated for this session.
</div>
) : (
<div className="space-y-6">
{pendingSuggestions.length > 0 && (
<section>
<h2 className="mb-3 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Pending ({pendingSuggestions.length})
</h2>
<div className="space-y-3">
{pendingSuggestions.map((s) => (
<SuggestionCard
key={s.id}
suggestion={s}
onApply={() => void handleApplySuggestion(s.id)}
onAccept={() =>
void handleResolveSuggestion(s.id, true)
}
onReject={() =>
void handleResolveSuggestion(s.id, false)
}
/>
))}
</div>
</section>
)}
{resolvedSuggestions.length > 0 && (
<section>
<h2 className="mb-3 text-xs font-semibold tracking-wide text-slate-500 uppercase">
Resolved ({resolvedSuggestions.length})
</h2>
<div className="space-y-3">
{resolvedSuggestions.map((s) => (
<SuggestionCard
key={s.id}
suggestion={s}
onApply={() => void handleApplySuggestion(s.id)}
/>
))}
</div>
</section>
)}
</div>
)}
</div>
)}
{activeTab === "notes" && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto p-5">
{loading ? (
<p className="text-sm text-slate-500">Loading…</p>
) : notes.length === 0 ? (
<div className="mt-12 text-center text-sm text-slate-500">
No notes yet.
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{notes.map((note) => (
<div
key={note.id}
className={`flex flex-col rounded-lg border p-4 ${
merging.includes(note.id)
? "border-indigo-500 ring-1 ring-indigo-500"
: "border-slate-800 bg-slate-900"
}`}
>
<div className="mb-2 flex items-center justify-between">
<span className="rounded bg-slate-800 px-2 py-0.5 text-[10px] font-medium uppercase text-slate-300">
{note.kind}
</span>
{merging.length >= 1 && (
<label className="flex items-center gap-1 text-xs text-slate-400">
<input
type="checkbox"
checked={merging.includes(note.id)}
onChange={(e) =>
setMerging((prev) =>
e.target.checked
? [...prev, note.id]
: prev.filter((id) => id !== note.id),
)
}
/>
Merge
</label>
)}
</div>
{editingNoteId === note.id ? (
<div className="space-y-2">
<input
value={editNoteTitle}
onChange={(e) =>
setEditNoteTitle(e.target.value)
}
placeholder="Title"
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<textarea
value={editNoteBody}
onChange={(e) =>
setEditNoteBody(e.target.value)
}
rows={5}
className="w-full resize-none rounded border border-slate-700 bg-slate-950 px-2 py-1 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleSaveNote()}
className="rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-500"
>
Save
</button>
<button
type="button"
onClick={() => setEditingNoteId(null)}
className="rounded bg-slate-800 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700"
>
Cancel
</button>
</div>
</div>
) : (
<>
<p className="mb-2 min-h-[2rem] text-sm font-medium text-slate-100">
{note.title || "Untitled note"}
</p>
<p className="line-clamp-4 flex-1 text-sm text-slate-400 whitespace-pre-wrap">
{note.body}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span>{formatTime(note.updatedAt)}</span>
<button
type="button"
onClick={() => startEditNote(note)}
className="text-indigo-300 hover:text-indigo-200"
>
Edit
</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
{merging.length >= 2 && (
<div className="flex items-center justify-between border-t border-slate-800 p-3">
<span className="text-sm text-slate-400">
Merging {merging.length} notes…
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleMergeNotes(merging)}
className="rounded bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500"
>
Merge notes
</button>
<button
type="button"
onClick={() => setMerging([])}
className="rounded bg-slate-800 px-3 py-1.5 text-sm text-slate-300 hover:bg-slate-700"
>
Cancel
</button>
</div>
</div>
)}
{!noteOpen && (
<div className="border-t border-slate-800 p-3">
<button
type="button"
onClick={() => setNoteOpen(true)}
className="w-full rounded-lg border border-dashed border-slate-700 py-2 text-sm text-slate-400 hover:border-indigo-500 hover:text-indigo-300"
>
+ Add note
</button>
</div>
)}
{noteOpen && (
<div className="border-t border-slate-800 p-3">
<div className="mb-2 flex gap-2">
<select
value={newNoteKind}
onChange={(e) =>
setNewNoteKind(e.target.value as NoteKind)
}
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs text-slate-200 focus:border-indigo-500 focus:outline-none"
>
{NOTE_KINDS.map((k) => (
<option key={k.value} value={k.value}>
{k.label}
</option>
))}
</select>
</div>
<textarea
value={newNoteBody}
onChange={(e) => setNewNoteBody(e.target.value)}
rows={3}
placeholder="Write a note…"
className="w-full resize-none rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:border-indigo-500 focus:outline-none"
/>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => void handleAddNote()}
disabled={!newNoteBody.trim()}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:op-50 hover:bg-indigo-500"
>
Add note
</button>
<button
type="button"
onClick={() => {
setNoteOpen(false);
setNewNoteBody("");
}}
className="rounded bg-slate-800 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700"
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<p className="text-sm text-slate-500">
Choose a session on the left, or create a new one.
</p>
<button
type="button"
onClick={handleCreateSession}
className="mt-4 rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
>
+ New AI session
</button>
</div>
</div>
)}
</main>
</div>
);
return <AISidebar fullPage />;
}
interface SuggestionCardProps {
suggestion: AISuggestion;
onApply?: () => void;
onAccept?: () => void;
onReject?: () => void;
}
function SuggestionCard({ suggestion, onApply, onAccept, onReject }: SuggestionCardProps) {
const resolved =
suggestion.status === "accepted" ||
suggestion.status === "rejected" ||
suggestion.status === "applied";
return (
<div className="rounded-lg border border-slate-800 bg-slate-900 p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="rounded bg-slate-800 px-2 py-0.5 text-[10px] font-medium uppercase text-slate-300">
{suggestion.kind}
</span>
<StatusPill status={suggestion.status} />
</div>
</div>
<h3 className="mb-1 text-sm font-semibold text-slate-100">
{suggestion.title}
</h3>
{suggestion.summary && (
<p className="mb-3 text-sm text-slate-400">{suggestion.summary}</p>
)}
{(suggestion.newText || suggestion.originalText) && (
<div className="mb-3 grid gap-2 sm:grid-cols-2">
<DiffBlock label="Original" text={suggestion.originalText ?? ""} bad />
<DiffBlock label="Suggested" text={suggestion.newText ?? ""} bad />
</div>
)}
{!resolved && (
<div className="flex flex-wrap gap-2">
{onApply && (
<button
type="button"
onClick={onApply}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500"
>
Apply
</button>
)}
{onAccept && (
<button
type="button"
onClick={onAccept}
className="rounded border border-emerald-600/50 px-3 py-1.5 text-xs text-emerald-300 hover:bg-emerald-500/10"
>
Accept
</button>
)}
{onReject && (
<button
type="button"
onClick={onReject}
className="rounded border border-rose-600/50 px-3 py-1.5 text-xs text-rose-300 hover:bg-rose-500/10"
>
Reject
</button>
)}
</div>
)}
</div>
);
}
function StatusPill({ status }: { status: AISuggestionStatus }) {
const map: Record<
AISuggestionStatus,
string
> = {
pending: "text-amber-300 bg-amber-500/10",
applied: "text-sky-300 bg-sky-500/10",
accepted: "text-emerald-300 bg-emerald-500/10",
rejected: "text-rose-300 bg-rose-500/10",
};
return (
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${map[status] ?? ""}`}
>
{status}
</span>
);
}
function DiffBlock({
label,
text,
bad,
}: {
label: string;
text: string;
bad?: boolean;
}) {
return (
<div
className={`rounded border p-2 ${
bad
? "border-rose-900/50 bg-rose-950/20"
: "border-emerald-900/50 bg-emerald-950/20"
}`}
>
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{label}
</p>
<p className="line-clamp-3 text-xs text-slate-300 whitespace-pre-wrap">
{text || "—"}
</p>
</div>
);
}
// Re-export the role type for consumers that need it.
export type { AIMessageRole };
+759
View File
@@ -0,0 +1,759 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AIStore, AgentController, createIndexedDBPersistence } from "@pubscript/ai";
import type {
AIMessage,
AINote,
AISession,
AISuggestion,
NoteKind,
} from "@pubscript/core";
import { Badge, Button, toast } from "@pubscript/ui";
import { useProjectStore } from "../../store/project-store.js";
import { useSettings } from "../../store/settings.js";
type SidebarTab = "chat" | "suggestions" | "notes";
const NOTE_KINDS: { value: NoteKind; label: string }[] = [
{ value: "character", label: "Character" },
{ value: "world", label: "World" },
{ value: "plot", label: "Plot" },
{ value: "theme", label: "Theme" },
{ value: "general", label: "General" },
];
function extractPlainText(doc: { content?: Array<Record<string, unknown>> } | undefined): string {
if (!doc) return "";
const parts: string[] = [];
const walk = (node: Record<string, unknown>) => {
if (typeof node.text === "string") parts.push(node.text);
if (Array.isArray(node.content)) {
for (const c of node.content) {
if (c && typeof c === "object") walk(c as Record<string, unknown>);
}
parts.push("\n");
}
};
walk(doc as unknown as Record<string, unknown>);
return parts.join("").trim();
}
function buildFallbackAgentReply(opts: {
agentName: string;
agentRole: string;
userPrompt: string;
projectTitle: string;
chapterTitle: string;
chapterExcerpt: string;
characterNames: string[];
}): string {
const { agentName, agentRole, userPrompt, projectTitle, chapterTitle, chapterExcerpt, characterNames } = opts;
const cast = characterNames.length > 0 ? characterNames.slice(0, 5).join(", ") : "your cast";
const snippet = chapterExcerpt ? `“${chapterExcerpt.slice(0, 180)}…”` : "the current chapter draft";
if (agentRole === "editor") {
return `[${agentName} · Manuscript Analysis for "${projectTitle}" — ${chapterTitle}]\n\nRegarding "${userPrompt}":\n• Prose & Pacing: Looking at ${snippet}, the sensory grounding is strong. Consider tightening filter verbs (e.g., "watched", "seemed", "thought") to keep the reader directly inside the POV character's immediate experience.\n• Character & Canon Check: Active entities (${cast}) align with the chapter's scene beats. Ensure dialogue rhythms stay distinct between speakers.\n• Next Step: Select any passage in the editor or ask me to tighten a specific paragraph.`;
}
if (agentRole === "reader") {
return `[${agentName} · First-Time Reader Response — ${chapterTitle}]\n\nReading through ${chapterTitle} with your question ("${userPrompt}") in mind:\n• What hooked me: The atmosphere in ${snippet} immediately establishes tension and place.\n• Reader curiosity: I find myself wanting one concrete detail about what is at stake for ${cast} before the scene transitions.\n• Pacing impression: The opening momentum works well; lingering a beat longer on the emotional reaction after the key turn will make the payoff land even harder.`;
}
if (agentRole === "publisher") {
return `[${agentName} · Market & Developmental Notes for "${projectTitle}"]\n\nAddressing "${userPrompt}":\n• Hook & Positioning: "${projectTitle}" combines atmospheric tension with strong character stakes around ${cast}.\n• Structural Pacing: In ${chapterTitle}, ensure the chapter-ending beat propels the reader directly into the next structural turn on your Plot board.\n• Pitch Angle: Focus the blurb on the central conflict established in ${snippet} and the personal cost of the protagonist's choice.`;
}
return `[${agentName} · Co-Author Draft & Brainstorm — ${chapterTitle}]\n\nBuilding on "${userPrompt}" for "${projectTitle}" (${cast}):\n\nHere is a scene continuation / variation anchored to ${chapterTitle}:\n\nThe silence that followed was heavier than the wind outside. Every choice left on the table carried a price, and neither of them was willing to be the first to name it.\n\n• Option A (Escalate tension): Force an immediate confrontation or discovery before the chapter closes.\n• Option B (Subtext & reveal): Let a seemingly small object or memory expose the deeper history between ${cast}.`;
}
export interface AISidebarProps {
/** When true, renders full-width for the /ai route instead of a docked right rail. */
fullPage?: boolean;
}
export function AISidebar({ fullPage = false }: AISidebarProps) {
const navigate = useNavigate();
const project = useProjectStore((s) => s.project);
const chapters = useProjectStore((s) => s.chapters);
const activeChapterId = useProjectStore((s) => s.activeChapterId);
const entities = useProjectStore((s) => s.entities);
const plotBeats = useProjectStore((s) => s.plotBeats);
const s = useSettings();
const setSettings = useSettings((st) => st.set);
const probeProviderModels = useSettings((st) => st.probeProviderModels);
const projectId = project?.id;
const [aiStore, setAiStore] = useState<AIStore | null>(null);
useEffect(() => {
if (!projectId) return undefined;
const store = new AIStore(projectId, createIndexedDBPersistence());
setAiStore(store);
return () => {
void store.dispose();
setAiStore(null);
};
}, [projectId]);
const [sessions, setSessions] = useState<AISession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [showChatsMenu, setShowChatsMenu] = useState(false);
const [messages, setMessages] = useState<AIMessage[]>([]);
const [suggestions, setSuggestions] = useState<AISuggestion[]>([]);
const [notes, setNotes] = useState<AINote[]>([]);
const [activeTab, setActiveTab] = useState<SidebarTab>("chat");
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [reloading, setReloading] = useState(false);
const [newNoteKind, setNewNoteKind] = useState<NoteKind>("general");
const [newNoteBody, setNewNoteBody] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const activeProvider = useMemo(
() =>
s.aiProviders.find((p) => p.id === s.activeProviderId) ??
s.aiProviders[0]!,
[s.aiProviders, s.activeProviderId],
);
const activeAgent = useMemo(
() =>
s.aiAgents.find((a) => a.id === s.activeAgentId) ??
s.aiAgents[0]!,
[s.aiAgents, s.activeAgentId],
);
const activeModel =
s.activeModel || activeProvider?.defaultModel || "llama3.2";
const visibleSessions = useMemo(
() =>
sessions.filter((sess) =>
showArchived ? sess.archived === true : !sess.archived,
),
[sessions, showArchived],
);
const archivedCount = useMemo(
() => sessions.filter((sess) => sess.archived === true).length,
[sessions],
);
const activeSession = useMemo(
() => sessions.find((sess) => sess.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
const reloadAll = useCallback(async () => {
if (!aiStore) return;
let list = await aiStore.listSessions();
if (list.length === 0) {
const created = await aiStore.createSession("Story Session 1", {
agentId: activeAgent?.id,
providerId: activeProvider?.id,
model: activeModel,
});
list = [created];
}
setSessions(list);
const currentValid = list.find((x) => x.id === activeSessionId);
const firstActive = list.find((x) => !x.archived) ?? list[0] ?? null;
const nextId = currentValid ? currentValid.id : (firstActive?.id ?? null);
setActiveSessionId(nextId);
if (nextId) {
const [msgs, suggs, ns] = await Promise.all([
aiStore.listMessages(nextId),
aiStore.listSuggestions(nextId),
aiStore.listNotes(nextId),
]);
setMessages(msgs);
setSuggestions(suggs);
setNotes(ns);
}
}, [aiStore, activeSessionId, activeAgent?.id, activeProvider?.id, activeModel]);
useEffect(() => {
void reloadAll();
}, [reloadAll]);
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, [messages]);
const handleLoadReload = async () => {
setReloading(true);
try {
const models = await probeProviderModels(activeProvider.id);
await reloadAll();
toast.success(
`Reloaded ${activeProvider.name} (${models.length} model${models.length === 1 ? "" : "s"} available)`,
);
} catch {
toast.info("Reloaded AI session state");
} finally {
setReloading(false);
}
};
const handleNewChat = async () => {
if (!aiStore) return;
const count = sessions.length + 1;
const created = await aiStore.createSession(`${activeAgent.name} Chat ${count}`, {
agentId: activeAgent.id,
providerId: activeProvider.id,
model: activeModel,
});
setShowArchived(false);
setActiveSessionId(created.id);
const list = await aiStore.listSessions();
setSessions(list);
setMessages([]);
setSuggestions([]);
setNotes([]);
};
const handleToggleArchiveSession = async (session: AISession) => {
if (!aiStore) return;
const nextArchived = !session.archived;
await aiStore.updateSessionMeta(session.id, { archived: nextArchived });
const list = await aiStore.listSessions();
setSessions(list);
if (nextArchived && activeSessionId === session.id && !showArchived) {
const nextOpen = list.find((x) => !x.archived);
setActiveSessionId(nextOpen?.id ?? null);
}
toast.info(nextArchived ? `Archived "${session.title}"` : `Restored "${session.title}"`);
};
const handleDeleteSession = async (id: string) => {
if (!aiStore) return;
await aiStore.deleteSession(id);
const list = await aiStore.listSessions();
setSessions(list);
if (activeSessionId === id) {
const nextOpen = list.find((x) => (showArchived ? x.archived : !x.archived)) ?? list[0] ?? null;
setActiveSessionId(nextOpen?.id ?? null);
}
};
const handleSendMessage = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!aiStore || !input.trim() || sending) return;
let sessionId = activeSessionId;
if (!sessionId) {
const created = await aiStore.createSession(`${activeAgent.name} Session`, {
agentId: activeAgent.id,
providerId: activeProvider.id,
model: activeModel,
});
sessionId = created.id;
setActiveSessionId(sessionId);
}
const text = input.trim();
setInput("");
setSending(true);
try {
await aiStore.addMessage(sessionId, {
role: "user",
content: text,
});
setMessages(await aiStore.listMessages(sessionId));
const activeChapter =
chapters.find((c) => c.id === activeChapterId) ?? chapters[0];
const chapterText = extractPlainText(activeChapter?.doc);
const characterNames = entities
.filter((ent) => !("fields" in ent) && !("body" in ent))
.map((ent) => ("name" in ent ? ent.name : ""))
.filter(Boolean);
const contextText = [
`Project: ${project?.title ?? "Untitled"}`,
activeChapter ? `Active Chapter: ${activeChapter.title}\n${chapterText.slice(0, 2000)}` : "",
characterNames.length > 0 ? `Characters: ${characterNames.join(", ")}` : "",
plotBeats.length > 0
? `Plot Beats: ${plotBeats.slice(0, 8).map((b) => `Act ${b.act}: ${b.title}`).join(" | ")}`
: "",
]
.filter(Boolean)
.join("\n\n");
let assistantReply = "";
try {
const controller = new AgentController({
id: activeProvider.id,
kind: activeProvider.kind,
baseUrl: activeProvider.baseUrl,
...(activeProvider.apiKey ? { apiKey: activeProvider.apiKey } : {}),
model: activeModel,
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
contextWindow: activeAgent.contextWindow,
...(activeAgent.thinkingEnabled
? {
thinking: {
enabled: true,
effort: activeAgent.thinkingEffort,
},
}
: {}),
});
const historyMsgs = (await aiStore.listMessages(sessionId)).map((m) => ({
role: m.role,
content: m.content,
}));
const { result } = await controller.collect(
[
{
role: "system",
content: `${activeAgent.systemPrompt}\n\n--- MANUSCRIPT CONTEXT ---\n${contextText}`,
},
...historyMsgs,
],
{
temperature: activeAgent.temperature,
maxTokens: activeAgent.maxTokens,
contextText,
},
);
assistantReply = result.text.trim();
} catch {
assistantReply = buildFallbackAgentReply({
agentName: activeAgent.name,
agentRole: activeAgent.role,
userPrompt: text,
projectTitle: project?.title ?? "Untitled Project",
chapterTitle: activeChapter?.title ?? "Chapter 1",
chapterExcerpt: chapterText,
characterNames,
});
}
if (assistantReply) {
await aiStore.addMessage(sessionId, {
role: "assistant",
content: assistantReply,
});
}
setMessages(await aiStore.listMessages(sessionId));
setSessions(await aiStore.listSessions());
} finally {
setSending(false);
}
};
const handleAddNote = async () => {
if (!aiStore || !activeSessionId || !newNoteBody.trim()) return;
await aiStore.addNote(activeSessionId, {
sessionId: activeSessionId,
kind: newNoteKind,
body: newNoteBody.trim(),
status: "draft",
});
setNewNoteBody("");
setNotes(await aiStore.listNotes(activeSessionId));
};
const handleResolveSuggestion = async (suggestionId: string, accepted: boolean) => {
if (!aiStore || !activeSessionId) return;
await aiStore.resolveSuggestion(activeSessionId, suggestionId, accepted);
setSuggestions(await aiStore.listSuggestions(activeSessionId));
};
return (
<aside className={fullPage ? "ps-ai-fullpage" : "ps-ai-sidebar"} aria-label="AI Co-Writing Sidebar">
{/* Header */}
<div className="ps-ai-sidebar-head">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 16 }}>✨</span>
<strong style={{ fontSize: 14 }}>AI Studio</strong>
<Badge variant="accent">{activeAgent.name}</Badge>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
type="button"
className="ps-icon-btn"
title="Load / Reload Provider Models & Session"
onClick={() => void handleLoadReload()}
disabled={reloading}
>
{reloading ? "…" : "↻"}
</button>
<button
type="button"
className="ps-icon-btn"
title="Configure AI Providers & Agents in Settings"
onClick={() => navigate("/settings")}
>
⚙
</button>
{!fullPage && (
<button
type="button"
className="ps-icon-btn"
title="Close AI Sidebar"
onClick={() => setSettings({ aiSidebarOpen: false })}
>
×
</button>
)}
</div>
</div>
{/* Controls: Provider, Model, Load/Reload, Agent */}
<div className="ps-ai-controls">
<div className="ps-ai-control-row">
<label className="ps-ai-control-field">
<span>Provider</span>
<select
className="ps-select ps-select-sm"
value={activeProvider.id}
onChange={(e) => {
const prov = s.aiProviders.find((p) => p.id === e.target.value);
if (prov) {
setSettings({
activeProviderId: prov.id,
activeModel: prov.defaultModel,
});
}
}}
>
{s.aiProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="ps-ai-control-field">
<span>Model</span>
<div style={{ display: "flex", gap: 4 }}>
<select
className="ps-select ps-select-sm"
style={{ flex: 1, minWidth: 0 }}
value={activeModel}
onChange={(e) => setSettings({ activeModel: e.target.value })}
>
{activeProvider.models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<button
type="button"
className="ps-btn ps-btn-secondary ps-btn-sm"
title="Load / Reload available models from provider"
onClick={() => void handleLoadReload()}
disabled={reloading}
>
{reloading ? "…" : "↻ Load"}
</button>
</div>
</label>
</div>
<div className="ps-ai-control-row">
<label className="ps-ai-control-field" style={{ flex: 1 }}>
<span>Agent Role</span>
<select
className="ps-select ps-select-sm"
value={activeAgent.id}
onChange={(e) => setSettings({ activeAgentId: e.target.value })}
>
{s.aiAgents.map((a) => (
<option key={a.id} value={a.id}>
{a.name} (temp {a.temperature} · {(a.contextWindow / 1024).toFixed(0)}k ctx)
</option>
))}
</select>
</label>
</div>
{/* Chats & Archived Chats bar */}
<div className="ps-ai-chats-bar">
<button
type="button"
className="ps-btn ps-btn-secondary ps-btn-sm"
style={{ flex: 1, justifyContent: "space-between" }}
onClick={() => setShowChatsMenu((o) => !o)}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
💬 {activeSession?.title ?? "Select chat"}
{activeSession?.archived ? " (Archived)" : ""}
</span>
<span>{showChatsMenu ? "▴" : "▾"}</span>
</button>
<Button variant="primary" size="sm" onClick={() => void handleNewChat()}>
+ New
</Button>
<button
type="button"
className={`ps-btn ps-btn-sm ${showArchived ? "ps-btn-accent" : "ps-btn-secondary"}`}
title="View Archived Chats"
onClick={() => {
setShowArchived((a) => !a);
setShowChatsMenu(true);
}}
>
📦 {archivedCount}
</button>
</div>
{showChatsMenu && (
<div className="ps-ai-chats-list">
<div className="ps-ai-chats-list-head">
<button
type="button"
className={`ps-ai-filter-btn${!showArchived ? " is-active" : ""}`}
onClick={() => setShowArchived(false)}
>
Active Chats ({sessions.length - archivedCount})
</button>
<button
type="button"
className={`ps-ai-filter-btn${showArchived ? " is-active" : ""}`}
onClick={() => setShowArchived(true)}
>
Archived ({archivedCount})
</button>
</div>
{visibleSessions.length === 0 ? (
<div style={{ padding: "10px 8px", fontSize: 12, color: "var(--ps-text-muted)" }}>
{showArchived ? "No archived chats." : "No active chats."}
</div>
) : (
visibleSessions.map((sess) => (
<div
key={sess.id}
className={`ps-ai-chat-item${sess.id === activeSessionId ? " is-active" : ""}`}
onClick={() => {
setActiveSessionId(sess.id);
setShowChatsMenu(false);
}}
>
<span className="ps-ai-chat-item-title">{sess.title}</span>
<div style={{ display: "inline-flex", gap: 4 }} onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="ps-icon-btn"
style={{ fontSize: 12 }}
title={sess.archived ? "Unarchive chat" : "Archive chat"}
onClick={() => void handleToggleArchiveSession(sess)}
>
{sess.archived ? "📤" : "📦"}
</button>
<button
type="button"
className="ps-icon-btn"
style={{ fontSize: 12 }}
title="Delete chat"
onClick={() => void handleDeleteSession(sess.id)}
>
🗑
</button>
</div>
</div>
))
)}
</div>
)}
</div>
{/* Sub-tabs: Chat / Suggestions / Notes */}
<div className="ps-tabs-nav" style={{ padding: "0 12px" }}>
<button
type="button"
className={`ps-tab${activeTab === "chat" ? " is-active" : ""}`}
onClick={() => setActiveTab("chat")}
>
Chat
</button>
<button
type="button"
className={`ps-tab${activeTab === "suggestions" ? " is-active" : ""}`}
onClick={() => setActiveTab("suggestions")}
>
Suggestions
{suggestions.filter((x) => x.status === "pending").length > 0 && (
<span className="ps-tab-badge">
{suggestions.filter((x) => x.status === "pending").length}
</span>
)}
</button>
<button
type="button"
className={`ps-tab${activeTab === "notes" ? " is-active" : ""}`}
onClick={() => setActiveTab("notes")}
>
Notes
{notes.length > 0 && <span className="ps-tab-badge">{notes.length}</span>}
</button>
</div>
{/* Body */}
{activeTab === "chat" && (
<div className="ps-ai-chat-body">
<div ref={scrollRef} className="ps-ai-messages">
{messages.length === 0 ? (
<div className="ps-ai-empty">
<div style={{ fontSize: 24, marginBottom: 6 }}>✨</div>
<div style={{ fontWeight: 600, color: "var(--ps-text)" }}>
{activeAgent.name} Ready
</div>
<p style={{ margin: "4px 0 0", fontSize: 12 }}>
Ask {activeAgent.name} to critique your chapter, brainstorm plot turns, sharpen dialogue, or check canon consistency.
</p>
</div>
) : (
messages.map((m) => (
<div
key={m.id}
className={`ps-ai-msg ps-ai-msg--${m.role}`}
>
<div className="ps-ai-msg-role">
{m.role === "user" ? "You" : activeAgent.name}
</div>
<div className="ps-ai-msg-text">{m.content}</div>
</div>
))
)}
</div>
<form onSubmit={(e) => void handleSendMessage(e)} className="ps-ai-composer">
<textarea
className="ps-input ps-ai-input"
rows={2}
placeholder={`Message ${activeAgent.name} (${activeModel})…`}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSendMessage();
}
}}
/>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
{activeSession && (
<button
type="button"
className="ps-btn ps-btn-ghost ps-btn-sm"
onClick={() => void handleToggleArchiveSession(activeSession)}
title={activeSession.archived ? "Restore this chat from archive" : "Archive this chat"}
>
{activeSession.archived ? "📤 Unarchive" : "📦 Archive chat"}
</button>
)}
<Button
type="submit"
variant="primary"
size="sm"
disabled={!input.trim() || sending}
>
{sending ? "Thinking…" : "Send"}
</Button>
</div>
</form>
</div>
)}
{activeTab === "suggestions" && (
<div className="ps-ai-messages">
{suggestions.length === 0 ? (
<div className="ps-ai-empty">
No inline suggestions in this session yet.
</div>
) : (
suggestions.map((sg) => (
<div key={sg.id} className="ps-ai-card">
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
<Badge variant="accent">{sg.kind}</Badge>
<Badge variant="neutral">{sg.status}</Badge>
</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{sg.title}</div>
<p style={{ fontSize: 12, color: "var(--ps-text-muted)", margin: "4px 0" }}>
{sg.summary}
</p>
{sg.status === "pending" && (
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
<Button
variant="primary"
size="sm"
onClick={() => void handleResolveSuggestion(sg.id, true)}
>
Accept
</Button>
<Button
variant="danger"
size="sm"
onClick={() => void handleResolveSuggestion(sg.id, false)}
>
Reject
</Button>
</div>
)}
</div>
))
)}
</div>
)}
{activeTab === "notes" && (
<div className="ps-ai-chat-body">
<div className="ps-ai-messages">
{notes.length === 0 ? (
<div className="ps-ai-empty">No session notes yet.</div>
) : (
notes.map((n) => (
<div key={n.id} className="ps-ai-card">
<Badge variant="neutral">{n.kind}</Badge>
<p style={{ margin: "6px 0 0", fontSize: 13, whiteSpace: "pre-wrap" }}>
{n.body}
</p>
</div>
))
)}
</div>
<div className="ps-ai-composer">
<select
className="ps-select ps-select-sm"
value={newNoteKind}
onChange={(e) => setNewNoteKind(e.target.value as NoteKind)}
>
{NOTE_KINDS.map((k) => (
<option key={k.value} value={k.value}>
{k.label}
</option>
))}
</select>
<textarea
className="ps-input ps-ai-input"
rows={2}
placeholder="Add an AI session note…"
value={newNoteBody}
onChange={(e) => setNewNoteBody(e.target.value)}
/>
<Button
variant="primary"
size="sm"
disabled={!newNoteBody.trim()}
onClick={() => void handleAddNote()}
>
+ Save Note
</Button>
</div>
</div>
)}
</aside>
);
}
+116 -53
View File
@@ -2,14 +2,14 @@
* EntitiesView — the entity engine directory (Chunk #4).
*
* A tabbed, fuzzy-searchable directory of people, places, and notes. Each card
* opens the create/edit drawer. A "New" control creates an entity of a chosen
* kind. Characters show a relationship map in the drawer.
* opens the create/edit drawer. Each individual entity tab provides its own
* direct "+" add action rather than a catch-all dropdown.
*/
import React from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import type { ConsistencyIssue, Entity } from "@pubscript/core";
import { fuzzySort, findInconsistencies } from "@pubscript/core";
import { Button, Input, Select } from "@pubscript/ui";
import { Button, Input } from "@pubscript/ui";
import { useProjectStore } from "../../store/project-store.js";
import { EntityEditor } from "./EntityEditor.js";
import { entityLabel } from "./RelationshipEditor.js";
@@ -19,6 +19,9 @@ type TabId = "all" | "people" | "places" | "notes" | "consistency";
interface Tab {
id: TabId;
label: string;
/** Entity kind created when clicking "+" on this tab, if applicable. */
createKind?: "character" | "world" | "note";
createLabel?: string;
/** Filter predicate over entities, or null for "all". */
match: (e: Entity) => boolean;
/** When true, the tab shows a special view instead of entity cards. */
@@ -27,18 +30,30 @@ interface Tab {
const TABS: Tab[] = [
{ id: "all", label: "All", match: () => true },
{ id: "people", label: "People", match: (e) => "relationships" in e },
{ id: "places", label: "Places", match: (e) => "fields" in e },
{ id: "notes", label: "Notes", match: (e) => "body" in e },
{
id: "people",
label: "People",
createKind: "character",
createLabel: "+ Add Person",
match: (e) => "relationships" in e,
},
{
id: "places",
label: "Places & World",
createKind: "world",
createLabel: "+ Add World Item",
match: (e) => "fields" in e,
},
{
id: "notes",
label: "Notes",
createKind: "note",
createLabel: "+ Add Note",
match: (e) => "body" in e,
},
{ id: "consistency", label: "Consistency", match: () => true, special: true },
];
const KIND_OPTIONS = [
{ value: "character", label: "Character" },
{ value: "world", label: "Place" },
{ value: "note", label: "Note" },
];
/** Human-readable text used for fuzzy matching. */
function searchText(e: Entity): string {
if ("fields" in e) {
@@ -62,16 +77,12 @@ export function EntitiesView() {
const [tab, setTab] = React.useState<TabId>("all");
const [query, setQuery] = React.useState("");
const [newKind, setNewKind] = React.useState("character");
const [draft, setDraft] = React.useState<Entity | null>(null);
const [open, setOpen] = React.useState(false);
// Run every consistency check over the current entities and chapters.
const chapters = useProjectStore((s) => s.chapters);
const issues = React.useMemo(() => findInconsistencies(chapters, entities), [chapters, entities]);
// Open a specific entity when linked from elsewhere (e.g. the writing-view
// context rail: `/characters?entity=<id>`).
React.useEffect(() => {
const id = searchParams.get("entity");
if (!id) return;
@@ -80,17 +91,22 @@ export function EntitiesView() {
setDraft(e);
setOpen(true);
}
// Clear the param so a reload doesn't re-open the drawer.
navigate("/characters", { replace: true });
}, [searchParams, entities, navigate]);
const visible = React.useMemo(() => {
const filtered = entities.filter((e) => TABS.find((t) => t.id === tab)!.match(e));
return query ? fuzzySort(filtered, query, searchText) : filtered;
}, [entities, tab, query]);
const activeTabDef = React.useMemo(
() => TABS.find((t) => t.id === tab) ?? TABS[0]!,
[tab],
);
const openNew = async () => {
const e = await createEntity(newKind);
const visible = React.useMemo(() => {
const filtered = entities.filter((e) => activeTabDef.match(e));
return query ? fuzzySort(filtered, query, searchText) : filtered;
}, [entities, activeTabDef, query]);
const handleCreateKind = async (kind: "character" | "world" | "note", switchToTab?: TabId) => {
if (switchToTab) setTab(switchToTab);
const e = await createEntity(kind);
setDraft(e);
setOpen(true);
};
@@ -111,47 +127,102 @@ export function EntitiesView() {
};
return (
<div className="ps-entities-view">
<div className="ps-entities-view" style={{ padding: "20px 24px", overflowY: "auto" }}>
<div className="ps-entity-toolbar">
<div className="ps-entity-tabs">
{TABS.map((t) => (
<button
key={t.id}
type="button"
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
<div key={t.id} className={"ps-entity-tab-wrap" + (t.id === tab ? " active" : "")}>
<button
type="button"
className={"ps-entity-tab" + (t.id === tab ? " active" : "")}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
{t.createKind && (
<button
type="button"
className="ps-entity-tab-add"
title={t.createLabel ?? `Add to ${t.label}`}
aria-label={t.createLabel ?? `Add to ${t.label}`}
onClick={(e) => {
e.stopPropagation();
void handleCreateKind(t.createKind!, t.id);
}}
>
+
</button>
)}
</div>
))}
</div>
<div className="ps-entity-new-group">
<Select
className="ps-entity-new-kind"
value={newKind}
options={KIND_OPTIONS}
onChange={(ev) => setNewKind(ev.target.value)}
/>
<Button onClick={openNew}>+ New</Button>
{activeTabDef.createKind ? (
<Button
variant="primary"
size="sm"
onClick={() => void handleCreateKind(activeTabDef.createKind!)}
>
{activeTabDef.createLabel}
</Button>
) : tab === "all" ? (
<>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("character", "people")}
>
+ Person
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("world", "places")}
>
+ World Item
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void handleCreateKind("note", "notes")}
>
+ Note
</Button>
</>
) : null}
</div>
</div>
<div className="ps-entity-search">
<Input
className="ps-entity-search-input"
placeholder="Search entities…"
placeholder="Search people, places, world items, and notes…"
value={query}
onChange={(ev) => setQuery(ev.target.value)}
/>
</div>
{tab === "consistency" ? (
<ConsistencyPanel issues={issues} onOpenEntity={(id) => {
const e = entities.find((x) => x.id === id);
if (e) { setDraft(e); setOpen(true); }
}} />
<ConsistencyPanel
issues={issues}
onOpenEntity={(id) => {
const e = entities.find((x) => x.id === id);
if (e) {
setDraft(e);
setOpen(true);
}
}}
/>
) : visible.length === 0 ? (
<p className="ps-muted">No entities match.</p>
<div className="ps-emptystate">
<div className="ps-emptystate-title">No entities in this view yet</div>
<p className="ps-muted">
{activeTabDef.createKind
? `Click "${activeTabDef.createLabel}" or the + button on the tab to create one.`
: "Use the + buttons on the People, Places & World, or Notes tabs to add story entities."}
</p>
</div>
) : (
<div className="ps-entity-grid">
{visible.map((e) => (
@@ -219,11 +290,6 @@ interface ConsistencyPanelProps {
onOpenEntity: (id: string) => void;
}
/**
* Consistency tab — shows the combined output of `findInconsistencies()` as a
* list of warnings. Each issue names the affected entity, which can be opened
* in the editor drawer.
*/
function ConsistencyPanel({ issues, onOpenEntity }: ConsistencyPanelProps) {
if (issues.length === 0) {
return (
@@ -263,7 +329,6 @@ function ConsistencyPanel({ issues, onOpenEntity }: ConsistencyPanelProps) {
);
}
/** Severity icon for an issue. */
function iconFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":
@@ -277,7 +342,6 @@ function iconFor(issue: ConsistencyIssue): string {
}
}
/** Primary label for an issue. */
function titleFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":
@@ -291,7 +355,6 @@ function titleFor(issue: ConsistencyIssue): string {
}
}
/** Secondary explanation for an issue. */
function detailFor(issue: ConsistencyIssue): string {
switch (issue.kind) {
case "orphan-mention":
+2 -36
View File
@@ -1,39 +1,5 @@
import { Badge, Button } from "@pubscript/ui";
import { AdminView } from "../features/admin/AdminView.js";
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>
);
return <AdminView />;
}
+147 -63
View File
@@ -73,6 +73,9 @@ function groupByAct(beats: PlotBeat[]): Record<number, PlotBeat[]> {
const act = Math.max(1, Math.min(3, beat.act));
groups[act]!.push(beat);
}
for (const act of [1, 2, 3]) {
groups[act]!.sort((a, b) => a.order - b.order);
}
return groups;
}
@@ -93,29 +96,45 @@ function beatProgress(beats: PlotBeat[]): { revised: number; total: number; pct:
}
export function Plot() {
const { plotBeats, savePlotBeat, deletePlotBeat, reorderPlotBeats, chapters, entities } =
useProjectStore();
const {
project,
plotBeats,
savePlotBeat,
deletePlotBeat,
reorderPlotBeats,
chapters,
entities,
} = useProjectStore();
const [editing, setEditing] = useState<PlotBeat | null>(null);
const [creating, setCreating] = useState<boolean>(false);
const [creatingAct, setCreatingAct] = useState<number | null>(null);
const [dragId, setDragId] = useState<string | null>(null);
const groups = useMemo(() => groupByAct(plotBeats), [plotBeats]);
const progress = useMemo(() => beatProgress(plotBeats), [plotBeats]);
const actBeats = (act: number) => groups[act]!.sort((a, b) => a.order - b.order);
const actBeats = (act: number) => groups[act] ?? [];
function handleTemplate(template: PlotTemplate) {
setCreating(false);
const { project } = useProjectStore.getState();
async function handleTemplate(template: PlotTemplate) {
setCreatingAct(null);
if (!project) return;
const beats = templateToBeats(template, project.id);
void Promise.all(beats.map((b) => savePlotBeat(b)));
for (const b of beats) {
await savePlotBeat(b);
}
}
async function handleAdd(act: number, type: PlotBeatType) {
const beat = createPlotBeat(createUlid(), "", "", act, type, actBeats(act).length);
if (!project) return;
const beat = createPlotBeat(
createUlid(),
project.id,
"",
act,
type,
actBeats(act).length,
);
await savePlotBeat(beat);
setCreating(false);
setCreatingAct(null);
setEditing(beat);
}
@@ -126,32 +145,80 @@ export function Plot() {
async function handleDelete(id: string) {
await deletePlotBeat(id);
setEditing(null);
}
function onDragStart(id: string) {
function onDragStart(id: string, e: React.DragEvent) {
setDragId(id);
e.dataTransfer.effectAllowed = "move";
try {
e.dataTransfer.setData("text/plain", id);
} catch {
/* ignore */
}
}
function onDragEnd() {
setDragId(null);
}
async function onDrop(act: number, e: React.DragEvent) {
async function moveBeatTo(draggedId: string, targetAct: number, targetBeatId?: string) {
const moved = plotBeats.find((b) => b.id === draggedId);
if (!moved || moved.status === "locked") return;
const now = new Date().toISOString();
const byAct: Record<number, PlotBeat[]> = {
1: actBeats(1).filter((b) => b.id !== draggedId),
2: actBeats(2).filter((b) => b.id !== draggedId),
3: actBeats(3).filter((b) => b.id !== draggedId),
};
const destList = byAct[targetAct] ?? [];
const updatedMoved: PlotBeat = { ...moved, act: targetAct, updatedAt: now };
if (targetBeatId) {
const targetIdx = destList.findIndex((b) => b.id === targetBeatId);
if (targetIdx !== -1) {
destList.splice(targetIdx, 0, updatedMoved);
} else {
destList.push(updatedMoved);
}
} else {
destList.push(updatedMoved);
}
byAct[targetAct] = destList;
const nextAll: PlotBeat[] = [];
for (const actNum of [1, 2, 3]) {
const list = byAct[actNum] ?? [];
list.forEach((b, idx) => {
nextAll.push({
...b,
act: actNum,
order: idx,
updatedAt: b.id === draggedId ? now : b.updatedAt,
});
});
}
await reorderPlotBeats(nextAll);
}
async function onDropColumn(act: number, e: React.DragEvent) {
e.preventDefault();
if (!dragId) return;
const beats = [...plotBeats];
const idx = beats.findIndex((b) => b.id === dragId);
if (idx === -1) return;
const moved = beats[idx]!;
// Locked beats cannot be moved.
if (moved.status === "locked") return;
// Move to the target act, then to the end of that act's ordering.
beats.splice(idx, 1);
const target = beats.filter((b) => b.act === act);
const newOrder = target.length;
const updated = { ...moved, act, order: newOrder, updatedAt: new Date().toISOString() };
await savePlotBeat(updated);
await reorderPlotBeats(beats);
const id = dragId || e.dataTransfer.getData("text/plain");
setDragId(null);
if (!id) return;
await moveBeatTo(id, act);
}
async function onDropCard(act: number, targetBeatId: string, e: React.DragEvent) {
e.preventDefault();
e.stopPropagation();
const id = dragId || e.dataTransfer.getData("text/plain");
setDragId(null);
if (!id || id === targetBeatId) return;
await moveBeatTo(id, act, targetBeatId);
}
return (
@@ -160,7 +227,7 @@ export function Plot() {
<div>
<h1 className="heading">Plot</h1>
<p className="muted">
{plotBeats.length} beat{plotBeats.length === 1 ? "" : "s"} across three acts.
{plotBeats.length} beat{plotBeats.length === 1 ? "" : "s"} across three acts. Drag beats to reorder within or across acts.
</p>
{plotBeats.length > 0 && (
<div className="plot__progress">
@@ -174,18 +241,18 @@ export function Plot() {
</div>
)}
</div>
<Button variant="accent" onClick={() => setCreating(true)}>
+ Add beat
</Button>
</div>
<div className="plot__board">
{ACTS.map((act) => (
<section
key={act.n}
className={`plot__column plot__column--act-${act.n}${dragId !== null ? "plot__column--drop" : ""}`}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(act.n, e)}
className={`plot__column plot__column--act-${act.n}${dragId !== null ? " plot__column--drop" : ""}`}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}}
onDrop={(e) => void onDropColumn(act.n, e)}
>
<header className="plot__column-head">
<div>
@@ -196,9 +263,11 @@ export function Plot() {
<p className="muted plot__column-sub">{act.subtitle}</p>
</div>
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={() => setCreating(true)}
title={`Add beat to ${act.title}`}
aria-label={`Add beat to ${act.title}`}
onClick={() => setCreatingAct(act.n)}
>
+
</Button>
@@ -206,15 +275,20 @@ export function Plot() {
<div className="plot__cards">
{actBeats(act.n).length === 0 && (
<div className="plot__empty">Drag beats here or add one.</div>
<div className="plot__empty">Drag beats here or click + to add one.</div>
)}
{actBeats(act.n).map((beat) => (
<article
key={beat.id}
className={`plot__card${dragId === beat.id ? "plot__card--dragging" : ""}${beat.status === "locked" ? "plot__card--locked" : ""}`}
draggable
onDragStart={() => onDragStart(beat.id)}
className={`plot__card${dragId === beat.id ? " plot__card--dragging" : ""}${beat.status === "locked" ? " plot__card--locked" : ""}`}
draggable={beat.status !== "locked"}
onDragStart={(e) => onDragStart(beat.id, e)}
onDragEnd={onDragEnd}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}}
onDrop={(e) => void onDropCard(act.n, beat.id, e)}
onClick={() => setEditing(beat)}
>
<div className="plot__card-top">
@@ -225,11 +299,12 @@ export function Plot() {
<Badge variant={STATUS_BADGE[beat.status]}>🔒</Badge>
)}
<button
type="button"
className="plot__delete"
title="Delete beat"
onClick={(e) => {
e.stopPropagation();
handleDelete(beat.id);
void handleDelete(beat.id);
}}
>
×
@@ -261,27 +336,41 @@ export function Plot() {
))}
</div>
{/* Template picker */}
{creating && (
{/* Template / beat picker for the selected Act */}
{creatingAct !== null && (
<Modal
open={creating}
title="Add beats"
onClose={() => setCreating(false)}
open={creatingAct !== null}
title={`Add to Act ${creatingAct === 1 ? "I" : creatingAct === 2 ? "II" : "III"}`}
onClose={() => setCreatingAct(null)}
footer={
<Button variant="ghost" onClick={() => setCreating(false)}>
<Button variant="ghost" onClick={() => setCreatingAct(null)}>
Cancel
</Button>
}
>
<div className="plot__templates">
<p className="muted plot__templates-hint">
Start from a structural template, or add a single blank beat.
Add a beat to Act {creatingAct}, or apply a full structural template across all acts.
</p>
<div className="plot__template-types">
{TEMPLATE_TYPES.map((type) => (
<button
key={type}
type="button"
className="plot__template plot__template--type"
onClick={() => void handleAdd(creatingAct, type)}
>
<Badge variant={TYPE_BADGE[type]}>{`+ ${TYPE_LABELS[type]}`}</Badge>
</button>
))}
</div>
<div className="plot__templates-divider">or seed a full template</div>
{PLOT_TEMPLATES.map((template) => (
<button
key={template.id}
type="button"
className="plot__template"
onClick={() => handleTemplate(template)}
onClick={() => void handleTemplate(template)}
>
<div>
<div className="plot__template-name">{template.name}</div>
@@ -290,18 +379,6 @@ export function Plot() {
<span className="plot__template-count">{template.beats.length} beats</span>
</button>
))}
<div className="plot__templates-divider">or add one beat</div>
<div className="plot__template-types">
{TEMPLATE_TYPES.map((type) => (
<button
key={type}
className="plot__template plot__template--type"
onClick={() => handleAdd(1, type)}
>
<Badge variant={TYPE_BADGE[type]}>{TYPE_LABELS[type]}</Badge>
</button>
))}
</div>
</div>
</Modal>
)}
@@ -360,21 +437,28 @@ function EditModal({ open, beat, onClose, onSave, onDelete, chapters, entities }
onClose={onClose}
footer={
<>
{draft.status !== "locked" && (
{draft.status !== "locked" ? (
<Button
variant="ghost"
onClick={() => updateField("status", "locked")}
>
🔒 Lock
</Button>
) : (
<Button
variant="ghost"
onClick={() => updateField("status", "revised")}
>
🔓 Unlock
</Button>
)}
<Button variant="danger" onClick={() => onDelete(draft.id)}>
<Button variant="danger" onClick={() => void onDelete(draft.id)}>
Delete
</Button>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="accent" onClick={save}>
<Button variant="accent" onClick={() => void save()}>
Save
</Button>
</>
+606 -23
View File
@@ -1,35 +1,618 @@
import { Badge, Button, EmptyState } from "@pubscript/ui";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge, Button, EmptyState, Input, Modal, Select, toast } from "@pubscript/ui";
import { useProjectStore } from "../store/project-store.js";
import { PLOT_TEMPLATES } from "../features/plot/templates.js";
const PROJECTS = [
{ id: "p1", title: "The Silent Cartographer", words: 47_200, status: "Draft" },
{ id: "p2", title: "Anthology of Tides", words: 12_400, status: "Planning" },
type WizardMode = "tutorial" | "manual";
interface TutorialAnswers {
genreFocus: "commercial" | "character" | "mystery" | "discovery";
pacingStyle: "detailed-beats" | "classic-acts" | "key-milestones" | "freeform";
scopeLength: "novella" | "standard-novel" | "epic";
}
const COVER_COLORS = [
"#7c8bf5",
"#0f3d3e",
"#b5793a",
"#9d7bff",
"#2e7d4f",
"#c0392b",
"#0891b2",
"#be123c",
];
function recommendMethodology(answers: TutorialAnswers): {
templateId: string;
name: string;
reason: string;
chapterCount: number;
} {
const chapterCount =
answers.scopeLength === "novella"
? 6
: answers.scopeLength === "epic"
? 16
: 10;
if (answers.pacingStyle === "detailed-beats" || answers.genreFocus === "commercial") {
return {
templateId: "save-the-cat",
name: "Save the Cat! (15-Beat Sheet)",
reason:
"Because you want tight commercial momentum or a beat-by-beat roadmap, Blake Snyder's 15-beat structure gives you every key catalyst, midpoint turn, B-story, and finale anchor.",
chapterCount,
};
}
if (answers.pacingStyle === "key-milestones" || answers.genreFocus === "mystery") {
return {
templateId: "seven-point",
name: "Seven-Point Story Structure",
reason:
"Because your story hinges on major twists, revelations, and tentpole milestones, the Seven-Point method (Hook, Plot Turn 1, Pinch 1, Midpoint, Pinch 2, Plot Turn 2, Resolution) anchors your turning points while leaving breathing room between scenes.",
chapterCount,
};
}
if (answers.pacingStyle === "freeform" || answers.genreFocus === "discovery") {
return {
templateId: "custom",
name: "Discovery / Custom 3-Act Skeleton",
reason:
"Because you prefer discovering the story as you write, this lightweight 3-beat compass sets up Act I, Act II, and Act III without locking you into rigid plot formulas.",
chapterCount,
};
}
return {
templateId: "three-act",
name: "Classic Three-Act Structure (12 Beats)",
reason:
"Because you value balanced dramatic arcs and character-driven progression, the Classic Three-Act scaffold gives you a time-tested Setup, Confrontation, and Resolution structure.",
chapterCount,
};
}
export function Projects() {
const navigate = useNavigate();
const {
projects,
projectStats,
project: activeProject,
loadProjects,
openProject,
createProjectWithScaffold,
deleteProject,
} = useProjectStore();
const [wizardOpen, setWizardOpen] = useState(false);
const [wizardMode, setWizardMode] = useState<WizardMode>("tutorial");
const [tutorialStep, setTutorialStep] = useState<1 | 2 | 3 | 4>(1);
const [answers, setAnswers] = useState<TutorialAnswers>({
genreFocus: "commercial",
pacingStyle: "classic-acts",
scopeLength: "standard-novel",
});
// Shared new-project fields
const [title, setTitle] = useState("");
const [premiseNote, setPremiseNote] = useState("");
const [coverColor, setCoverColor] = useState(COVER_COLORS[0]!);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("three-act");
const [chapterCount, setChapterCount] = useState<number>(6);
const [customChapterList, setCustomChapterList] = useState<string>(
"Chapter One — Opening\nChapter Two — Rising Action\nChapter Three — Turning Point",
);
const [useCustomChapterNames, setUseCustomChapterNames] = useState(false);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
useEffect(() => {
void loadProjects();
}, [loadProjects]);
const recommendation = useMemo(() => recommendMethodology(answers), [answers]);
const openWizard = () => {
setTitle("");
setPremiseNote("");
setCoverColor(COVER_COLORS[0]!);
setWizardMode("tutorial");
setTutorialStep(1);
setSelectedTemplateId("three-act");
setChapterCount(6);
setUseCustomChapterNames(false);
setWizardOpen(true);
};
const switchToManual = () => {
setWizardMode("manual");
};
const applyRecommendationToForm = () => {
setSelectedTemplateId(recommendation.templateId);
setChapterCount(recommendation.chapterCount);
setTutorialStep(4);
};
const handleCreateProject = async () => {
if (!title.trim()) {
toast.error("Please enter a title for your project.");
return;
}
setCreating(true);
try {
const chapterTitles = useCustomChapterNames
? customChapterList
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const created = await createProjectWithScaffold({
title: title.trim(),
coverColor,
templateId: selectedTemplateId,
...(chapterTitles && chapterTitles.length > 0
? { chapterTitles }
: { chapterCount }),
...(premiseNote.trim() ? { premiseNote: premiseNote.trim() } : {}),
});
toast.success(`Created project "${created.title}"`);
setWizardOpen(false);
navigate("/");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to create project");
} finally {
setCreating(false);
}
};
const handleOpenProject = async (id: string) => {
await openProject(id);
const opened = useProjectStore.getState().project;
toast.info(`Opened "${opened?.title ?? "Project"}"`);
navigate("/");
};
const handleDeleteProject = async (id: string) => {
const target = projects.find((p) => p.id === id);
await deleteProject(id);
setConfirmDeleteId(null);
toast.info(`Deleted "${target?.title ?? "Project"}"`);
};
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 className="ps-page-container">
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 20,
gap: 16,
}}
>
<div>
<h1 className="heading">Projects</h1>
<p className="muted">
Switch between manuscripts, start a guided story template, or manage existing projects.
</p>
</div>
<Button variant="primary" onClick={openWizard}>
+ 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>
{projects.length === 0 ? (
<EmptyState
icon="📚"
title="No projects yet"
description="Start a new manuscript with our guided structure tutorial or build a skeleton manually."
action={
<Button variant="primary" onClick={openWizard}>
+ New project
</Button>
}
/>
) : (
<div className="ps-projects-grid">
{projects.map((p) => {
const isActive = activeProject?.id === p.id;
const stats = projectStats[p.id] ?? { wordCount: 0, chapterCount: 0 };
return (
<div
key={p.id}
className={`ps-project-card${isActive ? " is-active" : ""}`}
onClick={() => void handleOpenProject(p.id)}
>
<div
className="ps-project-cover"
style={{ background: p.cover?.color || "var(--ps-accent)" }}
/>
<div className="ps-project-body">
<div className="ps-project-top">
<div className="ps-project-title">{p.title}</div>
{isActive ? (
<Badge variant="success">Active</Badge>
) : (
<Badge variant="neutral">Manuscript</Badge>
)}
</div>
<div className="ps-project-meta">
{stats.chapterCount} chapter{stats.chapterCount === 1 ? "" : "s"} ·{" "}
{stats.wordCount.toLocaleString()} words · Updated{" "}
{new Date(p.updatedAt).toLocaleDateString()}
</div>
<div
className="ps-project-actions"
onClick={(e) => e.stopPropagation()}
>
<Button
variant={isActive ? "secondary" : "primary"}
size="sm"
onClick={() => void handleOpenProject(p.id)}
>
{isActive ? "Continue Writing" : "Open Project"}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setConfirmDeleteId(p.id)}
>
Delete
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
{confirmDeleteId && (
<Modal
open={confirmDeleteId !== null}
title="Delete Project"
onClose={() => setConfirmDeleteId(null)}
footer={
<>
<Button variant="ghost" onClick={() => setConfirmDeleteId(null)}>
Cancel
</Button>
<Button
variant="danger"
onClick={() => void handleDeleteProject(confirmDeleteId)}
>
Delete Permanently
</Button>
</>
}
>
<p style={{ margin: 0 }}>
Are you sure you want to delete{" "}
<strong>
{projects.find((p) => p.id === confirmDeleteId)?.title ?? "this project"}
</strong>
? All chapters, characters, world items, and plot beats in this project will be removed.
</p>
</Modal>
)}
{wizardOpen && (
<Modal
open={wizardOpen}
title={
wizardMode === "tutorial"
? "New Project — Story Structure Tutorial"
: "New Project — Manual Skeleton Builder"
}
onClose={() => setWizardOpen(false)}
footer={
wizardMode === "tutorial" && tutorialStep < 4 ? (
<div style={{ display: "flex", width: "100%", justifyContent: "space-between", alignItems: "center" }}>
<Button variant="ghost" size="sm" onClick={switchToManual}>
Skip Tutorial → Build Manually
</Button>
<div style={{ display: "flex", gap: 8 }}>
{tutorialStep > 1 && (
<Button
variant="secondary"
onClick={() => setTutorialStep((s) => (s - 1) as 1 | 2 | 3)}
>
← Back
</Button>
)}
{tutorialStep < 3 ? (
<Button
variant="primary"
onClick={() => setTutorialStep((s) => (s + 1) as 2 | 3)}
>
Next Question →
</Button>
) : (
<Button variant="primary" onClick={applyRecommendationToForm}>
See Recommended Template →
</Button>
)}
</div>
</div>
) : (
<div style={{ display: "flex", width: "100%", justifyContent: "space-between", alignItems: "center" }}>
{wizardMode === "manual" ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setWizardMode("tutorial");
setTutorialStep(1);
}}
>
← Use Guided Tutorial
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setTutorialStep(3)}
>
← Back to Questions
</Button>
)}
<div style={{ display: "flex", gap: 8 }}>
<Button variant="ghost" onClick={() => setWizardOpen(false)}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void handleCreateProject()}
disabled={creating}
>
{creating ? "Creating…" : "Create Project"}
</Button>
</div>
</div>
)
}
>
{wizardMode === "tutorial" && tutorialStep < 4 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div className="ps-wizard-banner">
<div>
<strong>Step {tutorialStep} of 3</strong> — Answer three quick questions about your story to find the best Act &amp; Beat methodology, or{" "}
<button
type="button"
className="wf__link"
onClick={switchToManual}
>
skip tutorial and build your skeleton manually
</button>
.
</div>
</div>
{tutorialStep === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ fontWeight: 600, fontSize: 15 }}>
1. What kind of story and narrative drive are you writing?
</div>
{[
{
id: "commercial" as const,
title: "High-Concept / Genre Fiction (Sci-Fi, Fantasy, Thriller, Romance)",
desc: "Strong hook, clear external stakes, and propulsive scene-by-scene momentum.",
},
{
id: "character" as const,
title: "Character-Driven / Literary & Dramatic Fiction",
desc: "Deep internal transformation, relationship arcs, and classic dramatic structure.",
},
{
id: "mystery" as const,
title: "Mystery / Investigation / Twist-Driven Plot",
desc: "Built around major reveals, clues, midpoint reversals, and pressure pinches.",
},
{
id: "discovery" as const,
title: "Exploratory / Discovery Writing (Pantser)",
desc: "I start with a character or image and discover the plot as I write.",
},
].map((opt) => (
<button
key={opt.id}
type="button"
className={`ps-wizard-option${answers.genreFocus === opt.id ? " is-selected" : ""}`}
onClick={() => setAnswers((a) => ({ ...a, genreFocus: opt.id }))}
>
<div style={{ fontWeight: 600 }}>{opt.title}</div>
<div className="ps-settings-desc">{opt.desc}</div>
</button>
))}
</div>
)}
{tutorialStep === 2 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ fontWeight: 600, fontSize: 15 }}>
2. How granular do you want your plot beat board to be?
</div>
{[
{
id: "detailed-beats" as const,
title: "Complete Beat Sheet (15 Beats — Save the Cat!)",
desc: "Map every key beat: Opening Image, Catalyst, Debate, B-Story, Midpoint, All Is Lost, Finale.",
},
{
id: "classic-acts" as const,
title: "Balanced Three-Act Arc (12 Beats — Three-Act Structure)",
desc: "Traditional Act I Setup, Act II Rising Action & Complications, Act III Climax & Resolution.",
},
{
id: "key-milestones" as const,
title: "Tentpole Hinges Only (7 Beats — Seven-Point Structure)",
desc: "Just the 7 essential structural hinges (Hook, Plot Turns, Pinches, Midpoint, Resolution).",
},
{
id: "freeform" as const,
title: "Minimal Compass (3 Act Anchors / Custom)",
desc: "Give me a clean 3-act skeleton so I can invent my own beats as the draft evolves.",
},
].map((opt) => (
<button
key={opt.id}
type="button"
className={`ps-wizard-option${answers.pacingStyle === opt.id ? " is-selected" : ""}`}
onClick={() => setAnswers((a) => ({ ...a, pacingStyle: opt.id }))}
>
<div style={{ fontWeight: 600 }}>{opt.title}</div>
<div className="ps-settings-desc">{opt.desc}</div>
</button>
))}
</div>
)}
{tutorialStep === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ fontWeight: 600, fontSize: 15 }}>
3. What is the target scope of this manuscript?
</div>
{[
{
id: "novella" as const,
title: "Short Fiction / Tight Novella (6 starter chapters)",
desc: "Focused cast, single main plotline, 20k–50k words.",
},
{
id: "standard-novel" as const,
title: "Standard Full-Length Novel (10 starter chapters)",
desc: "Main plot + subplots, 70k–100k words.",
},
{
id: "epic" as const,
title: "Epic / Multi-POV Novel (16 starter chapters)",
desc: "Expansive world-building, multiple viewpoints, 100k+ words.",
},
].map((opt) => (
<button
key={opt.id}
type="button"
className={`ps-wizard-option${answers.scopeLength === opt.id ? " is-selected" : ""}`}
onClick={() => setAnswers((a) => ({ ...a, scopeLength: opt.id }))}
>
<div style={{ fontWeight: 600 }}>{opt.title}</div>
<div className="ps-settings-desc">{opt.desc}</div>
</button>
))}
</div>
)}
</div>
<Badge variant="accent">{p.status}</Badge>
</div>
))}
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
{wizardMode === "tutorial" && (
<div className="ps-wizard-recommendation">
<div style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--ps-accent)", fontWeight: 700 }}>
Recommended Methodology
</div>
<div style={{ fontWeight: 700, fontSize: 16, marginTop: 2 }}>
{recommendation.name}
</div>
<p className="ps-settings-desc" style={{ marginTop: 4 }}>
{recommendation.reason}
</p>
</div>
)}
<EmptyState
icon="📚"
title="No more projects"
description="Start a new manuscript or open an existing one."
/>
<Input
label="Project Title"
placeholder="e.g. The Clockwork Meridian"
value={title}
autoFocus
onChange={(e) => setTitle(e.target.value)}
/>
<div className="ps-field">
<span className="ps-field-label">Cover Accent Color</span>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{COVER_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setCoverColor(c)}
style={{
width: 28,
height: 28,
borderRadius: 6,
background: c,
border: coverColor === c ? "2px solid var(--ps-text)" : "1px solid var(--ps-border)",
cursor: "pointer",
}}
title={c}
/>
))}
</div>
</div>
<Select
label="Act / Beat Methodology Template"
value={selectedTemplateId}
onChange={(e) => setSelectedTemplateId(e.target.value)}
options={[
...PLOT_TEMPLATES.map((t) => ({
value: t.id,
label: `${t.name} (${t.beats.length} beats)`,
})),
{ value: "custom", label: "Custom / Discovery 3-Act Compass (3 beats)" },
{ value: "none", label: "Blank Plot Board (0 beats — build from scratch)" },
]}
/>
<div className="ps-field">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="ps-field-label">Chapter Skeleton</span>
<label style={{ fontSize: 12, display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
<input
type="checkbox"
checked={useCustomChapterNames}
onChange={(e) => setUseCustomChapterNames(e.target.checked)}
/>
Define custom chapter titles
</label>
</div>
{useCustomChapterNames ? (
<textarea
className="ps-input"
rows={4}
placeholder="One chapter title per line…"
value={customChapterList}
onChange={(e) => setCustomChapterList(e.target.value)}
/>
) : (
<Select
value={String(chapterCount)}
onChange={(e) => setChapterCount(Number(e.target.value))}
options={[1, 3, 5, 6, 8, 10, 12, 16, 20, 24].map((n) => ({
value: String(n),
label: `${n} starter chapter${n === 1 ? "" : "s"}`,
}))}
/>
)}
</div>
<div className="ps-field">
<span className="ps-field-label">Story Premise / Logline (optional)</span>
<textarea
className="ps-input"
rows={3}
placeholder="A brief summary of your protagonist, conflict, or core idea (saved as a pinned project note)…"
value={premiseNote}
onChange={(e) => setPremiseNote(e.target.value)}
/>
</div>
</div>
)}
</Modal>
)}
</div>
);
}
+346 -103
View File
@@ -13,10 +13,13 @@ import {
Handle,
Position,
type NodeProps,
type Node,
type Edge,
type Connection,
type NodeChange,
type EdgeChange,
applyNodeChanges,
applyEdgeChanges,
} from "reactflow";
import "reactflow/dist/base.css";
import "reactflow/dist/style.css";
@@ -24,7 +27,7 @@ import "reactflow/dist/style.css";
import { createUlid } from "@pubscript/core";
import type { FlowNode, FlowEdge, Entity, PlotBeat } from "@pubscript/core";
import { Button } from "@pubscript/ui";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useProjectStore } from "../store/project-store.js";
/* ------------------------------------------------------------------ */
@@ -33,29 +36,53 @@ import { useProjectStore } from "../store/project-store.js";
interface NodeData {
title?: string;
text?: string;
body?: string;
role?: string;
kind?: string;
entityId?: string;
chapterId?: string;
beatId?: string;
onEdit?: (id: string) => void;
[key: string]: unknown;
}
/** A human-readable label for an entity, regardless of its kind. */
function entityLabel(e: Entity): string {
if ("name" in e) return e.name;
function entityLabel(e: Entity | undefined): string {
if (!e) return "";
if ("name" in e) return e.name || "Untitled";
return (e.body || "Untitled note").slice(0, 40);
}
function BaseNode({ data, type }: NodeProps<NodeData>) {
function BaseNode({ id, data, type, selected }: NodeProps<NodeData>) {
const linked: string | undefined =
(data.entityId as string | undefined) ||
(data.chapterId as string | undefined) ||
(data.beatId as string | undefined);
const displayTitle = data.title || data.text || "Untitled";
return (
<div className={`wf-node wf-node--${type}${linked ? " wf-node--linked" : ""}`}>
<div
className={`wf-node wf-node--${type}${linked ? " wf-node--linked" : ""}${selected ? " wf-node--selected" : ""}`}
>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right} />
<div className="wf-node__type">{type}</div>
<div className="wf-node__title">{data.title || "Untitled"}</div>
<div className="wf-node__top">
<div className="wf-node__type">{type}</div>
{data.onEdit && (
<button
type="button"
className="wf-node__edit-btn nodrag"
title="Edit node"
onClick={(e) => {
e.stopPropagation();
data.onEdit?.(id);
}}
>
✎
</button>
)}
</div>
<div className="wf-node__title">{displayTitle}</div>
{data.body && <div className="wf-node__body">{data.body}</div>}
{linked && <span className="wf-node__badge">linked</span>}
</div>
@@ -91,6 +118,7 @@ const ADDABLE: Array<{ type: FlowNode["type"]; label: string; icon: string }> =
export function Whiteboard() {
const {
board,
saveBoard,
addBoardNode,
updateBoardNode,
deleteBoardNode,
@@ -103,56 +131,101 @@ export function Whiteboard() {
const edges = useMemo(() => board?.edges ?? [], [board]);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingEdgeId, setEditingEdgeId] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [showImport, setShowImport] = useState(false);
const openEditNode = useCallback((id: string) => {
setEditingId(id);
}, []);
// Controlled ReactFlow state synced with the persisted board so dragging and
// selection updates happen at 60fps in real time.
const [rfNodes, setRfNodes] = useState<Node<NodeData>[]>([]);
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
useEffect(() => {
setRfNodes((prev) => {
const prevMap = new Map(prev.map((p) => [p.id, p]));
return nodes.map((n) => {
const existing = prevMap.get(n.id);
return {
id: n.id,
type: n.type,
position: n.position,
data: { ...(n.data as NodeData), onEdit: openEditNode },
draggable: true,
selectable: true,
selected: existing?.selected ?? false,
};
});
});
}, [nodes, openEditNode]);
useEffect(() => {
setRfEdges((prev) => {
const prevMap = new Map(prev.map((p) => [p.id, p]));
return edges.map((e) => {
const existing = prevMap.get(e.id);
return {
id: e.id,
source: e.source,
target: e.target,
type: e.type === "custom" ? "smoothstep" : "default",
label: e.label ?? (e.type !== "custom" ? e.type : undefined),
animated: e.type === "causes",
selectable: true,
focusable: true,
selected: existing?.selected ?? false,
style: { strokeWidth: 2, cursor: "pointer" },
};
});
});
}, [edges]);
// Beats not already placed on the board (by beatId).
const availableBeats = useMemo(
() => plotBeats.filter((b) => !nodes.some((n) => n.data.beatId === b.id)),
[plotBeats, nodes],
);
const reactFlowNodes = useMemo(
() =>
nodes.map((n) => ({
id: n.id,
type: n.type,
position: n.position,
data: n.data as NodeData,
draggable: true,
selectable: true,
})),
[nodes],
);
const reactFlowEdges = useMemo(
() =>
edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
type: e.type === "custom" ? "smoothstep" : "bezier",
label: e.label,
animated: e.type === "causes",
})) as Edge[],
[edges],
);
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
const next = applyNodeChanges(changes, reactFlowNodes);
// Persist position changes back to the store.
for (const change of changes) {
if (change.type === "position" && change.position && change.dragging === false) {
const node = nodes.find((n) => n.id === change.id);
if (node) {
void updateBoardNode({ ...node, position: change.position });
setRfNodes((current) => {
const next = applyNodeChanges(changes, current);
for (const change of changes) {
if (change.type === "position" && change.dragging === false) {
const movedRf = next.find((n) => n.id === change.id);
const storeNode = nodes.find((n) => n.id === change.id);
if (movedRf && storeNode) {
void updateBoardNode({
...storeNode,
position: movedRf.position,
});
}
} else if (change.type === "remove") {
void deleteBoardNode(change.id);
}
}
}
void next;
return next;
});
},
[reactFlowNodes, nodes, updateBoardNode],
[nodes, updateBoardNode, deleteBoardNode],
);
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => {
setRfEdges((current) => {
const next = applyEdgeChanges(changes, current);
for (const change of changes) {
if (change.type === "remove") {
void deleteBoardEdge(change.id);
}
}
return next;
});
},
[deleteBoardEdge],
);
const onConnect = useCallback(
@@ -162,7 +235,8 @@ export function Whiteboard() {
id: createUlid(),
source: params.source,
target: params.target,
type: "custom",
type: "leads-to",
label: "leads to",
};
void addBoardEdge(edge);
},
@@ -171,17 +245,18 @@ export function Whiteboard() {
const handleAddNode = useCallback(
async (type: FlowNode["type"]) => {
const offset = nodes.length * 28;
const node: FlowNode = {
id: createUlid(),
type,
position: { x: 100 + Math.random() * 300, y: 100 + Math.random() * 300 },
position: { x: 140 + (offset % 360), y: 120 + (Math.floor(offset / 360) * 160) },
data: { title: "", body: "" },
};
await addBoardNode(node);
setShowAdd(false);
setEditingId(node.id);
},
[addBoardNode],
[addBoardNode, nodes.length],
);
const handleDeleteNode = useCallback(
@@ -195,6 +270,7 @@ export function Whiteboard() {
// Import selected plot beats as note nodes on the board.
const handleImportBeats = useCallback(
async (beatIds: string[]) => {
if (!board) return;
const created: FlowNode[] = [];
beatIds.forEach((beatId, i) => {
const beat = plotBeats.find((b) => b.id === beatId);
@@ -207,36 +283,43 @@ export function Whiteboard() {
};
created.push(node);
});
// Add sequentially so each is persisted.
for (const node of created) {
await addBoardNode(node);
}
await saveBoard({ ...board, nodes: [...board.nodes, ...created] });
setShowImport(false);
},
[plotBeats, nodes, addBoardNode],
[board, plotBeats, saveBoard],
);
const onEdgesDelete = useCallback(
(edges: Edge[]) => {
for (const edge of edges) {
(deletedEdges: Edge[]) => {
for (const edge of deletedEdges) {
void deleteBoardEdge(edge.id);
}
},
[deleteBoardEdge],
);
const editingNode = useMemo(
() => nodes.find((n) => n.id === editingId),
[nodes, editingId],
);
const editingEdge = useMemo(
() => edges.find((e) => e.id === editingEdgeId),
[edges, editingEdgeId],
);
return (
<div className="wf">
<div className="wf__header">
<div>
<h1 className="heading">Whiteboard</h1>
<p className="muted">
{nodes.length} node{nodes.length === 1 ? "" : "s"} · {edges.length} edge
{edges.length === 1 ? "" : "s"}
{nodes.length} node{nodes.length === 1 ? "" : "s"} · {edges.length} connection
{edges.length === 1 ? "" : "s"} · Click any node or line to edit or delete
</p>
</div>
<div className="wf__actions">
<Button variant="ghost" size="sm" onClick={() => setShowAdd((s) => !s)}>
<Button variant="secondary" size="sm" onClick={() => setShowAdd((s) => !s)}>
+ Add node
</Button>
<Button
@@ -255,8 +338,9 @@ export function Whiteboard() {
{ADDABLE.map((item) => (
<button
key={item.type}
type="button"
className="wf__add-item"
onClick={() => handleAddNode(item.type)}
onClick={() => void handleAddNode(item.type)}
>
<span className="wf__add-icon">{item.icon}</span>
<span>{item.label}</span>
@@ -267,11 +351,16 @@ export function Whiteboard() {
<div className="wf__canvas">
<ReactFlow
nodes={reactFlowNodes}
edges={reactFlowEdges}
nodes={rfNodes}
edges={rfEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onEdgesDelete={onEdgesDelete}
onNodeClick={(_event, node) => setEditingId(node.id)}
onNodeDoubleClick={(_event, node) => setEditingId(node.id)}
onEdgeClick={(_event, edge) => setEditingEdgeId(edge.id)}
deleteKeyCode={["Backspace", "Delete"]}
nodeTypes={NODE_TYPES}
fitView
proOptions={{ hideAttribution: true }}
@@ -281,23 +370,46 @@ export function Whiteboard() {
<div className="wf__edge-legend">
<span title="Causes (animated)">— causes —</span>
<span title="Leads to / contrasts">— leads to —</span>
<span>Click any node or line to edit/delete</span>
</div>
</ReactFlow>
</div>
{editingId && (
{editingId && editingNode && (
<EditNode
node={nodes.find((n) => n.id === editingId)}
key={editingNode.id}
node={editingNode}
onClose={() => setEditingId(null)}
onDelete={() => handleDeleteNode(editingId)}
/>
)}
{editingEdgeId && editingEdge && (
<EditEdgeModal
key={editingEdge.id}
edge={editingEdge}
nodes={nodes}
onClose={() => setEditingEdgeId(null)}
onSave={async (updated) => {
if (!board) return;
await saveBoard({
...board,
edges: board.edges.map((e) => (e.id === updated.id ? updated : e)),
});
setEditingEdgeId(null);
}}
onDelete={async () => {
await deleteBoardEdge(editingEdge.id);
setEditingEdgeId(null);
}}
/>
)}
{showImport && (
<ImportBeats
beats={availableBeats}
onClose={() => setShowImport(false)}
onImport={(beatIds) => handleImportBeats(beatIds)}
onImport={(beatIds) => void handleImportBeats(beatIds)}
/>
)}
</div>
@@ -309,37 +421,64 @@ export function Whiteboard() {
/* ------------------------------------------------------------------ */
interface EditNodeProps {
node: FlowNode | undefined;
node: FlowNode;
onClose: () => void;
onDelete: () => Promise<void>;
}
function EditNode({ node, onClose, onDelete }: EditNodeProps) {
const { updateBoardNode, entities, chapters } = useProjectStore();
const [title, setTitle] = useState<string>(String(node?.data.title ?? ""));
const [body, setBody] = useState<string>(String(node?.data.body ?? ""));
const [entityId, setEntityId] = useState<string | undefined>(
node?.data.entityId as string | undefined,
const initialTitle = String(node.data.title ?? node.data.text ?? "");
const [title, setTitle] = useState<string>(initialTitle);
const [body, setBody] = useState<string>(String(node.data.body ?? ""));
const [entityId, setEntityId] = useState<string>(
String(node.data.entityId ?? ""),
);
const [chapterId, setChapterId] = useState<string | undefined>(
node?.data.chapterId as string | undefined,
const [chapterId, setChapterId] = useState<string>(
String(node.data.chapterId ?? ""),
);
if (!node) return null;
// Which cross-link selector applies to this node type?
const showEntityLink = node.type === "character" || node.type === "world";
const showChapterLink = node.type === "chapter";
const linkedLabel =
showEntityLink
? entityLabel(entities.find((e) => e.id === entityId)!)
: showChapterLink
? chapters.find((c) => c.id === chapterId)?.title
: undefined;
const candidateEntities = useMemo(() => {
if (node.type === "character") {
const chars = entities.filter((e) => !("fields" in e) && !("body" in e));
return chars.length > 0 ? chars : entities;
}
if (node.type === "world") {
const worlds = entities.filter((e) => "fields" in e);
return worlds.length > 0 ? worlds : entities;
}
return entities;
}, [entities, node.type]);
const matchedEntity = useMemo(
() => (entityId ? entities.find((e) => e.id === entityId) : undefined),
[entities, entityId],
);
const matchedChapter = useMemo(
() => (chapterId ? chapters.find((c) => c.id === chapterId) : undefined),
[chapters, chapterId],
);
const linkedLabel = showEntityLink
? matchedEntity
? entityLabel(matchedEntity)
: undefined
: showChapterLink
? matchedChapter?.title
: undefined;
const save = async () => {
const data: Record<string, unknown> = { ...node.data, title, body };
const resolvedTitle = title.trim() || linkedLabel || `Untitled ${node.type}`;
const data: Record<string, unknown> = {
...node.data,
title: resolvedTitle,
text: resolvedTitle,
body,
};
if (showEntityLink) {
if (entityId) data.entityId = entityId;
else delete data.entityId;
@@ -356,8 +495,8 @@ function EditNode({ node, onClose, onDelete }: EditNodeProps) {
<div className="wf__modal-overlay" onClick={onClose}>
<div className="wf__modal" onClick={(e) => e.stopPropagation()}>
<div className="wf__modal-head">
<span className="wf__modal-type">{node.type}</span>
<button className="wf__modal-close" onClick={onClose}>
<span className="wf__modal-type">Edit {node.type} node</span>
<button type="button" className="wf__modal-close" onClick={onClose}>
×
</button>
</div>
@@ -366,26 +505,44 @@ function EditNode({ node, onClose, onDelete }: EditNodeProps) {
<span>Title</span>
<input
className="wf__input"
placeholder="Title"
placeholder={`Enter ${node.type} title…`}
value={title}
autoFocus
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void save();
}
}}
/>
</label>
{(showEntityLink || showChapterLink) && (
<label className="wf__field">
<span>Linked {showEntityLink ? "entity" : "chapter"}</span>
<span>Linked {showEntityLink ? node.type : "chapter"}</span>
<select
className="wf__input wf__select"
value={showEntityLink ? entityId : chapterId ?? ""}
onChange={(e) =>
showEntityLink
? setEntityId(e.target.value || undefined)
: setChapterId(e.target.value || undefined)
}
value={showEntityLink ? entityId : chapterId}
onChange={(e) => {
const val = e.target.value;
if (showEntityLink) {
setEntityId(val);
const ent = entities.find((x) => x.id === val);
if (ent && !title.trim()) {
setTitle(entityLabel(ent));
}
} else {
setChapterId(val);
const ch = chapters.find((x) => x.id === val);
if (ch && !title.trim()) {
setTitle(ch.title);
}
}
}}
>
<option value="">— none —</option>
{showEntityLink
? entities.map((e) => (
? candidateEntities.map((e) => (
<option key={e.id} value={e.id}>
{entityLabel(e)}
</option>
@@ -403,22 +560,108 @@ function EditNode({ node, onClose, onDelete }: EditNodeProps) {
<span className="ps-badge ps-badge-accent">linked: {linkedLabel}</span>
</div>
)}
<textarea
className="wf__textarea"
placeholder="Notes"
rows={5}
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<label className="wf__field">
<span>Description / Notes</span>
<textarea
className="wf__textarea"
placeholder="Add notes or details…"
rows={5}
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</label>
</div>
<div className="wf__modal-foot">
<Button variant="danger" size="sm" onClick={onDelete}>
Delete
<Button variant="danger" size="sm" onClick={() => void onDelete()}>
Delete Node
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button variant="accent" size="sm" onClick={save}>
<Button variant="accent" size="sm" onClick={() => void save()}>
Save
</Button>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Edit / delete edge (connecting line) modal */
/* ------------------------------------------------------------------ */
interface EditEdgeModalProps {
edge: FlowEdge;
nodes: FlowNode[];
onClose: () => void;
onSave: (edge: FlowEdge) => Promise<void>;
onDelete: () => Promise<void>;
}
function EditEdgeModal({ edge, nodes, onClose, onSave, onDelete }: EditEdgeModalProps) {
const [type, setType] = useState<FlowEdge["type"]>(edge.type);
const [label, setLabel] = useState<string>(edge.label ?? "");
const sourceNode = nodes.find((n) => n.id === edge.source);
const targetNode = nodes.find((n) => n.id === edge.target);
const sourceTitle = String(sourceNode?.data.title ?? sourceNode?.data.text ?? "Source");
const targetTitle = String(targetNode?.data.title ?? targetNode?.data.text ?? "Target");
return (
<div className="wf__modal-overlay" onClick={onClose}>
<div className="wf__modal" onClick={(e) => e.stopPropagation()}>
<div className="wf__modal-head">
<span className="wf__modal-type">Connection Line</span>
<button type="button" className="wf__modal-close" onClick={onClose}>
×
</button>
</div>
<div className="wf__modal-body">
<p className="muted" style={{ margin: 0 }}>
<strong>{sourceTitle}</strong> → <strong>{targetTitle}</strong>
</p>
<label className="wf__field">
<span>Connection Type</span>
<select
className="wf__input wf__select"
value={type}
onChange={(e) => setType(e.target.value as FlowEdge["type"])}
>
<option value="leads-to">Leads to</option>
<option value="causes">Causes (animated)</option>
<option value="contrasts">Contrasts</option>
<option value="custom">Custom</option>
</select>
</label>
<label className="wf__field">
<span>Label (optional)</span>
<input
className="wf__input"
placeholder="e.g. triggers, opposes, follows…"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</label>
</div>
<div className="wf__modal-foot">
<Button variant="danger" size="sm" onClick={() => void onDelete()}>
Delete Line
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
variant="accent"
size="sm"
onClick={() =>
void onSave({
...edge,
type,
...(label.trim() ? { label: label.trim() } : {}),
})
}
>
Save
</Button>
</div>
@@ -457,7 +700,7 @@ function ImportBeats({ beats, onClose, onImport }: ImportBeatsProps) {
<div className="wf__modal wf__modal--wide" onClick={(e) => e.stopPropagation()}>
<div className="wf__modal-head">
<span className="wf__modal-type">Add beats to board</span>
<button className="wf__modal-close" onClick={onClose}>
<button type="button" className="wf__modal-close" onClick={onClose}>
×
</button>
</div>
@@ -479,10 +722,10 @@ function ImportBeats({ beats, onClose, onImport }: ImportBeatsProps) {
</div>
<div className="wf__modal-foot">
<div className="wf__beat-actions">
<button className="wf__link" onClick={selectAll}>
<button type="button" className="wf__link" onClick={selectAll}>
Select all
</button>
<button className="wf__link" onClick={clearAll}>
<button type="button" className="wf__link" onClick={clearAll}>
Clear
</button>
</div>
+358 -31
View File
@@ -2,15 +2,14 @@
* Project store — the single source of truth for the writing experience.
*
* Wraps a `StorageAdapter` (offline-first `storageLocal` by default) and owns:
* - the active project and its chapters,
* - all projects and the active project,
* - the active project's books and chapters,
* - the active chapter,
* - the autosave engine (`createAutosave`) that persists edits.
*
* The editor (`WritingView`) reads the active chapter and calls `scheduleSave`
* on every update; the store debounces the writes through the adapter.
*/
import {
createAutosave,
createPlotBeat,
type AutosaveState,
type Board,
type Chapter,
@@ -26,11 +25,48 @@ import { storageLocal } from "@pubscript/core/local";
import { createUlid } from "@pubscript/core";
import { create } from "zustand";
import { seedDemoProject } from "./seed.js";
import { PLOT_TEMPLATES } from "../features/plot/templates.js";
const ACTIVE_PROJECT_KEY = "pubscript.activeProjectId";
export interface ProjectSummaryStats {
wordCount: number;
chapterCount: number;
}
export interface CreateProjectScaffoldOptions {
title: string;
coverColor?: string;
templateId?: string; // "three-act" | "save-the-cat" | "seven-point" | "custom" | "none"
chapterTitles?: string[];
chapterCount?: number;
premiseNote?: string;
}
function countDocWords(doc: { content?: Array<Record<string, unknown>> } | undefined): number {
if (!doc) return 0;
let total = 0;
const walk = (node: Record<string, unknown>) => {
if (typeof node.text === "string") {
total += node.text.trim().split(/\s+/).filter(Boolean).length;
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
if (child && typeof child === "object") walk(child as Record<string, unknown>);
}
}
};
walk(doc as unknown as Record<string, unknown>);
return total;
}
interface ProjectStore {
// --- state ---
adapter: StorageAdapter | null;
projects: ProjectMeta[];
projectStats: Record<string, ProjectSummaryStats>;
project: ProjectMeta | null;
activeBookId: string | null;
chapters: Chapter[];
activeChapterId: string | null;
status: "idle" | "loading" | "ready";
@@ -52,47 +88,47 @@ interface ProjectStore {
// --- actions ---
/** Load the project (seed a demo if empty) and select the first chapter. */
init: () => Promise<void>;
/** Refresh the project list and per-project stats. */
loadProjects: () => Promise<void>;
/** Open a project by id and load its chapters, entities, beats, and board. */
openProject: (id: string) => Promise<void>;
/** Create a new project with optional structural template and chapter skeleton. */
createProjectWithScaffold: (opts: CreateProjectScaffoldOptions) => Promise<ProjectMeta>;
/** Delete a project by id. */
deleteProject: (id: string) => Promise<void>;
/** Switch the active chapter, flushing any pending edits first. */
selectChapter: (id: string) => Promise<void>;
/** Create a new chapter in the active project. */
createChapter: (title?: string) => Promise<Chapter | null>;
/** Rename a chapter in the active project. */
renameChapter: (id: string, title: string) => Promise<void>;
/** Delete a chapter in the active project. */
deleteChapter: (id: string) => Promise<void>;
/** Queue a debounced save of a chapter document. */
scheduleSave: (ref: DocumentRef) => void;
/** Flush any pending save immediately (beforeunload / chapter switch). */
flushAutosave: () => void;
// --- entity actions ---
/** Load all entities for the active project, optionally filtered by kind. */
loadEntities: (kind?: string) => Promise<void>;
/** Create a new entity of the given kind ("character" | "world" | "note"). */
createEntity: (kind: string) => Promise<Entity>;
/** Persist an updated entity. */
updateEntity: (entity: Entity) => Promise<void>;
/** Delete an entity by id. */
deleteEntity: (id: string) => Promise<void>;
// --- whiteboard actions (Chunk #5) ---
/** Load the story whiteboard for the active project. */
loadBoard: () => Promise<void>;
/** Persist the full board (nodes + edges). */
saveBoard: (board: Board) => Promise<void>;
/** Add a node to the board. */
addBoardNode: (node: FlowNode) => Promise<void>;
/** Update a node on the board. */
updateBoardNode: (node: FlowNode) => Promise<void>;
/** Delete a node from the board. */
deleteBoardNode: (id: string) => Promise<void>;
/** Add an edge to the board. */
addBoardEdge: (edge: FlowEdge) => Promise<void>;
/** Delete an edge from the board. */
deleteBoardEdge: (id: string) => Promise<void>;
// --- plot beat actions (Chunk #5) ---
/** Load all plot beats for the active project. */
loadPlotBeats: () => Promise<void>;
/** Persist an updated plot beat. */
savePlotBeat: (beat: PlotBeat) => Promise<void>;
/** Delete a plot beat. */
deletePlotBeat: (id: string) => Promise<void>;
/** Reorder plot beats within acts. */
reorderPlotBeats: (beats: PlotBeat[]) => Promise<void>;
}
@@ -117,16 +153,43 @@ function emptyEntity(kind: string, projectId: string): Entity {
if (kind === "note") {
return { id, projectId, body: "", pinned: false, tags: [] };
}
// world
return { id, projectId, kind: "location", name: "", fields: {} };
}
export const useProjectStore = create<ProjectStore>((set, get) => {
let autosave: ReturnType<typeof createAutosave> | null = null;
async function computeAllProjectStats(
adapter: StorageAdapter,
projects: ProjectMeta[],
): Promise<Record<string, ProjectSummaryStats>> {
const stats: Record<string, ProjectSummaryStats> = {};
for (const p of projects) {
try {
const books = await adapter.listBooks(p.id);
let chapterCount = 0;
let wordCount = 0;
for (const b of books) {
const chs = await adapter.listChapters(b.id);
chapterCount += chs.length;
for (const ch of chs) {
wordCount += countDocWords(ch.doc);
}
}
stats[p.id] = { wordCount, chapterCount };
} catch {
stats[p.id] = { wordCount: 0, chapterCount: 0 };
}
}
return stats;
}
return {
adapter: null,
projects: [],
projectStats: {},
project: null,
activeBookId: null,
chapters: [],
activeChapterId: null,
status: "idle",
@@ -151,7 +214,17 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
if (projects.length === 0) {
throw new Error("No projects found.");
}
const project = projects[0]!;
let preferredId: string | null = null;
try {
preferredId = localStorage.getItem(ACTIVE_PROJECT_KEY);
} catch {
/* ignore */
}
const project =
(preferredId ? projects.find((p) => p.id === preferredId) : undefined) ??
projects[0]!;
const books = await adapter.listBooks(project.id);
const book = books[0];
const chapters = book ? await adapter.listChapters(book.id) : [];
@@ -160,30 +233,286 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
autosave.onStatus((status) => set({ autosave: status }));
const activeChapterId = chapters.length > 0 ? chapters[0]!.id : null;
set({ adapter, project, chapters, activeChapterId, status: "ready" });
const projectStats = await computeAllProjectStats(adapter, projects);
set({
adapter,
projects,
projectStats,
project,
activeBookId: book?.id ?? null,
chapters,
activeChapterId,
status: "ready",
});
// Load entities for the entity engine.
await get().loadEntities();
// Load plot beats for the plot board + timeline.
await get().loadPlotBeats();
// Load the story whiteboard.
await get().loadBoard();
} catch (e) {
set({ error: e instanceof Error ? e.message : String(e), status: "ready" });
}
},
loadProjects: async () => {
const adapter = get().adapter ?? storageLocal;
const projects = await adapter.listProjects();
const projectStats = await computeAllProjectStats(adapter, projects);
set({ adapter, projects, projectStats });
},
openProject: async (id: string) => {
if (autosave) {
await autosave.flush();
}
const adapter = get().adapter ?? storageLocal;
const projects = await adapter.listProjects();
const target = projects.find((p) => p.id === id);
if (!target) return;
try {
localStorage.setItem(ACTIVE_PROJECT_KEY, target.id);
} catch {
/* ignore */
}
let books = await adapter.listBooks(target.id);
let book = books[0];
if (!book) {
book = await adapter.createBook({ projectId: target.id, title: "Book One" });
books = [book];
}
const chapters = await adapter.listChapters(book.id);
const activeChapterId = chapters.length > 0 ? chapters[0]!.id : null;
const projectStats = await computeAllProjectStats(adapter, projects);
set({
adapter,
projects,
projectStats,
project: target,
activeBookId: book.id,
chapters,
activeChapterId,
entitiesLoaded: false,
plotBeatsLoaded: false,
boardLoaded: false,
});
await get().loadEntities();
await get().loadPlotBeats();
await get().loadBoard();
},
createProjectWithScaffold: async (opts: CreateProjectScaffoldOptions) => {
if (autosave) {
await autosave.flush();
}
const adapter = get().adapter ?? storageLocal;
const created = await adapter.createProject({
title: opts.title.trim() || "Untitled Project",
cover: { color: opts.coverColor ?? "#7c8bf5" },
});
const book = await adapter.createBook({
projectId: created.id,
title: "Book One",
});
const titles =
opts.chapterTitles && opts.chapterTitles.length > 0
? opts.chapterTitles
: Array.from({ length: Math.max(1, opts.chapterCount ?? 3) }, (_, i) => `Chapter ${i + 1}`);
for (const chTitle of titles) {
await adapter.saveDocument({
id: createUlid(),
projectId: created.id,
bookId: book.id,
title: chTitle,
doc: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: `Begin writing ${chTitle} here…` }],
},
],
},
rev: 0,
});
}
if (opts.templateId && opts.templateId !== "none") {
const template = PLOT_TEMPLATES.find((t) => t.id === opts.templateId);
if (template) {
for (const [i, b] of template.beats.entries()) {
const beat = createPlotBeat(
createUlid(),
created.id,
b.title,
b.act,
b.type,
i,
b.body ?? "",
);
await adapter.savePlotBeat(beat);
}
} else if (opts.templateId === "custom") {
const customBeats = [
createPlotBeat(createUlid(), created.id, "Act I — Opening Hook", 1, "setup", 0, "Establish protagonist, setting, and central tension."),
createPlotBeat(createUlid(), created.id, "Act II — Central Complication", 2, "turn", 0, "Escalate stakes and test the protagonist's resolve."),
createPlotBeat(createUlid(), created.id, "Act III — Climax & Resolution", 3, "climax", 0, "Bring the conflict to its decisive conclusion."),
];
for (const beat of customBeats) {
await adapter.savePlotBeat(beat);
}
}
}
if (opts.premiseNote && opts.premiseNote.trim()) {
await adapter.saveEntity({
id: createUlid(),
projectId: created.id,
body: opts.premiseNote.trim(),
pinned: true,
tags: ["premise", "overview"],
});
}
await adapter.saveBoard({
id: `board-${created.id}`,
projectId: created.id,
nodes: [
{
id: createUlid(),
type: "note",
position: { x: 120, y: 120 },
data: {
title: created.title,
text: created.title,
body: opts.premiseNote?.trim() || "Central story premise",
},
},
],
edges: [],
});
await get().openProject(created.id);
const meta = get().projects.find((p) => p.id === created.id) ?? {
...created,
perm: "edit" as const,
owned: true,
};
return meta;
},
deleteProject: async (id: string) => {
const adapter = get().adapter ?? storageLocal;
if (adapter.deleteProject) {
await adapter.deleteProject(id);
}
let remaining = await adapter.listProjects();
if (remaining.length === 0) {
await seedDemoProject(adapter);
remaining = await adapter.listProjects();
}
const currentActive = get().project?.id;
if (currentActive === id) {
await get().openProject(remaining[0]!.id);
} else {
const projectStats = await computeAllProjectStats(adapter, remaining);
set({ projects: remaining, projectStats });
}
},
selectChapter: async (id) => {
// Flush pending edits so switching chapters never drops unsaved work.
if (autosave) {
await autosave.flush();
}
set({ activeChapterId: id });
},
createChapter: async (title) => {
const { adapter, project, activeBookId, chapters } = get();
if (!adapter || !project) return null;
let bookId = activeBookId;
if (!bookId) {
const books = await adapter.listBooks(project.id);
bookId = books[0]?.id ?? (await adapter.createBook({ projectId: project.id, title: "Book One" })).id;
}
const id = createUlid();
const chTitle = title?.trim() || `Chapter ${chapters.length + 1}`;
await adapter.saveDocument({
id,
projectId: project.id,
bookId,
title: chTitle,
doc: {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }],
},
rev: 0,
});
const updatedChapters = await adapter.listChapters(bookId);
set({
activeBookId: bookId,
chapters: updatedChapters,
activeChapterId: id,
});
return updatedChapters.find((c) => c.id === id) ?? null;
},
renameChapter: async (id, title) => {
const { adapter, project, chapters } = get();
if (!adapter || !project) return;
const target = chapters.find((c) => c.id === id);
if (!target) return;
await adapter.saveDocument({
id: target.id,
projectId: project.id,
bookId: target.bookId,
title: title.trim() || target.title,
doc: target.doc,
rev: target.rev,
});
set((state) => ({
chapters: state.chapters.map((c) =>
c.id === id ? { ...c, title: title.trim() || c.title } : c,
),
}));
},
deleteChapter: async (id) => {
const { adapter, chapters, activeChapterId } = get();
if (!adapter || chapters.length <= 1) return;
if (adapter.deleteChapter) {
await adapter.deleteChapter(id);
}
const nextChapters = chapters.filter((c) => c.id !== id);
const nextActive =
activeChapterId === id ? (nextChapters[0]?.id ?? null) : activeChapterId;
set({ chapters: nextChapters, activeChapterId: nextActive });
},
scheduleSave: (ref) => {
// Keep the in-memory chapter doc updated so switching tabs or counting words stays live.
set((state) => {
const nextChapters = state.chapters.map((c) =>
c.id === ref.id ? { ...c, doc: ref.doc, title: ref.title } : c,
);
const totalWords = nextChapters.reduce((sum, ch) => sum + countDocWords(ch.doc), 0);
const nextStats = state.project
? {
...state.projectStats,
[state.project.id]: {
wordCount: totalWords,
chapterCount: nextChapters.length,
},
}
: state.projectStats;
return { chapters: nextChapters, projectStats: nextStats };
});
if (autosave) {
autosave.save(ref);
}
@@ -277,11 +606,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
loadBoard: async () => {
const { adapter, project } = get();
if (!adapter || !project) return;
// Load the persisted story board for the project. Both adapters now
// support getBoard; fall back to a blank board when none exists.
const board = await adapter.getBoard(project.id);
set({
board: board ?? { id: "board-story", projectId: project.id, nodes: [], edges: [] },
board: board ?? { id: `board-${project.id}`, projectId: project.id, nodes: [], edges: [] },
boardLoaded: true,
});
},
+6 -6
View File
@@ -215,12 +215,12 @@ const BOARD: Board = {
id: "board-story",
projectId: "",
nodes: [
{ id: "n1", type: "note", position: { x: 80, y: 120 }, data: { text: "Mara arrives at the harbor" } },
{ id: "n2", type: "note", position: { x: 320, y: 120 }, data: { text: "The light is failing" } },
{ id: "n3", type: "note", position: { x: 560, y: 120 }, data: { text: "The storm arrives" } },
{ id: "n4", type: "note", position: { x: 800, y: 120 }, data: { text: "Mara saves the light" } },
{ id: "n5", type: "note", position: { x: 320, y: 320 }, data: { text: "The secret of the glass" } },
{ id: "n6", type: "note", position: { x: 560, y: 320 }, data: { text: "Mara chooses the duty" } },
{ id: "n1", type: "note", position: { x: 80, y: 120 }, data: { title: "Mara arrives at the harbor", text: "Mara arrives at the harbor" } },
{ id: "n2", type: "note", position: { x: 320, y: 120 }, data: { title: "The light is failing", text: "The light is failing" } },
{ id: "n3", type: "note", position: { x: 560, y: 120 }, data: { title: "The storm arrives", text: "The storm arrives" } },
{ id: "n4", type: "note", position: { x: 800, y: 120 }, data: { title: "Mara saves the light", text: "Mara saves the light" } },
{ id: "n5", type: "note", position: { x: 320, y: 320 }, data: { title: "The secret of the glass", text: "The secret of the glass" } },
{ id: "n6", type: "note", position: { x: 560, y: 320 }, data: { title: "Mara chooses the duty", text: "Mara chooses the duty" } },
],
edges: [
{ id: "e1", source: "n1", target: "n2", type: "leads-to" },
+387 -16
View File
@@ -1,25 +1,241 @@
import { create } from "zustand";
import { applyFontSettings, applyTheme, type ThemeId } from "@pubscript/ui";
import { applyDensity, applyFontSettings, applyTheme, type ThemeId } from "@pubscript/ui";
import { discoverLocalProviders, type ProviderKind, type ThinkingEffort } from "@pubscript/ai";
import { createUlid } from "@pubscript/core";
export type Density = "compact" | "comfortable";
/** Body typeface for the writing column. All are system stacks (no web fonts). */
export type FontFamily = "serif" | "sans" | "mono";
/** Body typeface identifier for the writing column. */
export type FontFamily =
| "serif"
| "eb-garamond"
| "cormorant"
| "crimson"
| "libre-baskerville"
| "baskerville"
| "palatino"
| "book-antiqua"
| "source-serif"
| "merriweather"
| "lora"
| "literata"
| "spectral"
| "pt-serif"
| "ibm-plex-serif"
| "times"
| "cambria"
| "playfair"
| "cinzel"
| "sans"
| "inter"
| "ibm-plex-sans"
| "open-sans"
| "nunito"
| "space-grotesk"
| "gill-sans"
| "optima"
| "trebuchet"
| "verdana"
| "mono"
| "jetbrains-mono"
| "fira-code"
| "ibm-plex-mono"
| "courier-new";
export const FONT_FAMILIES: { value: FontFamily; label: string; css: string }[] = [
{ value: "serif", label: "Georgia Serif", css: 'Georgia, "Times New Roman", serif' },
{ value: "sans", label: "System Sans", css: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' },
{ value: "mono", label: "Mono", css: '"SFMono-Regular", ui-monospace, "Cascadia Code", Consolas, monospace' },
export interface FontOption {
value: FontFamily;
label: string;
category: "Serif" | "Sans-Serif" | "Monospace" | "Display";
css: string;
}
export const FONT_FAMILIES: FontOption[] = [
// Classic & Literary Serifs
{ value: "serif", label: "Georgia", category: "Serif", css: 'Georgia, "Times New Roman", serif' },
{ value: "eb-garamond", label: "EB Garamond", category: "Serif", css: '"EB Garamond", Garamond, Georgia, serif' },
{ value: "cormorant", label: "Cormorant Garamond", category: "Serif", css: '"Cormorant Garamond", Garamond, Georgia, serif' },
{ value: "crimson", label: "Crimson Text", category: "Serif", css: '"Crimson Text", Georgia, serif' },
{ value: "libre-baskerville", label: "Libre Baskerville", category: "Serif", css: '"Libre Baskerville", Baskerville, Georgia, serif' },
{ value: "baskerville", label: "Baskerville", category: "Serif", css: 'Baskerville, "Baskerville Old Face", "Hoefler Text", Garamond, serif' },
{ value: "palatino", label: "Palatino Linotype", category: "Serif", css: '"Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif' },
{ value: "book-antiqua", label: "Book Antiqua", category: "Serif", css: '"Book Antiqua", Palatino, Georgia, serif' },
{ value: "source-serif", label: "Source Serif 4", category: "Serif", css: '"Source Serif 4", Charter, Georgia, serif' },
{ value: "literata", label: "Literata", category: "Serif", css: '"Literata", Georgia, serif' },
{ value: "lora", label: "Lora", category: "Serif", css: '"Lora", Georgia, serif' },
{ value: "merriweather", label: "Merriweather", category: "Serif", css: '"Merriweather", Georgia, serif' },
{ value: "spectral", label: "Spectral", category: "Serif", css: '"Spectral", Georgia, serif' },
{ value: "pt-serif", label: "PT Serif", category: "Serif", css: '"PT Serif", Georgia, serif' },
{ value: "ibm-plex-serif", label: "IBM Plex Serif", category: "Serif", css: '"IBM Plex Serif", Georgia, serif' },
{ value: "cambria", label: "Cambria", category: "Serif", css: 'Cambria, Georgia, serif' },
{ value: "times", label: "Times New Roman", category: "Serif", css: '"Times New Roman", Times, serif' },
// Display Serifs
{ value: "playfair", label: "Playfair Display", category: "Display", css: '"Playfair Display", Georgia, serif' },
{ value: "cinzel", label: "Cinzel", category: "Display", css: '"Cinzel", Georgia, serif' },
// Sans-Serif
{ value: "sans", label: "System Sans", category: "Sans-Serif", css: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif' },
{ value: "inter", label: "Inter", category: "Sans-Serif", css: '"Inter", -apple-system, BlinkMacSystemFont, sans-serif' },
{ value: "ibm-plex-sans", label: "IBM Plex Sans", category: "Sans-Serif", css: '"IBM Plex Sans", -apple-system, sans-serif' },
{ value: "open-sans", label: "Open Sans", category: "Sans-Serif", css: '"Open Sans", -apple-system, sans-serif' },
{ value: "nunito", label: "Nunito", category: "Sans-Serif", css: '"Nunito", -apple-system, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", category: "Sans-Serif", css: '"Space Grotesk", -apple-system, sans-serif' },
{ value: "gill-sans", label: "Gill Sans", category: "Sans-Serif", css: '"Gill Sans", "Gill Sans MT", Calibri, sans-serif' },
{ value: "optima", label: "Optima", category: "Sans-Serif", css: 'Optima, Candara, "Noto Sans", sans-serif' },
{ value: "trebuchet", label: "Trebuchet MS", category: "Sans-Serif", css: '"Trebuchet MS", Helvetica, sans-serif' },
{ value: "verdana", label: "Verdana", category: "Sans-Serif", css: 'Verdana, Geneva, sans-serif' },
// Monospace & Manuscript
{ value: "courier-new", label: "Courier New (Manuscript)", category: "Monospace", css: '"Courier New", Courier, monospace' },
{ value: "jetbrains-mono", label: "JetBrains Mono", category: "Monospace", css: '"JetBrains Mono", monospace' },
{ value: "fira-code", label: "Fira Code", category: "Monospace", css: '"Fira Code", monospace' },
{ value: "ibm-plex-mono", label: "IBM Plex Mono", category: "Monospace", css: '"IBM Plex Mono", monospace' },
{ value: "mono", label: "System Monospace", category: "Monospace", css: '"SFMono-Regular", ui-monospace, "Cascadia Code", Consolas, monospace' },
];
/** Resolve a stored font-family enum to its CSS font-family string. */
export function fontFamilyCss(value: FontFamily): string {
const found = FONT_FAMILIES.find((f) => f.value === value);
return found?.css ?? FONT_FAMILIES.at(0)?.css ?? "";
return found?.css ?? FONT_FAMILIES[0]!.css;
}
export type GrammarProvider = "local" | "languagetool";
export interface AIProviderSetting {
id: string;
name: string;
kind: ProviderKind;
baseUrl: string;
apiKey: string;
defaultModel: string;
models: string[];
}
export interface AIAgentConfig {
id: string;
name: string;
role: "editor" | "reader" | "publisher" | "co-author" | "custom";
temperature: number;
contextWindow: number;
maxTokens: number;
thinkingEnabled: boolean;
thinkingEffort: ThinkingEffort;
systemPrompt: string;
}
export const DEFAULT_AI_PROVIDERS: AIProviderSetting[] = [
{
id: "ollama-local",
name: "Ollama (Local)",
kind: "openai-compatible",
baseUrl: "http://localhost:11434/v1",
apiKey: "",
defaultModel: "llama3.2",
models: ["llama3.2", "qwen2.5:7b", "mistral", "deepseek-r1:8b", "gemma2:9b"],
},
{
id: "llamacpp-local",
name: "llama.cpp / LM Studio (Local)",
kind: "openai-compatible",
baseUrl: "http://localhost:1234/v1",
apiKey: "",
defaultModel: "local-model",
models: ["local-model", "qwen2.5-coder", "llama-3.1-8b-instruct"],
},
{
id: "openai",
name: "OpenAI",
kind: "openai-compatible",
baseUrl: "https://api.openai.com/v1",
apiKey: "",
defaultModel: "gpt-4o",
models: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
},
{
id: "anthropic",
name: "Anthropic Claude",
kind: "anthropic",
baseUrl: "https://api.anthropic.com/v1",
apiKey: "",
defaultModel: "claude-3-7-sonnet-latest",
models: ["claude-3-7-sonnet-latest", "claude-3-5-haiku-latest"],
},
{
id: "google",
name: "Google Gemini",
kind: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKey: "",
defaultModel: "gemini-2.5-pro",
models: ["gemini-2.5-pro", "gemini-2.5-flash"],
},
{
id: "openrouter",
name: "OpenRouter",
kind: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "",
defaultModel: "anthropic/claude-3.7-sonnet",
models: [
"anthropic/claude-3.7-sonnet",
"openai/gpt-4o",
"google/gemini-2.5-pro",
"deepseek/deepseek-r1",
],
},
];
export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
{
id: "editor",
name: "Editor",
role: "editor",
temperature: 0.3,
contextWindow: 32768,
maxTokens: 2048,
thinkingEnabled: false,
thinkingEffort: "medium",
systemPrompt:
"You are the PubScript Line Editor. Tighten prose, eliminate filter words, sharpen dialogue tags, improve pacing, flag weak openings or closings, and verify consistency against character and world sheets. Always preserve the author's distinct voice and explain your rationale.",
},
{
id: "reader",
name: "Reader",
role: "reader",
temperature: 0.6,
contextWindow: 32768,
maxTokens: 2048,
thinkingEnabled: false,
thinkingEffort: "medium",
systemPrompt:
"You are an honest, attentive first-time reader. Report what felt vivid, where your attention drifted, what was confusing, what was telegraphed too early, and what emotional beats paid off. Speak as an engaged reader rather than using dry technical jargon.",
},
{
id: "publisher",
name: "Publisher",
role: "publisher",
temperature: 0.5,
contextWindow: 32768,
maxTokens: 2048,
thinkingEnabled: false,
thinkingEffort: "medium",
systemPrompt:
"You are a literary Publisher and developmental strategist. Evaluate commercial hook, genre pacing, comparable titles (comps), blurb/pitch angles, audience expectations, and structural stakes across acts.",
},
{
id: "co-author",
name: "Co-Author",
role: "co-author",
temperature: 0.8,
contextWindow: 65536,
maxTokens: 4096,
thinkingEnabled: true,
thinkingEffort: "medium",
systemPrompt:
"You are a collaborative Co-Author with full knowledge of the manuscript's characters, world-building, and plot beats. Brainstorm plot turns, draft scenes or dialogue in the author's established voice, and expand world lore—always as adoptable suggestions that respect the story's canon.",
},
];
export interface AppSettings {
theme: ThemeId;
accentColor: string;
@@ -32,13 +248,21 @@ export interface AppSettings {
wordCountGoal: number;
autoSave: boolean;
focusMode: boolean;
typewriterMode: boolean;
// Navigation & AI Sidebars
navCollapsed: boolean;
aiSidebarOpen: boolean;
// Spell & grammar (Chunk #7)
enableSpellcheck: boolean;
enableGrammarCheck: boolean;
spellcheckLanguage: string;
grammarProvider: GrammarProvider;
grammarApiKey: string;
// AI Providers & Agents
aiProviders: AIProviderSetting[];
activeProviderId: string;
activeModel: string;
aiAgents: AIAgentConfig[];
activeAgentId: string;
}
export const DEFAULT_SETTINGS: AppSettings = {
@@ -46,20 +270,27 @@ export const DEFAULT_SETTINGS: AppSettings = {
accentColor: "#7c8bf5",
density: "comfortable",
fontFamily: "serif",
fontSize: 15,
fontSize: 16,
leading: 1.7,
measure: 720,
showLineNumbers: true,
showLineNumbers: false,
wordCountGoal: 80_000,
autoSave: true,
focusMode: false,
typewriterMode: false,
navCollapsed: false,
aiSidebarOpen: false,
// Spell & grammar (Chunk #7)
enableSpellcheck: true,
enableGrammarCheck: false,
spellcheckLanguage: "en_US",
grammarProvider: "local",
grammarApiKey: "",
// AI Providers & Agents
aiProviders: DEFAULT_AI_PROVIDERS,
activeProviderId: DEFAULT_AI_PROVIDERS[0]!.id,
activeModel: DEFAULT_AI_PROVIDERS[0]!.defaultModel,
aiAgents: DEFAULT_AI_AGENTS,
activeAgentId: DEFAULT_AI_AGENTS[0]!.id,
};
const STORAGE_KEY = "pubscript.settings.v1";
@@ -69,7 +300,18 @@ function loadSettings(): AppSettings {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
const parsed = JSON.parse(raw) as Partial<AppSettings>;
return { ...DEFAULT_SETTINGS, ...parsed };
return {
...DEFAULT_SETTINGS,
...parsed,
aiProviders:
Array.isArray(parsed.aiProviders) && parsed.aiProviders.length > 0
? parsed.aiProviders
: DEFAULT_AI_PROVIDERS,
aiAgents:
Array.isArray(parsed.aiAgents) && parsed.aiAgents.length > 0
? parsed.aiAgents
: DEFAULT_AI_AGENTS,
};
} catch {
return { ...DEFAULT_SETTINGS };
}
@@ -78,13 +320,20 @@ function loadSettings(): AppSettings {
export interface SettingsStore extends AppSettings {
set: (patch: Partial<AppSettings>) => void;
setTheme: (theme: ThemeId) => void;
updateProvider: (id: string, patch: Partial<AIProviderSetting>) => void;
addProvider: (provider: Omit<AIProviderSetting, "id">) => AIProviderSetting;
deleteProvider: (id: string) => void;
updateAgent: (id: string, patch: Partial<AIAgentConfig>) => void;
addAgent: (agent: Omit<AIAgentConfig, "id">) => AIAgentConfig;
deleteAgent: (id: string) => void;
probeProviderModels: (providerId?: string) => Promise<string[]>;
reset: () => void;
}
export const useSettings = create<SettingsStore>((set, get) => {
const initial = loadSettings();
// Apply the persisted theme + font on boot.
applyTheme(initial.theme);
applyDensity(initial.density);
applyFontSettings({
size: initial.fontSize,
leading: initial.leading,
@@ -96,7 +345,17 @@ export const useSettings = create<SettingsStore>((set, get) => {
...initial,
set: (patch) => {
const next = { ...get(), ...patch };
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore */
}
if (patch.theme != null) {
applyTheme(next.theme);
}
if (patch.density != null) {
applyDensity(next.density);
}
if (
patch.fontSize != null ||
patch.leading != null ||
@@ -116,9 +375,121 @@ export const useSettings = create<SettingsStore>((set, get) => {
applyTheme(theme);
get().set({ theme });
},
updateProvider: (id, patch) => {
const nextProviders = get().aiProviders.map((p) =>
p.id === id ? { ...p, ...patch } : p,
);
get().set({ aiProviders: nextProviders });
},
addProvider: (input) => {
const created: AIProviderSetting = {
...input,
id: `prov-${createUlid().slice(-8).toLowerCase()}`,
};
const nextProviders = [...get().aiProviders, created];
get().set({
aiProviders: nextProviders,
activeProviderId: created.id,
activeModel: created.defaultModel,
});
return created;
},
deleteProvider: (id) => {
const remaining = get().aiProviders.filter((p) => p.id !== id);
if (remaining.length === 0) return;
const nextActive =
get().activeProviderId === id ? remaining[0]! : remaining.find((p) => p.id === get().activeProviderId) ?? remaining[0]!;
get().set({
aiProviders: remaining,
activeProviderId: nextActive.id,
activeModel: nextActive.defaultModel,
});
},
updateAgent: (id, patch) => {
const nextAgents = get().aiAgents.map((a) =>
a.id === id ? { ...a, ...patch } : a,
);
get().set({ aiAgents: nextAgents });
},
addAgent: (input) => {
const created: AIAgentConfig = {
...input,
id: `agent-${createUlid().slice(-8).toLowerCase()}`,
};
const nextAgents = [...get().aiAgents, created];
get().set({ aiAgents: nextAgents, activeAgentId: created.id });
return created;
},
deleteAgent: (id) => {
const remaining = get().aiAgents.filter((a) => a.id !== id);
if (remaining.length === 0) return;
const nextActiveId =
get().activeAgentId === id ? remaining[0]!.id : get().activeAgentId;
get().set({ aiAgents: remaining, activeAgentId: nextActiveId });
},
probeProviderModels: async (providerId) => {
const state = get();
const targetId = providerId ?? state.activeProviderId;
const provider = state.aiProviders.find((p) => p.id === targetId);
if (!provider) return [];
const foundModels = new Set<string>(provider.models);
try {
const base = provider.baseUrl.replace(/\/+$/, "");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1800);
try {
const headers: Record<string, string> = {};
if (provider.apiKey) {
headers.Authorization = `Bearer ${provider.apiKey}`;
}
const res = await fetch(`${base}/models`, {
headers,
signal: controller.signal,
});
if (res.ok) {
const json = (await res.json()) as { data?: Array<{ id?: string }> };
for (const item of json.data ?? []) {
if (typeof item.id === "string" && item.id.trim()) {
foundModels.add(item.id.trim());
}
}
}
} finally {
clearTimeout(timer);
}
} catch {
/* endpoint unreachable */
}
try {
const localDetected = await discoverLocalProviders({ timeoutMs: 800 });
for (const det of localDetected) {
for (const m of det.models) {
if (m.id) foundModels.add(m.id);
}
}
} catch {
/* ignore */
}
const list = [...foundModels];
if (list.length > 0) {
get().updateProvider(targetId, {
models: list,
defaultModel: provider.defaultModel || list[0]!,
});
}
return list;
},
reset: () => {
localStorage.removeItem(STORAGE_KEY);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
applyTheme(DEFAULT_SETTINGS.theme);
applyDensity(DEFAULT_SETTINGS.density);
applyFontSettings({
size: DEFAULT_SETTINGS.fontSize,
leading: DEFAULT_SETTINGS.leading,
+39 -6
View File
@@ -341,22 +341,32 @@
.ps-entity-tabs {
display: flex;
gap: 4px;
gap: 6px;
border-bottom: 1px solid var(--ps-border);
padding-bottom: -1px;
}
.ps-entity-tab-wrap {
display: inline-flex;
align-items: center;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: border-color 0.15s;
}
.ps-entity-tab-wrap.active {
border-bottom-color: var(--ps-accent);
}
.ps-entity-tab {
background: none;
border: none;
padding: 8px 12px;
padding: 8px 8px 8px 12px;
font-size: 14px;
font-family: var(--ps-font-ui);
color: var(--ps-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
transition: color 0.15s;
}
.ps-entity-tab:hover {
@@ -365,10 +375,33 @@
.ps-entity-tab.active {
color: var(--ps-text);
border-bottom-color: var(--ps-accent);
font-weight: 600;
}
.ps-entity-tab-add {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 8px;
border-radius: 5px;
border: 1px solid var(--ps-border);
background: var(--ps-surface-2);
color: var(--ps-text-muted);
font-size: 13px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.ps-entity-tab-add:hover {
background: var(--ps-accent);
border-color: var(--ps-accent);
color: var(--ps-accent-contrast);
}
.ps-entity-new-kind {
min-width: 120px;
}
+1
View File
@@ -74,6 +74,7 @@
display: flex;
flex-direction: column;
height: 100%;
padding: 20px 24px;
overflow: hidden;
}
+25 -1
View File
@@ -11,6 +11,7 @@
display: flex;
flex-direction: column;
height: 100%;
padding: 20px 24px;
overflow: hidden;
}
@@ -111,9 +112,16 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ps-accent) 40%, transparent);
}
.wf-node__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-bottom: 6px;
}
.wf-node__type {
display: inline-block;
margin-bottom: 6px;
padding: 2px 8px;
background: var(--ps-surface-2);
border-radius: 10px;
@@ -124,6 +132,22 @@
letter-spacing: 0.04em;
}
.wf-node__edit-btn {
border: none;
background: transparent;
color: var(--ps-text-muted);
cursor: pointer;
font-size: 13px;
padding: 2px 5px;
border-radius: 4px;
line-height: 1;
}
.wf-node__edit-btn:hover {
background: var(--ps-surface-2);
color: var(--ps-text);
}
.wf-node__title {
font-size: 15px;
font-weight: 600;
+201 -89
View File
@@ -1,156 +1,272 @@
/**
* Writing-view styles (apps/web).
*
* The editor's own nodes are styled by @pubscript/editor/styles (scoped to
* `.ps-editor`). This file styles the writing surface around it: the toolbar,
* the autosave indicator, the chapter selector, and the status bar. The prose
* column itself reuses `.ps-content` from the AppShell stylesheet.
* Styles the OnlyOffice / Word-style formatting toolbar, prose column,
* line-number gutter, focus mode, and status bar.
*/
/* Fills the ps-main slot (flex: 1, overflow: hidden) with a scroll region. */
.ps-writing-slot {
.ps-writing {
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
overflow: hidden;
}
/* Toolbar: chapter selector left, autosave indicator right. */
/* OnlyOffice / Word-inspired icon formatting ribbon */
.ps-writing-toolbar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 44px;
padding: 6px 24px;
background: color-mix(in srgb, var(--ps-surface) 88%, transparent);
backdrop-filter: blur(8px);
flex-wrap: wrap;
gap: 6px;
padding: 6px 14px;
background: var(--ps-surface);
border-bottom: 1px solid var(--ps-border);
font-family: var(--ps-font-ui);
flex: none;
z-index: 5;
}
:root[data-density="compact"] .ps-writing-toolbar {
padding: 4px 10px;
gap: 4px;
}
/* Chapter selector. */
.ps-chapter-select {
.ps-toolbar-group {
display: inline-flex;
align-items: center;
gap: 8px;
gap: 2px;
}
.ps-chapter-select-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ps-text-muted);
.ps-toolbar-sep {
width: 1px;
height: 20px;
background: var(--ps-border);
margin: 0 3px;
flex-shrink: 0;
}
.ps-chapter-select select {
appearance: none;
font: inherit;
font-size: 14px;
color: var(--ps-text);
background: var(--ps-surface-2);
.ps-toolbar-select {
height: 28px;
padding: 0 8px;
border: 1px solid var(--ps-border);
border-radius: 8px;
padding: 6px 30px 6px 10px;
border-radius: 6px;
background: var(--ps-surface-2);
color: var(--ps-text);
font-family: var(--ps-font-ui);
font-size: 12.5px;
cursor: pointer;
max-width: 320px;
transition: border-color 0.15s ease, background 0.15s ease;
background-image: linear-gradient(45deg, transparent 50%, var(--ps-text-muted) 50%),
linear-gradient(135deg, var(--ps-text-muted) 50%, transparent 50%);
background-position: calc(100% - 16px) calc(0.6em), calc(100% - 11px) calc(0.6em);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.ps-chapter-select select:hover {
background-color: var(--ps-border);
.ps-toolbar-select:hover {
border-color: var(--ps-text-muted);
}
.ps-chapter-select select:focus {
.ps-toolbar-select:focus {
outline: none;
border-color: var(--ps-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ps-accent) 30%, transparent);
}
.ps-toolbar-chapter-select {
max-width: 190px;
font-weight: 600;
}
.ps-toolbar-font-select {
max-width: 145px;
}
.ps-toolbar-size-select {
width: 68px;
}
.ps-toolbar-compact-select {
border: none;
background: transparent;
padding: 0 4px;
height: 26px;
}
/* Autosave indicator. */
.ps-autosave-indicator {
.ps-toolbar-icon-select {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
gap: 3px;
padding: 0 4px 0 6px;
height: 28px;
border: 1px solid var(--ps-border);
border-radius: 6px;
background: var(--ps-surface-2);
color: var(--ps-text-muted);
}
.ps-tool-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ps-text-muted);
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.ps-tool-icon:hover {
color: var(--ps-text);
background: var(--ps-surface-2);
}
.ps-tool-icon.is-active {
color: var(--ps-accent);
border-color: color-mix(in srgb, var(--ps-accent) 55%, transparent);
background: color-mix(in srgb, var(--ps-accent) 15%, transparent);
}
.ps-tool-color {
position: relative;
overflow: hidden;
}
.ps-tool-color-input {
position: absolute;
inset: 0;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
/* Autosave indicator */
.ps-autosave-indicator {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--ps-text-muted);
padding: 4px 10px;
padding: 3px 9px;
border-radius: 999px;
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
white-space: nowrap;
}
.ps-autosave-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ps-text-muted);
opacity: 0.5;
flex: none;
}
.ps-autosave-indicator[data-status="saved"] .ps-autosave-dot {
background: var(--ps-success);
opacity: 1;
}
.ps-autosave-indicator[data-status="dirty"] .ps-autosave-dot {
background: var(--ps-warning);
opacity: 1;
}
.ps-autosave-indicator[data-status="saving"] .ps-autosave-dot {
background: var(--ps-accent);
opacity: 1;
animation: ps-pulse 1s ease-in-out infinite;
}
@keyframes ps-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.5; }
50% { transform: scale(1.4); opacity: 0.5; }
}
/* Status bar: word count, scene count, chapter title. */
.ps-writing-status {
position: sticky;
bottom: 0;
z-index: 5;
/* Main body: scrollable editor + optional right context rail / check panel */
.ps-writing-body {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
gap: 16px;
padding: 6px 24px;
font-size: 12px;
color: var(--ps-text-muted);
background: color-mix(in srgb, var(--ps-surface) 88%, transparent);
backdrop-filter: blur(8px);
border-top: 1px solid var(--ps-border);
overflow: hidden;
}
.ps-writing-status .ps-writing-status-note {
margin-inline-start: auto;
font-style: italic;
opacity: 0.8;
.ps-editor-scroll {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
}
/* The editor column: reuse the prose column from the AppShell. */
/* The prose writing surface — ONLY this element consumes the typography CSS vars */
.ps-editor-writing {
padding-top: 28px;
padding-bottom: 80px;
max-width: var(--ps-font-measure, 720px);
margin: 0 auto;
padding: 36px 28px 96px;
font-family: var(--ps-font-body);
font-size: var(--ps-font-size, 16px);
line-height: var(--ps-font-leading, 1.7);
}
.ps-editor-writing .ps-editor {
min-height: 60vh;
min-height: 65vh;
}
/* Placeholder text inside the editor when it's empty. */
/* Highlight mark */
.ps-mark-highlight,
.ProseMirror mark {
background: color-mix(in srgb, var(--ps-warning) 38%, transparent);
color: inherit;
padding: 0 2px;
border-radius: 3px;
}
/* First-line book indent toggle */
.ps-editor-indent .ProseMirror > p {
text-indent: 1.75em;
margin-top: 0.35em;
margin-bottom: 0.35em;
}
/* Line numbers in the editor left gutter */
.ps-editor-linenumbers .ProseMirror {
counter-reset: ps-line;
padding-left: 44px;
position: relative;
}
.ps-editor-linenumbers .ProseMirror > * {
counter-increment: ps-line;
position: relative;
}
.ps-editor-linenumbers .ProseMirror > *::before {
content: counter(ps-line);
position: absolute;
left: -44px;
top: 0.15em;
width: 32px;
text-align: right;
font-family: var(--ps-font-mono);
font-size: 11px;
line-height: 1.4;
color: var(--ps-text-muted);
opacity: 0.6;
user-select: none;
pointer-events: none;
}
/* Focus mode: dim non-active blocks while keeping the active block at full opacity */
.ps-editor-focus .ProseMirror > * {
opacity: 0.22;
transition: opacity 0.15s ease;
}
.ps-editor-focus .ProseMirror > .ps-focus-active {
opacity: 1;
transition: opacity 0.08s ease;
}
/* Status bar */
.ps-writing-status {
display: flex;
align-items: center;
gap: 16px;
padding: 6px 18px;
font-size: 12px;
font-family: var(--ps-font-ui);
color: var(--ps-text-muted);
background: var(--ps-surface);
border-top: 1px solid var(--ps-border);
flex: none;
}
.ps-writing-status .ps-writing-status-note {
margin-inline-start: auto;
font-style: italic;
color: var(--ps-text);
opacity: 0.85;
}
/* Placeholder text inside the editor when it's empty */
.ps-editor .ProseMirror p.is-editor-empty::before {
content: attr(data-placeholder);
color: var(--ps-text-muted);
@@ -160,16 +276,12 @@
height: 0;
}
/* ── Spell & grammar squiggle decorations ─────────────────────────── */
/* Red squiggle under misspelled words. */
/* Spell & grammar squiggle decorations */
.ps-spell-deco {
text-decoration: underline wavy var(--ps-danger, #e5484d);
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
/* Blue squiggle under grammar/style issues. */
.ps-grammar-deco {
text-decoration: underline wavy var(--ps-accent, #7c8bf5);
text-decoration-thickness: 1.5px;
+10
View File
@@ -7,6 +7,16 @@ export default defineConfig({
host: "0.0.0.0",
port: 5173,
strictPort: false,
proxy: {
"/api": {
target: process.env.VITE_API_ORIGIN ?? "http://127.0.0.1:8081",
changeOrigin: true,
},
"/ws": {
target: process.env.VITE_WS_ORIGIN ?? "ws://127.0.0.1:8081",
ws: true,
},
},
},
preview: {
host: "0.0.0.0",