plotlink-ows 1.2.96 → 1.2.98
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/app/lib/cartoon-production-status.ts +77 -0
- package/app/web/components/CartoonNextAction.tsx +154 -36
- package/app/web/components/CartoonProductionStatus.tsx +142 -0
- package/app/web/components/CartoonPublishPage.tsx +34 -25
- package/app/web/components/CartoonWorkflowNav.tsx +3 -7
- package/app/web/components/CutListPanel.tsx +372 -243
- package/app/web/components/CutOverlayPreview.tsx +314 -0
- package/app/web/components/EpisodesPage.tsx +34 -4
- package/app/web/components/FinishEpisodePanel.tsx +21 -108
- package/app/web/components/LetteringEditor.tsx +246 -131
- package/app/web/components/PreviewPanel.tsx +200 -214
- package/app/web/components/StoriesPage.tsx +105 -91
- package/app/web/components/StoryBrowser.tsx +53 -19
- package/app/web/components/StoryInfoPage.tsx +31 -14
- package/app/web/components/StoryProgressPanel.tsx +31 -28
- package/app/web/dist/assets/{export-cut-BqZI0-Rv.js → export-cut-DVpOZ5AO.js} +1 -1
- package/app/web/dist/assets/index-CoG6WKyb.js +141 -0
- package/app/web/dist/assets/index-H5_FM885.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-C43toXVm.js +0 -141
- package/app/web/dist/assets/index-CcfChGEK.css +0 -32
|
@@ -143,11 +143,16 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
143
143
|
focusedLetteringWorkspaceVisible,
|
|
144
144
|
setFocusedLetteringWorkspaceVisible,
|
|
145
145
|
] = useState(true);
|
|
146
|
+
const [workflowActionRequest, setWorkflowActionRequest] = useState<{
|
|
147
|
+
action: CoachUiAction;
|
|
148
|
+
seq: number;
|
|
149
|
+
} | null>(null);
|
|
146
150
|
const contentTypeMap = useRef<Map<string, "fiction" | "cartoon">>(new Map());
|
|
147
151
|
const languageMap = useRef<Map<string, string>>(new Map());
|
|
148
152
|
const agentModeMap = useRef<Map<string, "normal" | "bypass">>(new Map());
|
|
149
153
|
const agentProviderMap = useRef<Map<string, "claude" | "codex">>(new Map());
|
|
150
154
|
const knownStoriesRef = useRef<Set<string>>(new Set());
|
|
155
|
+
const workflowActionSeqRef = useRef(0);
|
|
151
156
|
const renameRef = useRef<
|
|
152
157
|
| ((
|
|
153
158
|
oldName: string,
|
|
@@ -1006,9 +1011,8 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1006
1011
|
selectedStory,
|
|
1007
1012
|
);
|
|
1008
1013
|
|
|
1009
|
-
// Cartoon-only right-panel workflow nav
|
|
1010
|
-
//
|
|
1011
|
-
// ⇒ Whitepaper, genesis.md ⇒ Genesis / Ep 1, plot-NN ⇒ Episodes, else Progress.
|
|
1014
|
+
// Cartoon-only right-panel workflow nav. Cartoon users work from episodes in
|
|
1015
|
+
// the left browser; the top nav stays at story/workflow level only.
|
|
1012
1016
|
const isCartoonStory = !!selectedStory && selectedContentType === "cartoon";
|
|
1013
1017
|
const activeCartoonTab: CartoonWorkflowTab =
|
|
1014
1018
|
cartoonView === "story-info"
|
|
@@ -1017,13 +1021,10 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1017
1021
|
? "episodes"
|
|
1018
1022
|
: cartoonView === "publish"
|
|
1019
1023
|
? "publish"
|
|
1020
|
-
: selectedFile
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
: selectedFile && /^plot-\d+\.md$/.test(selectedFile)
|
|
1025
|
-
? "episodes"
|
|
1026
|
-
: "progress";
|
|
1024
|
+
: selectedFile &&
|
|
1025
|
+
(selectedFile === "genesis.md" || /^plot-\d+\.md$/.test(selectedFile))
|
|
1026
|
+
? "episodes"
|
|
1027
|
+
: "progress";
|
|
1027
1028
|
|
|
1028
1029
|
const handleCartoonNav = useCallback(
|
|
1029
1030
|
(tab: CartoonWorkflowTab) => {
|
|
@@ -1044,12 +1045,6 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1044
1045
|
case "episodes":
|
|
1045
1046
|
setCartoonView("episodes");
|
|
1046
1047
|
break;
|
|
1047
|
-
case "whitepaper":
|
|
1048
|
-
handleSelectFile(story, "structure.md");
|
|
1049
|
-
break;
|
|
1050
|
-
case "genesis":
|
|
1051
|
-
handleSelectFile(story, "genesis.md");
|
|
1052
|
-
break;
|
|
1053
1048
|
// Publish opens its own readiness page and stays on the Publish tab (#449),
|
|
1054
1049
|
// instead of visually routing to the Genesis file view.
|
|
1055
1050
|
case "publish":
|
|
@@ -1064,6 +1059,13 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1064
1059
|
(action: CoachUiAction, episodeFile: string | null) => {
|
|
1065
1060
|
const story = selectedStory;
|
|
1066
1061
|
if (!story) return;
|
|
1062
|
+
const queueWorkflowAction = (nextAction: CoachUiAction) => {
|
|
1063
|
+
workflowActionSeqRef.current += 1;
|
|
1064
|
+
setWorkflowActionRequest({
|
|
1065
|
+
action: nextAction,
|
|
1066
|
+
seq: workflowActionSeqRef.current,
|
|
1067
|
+
});
|
|
1068
|
+
};
|
|
1067
1069
|
switch (action) {
|
|
1068
1070
|
case "view-progress":
|
|
1069
1071
|
setCartoonView(null);
|
|
@@ -1077,7 +1079,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1077
1079
|
case "upload":
|
|
1078
1080
|
case "refresh-assets":
|
|
1079
1081
|
case "generate-markdown":
|
|
1080
|
-
if (episodeFile)
|
|
1082
|
+
if (!episodeFile) return;
|
|
1083
|
+
handleSelectFile(story, episodeFile);
|
|
1084
|
+
queueWorkflowAction(action);
|
|
1081
1085
|
break;
|
|
1082
1086
|
}
|
|
1083
1087
|
},
|
|
@@ -1196,7 +1200,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1196
1200
|
{/* Preview — takes remaining space. With a story but no file selected, show
|
|
1197
1201
|
the story-level progress overview (#418) instead of the empty state. */}
|
|
1198
1202
|
<div
|
|
1199
|
-
className="min-w-0 flex flex-col"
|
|
1203
|
+
className="min-w-0 min-h-0 flex flex-col"
|
|
1200
1204
|
style={
|
|
1201
1205
|
hideFocusedLetteringWorkspace
|
|
1202
1206
|
? { flex: "1 0 0" }
|
|
@@ -1211,91 +1215,101 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1211
1215
|
onSelect={handleCartoonNav}
|
|
1212
1216
|
/>
|
|
1213
1217
|
)}
|
|
1218
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
1219
|
+
{isCartoonStory && cartoonView === "story-info" && selectedStory ? (
|
|
1220
|
+
<StoryInfoPage
|
|
1221
|
+
storyName={selectedStory}
|
|
1222
|
+
authFetch={authFetch}
|
|
1223
|
+
onSaved={handleStoryInfoSaved}
|
|
1224
|
+
/>
|
|
1225
|
+
) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
|
|
1226
|
+
<EpisodesPage
|
|
1227
|
+
storyName={selectedStory}
|
|
1228
|
+
authFetch={authFetch}
|
|
1229
|
+
onOpenFile={handleSelectFile}
|
|
1230
|
+
/>
|
|
1231
|
+
) : isCartoonStory && cartoonView === "publish" && selectedStory ? (
|
|
1232
|
+
<CartoonPublishPage
|
|
1233
|
+
storyName={selectedStory}
|
|
1234
|
+
authFetch={authFetch}
|
|
1235
|
+
onOpenFile={handleSelectFile}
|
|
1236
|
+
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
1237
|
+
onPublish={handlePublish}
|
|
1238
|
+
publishingFile={publishingFile}
|
|
1239
|
+
genre={storyGenres[selectedStory]}
|
|
1240
|
+
language={storyLanguages[selectedStory]}
|
|
1241
|
+
isNsfw={storyNsfw[selectedStory]}
|
|
1242
|
+
refreshKey={cartoonPublishRefresh}
|
|
1243
|
+
/>
|
|
1244
|
+
) : selectedStory && !selectedFile ? (
|
|
1245
|
+
<StoryProgressPanel
|
|
1246
|
+
storyName={selectedStory}
|
|
1247
|
+
authFetch={authFetch}
|
|
1248
|
+
onOpenFile={handleSelectFile}
|
|
1249
|
+
/>
|
|
1250
|
+
) : (
|
|
1251
|
+
<PreviewPanel
|
|
1252
|
+
storyName={selectedStory}
|
|
1253
|
+
fileName={selectedFile}
|
|
1254
|
+
authFetch={authFetch}
|
|
1255
|
+
onPublish={handlePublish}
|
|
1256
|
+
publishingFile={publishingFile}
|
|
1257
|
+
walletAddress={walletAddress}
|
|
1258
|
+
contentType={
|
|
1259
|
+
resolveSelectedContentType(
|
|
1260
|
+
selectedStory,
|
|
1261
|
+
storyContentTypes,
|
|
1262
|
+
contentTypeMap.current,
|
|
1263
|
+
) || "fiction"
|
|
1264
|
+
}
|
|
1265
|
+
language={selectedStory ? storyLanguages[selectedStory] : undefined}
|
|
1266
|
+
genre={selectedStory ? storyGenres[selectedStory] : undefined}
|
|
1267
|
+
isNsfw={selectedStory ? storyNsfw[selectedStory] : undefined}
|
|
1268
|
+
hasGenesis={
|
|
1269
|
+
selectedStory ? genesisStories.has(selectedStory) : false
|
|
1270
|
+
}
|
|
1271
|
+
onViewProgress={() => setSelectedFile(null)}
|
|
1272
|
+
onOpenFile={(file) =>
|
|
1273
|
+
selectedStory && handleSelectFile(selectedStory, file)
|
|
1274
|
+
}
|
|
1275
|
+
onViewPublish={() => setCartoonView("publish")}
|
|
1276
|
+
focusedLetteringMode={focusedLetteringMode}
|
|
1277
|
+
focusedLetteringWorkspaceVisible={focusedLetteringWorkspaceVisible}
|
|
1278
|
+
onFocusedLetteringModeChange={handleFocusedLetteringModeChange}
|
|
1279
|
+
onFocusedLetteringWorkspaceVisibleChange={
|
|
1280
|
+
setFocusedLetteringWorkspaceVisible
|
|
1281
|
+
}
|
|
1282
|
+
workflowActionRequest={workflowActionRequest}
|
|
1283
|
+
onWorkflowActionHandled={(seq) =>
|
|
1284
|
+
setWorkflowActionRequest((prev) =>
|
|
1285
|
+
prev?.seq === seq ? null : prev,
|
|
1286
|
+
)
|
|
1287
|
+
}
|
|
1288
|
+
/>
|
|
1289
|
+
)}
|
|
1290
|
+
</div>
|
|
1214
1291
|
{!focusedLetteringMode &&
|
|
1215
1292
|
isCartoonStory &&
|
|
1216
1293
|
selectedStory &&
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1294
|
+
!selectedFile && (
|
|
1295
|
+
<div
|
|
1296
|
+
className="pointer-events-none absolute bottom-4 right-4 z-10 w-[min(22rem,calc(100%-2rem))]"
|
|
1297
|
+
data-testid="workflow-persistent-next-action"
|
|
1298
|
+
>
|
|
1299
|
+
<div className="pointer-events-auto rounded-xl border border-border/80 bg-background/95 shadow-lg backdrop-blur">
|
|
1222
1300
|
<CartoonNextAction
|
|
1223
1301
|
storyName={selectedStory}
|
|
1302
|
+
fileName={cartoonView === null ? selectedFile : null}
|
|
1224
1303
|
authFetch={authFetch}
|
|
1225
1304
|
refreshKey={cartoonPublishRefresh}
|
|
1226
1305
|
onCoachAction={handleWorkflowNextAction}
|
|
1227
1306
|
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
1228
1307
|
/>
|
|
1229
1308
|
</div>
|
|
1230
|
-
|
|
1231
|
-
{isCartoonStory && cartoonView === "story-info" && selectedStory ? (
|
|
1232
|
-
<StoryInfoPage
|
|
1233
|
-
storyName={selectedStory}
|
|
1234
|
-
authFetch={authFetch}
|
|
1235
|
-
onSaved={handleStoryInfoSaved}
|
|
1236
|
-
/>
|
|
1237
|
-
) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
|
|
1238
|
-
<EpisodesPage
|
|
1239
|
-
storyName={selectedStory}
|
|
1240
|
-
authFetch={authFetch}
|
|
1241
|
-
onOpenFile={handleSelectFile}
|
|
1242
|
-
/>
|
|
1243
|
-
) : isCartoonStory && cartoonView === "publish" && selectedStory ? (
|
|
1244
|
-
<CartoonPublishPage
|
|
1245
|
-
storyName={selectedStory}
|
|
1246
|
-
authFetch={authFetch}
|
|
1247
|
-
onOpenFile={handleSelectFile}
|
|
1248
|
-
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
1249
|
-
onPublish={handlePublish}
|
|
1250
|
-
publishingFile={publishingFile}
|
|
1251
|
-
genre={storyGenres[selectedStory]}
|
|
1252
|
-
language={storyLanguages[selectedStory]}
|
|
1253
|
-
isNsfw={storyNsfw[selectedStory]}
|
|
1254
|
-
refreshKey={cartoonPublishRefresh}
|
|
1255
|
-
/>
|
|
1256
|
-
) : selectedStory && !selectedFile ? (
|
|
1257
|
-
<StoryProgressPanel
|
|
1258
|
-
storyName={selectedStory}
|
|
1259
|
-
authFetch={authFetch}
|
|
1260
|
-
onOpenFile={handleSelectFile}
|
|
1261
|
-
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
1262
|
-
/>
|
|
1263
|
-
) : (
|
|
1264
|
-
<PreviewPanel
|
|
1265
|
-
storyName={selectedStory}
|
|
1266
|
-
fileName={selectedFile}
|
|
1267
|
-
authFetch={authFetch}
|
|
1268
|
-
onPublish={handlePublish}
|
|
1269
|
-
publishingFile={publishingFile}
|
|
1270
|
-
walletAddress={walletAddress}
|
|
1271
|
-
contentType={
|
|
1272
|
-
resolveSelectedContentType(
|
|
1273
|
-
selectedStory,
|
|
1274
|
-
storyContentTypes,
|
|
1275
|
-
contentTypeMap.current,
|
|
1276
|
-
) || "fiction"
|
|
1277
|
-
}
|
|
1278
|
-
language={selectedStory ? storyLanguages[selectedStory] : undefined}
|
|
1279
|
-
genre={selectedStory ? storyGenres[selectedStory] : undefined}
|
|
1280
|
-
isNsfw={selectedStory ? storyNsfw[selectedStory] : undefined}
|
|
1281
|
-
hasGenesis={
|
|
1282
|
-
selectedStory ? genesisStories.has(selectedStory) : false
|
|
1283
|
-
}
|
|
1284
|
-
onViewProgress={() => setSelectedFile(null)}
|
|
1285
|
-
onOpenFile={(file) =>
|
|
1286
|
-
selectedStory && handleSelectFile(selectedStory, file)
|
|
1287
|
-
}
|
|
1288
|
-
onViewPublish={() => setCartoonView("publish")}
|
|
1289
|
-
focusedLetteringMode={focusedLetteringMode}
|
|
1290
|
-
focusedLetteringWorkspaceVisible={focusedLetteringWorkspaceVisible}
|
|
1291
|
-
onFocusedLetteringModeChange={handleFocusedLetteringModeChange}
|
|
1292
|
-
onFocusedLetteringWorkspaceVisibleChange={
|
|
1293
|
-
setFocusedLetteringWorkspaceVisible
|
|
1294
|
-
}
|
|
1295
|
-
/>
|
|
1309
|
+
</div>
|
|
1296
1310
|
)}
|
|
1297
1311
|
{publishProgress && (
|
|
1298
|
-
<div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
|
|
1312
|
+
<div className="shrink-0 px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
|
|
1299
1313
|
{publishProgress}
|
|
1300
1314
|
</div>
|
|
1301
1315
|
)}
|
|
@@ -1304,7 +1318,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1304
1318
|
doesn't disappear on a timer. */}
|
|
1305
1319
|
{publishError && (
|
|
1306
1320
|
<div
|
|
1307
|
-
className="px-3 py-2 bg-error/10 border-t border-error/40 text-xs text-error flex items-start justify-between gap-3"
|
|
1321
|
+
className="shrink-0 px-3 py-2 bg-error/10 border-t border-error/40 text-xs text-error flex items-start justify-between gap-3"
|
|
1308
1322
|
data-testid="publish-block-error"
|
|
1309
1323
|
role="alert"
|
|
1310
1324
|
>
|
|
@@ -124,6 +124,22 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
124
124
|
return files[0]?.file ?? null;
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
+
const isEpisodeFile = (file: string) =>
|
|
128
|
+
file === "genesis.md" || /^plot-\d+\.md$/.test(file);
|
|
129
|
+
|
|
130
|
+
const cartoonEpisodeMeta = (file: string): { label: string; sort: number } | null => {
|
|
131
|
+
if (file === "genesis.md") {
|
|
132
|
+
return { label: "epi-01 (Genesis)", sort: 1 };
|
|
133
|
+
}
|
|
134
|
+
const m = file.match(/^plot-(\d+)\.md$/);
|
|
135
|
+
if (!m) return null;
|
|
136
|
+
const n = parseInt(m[1], 10) + 1;
|
|
137
|
+
return {
|
|
138
|
+
label: `epi-${String(n).padStart(2, "0")}`,
|
|
139
|
+
sort: n,
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
127
143
|
const handleStoryClick = (story: StoryInfo) => {
|
|
128
144
|
toggleExpand(story.name);
|
|
129
145
|
// Cartoon: a root-row click opens the story-level progress overview (#418) on
|
|
@@ -139,8 +155,18 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
139
155
|
}
|
|
140
156
|
};
|
|
141
157
|
|
|
142
|
-
// Sort files: structure first, genesis, then plots in order
|
|
143
|
-
|
|
158
|
+
// Sort files: structure first, genesis, then plots in order. Cartoon stories
|
|
159
|
+
// show only episode files to avoid leaking the markdown implementation model.
|
|
160
|
+
const sortFiles = (files: FileStatus[], contentType?: "fiction" | "cartoon") => {
|
|
161
|
+
if (contentType === "cartoon") {
|
|
162
|
+
return [...files]
|
|
163
|
+
.filter((f) => isEpisodeFile(f.file))
|
|
164
|
+
.sort((a, b) => {
|
|
165
|
+
const aa = cartoonEpisodeMeta(a.file)?.sort ?? 999;
|
|
166
|
+
const bb = cartoonEpisodeMeta(b.file)?.sort ?? 999;
|
|
167
|
+
return aa - bb;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
144
170
|
const order = (f: string) => {
|
|
145
171
|
if (f === "structure.md") return 0;
|
|
146
172
|
if (f === "genesis.md") return 1;
|
|
@@ -238,27 +264,35 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
238
264
|
{story.contentType === "cartoon" && (
|
|
239
265
|
<span className="bg-accent/10 text-accent rounded px-1.5 py-0.5 text-[10px] font-medium flex-shrink-0">Cartoon</span>
|
|
240
266
|
)}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
267
|
+
<span className="ml-auto flex-shrink-0 text-xs text-muted">
|
|
268
|
+
{story.contentType === "cartoon"
|
|
269
|
+
? `${sortFiles(story.files, "cartoon").length} ep`
|
|
270
|
+
: `${story.publishedCount}/${story.files.length}`}
|
|
271
|
+
</span>
|
|
272
|
+
</button>
|
|
273
|
+
{expanded.has(story.name) && (
|
|
274
|
+
<div className="pl-4">
|
|
275
|
+
{sortFiles(story.files, story.contentType).map((f) => {
|
|
276
|
+
const isSelected = selectedStory === story.name && selectedFile === f.file;
|
|
277
|
+
const cartoonMeta =
|
|
278
|
+
story.contentType === "cartoon"
|
|
279
|
+
? cartoonEpisodeMeta(f.file)
|
|
280
|
+
: null;
|
|
281
|
+
return (
|
|
282
|
+
<button
|
|
283
|
+
key={f.file}
|
|
252
284
|
onClick={() => onSelectFile(story.name, f.file)}
|
|
253
285
|
className={`w-full px-3 py-1.5 text-left flex items-center gap-2 text-xs hover:bg-surface ${
|
|
254
286
|
isSelected ? "bg-surface font-medium" : ""
|
|
255
287
|
}`}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
288
|
+
>
|
|
289
|
+
<span className={STATUS_COLOR[f.status]}>{STATUS_ICON[f.status]}</span>
|
|
290
|
+
<span className={story.contentType === "cartoon" ? "truncate" : "truncate font-mono"}>
|
|
291
|
+
{cartoonMeta?.label ?? f.file}
|
|
292
|
+
</span>
|
|
293
|
+
</button>
|
|
294
|
+
);
|
|
295
|
+
})}
|
|
262
296
|
</div>
|
|
263
297
|
)}
|
|
264
298
|
</div>
|
|
@@ -152,8 +152,37 @@ export function StoryInfoPage({ storyName, authFetch, onSaved }: StoryInfoPagePr
|
|
|
152
152
|
|
|
153
153
|
return (
|
|
154
154
|
<div className="h-full overflow-y-auto px-4 py-4" data-testid="story-info-page">
|
|
155
|
-
<
|
|
156
|
-
|
|
155
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
156
|
+
<div className="min-w-0">
|
|
157
|
+
<h2 className="text-base font-serif text-foreground">Story Info</h2>
|
|
158
|
+
<p className="mt-0.5 text-[11px] text-muted">These details appear on PlotLink when the story is published.</p>
|
|
159
|
+
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
|
|
160
|
+
<span className={`rounded-full border px-2 py-0.5 ${title.trim() ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}>
|
|
161
|
+
Title {title.trim() ? "ready" : "missing"}
|
|
162
|
+
</span>
|
|
163
|
+
<span className={`rounded-full border px-2 py-0.5 ${genre ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}>
|
|
164
|
+
Genre {genre ? "set" : "needed"}
|
|
165
|
+
</span>
|
|
166
|
+
<span className={`rounded-full border px-2 py-0.5 ${language ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}>
|
|
167
|
+
Language {language ? "set" : "needed"}
|
|
168
|
+
</span>
|
|
169
|
+
<span className={`rounded-full border px-2 py-0.5 ${cover === "present" ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}>
|
|
170
|
+
{cover === "present" ? "Cover ready" : "Cover missing"}
|
|
171
|
+
</span>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
<div className="flex flex-col items-start gap-1.5">
|
|
175
|
+
<button
|
|
176
|
+
type="button" onClick={handleSave} disabled={saving}
|
|
177
|
+
data-testid="story-info-save"
|
|
178
|
+
className="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
|
|
179
|
+
>
|
|
180
|
+
{saving ? "Saving…" : "Save Story Info"}
|
|
181
|
+
</button>
|
|
182
|
+
{saved && <span className="text-[11px] text-green-700" data-testid="story-info-saved">Saved</span>}
|
|
183
|
+
{saveError && <span className="text-[11px] text-error" data-testid="story-info-error">{saveError}</span>}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
157
186
|
|
|
158
187
|
<div className="mt-4 flex flex-col gap-4 max-w-xl">
|
|
159
188
|
<label className="flex flex-col gap-1">
|
|
@@ -248,18 +277,6 @@ export function StoryInfoPage({ storyName, authFetch, onSaved }: StoryInfoPagePr
|
|
|
248
277
|
/>
|
|
249
278
|
<span className="text-xs text-foreground">This story contains adult content (18+)</span>
|
|
250
279
|
</label>
|
|
251
|
-
|
|
252
|
-
<div className="flex items-center gap-3">
|
|
253
|
-
<button
|
|
254
|
-
type="button" onClick={handleSave} disabled={saving}
|
|
255
|
-
data-testid="story-info-save"
|
|
256
|
-
className="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
|
|
257
|
-
>
|
|
258
|
-
{saving ? "Saving…" : "Save Story Info"}
|
|
259
|
-
</button>
|
|
260
|
-
{saved && <span className="text-[11px] text-green-700" data-testid="story-info-saved">Saved</span>}
|
|
261
|
-
{saveError && <span className="text-[11px] text-error" data-testid="story-info-error">{saveError}</span>}
|
|
262
|
-
</div>
|
|
263
280
|
</div>
|
|
264
281
|
</div>
|
|
265
282
|
);
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import type { StoryProgress, EpisodeProgress, EpisodeState } from "@app-lib/story-progress";
|
|
3
3
|
import type { CartoonChecklistStep } from "@app-lib/cartoon-readiness";
|
|
4
|
-
import {
|
|
4
|
+
import { buildCartoonProductionStatus } from "@app-lib/cartoon-production-status";
|
|
5
|
+
import { cartoonWorkflowActiveKey } from "./CartoonNextAction";
|
|
5
6
|
|
|
6
7
|
interface StoryProgressPanelProps {
|
|
7
8
|
storyName: string;
|
|
8
9
|
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
9
10
|
/** Open a file from the map (the workflow steps link to their file). */
|
|
10
11
|
onOpenFile: (storyName: string, file: string) => void;
|
|
11
|
-
/** Open the Story Info workflow page when metadata/cover is the next gate. */
|
|
12
|
-
onOpenStoryInfo?: () => void;
|
|
13
12
|
/** Bumped by the parent to force a refresh (e.g. after a publish). */
|
|
14
13
|
refreshKey?: number;
|
|
15
14
|
}
|
|
@@ -20,13 +19,13 @@ interface StoryProgressPanelProps {
|
|
|
20
19
|
* For CARTOON stories this is the writer's main production dashboard: a vertical
|
|
21
20
|
* workflow map of numbered sections (Define Story Info → Story Whitepaper →
|
|
22
21
|
* Genesis / Episode 1 → Episode 2 …), each with a checkbox checklist and a clear
|
|
23
|
-
* status. The single next-action CTA
|
|
24
|
-
*
|
|
22
|
+
* status. The shell owns the single persistent next-action CTA, while the current
|
|
23
|
+
* section still marks where that action belongs.
|
|
25
24
|
*
|
|
26
25
|
* FICTION keeps the simpler original layout — metadata, setup steps, a chapter
|
|
27
26
|
* list — and is completely unaffected by the cartoon redesign.
|
|
28
27
|
*/
|
|
29
|
-
export function StoryProgressPanel({ storyName, authFetch, onOpenFile,
|
|
28
|
+
export function StoryProgressPanel({ storyName, authFetch, onOpenFile, refreshKey = 0 }: StoryProgressPanelProps) {
|
|
30
29
|
const [progress, setProgress] = useState<StoryProgress | null>(null);
|
|
31
30
|
const [loading, setLoading] = useState(true);
|
|
32
31
|
|
|
@@ -56,7 +55,7 @@ export function StoryProgressPanel({ storyName, authFetch, onOpenFile, onOpenSto
|
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
return progress.contentType === "cartoon"
|
|
59
|
-
? <CartoonWorkflowMap progress={progress} storyName={storyName} onOpenFile={onOpenFile}
|
|
58
|
+
? <CartoonWorkflowMap progress={progress} storyName={storyName} onOpenFile={onOpenFile} />
|
|
60
59
|
: <FictionProgressView progress={progress} storyName={storyName} onOpenFile={onOpenFile} />;
|
|
61
60
|
}
|
|
62
61
|
|
|
@@ -138,7 +137,7 @@ function Section({
|
|
|
138
137
|
title: string;
|
|
139
138
|
status: SectionStatus;
|
|
140
139
|
items: ChecklistItem[];
|
|
141
|
-
/**
|
|
140
|
+
/** Secondary text, shown small. */
|
|
142
141
|
fileName?: string | null;
|
|
143
142
|
/** Called to open the section's underlying file, or undefined for no navigation. */
|
|
144
143
|
openFile?: () => void;
|
|
@@ -172,6 +171,14 @@ function episodeStatus(ep: EpisodeProgress, isActive: boolean): SectionStatus {
|
|
|
172
171
|
return "needs-action";
|
|
173
172
|
}
|
|
174
173
|
|
|
174
|
+
function episodeDisplayLabel(ep: EpisodeProgress): string {
|
|
175
|
+
if (ep.file === "genesis.md") return "epi-01 (Genesis)";
|
|
176
|
+
const m = ep.file.match(/^plot-(\d+)\.md$/);
|
|
177
|
+
if (!m) return ep.label;
|
|
178
|
+
const episodeNumber = parseInt(m[1], 10) + 1;
|
|
179
|
+
return `epi-${String(episodeNumber).padStart(2, "0")}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
175
182
|
/** Build the rendered checklist for a cartoon episode from its production checklist. */
|
|
176
183
|
function episodeItems(ep: EpisodeProgress, openingDone = true): ChecklistItem[] {
|
|
177
184
|
const steps = ep.checklist ?? [];
|
|
@@ -203,12 +210,11 @@ const GENESIS_STUB: EpisodeProgress = {
|
|
|
203
210
|
};
|
|
204
211
|
|
|
205
212
|
function CartoonWorkflowMap({
|
|
206
|
-
progress, storyName, onOpenFile,
|
|
213
|
+
progress, storyName, onOpenFile,
|
|
207
214
|
}: {
|
|
208
215
|
progress: StoryProgress;
|
|
209
216
|
storyName: string;
|
|
210
217
|
onOpenFile: (storyName: string, file: string) => void;
|
|
211
|
-
onOpenStoryInfo?: () => void;
|
|
212
218
|
}) {
|
|
213
219
|
const m = progress.metadata;
|
|
214
220
|
const hasStructure = progress.setup.hasStructure;
|
|
@@ -217,17 +223,6 @@ function CartoonWorkflowMap({
|
|
|
217
223
|
const storyInfoIncomplete = metadataIncomplete || !coverDone;
|
|
218
224
|
const activeKey = cartoonWorkflowActiveKey(progress);
|
|
219
225
|
|
|
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
226
|
const infoItems: ChecklistItem[] = [
|
|
232
227
|
{ label: "Public title", status: m.title ? "done" : "todo", detail: m.title ?? null },
|
|
233
228
|
{ label: "Language", status: m.language ? "done" : "todo", detail: m.language ?? null },
|
|
@@ -245,9 +240,6 @@ function CartoonWorkflowMap({
|
|
|
245
240
|
return (
|
|
246
241
|
<div className="h-full overflow-y-auto" data-testid="story-progress-panel">
|
|
247
242
|
<ProgressHeader progress={progress} />
|
|
248
|
-
<div className="border-b border-border" data-testid="persistent-next-action">
|
|
249
|
-
{topNextAction}
|
|
250
|
-
</div>
|
|
251
243
|
<p className="px-4 pt-3 pb-1 text-[11px] font-medium text-muted uppercase tracking-wider">Production Progress</p>
|
|
252
244
|
|
|
253
245
|
<Section
|
|
@@ -259,9 +251,8 @@ function CartoonWorkflowMap({
|
|
|
259
251
|
|
|
260
252
|
<Section
|
|
261
253
|
index={++idx}
|
|
262
|
-
title="Story
|
|
254
|
+
title="Story Bible"
|
|
263
255
|
status={whitepaperStatus}
|
|
264
|
-
fileName="structure.md"
|
|
265
256
|
openFile={hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined}
|
|
266
257
|
items={[{ label: "Planning document", status: hasStructure ? "done" : "todo", detail: hasStructure ? null : "Not written yet" }]}
|
|
267
258
|
/>
|
|
@@ -313,12 +304,17 @@ function EpisodeSection({
|
|
|
313
304
|
}) {
|
|
314
305
|
const status = episodeStatus(ep, isActive);
|
|
315
306
|
const items = episodeItems(ep, openingDone);
|
|
316
|
-
const
|
|
307
|
+
const production = buildCartoonProductionStatus({
|
|
308
|
+
checklist: ep.checklist ? { steps: ep.checklist, nextStep: null } : null,
|
|
309
|
+
markdownReady: ep.state === "ready" || ep.published,
|
|
310
|
+
published: ep.published,
|
|
311
|
+
});
|
|
312
|
+
const label = episodeDisplayLabel(ep);
|
|
313
|
+
const title = ep.title ? `${label} · ${ep.title}` : label;
|
|
317
314
|
const heading = (
|
|
318
315
|
<div className="flex items-center gap-2 min-w-0">
|
|
319
316
|
<span className={`flex-shrink-0 ${SECTION_TONE[status]}`} aria-hidden>{SECTION_ICON[status]}</span>
|
|
320
317
|
<span className="text-xs font-medium text-foreground truncate">{index}. {title}</span>
|
|
321
|
-
<span className="text-[10px] text-muted truncate">{ep.file}</span>
|
|
322
318
|
<span className={`ml-auto text-[10px] font-medium ${SECTION_TONE[status]} flex-shrink-0`}>{SECTION_LABEL[status]}</span>
|
|
323
319
|
</div>
|
|
324
320
|
);
|
|
@@ -336,6 +332,13 @@ function EpisodeSection({
|
|
|
336
332
|
) : (
|
|
337
333
|
<div data-state={ep.state}>{heading}</div>
|
|
338
334
|
)}
|
|
335
|
+
{production?.statusLabel && (
|
|
336
|
+
<div className="mt-1 ml-1 text-[10px] text-muted">
|
|
337
|
+
<span className="font-medium text-foreground">Active step:</span>{" "}
|
|
338
|
+
{production.statusLabel}
|
|
339
|
+
{production.activeStep?.detail ? ` · ${production.activeStep.detail}` : ""}
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
339
342
|
<div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
|
|
340
343
|
{items.map((it, i) => <ChecklistRow key={i} item={it} />)}
|
|
341
344
|
</div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{M as w,v as O,t as A,c as M,b as R,s as H,l as I,a as B,d as F}from"./index-
|
|
1
|
+
import{M as w,v as O,t as A,c as M,b as R,s as H,l as I,a as B,d as F}from"./index-CoG6WKyb.js";const z=w;async function G(e){if(typeof document>"u"||!document.fonts||typeof document.fonts.load!="function")return{ready:!0,missing:[]};const n=[];for(const s of e)try{const t=await document.fonts.load(`16px "${s}"`);!t||t.length===0?n.push(s):document.fonts.check(`16px "${s}"`)||n.push(s)}catch{n.push(s)}return{ready:n.length===0,missing:n}}function C(e){return new Promise((n,s)=>{const t=new Image;t.crossOrigin="anonymous",t.onload=()=>n(t),t.onerror=()=>s(new Error("Failed to load image")),t.src=e})}const W="rgba(255, 255, 255, 0.95)",_="#1a1a1a",L="rgba(244, 239, 230, 0.94)",N="rgba(26, 26, 26, 0.55)";function Y(e){return Math.max(2,e*.004)}function K(e,n,s,t,c,d,f){e.beginPath();for(const o of F(n,s,t,c,d,f))o.k==="M"?e.moveTo(o.x,o.y):o.k==="L"?e.lineTo(o.x,o.y):e.arcTo(o.cornerX,o.cornerY,o.x,o.y,o.r);e.closePath()}function P(e,n,s,t,c,d){var f;for(const o of n){const l=o.x*s,i=o.y*t,r=o.width*s,a=o.height*t,y=Y(t);if(o.type==="speech"){const u=R(o,r,a),k=o.tailAnchor?H(l,i,r,a,o.tailAnchor,u):null;K(e,l,i,r,a,k,u),e.fillStyle=W,e.fill(),e.strokeStyle=_,e.lineWidth=y,e.lineJoin="round",e.stroke()}else if(o.type==="narration"){const u=Math.min(r,a)*.12;e.beginPath(),e.roundRect(l,i,r,a,u),e.fillStyle=L,e.fill(),e.strokeStyle=N,e.lineWidth=Math.max(1.5,y*.75),e.lineJoin="round",e.stroke()}const g=o.type==="sfx"?d:c,T=o.type!=="sfx"&&!!o.speaker,h=I((u,k,E=400)=>(e.font=`${E} ${k}px ${g}`,e.measureText(u).width),o.text,r,a,B(o,t,r,a));e.textAlign="center",e.textBaseline="middle";const p=l+r/2,S=T?h.speakerFontSize*1.2:0;T&&(e.fillStyle="#3a3a3a",e.font=`700 ${h.speakerFontSize}px ${g}`,e.fillText(o.speaker,p,i+S/2+a*.04,r-6));const v=i+S,b=a-S,$=h.lines.length*h.lineHeight;let m=v+b/2-$/2+h.lineHeight/2;e.font=`${((f=o.textStyle)==null?void 0:f.fontWeight)??400} ${h.fontSize}px ${g}`;for(const u of h.lines)o.type==="sfx"?(e.fillStyle="#000",e.strokeStyle="#fff",e.lineWidth=3,e.strokeText(u,p,m),e.fillText(u,p,m)):(e.fillStyle="#1a1a1a",e.fillText(u,p,m)),m+=h.lineHeight}}function X(e,n,s,t,c){const d=Math.max(14,Math.min(t*.05,28));e.font=`${d}px ${c}`,e.fillStyle="#1a1a1a",e.textAlign="center",e.textBaseline="middle";const f=[];if(n.dialogue)for(const i of n.dialogue)f.push(`${i.speaker}: ${i.text}`);n.narration&&f.push(n.narration);const o=d*1.6,l=t/2-(f.length-1)*o/2;for(let i=0;i<f.length;i++)e.fillText(f[i],s/2,l+i*o,s-40)}async function Z(e,n,s,t,c,d){const f=O(n);if(!f.valid)throw new Error(f.error??"Overlay geometry is invalid");let o=800,l=600,i=null;if(e)i=await C(e),o=i.naturalWidth,l=i.naturalHeight;else if(d){const y=A(d.aspectRatio);y&&(o=y.width,l=y.height)}const r=document.createElement("canvas");r.width=o,r.height=l;const a=r.getContext("2d");return i?a.drawImage(i,0,0,o,l):(a.fillStyle=(d==null?void 0:d.background)||"#ffffff",a.fillRect(0,0,o,l)),P(a,n,o,l,s,t),c&&n.length===0&&!i&&X(a,c,o,l,s),M(r)}function j(e){return e.size>z?{valid:!1,error:`Image is ${(e.size/1024).toFixed(0)}KB, exceeds 1MB limit`}:{valid:!0}}export{z as MAX_SIZE,G as ensureFontsReady,Z as exportCut,P as renderOverlays,A as textPanelDimensions,j as validateExportSize};
|