feat: resolve all 22 items in Features and Bugs.txt and complete UI polish
This commit is contained in:
@@ -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
@@ -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)} />
|
||||
|
||||
@@ -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" }),
|
||||
};
|
||||
@@ -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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 & 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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 20px 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user