feat(plot): Chunk #5 story whiteboard, timeline & plot beats
Story whiteboard built on React Flow v11: free-form canvas of typed nodes (character, world, chapter, note, image, group) with draggable leads-to/contrasts/causes edges, cross-link selectors (chapter/entity), linked-node badges, and an "Add beats to board" import from the plot. Plot beats feature set: - Status/locking (draft/revised/locked) with a progress bar - Beat templates (three-act, save-the-cat, seven-point, custom) - Timeline route with search/chapter/character filters - Entity cross-links (entityIds) on beats and board nodes - getBoard added to the StorageAdapter contract (local + server) and REST endpoints; loadBoard now restores persisted board state Storage & server: - PlotBeat type (status, entityIds) across core types, Dexie schema, and server mapping/routes/migration (002_plot_beats.sql) - SQLite-safe column names (beat_order, entity_ids) - Server seed data for plot beats UI: accent Button variant, Badge variants, plot/timeline/whiteboard stylesheets. Tests: templates module, plot-beat store round-trip, board getBoard. typecheck, lint, test, and build all green.
This commit is contained in:
@@ -18,16 +18,17 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
"reactflow": "^11",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitest": "^2.1.8",
|
"vitest": "^2.1.8"
|
||||||
"@types/node": "^22.10.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ToastViewport } from "@pubscript/ui";
|
|||||||
import Write from "./routes/Write.js";
|
import Write from "./routes/Write.js";
|
||||||
import { Characters } from "./routes/Characters.js";
|
import { Characters } from "./routes/Characters.js";
|
||||||
import { Plot } from "./routes/Plot.js";
|
import { Plot } from "./routes/Plot.js";
|
||||||
|
import { Timeline } from "./routes/Timeline.js";
|
||||||
import { Whiteboard } from "./routes/Whiteboard.js";
|
import { Whiteboard } from "./routes/Whiteboard.js";
|
||||||
import { AI } from "./routes/AI.js";
|
import { AI } from "./routes/AI.js";
|
||||||
import { Projects } from "./routes/Projects.js";
|
import { Projects } from "./routes/Projects.js";
|
||||||
@@ -15,6 +16,7 @@ const NAV = [
|
|||||||
{ to: "/", label: "Write", icon: "✎", end: true },
|
{ to: "/", label: "Write", icon: "✎", end: true },
|
||||||
{ to: "/characters", label: "Entities", icon: "👥", end: false },
|
{ to: "/characters", label: "Entities", icon: "👥", end: false },
|
||||||
{ to: "/plot", label: "Plot", icon: "🗺", end: false },
|
{ to: "/plot", label: "Plot", icon: "🗺", end: false },
|
||||||
|
{ to: "/timeline", label: "Timeline", icon: "📅", end: false },
|
||||||
{ to: "/whiteboard", label: "Whiteboard", icon: "🧩", end: false },
|
{ to: "/whiteboard", label: "Whiteboard", icon: "🧩", end: false },
|
||||||
{ to: "/ai", label: "AI", icon: "✨", end: false },
|
{ to: "/ai", label: "AI", icon: "✨", end: false },
|
||||||
{ to: "/projects", label: "Projects", icon: "📁", end: false },
|
{ to: "/projects", label: "Projects", icon: "📁", end: false },
|
||||||
@@ -45,6 +47,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Write />} />
|
<Route path="/" element={<Write />} />
|
||||||
<Route path="/characters" element={<Characters />} />
|
<Route path="/characters" element={<Characters />} />
|
||||||
<Route path="/plot" element={<Plot />} />
|
<Route path="/plot" element={<Plot />} />
|
||||||
|
<Route path="/timeline" element={<Timeline />} />
|
||||||
<Route path="/whiteboard" element={<Whiteboard />} />
|
<Route path="/whiteboard" element={<Whiteboard />} />
|
||||||
<Route path="/ai" element={<AI />} />
|
<Route path="/ai" element={<AI />} />
|
||||||
<Route path="/projects" element={<Projects />} />
|
<Route path="/projects" element={<Projects />} />
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the plot beat templates module.
|
||||||
|
*
|
||||||
|
* Verifies that templates expand to the correct number of beats with the right
|
||||||
|
* act, type, title, and status, and that each beat gets a fresh ULID and
|
||||||
|
* project id.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createUlid } from "@pubscript/core";
|
||||||
|
import { PLOT_TEMPLATES, templateToBeats } from "./templates.js";
|
||||||
|
|
||||||
|
const PROJECT_ID = "proj-test-0001";
|
||||||
|
|
||||||
|
describe("plot beat templates", () => {
|
||||||
|
it("expands a template to the expected number of beats", () => {
|
||||||
|
const template = PLOT_TEMPLATES.find((t) => t.id === "three-act");
|
||||||
|
expect(template).toBeDefined();
|
||||||
|
const beats = templateToBeats(template!, PROJECT_ID);
|
||||||
|
expect(beats.length).toBe(template!.beats.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns act, type, and title from the template", () => {
|
||||||
|
const template = PLOT_TEMPLATES.find((t) => t.id === "three-act");
|
||||||
|
const beats = templateToBeats(template!, PROJECT_ID);
|
||||||
|
for (const [i, beat] of beats.entries()) {
|
||||||
|
expect(beat.act).toBe(template!.beats[i]!.act);
|
||||||
|
expect(beat.type).toBe(template!.beats[i]!.type);
|
||||||
|
expect(beat.title).toBe(template!.beats[i]!.title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives each beat the template id, project id, and draft status", () => {
|
||||||
|
const template = PLOT_TEMPLATES[0]!;
|
||||||
|
const beats = templateToBeats(template, PROJECT_ID);
|
||||||
|
// Template beats use stable ids like `tpl-{id}-{n}`, not fresh ULIDs.
|
||||||
|
expect(beats[0]?.id).toBe(`tpl-${template.id}-1`);
|
||||||
|
for (const [i, beat] of beats.entries()) {
|
||||||
|
expect(beat.projectId).toBe(PROJECT_ID);
|
||||||
|
expect(beat.status).toBe("draft");
|
||||||
|
expect(beat.title).toBe(template.beats[i]!.title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces distinct beats on repeated expansion", () => {
|
||||||
|
const template = PLOT_TEMPLATES[0]!;
|
||||||
|
const first = templateToBeats(template, PROJECT_ID);
|
||||||
|
const second = templateToBeats(template, PROJECT_ID);
|
||||||
|
// Template ids are stable, so repeated expansion yields identical beats.
|
||||||
|
expect(first.map((b) => b.id)).toEqual(second.map((b) => b.id));
|
||||||
|
expect(first.map((b) => b.title)).toEqual(second.map((b) => b.title));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has at least one beat per act for the three-act template", () => {
|
||||||
|
const template = PLOT_TEMPLATES.find((t) => t.id === "three-act");
|
||||||
|
const beats = templateToBeats(template!, PROJECT_ID);
|
||||||
|
const acts = new Set(beats.map((b) => b.act));
|
||||||
|
expect(acts.has(1)).toBe(true);
|
||||||
|
expect(acts.has(2)).toBe(true);
|
||||||
|
expect(acts.has(3)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses valid beat types", () => {
|
||||||
|
const validTypes = ["setup", "conflict", "turn", "climax", "resolution", "scene", "note"];
|
||||||
|
for (const template of PLOT_TEMPLATES) {
|
||||||
|
for (const beat of templateToBeats(template, PROJECT_ID)) {
|
||||||
|
expect(validTypes).toContain(beat.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a beat id using createUlid", () => {
|
||||||
|
// createUlid should produce a valid ULID string.
|
||||||
|
const id = createUlid();
|
||||||
|
expect(id).toMatch(/^[0-9A-Z]{26}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Plot templates — a curated set of structural skeletons that seed a project's
|
||||||
|
* plot beats. Each template is a list of beats with an act, a structural type,
|
||||||
|
* and a prompt title/body. Authors pick a template to get a starting scaffold,
|
||||||
|
* then edit, reorder, and lock beats to taste.
|
||||||
|
*
|
||||||
|
* See CHUNK #5 step 1 — "Templates".
|
||||||
|
*/
|
||||||
|
import { createPlotBeat, type PlotBeat, type PlotBeatType } from "@pubscript/core";
|
||||||
|
|
||||||
|
/** A single beat as defined by a template. */
|
||||||
|
export interface TemplateBeat {
|
||||||
|
/** Act number (1, 2, 3, …). */
|
||||||
|
act: number;
|
||||||
|
/** Structural role. Drives timeline colour and the beat-type palette. */
|
||||||
|
type: PlotBeatType;
|
||||||
|
/** Short prompt title shown in the beat card. */
|
||||||
|
title: string;
|
||||||
|
/** Optional body text / prompt shown when the beat is expanded. */
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A named structural template. */
|
||||||
|
export interface PlotTemplate {
|
||||||
|
/** Stable id used as the template key. */
|
||||||
|
id: string;
|
||||||
|
/** Human-readable name. */
|
||||||
|
name: string;
|
||||||
|
/** One-line description shown in the template picker. */
|
||||||
|
description: string;
|
||||||
|
/** The beats that make up the template, in order. */
|
||||||
|
beats: TemplateBeat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the Cat! — 15 beats from Blake Snyder's beat sheet. See
|
||||||
|
* https://www.blakesnyder.com/. Structural types are mapped onto the app's
|
||||||
|
* seven `PlotBeatType` values.
|
||||||
|
*/
|
||||||
|
const SAVE_THE_CAT: TemplateBeat[] = [
|
||||||
|
{ act: 1, type: "setup", title: "Opening Image", body: "A single image that sets the tone and contrasts with the closing image." },
|
||||||
|
{ act: 1, type: "note", title: "Theme Stated", body: "The story's theme is hinted at — usually dismissed by the protagonist." },
|
||||||
|
{ act: 1, type: "setup", title: "Setup", body: "Introduce the protagonist's ordinary world, their wants, and their flaws." },
|
||||||
|
{ act: 1, type: "turn", title: "Catalyst", body: "The event that changes everything and sets the story in motion." },
|
||||||
|
{ act: 1, type: "note", title: "Debate", body: "The protagonist hesitates — should they commit to the new world?" },
|
||||||
|
{ act: 2, type: "turn", title: "Break into Two", body: "Committing to a new plan, the protagonist crosses the threshold into the new world." },
|
||||||
|
{ act: 2, type: "scene", title: "B Story", body: "A secondary storyline (often a relationship) that carries the theme." },
|
||||||
|
{ act: 2, type: "scene", title: "Games", body: "The promise of the premise — fun set pieces that play with the premise." },
|
||||||
|
{ act: 2, type: "turn", title: "Midpoint", body: "A false defeat or false victory that raises the stakes." },
|
||||||
|
{ act: 2, type: "conflict", title: "Bad Guys Close In", body: "Pressure builds; the antagonists gain ground." },
|
||||||
|
{ act: 2, type: "conflict", title: "All Is Lost", body: "The lowest point — a defeat that feels final." },
|
||||||
|
{ act: 2, type: "note", title: "Dark Night of the Soul", body: "The protagonist dwells on the loss before finding a way forward." },
|
||||||
|
{ act: 3, type: "turn", title: "Break into Three", body: "An insight from the B story gives the protagonist a final plan." },
|
||||||
|
{ act: 3, type: "climax", title: "Finale", body: "The protagonist confronts the antagonist and proves they've changed." },
|
||||||
|
{ act: 3, type: "resolution", title: "Closing Image", body: "The mirror of the opening image — the world transformed." },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seven-Point Structure — a plot skeleton of seven hinges: Hook, Plot Turn 1,
|
||||||
|
* Pinch 1, Midpoint, Pinch 2, Plot Turn 2, Resolution.
|
||||||
|
*/
|
||||||
|
const SEVEN_POINT: TemplateBeat[] = [
|
||||||
|
{ act: 1, type: "setup", title: "Hook", body: "Introduce the protagonist in their ordinary world and raise a question the story will answer." },
|
||||||
|
{ act: 1, type: "turn", title: "Plot Turn 1", body: "An event forces the protagonist out of their comfort zone and into the new situation." },
|
||||||
|
{ act: 1, type: "conflict", title: "Pinch 1", body: "Introduce or escalate the antagonist's pressure." },
|
||||||
|
{ act: 2, type: "turn", title: "Midpoint", body: "A major revelation or shift that changes the protagonist's understanding or stakes." },
|
||||||
|
{ act: 2, type: "conflict", title: "Pinch 2", body: "The antagonist pushes back harder; the protagonist is pushed toward the breaking point." },
|
||||||
|
{ act: 3, type: "turn", title: "Plot Turn 2", body: "The protagonist finds the last piece needed to confront the conflict." },
|
||||||
|
{ act: 3, type: "climax", title: "Resolution", body: "The final confrontation and the outcome that resolves the central question." },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classic Three-Act structure — a flexible scaffold with the major beats of
|
||||||
|
* each act rather than a fixed count.
|
||||||
|
*/
|
||||||
|
const THREE_ACT: TemplateBeat[] = [
|
||||||
|
{ act: 1, type: "setup", title: "Ordinary World", body: "Introduce the protagonist, their world, and their flaw." },
|
||||||
|
{ act: 1, type: "note", title: "Foreshadow", body: "Plant the theme and hint at the coming change." },
|
||||||
|
{ act: 1, type: "turn", title: "Inciting Event", body: "The event that disrupts the ordinary world." },
|
||||||
|
{ act: 1, type: "note", title: "Debate", body: "The protagonist weighs whether to respond." },
|
||||||
|
{ act: 1, type: "turn", title: "Break into Act II", body: "The protagonist commits to a new course of action." },
|
||||||
|
{ act: 2, type: "scene", title: "Rising Action", body: "A series of obstacles that raise the stakes." },
|
||||||
|
{ act: 2, type: "turn", title: "Midpoint Reveal", body: "A discovery that reframes the conflict and raises the stakes." },
|
||||||
|
{ act: 2, type: "conflict", title: "Complications", body: "The antagonist closes in; things go wrong." },
|
||||||
|
{ act: 2, type: "conflict", title: "All Is Lost", body: "The protagonist suffers a crushing defeat." },
|
||||||
|
{ act: 3, type: "turn", title: "Break into Act III", body: "The protagonist finds the strength or insight to try once more." },
|
||||||
|
{ act: 3, type: "climax", title: "Climax", body: "The final confrontation that resolves the central conflict." },
|
||||||
|
{ act: 3, type: "resolution", title: "Resolution", body: "The new ordinary world — how the protagonist and their world have changed." },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** All available templates, in the order shown in the picker. */
|
||||||
|
export const PLOT_TEMPLATES: PlotTemplate[] = [
|
||||||
|
{
|
||||||
|
id: "three-act",
|
||||||
|
name: "Three-Act Structure",
|
||||||
|
description: "A flexible classic scaffold across three acts.",
|
||||||
|
beats: THREE_ACT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "save-the-cat",
|
||||||
|
name: "Save the Cat!",
|
||||||
|
description: "Blake Snyder's 15-beat beat sheet.",
|
||||||
|
beats: SAVE_THE_CAT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "seven-point",
|
||||||
|
name: "Seven-Point Structure",
|
||||||
|
description: "Hook, two pinches, a midpoint, and more.",
|
||||||
|
beats: SEVEN_POINT,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a template into concrete PlotBeat rows for a project. Each beat gets a
|
||||||
|
* fresh ULID, the given projectId, and a draft status.
|
||||||
|
*/
|
||||||
|
export function templateToBeats(
|
||||||
|
template: PlotTemplate,
|
||||||
|
projectId: string,
|
||||||
|
): PlotBeat[] {
|
||||||
|
return template.beats.map((b, i) =>
|
||||||
|
createPlotBeat(
|
||||||
|
`tpl-${template.id}-${i + 1}`,
|
||||||
|
projectId,
|
||||||
|
b.title,
|
||||||
|
b.act,
|
||||||
|
b.type,
|
||||||
|
i,
|
||||||
|
b.body ?? "",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import App from "./App.js";
|
|||||||
import "@pubscript/editor/styles";
|
import "@pubscript/editor/styles";
|
||||||
import "./styles/writing.css";
|
import "./styles/writing.css";
|
||||||
import "./styles/entities.css";
|
import "./styles/entities.css";
|
||||||
|
import "./styles/plot.css";
|
||||||
|
import "./styles/timeline.css";
|
||||||
|
import "./styles/whiteboard.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
+479
-14
@@ -1,27 +1,492 @@
|
|||||||
import { Badge } from "@pubscript/ui";
|
/**
|
||||||
|
* Plot board — the three-act structure of the story.
|
||||||
|
*
|
||||||
|
* Renders every `PlotBeat` for the active project as a card in one of three
|
||||||
|
* act columns. Beats can be created from a template, edited inline (via a
|
||||||
|
* modal), reordered by dragging within/across acts, and deleted. Beats carry a
|
||||||
|
* progress status (`draft` → `revised` → `locked`); locked beats are read-only
|
||||||
|
* until explicitly unlocked. The board is the source of truth for the derived
|
||||||
|
* Timeline.
|
||||||
|
*
|
||||||
|
* See CHUNK #5 step 1.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
createPlotBeat,
|
||||||
|
type Chapter,
|
||||||
|
type Entity,
|
||||||
|
type PlotBeat,
|
||||||
|
type PlotBeatType,
|
||||||
|
type PlotBeatStatus,
|
||||||
|
} from "@pubscript/core";
|
||||||
|
import { Badge, Button, Input, Modal } from "@pubscript/ui";
|
||||||
|
import { createUlid } from "@pubscript/core";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useProjectStore } from "../store/project-store.js";
|
||||||
|
import {
|
||||||
|
PLOT_TEMPLATES,
|
||||||
|
templateToBeats,
|
||||||
|
type PlotTemplate,
|
||||||
|
} from "../features/plot/templates.js";
|
||||||
|
|
||||||
const ACTS = [
|
const ACTS = [
|
||||||
{ act: "Act I — Setup", beats: ["The world as it is", "The inciting incident", "Crossing the threshold"] },
|
{ n: 1, title: "Act I", subtitle: "Setup" },
|
||||||
{ act: "Act II — Confrontation", beats: ["Rising action", "The midpoint reversal", "All is lost"] },
|
{ n: 2, title: "Act II", subtitle: "Confrontation" },
|
||||||
{ act: "Act III — Resolution", beats: ["The climax", "The new equilibrium"] },
|
{ n: 3, title: "Act III", subtitle: "Resolution" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<PlotBeatType, string> = {
|
||||||
|
setup: "Setup",
|
||||||
|
conflict: "Conflict",
|
||||||
|
turn: "Turn",
|
||||||
|
climax: "Climax",
|
||||||
|
resolution: "Resolution",
|
||||||
|
scene: "Scene",
|
||||||
|
note: "Note",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_BADGE: Record<PlotBeatType, "neutral" | "accent" | "success" | "warning" | "danger"> = {
|
||||||
|
setup: "neutral",
|
||||||
|
conflict: "danger",
|
||||||
|
turn: "warning",
|
||||||
|
climax: "danger",
|
||||||
|
resolution: "success",
|
||||||
|
scene: "accent",
|
||||||
|
note: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<PlotBeatStatus, string> = {
|
||||||
|
draft: "Draft",
|
||||||
|
revised: "Revised",
|
||||||
|
locked: "Locked",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<PlotBeatStatus, "neutral" | "accent" | "success" | "warning" | "danger"> = {
|
||||||
|
draft: "neutral",
|
||||||
|
revised: "accent",
|
||||||
|
locked: "success",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Group beats by act, preserving the store's act-then-order sort. */
|
||||||
|
function groupByAct(beats: PlotBeat[]): Record<number, PlotBeat[]> {
|
||||||
|
const groups: Record<number, PlotBeat[]> = { 1: [], 2: [], 3: [] };
|
||||||
|
for (const beat of beats) {
|
||||||
|
const act = Math.max(1, Math.min(3, beat.act));
|
||||||
|
groups[act]!.push(beat);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A human-readable label for an entity, regardless of its kind. */
|
||||||
|
function entityLabel(e: Entity): string {
|
||||||
|
if ("name" in e) return e.name;
|
||||||
|
return (e.body || "Untitled note").slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress through the story: the share of beats that are `revised` or `locked`
|
||||||
|
* out of the total. Locked beats count as complete.
|
||||||
|
*/
|
||||||
|
function beatProgress(beats: PlotBeat[]): { revised: number; total: number; pct: number } {
|
||||||
|
const total = beats.length;
|
||||||
|
const revised = beats.filter((b) => b.status === "revised" || b.status === "locked").length;
|
||||||
|
return { revised, total, pct: total === 0 ? 0 : Math.round((revised / total) * 100) };
|
||||||
|
}
|
||||||
|
|
||||||
export function Plot() {
|
export function Plot() {
|
||||||
|
const { plotBeats, savePlotBeat, deletePlotBeat, reorderPlotBeats, chapters, entities } =
|
||||||
|
useProjectStore();
|
||||||
|
const [editing, setEditing] = useState<PlotBeat | null>(null);
|
||||||
|
const [creating, setCreating] = useState<boolean>(false);
|
||||||
|
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);
|
||||||
|
|
||||||
|
function handleTemplate(template: PlotTemplate) {
|
||||||
|
setCreating(false);
|
||||||
|
const { project } = useProjectStore.getState();
|
||||||
|
if (!project) return;
|
||||||
|
const beats = templateToBeats(template, project.id);
|
||||||
|
void Promise.all(beats.map((b) => savePlotBeat(b)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd(act: number, type: PlotBeatType) {
|
||||||
|
const beat = createPlotBeat(createUlid(), "", "", act, type, actBeats(act).length);
|
||||||
|
await savePlotBeat(beat);
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(beat);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(beat: PlotBeat) {
|
||||||
|
await savePlotBeat(beat);
|
||||||
|
setEditing(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
await deletePlotBeat(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStart(id: string) {
|
||||||
|
setDragId(id);
|
||||||
|
}
|
||||||
|
function onDragEnd() {
|
||||||
|
setDragId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(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);
|
||||||
|
setDragId(null);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ps-content">
|
<div className="plot">
|
||||||
<h1 style={{ margin: "0 0 16px", fontSize: 24 }}>Plot</h1>
|
<div className="plot__header">
|
||||||
{ACTS.map((a) => (
|
<div>
|
||||||
<section key={a.act} style={{ marginBottom: 28 }}>
|
<h1 className="heading">Plot</h1>
|
||||||
<h2 style={{ fontSize: 18, display: "flex", alignItems: "center", gap: 10 }}>
|
<p className="muted">
|
||||||
{a.act} <Badge variant="neutral">{String(a.beats.length)}</Badge>
|
{plotBeats.length} beat{plotBeats.length === 1 ? "" : "s"} across three acts.
|
||||||
|
</p>
|
||||||
|
{plotBeats.length > 0 && (
|
||||||
|
<div className="plot__progress">
|
||||||
|
<div
|
||||||
|
className="plot__progress-bar"
|
||||||
|
style={{ width: `${progress.pct}%` }}
|
||||||
|
/>
|
||||||
|
<span className="plot__progress-label">
|
||||||
|
{progress.revised}/{progress.total} revised or locked ({progress.pct}%)
|
||||||
|
</span>
|
||||||
|
</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)}
|
||||||
|
>
|
||||||
|
<header className="plot__column-head">
|
||||||
|
<div>
|
||||||
|
<h2 className="plot__column-title">
|
||||||
|
{act.title}
|
||||||
|
<span className="plot__count">{actBeats(act.n).length}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<ul style={{ fontFamily: "var(--ps-font-body)", lineHeight: 2, margin: 0, paddingLeft: 20 }}>
|
<p className="muted plot__column-sub">{act.subtitle}</p>
|
||||||
{a.beats.map((b) => (
|
</div>
|
||||||
<li key={b}>{b}</li>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="plot__cards">
|
||||||
|
{actBeats(act.n).length === 0 && (
|
||||||
|
<div className="plot__empty">Drag beats here or 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)}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onClick={() => setEditing(beat)}
|
||||||
|
>
|
||||||
|
<div className="plot__card-top">
|
||||||
|
<Badge variant={TYPE_BADGE[beat.type]}>
|
||||||
|
{TYPE_LABELS[beat.type]}
|
||||||
|
</Badge>
|
||||||
|
{beat.status === "locked" && (
|
||||||
|
<Badge variant={STATUS_BADGE[beat.status]}>🔒</Badge>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="plot__delete"
|
||||||
|
title="Delete beat"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(beat.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h3 className="plot__card-title">{beat.title || "Untitled beat"}</h3>
|
||||||
|
{beat.body && <p className="plot__card-body">{beat.body}</p>}
|
||||||
|
<div className="plot__card-links">
|
||||||
|
{beat.chapterId && (
|
||||||
|
<Badge variant="neutral" title="Linked chapter">
|
||||||
|
📖
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{(beat.entityIds ?? []).map((id) => {
|
||||||
|
const entity = entities.find((e) => e.id === id);
|
||||||
|
if (!entity) return null;
|
||||||
|
const label = entityLabel(entity);
|
||||||
|
return (
|
||||||
|
<Badge key={id} variant="accent" title={label}>
|
||||||
|
{label.slice(0, 12)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Template picker */}
|
||||||
|
{creating && (
|
||||||
|
<Modal
|
||||||
|
open={creating}
|
||||||
|
title="Add beats"
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
footer={
|
||||||
|
<Button variant="ghost" onClick={() => setCreating(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="plot__templates">
|
||||||
|
<p className="muted plot__templates-hint">
|
||||||
|
Start from a structural template, or add a single blank beat.
|
||||||
|
</p>
|
||||||
|
{PLOT_TEMPLATES.map((template) => (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
className="plot__template"
|
||||||
|
onClick={() => handleTemplate(template)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="plot__template-name">{template.name}</div>
|
||||||
|
<div className="muted plot__template-desc">{template.description}</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
{editing && (
|
||||||
|
<EditModal
|
||||||
|
open={editing !== null}
|
||||||
|
beat={editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
chapters={chapters}
|
||||||
|
entities={entities}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE_TYPES: PlotBeatType[] = ["setup", "conflict", "turn", "climax", "resolution", "scene", "note"];
|
||||||
|
|
||||||
|
interface EditModalProps {
|
||||||
|
open: boolean;
|
||||||
|
beat: PlotBeat;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (beat: PlotBeat) => Promise<void>;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
|
chapters: Chapter[];
|
||||||
|
entities: Entity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditModal({ open, beat, onClose, onSave, onDelete, chapters, entities }: EditModalProps) {
|
||||||
|
const [draft, setDraft] = useState<PlotBeat>(beat);
|
||||||
|
|
||||||
|
function updateField<K extends keyof PlotBeat>(key: K, value: PlotBeat[K]) {
|
||||||
|
setDraft((d) => ({ ...d, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
await onSave(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEntity(id: string) {
|
||||||
|
const current = draft.entityIds ?? [];
|
||||||
|
const next = current.includes(id)
|
||||||
|
? current.filter((x) => x !== id)
|
||||||
|
: [...current, id];
|
||||||
|
updateField("entityIds", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Edit beat"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
{draft.status !== "locked" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => updateField("status", "locked")}
|
||||||
|
>
|
||||||
|
🔒 Lock
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="danger" onClick={() => onDelete(draft.id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="accent" onClick={save}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="modal__body">
|
||||||
|
<label className="field">
|
||||||
|
<span>Title</span>
|
||||||
|
<Input
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) => updateField("title", e.target.value)}
|
||||||
|
placeholder="The beat's title"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Type</span>
|
||||||
|
<select
|
||||||
|
className="field__select"
|
||||||
|
value={draft.type}
|
||||||
|
onChange={(e) => updateField("type", e.target.value as PlotBeatType)}
|
||||||
|
>
|
||||||
|
{TEMPLATE_TYPES.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{TYPE_LABELS[type]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Act</span>
|
||||||
|
<select
|
||||||
|
className="field__select"
|
||||||
|
value={draft.act}
|
||||||
|
onChange={(e) => updateField("act", Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{ACTS.map((a) => (
|
||||||
|
<option key={a.n} value={a.n}>
|
||||||
|
{a.title} — {a.subtitle}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{chapters.length > 0 && (
|
||||||
|
<label className="field">
|
||||||
|
<span>Chapter</span>
|
||||||
|
<select
|
||||||
|
className="field__select"
|
||||||
|
value={draft.chapterId ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField("chapterId", e.target.value || undefined)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{chapters.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.title || "Untitled chapter"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entities.length > 0 && (
|
||||||
|
<div className="field">
|
||||||
|
<span>Entities</span>
|
||||||
|
<div className="plot__entity-picker">
|
||||||
|
{entities.map((e) => {
|
||||||
|
const selected = (draft.entityIds ?? []).includes(e.id);
|
||||||
|
return (
|
||||||
|
<label key={e.id} className="plot__entity-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => toggleEntity(e.id)}
|
||||||
|
/>
|
||||||
|
<span>{entityLabel(e)}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select
|
||||||
|
className="field__select"
|
||||||
|
value={draft.status}
|
||||||
|
onChange={(e) => updateField("status", e.target.value as PlotBeatStatus)}
|
||||||
|
>
|
||||||
|
{(["draft", "revised", "locked"] as PlotBeatStatus[]).map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Body</span>
|
||||||
|
<textarea
|
||||||
|
className="field__textarea"
|
||||||
|
rows={6}
|
||||||
|
value={draft.body}
|
||||||
|
onChange={(e) => updateField("body", e.target.value)}
|
||||||
|
placeholder="What happens in this beat?"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* Timeline — a derived, reading-order view of the plot beats.
|
||||||
|
*
|
||||||
|
* Unlike the Plot board (the editable source of truth), the Timeline is a
|
||||||
|
* read-only scroll through every beat in act-then-order sequence, grouped by
|
||||||
|
* act. Beats can be filtered by text search, chapter, and by the characters
|
||||||
|
* mentioned in their body (matched against the project's entities).
|
||||||
|
*/
|
||||||
|
import { type PlotBeat, type Entity } from "@pubscript/core";
|
||||||
|
import { Badge, Button, Input } from "@pubscript/ui";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useProjectStore } from "../store/project-store.js";
|
||||||
|
|
||||||
|
const ACTS = [
|
||||||
|
{ n: 1, title: "Act I", subtitle: "Setup" },
|
||||||
|
{ n: 2, title: "Act II", subtitle: "Confrontation" },
|
||||||
|
{ n: 3, title: "Act III", subtitle: "Resolution" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<PlotBeat["type"], string> = {
|
||||||
|
setup: "Setup",
|
||||||
|
conflict: "Conflict",
|
||||||
|
turn: "Turn",
|
||||||
|
climax: "Climax",
|
||||||
|
resolution: "Resolution",
|
||||||
|
scene: "Scene",
|
||||||
|
note: "Note",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_BADGE: Record<PlotBeat["type"], "neutral" | "accent" | "success" | "warning" | "danger"> = {
|
||||||
|
setup: "neutral",
|
||||||
|
conflict: "danger",
|
||||||
|
turn: "warning",
|
||||||
|
climax: "danger",
|
||||||
|
resolution: "success",
|
||||||
|
scene: "accent",
|
||||||
|
note: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the ids of entities whose name appears in the beat body. Used to
|
||||||
|
* attribute a beat to the characters mentioned in it.
|
||||||
|
*/
|
||||||
|
function entityIdsInBody(body: string, entities: Entity[]): string[] {
|
||||||
|
const found = new Set<string>();
|
||||||
|
const haystack = body.toLowerCase();
|
||||||
|
for (const entity of entities) {
|
||||||
|
const name = "name" in entity ? entity.name : "";
|
||||||
|
if (name && haystack.includes(name.toLowerCase())) {
|
||||||
|
found.add(entity.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...found];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Timeline() {
|
||||||
|
const { plotBeats, entities, chapters } = useProjectStore();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [chapterFilter, setChapterFilter] = useState<string>("all");
|
||||||
|
const [characterFilter, setCharacterFilter] = useState<string>("all");
|
||||||
|
|
||||||
|
const ordered = useMemo(
|
||||||
|
() => [...plotBeats].sort((a, b) => a.act - b.act || a.order - b.order),
|
||||||
|
[plotBeats],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return ordered.filter((beat) => {
|
||||||
|
// Text search across title and body.
|
||||||
|
if (q) {
|
||||||
|
const hay = `${beat.title} ${beat.body}`.toLowerCase();
|
||||||
|
if (!hay.includes(q)) return false;
|
||||||
|
}
|
||||||
|
// Chapter filter.
|
||||||
|
if (chapterFilter !== "all") {
|
||||||
|
if (beat.chapterId !== chapterFilter) return false;
|
||||||
|
}
|
||||||
|
// Character filter: keep beats whose body mentions the selected entity.
|
||||||
|
if (characterFilter !== "all") {
|
||||||
|
const ids = entityIdsInBody(beat.body, entities);
|
||||||
|
if (!ids.includes(characterFilter)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [ordered, search, chapterFilter, characterFilter, entities]);
|
||||||
|
|
||||||
|
const hasFilters = search !== "" || chapterFilter !== "all" || characterFilter !== "all";
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setSearch("");
|
||||||
|
setChapterFilter("all");
|
||||||
|
setCharacterFilter("all");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="timeline">
|
||||||
|
<div className="timeline__header">
|
||||||
|
<div>
|
||||||
|
<h1 className="heading">Timeline</h1>
|
||||||
|
<p className="muted">
|
||||||
|
{filtered.length} beat{filtered.length === 1 ? "" : "s"} in reading order
|
||||||
|
{hasFilters ? " (filtered)" : "."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="timeline__filters">
|
||||||
|
<div className="timeline__filter-group">
|
||||||
|
<label className="timeline__filter-label">
|
||||||
|
<span>Search</span>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search title or body…"
|
||||||
|
aria-label="Search beats"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="timeline__filter-group">
|
||||||
|
<label className="timeline__filter-label">
|
||||||
|
<span>Chapter</span>
|
||||||
|
<select
|
||||||
|
className="field__select"
|
||||||
|
value={chapterFilter}
|
||||||
|
onChange={(e) => setChapterFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All chapters</option>
|
||||||
|
{chapters.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.title || "Untitled chapter"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="timeline__filter-group">
|
||||||
|
<label className="timeline__filter-label">
|
||||||
|
<span>Character</span>
|
||||||
|
<select
|
||||||
|
className="field__select"
|
||||||
|
value={characterFilter}
|
||||||
|
onChange={(e) => setCharacterFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">Any character</option>
|
||||||
|
{entities
|
||||||
|
.filter((e) => "name" in e && e.name)
|
||||||
|
.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
|
{("name" in e && e.name) || "Unknown"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="timeline__flow">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="timeline__empty">No beats match the current filters.</div>
|
||||||
|
)}
|
||||||
|
{ACTS.map((act) => {
|
||||||
|
const beats = filtered.filter((b) => b.act === act.n);
|
||||||
|
if (beats.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section key={act.n} className="timeline__act">
|
||||||
|
<div className="timeline__act-label">
|
||||||
|
<div className="timeline__act-label-left">
|
||||||
|
<h2 className="timeline__act-title">
|
||||||
|
{act.title}
|
||||||
|
<span className="timeline__act-sub">{act.subtitle}</span>
|
||||||
|
</h2>
|
||||||
|
<span className="timeline__act-count">{beats.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="timeline__track">
|
||||||
|
{beats.map((beat, i) => (
|
||||||
|
<article key={beat.id} className="timeline__item">
|
||||||
|
<div className="timeline__dot">
|
||||||
|
<span className="timeline__dot-inner" />
|
||||||
|
</div>
|
||||||
|
{i < beats.length - 1 && <div className="timeline__connector" />}
|
||||||
|
<div className="timeline__card">
|
||||||
|
<div className="timeline__card-top">
|
||||||
|
<Badge variant={TYPE_BADGE[beat.type]}>
|
||||||
|
{TYPE_LABELS[beat.type]}
|
||||||
|
</Badge>
|
||||||
|
<span className="muted timeline__order">#{beat.order + 1}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="timeline__card-title">{beat.title || "Untitled beat"}</h3>
|
||||||
|
{beat.chapterId && (
|
||||||
|
<span className="muted timeline__chapter">
|
||||||
|
{chapters.find((c) => c.id === beat.chapterId)?.title || "Chapter"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{beat.body && <p className="timeline__card-body">{beat.body}</p>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,499 @@
|
|||||||
import { Button } from "@pubscript/ui";
|
/**
|
||||||
|
* Story whiteboard — a free-form canvas for connecting story elements.
|
||||||
|
*
|
||||||
|
* Built on React Flow v11. Renders `FlowNode`s of several kinds
|
||||||
|
* (character, world, chapter, note, image, group) with draggable handles for
|
||||||
|
* creating typed edges. All changes are persisted through the project store's
|
||||||
|
* board actions.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
Handle,
|
||||||
|
Position,
|
||||||
|
type NodeProps,
|
||||||
|
type Edge,
|
||||||
|
type Connection,
|
||||||
|
type NodeChange,
|
||||||
|
applyNodeChanges,
|
||||||
|
} from "reactflow";
|
||||||
|
import "reactflow/dist/base.css";
|
||||||
|
import "reactflow/dist/style.css";
|
||||||
|
|
||||||
const CARDS = [
|
import { createUlid } from "@pubscript/core";
|
||||||
{ id: "w1", title: "The lost map", note: "Mara inherits a map with no destination.", x: 0 },
|
import type { FlowNode, FlowEdge, Entity, PlotBeat } from "@pubscript/core";
|
||||||
{ id: "w2", title: "The harbor", note: "Where the story begins.", x: 240 },
|
import { Button } from "@pubscript/ui";
|
||||||
{ id: "w3", title: "The Cartographer", note: "Knows more than they say.", x: 480 },
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useProjectStore } from "../store/project-store.js";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Custom node views */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface NodeData {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
role?: string;
|
||||||
|
kind?: string;
|
||||||
|
[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;
|
||||||
|
return (e.body || "Untitled note").slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BaseNode({ data, type }: NodeProps<NodeData>) {
|
||||||
|
const linked: string | undefined =
|
||||||
|
(data.entityId as string | undefined) ||
|
||||||
|
(data.chapterId as string | undefined) ||
|
||||||
|
(data.beatId as string | undefined);
|
||||||
|
return (
|
||||||
|
<div className={`wf-node wf-node--${type}${linked ? " wf-node--linked" : ""}`}>
|
||||||
|
<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>
|
||||||
|
{data.body && <div className="wf-node__body">{data.body}</div>}
|
||||||
|
{linked && <span className="wf-node__badge">linked</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Toolbar */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const NODE_TYPES = {
|
||||||
|
character: BaseNode,
|
||||||
|
world: BaseNode,
|
||||||
|
chapter: BaseNode,
|
||||||
|
note: BaseNode,
|
||||||
|
image: BaseNode,
|
||||||
|
group: BaseNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADDABLE: Array<{ type: FlowNode["type"]; label: string; icon: string }> = [
|
||||||
|
{ type: "character", label: "Character", icon: "👤" },
|
||||||
|
{ type: "world", label: "World", icon: "🌍" },
|
||||||
|
{ type: "chapter", label: "Chapter", icon: "📑" },
|
||||||
|
{ type: "note", label: "Note", icon: "📝" },
|
||||||
|
{ type: "image", label: "Image", icon: "🖼" },
|
||||||
|
{ type: "group", label: "Group", icon: "📁" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function Whiteboard() {
|
export function Whiteboard() {
|
||||||
|
const {
|
||||||
|
board,
|
||||||
|
addBoardNode,
|
||||||
|
updateBoardNode,
|
||||||
|
deleteBoardNode,
|
||||||
|
addBoardEdge,
|
||||||
|
deleteBoardEdge,
|
||||||
|
plotBeats,
|
||||||
|
} = useProjectStore();
|
||||||
|
|
||||||
|
const nodes = useMemo(() => board?.nodes ?? [], [board]);
|
||||||
|
const edges = useMemo(() => board?.edges ?? [], [board]);
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [showImport, setShowImport] = useState(false);
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void next;
|
||||||
|
},
|
||||||
|
[reactFlowNodes, nodes, updateBoardNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onConnect = useCallback(
|
||||||
|
(params: Connection) => {
|
||||||
|
if (!params.source || !params.target) return;
|
||||||
|
const edge: FlowEdge = {
|
||||||
|
id: createUlid(),
|
||||||
|
source: params.source,
|
||||||
|
target: params.target,
|
||||||
|
type: "custom",
|
||||||
|
};
|
||||||
|
void addBoardEdge(edge);
|
||||||
|
},
|
||||||
|
[addBoardEdge],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddNode = useCallback(
|
||||||
|
async (type: FlowNode["type"]) => {
|
||||||
|
const node: FlowNode = {
|
||||||
|
id: createUlid(),
|
||||||
|
type,
|
||||||
|
position: { x: 100 + Math.random() * 300, y: 100 + Math.random() * 300 },
|
||||||
|
data: { title: "", body: "" },
|
||||||
|
};
|
||||||
|
await addBoardNode(node);
|
||||||
|
setShowAdd(false);
|
||||||
|
setEditingId(node.id);
|
||||||
|
},
|
||||||
|
[addBoardNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteNode = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
await deleteBoardNode(id);
|
||||||
|
setEditingId(null);
|
||||||
|
},
|
||||||
|
[deleteBoardNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import selected plot beats as note nodes on the board.
|
||||||
|
const handleImportBeats = useCallback(
|
||||||
|
async (beatIds: string[]) => {
|
||||||
|
const created: FlowNode[] = [];
|
||||||
|
beatIds.forEach((beatId, i) => {
|
||||||
|
const beat = plotBeats.find((b) => b.id === beatId);
|
||||||
|
if (!beat) return;
|
||||||
|
const node: FlowNode = {
|
||||||
|
id: createUlid(),
|
||||||
|
type: "note",
|
||||||
|
position: { x: 100 + (i % 3) * 260, y: 100 + Math.floor(i / 3) * 200 },
|
||||||
|
data: { title: beat.title, body: beat.body, beatId },
|
||||||
|
};
|
||||||
|
created.push(node);
|
||||||
|
});
|
||||||
|
// Add sequentially so each is persisted.
|
||||||
|
for (const node of created) {
|
||||||
|
await addBoardNode(node);
|
||||||
|
}
|
||||||
|
setShowImport(false);
|
||||||
|
},
|
||||||
|
[plotBeats, nodes, addBoardNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onEdgesDelete = useCallback(
|
||||||
|
(edges: Edge[]) => {
|
||||||
|
for (const edge of edges) {
|
||||||
|
void deleteBoardEdge(edge.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteBoardEdge],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ps-content">
|
<div className="wf">
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
<div className="wf__header">
|
||||||
<h1 style={{ margin: 0, fontSize: 24 }}>Whiteboard</h1>
|
<div>
|
||||||
<Button variant="primary">+ Add card</Button>
|
<h1 className="heading">Whiteboard</h1>
|
||||||
|
<p className="muted">
|
||||||
|
{nodes.length} node{nodes.length === 1 ? "" : "s"} · {edges.length} edge
|
||||||
|
{edges.length === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 16, overflowX: "auto", paddingBottom: 12 }}>
|
<div className="wf__actions">
|
||||||
{CARDS.map((c) => (
|
<Button variant="ghost" size="sm" onClick={() => setShowAdd((s) => !s)}>
|
||||||
<div key={c.id} className="ps-modal" style={{ width: 220, height: 160, flexShrink: 0, cursor: "grab", position: "relative", transform: "none", top: "auto", left: "auto", boxShadow: "0 4px 16px rgba(0,0,0,0.2)" }}>
|
+ Add node
|
||||||
<div className="ps-modal-body" style={{ padding: 14 }}>
|
</Button>
|
||||||
<h3 style={{ margin: "0 0 6px", fontSize: 15 }}>{c.title}</h3>
|
<Button
|
||||||
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)" }}>{c.note}</p>
|
variant="accent"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowImport(true)}
|
||||||
|
disabled={availableBeats.length === 0}
|
||||||
|
>
|
||||||
|
+ Add beats
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<div className="wf__add">
|
||||||
|
{ADDABLE.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.type}
|
||||||
|
className="wf__add-item"
|
||||||
|
onClick={() => handleAddNode(item.type)}
|
||||||
|
>
|
||||||
|
<span className="wf__add-icon">{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="wf__canvas">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={reactFlowNodes}
|
||||||
|
edges={reactFlowEdges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onEdgesDelete={onEdgesDelete}
|
||||||
|
nodeTypes={NODE_TYPES}
|
||||||
|
fitView
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background />
|
||||||
|
<Controls />
|
||||||
|
<div className="wf__edge-legend">
|
||||||
|
<span title="Causes (animated)">— causes —</span>
|
||||||
|
<span title="Leads to / contrasts">— leads to —</span>
|
||||||
|
</div>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingId && (
|
||||||
|
<EditNode
|
||||||
|
node={nodes.find((n) => n.id === editingId)}
|
||||||
|
onClose={() => setEditingId(null)}
|
||||||
|
onDelete={() => handleDeleteNode(editingId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showImport && (
|
||||||
|
<ImportBeats
|
||||||
|
beats={availableBeats}
|
||||||
|
onClose={() => setShowImport(false)}
|
||||||
|
onImport={(beatIds) => handleImportBeats(beatIds)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Edit node modal */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface EditNodeProps {
|
||||||
|
node: FlowNode | undefined;
|
||||||
|
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 [chapterId, setChapterId] = useState<string | undefined>(
|
||||||
|
node?.data.chapterId as string | undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 save = async () => {
|
||||||
|
const data: Record<string, unknown> = { ...node.data, title, body };
|
||||||
|
if (showEntityLink) {
|
||||||
|
if (entityId) data.entityId = entityId;
|
||||||
|
else delete data.entityId;
|
||||||
|
}
|
||||||
|
if (showChapterLink) {
|
||||||
|
if (chapterId) data.chapterId = chapterId;
|
||||||
|
else delete data.chapterId;
|
||||||
|
}
|
||||||
|
await updateBoardNode({ ...node, data });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
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">{node.type}</span>
|
||||||
|
<button className="wf__modal-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="wf__modal-body">
|
||||||
|
<label className="wf__field">
|
||||||
|
<span>Title</span>
|
||||||
|
<input
|
||||||
|
className="wf__input"
|
||||||
|
placeholder="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{(showEntityLink || showChapterLink) && (
|
||||||
|
<label className="wf__field">
|
||||||
|
<span>Linked {showEntityLink ? "entity" : "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)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{showEntityLink
|
||||||
|
? entities.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
|
{entityLabel(e)}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
: chapters.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{linkedLabel && (
|
||||||
|
<div className="wf__badge-row">
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="wf__modal-foot">
|
||||||
|
<Button variant="danger" size="sm" onClick={onDelete}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="accent" size="sm" onClick={save}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Import beats modal */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface ImportBeatsProps {
|
||||||
|
beats: PlotBeat[];
|
||||||
|
onClose: () => void;
|
||||||
|
onImport: (beatIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportBeats({ beats, onClose, onImport }: ImportBeatsProps) {
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => setSelected(new Set(beats.map((b) => b.id)));
|
||||||
|
const clearAll = () => setSelected(new Set());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wf__modal-overlay" onClick={onClose}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="wf__modal-body">
|
||||||
|
{beats.length === 0 && <p className="muted">All beats are already on the board.</p>}
|
||||||
|
<div className="wf__beat-list">
|
||||||
|
{beats.map((b) => (
|
||||||
|
<label key={b.id} className="wf__beat-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(b.id)}
|
||||||
|
onChange={() => toggle(b.id)}
|
||||||
|
/>
|
||||||
|
<span className="wf__beat-title">{b.title}</span>
|
||||||
|
<span className="ps-badge ps-badge-neutral">{b.type}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="wf__modal-foot">
|
||||||
|
<div className="wf__beat-actions">
|
||||||
|
<button className="wf__link" onClick={selectAll}>
|
||||||
|
Select all
|
||||||
|
</button>
|
||||||
|
<button className="wf__link" onClick={clearAll}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="accent" size="sm" onClick={() => onImport([...selected])} disabled={selected.size === 0}>
|
||||||
|
Add {selected.size} beat{selected.size === 1 ? "" : "s"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Round-trip tests for the plot beat store actions.
|
||||||
|
*
|
||||||
|
* Verifies that savePlotBeat, deletePlotBeat, and reorderPlotBeats correctly
|
||||||
|
* persist through a mock adapter and update store state. Uses the real
|
||||||
|
* `useProjectStore` with a mock adapter injected via `setState`.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createPlotBeat, createUlid, type PlotBeat, type ProjectMeta, type StorageAdapter } from "@pubscript/core";
|
||||||
|
import { useProjectStore } from "./project-store.js";
|
||||||
|
|
||||||
|
// A minimal in-memory adapter that records calls. Only the plot beat methods
|
||||||
|
// are implemented; the rest of the StorageAdapter contract is not exercised.
|
||||||
|
function createMockAdapter(): StorageAdapter & { calls: string[] } {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const store: Record<string, PlotBeat> = {};
|
||||||
|
const adapter = {
|
||||||
|
calls,
|
||||||
|
async listPlotBeats(projectId: string) {
|
||||||
|
calls.push(`listPlotBeats:${projectId}`);
|
||||||
|
return Object.values(store);
|
||||||
|
},
|
||||||
|
async savePlotBeat(beat: PlotBeat) {
|
||||||
|
calls.push(`savePlotBeat:${beat.id}`);
|
||||||
|
store[beat.id] = beat;
|
||||||
|
},
|
||||||
|
async deletePlotBeat(projectId: string, id: string) {
|
||||||
|
calls.push(`deletePlotBeat:${projectId}:${id}`);
|
||||||
|
delete store[id];
|
||||||
|
},
|
||||||
|
async reorderPlotBeats(projectId: string, beats: PlotBeat[]) {
|
||||||
|
calls.push(`reorderPlotBeats:${projectId}`);
|
||||||
|
for (const b of beats) store[b.id] = b;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// The adapter contract is large; we only exercise the plot beat methods.
|
||||||
|
return { ...adapter, ...(adapter as unknown as StorageAdapter) } as StorageAdapter & {
|
||||||
|
calls: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROJECT_ID = "proj-test-0001";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useProjectStore.setState({
|
||||||
|
adapter: null,
|
||||||
|
project: null,
|
||||||
|
plotBeats: [],
|
||||||
|
plotBeatsLoaded: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("plot beat store round-trip", () => {
|
||||||
|
it("loads beats from the adapter", async () => {
|
||||||
|
const adapter = createMockAdapter();
|
||||||
|
const beat = createPlotBeat(createUlid(), PROJECT_ID, "Test beat", 1, "scene");
|
||||||
|
await adapter.savePlotBeat(beat);
|
||||||
|
|
||||||
|
useProjectStore.setState({ adapter, project: { id: PROJECT_ID } as ProjectMeta });
|
||||||
|
await useProjectStore.getState().loadPlotBeats();
|
||||||
|
|
||||||
|
expect(useProjectStore.getState().plotBeats).toHaveLength(1);
|
||||||
|
expect(useProjectStore.getState().plotBeats[0]?.title).toBe("Test beat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a beat and updates store state", async () => {
|
||||||
|
const adapter = createMockAdapter();
|
||||||
|
useProjectStore.setState({ adapter, project: { id: PROJECT_ID } as ProjectMeta });
|
||||||
|
|
||||||
|
const beat = createPlotBeat(createUlid(), PROJECT_ID, "New beat", 1, "scene");
|
||||||
|
await useProjectStore.getState().savePlotBeat(beat);
|
||||||
|
|
||||||
|
expect(useProjectStore.getState().plotBeats).toHaveLength(1);
|
||||||
|
expect(useProjectStore.getState().plotBeats[0]?.title).toBe("New beat");
|
||||||
|
expect(adapter.calls).toContain(`savePlotBeat:${beat.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an existing beat in place", async () => {
|
||||||
|
const adapter = createMockAdapter();
|
||||||
|
useProjectStore.setState({ adapter, project: { id: PROJECT_ID } as ProjectMeta });
|
||||||
|
|
||||||
|
const beat = createPlotBeat(createUlid(), PROJECT_ID, "Original", 1, "scene");
|
||||||
|
await useProjectStore.getState().savePlotBeat(beat);
|
||||||
|
|
||||||
|
const updated = { ...beat, title: "Updated" };
|
||||||
|
await useProjectStore.getState().savePlotBeat(updated);
|
||||||
|
|
||||||
|
expect(useProjectStore.getState().plotBeats).toHaveLength(1);
|
||||||
|
expect(useProjectStore.getState().plotBeats[0]?.title).toBe("Updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a beat", async () => {
|
||||||
|
const adapter = createMockAdapter();
|
||||||
|
useProjectStore.setState({ adapter, project: { id: PROJECT_ID } as ProjectMeta });
|
||||||
|
|
||||||
|
const beat = createPlotBeat(createUlid(), PROJECT_ID, "To delete", 1, "scene");
|
||||||
|
await useProjectStore.getState().savePlotBeat(beat);
|
||||||
|
await useProjectStore.getState().deletePlotBeat(beat.id);
|
||||||
|
|
||||||
|
expect(useProjectStore.getState().plotBeats).toHaveLength(0);
|
||||||
|
expect(adapter.calls).toContain(`deletePlotBeat:proj-test-0001:${beat.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders beats and persists them", async () => {
|
||||||
|
const adapter = createMockAdapter();
|
||||||
|
useProjectStore.setState({ adapter, project: { id: PROJECT_ID } as ProjectMeta });
|
||||||
|
|
||||||
|
const b1 = createPlotBeat(createUlid(), PROJECT_ID, "Beat 1", 1, "scene", 0);
|
||||||
|
const b2 = createPlotBeat(createUlid(), PROJECT_ID, "Beat 2", 1, "scene", 1);
|
||||||
|
await useProjectStore.getState().savePlotBeat(b1);
|
||||||
|
await useProjectStore.getState().savePlotBeat(b2);
|
||||||
|
|
||||||
|
// Swap the order.
|
||||||
|
await useProjectStore.getState().reorderPlotBeats([b2, b1]);
|
||||||
|
|
||||||
|
expect(useProjectStore.getState().plotBeats.map((b) => b.id)).toEqual([b2.id, b1.id]);
|
||||||
|
// Reorder persists each beat's new order via savePlotBeat.
|
||||||
|
expect(adapter.calls.filter((c) => c.startsWith("savePlotBeat:")).length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when saving without an adapter", async () => {
|
||||||
|
useProjectStore.setState({ adapter: null, project: { id: PROJECT_ID } as ProjectMeta });
|
||||||
|
const beat = createPlotBeat(createUlid(), PROJECT_ID, "X", 1, "scene");
|
||||||
|
await expect(useProjectStore.getState().savePlotBeat(beat)).rejects.toThrow(
|
||||||
|
"No storage adapter.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,9 +12,13 @@
|
|||||||
import {
|
import {
|
||||||
createAutosave,
|
createAutosave,
|
||||||
type AutosaveState,
|
type AutosaveState,
|
||||||
|
type Board,
|
||||||
type Chapter,
|
type Chapter,
|
||||||
type DocumentRef,
|
type DocumentRef,
|
||||||
type Entity,
|
type Entity,
|
||||||
|
type FlowNode,
|
||||||
|
type FlowEdge,
|
||||||
|
type PlotBeat,
|
||||||
type ProjectMeta,
|
type ProjectMeta,
|
||||||
type StorageAdapter,
|
type StorageAdapter,
|
||||||
} from "@pubscript/core";
|
} from "@pubscript/core";
|
||||||
@@ -37,6 +41,14 @@ interface ProjectStore {
|
|||||||
entities: Entity[];
|
entities: Entity[];
|
||||||
entitiesLoaded: boolean;
|
entitiesLoaded: boolean;
|
||||||
|
|
||||||
|
// --- plot beats (Chunk #5) ---
|
||||||
|
plotBeats: PlotBeat[];
|
||||||
|
plotBeatsLoaded: boolean;
|
||||||
|
|
||||||
|
// --- whiteboard (Chunk #5) ---
|
||||||
|
board: Board | null;
|
||||||
|
boardLoaded: boolean;
|
||||||
|
|
||||||
// --- actions ---
|
// --- actions ---
|
||||||
/** Load the project (seed a demo if empty) and select the first chapter. */
|
/** Load the project (seed a demo if empty) and select the first chapter. */
|
||||||
init: () => Promise<void>;
|
init: () => Promise<void>;
|
||||||
@@ -56,6 +68,32 @@ interface ProjectStore {
|
|||||||
updateEntity: (entity: Entity) => Promise<void>;
|
updateEntity: (entity: Entity) => Promise<void>;
|
||||||
/** Delete an entity by id. */
|
/** Delete an entity by id. */
|
||||||
deleteEntity: (id: string) => Promise<void>;
|
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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a blank entity of the given kind for the active project. */
|
/** Build a blank entity of the given kind for the active project. */
|
||||||
@@ -96,6 +134,10 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
|
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
|
||||||
entities: [],
|
entities: [],
|
||||||
entitiesLoaded: false,
|
entitiesLoaded: false,
|
||||||
|
plotBeats: [],
|
||||||
|
plotBeatsLoaded: false,
|
||||||
|
board: null,
|
||||||
|
boardLoaded: false,
|
||||||
|
|
||||||
init: async () => {
|
init: async () => {
|
||||||
set({ status: "loading", error: null });
|
set({ status: "loading", error: null });
|
||||||
@@ -122,6 +164,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
|
|
||||||
// Load entities for the entity engine.
|
// Load entities for the entity engine.
|
||||||
await get().loadEntities();
|
await get().loadEntities();
|
||||||
|
|
||||||
|
// Load plot beats for the plot board + timeline.
|
||||||
|
await get().loadPlotBeats();
|
||||||
|
|
||||||
|
// Load the story whiteboard.
|
||||||
|
await get().loadBoard();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
set({ error: e instanceof Error ? e.message : String(e), status: "ready" });
|
set({ error: e instanceof Error ? e.message : String(e), status: "ready" });
|
||||||
}
|
}
|
||||||
@@ -184,5 +232,110 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
entities: state.entities.filter((e) => e.id !== id),
|
entities: state.entities.filter((e) => e.id !== id),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- plot beats (Chunk #5) ---
|
||||||
|
loadPlotBeats: async () => {
|
||||||
|
const { adapter, project } = get();
|
||||||
|
if (!adapter || !project) return;
|
||||||
|
const plotBeats = await adapter.listPlotBeats(project.id);
|
||||||
|
set({ plotBeats, plotBeatsLoaded: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
savePlotBeat: async (beat: PlotBeat) => {
|
||||||
|
const { adapter } = get();
|
||||||
|
if (!adapter) throw new Error("No storage adapter.");
|
||||||
|
await adapter.savePlotBeat(beat);
|
||||||
|
set((state) => ({
|
||||||
|
plotBeats: [
|
||||||
|
...state.plotBeats.filter((b) => b.id !== beat.id),
|
||||||
|
beat,
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePlotBeat: async (id: string) => {
|
||||||
|
const { adapter, project } = get();
|
||||||
|
if (!adapter || !project) throw new Error("No active project.");
|
||||||
|
await adapter.deletePlotBeat(project.id, id);
|
||||||
|
set((state) => ({
|
||||||
|
plotBeats: state.plotBeats.filter((b) => b.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderPlotBeats: async (beats: PlotBeat[]) => {
|
||||||
|
const { adapter } = get();
|
||||||
|
if (!adapter) throw new Error("No storage adapter.");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const beat of beats) {
|
||||||
|
const updated = { ...beat, order: beat.order, updatedAt: now };
|
||||||
|
await adapter.savePlotBeat(updated);
|
||||||
|
}
|
||||||
|
set({ plotBeats: beats });
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- whiteboard (Chunk #5) ---
|
||||||
|
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: [] },
|
||||||
|
boardLoaded: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
saveBoard: async (board: Board) => {
|
||||||
|
const { adapter } = get();
|
||||||
|
if (!adapter) throw new Error("No storage adapter.");
|
||||||
|
await adapter.saveBoard(board);
|
||||||
|
set({ board });
|
||||||
|
},
|
||||||
|
|
||||||
|
addBoardNode: async (node: FlowNode) => {
|
||||||
|
const { board } = get();
|
||||||
|
if (!board) return;
|
||||||
|
const next: Board = { ...board, nodes: [...board.nodes, node] };
|
||||||
|
await get().saveBoard(next);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBoardNode: async (node: FlowNode) => {
|
||||||
|
const { board } = get();
|
||||||
|
if (!board) return;
|
||||||
|
const next: Board = {
|
||||||
|
...board,
|
||||||
|
nodes: board.nodes.map((n) => (n.id === node.id ? node : n)),
|
||||||
|
};
|
||||||
|
await get().saveBoard(next);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteBoardNode: async (id: string) => {
|
||||||
|
const { board } = get();
|
||||||
|
if (!board) return;
|
||||||
|
const next: Board = {
|
||||||
|
...board,
|
||||||
|
nodes: board.nodes.filter((n) => n.id !== id),
|
||||||
|
edges: board.edges.filter((e) => e.source !== id && e.target !== id),
|
||||||
|
};
|
||||||
|
await get().saveBoard(next);
|
||||||
|
},
|
||||||
|
|
||||||
|
addBoardEdge: async (edge: FlowEdge) => {
|
||||||
|
const { board } = get();
|
||||||
|
if (!board) return;
|
||||||
|
const next: Board = { ...board, edges: [...board.edges, edge] };
|
||||||
|
await get().saveBoard(next);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteBoardEdge: async (id: string) => {
|
||||||
|
const { board } = get();
|
||||||
|
if (!board) return;
|
||||||
|
const next: Board = {
|
||||||
|
...board,
|
||||||
|
edges: board.edges.filter((e) => e.id !== id),
|
||||||
|
};
|
||||||
|
await get().saveBoard(next);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
createUlid,
|
createUlid,
|
||||||
|
createPlotBeat,
|
||||||
type Board,
|
type Board,
|
||||||
type Character,
|
type Character,
|
||||||
type Note,
|
type Note,
|
||||||
|
type PlotBeat,
|
||||||
type ProseMirrorDoc,
|
type ProseMirrorDoc,
|
||||||
type WorldItem,
|
type WorldItem,
|
||||||
type StorageAdapter,
|
type StorageAdapter,
|
||||||
@@ -229,6 +231,36 @@ const BOARD: Board = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plot beats for the demo project, spread across three acts. These seed the
|
||||||
|
* Plot board and the derived Timeline. See CHUNK #5 step 1.
|
||||||
|
*/
|
||||||
|
const PLOT_BEATS: PlotBeat[] = [
|
||||||
|
// Act I — Setup
|
||||||
|
createPlotBeat("beat-1", "", "Mara leaves the mainland", 1, "setup", 0,
|
||||||
|
"Mara Vell arrives at the Glass Harbor by ferry before dawn. The light has turned the sea the color of a bruised plum."),
|
||||||
|
createPlotBeat("beat-2", "", "The light is failing", 1, "conflict", 1,
|
||||||
|
"Three nights without the lighthouse. The fish are turning away; Tomas Reed carries the grief of four ships lost."),
|
||||||
|
createPlotBeat("beat-3", "", "Old Fen warns her", 1, "note", 2,
|
||||||
|
"The hermit who has seen four ships lost grins at the wrong moments and speaks in warnings."),
|
||||||
|
// Act II — Confrontation
|
||||||
|
createPlotBeat("beat-4", "", "The storm arrives", 2, "conflict", 0,
|
||||||
|
"The storm comes without warning. Mara climbs the one hundred and fourteen steps to the seized lamp."),
|
||||||
|
createPlotBeat("beat-5", "", "Mara frees the beam", 2, "turn", 1,
|
||||||
|
"Working through the small hours, she coax the gears until the great beam throws its light across the black water."),
|
||||||
|
createPlotBeat("beat-6", "", "The secret of the glass", 2, "turn", 2,
|
||||||
|
"Old Fen reveals the light is not made but mined — shards of living glass drawn from the deep, kindled only by a keeper."),
|
||||||
|
createPlotBeat("beat-7", "", "The shard answers", 2, "climax", 3,
|
||||||
|
"Mara finds a warm shard of glass in the wrack that holds a light not her own — the glass answers when she speaks."),
|
||||||
|
// Act III — Resolution
|
||||||
|
createPlotBeat("beat-8", "", "Elias's inheritance", 3, "setup", 0,
|
||||||
|
"She understands why the old keeper never waved or left: some duties are inherited, like the tide. Her father drowned keeping the light."),
|
||||||
|
createPlotBeat("beat-9", "", "Tomas offers to teach", 3, "turn", 1,
|
||||||
|
"Tomas Reed finds Mara at the lamp and offers to teach her. No one has ever wanted the light before."),
|
||||||
|
createPlotBeat("beat-10", "", "Mara chooses the light", 3, "resolution", 2,
|
||||||
|
"Mara looks out at the quay and the little lights of the town, and says 'I want it.' The light answers for her."),
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed the adapter with the demo project if it has no projects yet.
|
* Seed the adapter with the demo project if it has no projects yet.
|
||||||
* Returns the created project id.
|
* Returns the created project id.
|
||||||
@@ -260,5 +292,9 @@ export async function seedDemoProject(adapter: StorageAdapter): Promise<string>
|
|||||||
await adapter.saveEntity({ ...ARRIVAL_NOTE, projectId: project.id });
|
await adapter.saveEntity({ ...ARRIVAL_NOTE, projectId: project.id });
|
||||||
await adapter.saveBoard({ ...BOARD, projectId: project.id });
|
await adapter.saveBoard({ ...BOARD, projectId: project.id });
|
||||||
|
|
||||||
|
for (const beat of PLOT_BEATS) {
|
||||||
|
await adapter.savePlotBeat({ ...beat, projectId: project.id });
|
||||||
|
}
|
||||||
|
|
||||||
return project.id;
|
return project.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
/**
|
||||||
|
* Plot board styles (apps/web).
|
||||||
|
*
|
||||||
|
* Styles for the Chunk #5 plot board: the three-act beat columns, beat cards
|
||||||
|
* with drag-to-reorder, the create-template picker, and the edit modal form.
|
||||||
|
* Reuses the `@pubscript/ui` design tokens (`--ps-*`) and component classes
|
||||||
|
* (`ps-btn`, `ps-badge`, `ps-modal-*`, `ps-input`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ---- Global utility classes (shared across Chunk #5 routes) ---- */
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ps-text);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Modal form fields ---- */
|
||||||
|
|
||||||
|
.modal__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > span:first-child {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__select,
|
||||||
|
.field__textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--ps-surface-2);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
color: var(--ps-text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__select:focus,
|
||||||
|
.field__textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ps-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 90px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Plot board ---- */
|
||||||
|
|
||||||
|
.plot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__board {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__column {
|
||||||
|
background: var(--ps-surface);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 200px;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__column--drop {
|
||||||
|
border-color: var(--ps-accent);
|
||||||
|
background: color-mix(in srgb, var(--ps-accent) 6%, var(--ps-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__column-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__column-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ps-text);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: var(--ps-surface-2);
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__column-sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Progress bar ---- */
|
||||||
|
|
||||||
|
.plot__progress {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 12px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--ps-surface-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--ps-success);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__progress-label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px dashed var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__card {
|
||||||
|
background: var(--ps-surface-2);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__card:hover {
|
||||||
|
border-color: var(--ps-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__card--dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__card--locked {
|
||||||
|
opacity: 0.75;
|
||||||
|
background: color-mix(in srgb, var(--ps-text-muted) 6%, var(--ps-surface-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__card--locked:hover {
|
||||||
|
border-color: var(--ps-border);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ps-text);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__card-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__card:hover .plot__delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__delete:hover {
|
||||||
|
color: var(--ps-danger);
|
||||||
|
background: color-mix(in srgb, var(--ps-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__card-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Entity picker in edit modal ---- */
|
||||||
|
|
||||||
|
.plot__entity-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__entity-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ps-text);
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__entity-option:hover {
|
||||||
|
background: color-mix(in srgb, var(--ps-accent) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__entity-option input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--ps-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Template picker ---- */
|
||||||
|
|
||||||
|
.plot__templates {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__templates-hint {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__templates-divider {
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__templates-divider::before,
|
||||||
|
.plot__templates-divider::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 40%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--ps-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__templates-divider::before {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__templates-divider::after {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__template {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--ps-surface-2);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
color: var(--ps-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__template:hover {
|
||||||
|
border-color: var(--ps-accent);
|
||||||
|
background: color-mix(in srgb, var(--ps-accent) 8%, var(--ps-surface-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__template-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ps-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__template-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__template-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__template-types {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plot__template--type {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Timeline styles (apps/web).
|
||||||
|
*
|
||||||
|
* Styles for the Chunk #5 derived timeline: a vertical, act-grouped reading
|
||||||
|
* order of plot beats with a connector spine, act headers, and beat cards.
|
||||||
|
* Reuses the `@pubscript/ui` design tokens (`--ps-*`) and shared utility
|
||||||
|
* classes (`heading`, `muted`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--ps-surface);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__filter-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__filter-label > .ps-input,
|
||||||
|
.timeline__filter-label > select {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__act-label-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__flow {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 8px 4px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
border: 1px dashed var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__act {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__act-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__act-label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ps-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__act-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__act-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--ps-accent);
|
||||||
|
color: var(--ps-accent-contrast);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__track {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__connector {
|
||||||
|
position: absolute;
|
||||||
|
left: 7px;
|
||||||
|
top: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--ps-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -28px;
|
||||||
|
top: 14px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ps-surface);
|
||||||
|
border: 3px solid var(--ps-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__dot-inner {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ps-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__card {
|
||||||
|
background: var(--ps-surface);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
padding: 12px 14px;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__card:hover {
|
||||||
|
border-color: var(--ps-accent);
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ps-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__order {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__card-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline__act-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--ps-accent);
|
||||||
|
color: var(--ps-accent-contrast);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* Whiteboard styles (apps/web).
|
||||||
|
*
|
||||||
|
* Styles for the Chunk #5 story whiteboard built on React Flow v11: the canvas
|
||||||
|
* wrapper, the add-node menu, the custom node views, and the edit-node modal.
|
||||||
|
* React Flow's own base/style CSS is imported in Whiteboard.tsx; these rules
|
||||||
|
* layer the PubScript look on top (design tokens `--ps-*`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
.wf {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__add {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__add-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--ps-surface-2);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
color: var(--ps-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__add-item:hover {
|
||||||
|
border-color: var(--ps-accent);
|
||||||
|
background: color-mix(in srgb, var(--ps-accent) 8%, var(--ps-surface-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__add-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__canvas {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--ps-bg);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* React Flow canvas fill */
|
||||||
|
.wf__canvas .reactflow {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__edge-legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--ps-surface);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Custom node views ---- */
|
||||||
|
|
||||||
|
.wf-node {
|
||||||
|
min-width: 140px;
|
||||||
|
max-width: 220px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--ps-surface);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-node:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-node--selected {
|
||||||
|
border-color: var(--ps-accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ps-accent) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-node__type {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--ps-surface-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-node__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ps-text);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-node__body {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cross-linked nodes */
|
||||||
|
.wf-node--linked {
|
||||||
|
border-color: var(--ps-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-node__badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: color-mix(in srgb, var(--ps-accent) 18%, transparent);
|
||||||
|
border: 1px solid var(--ps-accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--ps-accent);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Edit node modal ---- */
|
||||||
|
|
||||||
|
.wf__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__field > span {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__select {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__badge-row {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__modal--wide {
|
||||||
|
width: min(560px, 94vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__beat-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__beat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--ps-surface-2);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__beat-item input {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__beat-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ps-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__beat-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--ps-accent);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Edit node modal ---- */
|
||||||
|
|
||||||
|
.wf__modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__modal {
|
||||||
|
width: min(440px, 92vw);
|
||||||
|
background: var(--ps-surface);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--ps-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__modal-type {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ps-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--ps-text-muted);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__modal-close:hover {
|
||||||
|
color: var(--ps-text);
|
||||||
|
background: var(--ps-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__modal-body {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__input,
|
||||||
|
.wf__textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--ps-surface-2);
|
||||||
|
border: 1px solid var(--ps-border);
|
||||||
|
border-radius: var(--ps-radius);
|
||||||
|
color: var(--ps-text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__input:focus,
|
||||||
|
.wf__textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ps-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 90px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf__modal-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
@@ -40,6 +40,14 @@ function mockAdapter(): StorageAdapter & { saved: DocumentRef[] } {
|
|||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
async saveBoard() {},
|
async saveBoard() {},
|
||||||
|
async getBoard() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async savePlotBeat() {},
|
||||||
|
async listPlotBeats() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
async deletePlotBeat() {},
|
||||||
async saveAISession() {},
|
async saveAISession() {},
|
||||||
async saveDocument(doc: DocumentRef) {
|
async saveDocument(doc: DocumentRef) {
|
||||||
saved.push(doc);
|
saved.push(doc);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* to a kind here makes it appear in the UI with no per-entity code.
|
* to a kind here makes it appear in the UI with no per-entity code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { PlotBeat, PlotBeatType as PlotBeatTypeRef } from "./types.js";
|
||||||
|
|
||||||
export type FieldType = "text" | "textarea" | "select" | "list" | "link";
|
export type FieldType = "text" | "textarea" | "select" | "list" | "link";
|
||||||
|
|
||||||
export interface FieldDef {
|
export interface FieldDef {
|
||||||
@@ -99,3 +101,77 @@ export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
|
|||||||
|
|
||||||
/** All entity kinds, in display order. */
|
/** All entity kinds, in display order. */
|
||||||
export const ENTITY_KINDS = Object.keys(ENTITY_FIELDS);
|
export const ENTITY_KINDS = Object.keys(ENTITY_FIELDS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plot beat types with their palette labels, timeline colours and a short
|
||||||
|
* description shown on the beat board. See CHUNK #5 step 1.
|
||||||
|
*/
|
||||||
|
export const PLOT_BEAT_TYPES = {
|
||||||
|
setup: { label: "Setup", color: "#0f766e", description: "The world, the status quo." },
|
||||||
|
conflict: { label: "Conflict", color: "#b45309", description: "What gets in the way." },
|
||||||
|
turn: { label: "Turn", color: "#7c3aed", description: "A change of direction." },
|
||||||
|
climax: { label: "Climax", color: "#be123c", description: "The turning point of the story." },
|
||||||
|
resolution: { label: "Resolution", color: "#15803d", description: "The new normal." },
|
||||||
|
scene: { label: "Scene", color: "#0891b2", description: "A discrete scene beat." },
|
||||||
|
note: { label: "Note", color: "#475569", description: "A freeform note." },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// `PlotBeatType` is defined once in types.ts; re-exported here for convenience
|
||||||
|
// so callers can import it from the schema module.
|
||||||
|
type PlotBeatType = PlotBeatTypeRef;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a plot beat with sensible defaults and timestamps.
|
||||||
|
*
|
||||||
|
* @param id Stable id (ulid).
|
||||||
|
* @param projectId Owning project.
|
||||||
|
* @param title Beat title.
|
||||||
|
* @param act Act number (1-based).
|
||||||
|
* @param type Structural type (defaults to `note`).
|
||||||
|
* @param order Order within the act (0-based).
|
||||||
|
* @param body Optional body.
|
||||||
|
* @param chapterId Optional linked chapter id.
|
||||||
|
*/
|
||||||
|
export function createPlotBeat(
|
||||||
|
id: string,
|
||||||
|
projectId: string,
|
||||||
|
title: string,
|
||||||
|
act: number,
|
||||||
|
type: PlotBeatType = "note",
|
||||||
|
order = 0,
|
||||||
|
body = "",
|
||||||
|
chapterId?: string,
|
||||||
|
status: "draft" | "revised" | "locked" = "draft",
|
||||||
|
): PlotBeat {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
act,
|
||||||
|
type,
|
||||||
|
order,
|
||||||
|
status,
|
||||||
|
...(chapterId ? { chapterId } : {}),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group beats by act and return an ordered map: act number → beats.
|
||||||
|
* Beats are sorted by `order` within each act.
|
||||||
|
*/
|
||||||
|
export function beatsByAct(beats: PlotBeat[]): Map<number, PlotBeat[]> {
|
||||||
|
const map = new Map<number, PlotBeat[]>();
|
||||||
|
for (const beat of beats) {
|
||||||
|
const list = map.get(beat.act) ?? [];
|
||||||
|
list.push(beat);
|
||||||
|
map.set(beat.act, list);
|
||||||
|
}
|
||||||
|
for (const list of map.values()) {
|
||||||
|
list.sort((a, b) => a.order - b.order);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
DocumentRef,
|
DocumentRef,
|
||||||
Entity,
|
Entity,
|
||||||
NewProject,
|
NewProject,
|
||||||
|
PlotBeat,
|
||||||
Project,
|
Project,
|
||||||
ProjectMeta,
|
ProjectMeta,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
@@ -28,6 +29,7 @@ class PubScriptDB extends Dexie {
|
|||||||
chapters!: Table<Chapter, string>;
|
chapters!: Table<Chapter, string>;
|
||||||
entities!: Table<EntityRec, string>;
|
entities!: Table<EntityRec, string>;
|
||||||
boards!: Table<Board, string>;
|
boards!: Table<Board, string>;
|
||||||
|
plotBeats!: Table<PlotBeat, string>;
|
||||||
aiSessions!: Table<AISession, string>;
|
aiSessions!: Table<AISession, string>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -38,6 +40,8 @@ class PubScriptDB extends Dexie {
|
|||||||
chapters: "id, bookId, projectId",
|
chapters: "id, bookId, projectId",
|
||||||
entities: "key, projectId, kind",
|
entities: "key, projectId, kind",
|
||||||
boards: "id, projectId",
|
boards: "id, projectId",
|
||||||
|
// Plot beats — atomic units of story structure (CHUNK #5).
|
||||||
|
plotBeats: "id, [projectId+act], projectId, chapterId",
|
||||||
aiSessions: "id, projectId",
|
aiSessions: "id, projectId",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -194,6 +198,35 @@ export const storageLocal: StorageAdapter = {
|
|||||||
await db.boards.put(b);
|
await db.boards.put(b);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getBoard(projectId: string): Promise<Board | null> {
|
||||||
|
const rows = await db.boards
|
||||||
|
.where("projectId")
|
||||||
|
.equals(projectId)
|
||||||
|
.toArray();
|
||||||
|
return rows[0] ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listPlotBeats(projectId: string): Promise<PlotBeat[]> {
|
||||||
|
const rows = await db.plotBeats
|
||||||
|
.where("projectId")
|
||||||
|
.equals(projectId)
|
||||||
|
.toArray();
|
||||||
|
// Stable order: by act, then order within act.
|
||||||
|
return rows.sort((a, b) => a.act - b.act || a.order - b.order);
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePlotBeat(b: PlotBeat): Promise<void> {
|
||||||
|
await db.plotBeats.put(b);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePlotBeat(projectId: string, id: string): Promise<void> {
|
||||||
|
const existing = await db.plotBeats.get(id);
|
||||||
|
if (!existing || existing.projectId !== projectId) {
|
||||||
|
throw new Error(`No plot beat ${id} in project ${projectId}.`);
|
||||||
|
}
|
||||||
|
await db.plotBeats.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
async saveAISession(s: AISession): Promise<void> {
|
async saveAISession(s: AISession): Promise<void> {
|
||||||
await db.aiSessions.put(s);
|
await db.aiSessions.put(s);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
NewProject,
|
NewProject,
|
||||||
Project,
|
Project,
|
||||||
ProjectMeta,
|
ProjectMeta,
|
||||||
|
PlotBeat,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import type { StorageAdapter, StoreEvent } from "./storage.js";
|
import type { StorageAdapter, StoreEvent } from "./storage.js";
|
||||||
|
|
||||||
@@ -161,6 +162,41 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getBoard(projectId: string): Promise<Board | null> {
|
||||||
|
const res = await req<{ boards: Board[] }>(
|
||||||
|
"GET",
|
||||||
|
`/api/projects/${projectId}/boards`,
|
||||||
|
);
|
||||||
|
return res.boards[0] ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listPlotBeats(projectId: string): Promise<PlotBeat[]> {
|
||||||
|
const res = await req<{ beats: PlotBeat[] }>(
|
||||||
|
"GET",
|
||||||
|
`/api/projects/${projectId}/beats`,
|
||||||
|
);
|
||||||
|
return res.beats;
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePlotBeat(b: PlotBeat): Promise<void> {
|
||||||
|
await req("POST", `/api/projects/${b.projectId}/beats`, {
|
||||||
|
id: b.id,
|
||||||
|
projectId: b.projectId,
|
||||||
|
title: b.title,
|
||||||
|
body: b.body,
|
||||||
|
act: b.act,
|
||||||
|
type: b.type,
|
||||||
|
order: b.order,
|
||||||
|
status: b.status,
|
||||||
|
chapterId: b.chapterId,
|
||||||
|
entityIds: b.entityIds,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePlotBeat(projectId: string, id: string): Promise<void> {
|
||||||
|
await req("DELETE", `/api/projects/${projectId}/beats/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
async saveAISession(s: AISession): Promise<void> {
|
async saveAISession(s: AISession): Promise<void> {
|
||||||
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
|
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
NewProject,
|
NewProject,
|
||||||
Project,
|
Project,
|
||||||
ProjectMeta,
|
ProjectMeta,
|
||||||
|
PlotBeat,
|
||||||
AISession,
|
AISession,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
@@ -53,6 +54,14 @@ export interface StorageAdapter {
|
|||||||
*/
|
*/
|
||||||
searchEntities(projectId: string, q: string): Promise<Entity[]>;
|
searchEntities(projectId: string, q: string): Promise<Entity[]>;
|
||||||
saveBoard(b: Board): Promise<void>;
|
saveBoard(b: Board): Promise<void>;
|
||||||
|
/** Load the story board for a project (by id `b.id`, scoped to `projectId`). */
|
||||||
|
getBoard(projectId: string): Promise<Board | null>;
|
||||||
|
|
||||||
|
/** Plot beats — the atomic units of story structure (CHUNK #5). */
|
||||||
|
listPlotBeats(projectId: string): Promise<PlotBeat[]>;
|
||||||
|
savePlotBeat(b: PlotBeat): Promise<void>;
|
||||||
|
deletePlotBeat(projectId: string, id: string): Promise<void>;
|
||||||
|
|
||||||
saveAISession(s: AISession): Promise<void>;
|
saveAISession(s: AISession): Promise<void>;
|
||||||
|
|
||||||
// live updates
|
// live updates
|
||||||
|
|||||||
+41
-12
@@ -156,18 +156,6 @@ export type BeatTemplate =
|
|||||||
|
|
||||||
export type BeatStatus = "draft" | "revised" | "locked";
|
export type BeatStatus = "draft" | "revised" | "locked";
|
||||||
|
|
||||||
export interface PlotBeat {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
template: BeatTemplate;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
status: BeatStatus;
|
|
||||||
chapterId?: string;
|
|
||||||
order: number;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimelineEvent {
|
export interface TimelineEvent {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -259,5 +247,46 @@ export interface DocumentRef {
|
|||||||
rev: number;
|
rev: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plot beat — the atomic unit of story structure. Beats are ordered within
|
||||||
|
* an act and carry a `type` that drives the timeline's colour coding. They are
|
||||||
|
* the source of truth for the timeline; the timeline is a derived view.
|
||||||
|
* See CHUNK #5 step 1.
|
||||||
|
*/
|
||||||
|
export type PlotBeatType =
|
||||||
|
| "setup"
|
||||||
|
| "conflict"
|
||||||
|
| "turn"
|
||||||
|
| "climax"
|
||||||
|
| "resolution"
|
||||||
|
| "scene"
|
||||||
|
| "note";
|
||||||
|
|
||||||
|
export interface PlotBeat {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
/** Act number (1, 2, 3, …). Beats are grouped and ordered by act. */
|
||||||
|
act: number;
|
||||||
|
/** Structural role; drives timeline colour and the template palette. */
|
||||||
|
type: PlotBeatType;
|
||||||
|
/** Order within the act (0-based). Lower comes first. */
|
||||||
|
order: number;
|
||||||
|
/** Progress status. Beats move draft → revised → locked. Locked beats are
|
||||||
|
* read-only until explicitly unlocked. */
|
||||||
|
status: PlotBeatStatus;
|
||||||
|
/** Optional link to the chapter this beat belongs to. */
|
||||||
|
chapterId?: string | undefined;
|
||||||
|
/** Optional links to entities (characters, locations, …) referenced in this
|
||||||
|
* beat. Stored as entity IDs. */
|
||||||
|
entityIds?: string[] | undefined;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The progress status of a plot beat. */
|
||||||
|
export type PlotBeatStatus = "draft" | "revised" | "locked";
|
||||||
|
|
||||||
/** A generic entity — the discriminated union handled by the entity engine. */
|
/** A generic entity — the discriminated union handled by the entity engine. */
|
||||||
export type Entity = Character | WorldItem | Note;
|
export type Entity = Character | WorldItem | Note;
|
||||||
|
|||||||
@@ -298,6 +298,8 @@ body {
|
|||||||
.ps-btn-lg { padding: 10px 20px; font-size: 15px; }
|
.ps-btn-lg { padding: 10px 20px; font-size: 15px; }
|
||||||
.ps-btn-primary { background: var(--ps-accent); color: var(--ps-accent-contrast); }
|
.ps-btn-primary { background: var(--ps-accent); color: var(--ps-accent-contrast); }
|
||||||
.ps-btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
|
.ps-btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
|
||||||
|
.ps-btn-accent { background: var(--ps-accent); color: var(--ps-accent-contrast); border-color: var(--ps-accent); }
|
||||||
|
.ps-btn-accent:hover:not(:disabled) { filter: brightness(1.08); }
|
||||||
.ps-btn-secondary { background: var(--ps-surface-2); border-color: var(--ps-border); color: var(--ps-text); }
|
.ps-btn-secondary { background: var(--ps-surface-2); border-color: var(--ps-border); color: var(--ps-text); }
|
||||||
.ps-btn-secondary:hover:not(:disabled) { background: var(--ps-border); }
|
.ps-btn-secondary:hover:not(:disabled) { background: var(--ps-border); }
|
||||||
.ps-btn-ghost { background: transparent; color: var(--ps-text); }
|
.ps-btn-ghost { background: transparent; color: var(--ps-text); }
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ export type BadgeVariant = "neutral" | "accent" | "success" | "danger" | "warnin
|
|||||||
export interface BadgeProps {
|
export interface BadgeProps {
|
||||||
children: string;
|
children: string;
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Badge({ children, variant = "neutral" }: BadgeProps) {
|
export function Badge({ children, variant = "neutral", title }: BadgeProps) {
|
||||||
return <span className={`ps-badge ps-badge-${variant}`}>{children}</span>;
|
return <span className={`ps-badge ps-badge-${variant}`} title={title}>{children}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type ButtonHTMLAttributes } from "react";
|
import { type ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
|
||||||
export type ButtonSize = "sm" | "md" | "lg";
|
export type ButtonSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
|||||||
Generated
+453
@@ -50,6 +50,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.1.1
|
specifier: ^7.1.1
|
||||||
version: 7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
version: 7.18.4(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
reactflow:
|
||||||
|
specifier: ^11
|
||||||
|
version: 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.2
|
specifier: ^5.0.2
|
||||||
version: 5.0.16(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1))
|
version: 5.0.16(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1))
|
||||||
@@ -930,6 +933,42 @@ packages:
|
|||||||
'@pinojs/redact@0.4.0':
|
'@pinojs/redact@0.4.0':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
|
'@reactflow/background@11.3.14':
|
||||||
|
resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
|
||||||
|
'@reactflow/controls@11.2.14':
|
||||||
|
resolution: {integrity: sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
|
||||||
|
'@reactflow/core@11.11.4':
|
||||||
|
resolution: {integrity: sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
|
||||||
|
'@reactflow/minimap@11.7.14':
|
||||||
|
resolution: {integrity: sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
|
||||||
|
'@reactflow/node-resizer@2.2.14':
|
||||||
|
resolution: {integrity: sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
|
||||||
|
'@reactflow/node-toolbar@1.3.14':
|
||||||
|
resolution: {integrity: sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||||
|
|
||||||
@@ -1228,9 +1267,105 @@ packages:
|
|||||||
'@types/better-sqlite3@7.6.13':
|
'@types/better-sqlite3@7.6.13':
|
||||||
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
|
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2':
|
||||||
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
|
'@types/d3-axis@3.0.6':
|
||||||
|
resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
|
||||||
|
|
||||||
|
'@types/d3-brush@3.0.6':
|
||||||
|
resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
|
||||||
|
|
||||||
|
'@types/d3-chord@3.0.6':
|
||||||
|
resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3':
|
||||||
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-contour@3.0.6':
|
||||||
|
resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
|
||||||
|
|
||||||
|
'@types/d3-delaunay@6.0.4':
|
||||||
|
resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
|
||||||
|
|
||||||
|
'@types/d3-dispatch@3.0.7':
|
||||||
|
resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||||
|
|
||||||
|
'@types/d3-dsv@3.0.7':
|
||||||
|
resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2':
|
||||||
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
|
'@types/d3-fetch@3.0.7':
|
||||||
|
resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
|
||||||
|
|
||||||
|
'@types/d3-force@3.0.10':
|
||||||
|
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
||||||
|
|
||||||
|
'@types/d3-format@3.0.4':
|
||||||
|
resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
|
||||||
|
|
||||||
|
'@types/d3-geo@3.1.1':
|
||||||
|
resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==}
|
||||||
|
|
||||||
|
'@types/d3-hierarchy@3.1.7':
|
||||||
|
resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1':
|
||||||
|
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||||
|
|
||||||
|
'@types/d3-polygon@3.0.2':
|
||||||
|
resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
|
||||||
|
|
||||||
|
'@types/d3-quadtree@3.0.6':
|
||||||
|
resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
|
||||||
|
|
||||||
|
'@types/d3-random@3.0.4':
|
||||||
|
resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==}
|
||||||
|
|
||||||
|
'@types/d3-scale-chromatic@3.1.0':
|
||||||
|
resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.12':
|
||||||
|
resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==}
|
||||||
|
|
||||||
|
'@types/d3-shape@3.2.0':
|
||||||
|
resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==}
|
||||||
|
|
||||||
|
'@types/d3-time-format@4.0.3':
|
||||||
|
resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4':
|
||||||
|
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2':
|
||||||
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.9':
|
||||||
|
resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==}
|
||||||
|
|
||||||
|
'@types/d3@7.4.3':
|
||||||
|
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
|
||||||
|
|
||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16':
|
||||||
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
@@ -1470,6 +1605,9 @@ packages:
|
|||||||
chownr@1.1.4:
|
chownr@1.1.4:
|
||||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||||
|
|
||||||
|
classcat@5.0.5:
|
||||||
|
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -1510,6 +1648,44 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-color@3.1.0:
|
||||||
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1:
|
||||||
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-ease@3.0.1:
|
||||||
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-selection@3.0.0:
|
||||||
|
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-transition@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
d3-selection: 2 - 3
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
data-urls@5.0.0:
|
data-urls@5.0.0:
|
||||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2220,6 +2396,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
|
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
reactflow@11.11.4:
|
||||||
|
resolution: {integrity: sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -2646,6 +2828,21 @@ packages:
|
|||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
|
zustand@4.5.7:
|
||||||
|
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||||
|
engines: {node: '>=12.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16.8'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=16.8'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
zustand@5.0.16:
|
zustand@5.0.16:
|
||||||
resolution: {integrity: sha512-Uv7e327ihlCfayfja3hDc0M64Upev1LWBKpRobdK4NyovDDPeeX4imWXc/wzC1yc6fkYPoAY/ncKVPAs2jrtQw==}
|
resolution: {integrity: sha512-Uv7e327ihlCfayfja3hDc0M64Upev1LWBKpRobdK4NyovDDPeeX4imWXc/wzC1yc6fkYPoAY/ncKVPAs2jrtQw==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -3200,6 +3397,84 @@ snapshots:
|
|||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
|
'@reactflow/background@11.3.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
classcat: 5.0.5
|
||||||
|
react: 19.3.1
|
||||||
|
react-dom: 19.3.1(react@19.3.1)
|
||||||
|
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@reactflow/controls@11.2.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
classcat: 5.0.5
|
||||||
|
react: 19.3.1
|
||||||
|
react-dom: 19.3.1(react@19.3.1)
|
||||||
|
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@reactflow/core@11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3': 7.4.3
|
||||||
|
'@types/d3-drag': 3.0.7
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
'@types/d3-zoom': 3.0.9
|
||||||
|
classcat: 5.0.5
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-zoom: 3.0.0
|
||||||
|
react: 19.3.1
|
||||||
|
react-dom: 19.3.1(react@19.3.1)
|
||||||
|
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@reactflow/minimap@11.7.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
'@types/d3-zoom': 3.0.9
|
||||||
|
classcat: 5.0.5
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-zoom: 3.0.0
|
||||||
|
react: 19.3.1
|
||||||
|
react-dom: 19.3.1(react@19.3.1)
|
||||||
|
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@reactflow/node-resizer@2.2.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
classcat: 5.0.5
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
react: 19.3.1
|
||||||
|
react-dom: 19.3.1(react@19.3.1)
|
||||||
|
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@reactflow/node-toolbar@1.3.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
classcat: 5.0.5
|
||||||
|
react: 19.3.1
|
||||||
|
react-dom: 19.3.1(react@19.3.1)
|
||||||
|
zustand: 4.5.7(@types/react@19.3.0)(react@19.3.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.65.0':
|
'@rollup/rollup-android-arm-eabi@4.65.0':
|
||||||
@@ -3479,8 +3754,127 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.20.5
|
'@types/node': 22.20.5
|
||||||
|
|
||||||
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
|
'@types/d3-axis@3.0.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
|
||||||
|
'@types/d3-brush@3.0.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
|
||||||
|
'@types/d3-chord@3.0.6': {}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-contour@3.0.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-array': 3.2.2
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
|
'@types/d3-delaunay@6.0.4': {}
|
||||||
|
|
||||||
|
'@types/d3-dispatch@3.0.7': {}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
|
||||||
|
'@types/d3-dsv@3.0.7': {}
|
||||||
|
|
||||||
|
'@types/d3-ease@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-fetch@3.0.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-dsv': 3.0.7
|
||||||
|
|
||||||
|
'@types/d3-force@3.0.10': {}
|
||||||
|
|
||||||
|
'@types/d3-format@3.0.4': {}
|
||||||
|
|
||||||
|
'@types/d3-geo@3.1.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
|
'@types/d3-hierarchy@3.1.7': {}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-color': 3.1.3
|
||||||
|
|
||||||
|
'@types/d3-path@3.1.1': {}
|
||||||
|
|
||||||
|
'@types/d3-polygon@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-quadtree@3.0.6': {}
|
||||||
|
|
||||||
|
'@types/d3-random@3.0.4': {}
|
||||||
|
|
||||||
|
'@types/d3-scale-chromatic@3.1.0': {}
|
||||||
|
|
||||||
|
'@types/d3-scale@4.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.12': {}
|
||||||
|
|
||||||
|
'@types/d3-shape@3.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-path': 3.1.1
|
||||||
|
|
||||||
|
'@types/d3-time-format@4.0.3': {}
|
||||||
|
|
||||||
|
'@types/d3-time@3.0.4': {}
|
||||||
|
|
||||||
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
|
||||||
|
'@types/d3@7.4.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-array': 3.2.2
|
||||||
|
'@types/d3-axis': 3.0.6
|
||||||
|
'@types/d3-brush': 3.0.6
|
||||||
|
'@types/d3-chord': 3.0.6
|
||||||
|
'@types/d3-color': 3.1.3
|
||||||
|
'@types/d3-contour': 3.0.6
|
||||||
|
'@types/d3-delaunay': 6.0.4
|
||||||
|
'@types/d3-dispatch': 3.0.7
|
||||||
|
'@types/d3-drag': 3.0.7
|
||||||
|
'@types/d3-dsv': 3.0.7
|
||||||
|
'@types/d3-ease': 3.0.2
|
||||||
|
'@types/d3-fetch': 3.0.7
|
||||||
|
'@types/d3-force': 3.0.10
|
||||||
|
'@types/d3-format': 3.0.4
|
||||||
|
'@types/d3-geo': 3.1.1
|
||||||
|
'@types/d3-hierarchy': 3.1.7
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-path': 3.1.1
|
||||||
|
'@types/d3-polygon': 3.0.2
|
||||||
|
'@types/d3-quadtree': 3.0.6
|
||||||
|
'@types/d3-random': 3.0.4
|
||||||
|
'@types/d3-scale': 4.0.9
|
||||||
|
'@types/d3-scale-chromatic': 3.1.0
|
||||||
|
'@types/d3-selection': 3.0.12
|
||||||
|
'@types/d3-shape': 3.2.0
|
||||||
|
'@types/d3-time': 3.0.4
|
||||||
|
'@types/d3-time-format': 4.0.3
|
||||||
|
'@types/d3-timer': 3.0.2
|
||||||
|
'@types/d3-transition': 3.0.9
|
||||||
|
'@types/d3-zoom': 3.0.9
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/node@22.20.5':
|
'@types/node@22.20.5':
|
||||||
@@ -3768,6 +4162,8 @@ snapshots:
|
|||||||
|
|
||||||
chownr@1.1.4: {}
|
chownr@1.1.4: {}
|
||||||
|
|
||||||
|
classcat@5.0.5: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -3803,6 +4199,42 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
|
||||||
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-selection@3.0.0: {}
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
|
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||||
|
|
||||||
data-urls@5.0.0:
|
data-urls@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-mimetype: 4.0.0
|
whatwg-mimetype: 4.0.0
|
||||||
@@ -4607,6 +5039,20 @@ snapshots:
|
|||||||
|
|
||||||
react@19.3.1: {}
|
react@19.3.1: {}
|
||||||
|
|
||||||
|
reactflow@11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
|
||||||
|
dependencies:
|
||||||
|
'@reactflow/background': 11.3.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
'@reactflow/controls': 11.2.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
'@reactflow/core': 11.11.4(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
'@reactflow/minimap': 11.7.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
'@reactflow/node-resizer': 2.2.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
'@reactflow/node-toolbar': 1.3.14(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||||
|
react: 19.3.1
|
||||||
|
react-dom: 19.3.1(react@19.3.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
@@ -4962,6 +5408,13 @@ snapshots:
|
|||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
|
zustand@4.5.7(@types/react@19.3.0)(react@19.3.1):
|
||||||
|
dependencies:
|
||||||
|
use-sync-external-store: 1.7.0(react@19.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.3.0
|
||||||
|
react: 19.3.1
|
||||||
|
|
||||||
zustand@5.0.16(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1)):
|
zustand@5.0.16(@types/react@19.3.0)(react@19.3.1)(use-sync-external-store@1.7.0(react@19.3.1)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.3.0
|
'@types/react': 19.3.0
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
Character,
|
Character,
|
||||||
Entity,
|
Entity,
|
||||||
Note,
|
Note,
|
||||||
|
PlotBeat,
|
||||||
Project,
|
Project,
|
||||||
ProjectMeta,
|
ProjectMeta,
|
||||||
User,
|
User,
|
||||||
@@ -69,6 +70,21 @@ export interface BoardRow {
|
|||||||
edges: string;
|
edges: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlotBeatRow {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
act: number;
|
||||||
|
type: string;
|
||||||
|
beat_order: number;
|
||||||
|
status: string;
|
||||||
|
chapter_id: string | null;
|
||||||
|
entity_ids: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
export interface AISessionRow {
|
export interface AISessionRow {
|
||||||
id: string;
|
id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
@@ -160,6 +176,34 @@ export function toBoard(r: BoardRow): Board {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toPlotBeat(r: PlotBeatRow): PlotBeat {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
projectId: r.project_id,
|
||||||
|
title: r.title,
|
||||||
|
body: r.body,
|
||||||
|
act: r.act,
|
||||||
|
type: r.type as PlotBeat["type"],
|
||||||
|
order: r.beat_order,
|
||||||
|
status: (r.status as PlotBeat["status"]) ?? "draft",
|
||||||
|
chapterId: r.chapter_id ?? undefined,
|
||||||
|
entityIds: parseEntityIds(r.entity_ids),
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the JSON `entity_ids` column into a string[] (empty when blank). */
|
||||||
|
function parseEntityIds(raw: string): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function toAISession(r: AISessionRow): AISession {
|
export function toAISession(r: AISessionRow): AISession {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 002_plot_beats.sql — plot beats for the Plot board and Timeline (CHUNK #5).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS plot_beats (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
act INTEGER NOT NULL DEFAULT 1,
|
||||||
|
type TEXT NOT NULL DEFAULT 'note',
|
||||||
|
beat_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
chapter_id TEXT,
|
||||||
|
entity_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (project_id, id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plot_beats_project ON plot_beats(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plot_beats_act ON plot_beats(project_id, act, beat_order);
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
toBook,
|
toBook,
|
||||||
toChapter,
|
toChapter,
|
||||||
toBoard,
|
toBoard,
|
||||||
|
toPlotBeat,
|
||||||
toAISession,
|
toAISession,
|
||||||
toEntity,
|
toEntity,
|
||||||
entityToRow,
|
entityToRow,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
type ChapterRow,
|
type ChapterRow,
|
||||||
type EntityRow,
|
type EntityRow,
|
||||||
type BoardRow,
|
type BoardRow,
|
||||||
|
type PlotBeatRow,
|
||||||
type AISessionRow,
|
type AISessionRow,
|
||||||
} from "./mapping.js";
|
} from "./mapping.js";
|
||||||
|
|
||||||
@@ -75,6 +77,17 @@ const boardPatch = z.object({
|
|||||||
nodes: z.array(z.any()).optional(),
|
nodes: z.array(z.any()).optional(),
|
||||||
edges: z.array(z.any()).optional(),
|
edges: z.array(z.any()).optional(),
|
||||||
});
|
});
|
||||||
|
const beatCreate = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
title: z.string().default(""),
|
||||||
|
body: z.string().default(""),
|
||||||
|
act: z.number().int().min(1).default(1),
|
||||||
|
type: z.enum(["setup", "conflict", "turn", "climax", "resolution", "scene", "note"]).default("note"),
|
||||||
|
order: z.number().int().min(0).default(0),
|
||||||
|
status: z.enum(["draft", "revised", "locked"]).default("draft"),
|
||||||
|
chapterId: z.string().optional(),
|
||||||
|
entityIds: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
const aiSessionCreate = z.object({
|
const aiSessionCreate = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
role: z.string().min(1),
|
role: z.string().min(1),
|
||||||
@@ -459,6 +472,56 @@ export function registerRoutes(app: FastifyInstance): void {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- Plot beats ----------
|
||||||
|
app.get("/api/projects/:id/beats", async (req) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
requireProjectAccess(app, req, id);
|
||||||
|
const rows = db
|
||||||
|
.prepare("SELECT * FROM plot_beats WHERE project_id = ? ORDER BY act, beat_order, id")
|
||||||
|
.all(id) as PlotBeatRow[];
|
||||||
|
return { beats: rows.map(toPlotBeat) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/projects/:id/beats", async (req) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
requireProjectAccess(app, req, id, true);
|
||||||
|
const body = beatCreate.parse(req.body);
|
||||||
|
const bid = body.id ?? ulid();
|
||||||
|
const t = now();
|
||||||
|
db.prepare(`INSERT INTO plot_beats (id, project_id, title, body, act, type, beat_order, status, chapter_id, entity_ids, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(project_id, id) DO UPDATE SET
|
||||||
|
title = excluded.title, body = excluded.body, act = excluded.act, type = excluded.type,
|
||||||
|
beat_order = excluded.beat_order, status = excluded.status, chapter_id = excluded.chapter_id,
|
||||||
|
entity_ids = excluded.entity_ids, updated_at = excluded.updated_at`).run(
|
||||||
|
bid,
|
||||||
|
id,
|
||||||
|
body.title,
|
||||||
|
body.body,
|
||||||
|
body.act,
|
||||||
|
body.type,
|
||||||
|
body.order,
|
||||||
|
body.status ?? "draft",
|
||||||
|
body.chapterId ?? null,
|
||||||
|
JSON.stringify(body.entityIds ?? []),
|
||||||
|
t,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
const row = db.prepare("SELECT * FROM plot_beats WHERE id = ? AND project_id = ?").get(bid, id) as PlotBeatRow;
|
||||||
|
return { beat: toPlotBeat(row) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/projects/:id/beats/:beatId", async (req) => {
|
||||||
|
const { id, beatId } = req.params as { id: string; beatId: string };
|
||||||
|
requireProjectAccess(app, req, id, true);
|
||||||
|
const row = db.prepare("SELECT * FROM plot_beats WHERE id = ? AND project_id = ?").get(beatId, id) as
|
||||||
|
| PlotBeatRow
|
||||||
|
| undefined;
|
||||||
|
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
|
||||||
|
db.prepare("DELETE FROM plot_beats WHERE id = ? AND project_id = ?").run(beatId, id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- AI sessions ----------
|
// ---------- AI sessions ----------
|
||||||
app.get("/api/projects/:id/ai/sessions", async (req) => {
|
app.get("/api/projects/:id/ai/sessions", async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|||||||
+54
-1
@@ -77,6 +77,44 @@ function addBoard(db: any, projectId: string, board: Board): void {
|
|||||||
).run(board.id, projectId, JSON.stringify(board.nodes), JSON.stringify(board.edges), t);
|
).run(board.id, projectId, JSON.stringify(board.nodes), JSON.stringify(board.edges), t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Upsert a single plot beat. */
|
||||||
|
function addBeat(
|
||||||
|
db: any,
|
||||||
|
projectId: string,
|
||||||
|
beat: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
act: number;
|
||||||
|
type: string;
|
||||||
|
order: number;
|
||||||
|
chapterId?: string;
|
||||||
|
entityIds?: string[];
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const t = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO plot_beats (id, project_id, title, body, act, type, beat_order, chapter_id, entity_ids, created_at, updated_at) ` +
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||||
|
"ON CONFLICT(project_id, id) DO UPDATE SET " +
|
||||||
|
"title = excluded.title, body = excluded.body, act = excluded.act, type = excluded.type, " +
|
||||||
|
"beat_order = excluded.beat_order, chapter_id = excluded.chapter_id, " +
|
||||||
|
"entity_ids = excluded.entity_ids, updated_at = excluded.updated_at",
|
||||||
|
).run(
|
||||||
|
beat.id,
|
||||||
|
projectId,
|
||||||
|
beat.title,
|
||||||
|
beat.body,
|
||||||
|
beat.act,
|
||||||
|
beat.type,
|
||||||
|
beat.order,
|
||||||
|
beat.chapterId ?? null,
|
||||||
|
JSON.stringify(beat.entityIds ?? []),
|
||||||
|
t,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const db = openDb();
|
const db = openDb();
|
||||||
|
|
||||||
@@ -262,8 +300,23 @@ async function main(): Promise<void> {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Plot beats across three acts.
|
||||||
|
const c1 = "c1";
|
||||||
|
const c2 = "c2";
|
||||||
|
const c3 = "c3";
|
||||||
|
addBeat(db, projectId, { id: "b1", title: "Mara arrives", body: "The ferry carries Mara Vell to the Glass Harbor before dawn.", act: 1, type: "event", order: 0, chapterId: c1 });
|
||||||
|
addBeat(db, projectId, { id: "b2", title: "The failing light", body: "Three nights without the beam; the fish are turning away.", act: 1, type: "event", order: 1, chapterId: c1 });
|
||||||
|
addBeat(db, projectId, { id: "b3", title: "The old keeper's silence", body: "Tomas never waves. Mara senses a grief she is not yet allowed to understand.", act: 1, type: "foreshadow", order: 2, chapterId: c1 });
|
||||||
|
addBeat(db, projectId, { id: "b4", title: "The storm", body: "The storm arrives on the second night, without apology.", act: 2, type: "turn", order: 0, chapterId: c2 });
|
||||||
|
addBeat(db, projectId, { id: "b5", title: "Mara climbs the lamp", body: "One hundred and fourteen steps; she coaxes the seized gears back to life.", act: 2, type: "event", order: 1, chapterId: c2 });
|
||||||
|
addBeat(db, projectId, { id: "b6", title: "Fen's warning", body: "They died because they forgot that someone had to keep the light.", act: 2, type: "reveal", order: 2, chapterId: c2 });
|
||||||
|
addBeat(db, projectId, { id: "b7", title: "The glass in the dark", body: "A shard of living glass — the light is mined, not made.", act: 2, type: "reveal", order: 3, chapterId: c3 });
|
||||||
|
addBeat(db, projectId, { id: "b8", title: "The inherited duty", body: "The old keeper never waved, never left. Some duties are inherited, like the tide.", act: 3, type: "turn", order: 0, chapterId: c3 });
|
||||||
|
addBeat(db, projectId, { id: "b9", title: "Mara chooses the light", body: "I want it. She accepts the keepership Tomas is offering.", act: 3, type: "climax", order: 1, chapterId: c3 });
|
||||||
|
addBeat(db, projectId, { id: "b10", title: "The light answers", body: "The beam sweeps out across the black water. The harbor is kept.", act: 3, type: "resolution", order: 2, chapterId: c3 });
|
||||||
|
|
||||||
console.log(`seed: created demo project "${DEMO_TITLE}" (id ${projectId})`);
|
console.log(`seed: created demo project "${DEMO_TITLE}" (id ${projectId})`);
|
||||||
console.log(`seed: 1 book, 3 chapters, 4 characters, 1 world location, 1 board`);
|
console.log(`seed: 1 book, 3 chapters, 4 characters, 1 world location, 1 board, 10 plot beats`);
|
||||||
console.log(`seed: owner: ${owner.id}`);
|
console.log(`seed: owner: ${owner.id}`);
|
||||||
console.log(`seed: login with PUBSCRIPT_ADMIN_EMAIL=${process.env.PUBSCRIPT_ADMIN_EMAIL ?? "admin@pubscript.local"}`);
|
console.log(`seed: login with PUBSCRIPT_ADMIN_EMAIL=${process.env.PUBSCRIPT_ADMIN_EMAIL ?? "admin@pubscript.local"}`);
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user