plotlink-ows 1.0.33 → 1.2.95
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/active-wallet.ts +260 -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 +813 -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 +8 -1
- 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 +242 -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/dashboard.ts +6 -4
- package/app/routes/publish.ts +259 -45
- package/app/routes/settings.ts +92 -37
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/routes/wallet.ts +58 -30
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonNextAction.tsx +145 -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 +1337 -0
- package/app/web/components/Dashboard.tsx +15 -6
- 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 +1182 -0
- package/app/web/components/PreviewPanel.tsx +952 -78
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +745 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +446 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +156 -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-che5mMWc.js +1 -0
- package/app/web/dist/assets/index-CcfChGEK.css +32 -0
- package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -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-DxATSk7X.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,446 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { StoryProgress, EpisodeProgress, EpisodeState } from "@app-lib/story-progress";
|
|
3
|
+
import type { CartoonChecklistStep } from "@app-lib/cartoon-readiness";
|
|
4
|
+
import { cartoonWorkflowActiveKey, CartoonNextActionView } from "./CartoonNextAction";
|
|
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
|
+
/** Open the Story Info workflow page when metadata/cover is the next gate. */
|
|
12
|
+
onOpenStoryInfo?: () => void;
|
|
13
|
+
/** Bumped by the parent to force a refresh (e.g. after a publish). */
|
|
14
|
+
refreshKey?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Story-level "View Progress" overview (#418, redesigned #438).
|
|
19
|
+
*
|
|
20
|
+
* For CARTOON stories this is the writer's main production dashboard: a vertical
|
|
21
|
+
* workflow map of numbered sections (Define Story Info → Story Whitepaper →
|
|
22
|
+
* Genesis / Episode 1 → Episode 2 …), each with a checkbox checklist and a clear
|
|
23
|
+
* status. The single next-action CTA stays persistent above the map, while the
|
|
24
|
+
* current section still marks where that action belongs.
|
|
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, onOpenStoryInfo, 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} onOpenStoryInfo={onOpenStoryInfo} />
|
|
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, plus a
|
|
132
|
+
* nested checklist. The header navigates to `openFile` when one is provided.
|
|
133
|
+
*/
|
|
134
|
+
function Section({
|
|
135
|
+
index, title, status, items, fileName, openFile,
|
|
136
|
+
}: {
|
|
137
|
+
index: number;
|
|
138
|
+
title: string;
|
|
139
|
+
status: SectionStatus;
|
|
140
|
+
items: ChecklistItem[];
|
|
141
|
+
/** Power-user secondary text (real file name), shown small. */
|
|
142
|
+
fileName?: string | null;
|
|
143
|
+
/** Called to open the section's underlying file, or undefined for no navigation. */
|
|
144
|
+
openFile?: () => void;
|
|
145
|
+
}) {
|
|
146
|
+
const heading = (
|
|
147
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
148
|
+
<span className={`flex-shrink-0 ${SECTION_TONE[status]}`} aria-hidden>{SECTION_ICON[status]}</span>
|
|
149
|
+
<span className="text-xs font-medium text-foreground truncate">{index}. {title}</span>
|
|
150
|
+
{fileName && <span className="text-[10px] text-muted truncate">{fileName}</span>}
|
|
151
|
+
<span className={`ml-auto text-[10px] font-medium ${SECTION_TONE[status]} flex-shrink-0`}>{SECTION_LABEL[status]}</span>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
return (
|
|
155
|
+
<div className="px-4 py-2.5 border-b border-border" data-testid={`workflow-section-${index}`} data-status={status}>
|
|
156
|
+
{openFile
|
|
157
|
+
? <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>
|
|
158
|
+
: heading}
|
|
159
|
+
<div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
|
|
160
|
+
{items.map((it, i) => <ChecklistRow key={i} item={it} />)}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Map a cartoon episode's coarse state + whether it's the active step → a section status. */
|
|
167
|
+
function episodeStatus(ep: EpisodeProgress, isActive: boolean): SectionStatus {
|
|
168
|
+
if (ep.published) return "published";
|
|
169
|
+
if (isActive) return "current";
|
|
170
|
+
if (ep.state === "placeholder") return "not-started";
|
|
171
|
+
if (ep.state === "blocked") return "needs-action";
|
|
172
|
+
return "needs-action";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Build the rendered checklist for a cartoon episode from its production checklist. */
|
|
176
|
+
function episodeItems(ep: EpisodeProgress, openingDone = true): ChecklistItem[] {
|
|
177
|
+
const steps = ep.checklist ?? [];
|
|
178
|
+
const items: ChecklistItem[] = [];
|
|
179
|
+
// Genesis is the reader-facing Episode 1 opening, so surface its opening text
|
|
180
|
+
// as the first checklist line (done once genesis.md is written; to-do in the
|
|
181
|
+
// not-yet-written stub); a plain plot episode starts at its cut plan.
|
|
182
|
+
if (ep.kind === "genesis") items.push({ label: "Opening text", status: openingDone ? "done" : "todo" });
|
|
183
|
+
if (steps.length === 0) {
|
|
184
|
+
// Not started yet — no cut plan. Show the first couple of steps as to-do so
|
|
185
|
+
// the writer sees what starting the episode involves.
|
|
186
|
+
items.push({ label: "Cut plan", status: "todo" });
|
|
187
|
+
items.push({ label: "Clean artwork", status: "todo" });
|
|
188
|
+
return items;
|
|
189
|
+
}
|
|
190
|
+
for (const s of steps) items.push(checklistStepItem(s));
|
|
191
|
+
return items;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function checklistStepItem(s: CartoonChecklistStep): ChecklistItem {
|
|
195
|
+
return { label: s.label, status: s.status, detail: s.detail };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The not-yet-written Genesis (Episode 1) stub, so the section — and its CTA —
|
|
199
|
+
* always render even before genesis.md exists. */
|
|
200
|
+
const GENESIS_STUB: EpisodeProgress = {
|
|
201
|
+
file: "genesis.md", label: "Episode 1 / Genesis", kind: "genesis", title: null,
|
|
202
|
+
state: "placeholder", summary: "", published: false, checklist: [], cuts: null,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
function CartoonWorkflowMap({
|
|
206
|
+
progress, storyName, onOpenFile, onOpenStoryInfo,
|
|
207
|
+
}: {
|
|
208
|
+
progress: StoryProgress;
|
|
209
|
+
storyName: string;
|
|
210
|
+
onOpenFile: (storyName: string, file: string) => void;
|
|
211
|
+
onOpenStoryInfo?: () => void;
|
|
212
|
+
}) {
|
|
213
|
+
const m = progress.metadata;
|
|
214
|
+
const hasStructure = progress.setup.hasStructure;
|
|
215
|
+
const coverDone = progress.cover === "present";
|
|
216
|
+
const metadataIncomplete = !m.title || !m.language || !m.genre;
|
|
217
|
+
const storyInfoIncomplete = metadataIncomplete || !coverDone;
|
|
218
|
+
const activeKey = cartoonWorkflowActiveKey(progress);
|
|
219
|
+
|
|
220
|
+
const topNextAction = (
|
|
221
|
+
<CartoonNextActionView
|
|
222
|
+
progress={progress}
|
|
223
|
+
onOpenStoryInfo={onOpenStoryInfo}
|
|
224
|
+
onCoachAction={(action, episodeFile) => {
|
|
225
|
+
if (action === "view-progress") return; // already here
|
|
226
|
+
if (episodeFile) onOpenFile(storyName, episodeFile);
|
|
227
|
+
}}
|
|
228
|
+
/>
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const infoItems: ChecklistItem[] = [
|
|
232
|
+
{ label: "Public title", status: m.title ? "done" : "todo", detail: m.title ?? null },
|
|
233
|
+
{ label: "Language", status: m.language ? "done" : "todo", detail: m.language ?? null },
|
|
234
|
+
{ label: "Genre", status: m.genre ? "done" : "todo", detail: m.genre ?? null },
|
|
235
|
+
{ label: "Cover image", status: coverDone ? "done" : "todo", detail: progress.cover === "invalid" ? "Invalid — re-import" : coverDone ? null : "Missing" },
|
|
236
|
+
];
|
|
237
|
+
const infoStatus: SectionStatus = activeKey === "story-info" ? "current" : storyInfoIncomplete ? "needs-action" : "done";
|
|
238
|
+
const whitepaperStatus: SectionStatus = hasStructure ? "done" : activeKey === "whitepaper" ? "current" : "not-started";
|
|
239
|
+
|
|
240
|
+
const genesisEp = progress.episodes.find((e) => e.kind === "genesis") ?? null;
|
|
241
|
+
const plotEps = progress.episodes.filter((e) => e.kind === "plot");
|
|
242
|
+
|
|
243
|
+
let idx = 0;
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<div className="h-full overflow-y-auto" data-testid="story-progress-panel">
|
|
247
|
+
<ProgressHeader progress={progress} />
|
|
248
|
+
<div className="border-b border-border" data-testid="persistent-next-action">
|
|
249
|
+
{topNextAction}
|
|
250
|
+
</div>
|
|
251
|
+
<p className="px-4 pt-3 pb-1 text-[11px] font-medium text-muted uppercase tracking-wider">Production Progress</p>
|
|
252
|
+
|
|
253
|
+
<Section
|
|
254
|
+
index={++idx}
|
|
255
|
+
title="Define Story Info"
|
|
256
|
+
status={infoStatus}
|
|
257
|
+
items={infoItems}
|
|
258
|
+
/>
|
|
259
|
+
|
|
260
|
+
<Section
|
|
261
|
+
index={++idx}
|
|
262
|
+
title="Story Whitepaper"
|
|
263
|
+
status={whitepaperStatus}
|
|
264
|
+
fileName="structure.md"
|
|
265
|
+
openFile={hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined}
|
|
266
|
+
items={[{ label: "Planning document", status: hasStructure ? "done" : "todo", detail: hasStructure ? null : "Not written yet" }]}
|
|
267
|
+
/>
|
|
268
|
+
|
|
269
|
+
{/* Genesis / Episode 1 — always shown (a not-started stub before it's
|
|
270
|
+
written), so the "Write the Genesis" CTA always has a home. */}
|
|
271
|
+
{genesisEp ? (
|
|
272
|
+
<EpisodeSection
|
|
273
|
+
index={++idx} ep={genesisEp} isActive={activeKey === genesisEp.file}
|
|
274
|
+
storyName={storyName} onOpenFile={onOpenFile}
|
|
275
|
+
/>
|
|
276
|
+
) : (
|
|
277
|
+
<EpisodeSection
|
|
278
|
+
index={++idx} ep={GENESIS_STUB} isActive={activeKey === "genesis.md"} openingDone={false} canOpen={false}
|
|
279
|
+
storyName={storyName} onOpenFile={onOpenFile}
|
|
280
|
+
/>
|
|
281
|
+
)}
|
|
282
|
+
|
|
283
|
+
{plotEps.map((ep) => (
|
|
284
|
+
<EpisodeSection
|
|
285
|
+
key={ep.file} index={++idx} ep={ep} isActive={activeKey === ep.file}
|
|
286
|
+
storyName={storyName} onOpenFile={onOpenFile}
|
|
287
|
+
/>
|
|
288
|
+
))}
|
|
289
|
+
|
|
290
|
+
<div className="px-4 py-2 text-[11px] text-muted flex flex-wrap gap-x-3" data-testid="progress-summary">
|
|
291
|
+
<span>{progress.summary.published} published</span>
|
|
292
|
+
<span>{progress.summary.readyToPublish} ready</span>
|
|
293
|
+
{progress.summary.placeholders > 0 && <span>{progress.summary.placeholders} not started</span>}
|
|
294
|
+
{progress.summary.blocked > 0 && <span className="text-error">{progress.summary.blocked} need fixes</span>}
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A `progress-episode-<file>` section, kept testid-stable so clicking it opens the file. */
|
|
301
|
+
function EpisodeSection({
|
|
302
|
+
index, ep, isActive, storyName, onOpenFile, openingDone = true, canOpen = true,
|
|
303
|
+
}: {
|
|
304
|
+
index: number;
|
|
305
|
+
ep: EpisodeProgress;
|
|
306
|
+
isActive: boolean;
|
|
307
|
+
storyName: string;
|
|
308
|
+
onOpenFile: (storyName: string, file: string) => void;
|
|
309
|
+
/** Whether the genesis opening text is already written (false for the stub). */
|
|
310
|
+
openingDone?: boolean;
|
|
311
|
+
/** Whether the header navigates to the file (false for the not-yet-written stub). */
|
|
312
|
+
canOpen?: boolean;
|
|
313
|
+
}) {
|
|
314
|
+
const status = episodeStatus(ep, isActive);
|
|
315
|
+
const items = episodeItems(ep, openingDone);
|
|
316
|
+
const title = ep.title ? `${ep.label} · ${ep.title}` : ep.label;
|
|
317
|
+
const heading = (
|
|
318
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
319
|
+
<span className={`flex-shrink-0 ${SECTION_TONE[status]}`} aria-hidden>{SECTION_ICON[status]}</span>
|
|
320
|
+
<span className="text-xs font-medium text-foreground truncate">{index}. {title}</span>
|
|
321
|
+
<span className="text-[10px] text-muted truncate">{ep.file}</span>
|
|
322
|
+
<span className={`ml-auto text-[10px] font-medium ${SECTION_TONE[status]} flex-shrink-0`}>{SECTION_LABEL[status]}</span>
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
return (
|
|
326
|
+
<div className="px-4 py-2.5 border-b border-border" data-testid={`workflow-section-${index}`} data-status={status}>
|
|
327
|
+
{canOpen ? (
|
|
328
|
+
<button
|
|
329
|
+
onClick={() => onOpenFile(storyName, ep.file)}
|
|
330
|
+
data-testid={`progress-episode-${ep.file}`}
|
|
331
|
+
data-state={ep.state}
|
|
332
|
+
className="w-full text-left rounded hover:bg-surface -mx-1 px-1 py-0.5"
|
|
333
|
+
>
|
|
334
|
+
{heading}
|
|
335
|
+
</button>
|
|
336
|
+
) : (
|
|
337
|
+
<div data-state={ep.state}>{heading}</div>
|
|
338
|
+
)}
|
|
339
|
+
<div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
|
|
340
|
+
{items.map((it, i) => <ChecklistRow key={i} item={it} />)}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Fiction progress view — the original, simpler layout (unchanged behavior)
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
const STATE_ICON: Record<EpisodeState, string> = {
|
|
351
|
+
published: "✓", ready: "●", "in-progress": "◐", planning: "○", placeholder: "○", blocked: "✕", draft: "○",
|
|
352
|
+
};
|
|
353
|
+
const STATE_TONE: Record<EpisodeState, string> = {
|
|
354
|
+
published: "text-green-700", ready: "text-green-700", "in-progress": "text-accent", planning: "text-accent", placeholder: "text-muted", blocked: "text-error", draft: "text-muted",
|
|
355
|
+
};
|
|
356
|
+
const STATE_LABEL: Record<EpisodeState, string> = {
|
|
357
|
+
published: "Published", ready: "Ready", "in-progress": "In progress", planning: "Planning", placeholder: "Not started", blocked: "Needs fixes", draft: "Draft",
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
function FictionProgressView({
|
|
361
|
+
progress, storyName, onOpenFile,
|
|
362
|
+
}: { progress: StoryProgress; storyName: string; onOpenFile: (storyName: string, file: string) => void }) {
|
|
363
|
+
const [copied, setCopied] = useState(false);
|
|
364
|
+
return (
|
|
365
|
+
<div className="h-full overflow-y-auto" data-testid="story-progress-panel">
|
|
366
|
+
<ProgressHeader progress={progress} />
|
|
367
|
+
|
|
368
|
+
{progress.nextAction && (
|
|
369
|
+
<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">
|
|
370
|
+
<div>
|
|
371
|
+
<span className="font-medium text-foreground">Next: </span>
|
|
372
|
+
<span className="text-muted">{progress.nextAction}</span>
|
|
373
|
+
</div>
|
|
374
|
+
{progress.nextPrompt && (
|
|
375
|
+
<div className="flex items-start gap-1.5" data-testid="progress-next-prompt">
|
|
376
|
+
<code className="flex-1 rounded border border-border bg-surface px-1.5 py-1 text-[10px] text-foreground break-words">{progress.nextPrompt}</code>
|
|
377
|
+
<button
|
|
378
|
+
onClick={() => { if (progress.nextPrompt) navigator.clipboard?.writeText(progress.nextPrompt).then(() => { setCopied(true); }).catch(() => {}); }}
|
|
379
|
+
data-testid="copy-next-prompt"
|
|
380
|
+
className="rounded border border-border px-2 py-1 text-[10px] text-muted hover:border-accent hover:text-accent transition-colors flex-shrink-0"
|
|
381
|
+
>
|
|
382
|
+
{copied ? "Copied!" : "Copy"}
|
|
383
|
+
</button>
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
|
+
|
|
389
|
+
{/* Setup steps. */}
|
|
390
|
+
<div className="px-4 py-2 border-b border-border flex flex-col gap-1">
|
|
391
|
+
<StepRow done={progress.setup.hasStructure} label="Story bible (structure.md)"
|
|
392
|
+
onClick={progress.setup.hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined} />
|
|
393
|
+
<StepRow done={progress.setup.hasGenesis} label="Genesis written"
|
|
394
|
+
onClick={progress.setup.hasGenesis ? () => onOpenFile(storyName, "genesis.md") : undefined} />
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
{/* Chapter list. */}
|
|
398
|
+
<div className="px-4 py-2">
|
|
399
|
+
<p className="text-[11px] font-medium text-muted uppercase tracking-wider mb-1.5">Chapters</p>
|
|
400
|
+
{progress.episodes.length === 0 ? (
|
|
401
|
+
<p className="text-xs text-muted italic" data-testid="progress-no-episodes">No chapters yet — write the Genesis to start.</p>
|
|
402
|
+
) : (
|
|
403
|
+
<ol className="flex flex-col gap-1">
|
|
404
|
+
{progress.episodes.map((ep) => (
|
|
405
|
+
<li key={ep.file}>
|
|
406
|
+
<button
|
|
407
|
+
onClick={() => onOpenFile(storyName, ep.file)}
|
|
408
|
+
data-testid={`progress-episode-${ep.file}`}
|
|
409
|
+
data-state={ep.state}
|
|
410
|
+
className="w-full text-left flex items-start gap-2 rounded px-2 py-1.5 hover:bg-surface"
|
|
411
|
+
>
|
|
412
|
+
<span className={`mt-0.5 ${STATE_TONE[ep.state]}`} aria-hidden>{STATE_ICON[ep.state]}</span>
|
|
413
|
+
<span className="min-w-0 flex-1">
|
|
414
|
+
<span className="flex items-center gap-1.5">
|
|
415
|
+
<span className="text-xs font-medium text-foreground">{ep.label}</span>
|
|
416
|
+
{ep.title && <span className="text-[11px] text-muted truncate">· {ep.title}</span>}
|
|
417
|
+
<span className={`ml-auto text-[10px] font-medium ${STATE_TONE[ep.state]}`}>{STATE_LABEL[ep.state]}</span>
|
|
418
|
+
</span>
|
|
419
|
+
<span className="block text-[11px] text-muted">{ep.summary}</span>
|
|
420
|
+
</span>
|
|
421
|
+
</button>
|
|
422
|
+
</li>
|
|
423
|
+
))}
|
|
424
|
+
</ol>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
<div className="px-4 py-2 border-t border-border text-[11px] text-muted flex flex-wrap gap-x-3" data-testid="progress-summary">
|
|
429
|
+
<span>{progress.summary.published} published</span>
|
|
430
|
+
{progress.summary.blocked > 0 && <span className="text-error">{progress.summary.blocked} need fixes</span>}
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function StepRow({ done, label, onClick }: { done: boolean; label: string; onClick?: () => void }) {
|
|
437
|
+
const inner = (
|
|
438
|
+
<span className="flex items-center gap-2 text-xs">
|
|
439
|
+
<span className={done ? "text-green-700" : "text-muted"} aria-hidden>{done ? "✓" : "○"}</span>
|
|
440
|
+
<span className={done ? "text-foreground" : "text-muted"}>{label}</span>
|
|
441
|
+
</span>
|
|
442
|
+
);
|
|
443
|
+
return onClick
|
|
444
|
+
? <button onClick={onClick} className="text-left hover:underline">{inner}</button>
|
|
445
|
+
: <div>{inner}</div>;
|
|
446
|
+
}
|