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,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.total > 0 && c.withText === c.total ? "done" : "todo", detail: c ? `${c.withText} / ${c.total}` : 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
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import ReactMarkdown from "react-markdown";
|
|
2
|
+
import remarkBreaks from "remark-breaks";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
5
|
+
import { summarizeCartoonMarkdown, PROSE_PREVIEW_LIMIT } from "../lib/cartoon-publish-summary";
|
|
6
|
+
import { cartoonPublishVerdict, type CartoonReadinessStage } from "@app-lib/cartoon-readiness";
|
|
7
|
+
|
|
8
|
+
/** Custom sanitizer matching plotlink.xyz — allows img with src, alt, title. */
|
|
9
|
+
const sanitizeSchema = {
|
|
10
|
+
...defaultSchema,
|
|
11
|
+
attributes: {
|
|
12
|
+
...defaultSchema.attributes,
|
|
13
|
+
img: ["src", "alt", "title"],
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const VERDICT_TONE: Record<"ok" | "info" | "warning" | "blocker", string> = {
|
|
18
|
+
ok: "border-green-300 bg-green-50 text-green-800",
|
|
19
|
+
info: "border-accent/30 bg-accent/5 text-foreground",
|
|
20
|
+
warning: "border-amber-300 bg-amber-50 text-amber-800",
|
|
21
|
+
blocker: "border-error/30 bg-error/5 text-error",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface CartoonPublishPreviewProps {
|
|
25
|
+
/** The exact plot-NN.md markdown that will be sent to PlotLink. */
|
|
26
|
+
content: string;
|
|
27
|
+
/** Current readiness stage (from classifyCartoonReadiness), if known. */
|
|
28
|
+
stage: CartoonReadinessStage | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Publish Preview: renders EXACTLY the markdown PlotLink will publish (image
|
|
33
|
+
* blocks plus any prose actually in the markdown), with a compact pre-publish
|
|
34
|
+
* summary — image count, char count, readiness, and any non-image prose that
|
|
35
|
+
* will be published. This is deliberately NOT the cuts.json planning view (see
|
|
36
|
+
* CartoonPreview / Cut Inspector); planning prose must not masquerade as publish
|
|
37
|
+
* content (#289).
|
|
38
|
+
*/
|
|
39
|
+
export function CartoonPublishPreview({ content, stage }: CartoonPublishPreviewProps) {
|
|
40
|
+
const summary = summarizeCartoonMarkdown(content);
|
|
41
|
+
const truncated = summary.nonImageProse.length > PROSE_PREVIEW_LIMIT;
|
|
42
|
+
// Two-axis verdict (#421): "Publish possible?" (hard) vs "Recommended?" (soft),
|
|
43
|
+
// so a placeholder is never shown as simply "Ready to publish".
|
|
44
|
+
const verdict = cartoonPublishVerdict({
|
|
45
|
+
stage,
|
|
46
|
+
imageCount: summary.imageCount,
|
|
47
|
+
hasNonImageProse: summary.nonImageProse.length > 0,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="h-full overflow-y-auto" data-testid="cartoon-publish-preview">
|
|
52
|
+
{/* Compact pre-publish content summary */}
|
|
53
|
+
<div
|
|
54
|
+
className="px-4 py-2 border-b border-border text-[10px] text-muted flex flex-wrap items-center gap-x-3 gap-y-1"
|
|
55
|
+
data-testid="cartoon-publish-summary"
|
|
56
|
+
>
|
|
57
|
+
<span>{summary.imageCount} image{summary.imageCount === 1 ? "" : "s"}</span>
|
|
58
|
+
<span>{summary.charCount.toLocaleString()} / 10,000 chars</span>
|
|
59
|
+
<span
|
|
60
|
+
className={`rounded-full px-2 py-0.5 font-medium ${verdict.possible ? "bg-green-100 text-green-800" : "bg-background text-muted"}`}
|
|
61
|
+
data-testid="publish-possible"
|
|
62
|
+
>
|
|
63
|
+
{verdict.possible ? "Publish possible" : "Publish not possible yet"}
|
|
64
|
+
</span>
|
|
65
|
+
<span
|
|
66
|
+
className={`rounded-full px-2 py-0.5 font-medium ${verdict.recommended ? "bg-green-100 text-green-800" : verdict.tone === "warning" ? "bg-amber-100 text-amber-800" : "bg-background text-muted"}`}
|
|
67
|
+
data-testid="publish-recommended"
|
|
68
|
+
>
|
|
69
|
+
{verdict.recommended ? "Recommended" : "Not recommended yet"}
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Plain-language verdict headline + the single next action (#421), so the
|
|
74
|
+
writer sees what to do instead of decoding validator strings. */}
|
|
75
|
+
<div
|
|
76
|
+
className={`px-4 py-2 border-b text-[11px] ${VERDICT_TONE[verdict.tone]}`}
|
|
77
|
+
data-testid="cartoon-publish-verdict"
|
|
78
|
+
>
|
|
79
|
+
<p className="font-medium">{verdict.headline}</p>
|
|
80
|
+
{verdict.detail && <p className="mt-0.5 opacity-90">{verdict.detail}</p>}
|
|
81
|
+
{verdict.action && <p className="mt-0.5 opacity-90">→ {verdict.action}</p>}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Any non-image text in the markdown WILL be published verbatim. Surface
|
|
85
|
+
it explicitly so leftover planning/placeholder prose can't slip past. */}
|
|
86
|
+
{summary.nonImageProse && (
|
|
87
|
+
<div
|
|
88
|
+
className="px-4 py-2 border-b border-amber-300 bg-amber-50 text-[11px] text-amber-800"
|
|
89
|
+
data-testid="cartoon-nonimage-prose"
|
|
90
|
+
>
|
|
91
|
+
<p className="font-medium">⚠ Non-image text in the published markdown:</p>
|
|
92
|
+
<p className="font-mono mt-1 whitespace-pre-wrap break-words">
|
|
93
|
+
{summary.nonImageProsePreview}{truncated ? "…" : ""}
|
|
94
|
+
</p>
|
|
95
|
+
<p className="mt-1">
|
|
96
|
+
This text publishes verbatim around the comic images. Remove it (or re-run
|
|
97
|
+
“Prepare episode for publish”) if it is planning or placeholder prose.
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{/* Exactly what PlotLink renders from the published markdown */}
|
|
103
|
+
<div className="max-w-lg mx-auto px-4 py-6">
|
|
104
|
+
{content.trim() ? (
|
|
105
|
+
<div className="prose max-w-none">
|
|
106
|
+
<ReactMarkdown
|
|
107
|
+
remarkPlugins={[remarkBreaks, remarkGfm]}
|
|
108
|
+
rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
|
|
109
|
+
>
|
|
110
|
+
{content}
|
|
111
|
+
</ReactMarkdown>
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
<p className="text-muted italic text-sm" data-testid="cartoon-publish-empty">
|
|
115
|
+
No publish markdown yet — build it from the cut plan (Edit → Upload & Prepare for Publish).
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { CARTOON_CLEAN_IMAGE_HELP, type CartoonChecklist } from "@app-lib/cartoon-readiness";
|
|
2
|
+
|
|
3
|
+
interface CartoonStepGuideProps {
|
|
4
|
+
checklist: CartoonChecklist | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const STATUS_MARK: Record<"done" | "current" | "todo", string> = {
|
|
8
|
+
done: "✓",
|
|
9
|
+
current: "▸",
|
|
10
|
+
todo: "○",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Granular step checklist for the cartoon plot workspace (#335). Renders the six
|
|
15
|
+
* production steps a creator actually performs — plan cuts → create clean images
|
|
16
|
+
* → add bubbles → export → upload → publish — each with real per-cut status and
|
|
17
|
+
* a plain-language "next step" line, so a first-time writer can tell what to do
|
|
18
|
+
* next without knowing what "markdown generation" means. The checklist is
|
|
19
|
+
* computed upstream (it needs cuts.json + asset/upload/publish state); this just
|
|
20
|
+
* renders it. Renders nothing when there is no checklist (e.g. a fiction plot),
|
|
21
|
+
* so it never appears outside the cartoon flow.
|
|
22
|
+
*/
|
|
23
|
+
export function CartoonStepGuide({ checklist }: CartoonStepGuideProps) {
|
|
24
|
+
if (!checklist || checklist.steps.length === 0) return null;
|
|
25
|
+
const { steps, nextStep } = checklist;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className="w-full max-w-[32rem] flex flex-col gap-3 rounded-xl border border-border bg-surface/70 p-3"
|
|
30
|
+
data-testid="cartoon-step-guide"
|
|
31
|
+
data-layout="diagram"
|
|
32
|
+
>
|
|
33
|
+
<div className="flex items-center justify-between gap-2">
|
|
34
|
+
<span className="text-xs font-medium text-foreground">Episode steps</span>
|
|
35
|
+
<span className="text-[10px] uppercase tracking-[0.18em] text-muted">Flow</span>
|
|
36
|
+
</div>
|
|
37
|
+
<ol className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
38
|
+
{steps.map((s, i) => (
|
|
39
|
+
<li
|
|
40
|
+
key={s.key}
|
|
41
|
+
data-testid={`cartoon-step-${s.key}`}
|
|
42
|
+
data-status={s.status}
|
|
43
|
+
className={`rounded-lg border px-2.5 py-2 text-xs ${
|
|
44
|
+
s.status === "current"
|
|
45
|
+
? "border-accent/40 bg-accent/10 text-accent"
|
|
46
|
+
: s.status === "done"
|
|
47
|
+
? "border-border bg-background/70 text-foreground"
|
|
48
|
+
: "border-border/80 bg-background/50 text-muted"
|
|
49
|
+
}`}
|
|
50
|
+
>
|
|
51
|
+
<div className="flex items-start gap-2">
|
|
52
|
+
<span
|
|
53
|
+
aria-hidden
|
|
54
|
+
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-medium ${
|
|
55
|
+
s.status === "current"
|
|
56
|
+
? "bg-accent text-white"
|
|
57
|
+
: s.status === "done"
|
|
58
|
+
? "bg-foreground text-background"
|
|
59
|
+
: "bg-surface text-muted"
|
|
60
|
+
}`}
|
|
61
|
+
>
|
|
62
|
+
{STATUS_MARK[s.status]}
|
|
63
|
+
</span>
|
|
64
|
+
<span className="flex min-w-0 flex-col gap-0.5">
|
|
65
|
+
<span className="leading-tight">
|
|
66
|
+
{i + 1}. {s.label}
|
|
67
|
+
</span>
|
|
68
|
+
{s.detail && (
|
|
69
|
+
<span className="font-normal text-[10px] text-muted" data-testid={`cartoon-step-${s.key}-detail`}>
|
|
70
|
+
{s.detail}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ol>
|
|
78
|
+
<div className="rounded-lg border border-border/80 bg-background/60 px-3 py-2">
|
|
79
|
+
{nextStep && (
|
|
80
|
+
<span className="block text-xs text-foreground mt-0.5" data-testid="cartoon-next-step">
|
|
81
|
+
Next: {nextStep}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
<span className="mt-1 block text-[11px] text-muted" data-testid="cartoon-clean-image-help">
|
|
85
|
+
{CARTOON_CLEAN_IMAGE_HELP}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|