plotlink-ows 1.0.32 → 1.2.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +811 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +10 -3
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +243 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/publish.ts +209 -28
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1299 -0
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1141 -0
- package/app/web/components/PreviewPanel.tsx +1017 -144
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +710 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +516 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WorkflowCoach.tsx +128 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
- package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
- package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-BFw-v-OZ.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/scripts/e2e-verify.ts +0 -1100
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
import type { StoryProgress, EpisodeProgress, EpisodeState } from "@app-lib/story-progress";
|
|
3
|
+
import type { CartoonChecklistStep } from "@app-lib/cartoon-readiness";
|
|
4
|
+
import { WorkflowCoachView } from "./WorkflowCoach";
|
|
5
|
+
|
|
6
|
+
interface StoryProgressPanelProps {
|
|
7
|
+
storyName: string;
|
|
8
|
+
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
9
|
+
/** Open a file from the map (the workflow steps link to their file). */
|
|
10
|
+
onOpenFile: (storyName: string, file: string) => void;
|
|
11
|
+
/** Bumped by the parent to force a refresh (e.g. after a publish). */
|
|
12
|
+
refreshKey?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Story-level "View Progress" overview (#418, redesigned #438).
|
|
17
|
+
*
|
|
18
|
+
* For CARTOON stories this is the writer's main production dashboard: a vertical
|
|
19
|
+
* workflow map of numbered sections (Define Story Info → Story Whitepaper →
|
|
20
|
+
* Genesis / Episode 1 → Episode 2 …), each with a checkbox checklist and a clear
|
|
21
|
+
* status. The single next-action CTA is rendered inside the section it belongs to
|
|
22
|
+
* (never a duplicated global bar), so a first-time creator understands the whole
|
|
23
|
+
* webtoon workflow and where they are in it without reading terminal output or
|
|
24
|
+
* raw file names.
|
|
25
|
+
*
|
|
26
|
+
* FICTION keeps the simpler original layout — metadata, setup steps, a chapter
|
|
27
|
+
* list — and is completely unaffected by the cartoon redesign.
|
|
28
|
+
*/
|
|
29
|
+
export function StoryProgressPanel({ storyName, authFetch, onOpenFile, refreshKey = 0 }: StoryProgressPanelProps) {
|
|
30
|
+
const [progress, setProgress] = useState<StoryProgress | null>(null);
|
|
31
|
+
const [loading, setLoading] = useState(true);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
const load = async () => {
|
|
36
|
+
setLoading(true);
|
|
37
|
+
try {
|
|
38
|
+
const res = await authFetch(`/api/stories/${storyName}/progress`);
|
|
39
|
+
const data = res.ok ? await res.json() : null;
|
|
40
|
+
if (!cancelled) { setProgress(data); setLoading(false); }
|
|
41
|
+
} catch {
|
|
42
|
+
if (!cancelled) { setProgress(null); setLoading(false); }
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
load();
|
|
46
|
+
return () => { cancelled = true; };
|
|
47
|
+
}, [storyName, authFetch, refreshKey]);
|
|
48
|
+
|
|
49
|
+
if (loading) {
|
|
50
|
+
return <div className="h-full flex items-center justify-center text-muted text-sm" data-testid="progress-loading">Loading progress…</div>;
|
|
51
|
+
}
|
|
52
|
+
// Guard against a missing/malformed response (not just null) so a partial
|
|
53
|
+
// payload can never crash the panel.
|
|
54
|
+
if (!progress || !progress.metadata || !Array.isArray(progress.episodes)) {
|
|
55
|
+
return <div className="h-full flex items-center justify-center text-muted text-sm">Could not load story progress.</div>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return progress.contentType === "cartoon"
|
|
59
|
+
? <CartoonWorkflowMap progress={progress} storyName={storyName} onOpenFile={onOpenFile} />
|
|
60
|
+
: <FictionProgressView progress={progress} storyName={storyName} onOpenFile={onOpenFile} />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Shared header
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function Chip({ label, value, tone = "muted" }: { label: string; value: string; tone?: "muted" | "ok" | "warn" }) {
|
|
68
|
+
const cls = tone === "ok" ? "text-green-700" : tone === "warn" ? "text-amber-700" : "text-muted";
|
|
69
|
+
return (
|
|
70
|
+
<span className="text-[11px]">
|
|
71
|
+
<span className="text-muted">{label}: </span>
|
|
72
|
+
<span className={`font-medium ${cls}`}>{value}</span>
|
|
73
|
+
</span>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ProgressHeader({ progress }: { progress: StoryProgress }) {
|
|
78
|
+
const cartoon = progress.contentType === "cartoon";
|
|
79
|
+
const coverTone = progress.cover === "present" ? "ok" : progress.cover === "invalid" ? "warn" : "muted";
|
|
80
|
+
return (
|
|
81
|
+
<div className="px-4 py-3 border-b border-border">
|
|
82
|
+
<div className="flex items-center gap-2">
|
|
83
|
+
<h2 className="text-base font-serif text-foreground truncate">{progress.metadata.title || progress.name}</h2>
|
|
84
|
+
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${cartoon ? "bg-accent/10 text-accent" : "bg-surface text-muted"}`}>
|
|
85
|
+
{cartoon ? "Cartoon" : "Fiction"}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1">
|
|
89
|
+
<Chip label="Language" value={progress.metadata.language || "Needs metadata"} tone={progress.metadata.language ? "muted" : "warn"} />
|
|
90
|
+
<Chip label="Genre" value={progress.metadata.genre || "Needs metadata"} tone={progress.metadata.genre ? "muted" : "warn"} />
|
|
91
|
+
{progress.metadata.isNsfw != null && <Chip label="Adult" value={progress.metadata.isNsfw ? "Yes (18+)" : "No"} />}
|
|
92
|
+
{cartoon && <Chip label="Cover" value={progress.cover === "present" ? "Ready" : progress.cover === "invalid" ? "Invalid" : "Missing"} tone={coverTone} />}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Cartoon vertical workflow map (#438)
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
type SectionStatus = "published" | "done" | "current" | "needs-action" | "not-started";
|
|
103
|
+
|
|
104
|
+
const SECTION_ICON: Record<SectionStatus, string> = {
|
|
105
|
+
published: "✓", done: "●", current: "◉", "needs-action": "●", "not-started": "○",
|
|
106
|
+
};
|
|
107
|
+
const SECTION_TONE: Record<SectionStatus, string> = {
|
|
108
|
+
published: "text-green-700", done: "text-green-700", current: "text-accent", "needs-action": "text-amber-700", "not-started": "text-muted",
|
|
109
|
+
};
|
|
110
|
+
const SECTION_LABEL: Record<SectionStatus, string> = {
|
|
111
|
+
published: "Published", done: "Complete", current: "Current", "needs-action": "Needs action", "not-started": "Not started",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type CheckStatus = "done" | "current" | "todo";
|
|
115
|
+
const CHECK_ICON: Record<CheckStatus, string> = { done: "✓", current: "◓", todo: "○" };
|
|
116
|
+
const CHECK_TONE: Record<CheckStatus, string> = { done: "text-green-700", current: "text-accent", todo: "text-muted" };
|
|
117
|
+
|
|
118
|
+
interface ChecklistItem { label: string; status: CheckStatus; detail?: string | null }
|
|
119
|
+
|
|
120
|
+
function ChecklistRow({ item }: { item: ChecklistItem }) {
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex items-baseline gap-2 text-[11px]" data-testid="checklist-item" data-status={item.status}>
|
|
123
|
+
<span className={`${CHECK_TONE[item.status]} flex-shrink-0`} aria-hidden>{CHECK_ICON[item.status]}</span>
|
|
124
|
+
<span className={item.status === "todo" ? "text-muted" : "text-foreground"}>{item.label}</span>
|
|
125
|
+
{item.detail && <span className="text-muted">· {item.detail}</span>}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One numbered workflow section: a status bullet + title + status badge, a nested
|
|
132
|
+
* checklist, and — only for the active section — the single next-action CTA. The
|
|
133
|
+
* header navigates to `openFile` when one is provided (sections whose dedicated
|
|
134
|
+
* page does not exist yet, e.g. Story Info, render a plain header).
|
|
135
|
+
*/
|
|
136
|
+
function Section({
|
|
137
|
+
index, title, status, items, fileName, openFile, cta,
|
|
138
|
+
}: {
|
|
139
|
+
index: number;
|
|
140
|
+
title: string;
|
|
141
|
+
status: SectionStatus;
|
|
142
|
+
items: ChecklistItem[];
|
|
143
|
+
/** Power-user secondary text (real file name), shown small. */
|
|
144
|
+
fileName?: string | null;
|
|
145
|
+
/** Called to open the section's underlying file, or undefined for no navigation. */
|
|
146
|
+
openFile?: () => void;
|
|
147
|
+
/** The single CTA node, rendered under this section when it is the active one. */
|
|
148
|
+
cta?: ReactNode;
|
|
149
|
+
}) {
|
|
150
|
+
const heading = (
|
|
151
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
152
|
+
<span className={`flex-shrink-0 ${SECTION_TONE[status]}`} aria-hidden>{SECTION_ICON[status]}</span>
|
|
153
|
+
<span className="text-xs font-medium text-foreground truncate">{index}. {title}</span>
|
|
154
|
+
{fileName && <span className="text-[10px] text-muted truncate">{fileName}</span>}
|
|
155
|
+
<span className={`ml-auto text-[10px] font-medium ${SECTION_TONE[status]} flex-shrink-0`}>{SECTION_LABEL[status]}</span>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
return (
|
|
159
|
+
<div className="px-4 py-2.5 border-b border-border" data-testid={`workflow-section-${index}`} data-status={status}>
|
|
160
|
+
{openFile
|
|
161
|
+
? <button onClick={openFile} className="w-full text-left rounded hover:bg-surface -mx-1 px-1 py-0.5" data-testid={`section-open-${index}`}>{heading}</button>
|
|
162
|
+
: heading}
|
|
163
|
+
<div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
|
|
164
|
+
{items.map((it, i) => <ChecklistRow key={i} item={it} />)}
|
|
165
|
+
</div>
|
|
166
|
+
{cta && <div className="mt-2 ml-1" data-testid="section-cta">{cta}</div>}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Map a cartoon episode's coarse state + whether it's the active step → a section status. */
|
|
172
|
+
function episodeStatus(ep: EpisodeProgress, isActive: boolean): SectionStatus {
|
|
173
|
+
if (ep.published) return "published";
|
|
174
|
+
if (isActive) return "current";
|
|
175
|
+
if (ep.state === "placeholder") return "not-started";
|
|
176
|
+
if (ep.state === "blocked") return "needs-action";
|
|
177
|
+
return "needs-action";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Build the rendered checklist for a cartoon episode from its production checklist. */
|
|
181
|
+
function episodeItems(ep: EpisodeProgress, openingDone = true): ChecklistItem[] {
|
|
182
|
+
const steps = ep.checklist ?? [];
|
|
183
|
+
const items: ChecklistItem[] = [];
|
|
184
|
+
// Genesis is the reader-facing Episode 1 opening, so surface its opening text
|
|
185
|
+
// as the first checklist line (done once genesis.md is written; to-do in the
|
|
186
|
+
// not-yet-written stub); a plain plot episode starts at its cut plan.
|
|
187
|
+
if (ep.kind === "genesis") items.push({ label: "Opening text", status: openingDone ? "done" : "todo" });
|
|
188
|
+
if (steps.length === 0) {
|
|
189
|
+
// Not started yet — no cut plan. Show the first couple of steps as to-do so
|
|
190
|
+
// the writer sees what starting the episode involves.
|
|
191
|
+
items.push({ label: "Cut plan", status: "todo" });
|
|
192
|
+
items.push({ label: "Clean artwork", status: "todo" });
|
|
193
|
+
return items;
|
|
194
|
+
}
|
|
195
|
+
for (const s of steps) items.push(checklistStepItem(s));
|
|
196
|
+
return items;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function checklistStepItem(s: CartoonChecklistStep): ChecklistItem {
|
|
200
|
+
return { label: s.label, status: s.status, detail: s.detail };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The not-yet-written Genesis (Episode 1) stub, so the section — and its CTA —
|
|
204
|
+
* always render even before genesis.md exists. */
|
|
205
|
+
const GENESIS_STUB: EpisodeProgress = {
|
|
206
|
+
file: "genesis.md", label: "Episode 1 / Genesis", kind: "genesis", title: null,
|
|
207
|
+
state: "placeholder", summary: "", published: false, checklist: [], cuts: null,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** The single Story-Info next step, when cover/metadata is the active gate. */
|
|
211
|
+
function storyInfoNextStep(progress: StoryProgress): string {
|
|
212
|
+
if (progress.cover !== "present") {
|
|
213
|
+
return progress.cover === "invalid"
|
|
214
|
+
? "Replace the cover image — it must be a valid WebP or JPEG."
|
|
215
|
+
: "Add a cover image before publishing.";
|
|
216
|
+
}
|
|
217
|
+
const missing: string[] = [];
|
|
218
|
+
if (!progress.metadata.language) missing.push("language");
|
|
219
|
+
if (!progress.metadata.genre) missing.push("genre");
|
|
220
|
+
if (!progress.metadata.title) missing.push("title");
|
|
221
|
+
return `Add the story ${missing.join(" and ") || "details"} before publishing.`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function CartoonWorkflowMap({
|
|
225
|
+
progress, storyName, onOpenFile,
|
|
226
|
+
}: { progress: StoryProgress; storyName: string; onOpenFile: (storyName: string, file: string) => void }) {
|
|
227
|
+
const coach = progress.coach ?? null;
|
|
228
|
+
const m = progress.metadata;
|
|
229
|
+
const hasStructure = progress.setup.hasStructure;
|
|
230
|
+
const hasGenesis = progress.setup.hasGenesis;
|
|
231
|
+
const coverDone = progress.cover === "present";
|
|
232
|
+
// Required publish metadata (title/language/genre) still hard-gates the active
|
|
233
|
+
// step. A missing COVER is a publish-readiness recommendation, NOT the primary
|
|
234
|
+
// step (#462) — it's kept out of the active-gate decision while an episode is
|
|
235
|
+
// mid-production, so the cut/lettering production CTA leads instead.
|
|
236
|
+
const metadataIncomplete = !m.title || !m.language || !m.genre;
|
|
237
|
+
const storyInfoIncomplete = metadataIncomplete || !coverDone;
|
|
238
|
+
// The active (first unpublished) episode and whether it still has production
|
|
239
|
+
// work to do (anything short of publish-ready).
|
|
240
|
+
const activeEp = progress.episodes.find((e) => !e.published) ?? null;
|
|
241
|
+
const productionPending = !!activeEp && activeEp.state !== "ready";
|
|
242
|
+
|
|
243
|
+
// The SINGLE active gate, chosen in the same order buildStoryProgress derives
|
|
244
|
+
// its next step (structure → genesis → story info/cover → active episode), so
|
|
245
|
+
// the one CTA always matches the story-level next action and lands in its own
|
|
246
|
+
// section. `deriveCartoonCoach` agrees on every gate EXCEPT story info (it
|
|
247
|
+
// skips cover/metadata), so we own that gate here; the coach drives the rest.
|
|
248
|
+
// Crucially, every gate maps to a section that is ALWAYS rendered (Whitepaper,
|
|
249
|
+
// the always-present Genesis section, an episode, or the trailing block), so
|
|
250
|
+
// the CTA can never fall through the cracks (#444 review: it vanished when the
|
|
251
|
+
// bible was written but Genesis wasn't).
|
|
252
|
+
let activeKey: string | null;
|
|
253
|
+
if (!hasStructure) activeKey = "whitepaper";
|
|
254
|
+
else if (!hasGenesis) activeKey = "genesis.md";
|
|
255
|
+
else if (metadataIncomplete) activeKey = "story-info";
|
|
256
|
+
// #462: a mid-production episode leads over a missing cover — the cut/lettering
|
|
257
|
+
// production CTA is the primary step. A missing cover only becomes the active
|
|
258
|
+
// step once the active episode's production is complete (no work pending),
|
|
259
|
+
// where it reads as the publish-readiness recommendation.
|
|
260
|
+
else if (productionPending && coach?.episodeFile) activeKey = coach.episodeFile;
|
|
261
|
+
else if (!coverDone) activeKey = "story-info";
|
|
262
|
+
else activeKey = coach?.episodeFile ?? null;
|
|
263
|
+
|
|
264
|
+
// The coach-driven CTA (setup prompts + episode actions), reused from the
|
|
265
|
+
// tested coach view so routing stays byte-identical to the old overview.
|
|
266
|
+
const coachCta = coach ? (
|
|
267
|
+
<WorkflowCoachView
|
|
268
|
+
coach={coach}
|
|
269
|
+
onAction={(action, episodeFile) => {
|
|
270
|
+
if (action === "view-progress") return; // already here
|
|
271
|
+
if (episodeFile) onOpenFile(storyName, episodeFile);
|
|
272
|
+
}}
|
|
273
|
+
/>
|
|
274
|
+
) : null;
|
|
275
|
+
|
|
276
|
+
// Story Info owns the CTA when metadata/cover is the gate. There is no
|
|
277
|
+
// dedicated Story Info page yet (#439/§4) and the coach carries no cover
|
|
278
|
+
// action, so this is an informational next-step line (not a route) — still the
|
|
279
|
+
// one and only CTA, placed in the relevant section.
|
|
280
|
+
const storyInfoCta = (
|
|
281
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-accent/5 border border-accent/30 rounded text-xs" data-testid="story-info-cta">
|
|
282
|
+
<span className="rounded-full bg-background px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-accent flex-shrink-0">Story info</span>
|
|
283
|
+
<span className="min-w-0 flex-1 text-foreground"><span className="text-muted">Next: </span><span className="font-medium">{storyInfoNextStep(progress)}</span></span>
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// Return the one CTA for a section, or null — guarantees a single visible CTA.
|
|
288
|
+
const ctaFor = (key: string): ReactNode => {
|
|
289
|
+
if (key !== activeKey) return null;
|
|
290
|
+
return activeKey === "story-info" ? storyInfoCta : coachCta;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const infoItems: ChecklistItem[] = [
|
|
294
|
+
{ label: "Public title", status: m.title ? "done" : "todo", detail: m.title ?? null },
|
|
295
|
+
{ label: "Language", status: m.language ? "done" : "todo", detail: m.language ?? null },
|
|
296
|
+
{ label: "Genre", status: m.genre ? "done" : "todo", detail: m.genre ?? null },
|
|
297
|
+
{ label: "Cover image", status: coverDone ? "done" : "todo", detail: progress.cover === "invalid" ? "Invalid — re-import" : coverDone ? null : "Missing" },
|
|
298
|
+
];
|
|
299
|
+
const infoStatus: SectionStatus = activeKey === "story-info" ? "current" : storyInfoIncomplete ? "needs-action" : "done";
|
|
300
|
+
const whitepaperStatus: SectionStatus = hasStructure ? "done" : activeKey === "whitepaper" ? "current" : "not-started";
|
|
301
|
+
|
|
302
|
+
const genesisEp = progress.episodes.find((e) => e.kind === "genesis") ?? null;
|
|
303
|
+
const plotEps = progress.episodes.filter((e) => e.kind === "plot");
|
|
304
|
+
|
|
305
|
+
let idx = 0;
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div className="h-full overflow-y-auto" data-testid="story-progress-panel">
|
|
309
|
+
<ProgressHeader progress={progress} />
|
|
310
|
+
<p className="px-4 pt-3 pb-1 text-[11px] font-medium text-muted uppercase tracking-wider">Production Progress</p>
|
|
311
|
+
|
|
312
|
+
<Section
|
|
313
|
+
index={++idx}
|
|
314
|
+
title="Define Story Info"
|
|
315
|
+
status={infoStatus}
|
|
316
|
+
items={infoItems}
|
|
317
|
+
cta={ctaFor("story-info") ?? undefined}
|
|
318
|
+
/>
|
|
319
|
+
|
|
320
|
+
<Section
|
|
321
|
+
index={++idx}
|
|
322
|
+
title="Story Whitepaper"
|
|
323
|
+
status={whitepaperStatus}
|
|
324
|
+
fileName="structure.md"
|
|
325
|
+
openFile={hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined}
|
|
326
|
+
items={[{ label: "Planning document", status: hasStructure ? "done" : "todo", detail: hasStructure ? null : "Not written yet" }]}
|
|
327
|
+
cta={ctaFor("whitepaper") ?? undefined}
|
|
328
|
+
/>
|
|
329
|
+
|
|
330
|
+
{/* Genesis / Episode 1 — always shown (a not-started stub before it's
|
|
331
|
+
written), so the "Write the Genesis" CTA always has a home. */}
|
|
332
|
+
{genesisEp ? (
|
|
333
|
+
<EpisodeSection
|
|
334
|
+
index={++idx} ep={genesisEp} isActive={activeKey === genesisEp.file}
|
|
335
|
+
storyName={storyName} onOpenFile={onOpenFile} cta={ctaFor(genesisEp.file) ?? undefined}
|
|
336
|
+
/>
|
|
337
|
+
) : (
|
|
338
|
+
<EpisodeSection
|
|
339
|
+
index={++idx} ep={GENESIS_STUB} isActive={activeKey === "genesis.md"} openingDone={false} canOpen={false}
|
|
340
|
+
storyName={storyName} onOpenFile={onOpenFile} cta={ctaFor("genesis.md") ?? undefined}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
|
|
344
|
+
{plotEps.map((ep) => (
|
|
345
|
+
<EpisodeSection
|
|
346
|
+
key={ep.file} index={++idx} ep={ep} isActive={activeKey === ep.file}
|
|
347
|
+
storyName={storyName} onOpenFile={onOpenFile} cta={ctaFor(ep.file) ?? undefined}
|
|
348
|
+
/>
|
|
349
|
+
))}
|
|
350
|
+
|
|
351
|
+
{/* All episodes published with nothing queued → a trailing "start next" CTA. */}
|
|
352
|
+
{activeKey === null && coachCta && (
|
|
353
|
+
<div className="px-4 py-2.5 border-b border-border" data-testid="workflow-next-episode">
|
|
354
|
+
<div className="ml-1" data-testid="section-cta">{coachCta}</div>
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
<div className="px-4 py-2 text-[11px] text-muted flex flex-wrap gap-x-3" data-testid="progress-summary">
|
|
359
|
+
<span>{progress.summary.published} published</span>
|
|
360
|
+
<span>{progress.summary.readyToPublish} ready</span>
|
|
361
|
+
{progress.summary.placeholders > 0 && <span>{progress.summary.placeholders} not started</span>}
|
|
362
|
+
{progress.summary.blocked > 0 && <span className="text-error">{progress.summary.blocked} need fixes</span>}
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A `progress-episode-<file>` section, kept testid-stable so clicking it opens the file. */
|
|
369
|
+
function EpisodeSection({
|
|
370
|
+
index, ep, isActive, storyName, onOpenFile, cta, openingDone = true, canOpen = true,
|
|
371
|
+
}: {
|
|
372
|
+
index: number;
|
|
373
|
+
ep: EpisodeProgress;
|
|
374
|
+
isActive: boolean;
|
|
375
|
+
storyName: string;
|
|
376
|
+
onOpenFile: (storyName: string, file: string) => void;
|
|
377
|
+
cta?: ReactNode;
|
|
378
|
+
/** Whether the genesis opening text is already written (false for the stub). */
|
|
379
|
+
openingDone?: boolean;
|
|
380
|
+
/** Whether the header navigates to the file (false for the not-yet-written stub). */
|
|
381
|
+
canOpen?: boolean;
|
|
382
|
+
}) {
|
|
383
|
+
const status = episodeStatus(ep, isActive);
|
|
384
|
+
const items = episodeItems(ep, openingDone);
|
|
385
|
+
const title = ep.title ? `${ep.label} · ${ep.title}` : ep.label;
|
|
386
|
+
const heading = (
|
|
387
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
388
|
+
<span className={`flex-shrink-0 ${SECTION_TONE[status]}`} aria-hidden>{SECTION_ICON[status]}</span>
|
|
389
|
+
<span className="text-xs font-medium text-foreground truncate">{index}. {title}</span>
|
|
390
|
+
<span className="text-[10px] text-muted truncate">{ep.file}</span>
|
|
391
|
+
<span className={`ml-auto text-[10px] font-medium ${SECTION_TONE[status]} flex-shrink-0`}>{SECTION_LABEL[status]}</span>
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
return (
|
|
395
|
+
<div className="px-4 py-2.5 border-b border-border" data-testid={`workflow-section-${index}`} data-status={status}>
|
|
396
|
+
{canOpen ? (
|
|
397
|
+
<button
|
|
398
|
+
onClick={() => onOpenFile(storyName, ep.file)}
|
|
399
|
+
data-testid={`progress-episode-${ep.file}`}
|
|
400
|
+
data-state={ep.state}
|
|
401
|
+
className="w-full text-left rounded hover:bg-surface -mx-1 px-1 py-0.5"
|
|
402
|
+
>
|
|
403
|
+
{heading}
|
|
404
|
+
</button>
|
|
405
|
+
) : (
|
|
406
|
+
<div data-state={ep.state}>{heading}</div>
|
|
407
|
+
)}
|
|
408
|
+
<div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
|
|
409
|
+
{items.map((it, i) => <ChecklistRow key={i} item={it} />)}
|
|
410
|
+
</div>
|
|
411
|
+
{cta && <div className="mt-2 ml-1" data-testid="section-cta">{cta}</div>}
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Fiction progress view — the original, simpler layout (unchanged behavior)
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
const STATE_ICON: Record<EpisodeState, string> = {
|
|
421
|
+
published: "✓", ready: "●", "in-progress": "◐", planning: "○", placeholder: "○", blocked: "✕", draft: "○",
|
|
422
|
+
};
|
|
423
|
+
const STATE_TONE: Record<EpisodeState, string> = {
|
|
424
|
+
published: "text-green-700", ready: "text-green-700", "in-progress": "text-accent", planning: "text-accent", placeholder: "text-muted", blocked: "text-error", draft: "text-muted",
|
|
425
|
+
};
|
|
426
|
+
const STATE_LABEL: Record<EpisodeState, string> = {
|
|
427
|
+
published: "Published", ready: "Ready", "in-progress": "In progress", planning: "Planning", placeholder: "Not started", blocked: "Needs fixes", draft: "Draft",
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
function FictionProgressView({
|
|
431
|
+
progress, storyName, onOpenFile,
|
|
432
|
+
}: { progress: StoryProgress; storyName: string; onOpenFile: (storyName: string, file: string) => void }) {
|
|
433
|
+
const [copied, setCopied] = useState(false);
|
|
434
|
+
return (
|
|
435
|
+
<div className="h-full overflow-y-auto" data-testid="story-progress-panel">
|
|
436
|
+
<ProgressHeader progress={progress} />
|
|
437
|
+
|
|
438
|
+
{progress.nextAction && (
|
|
439
|
+
<div className="px-4 py-2 border-b border-accent/30 bg-accent/5 text-xs space-y-1.5" data-testid="progress-next-action">
|
|
440
|
+
<div>
|
|
441
|
+
<span className="font-medium text-foreground">Next: </span>
|
|
442
|
+
<span className="text-muted">{progress.nextAction}</span>
|
|
443
|
+
</div>
|
|
444
|
+
{progress.nextPrompt && (
|
|
445
|
+
<div className="flex items-start gap-1.5" data-testid="progress-next-prompt">
|
|
446
|
+
<code className="flex-1 rounded border border-border bg-surface px-1.5 py-1 text-[10px] text-foreground break-words">{progress.nextPrompt}</code>
|
|
447
|
+
<button
|
|
448
|
+
onClick={() => { if (progress.nextPrompt) navigator.clipboard?.writeText(progress.nextPrompt).then(() => { setCopied(true); }).catch(() => {}); }}
|
|
449
|
+
data-testid="copy-next-prompt"
|
|
450
|
+
className="rounded border border-border px-2 py-1 text-[10px] text-muted hover:border-accent hover:text-accent transition-colors flex-shrink-0"
|
|
451
|
+
>
|
|
452
|
+
{copied ? "Copied!" : "Copy"}
|
|
453
|
+
</button>
|
|
454
|
+
</div>
|
|
455
|
+
)}
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
|
|
459
|
+
{/* Setup steps. */}
|
|
460
|
+
<div className="px-4 py-2 border-b border-border flex flex-col gap-1">
|
|
461
|
+
<StepRow done={progress.setup.hasStructure} label="Story bible (structure.md)"
|
|
462
|
+
onClick={progress.setup.hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined} />
|
|
463
|
+
<StepRow done={progress.setup.hasGenesis} label="Genesis written"
|
|
464
|
+
onClick={progress.setup.hasGenesis ? () => onOpenFile(storyName, "genesis.md") : undefined} />
|
|
465
|
+
</div>
|
|
466
|
+
|
|
467
|
+
{/* Chapter list. */}
|
|
468
|
+
<div className="px-4 py-2">
|
|
469
|
+
<p className="text-[11px] font-medium text-muted uppercase tracking-wider mb-1.5">Chapters</p>
|
|
470
|
+
{progress.episodes.length === 0 ? (
|
|
471
|
+
<p className="text-xs text-muted italic" data-testid="progress-no-episodes">No chapters yet — write the Genesis to start.</p>
|
|
472
|
+
) : (
|
|
473
|
+
<ol className="flex flex-col gap-1">
|
|
474
|
+
{progress.episodes.map((ep) => (
|
|
475
|
+
<li key={ep.file}>
|
|
476
|
+
<button
|
|
477
|
+
onClick={() => onOpenFile(storyName, ep.file)}
|
|
478
|
+
data-testid={`progress-episode-${ep.file}`}
|
|
479
|
+
data-state={ep.state}
|
|
480
|
+
className="w-full text-left flex items-start gap-2 rounded px-2 py-1.5 hover:bg-surface"
|
|
481
|
+
>
|
|
482
|
+
<span className={`mt-0.5 ${STATE_TONE[ep.state]}`} aria-hidden>{STATE_ICON[ep.state]}</span>
|
|
483
|
+
<span className="min-w-0 flex-1">
|
|
484
|
+
<span className="flex items-center gap-1.5">
|
|
485
|
+
<span className="text-xs font-medium text-foreground">{ep.label}</span>
|
|
486
|
+
{ep.title && <span className="text-[11px] text-muted truncate">· {ep.title}</span>}
|
|
487
|
+
<span className={`ml-auto text-[10px] font-medium ${STATE_TONE[ep.state]}`}>{STATE_LABEL[ep.state]}</span>
|
|
488
|
+
</span>
|
|
489
|
+
<span className="block text-[11px] text-muted">{ep.summary}</span>
|
|
490
|
+
</span>
|
|
491
|
+
</button>
|
|
492
|
+
</li>
|
|
493
|
+
))}
|
|
494
|
+
</ol>
|
|
495
|
+
)}
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
<div className="px-4 py-2 border-t border-border text-[11px] text-muted flex flex-wrap gap-x-3" data-testid="progress-summary">
|
|
499
|
+
<span>{progress.summary.published} published</span>
|
|
500
|
+
{progress.summary.blocked > 0 && <span className="text-error">{progress.summary.blocked} need fixes</span>}
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function StepRow({ done, label, onClick }: { done: boolean; label: string; onClick?: () => void }) {
|
|
507
|
+
const inner = (
|
|
508
|
+
<span className="flex items-center gap-2 text-xs">
|
|
509
|
+
<span className={done ? "text-green-700" : "text-muted"} aria-hidden>{done ? "✓" : "○"}</span>
|
|
510
|
+
<span className={done ? "text-foreground" : "text-muted"}>{label}</span>
|
|
511
|
+
</span>
|
|
512
|
+
);
|
|
513
|
+
return onClick
|
|
514
|
+
? <button onClick={onClick} className="text-left hover:underline">{inner}</button>
|
|
515
|
+
: <div>{inner}</div>;
|
|
516
|
+
}
|