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:
@@ -40,6 +40,14 @@ function mockAdapter(): StorageAdapter & { saved: DocumentRef[] } {
|
||||
return [];
|
||||
},
|
||||
async saveBoard() {},
|
||||
async getBoard() {
|
||||
return null;
|
||||
},
|
||||
async savePlotBeat() {},
|
||||
async listPlotBeats() {
|
||||
return [];
|
||||
},
|
||||
async deletePlotBeat() {},
|
||||
async saveAISession() {},
|
||||
async saveDocument(doc: DocumentRef) {
|
||||
saved.push(doc);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* 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 interface FieldDef {
|
||||
@@ -99,3 +101,77 @@ export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
|
||||
|
||||
/** All entity kinds, in display order. */
|
||||
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,
|
||||
Entity,
|
||||
NewProject,
|
||||
PlotBeat,
|
||||
Project,
|
||||
ProjectMeta,
|
||||
} from "./types.js";
|
||||
@@ -28,6 +29,7 @@ class PubScriptDB extends Dexie {
|
||||
chapters!: Table<Chapter, string>;
|
||||
entities!: Table<EntityRec, string>;
|
||||
boards!: Table<Board, string>;
|
||||
plotBeats!: Table<PlotBeat, string>;
|
||||
aiSessions!: Table<AISession, string>;
|
||||
|
||||
constructor() {
|
||||
@@ -38,6 +40,8 @@ class PubScriptDB extends Dexie {
|
||||
chapters: "id, bookId, projectId",
|
||||
entities: "key, projectId, kind",
|
||||
boards: "id, projectId",
|
||||
// Plot beats — atomic units of story structure (CHUNK #5).
|
||||
plotBeats: "id, [projectId+act], projectId, chapterId",
|
||||
aiSessions: "id, projectId",
|
||||
});
|
||||
}
|
||||
@@ -194,6 +198,35 @@ export const storageLocal: StorageAdapter = {
|
||||
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> {
|
||||
await db.aiSessions.put(s);
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
NewProject,
|
||||
Project,
|
||||
ProjectMeta,
|
||||
PlotBeat,
|
||||
} from "./types.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> {
|
||||
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
|
||||
id: s.id,
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
NewProject,
|
||||
Project,
|
||||
ProjectMeta,
|
||||
PlotBeat,
|
||||
AISession,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -53,6 +54,14 @@ export interface StorageAdapter {
|
||||
*/
|
||||
searchEntities(projectId: string, q: string): Promise<Entity[]>;
|
||||
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>;
|
||||
|
||||
// live updates
|
||||
|
||||
+41
-12
@@ -156,18 +156,6 @@ export type BeatTemplate =
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -259,5 +247,46 @@ export interface DocumentRef {
|
||||
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. */
|
||||
export type Entity = Character | WorldItem | Note;
|
||||
|
||||
Reference in New Issue
Block a user