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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user