plotlink-ows 1.0.33 → 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 +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 +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 +203 -22
- 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 +951 -78
- 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-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,267 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { AssetImage } from "./asset-image";
|
|
3
|
+
import { cutNextAction } from "@app-lib/cuts";
|
|
4
|
+
|
|
5
|
+
type AuthFetch = (url: string, opts?: RequestInit) => Promise<Response>;
|
|
6
|
+
|
|
7
|
+
interface CutDialogue {
|
|
8
|
+
speaker: string;
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Cut {
|
|
13
|
+
id: number;
|
|
14
|
+
shotType: string;
|
|
15
|
+
description: string;
|
|
16
|
+
characters: string[];
|
|
17
|
+
dialogue: CutDialogue[];
|
|
18
|
+
narration: string;
|
|
19
|
+
sfx: string;
|
|
20
|
+
cleanImagePath: string | null;
|
|
21
|
+
finalImagePath: string | null;
|
|
22
|
+
exportedAt: string | null;
|
|
23
|
+
uploadedCid: string | null;
|
|
24
|
+
uploadedUrl: string | null;
|
|
25
|
+
kind?: "image" | "text";
|
|
26
|
+
background?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CutsFile {
|
|
30
|
+
version: number;
|
|
31
|
+
plotFile: string;
|
|
32
|
+
cuts: Cut[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CartoonPreviewProps {
|
|
36
|
+
storyName: string;
|
|
37
|
+
fileName: string;
|
|
38
|
+
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
39
|
+
// #371: deep-link from a cut's next-action CTA into the Edit tab for that exact
|
|
40
|
+
// cut. `opensEditor` is whether the lettering editor can open directly (clean
|
|
41
|
+
// image / text panel / final) vs. just focusing the row to add clean art.
|
|
42
|
+
onEditCut?: (cutId: number, opensEditor: boolean) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function TextOverlay({ cut }: { cut: Cut }) {
|
|
46
|
+
const hasText = cut.dialogue.length > 0 || cut.narration || cut.sfx;
|
|
47
|
+
if (!hasText) return null;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="space-y-1.5" data-testid={`cut-${cut.id}-overlay`}>
|
|
51
|
+
{cut.dialogue.map((d, i) => (
|
|
52
|
+
<div key={i} className="flex gap-2 text-xs">
|
|
53
|
+
<span className="font-medium text-foreground flex-shrink-0">{d.speaker}:</span>
|
|
54
|
+
<span className="text-foreground">{d.text}</span>
|
|
55
|
+
</div>
|
|
56
|
+
))}
|
|
57
|
+
{cut.narration && (
|
|
58
|
+
<div className="border-l-2 border-border pl-3">
|
|
59
|
+
<p className="text-xs text-muted italic">{cut.narration}</p>
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
{cut.sfx && (
|
|
63
|
+
<p className="text-xs font-mono text-muted">SFX: {cut.sfx}</p>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function CutCard({ cut, storyName, authFetch, onEditCut }: { cut: Cut; storyName: string; authFetch: AuthFetch; onEditCut?: (cutId: number, opensEditor: boolean) => void }) {
|
|
70
|
+
const hasFinal = !!cut.finalImagePath;
|
|
71
|
+
const hasClean = !!cut.cleanImagePath;
|
|
72
|
+
const hasImage = hasFinal || hasClean;
|
|
73
|
+
// A cut with no clean/final image is a planned image cut whose art is still
|
|
74
|
+
// pending — even if narration/dialogue text already exists in cuts.json. It is
|
|
75
|
+
// NOT a finished narration-only card.
|
|
76
|
+
const hasPlannedText = cut.dialogue.length > 0 || !!cut.narration || !!cut.sfx;
|
|
77
|
+
const isTextPanel = cut.kind === "text";
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="space-y-2">
|
|
81
|
+
{/* Cut header */}
|
|
82
|
+
<div className="flex items-center gap-2">
|
|
83
|
+
<span className="text-[10px] font-mono text-muted bg-surface border border-border rounded px-1.5 py-0.5">
|
|
84
|
+
#{cut.id}
|
|
85
|
+
</span>
|
|
86
|
+
<span className="text-[10px] font-mono text-muted">{cut.shotType}</span>
|
|
87
|
+
{cut.characters.length > 0 && (
|
|
88
|
+
<span className="text-[10px] text-muted truncate">
|
|
89
|
+
{cut.characters.join(", ")}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Final image — lettered, no overlay needed */}
|
|
95
|
+
{hasFinal && (
|
|
96
|
+
<AssetImage
|
|
97
|
+
storyName={storyName}
|
|
98
|
+
assetPath={cut.finalImagePath!}
|
|
99
|
+
authFetch={authFetch}
|
|
100
|
+
alt={cut.description || `Cut ${cut.id}`}
|
|
101
|
+
/>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{/* Clean image with text overlay */}
|
|
105
|
+
{!hasFinal && hasClean && (
|
|
106
|
+
<div className="border border-border rounded overflow-hidden">
|
|
107
|
+
<AssetImage
|
|
108
|
+
storyName={storyName}
|
|
109
|
+
assetPath={cut.cleanImagePath!}
|
|
110
|
+
authFetch={authFetch}
|
|
111
|
+
alt={cut.description || `Cut ${cut.id}`}
|
|
112
|
+
/>
|
|
113
|
+
<div className="px-3 py-2 bg-surface/80 border-t border-border">
|
|
114
|
+
<TextOverlay cut={cut} />
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{/* Intentional text/interstitial panel (#351) — not pending art. Shows on
|
|
120
|
+
its styled background; the text is the panel content, not a caption. */}
|
|
121
|
+
{!hasImage && isTextPanel && (
|
|
122
|
+
<div
|
|
123
|
+
className="w-full border border-border rounded p-4 space-y-2"
|
|
124
|
+
style={{ background: cut.background || undefined }}
|
|
125
|
+
data-testid={`cut-${cut.id}-textpanel`}
|
|
126
|
+
>
|
|
127
|
+
<span className="text-[10px] font-mono text-muted">Text panel</span>
|
|
128
|
+
{hasPlannedText ? (
|
|
129
|
+
<TextOverlay cut={cut} />
|
|
130
|
+
) : (
|
|
131
|
+
<p className="text-xs text-muted italic">Empty text panel — open the editor to add text.</p>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{/* Planned image cut — art not generated/uploaded yet */}
|
|
137
|
+
{!hasImage && !isTextPanel && (
|
|
138
|
+
<div
|
|
139
|
+
className="w-full bg-surface border border-dashed border-border rounded p-4 space-y-2"
|
|
140
|
+
data-testid={`cut-${cut.id}-pending`}
|
|
141
|
+
>
|
|
142
|
+
<div className="aspect-video flex flex-col items-center justify-center gap-1 text-center">
|
|
143
|
+
<span className="text-xs text-muted font-medium">Image pending</span>
|
|
144
|
+
<span className="text-[10px] text-muted">Planned image cut — generate & upload the art</span>
|
|
145
|
+
</div>
|
|
146
|
+
{hasPlannedText && (
|
|
147
|
+
<div className="border-t border-dashed border-border pt-2 space-y-1">
|
|
148
|
+
<span className="text-[10px] font-mono text-muted">Planned text (will be lettered onto the image)</span>
|
|
149
|
+
<TextOverlay cut={cut} />
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{/* Description */}
|
|
156
|
+
{cut.description && (
|
|
157
|
+
<p className="text-xs text-muted italic">{cut.description}</p>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{/* Text shown below final images (already lettered, so just metadata) */}
|
|
161
|
+
{hasFinal && (
|
|
162
|
+
<TextOverlay cut={cut} />
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
{/* #371: direct next-action CTA — jumps to the Edit tab for THIS cut so the
|
|
166
|
+
writer never has to hunt for it in the cut list. Works for image cuts
|
|
167
|
+
and text/interstitial panels alike. */}
|
|
168
|
+
{onEditCut && (() => {
|
|
169
|
+
const action = cutNextAction(cut);
|
|
170
|
+
return (
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
data-testid={`cut-${cut.id}-cta`}
|
|
174
|
+
data-cut-action={action.key}
|
|
175
|
+
onClick={() => onEditCut(cut.id, action.opensEditor)}
|
|
176
|
+
className="w-full px-3 py-1.5 text-xs font-medium rounded bg-accent text-white hover:bg-accent-dim"
|
|
177
|
+
>
|
|
178
|
+
{action.label}
|
|
179
|
+
</button>
|
|
180
|
+
);
|
|
181
|
+
})()}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function CartoonPreview({ storyName, fileName, authFetch, onEditCut }: CartoonPreviewProps) {
|
|
187
|
+
const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
|
|
188
|
+
const [loading, setLoading] = useState(true);
|
|
189
|
+
const [error, setError] = useState<string | null>(null);
|
|
190
|
+
|
|
191
|
+
const plotFile = fileName.replace(/\.md$/, "");
|
|
192
|
+
|
|
193
|
+
const loadCuts = useCallback(async () => {
|
|
194
|
+
setLoading(true);
|
|
195
|
+
setError(null);
|
|
196
|
+
try {
|
|
197
|
+
const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`);
|
|
198
|
+
if (res.status === 404) {
|
|
199
|
+
setCutsFile(null);
|
|
200
|
+
setLoading(false);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
const data = await res.json();
|
|
205
|
+
setError(data.error || "Failed to load cuts");
|
|
206
|
+
setLoading(false);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const data = await res.json();
|
|
210
|
+
setCutsFile(data);
|
|
211
|
+
} catch {
|
|
212
|
+
setError("Failed to load cuts");
|
|
213
|
+
} finally {
|
|
214
|
+
setLoading(false);
|
|
215
|
+
}
|
|
216
|
+
}, [authFetch, storyName, plotFile]);
|
|
217
|
+
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
loadCuts();
|
|
220
|
+
const interval = setInterval(loadCuts, 5000);
|
|
221
|
+
return () => clearInterval(interval);
|
|
222
|
+
}, [loadCuts]);
|
|
223
|
+
|
|
224
|
+
if (loading && !cutsFile) {
|
|
225
|
+
return (
|
|
226
|
+
<div className="h-full flex items-center justify-center text-muted text-sm">
|
|
227
|
+
Loading cuts...
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (error) {
|
|
233
|
+
return (
|
|
234
|
+
<div className="h-full flex flex-col items-center justify-center gap-2 px-4 text-center" data-testid="cuts-error">
|
|
235
|
+
<p className="text-sm text-error font-medium">Invalid cuts file</p>
|
|
236
|
+
<p className="text-xs text-error">{error}</p>
|
|
237
|
+
<p className="text-xs text-muted max-w-sm">
|
|
238
|
+
{plotFile}.cuts.json must follow the OWS v1 schema. Ask Claude to regenerate it using the v1 cuts schema shown in the cartoon writing instructions.
|
|
239
|
+
</p>
|
|
240
|
+
<button onClick={loadCuts} className="text-xs text-accent hover:text-accent-dim">
|
|
241
|
+
Retry
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!cutsFile || cutsFile.cuts.length === 0) {
|
|
248
|
+
return (
|
|
249
|
+
<div className="h-full flex flex-col items-center justify-center gap-2 px-4 text-center">
|
|
250
|
+
<p className="text-sm text-muted">No cuts yet</p>
|
|
251
|
+
<p className="text-xs text-muted">
|
|
252
|
+
Ask Claude to create a cut plan for this episode.
|
|
253
|
+
</p>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div className="h-full overflow-y-auto">
|
|
260
|
+
<div className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
|
261
|
+
{cutsFile.cuts.map((cut) => (
|
|
262
|
+
<CutCard key={cut.id} cut={cut} storyName={storyName} authFetch={authFetch} onEditCut={onEditCut} />
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { StoryProgress, EpisodeProgress } from "@app-lib/story-progress";
|
|
3
|
+
import { cartoonGenesisReadiness, classifyCartoonReadiness, groupCartoonIssues } from "@app-lib/cartoon-readiness";
|
|
4
|
+
import type { Cut } from "@app-lib/cuts";
|
|
5
|
+
import { derivePublishTitle, isRawFilenameTitle, hasExplicitEpisodeTitle } from "../lib/publish-helpers";
|
|
6
|
+
|
|
7
|
+
interface CartoonPublishPageProps {
|
|
8
|
+
storyName: string;
|
|
9
|
+
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
10
|
+
/** Open the episode's cut workspace to finish production (letter / export / upload). */
|
|
11
|
+
onOpenFile: (storyName: string, file: string) => void;
|
|
12
|
+
/** Switch to the Story Info page (to add a missing cover / set genre+language). */
|
|
13
|
+
onOpenStoryInfo: () => void;
|
|
14
|
+
/** Trigger the on-chain publish for the active episode (same flow the episode used
|
|
15
|
+
* to host). The page loads the imported cover for Genesis and hands it through. */
|
|
16
|
+
onPublish?: (storyName: string, file: string, genre: string, language: string, isNsfw: boolean, coverFile?: File | null) => void | Promise<boolean | void>;
|
|
17
|
+
/** The file currently mid-publish (disables the button + shows progress). */
|
|
18
|
+
publishingFile?: string | null;
|
|
19
|
+
/** Story metadata from Story Info — Genesis can't publish without genre+language. */
|
|
20
|
+
genre?: string;
|
|
21
|
+
language?: string;
|
|
22
|
+
isNsfw?: boolean;
|
|
23
|
+
refreshKey?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type CheckState = "done" | "todo";
|
|
27
|
+
interface PublishCheck { label: string; status: CheckState; detail?: string | null }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Dedicated cartoon "Publish" workflow page (#449, spec §10).
|
|
31
|
+
*
|
|
32
|
+
* The Publish nav tab opens THIS page (the nav stays on Publish) instead of
|
|
33
|
+
* visually routing to the Genesis file view. It consolidates the finalization
|
|
34
|
+
* prerequisites for the active episode — opening text, cut plan, converted clean
|
|
35
|
+
* images, lettering, exported + uploaded finals, cover, and the on-chain publish
|
|
36
|
+
* — into one readiness summary, then launches the existing publish/finish
|
|
37
|
+
* controls in the episode (so the cover handling, preflight, and SSE publish flow
|
|
38
|
+
* are unchanged). Raw validator lines stay collapsed under technical details.
|
|
39
|
+
*
|
|
40
|
+
* Cartoon-only: mounted from the cartoon workflow nav, so fiction publish is
|
|
41
|
+
* untouched.
|
|
42
|
+
*/
|
|
43
|
+
export function CartoonPublishPage({ storyName, authFetch, onOpenFile, onOpenStoryInfo, onPublish, publishingFile, genre, language, isNsfw, refreshKey = 0 }: CartoonPublishPageProps) {
|
|
44
|
+
const [progress, setProgress] = useState<StoryProgress | null>(null);
|
|
45
|
+
const [loading, setLoading] = useState(true);
|
|
46
|
+
const [loadError, setLoadError] = useState(false);
|
|
47
|
+
const [publishError, setPublishError] = useState<string | null>(null);
|
|
48
|
+
// Diagnostics inputs for the active episode (#461): the migrated publish-title,
|
|
49
|
+
// genesis-readiness, and grouped-issues panels that used to live in the episode
|
|
50
|
+
// view. The episode's markdown content + cut plan + (genesis) structure.md drive
|
|
51
|
+
// the same pure helpers PreviewPanel used, so the diagnostics read identically.
|
|
52
|
+
const [activeContent, setActiveContent] = useState<string | null>(null);
|
|
53
|
+
const [activeCuts, setActiveCuts] = useState<Cut[] | null>(null);
|
|
54
|
+
const [activeEpisodeTitle, setActiveEpisodeTitle] = useState<string | null>(null);
|
|
55
|
+
const [structureContent, setStructureContent] = useState<string | null>(null);
|
|
56
|
+
|
|
57
|
+
// Load the imported Genesis cover (assets/cover.webp) as a File so the publish
|
|
58
|
+
// flow attaches it on createStoryline — the same auto-detect the episode used to
|
|
59
|
+
// run. Best-effort: a missing/invalid cover just publishes without one (#461).
|
|
60
|
+
const loadCoverFile = async (): Promise<File | null> => {
|
|
61
|
+
try {
|
|
62
|
+
const res = await authFetch(`/api/stories/${storyName}/cover-asset`);
|
|
63
|
+
const data = res.ok ? await res.json() : null;
|
|
64
|
+
if (!data?.found || !data.valid || !data.path) return null;
|
|
65
|
+
const assetRes = await authFetch(`/api/stories/${storyName}/asset/${String(data.path).replace(/^assets\//, "")}`);
|
|
66
|
+
if (!assetRes.ok) return null;
|
|
67
|
+
const blob = await assetRes.blob();
|
|
68
|
+
return new File([blob], String(data.path).split("/").pop() || "cover.webp", { type: data.type || blob.type });
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
let cancelled = false;
|
|
76
|
+
const load = async () => {
|
|
77
|
+
setLoading(true);
|
|
78
|
+
setLoadError(false);
|
|
79
|
+
try {
|
|
80
|
+
const res = await authFetch(`/api/stories/${storyName}/progress`);
|
|
81
|
+
const data = res.ok ? await res.json() : null;
|
|
82
|
+
if (cancelled) return;
|
|
83
|
+
if (!data || !Array.isArray(data.episodes)) { setLoadError(true); setProgress(null); }
|
|
84
|
+
else setProgress(data);
|
|
85
|
+
setLoading(false);
|
|
86
|
+
} catch {
|
|
87
|
+
if (!cancelled) { setLoadError(true); setProgress(null); setLoading(false); }
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
load();
|
|
91
|
+
return () => { cancelled = true; };
|
|
92
|
+
}, [storyName, authFetch, refreshKey]);
|
|
93
|
+
|
|
94
|
+
// The active episode file to diagnose: first unpublished (Genesis first).
|
|
95
|
+
const activeFile = progress?.episodes?.find((e) => !e.published)?.file ?? null;
|
|
96
|
+
const activeIsGenesis = activeFile === "genesis.md";
|
|
97
|
+
|
|
98
|
+
// Reset the per-episode diagnostics state DURING RENDER whenever the active
|
|
99
|
+
// episode (or refresh) changes, so a stale episode's content/cuts/structure
|
|
100
|
+
// never leak beside another and the publish gate doesn't read prior data while
|
|
101
|
+
// the new fetch is in flight (#461). Reset-during-render (via a loaded-key
|
|
102
|
+
// useState, mirroring WorkflowCoach) avoids the setState-in-effect cascade the
|
|
103
|
+
// ESLint rule flags. The effect below only performs the async fetch + assigns.
|
|
104
|
+
const diagKey = JSON.stringify([activeFile ?? "", refreshKey]);
|
|
105
|
+
const [loadedDiagKey, setLoadedDiagKey] = useState<string | null>(null);
|
|
106
|
+
if (loadedDiagKey !== diagKey) {
|
|
107
|
+
setLoadedDiagKey(diagKey);
|
|
108
|
+
setActiveContent(null);
|
|
109
|
+
setActiveCuts(null);
|
|
110
|
+
setActiveEpisodeTitle(null);
|
|
111
|
+
setStructureContent(null);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Fetch the active episode's markdown + cut plan (+ structure.md for Genesis)
|
|
115
|
+
// so the migrated diagnostics can recompute with the same helpers PreviewPanel
|
|
116
|
+
// used (#461). Best-effort: missing cuts (404) ⇒ null.
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!activeFile) return;
|
|
119
|
+
let cancelled = false;
|
|
120
|
+
const plotKey = activeFile.replace(/\.md$/, "");
|
|
121
|
+
(async () => {
|
|
122
|
+
try {
|
|
123
|
+
const reqs: Promise<Response>[] = [
|
|
124
|
+
authFetch(`/api/stories/${storyName}/${activeFile}`),
|
|
125
|
+
authFetch(`/api/stories/${storyName}/cuts/${plotKey}`),
|
|
126
|
+
];
|
|
127
|
+
if (activeIsGenesis) reqs.push(authFetch(`/api/stories/${storyName}/structure.md`));
|
|
128
|
+
const [fileRes, cutsRes, structRes] = await Promise.all(reqs);
|
|
129
|
+
if (cancelled) return;
|
|
130
|
+
setActiveContent(fileRes.ok ? (await fileRes.json()).content ?? "" : "");
|
|
131
|
+
if (cutsRes.ok) {
|
|
132
|
+
const cutsData = await cutsRes.json();
|
|
133
|
+
if (cancelled) return;
|
|
134
|
+
setActiveCuts(Array.isArray(cutsData.cuts) ? cutsData.cuts : []);
|
|
135
|
+
setActiveEpisodeTitle(typeof cutsData.title === "string" ? cutsData.title : null);
|
|
136
|
+
} else {
|
|
137
|
+
setActiveCuts(null);
|
|
138
|
+
setActiveEpisodeTitle(null);
|
|
139
|
+
}
|
|
140
|
+
if (activeIsGenesis && structRes) {
|
|
141
|
+
setStructureContent(structRes.ok ? (await structRes.json())?.content ?? null : null);
|
|
142
|
+
} else {
|
|
143
|
+
setStructureContent(null);
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
if (!cancelled) { setActiveContent(""); setActiveCuts(null); setActiveEpisodeTitle(null); setStructureContent(null); }
|
|
147
|
+
}
|
|
148
|
+
})();
|
|
149
|
+
return () => { cancelled = true; };
|
|
150
|
+
}, [activeFile, activeIsGenesis, storyName, authFetch, refreshKey]);
|
|
151
|
+
|
|
152
|
+
if (loading) {
|
|
153
|
+
return <div className="h-full flex items-center justify-center text-muted text-sm" data-testid="publish-page-loading">Loading publish readiness…</div>;
|
|
154
|
+
}
|
|
155
|
+
if (loadError || !progress) {
|
|
156
|
+
return <div className="h-full flex items-center justify-center text-muted text-sm">Could not load publish readiness.</div>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The active episode to finalize: the first unpublished one (Genesis first).
|
|
160
|
+
const active: EpisodeProgress | undefined = progress.episodes.find((e) => !e.published);
|
|
161
|
+
|
|
162
|
+
if (!active) {
|
|
163
|
+
return (
|
|
164
|
+
<div className="h-full overflow-y-auto px-4 py-4" data-testid="cartoon-publish-page">
|
|
165
|
+
<h2 className="text-base font-serif text-foreground">Publish</h2>
|
|
166
|
+
<p className="mt-2 text-xs text-green-700" data-testid="publish-all-done">
|
|
167
|
+
{progress.episodes.length > 0
|
|
168
|
+
? "All episodes are published to PlotLink. Plan the next episode to continue."
|
|
169
|
+
: "No episodes yet — write the Genesis (Episode 1) to begin."}
|
|
170
|
+
</p>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const c = active.cuts;
|
|
176
|
+
const coverDone = progress.cover === "present";
|
|
177
|
+
const checks: PublishCheck[] = [
|
|
178
|
+
{ label: "Opening text ready", status: "done" }, // the episode exists once it appears here
|
|
179
|
+
{ label: "Cut plan", status: c && c.total > 0 ? "done" : "todo", detail: c ? `${c.total} cut${c.total === 1 ? "" : "s"} planned` : "not started" },
|
|
180
|
+
{ label: "Clean images converted", status: c && c.needClean > 0 && c.withClean === c.needClean ? "done" : "todo", detail: c ? `${c.withClean} / ${c.needClean}` : null },
|
|
181
|
+
{ label: "Cuts lettered", status: c && c.needClean > 0 && c.withText === c.needClean ? "done" : "todo", detail: c ? `${c.withText} / ${c.needClean}` : null },
|
|
182
|
+
{ label: "Final images exported", status: c && c.total > 0 && c.exported === c.total ? "done" : "todo", detail: c ? `${c.exported} / ${c.total}` : null },
|
|
183
|
+
{ label: "Final images uploaded", status: c && c.total > 0 && c.uploaded === c.total ? "done" : "todo", detail: c ? `${c.uploaded} / ${c.total}` : null },
|
|
184
|
+
{ label: "Cover image", status: coverDone ? "done" : "todo", detail: coverDone ? null : "recommended before publishing" },
|
|
185
|
+
{ label: "Publish to PlotLink", status: active.published ? "done" : "todo" },
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
const ready = active.state === "ready";
|
|
189
|
+
const blocked = active.state === "blocked";
|
|
190
|
+
// Genesis publishes via createStoryline and needs genre+language (set in Story
|
|
191
|
+
// Info); plots inherit the storyline, so they don't.
|
|
192
|
+
const isGenesisActive = active.file === "genesis.md";
|
|
193
|
+
const metaReady = !isGenesisActive || (!!genre && !!language);
|
|
194
|
+
const isPublishing = !!publishingFile && publishingFile === active.file;
|
|
195
|
+
|
|
196
|
+
// ── Migrated episode diagnostics (#461) ──────────────────────────────────
|
|
197
|
+
// The same pure helpers PreviewPanel used, now driven by the active episode's
|
|
198
|
+
// fetched markdown/cuts/structure. Computed only once the content is loaded so
|
|
199
|
+
// a still-loading panel doesn't flash a false "raw title" block.
|
|
200
|
+
const diagLoaded = activeContent !== null;
|
|
201
|
+
// #358: the exact public title this episode will publish with, plus its block
|
|
202
|
+
// states (raw filename, or — for plots — only a generic "Episode NN" fallback).
|
|
203
|
+
const resolvedPublishTitle = diagLoaded
|
|
204
|
+
? derivePublishTitle({
|
|
205
|
+
fileName: active.file,
|
|
206
|
+
fileContent: activeContent ?? "",
|
|
207
|
+
storySlug: storyName,
|
|
208
|
+
structureContent,
|
|
209
|
+
contentType: "cartoon",
|
|
210
|
+
episodeTitle: activeEpisodeTitle,
|
|
211
|
+
})
|
|
212
|
+
: null;
|
|
213
|
+
const rawTitleBlocked = !!resolvedPublishTitle && isRawFilenameTitle(resolvedPublishTitle, active.file);
|
|
214
|
+
const episodeTitleMissing = !isGenesisActive && diagLoaded
|
|
215
|
+
&& !hasExplicitEpisodeTitle({ fileContent: activeContent ?? "", episodeTitle: activeEpisodeTitle });
|
|
216
|
+
const titleBlocked = rawTitleBlocked || episodeTitleMissing;
|
|
217
|
+
// #359: cartoon Genesis prologue readiness (blockers disable publish).
|
|
218
|
+
const genesisReadiness = isGenesisActive && diagLoaded ? cartoonGenesisReadiness(activeContent ?? "") : null;
|
|
219
|
+
const genesisBlocked = !!genesisReadiness && genesisReadiness.blockers.length > 0;
|
|
220
|
+
// #360: grouped publish-readiness issues for a blocked plot (shown only when
|
|
221
|
+
// there are issues).
|
|
222
|
+
const readinessReport = !isGenesisActive && diagLoaded && activeCuts !== null
|
|
223
|
+
? classifyCartoonReadiness(activeContent ?? "", activeCuts)
|
|
224
|
+
: null;
|
|
225
|
+
const cartoonIssues = readinessReport && readinessReport.stage === "error" ? readinessReport.issues : [];
|
|
226
|
+
|
|
227
|
+
// The diagnostics also gate publish (mirror PreviewPanel's titleBlocked /
|
|
228
|
+
// genesisBlocked), so a raw title / weak Genesis can't publish from here either.
|
|
229
|
+
// Require the diagnostics to have LOADED first (#461, re1): until the episode
|
|
230
|
+
// content (+ cut plan for plots) is fetched, titleBlocked/genesisBlocked are
|
|
231
|
+
// both false, so a ready episode with metadata could otherwise publish in the
|
|
232
|
+
// load window before the raw-title / weak-Genesis checks have run.
|
|
233
|
+
const diagReady = diagLoaded && (isGenesisActive || activeCuts !== null);
|
|
234
|
+
const canPublish = ready && metaReady && diagReady && !titleBlocked && !genesisBlocked && !isPublishing && !!onPublish;
|
|
235
|
+
|
|
236
|
+
const handlePublish = async () => {
|
|
237
|
+
if (!canPublish || !onPublish) return;
|
|
238
|
+
setPublishError(null);
|
|
239
|
+
try {
|
|
240
|
+
const cover = isGenesisActive ? await loadCoverFile() : null;
|
|
241
|
+
await onPublish(storyName, active.file, genre ?? "", language ?? "", !!isNsfw, cover);
|
|
242
|
+
} catch {
|
|
243
|
+
setPublishError("Publish could not be started. Please try again.");
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<div className="h-full overflow-y-auto px-4 py-4" data-testid="cartoon-publish-page">
|
|
249
|
+
<h2 className="text-base font-serif text-foreground">Publish {active.label}</h2>
|
|
250
|
+
<p className="mt-0.5 text-[11px] text-muted">Finalize this episode: convert, letter, export, upload, then publish to PlotLink.</p>
|
|
251
|
+
|
|
252
|
+
<ul className="mt-3 flex flex-col gap-1.5 max-w-xl" data-testid="publish-checklist">
|
|
253
|
+
{checks.map((ck, i) => (
|
|
254
|
+
<li key={i} className="flex items-baseline gap-2 text-xs" data-testid="publish-check" data-status={ck.status}>
|
|
255
|
+
<span className={`flex-shrink-0 ${ck.status === "done" ? "text-green-700" : "text-muted"}`} aria-hidden>{ck.status === "done" ? "✓" : "○"}</span>
|
|
256
|
+
<span className={ck.status === "done" ? "text-foreground" : "text-muted"}>{ck.label}</span>
|
|
257
|
+
{ck.detail && <span className="text-muted">· {ck.detail}</span>}
|
|
258
|
+
</li>
|
|
259
|
+
))}
|
|
260
|
+
</ul>
|
|
261
|
+
|
|
262
|
+
{/* Migrated episode diagnostics (#461): the publish title (#358), Genesis
|
|
263
|
+
prologue readiness (#359), and grouped publish issues (#360) that used
|
|
264
|
+
to render in the episode action bar — same helpers, same data-testids. */}
|
|
265
|
+
{resolvedPublishTitle && (
|
|
266
|
+
<div
|
|
267
|
+
className="mt-4 flex flex-col gap-0.5 max-w-xl"
|
|
268
|
+
data-testid="publish-title-preview"
|
|
269
|
+
data-raw={rawTitleBlocked ? "true" : "false"}
|
|
270
|
+
data-blocked={titleBlocked ? "true" : "false"}
|
|
271
|
+
>
|
|
272
|
+
<span className="text-[11px] text-foreground">
|
|
273
|
+
<span className="font-medium">{isGenesisActive ? "Story title" : "Episode title"}:</span>{" "}
|
|
274
|
+
<span className={titleBlocked ? "text-error font-medium" : "text-foreground"}>{resolvedPublishTitle}</span>
|
|
275
|
+
</span>
|
|
276
|
+
{rawTitleBlocked ? (
|
|
277
|
+
<span className="text-[10px] text-error" data-testid="publish-title-raw-error">
|
|
278
|
+
This would publish as a raw filename. {isGenesisActive
|
|
279
|
+
? "Add a real “# Title” heading to genesis.md"
|
|
280
|
+
: "Set a title in the cut plan (or add a “# Title” to the episode)"} before publishing.
|
|
281
|
+
</span>
|
|
282
|
+
) : episodeTitleMissing ? (
|
|
283
|
+
<span className="text-[10px] text-error" data-testid="publish-title-episode-required">
|
|
284
|
+
“{resolvedPublishTitle}” is a generic placeholder, not a reader-facing title, so it can’t be published. Set a real episode title in the cut plan (or add a “# Title” to the episode) — e.g. “Episode 01 — The Couple Coupon” — before publishing.
|
|
285
|
+
</span>
|
|
286
|
+
) : null}
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{genesisReadiness && (
|
|
291
|
+
<div
|
|
292
|
+
className="mt-4 flex flex-col gap-1 rounded border border-border bg-surface/50 p-2 max-w-xl"
|
|
293
|
+
data-testid="cartoon-genesis-readiness"
|
|
294
|
+
data-blocked={genesisBlocked ? "true" : "false"}
|
|
295
|
+
>
|
|
296
|
+
<span className="text-[11px] font-medium text-foreground">Story opening (Prologue)</span>
|
|
297
|
+
<span className="text-[10px] text-muted" data-testid="genesis-readiness-hint">
|
|
298
|
+
Genesis is the first thing readers see. Write it as the story opening/prologue, not a synopsis — set up the premise and stakes, then bridge into Episode 01.
|
|
299
|
+
</span>
|
|
300
|
+
{genesisReadiness.blockers.map((b, i) => (
|
|
301
|
+
<span key={`b-${i}`} className="text-[10px] text-error" data-testid="genesis-readiness-blocker">{b}</span>
|
|
302
|
+
))}
|
|
303
|
+
{genesisReadiness.warnings.map((w, i) => (
|
|
304
|
+
<span key={`w-${i}`} className="text-[10px] text-amber-600" data-testid="genesis-readiness-warning">{w}</span>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
|
|
309
|
+
{cartoonIssues.length > 0 && (
|
|
310
|
+
<div
|
|
311
|
+
className="mt-4 flex flex-col gap-2 rounded-xl border border-error/30 bg-error/5 px-3 py-3 max-w-xl"
|
|
312
|
+
data-testid="cartoon-publish-issues"
|
|
313
|
+
>
|
|
314
|
+
<div className="flex items-center gap-2">
|
|
315
|
+
<span className="rounded-full bg-error px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-white">Before publish</span>
|
|
316
|
+
<span className="text-xs font-medium text-foreground">Finish these workflow steps</span>
|
|
317
|
+
</div>
|
|
318
|
+
{groupCartoonIssues(cartoonIssues).map((g) => (
|
|
319
|
+
<div
|
|
320
|
+
key={g.key}
|
|
321
|
+
className="rounded-lg border border-error/15 bg-background/70 px-2.5 py-2"
|
|
322
|
+
data-testid={`cartoon-issue-group-${g.key}`}
|
|
323
|
+
>
|
|
324
|
+
<span className="text-[11px] font-medium text-foreground">{g.title}</span>
|
|
325
|
+
</div>
|
|
326
|
+
))}
|
|
327
|
+
<details className="text-[10px] text-muted" data-testid="cartoon-technical-details">
|
|
328
|
+
<summary className="cursor-pointer select-none">Technical details</summary>
|
|
329
|
+
<ul className="mt-1 ml-3 list-disc">
|
|
330
|
+
{cartoonIssues.map((issue, i) => (
|
|
331
|
+
<li key={i} className="font-mono break-words">{issue}</li>
|
|
332
|
+
))}
|
|
333
|
+
</ul>
|
|
334
|
+
</details>
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
<div className="mt-4 flex flex-col gap-2 max-w-xl">
|
|
339
|
+
{!coverDone && (
|
|
340
|
+
<button
|
|
341
|
+
onClick={onOpenStoryInfo}
|
|
342
|
+
data-testid="publish-add-cover"
|
|
343
|
+
className="self-start rounded border border-border px-3 py-1.5 text-xs text-foreground hover:border-accent hover:text-accent transition-colors"
|
|
344
|
+
>
|
|
345
|
+
Add a cover image (Story Info)
|
|
346
|
+
</button>
|
|
347
|
+
)}
|
|
348
|
+
{isGenesisActive && !metaReady && (
|
|
349
|
+
<button
|
|
350
|
+
onClick={onOpenStoryInfo}
|
|
351
|
+
data-testid="publish-set-metadata"
|
|
352
|
+
className="self-start rounded border border-border px-3 py-1.5 text-xs text-foreground hover:border-accent hover:text-accent transition-colors"
|
|
353
|
+
>
|
|
354
|
+
Set genre & language (Story Info)
|
|
355
|
+
</button>
|
|
356
|
+
)}
|
|
357
|
+
{!ready && (
|
|
358
|
+
<button
|
|
359
|
+
onClick={() => onOpenFile(storyName, active.file)}
|
|
360
|
+
data-testid="publish-open-episode"
|
|
361
|
+
className="self-start rounded border border-accent/40 px-3 py-1.5 text-xs text-accent hover:bg-accent/5 transition-colors"
|
|
362
|
+
>
|
|
363
|
+
Open {active.label} to finish {blocked ? "and fix issues" : "(letter / export / upload)"}
|
|
364
|
+
</button>
|
|
365
|
+
)}
|
|
366
|
+
<button
|
|
367
|
+
onClick={handlePublish}
|
|
368
|
+
disabled={!canPublish}
|
|
369
|
+
data-testid="publish-cta"
|
|
370
|
+
className="self-start rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-dim disabled:opacity-50 transition-colors"
|
|
371
|
+
title={canPublish ? undefined : "Finish the remaining steps above first"}
|
|
372
|
+
>
|
|
373
|
+
{isPublishing ? "Publishing…" : `Publish ${active.label} to PlotLink`}
|
|
374
|
+
</button>
|
|
375
|
+
{!ready ? (
|
|
376
|
+
<p className="text-[11px] text-muted" data-testid="publish-blocked-reason">
|
|
377
|
+
{blocked
|
|
378
|
+
? `Not publishable yet — ${active.summary.toLowerCase()}. Open the episode to fix the flagged cuts.`
|
|
379
|
+
: `Not ready yet — ${active.summary.toLowerCase()}.`}
|
|
380
|
+
</p>
|
|
381
|
+
) : !metaReady ? (
|
|
382
|
+
<p className="text-[11px] text-amber-700" data-testid="publish-needs-metadata">
|
|
383
|
+
Set the genre and language in Story Info before publishing.
|
|
384
|
+
</p>
|
|
385
|
+
) : titleBlocked || genesisBlocked ? (
|
|
386
|
+
<p className="text-[11px] text-error" data-testid="publish-title-blocked-reason">
|
|
387
|
+
{genesisBlocked
|
|
388
|
+
? "Fix the Story opening issues above before publishing."
|
|
389
|
+
: "Set a real reader-facing title above before publishing."}
|
|
390
|
+
</p>
|
|
391
|
+
) : null}
|
|
392
|
+
{publishError && (
|
|
393
|
+
<p className="text-[11px] text-error" data-testid="publish-error">{publishError}</p>
|
|
394
|
+
)}
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<details className="mt-4 max-w-xl" data-testid="publish-technical-details">
|
|
398
|
+
<summary className="text-[11px] text-muted cursor-pointer hover:text-foreground">Technical validation details</summary>
|
|
399
|
+
<div className="mt-1 text-[10px] text-muted space-y-0.5">
|
|
400
|
+
<p>Episode file: <span className="font-mono">{active.file}</span></p>
|
|
401
|
+
<p>State: {active.state} — {active.summary}</p>
|
|
402
|
+
<p>Per-cut production (cut plan, clean images, lettering, export, upload) happens in the episode’s cut workspace; open it above to finish any remaining step.</p>
|
|
403
|
+
</div>
|
|
404
|
+
</details>
|
|
405
|
+
</div>
|
|
406
|
+
);
|
|
407
|
+
}
|