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:
@@ -7,6 +7,7 @@ import type {
|
||||
Character,
|
||||
Entity,
|
||||
Note,
|
||||
PlotBeat,
|
||||
Project,
|
||||
ProjectMeta,
|
||||
User,
|
||||
@@ -69,6 +70,21 @@ export interface BoardRow {
|
||||
edges: 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 {
|
||||
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 {
|
||||
return {
|
||||
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,
|
||||
toChapter,
|
||||
toBoard,
|
||||
toPlotBeat,
|
||||
toAISession,
|
||||
toEntity,
|
||||
entityToRow,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
type ChapterRow,
|
||||
type EntityRow,
|
||||
type BoardRow,
|
||||
type PlotBeatRow,
|
||||
type AISessionRow,
|
||||
} from "./mapping.js";
|
||||
|
||||
@@ -75,6 +77,17 @@ const boardPatch = z.object({
|
||||
nodes: 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({
|
||||
id: z.string().optional(),
|
||||
role: z.string().min(1),
|
||||
@@ -459,6 +472,56 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
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 ----------
|
||||
app.get("/api/projects/:id/ai/sessions", async (req) => {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
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: 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: login with PUBSCRIPT_ADMIN_EMAIL=${process.env.PUBSCRIPT_ADMIN_EMAIL ?? "admin@pubscript.local"}`);
|
||||
db.close();
|
||||
|
||||
Reference in New Issue
Block a user