plotlink-ows 1.2.95 → 1.2.97
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/lib/cuts.ts +135 -18
- package/app/lib/lettering-status.ts +64 -6
- 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/CutListPanel.tsx +1239 -513
- package/app/web/components/CutOverlayPreview.tsx +306 -0
- package/app/web/components/EpisodesPage.tsx +24 -0
- package/app/web/components/FinishEpisodePanel.tsx +28 -104
- package/app/web/components/LetteringEditor.tsx +968 -437
- package/app/web/components/PreviewPanel.tsx +1457 -848
- package/app/web/components/StoriesPage.tsx +1022 -526
- package/app/web/components/StoryInfoPage.tsx +31 -14
- package/app/web/components/StoryProgressPanel.tsx +19 -23
- package/app/web/dist/assets/{export-cut-che5mMWc.js → export-cut-Cj-cOtan.js} +1 -1
- package/app/web/dist/assets/index-CCeEYE7p.css +32 -0
- package/app/web/dist/assets/index-CF7pE09m.js +141 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-CcfChGEK.css +0 -32
- package/app/web/dist/assets/index-Dc2TQ3Ij.js +0 -143
|
@@ -3,15 +3,36 @@ import { StoryBrowser } from "./StoryBrowser";
|
|
|
3
3
|
import { TerminalPanel } from "./TerminalPanel";
|
|
4
4
|
import { PreviewPanel } from "./PreviewPanel";
|
|
5
5
|
import { StoryProgressPanel } from "./StoryProgressPanel";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
CartoonWorkflowNav,
|
|
8
|
+
type CartoonWorkflowTab,
|
|
9
|
+
} from "./CartoonWorkflowNav";
|
|
7
10
|
import { StoryInfoPage } from "./StoryInfoPage";
|
|
8
11
|
import { EpisodesPage } from "./EpisodesPage";
|
|
9
12
|
import { CartoonPublishPage } from "./CartoonPublishPage";
|
|
10
13
|
import { CartoonNextAction } from "./CartoonNextAction";
|
|
11
14
|
import { LANGUAGES, GENRES } from "../../../lib/genres";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
import {
|
|
16
|
+
getContentTypeForPublish,
|
|
17
|
+
resolveSelectedContentType,
|
|
18
|
+
needsLegacyProviderRepair,
|
|
19
|
+
attachCoverToStoryline,
|
|
20
|
+
derivePublishTitle,
|
|
21
|
+
shouldBlockDuplicatePlotPublish,
|
|
22
|
+
isRawFilenameTitle,
|
|
23
|
+
hasExplicitEpisodeTitle,
|
|
24
|
+
isPreflightBlocked,
|
|
25
|
+
formatPreflightBlock,
|
|
26
|
+
} from "../lib/publish-helpers";
|
|
27
|
+
import {
|
|
28
|
+
verifyPublicCartoonTitle,
|
|
29
|
+
publicTitleWarning,
|
|
30
|
+
} from "../lib/verify-public-title";
|
|
31
|
+
import {
|
|
32
|
+
isCodexAuthUnclear,
|
|
33
|
+
CODEX_AUTH_UNCLEAR_MESSAGE,
|
|
34
|
+
type AgentReadiness,
|
|
35
|
+
} from "@app-lib/agent-readiness";
|
|
15
36
|
import { cartoonGenesisReadiness } from "@app-lib/cartoon-readiness";
|
|
16
37
|
import type { CoachUiAction } from "@app-lib/cartoon-coach";
|
|
17
38
|
|
|
@@ -33,7 +54,9 @@ function loadRatio(): number {
|
|
|
33
54
|
const n = parseFloat(v);
|
|
34
55
|
if (n > 0 && n < 1) return n;
|
|
35
56
|
}
|
|
36
|
-
} catch {
|
|
57
|
+
} catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
37
60
|
return DEFAULT_RATIO;
|
|
38
61
|
}
|
|
39
62
|
|
|
@@ -50,7 +73,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
50
73
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
51
74
|
// Cartoon right-panel workflow nav (#439): a non-file workflow page is open
|
|
52
75
|
// (Story Info / Episodes). null ⇒ the view follows selectedFile (or Progress).
|
|
53
|
-
const [cartoonView, setCartoonView] = useState<
|
|
76
|
+
const [cartoonView, setCartoonView] = useState<
|
|
77
|
+
"story-info" | "episodes" | "publish" | null
|
|
78
|
+
>(null);
|
|
54
79
|
const [publishingFile, setPublishingFile] = useState<string | null>(null);
|
|
55
80
|
// Bumped on a confirmed publish so the cartoon Publish page re-reads readiness
|
|
56
81
|
// and advances to the next episode (#461).
|
|
@@ -68,43 +93,89 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
68
93
|
const [newStoryDescription, setNewStoryDescription] = useState("");
|
|
69
94
|
const [newStoryGenre, setNewStoryGenre] = useState("");
|
|
70
95
|
const [newStoryLanguage, setNewStoryLanguage] = useState("English");
|
|
71
|
-
const [newStoryAgentMode, setNewStoryAgentMode] = useState<
|
|
72
|
-
|
|
96
|
+
const [newStoryAgentMode, setNewStoryAgentMode] = useState<
|
|
97
|
+
"normal" | "bypass"
|
|
98
|
+
>("normal");
|
|
99
|
+
const [newStoryAgentProvider, setNewStoryAgentProvider] = useState<
|
|
100
|
+
"claude" | "codex"
|
|
101
|
+
>("claude");
|
|
73
102
|
const [readiness, setReadiness] = useState<AgentReadiness | null>(null);
|
|
74
103
|
const [codexEnableCopied, setCodexEnableCopied] = useState(false);
|
|
75
|
-
const [bypassStories, setBypassStories] = useState<Record<string, boolean>>(
|
|
76
|
-
|
|
104
|
+
const [bypassStories, setBypassStories] = useState<Record<string, boolean>>(
|
|
105
|
+
{},
|
|
106
|
+
);
|
|
107
|
+
const [agentProviders, setAgentProviders] = useState<
|
|
108
|
+
Record<string, "claude" | "codex">
|
|
109
|
+
>({});
|
|
77
110
|
// Track confirmed stories (those with structure.md) for Archive gating
|
|
78
|
-
const [confirmedStories, setConfirmedStories] = useState<Set<string>>(
|
|
111
|
+
const [confirmedStories, setConfirmedStories] = useState<Set<string>>(
|
|
112
|
+
new Set(),
|
|
113
|
+
);
|
|
79
114
|
// Stories that already have a genesis.md — so the outline footer can suggest
|
|
80
115
|
// reviewing Genesis rather than writing it again (#422).
|
|
81
116
|
const [genesisStories, setGenesisStories] = useState<Set<string>>(new Set());
|
|
82
|
-
const [storyContentTypes, setStoryContentTypes] = useState<
|
|
117
|
+
const [storyContentTypes, setStoryContentTypes] = useState<
|
|
118
|
+
Record<string, "fiction" | "cartoon">
|
|
119
|
+
>({});
|
|
83
120
|
// `undefined` ⇒ language couldn't be determined for the story → the publish
|
|
84
121
|
// panel shows "Needs metadata" rather than defaulting to English (#424).
|
|
85
|
-
const [storyLanguages, setStoryLanguages] = useState<
|
|
122
|
+
const [storyLanguages, setStoryLanguages] = useState<
|
|
123
|
+
Record<string, string | undefined>
|
|
124
|
+
>({});
|
|
86
125
|
// Publish metadata from .story.json (#424) so the publish controls seed real
|
|
87
126
|
// values. `undefined` ⇒ not set in .story.json → client shows "Needs metadata".
|
|
88
|
-
const [storyGenres, setStoryGenres] = useState<
|
|
89
|
-
|
|
127
|
+
const [storyGenres, setStoryGenres] = useState<
|
|
128
|
+
Record<string, string | undefined>
|
|
129
|
+
>({});
|
|
130
|
+
const [storyNsfw, setStoryNsfw] = useState<
|
|
131
|
+
Record<string, boolean | undefined>
|
|
132
|
+
>({});
|
|
90
133
|
const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
|
|
91
134
|
// Provider recorded on each persisted story (read-only, from /api/stories).
|
|
92
135
|
// Absent ⇒ legacy story with no provider (defaults to Claude at launch).
|
|
93
|
-
const [storyProviders, setStoryProviders] = useState<
|
|
136
|
+
const [storyProviders, setStoryProviders] = useState<
|
|
137
|
+
Record<string, "claude" | "codex" | undefined>
|
|
138
|
+
>({});
|
|
139
|
+
// #493: cartoon lettering can temporarily collapse the wider work area so the
|
|
140
|
+
// editor gets a true task-mode surface, then restore it on demand.
|
|
141
|
+
const [focusedLetteringMode, setFocusedLetteringMode] = useState(false);
|
|
142
|
+
const [
|
|
143
|
+
focusedLetteringWorkspaceVisible,
|
|
144
|
+
setFocusedLetteringWorkspaceVisible,
|
|
145
|
+
] = useState(true);
|
|
146
|
+
const [workflowActionRequest, setWorkflowActionRequest] = useState<{
|
|
147
|
+
action: CoachUiAction;
|
|
148
|
+
seq: number;
|
|
149
|
+
} | null>(null);
|
|
94
150
|
const contentTypeMap = useRef<Map<string, "fiction" | "cartoon">>(new Map());
|
|
95
151
|
const languageMap = useRef<Map<string, string>>(new Map());
|
|
96
152
|
const agentModeMap = useRef<Map<string, "normal" | "bypass">>(new Map());
|
|
97
153
|
const agentProviderMap = useRef<Map<string, "claude" | "codex">>(new Map());
|
|
98
154
|
const knownStoriesRef = useRef<Set<string>>(new Set());
|
|
99
|
-
const
|
|
155
|
+
const workflowActionSeqRef = useRef(0);
|
|
156
|
+
const renameRef = useRef<
|
|
157
|
+
| ((
|
|
158
|
+
oldName: string,
|
|
159
|
+
newName: string,
|
|
160
|
+
meta?: {
|
|
161
|
+
contentType?: "fiction" | "cartoon";
|
|
162
|
+
language?: string;
|
|
163
|
+
agentMode?: "normal" | "bypass";
|
|
164
|
+
agentProvider?: "claude" | "codex";
|
|
165
|
+
},
|
|
166
|
+
) => Promise<boolean>)
|
|
167
|
+
| null
|
|
168
|
+
>(null);
|
|
100
169
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
101
170
|
const dragging = useRef(false);
|
|
102
171
|
|
|
103
172
|
// Fetch wallet address for edit panel authorship check
|
|
104
173
|
useEffect(() => {
|
|
105
174
|
authFetch("/api/wallet")
|
|
106
|
-
.then((res) => res.ok ? res.json() : null)
|
|
107
|
-
.then((data) => {
|
|
175
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
176
|
+
.then((data) => {
|
|
177
|
+
if (data?.address) setWalletAddress(data.address);
|
|
178
|
+
})
|
|
108
179
|
.catch(() => {});
|
|
109
180
|
}, [authFetch]);
|
|
110
181
|
|
|
@@ -112,21 +183,30 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
112
183
|
// readiness null (no warning shown); this never blocks fiction or cartoon.
|
|
113
184
|
useEffect(() => {
|
|
114
185
|
authFetch("/api/agent/readiness")
|
|
115
|
-
.then((res) => res.ok ? res.json() : null)
|
|
116
|
-
.then((data) => {
|
|
186
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
187
|
+
.then((data) => {
|
|
188
|
+
if (data) setReadiness(data);
|
|
189
|
+
})
|
|
117
190
|
.catch(() => {});
|
|
118
191
|
}, [authFetch]);
|
|
119
192
|
|
|
120
193
|
// Persist ratio to localStorage
|
|
121
194
|
useEffect(() => {
|
|
122
|
-
try {
|
|
195
|
+
try {
|
|
196
|
+
localStorage.setItem(STORAGE_KEY, String(ratio));
|
|
197
|
+
} catch {
|
|
198
|
+
/* ignore */
|
|
199
|
+
}
|
|
123
200
|
}, [ratio]);
|
|
124
201
|
|
|
125
202
|
// Clamp ratio on window resize so panels stay above MIN_PANEL_PX
|
|
126
203
|
useEffect(() => {
|
|
127
204
|
const onResize = () => {
|
|
128
205
|
if (!containerRef.current) return;
|
|
129
|
-
const available =
|
|
206
|
+
const available =
|
|
207
|
+
containerRef.current.getBoundingClientRect().width -
|
|
208
|
+
SIDEBAR_PX -
|
|
209
|
+
HANDLE_PX;
|
|
130
210
|
setRatio((prev) => clampRatio(prev, available));
|
|
131
211
|
};
|
|
132
212
|
window.addEventListener("resize", onResize);
|
|
@@ -148,38 +228,50 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
148
228
|
// title/metadata (server writes .story.json + CLAUDE.md), then land on the
|
|
149
229
|
// story progress overview (#418) so the user sees what's next + a copy-paste
|
|
150
230
|
// first prompt — no need to ask the agent to rename an "Untitled" project.
|
|
151
|
-
const handleCreateStory = useCallback(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
231
|
+
const handleCreateStory = useCallback(
|
|
232
|
+
async (
|
|
233
|
+
contentType: "fiction" | "cartoon",
|
|
234
|
+
language: string,
|
|
235
|
+
agentMode: "normal" | "bypass",
|
|
236
|
+
agentProvider: "claude" | "codex",
|
|
237
|
+
) => {
|
|
238
|
+
const title = newStoryTitle.trim();
|
|
239
|
+
if (!title) return; // guarded by the disabled Create buttons
|
|
240
|
+
const provider = contentType === "cartoon" ? "codex" : agentProvider;
|
|
241
|
+
try {
|
|
242
|
+
const res = await authFetch("/api/stories/create", {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "Content-Type": "application/json" },
|
|
245
|
+
body: JSON.stringify({
|
|
246
|
+
title,
|
|
247
|
+
description: newStoryDescription.trim() || undefined,
|
|
248
|
+
language,
|
|
249
|
+
genre: newStoryGenre || undefined,
|
|
250
|
+
contentType,
|
|
251
|
+
agentMode,
|
|
252
|
+
agentProvider: provider,
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
if (!res.ok) return;
|
|
256
|
+
const data = await res.json();
|
|
257
|
+
setShowNewStoryModal(false);
|
|
258
|
+
// Prime the client maps so gating/labels are right before the next poll.
|
|
259
|
+
setStoryContentTypes((prev) => ({ ...prev, [data.name]: contentType }));
|
|
260
|
+
setStoryLanguages((prev) => ({ ...prev, [data.name]: language }));
|
|
261
|
+
if (newStoryGenre)
|
|
262
|
+
setStoryGenres((prev) => ({ ...prev, [data.name]: newStoryGenre }));
|
|
263
|
+
setAgentProviders((prev) => ({ ...prev, [data.name]: provider }));
|
|
264
|
+
if (agentMode === "bypass")
|
|
265
|
+
setBypassStories((prev) => ({ ...prev, [data.name]: true }));
|
|
266
|
+
// Land on the progress overview for the named story.
|
|
267
|
+
setSelectedStory(data.name);
|
|
268
|
+
setSelectedFile(null);
|
|
269
|
+
} catch {
|
|
270
|
+
/* leave the modal open on failure */
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
[authFetch, newStoryTitle, newStoryDescription, newStoryGenre],
|
|
274
|
+
);
|
|
183
275
|
|
|
184
276
|
// Poll for new stories and auto-transition untitled sessions
|
|
185
277
|
useEffect(() => {
|
|
@@ -192,11 +284,14 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
192
284
|
const currentNames = new Set<string>(
|
|
193
285
|
(data.stories as { name: string }[])
|
|
194
286
|
.filter((s) => s.name !== "_example")
|
|
195
|
-
.map((s) => s.name)
|
|
287
|
+
.map((s) => s.name),
|
|
196
288
|
);
|
|
197
289
|
// Detect newly appeared stories
|
|
198
290
|
for (const name of currentNames) {
|
|
199
|
-
if (
|
|
291
|
+
if (
|
|
292
|
+
!knownStoriesRef.current.has(name) &&
|
|
293
|
+
untitledSessions.length > 0
|
|
294
|
+
) {
|
|
200
295
|
// New story appeared — rename the oldest untitled session to the story name
|
|
201
296
|
const oldName = untitledSessions[0];
|
|
202
297
|
// Read the pending session's metadata BEFORE the rename so it can be
|
|
@@ -207,9 +302,14 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
207
302
|
const provider = agentProviderMap.current.get(oldName) || "claude";
|
|
208
303
|
let renamed = false;
|
|
209
304
|
if (renameRef.current) {
|
|
210
|
-
renamed = await renameRef
|
|
211
|
-
|
|
212
|
-
|
|
305
|
+
renamed = await renameRef
|
|
306
|
+
.current(oldName, name, {
|
|
307
|
+
contentType: ct,
|
|
308
|
+
language: lang,
|
|
309
|
+
agentMode: mode,
|
|
310
|
+
agentProvider: provider,
|
|
311
|
+
})
|
|
312
|
+
.catch(() => false);
|
|
213
313
|
}
|
|
214
314
|
if (renamed) {
|
|
215
315
|
setUntitledSessions((prev) => prev.slice(1));
|
|
@@ -232,7 +332,12 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
232
332
|
authFetch(`/api/stories/${name}/metadata`, {
|
|
233
333
|
method: "POST",
|
|
234
334
|
headers: { "Content-Type": "application/json" },
|
|
235
|
-
body: JSON.stringify({
|
|
335
|
+
body: JSON.stringify({
|
|
336
|
+
contentType: ct,
|
|
337
|
+
language: lang,
|
|
338
|
+
agentMode: mode,
|
|
339
|
+
agentProvider: provider,
|
|
340
|
+
}),
|
|
236
341
|
}).catch(() => {});
|
|
237
342
|
}
|
|
238
343
|
setSelectedStory(name);
|
|
@@ -240,60 +345,78 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
240
345
|
}
|
|
241
346
|
}
|
|
242
347
|
knownStoriesRef.current = currentNames;
|
|
243
|
-
} catch {
|
|
348
|
+
} catch {
|
|
349
|
+
/* ignore */
|
|
350
|
+
}
|
|
244
351
|
}, 3000);
|
|
245
352
|
return () => clearInterval(interval);
|
|
246
353
|
}, [authFetch, untitledSessions]);
|
|
247
354
|
|
|
248
355
|
// Initialize known stories on mount
|
|
249
356
|
useEffect(() => {
|
|
250
|
-
authFetch("/api/stories")
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
357
|
+
authFetch("/api/stories")
|
|
358
|
+
.then((res) => {
|
|
359
|
+
if (res.ok) return res.json();
|
|
360
|
+
})
|
|
361
|
+
.then((data) => {
|
|
362
|
+
if (data?.stories) {
|
|
363
|
+
knownStoriesRef.current = new Set(
|
|
364
|
+
(data.stories as { name: string }[])
|
|
365
|
+
.filter((s) => s.name !== "_example")
|
|
366
|
+
.map((s) => s.name),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
.catch(() => {});
|
|
261
371
|
}, [authFetch]);
|
|
262
372
|
|
|
263
|
-
const handleSelectFile = useCallback(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
373
|
+
const handleSelectFile = useCallback(
|
|
374
|
+
(storyName: string, fileName: string) => {
|
|
375
|
+
setSelectedStory(storyName);
|
|
376
|
+
setSelectedFile(fileName);
|
|
377
|
+
setCartoonView(null); // a file view supersedes a non-file workflow page
|
|
378
|
+
},
|
|
379
|
+
[],
|
|
380
|
+
);
|
|
268
381
|
|
|
269
382
|
const latestStoryRef = useRef<string | null>(null);
|
|
270
383
|
|
|
271
|
-
const handleSelectStory = useCallback(
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
384
|
+
const handleSelectStory = useCallback(
|
|
385
|
+
async (name: string) => {
|
|
386
|
+
latestStoryRef.current = name;
|
|
387
|
+
setSelectedStory(name);
|
|
388
|
+
setSelectedFile(null);
|
|
389
|
+
setCartoonView(null);
|
|
390
|
+
// Cartoon stories land on the story-level progress overview (#418). Fiction
|
|
391
|
+
// PRESERVES the existing auto-open-latest-file behavior (fiction can still
|
|
392
|
+
// reach the overview via the "Progress" button).
|
|
393
|
+
try {
|
|
394
|
+
const res = await authFetch(`/api/stories/${name}`);
|
|
395
|
+
if (res.ok && latestStoryRef.current === name) {
|
|
396
|
+
const data = await res.json();
|
|
397
|
+
if (data.contentType === "cartoon") return; // overview
|
|
398
|
+
const files: { file: string }[] = data.files || [];
|
|
399
|
+
const plots = files
|
|
400
|
+
.map((f) => ({
|
|
401
|
+
file: f.file,
|
|
402
|
+
num: f.file.match(/^plot-(\d+)\.md$/)?.[1],
|
|
403
|
+
}))
|
|
404
|
+
.filter((p) => p.num != null)
|
|
405
|
+
.sort((a, b) => parseInt(b.num!) - parseInt(a.num!));
|
|
406
|
+
const latest =
|
|
407
|
+
plots[0]?.file ??
|
|
408
|
+
files.find((f) => f.file === "genesis.md")?.file ??
|
|
409
|
+
files.find((f) => f.file === "structure.md")?.file ??
|
|
410
|
+
files[0]?.file;
|
|
411
|
+
if (latest && latestStoryRef.current === name)
|
|
412
|
+
setSelectedFile(latest);
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
/* ignore — stays on overview */
|
|
294
416
|
}
|
|
295
|
-
}
|
|
296
|
-
|
|
417
|
+
},
|
|
418
|
+
[authFetch],
|
|
419
|
+
);
|
|
297
420
|
|
|
298
421
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
299
422
|
e.preventDefault();
|
|
@@ -321,284 +444,400 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
321
444
|
window.addEventListener("mouseup", onMouseUp);
|
|
322
445
|
}, []);
|
|
323
446
|
|
|
324
|
-
const handlePublish = useCallback(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
// genesis publish and is immutable on-chain, so a headingless genesis.md
|
|
348
|
-
// must not fall back to the bare "genesis" filename. For genesis, fetch
|
|
349
|
-
// structure.md so its `# Title` H1 can stand in, with a prettified folder
|
|
350
|
-
// slug as the last resort. Best-effort: structure.md may be absent.
|
|
351
|
-
const publishContentType = storyContentTypes[storyName];
|
|
352
|
-
let structureContent: string | null = null;
|
|
353
|
-
let episodeTitle: string | null = null;
|
|
354
|
-
if (fileName === "genesis.md") {
|
|
355
|
-
try {
|
|
356
|
-
const structRes = await authFetch(`/api/stories/${storyName}/structure.md`);
|
|
357
|
-
if (structRes.ok) structureContent = (await structRes.json()).content ?? null;
|
|
358
|
-
} catch { /* best effort — fall back to the prettified slug */ }
|
|
359
|
-
} else if (publishContentType === "cartoon" && fileName.match(/^plot-\d+\.md$/)) {
|
|
360
|
-
// Cartoon publish markdown is image-only (no H1), so read the cut plan's
|
|
361
|
-
// episode title to avoid publishing the raw "plot-NN" filename (#347).
|
|
362
|
-
try {
|
|
363
|
-
const cutsRes = await authFetch(`/api/stories/${storyName}/cuts/${fileName.replace(/\.md$/, "")}`);
|
|
364
|
-
if (cutsRes.ok) episodeTitle = (await cutsRes.json()).title ?? null;
|
|
365
|
-
} catch { /* best effort — fall back to a friendly "Episode NN" */ }
|
|
366
|
-
}
|
|
367
|
-
const title = derivePublishTitle({
|
|
368
|
-
fileName,
|
|
369
|
-
fileContent: fileData.content,
|
|
370
|
-
storySlug: storyName,
|
|
371
|
-
structureContent,
|
|
372
|
-
contentType: publishContentType,
|
|
373
|
-
episodeTitle,
|
|
374
|
-
});
|
|
447
|
+
const handlePublish = useCallback(
|
|
448
|
+
async (
|
|
449
|
+
storyName: string,
|
|
450
|
+
fileName: string,
|
|
451
|
+
genre: string,
|
|
452
|
+
language: string,
|
|
453
|
+
isNsfw: boolean,
|
|
454
|
+
coverFile?: File | null,
|
|
455
|
+
) => {
|
|
456
|
+
setPublishingFile(fileName);
|
|
457
|
+
setPublishProgress("Reading file...");
|
|
458
|
+
setPublishError(null); // clear any prior durable block on a fresh attempt (#375)
|
|
459
|
+
let coverAttachFailed = false;
|
|
460
|
+
// Durable #379 public-title verification warning, set after indexing if
|
|
461
|
+
// PlotLink indexed a raw/generic public title (surfaced even though the
|
|
462
|
+
// publish itself succeeded — the metadata is immutable).
|
|
463
|
+
let titleVerifyWarning: string | null = null;
|
|
464
|
+
// Whether the publish actually SUCCEEDED on-chain (the SSE `done` event with a
|
|
465
|
+
// txHash). Returned to the caller so PreviewPanel drops the selected genesis
|
|
466
|
+
// cover ONLY on a confirmed-successful publish. A publish that is blocked
|
|
467
|
+
// before the stream (#375) OR opens then fails/errors before `done` (#376)
|
|
468
|
+
// leaves this false, so the writer's cover stays selected for the retry.
|
|
469
|
+
let succeeded = false;
|
|
375
470
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
setPublishProgress(
|
|
381
|
-
fileName === "genesis.md"
|
|
382
|
-
? "Add a real “# Title” heading to genesis.md before publishing — it would otherwise publish as a raw filename."
|
|
383
|
-
: "Set an episode title in the cut plan before publishing — it would otherwise publish as a raw filename.",
|
|
471
|
+
try {
|
|
472
|
+
// Get file content
|
|
473
|
+
const fileRes = await authFetch(
|
|
474
|
+
`/api/stories/${storyName}/${fileName}`,
|
|
384
475
|
);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
476
|
+
if (!fileRes.ok) throw new Error("Failed to read file");
|
|
477
|
+
const fileData = await fileRes.json();
|
|
388
478
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
)
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
479
|
+
// Derive the publish title (#331). The storyline title is set once at
|
|
480
|
+
// genesis publish and is immutable on-chain, so a headingless genesis.md
|
|
481
|
+
// must not fall back to the bare "genesis" filename. For genesis, fetch
|
|
482
|
+
// structure.md so its `# Title` H1 can stand in, with a prettified folder
|
|
483
|
+
// slug as the last resort. Best-effort: structure.md may be absent.
|
|
484
|
+
const publishContentType = storyContentTypes[storyName];
|
|
485
|
+
let structureContent: string | null = null;
|
|
486
|
+
let episodeTitle: string | null = null;
|
|
487
|
+
if (fileName === "genesis.md") {
|
|
488
|
+
try {
|
|
489
|
+
const structRes = await authFetch(
|
|
490
|
+
`/api/stories/${storyName}/structure.md`,
|
|
491
|
+
);
|
|
492
|
+
if (structRes.ok)
|
|
493
|
+
structureContent = (await structRes.json()).content ?? null;
|
|
494
|
+
} catch {
|
|
495
|
+
/* best effort — fall back to the prettified slug */
|
|
496
|
+
}
|
|
497
|
+
} else if (
|
|
498
|
+
publishContentType === "cartoon" &&
|
|
499
|
+
fileName.match(/^plot-\d+\.md$/)
|
|
500
|
+
) {
|
|
501
|
+
// Cartoon publish markdown is image-only (no H1), so read the cut plan's
|
|
502
|
+
// episode title to avoid publishing the raw "plot-NN" filename (#347).
|
|
503
|
+
try {
|
|
504
|
+
const cutsRes = await authFetch(
|
|
505
|
+
`/api/stories/${storyName}/cuts/${fileName.replace(/\.md$/, "")}`,
|
|
506
|
+
);
|
|
507
|
+
if (cutsRes.ok) episodeTitle = (await cutsRes.json()).title ?? null;
|
|
508
|
+
} catch {
|
|
509
|
+
/* best effort — fall back to a friendly "Episode NN" */
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const title = derivePublishTitle({
|
|
513
|
+
fileName,
|
|
514
|
+
fileContent: fileData.content,
|
|
515
|
+
storySlug: storyName,
|
|
516
|
+
structureContent,
|
|
517
|
+
contentType: publishContentType,
|
|
518
|
+
episodeTitle,
|
|
519
|
+
});
|
|
401
520
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
521
|
+
// Defense-in-depth (#358): never publish a cartoon story/episode whose
|
|
522
|
+
// public title is still a raw filename label ("genesis"/"plot-NN"). The
|
|
523
|
+
// publish panel already blocks this, but guard the action too.
|
|
524
|
+
if (
|
|
525
|
+
publishContentType === "cartoon" &&
|
|
526
|
+
isRawFilenameTitle(title, fileName)
|
|
527
|
+
) {
|
|
409
528
|
setPublishProgress(
|
|
410
|
-
|
|
529
|
+
fileName === "genesis.md"
|
|
530
|
+
? "Add a real “# Title” heading to genesis.md before publishing — it would otherwise publish as a raw filename."
|
|
531
|
+
: "Set an episode title in the cut plan before publishing — it would otherwise publish as a raw filename.",
|
|
411
532
|
);
|
|
412
|
-
setTimeout(() => {
|
|
533
|
+
setTimeout(() => {
|
|
534
|
+
setPublishingFile(null);
|
|
535
|
+
setPublishProgress("");
|
|
536
|
+
}, 6000);
|
|
413
537
|
return false;
|
|
414
538
|
}
|
|
415
|
-
}
|
|
416
539
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
540
|
+
// Defense-in-depth (#365, tightened #368): a cartoon plot must have an
|
|
541
|
+
// explicit reader-facing title (cut-plan title or a real H1) that is NOT a
|
|
542
|
+
// generic "Episode NN"/"Chapter NN"/"plot-NN" placeholder. Block the action
|
|
543
|
+
// too, not just the panel.
|
|
544
|
+
if (
|
|
545
|
+
publishContentType === "cartoon" &&
|
|
546
|
+
fileName.match(/^plot-\d+\.md$/) &&
|
|
547
|
+
!hasExplicitEpisodeTitle({
|
|
548
|
+
fileContent: fileData.content,
|
|
549
|
+
episodeTitle,
|
|
550
|
+
})
|
|
551
|
+
) {
|
|
427
552
|
setPublishProgress(
|
|
428
|
-
"
|
|
553
|
+
"Set a real episode title in the cut plan (or add a “# Title” to the episode) before publishing — a generic “Episode NN” placeholder can’t be published.",
|
|
429
554
|
);
|
|
430
|
-
setTimeout(() => {
|
|
431
|
-
|
|
555
|
+
setTimeout(() => {
|
|
556
|
+
setPublishingFile(null);
|
|
557
|
+
setPublishProgress("");
|
|
558
|
+
}, 6000);
|
|
559
|
+
return false;
|
|
432
560
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
561
|
+
|
|
562
|
+
// Defense-in-depth (#359, hardened in #400): a cartoon Genesis is the
|
|
563
|
+
// reader-facing opening, so block publish when it isn't a real story
|
|
564
|
+
// opening (missing H1, synopsis/outline shape, too short, or a single dense
|
|
565
|
+
// block) even if the panel guard is bypassed. Surface the specific blocker.
|
|
566
|
+
if (publishContentType === "cartoon" && fileName === "genesis.md") {
|
|
567
|
+
const genesisBlockers = cartoonGenesisReadiness(
|
|
568
|
+
fileData.content,
|
|
569
|
+
).blockers;
|
|
570
|
+
if (genesisBlockers.length > 0) {
|
|
571
|
+
setPublishProgress(
|
|
572
|
+
`Genesis is the reader-facing Story opening — fix it before publishing: ${genesisBlockers[0]}`,
|
|
573
|
+
);
|
|
574
|
+
setTimeout(() => {
|
|
575
|
+
setPublishingFile(null);
|
|
576
|
+
setPublishProgress("");
|
|
577
|
+
}, 6000);
|
|
578
|
+
return false;
|
|
440
579
|
}
|
|
441
|
-
} catch { /* ignore */ }
|
|
442
|
-
if (!storylineId) {
|
|
443
|
-
setPublishProgress("Error: Publish genesis first to create the storyline");
|
|
444
|
-
setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 3000);
|
|
445
|
-
return false;
|
|
446
580
|
}
|
|
447
|
-
}
|
|
448
581
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
582
|
+
// For plot files, find the storylineId from the genesis publish status
|
|
583
|
+
let storylineId: number | undefined;
|
|
584
|
+
if (fileName.match(/^plot-\d+\.md$/)) {
|
|
585
|
+
// #332: never mint a second chainPlot for a plot that already has an
|
|
586
|
+
// on-chain chapter recorded — a duplicate chainPlot creates a permanent
|
|
587
|
+
// extra chapter on PlotLink. fileData carries the retained txHash/
|
|
588
|
+
// plotIndex even when a later content edit reset status to "pending".
|
|
589
|
+
// The published-not-indexed recovery path is exempt (handled in the
|
|
590
|
+
// preview UI behind an explicit duplicate-risk confirm).
|
|
591
|
+
if (shouldBlockDuplicatePlotPublish(fileData)) {
|
|
592
|
+
setPublishProgress(
|
|
593
|
+
"Already published on PlotLink — republishing would create a duplicate chapter. Open it on PlotLink instead (or use Retry Index if it isn't showing yet).",
|
|
594
|
+
);
|
|
595
|
+
setTimeout(() => {
|
|
596
|
+
setPublishingFile(null);
|
|
597
|
+
setPublishProgress("");
|
|
598
|
+
}, 6000);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
const storyRes = await authFetch(`/api/stories/${storyName}`);
|
|
603
|
+
if (storyRes.ok) {
|
|
604
|
+
const storyData = await storyRes.json();
|
|
605
|
+
const genesis = storyData.files.find(
|
|
606
|
+
(f: { file: string; storylineId?: number }) =>
|
|
607
|
+
f.file === "genesis.md" && f.storylineId,
|
|
608
|
+
);
|
|
609
|
+
storylineId = genesis?.storylineId;
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
/* ignore */
|
|
613
|
+
}
|
|
614
|
+
if (!storylineId) {
|
|
615
|
+
setPublishProgress(
|
|
616
|
+
"Error: Publish genesis first to create the storyline",
|
|
617
|
+
);
|
|
618
|
+
setTimeout(() => {
|
|
619
|
+
setPublishingFile(null);
|
|
620
|
+
setPublishProgress("");
|
|
621
|
+
}, 3000);
|
|
467
622
|
return false;
|
|
468
623
|
}
|
|
469
624
|
}
|
|
470
|
-
} catch { /* preflight unreachable — don't hard-block; let the publish stream report */ }
|
|
471
625
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
626
|
+
// #375: gate on wallet balance BEFORE opening the publish stream. The
|
|
627
|
+
// pilot's publish proceeded into "Broadcasting transaction..." despite
|
|
628
|
+
// preflight already reporting insufficient ETH, then returned to draft with
|
|
629
|
+
// no durable error. Run preflight here and, if the OWS wallet can't cover at
|
|
630
|
+
// least the creation fee (or is otherwise not ready), block with a durable,
|
|
631
|
+
// obvious inline error instead of calling /api/publish/file. A preflight
|
|
632
|
+
// network/HTTP error is NOT treated as a block — fall through so a flaky
|
|
633
|
+
// preflight can't stop an otherwise-fundable publish (the stream surfaces
|
|
634
|
+
// its own error).
|
|
635
|
+
setPublishProgress("Checking wallet balance...");
|
|
636
|
+
try {
|
|
637
|
+
const preRes = await authFetch("/api/publish/preflight");
|
|
638
|
+
if (preRes.ok) {
|
|
639
|
+
const pre = await preRes.json();
|
|
640
|
+
if (isPreflightBlocked(pre)) {
|
|
641
|
+
setPublishError(formatPreflightBlock(pre));
|
|
642
|
+
setPublishingFile(null);
|
|
643
|
+
setPublishProgress("");
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
} catch {
|
|
648
|
+
/* preflight unreachable — don't hard-block; let the publish stream report */
|
|
649
|
+
}
|
|
482
650
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
651
|
+
// Run publish flow via SSE
|
|
652
|
+
setPublishProgress("Publishing...");
|
|
653
|
+
const publishRes = await authFetch("/api/publish/file", {
|
|
654
|
+
method: "POST",
|
|
655
|
+
headers: { "Content-Type": "application/json" },
|
|
656
|
+
body: JSON.stringify({
|
|
657
|
+
storyName,
|
|
658
|
+
fileName,
|
|
659
|
+
title,
|
|
660
|
+
content: fileData.content,
|
|
661
|
+
genre,
|
|
662
|
+
language,
|
|
663
|
+
isNsfw,
|
|
664
|
+
storylineId,
|
|
665
|
+
...(getContentTypeForPublish(
|
|
666
|
+
storyContentTypes,
|
|
667
|
+
storyName,
|
|
668
|
+
storylineId,
|
|
669
|
+
)
|
|
670
|
+
? {
|
|
671
|
+
contentType: getContentTypeForPublish(
|
|
672
|
+
storyContentTypes,
|
|
673
|
+
storyName,
|
|
674
|
+
storylineId,
|
|
675
|
+
),
|
|
676
|
+
}
|
|
677
|
+
: {}),
|
|
678
|
+
}),
|
|
679
|
+
});
|
|
487
680
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
681
|
+
if (!publishRes.ok) {
|
|
682
|
+
const err = await publishRes.json();
|
|
683
|
+
throw new Error(err.error || "Publish failed");
|
|
684
|
+
}
|
|
491
685
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if (done) break;
|
|
496
|
-
const text = decoder.decode(value);
|
|
497
|
-
const lines = text.split("\n").filter((l) => l.startsWith("data: "));
|
|
498
|
-
for (const line of lines) {
|
|
499
|
-
try {
|
|
500
|
-
const data = JSON.parse(line.slice(6));
|
|
501
|
-
if (data.step) setPublishProgress(data.message || data.step);
|
|
502
|
-
if (data.step === "done" && data.txHash) {
|
|
503
|
-
// Publish confirmed on-chain — the only point at which the cover
|
|
504
|
-
// selection may be dropped (#376). Anything short of this (a
|
|
505
|
-
// pre-stream block, a non-ok response, an error before `done`, or
|
|
506
|
-
// a stream that ends without `done`) leaves `succeeded` false so
|
|
507
|
-
// PreviewPanel keeps the selected/auto-detected cover for retry.
|
|
508
|
-
succeeded = true;
|
|
509
|
-
// Update publish status with gasCost
|
|
510
|
-
await authFetch(`/api/stories/${storyName}/${fileName}/publish-status`, {
|
|
511
|
-
method: "POST",
|
|
512
|
-
headers: { "Content-Type": "application/json" },
|
|
513
|
-
body: JSON.stringify({
|
|
514
|
-
txHash: data.txHash,
|
|
515
|
-
storylineId: data.storylineId,
|
|
516
|
-
plotIndex: data.plotIndex,
|
|
517
|
-
contentCid: data.contentCid,
|
|
518
|
-
gasCost: data.gasCost,
|
|
519
|
-
indexError: data.indexError,
|
|
520
|
-
authorAddress: walletAddress,
|
|
521
|
-
}),
|
|
522
|
-
});
|
|
523
|
-
// Advance the cartoon Publish page to the next episode (#461).
|
|
524
|
-
setCartoonPublishRefresh((n) => n + 1);
|
|
686
|
+
// Read SSE stream
|
|
687
|
+
const reader = publishRes.body?.getReader();
|
|
688
|
+
const decoder = new TextDecoder();
|
|
525
689
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
690
|
+
if (reader) {
|
|
691
|
+
while (true) {
|
|
692
|
+
const { done, value } = await reader.read();
|
|
693
|
+
if (done) break;
|
|
694
|
+
const text = decoder.decode(value);
|
|
695
|
+
const lines = text
|
|
696
|
+
.split("\n")
|
|
697
|
+
.filter((l) => l.startsWith("data: "));
|
|
698
|
+
for (const line of lines) {
|
|
699
|
+
try {
|
|
700
|
+
const data = JSON.parse(line.slice(6));
|
|
701
|
+
if (data.step) setPublishProgress(data.message || data.step);
|
|
702
|
+
if (data.step === "done" && data.txHash) {
|
|
703
|
+
// Publish confirmed on-chain — the only point at which the cover
|
|
704
|
+
// selection may be dropped (#376). Anything short of this (a
|
|
705
|
+
// pre-stream block, a non-ok response, an error before `done`, or
|
|
706
|
+
// a stream that ends without `done`) leaves `succeeded` false so
|
|
707
|
+
// PreviewPanel keeps the selected/auto-detected cover for retry.
|
|
708
|
+
succeeded = true;
|
|
709
|
+
// Update publish status with gasCost
|
|
710
|
+
await authFetch(
|
|
711
|
+
`/api/stories/${storyName}/${fileName}/publish-status`,
|
|
712
|
+
{
|
|
713
|
+
method: "POST",
|
|
714
|
+
headers: { "Content-Type": "application/json" },
|
|
715
|
+
body: JSON.stringify({
|
|
716
|
+
txHash: data.txHash,
|
|
717
|
+
storylineId: data.storylineId,
|
|
718
|
+
plotIndex: data.plotIndex,
|
|
719
|
+
contentCid: data.contentCid,
|
|
720
|
+
gasCost: data.gasCost,
|
|
721
|
+
indexError: data.indexError,
|
|
722
|
+
authorAddress: walletAddress,
|
|
723
|
+
}),
|
|
724
|
+
},
|
|
725
|
+
);
|
|
726
|
+
// Advance the cartoon Publish page to the next episode (#461).
|
|
727
|
+
setCartoonPublishRefresh((n) => n + 1);
|
|
542
728
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const verdict = verifyPublicCartoonTitle({ fileName, detail, plotIndex: data.plotIndex });
|
|
564
|
-
if (!verdict.ok) titleVerifyWarning = publicTitleWarning(verdict);
|
|
729
|
+
// Pre-publish cover (#284): a new genesis can't carry a cover
|
|
730
|
+
// through createStoryline, so once the storyline exists, attach
|
|
731
|
+
// the selected cover via upload-cover + update-storyline. Best-
|
|
732
|
+
// effort — a failure leaves the storyline published with no
|
|
733
|
+
// cover, settable later via Edit Story.
|
|
734
|
+
if (
|
|
735
|
+
coverFile &&
|
|
736
|
+
fileName === "genesis.md" &&
|
|
737
|
+
data.storylineId
|
|
738
|
+
) {
|
|
739
|
+
setPublishProgress("Uploading cover...");
|
|
740
|
+
let coverCid: string | null = null;
|
|
741
|
+
try {
|
|
742
|
+
coverCid = await attachCoverToStoryline(
|
|
743
|
+
authFetch,
|
|
744
|
+
data.storylineId,
|
|
745
|
+
coverFile,
|
|
746
|
+
);
|
|
747
|
+
} catch {
|
|
748
|
+
/* non-fatal: storyline is already published */
|
|
565
749
|
}
|
|
566
|
-
|
|
750
|
+
// A null result means the cover was not attached (upload or
|
|
751
|
+
// update-storyline failed). The storyline is published either
|
|
752
|
+
// way; tell the user so they can retry from Edit Story.
|
|
753
|
+
if (!coverCid) coverAttachFailed = true;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// #379: end-to-end public-title verification. Local guards ensure
|
|
757
|
+
// OWS sends a reader-facing title, but the pilot showed PlotLink
|
|
758
|
+
// can still index a raw "genesis"/"plot-NN" title. There is no
|
|
759
|
+
// public JSON read endpoint, so an OWS server route reads the
|
|
760
|
+
// rendered public page's og:title (no CORS) and returns the
|
|
761
|
+
// indexed title; verify it here. Inconclusive reads (page
|
|
762
|
+
// unreachable / no title) never warn — only a confirmed
|
|
763
|
+
// raw/generic public title does. The publish is already on-chain +
|
|
764
|
+
// immutable, so this can only warn.
|
|
765
|
+
if (publishContentType === "cartoon" && data.storylineId) {
|
|
766
|
+
try {
|
|
767
|
+
const isPlot = fileName !== "genesis.md";
|
|
768
|
+
const q =
|
|
769
|
+
`storylineId=${data.storylineId}` +
|
|
770
|
+
(isPlot && data.plotIndex != null
|
|
771
|
+
? `&plotIndex=${data.plotIndex}`
|
|
772
|
+
: "");
|
|
773
|
+
const pubRes = await authFetch(
|
|
774
|
+
`/api/publish/public-title?${q}`,
|
|
775
|
+
);
|
|
776
|
+
if (pubRes.ok) {
|
|
777
|
+
const pub = await pubRes.json();
|
|
778
|
+
const detail = isPlot
|
|
779
|
+
? {
|
|
780
|
+
plots:
|
|
781
|
+
pub.plotTitle != null
|
|
782
|
+
? [
|
|
783
|
+
{
|
|
784
|
+
plotIndex: data.plotIndex,
|
|
785
|
+
title: pub.plotTitle,
|
|
786
|
+
},
|
|
787
|
+
]
|
|
788
|
+
: [],
|
|
789
|
+
}
|
|
790
|
+
: { title: pub.storylineTitle };
|
|
791
|
+
const verdict = verifyPublicCartoonTitle({
|
|
792
|
+
fileName,
|
|
793
|
+
detail,
|
|
794
|
+
plotIndex: data.plotIndex,
|
|
795
|
+
});
|
|
796
|
+
if (!verdict.ok)
|
|
797
|
+
titleVerifyWarning = publicTitleWarning(verdict);
|
|
798
|
+
}
|
|
799
|
+
} catch {
|
|
800
|
+
/* inconclusive — don't false-warn on a read failure */
|
|
801
|
+
}
|
|
802
|
+
}
|
|
567
803
|
}
|
|
804
|
+
} catch {
|
|
805
|
+
/* ignore partial SSE */
|
|
568
806
|
}
|
|
569
|
-
}
|
|
807
|
+
}
|
|
570
808
|
}
|
|
571
809
|
}
|
|
572
|
-
}
|
|
573
810
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
811
|
+
// A failed public-title verification (#379) is a durable warning that
|
|
812
|
+
// outranks the transient "Published!" line — the metadata is immutable, so
|
|
813
|
+
// the writer must know the next publish needs corrected metadata.
|
|
814
|
+
if (titleVerifyWarning) setPublishError(titleVerifyWarning);
|
|
815
|
+
setPublishProgress(
|
|
816
|
+
coverAttachFailed
|
|
817
|
+
? "Published, but cover upload failed — set it later from Edit Story."
|
|
818
|
+
: "Published!",
|
|
819
|
+
);
|
|
820
|
+
} catch (err: unknown) {
|
|
821
|
+
const message = err instanceof Error ? err.message : "Publish failed";
|
|
822
|
+
setPublishProgress(`Error: ${message}`);
|
|
823
|
+
} finally {
|
|
824
|
+
setTimeout(() => {
|
|
825
|
+
setPublishingFile(null);
|
|
826
|
+
setPublishProgress("");
|
|
827
|
+
}, 3000);
|
|
828
|
+
}
|
|
829
|
+
// Tell PreviewPanel whether it may drop the selected cover. Clear ONLY when
|
|
830
|
+
// the publish is confirmed on-chain AND the cover was actually attached:
|
|
831
|
+
// - pre-stream block (#375) or failed/aborted publish (#376) → succeeded false → keep,
|
|
832
|
+
// - published on-chain but the cover upload/attach failed (#376/re1) →
|
|
833
|
+
// coverAttachFailed true → keep, so the writer doesn't silently lose the
|
|
834
|
+
// cover that never made it onto the storyline (settable via Edit Story).
|
|
835
|
+
// A publish with no selected cover (coverAttachFailed stays false) clears as
|
|
836
|
+
// before once it succeeds.
|
|
837
|
+
return succeeded && !coverAttachFailed;
|
|
838
|
+
},
|
|
839
|
+
[authFetch, storyContentTypes, walletAddress],
|
|
840
|
+
);
|
|
602
841
|
|
|
603
842
|
const handleDestroySession = useCallback((name: string) => {
|
|
604
843
|
if (name.startsWith("_new_")) {
|
|
@@ -623,9 +862,25 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
623
862
|
}, []);
|
|
624
863
|
|
|
625
864
|
useEffect(() => {
|
|
626
|
-
const updateFromStories = (
|
|
627
|
-
|
|
628
|
-
|
|
865
|
+
const updateFromStories = (
|
|
866
|
+
stories: {
|
|
867
|
+
name: string;
|
|
868
|
+
title?: string | null;
|
|
869
|
+
hasStructure: boolean;
|
|
870
|
+
hasGenesis?: boolean;
|
|
871
|
+
contentType?: "fiction" | "cartoon";
|
|
872
|
+
language?: string;
|
|
873
|
+
genre?: string;
|
|
874
|
+
isNsfw?: boolean;
|
|
875
|
+
agentProvider?: "claude" | "codex";
|
|
876
|
+
}[],
|
|
877
|
+
) => {
|
|
878
|
+
setConfirmedStories(
|
|
879
|
+
new Set(stories.filter((s) => s.hasStructure).map((s) => s.name)),
|
|
880
|
+
);
|
|
881
|
+
setGenesisStories(
|
|
882
|
+
new Set(stories.filter((s) => s.hasGenesis).map((s) => s.name)),
|
|
883
|
+
);
|
|
629
884
|
const ct: Record<string, "fiction" | "cartoon"> = {};
|
|
630
885
|
const lang: Record<string, string | undefined> = {};
|
|
631
886
|
const genre: Record<string, string | undefined> = {};
|
|
@@ -649,9 +904,12 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
649
904
|
setStoryProviders(prov);
|
|
650
905
|
setStoryTitles(titles);
|
|
651
906
|
};
|
|
652
|
-
authFetch("/api/stories")
|
|
653
|
-
|
|
654
|
-
|
|
907
|
+
authFetch("/api/stories")
|
|
908
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
909
|
+
.then((data) => {
|
|
910
|
+
if (data?.stories) updateFromStories(data.stories);
|
|
911
|
+
})
|
|
912
|
+
.catch(() => {});
|
|
655
913
|
const interval = setInterval(async () => {
|
|
656
914
|
try {
|
|
657
915
|
const res = await authFetch("/api/stories");
|
|
@@ -659,7 +917,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
659
917
|
const data = await res.json();
|
|
660
918
|
updateFromStories(data.stories);
|
|
661
919
|
}
|
|
662
|
-
} catch {
|
|
920
|
+
} catch {
|
|
921
|
+
/* ignore */
|
|
922
|
+
}
|
|
663
923
|
}, 5000);
|
|
664
924
|
return () => clearInterval(interval);
|
|
665
925
|
}, [authFetch]);
|
|
@@ -670,15 +930,21 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
670
930
|
// is still null (loading or probe-endpoint failure) we DO NOT block, to avoid
|
|
671
931
|
// permanently bricking cartoon if the probe errors.
|
|
672
932
|
const codexReady =
|
|
673
|
-
!!readiness &&
|
|
933
|
+
!!readiness &&
|
|
934
|
+
readiness.codex.installed &&
|
|
935
|
+
readiness.codex.imageGeneration === "enabled";
|
|
674
936
|
const cartoonBlocked = !!readiness && !codexReady;
|
|
675
937
|
|
|
676
938
|
const copyCodexEnable = useCallback(async () => {
|
|
677
939
|
try {
|
|
678
|
-
await navigator.clipboard.writeText(
|
|
940
|
+
await navigator.clipboard.writeText(
|
|
941
|
+
"codex features enable image_generation",
|
|
942
|
+
);
|
|
679
943
|
setCodexEnableCopied(true);
|
|
680
944
|
setTimeout(() => setCodexEnableCopied(false), 2000);
|
|
681
|
-
} catch {
|
|
945
|
+
} catch {
|
|
946
|
+
/* clipboard unavailable */
|
|
947
|
+
}
|
|
682
948
|
}, []);
|
|
683
949
|
|
|
684
950
|
// Explicit, scoped repair for a legacy cartoon story with no recorded
|
|
@@ -704,29 +970,41 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
704
970
|
const data = await listRes.json();
|
|
705
971
|
if (data?.stories) {
|
|
706
972
|
const prov: Record<string, "claude" | "codex" | undefined> = {};
|
|
707
|
-
for (const s of data.stories as {
|
|
973
|
+
for (const s of data.stories as {
|
|
974
|
+
name: string;
|
|
975
|
+
agentProvider?: "claude" | "codex";
|
|
976
|
+
}[]) {
|
|
708
977
|
prov[s.name] = s.agentProvider;
|
|
709
978
|
}
|
|
710
979
|
setStoryProviders(prov);
|
|
711
980
|
}
|
|
712
981
|
}
|
|
713
|
-
} catch {
|
|
982
|
+
} catch {
|
|
983
|
+
/* ignore */
|
|
984
|
+
}
|
|
714
985
|
}, [authFetch, selectedStory]);
|
|
715
986
|
|
|
716
|
-
const handleArchiveStory = useCallback(
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
987
|
+
const handleArchiveStory = useCallback(
|
|
988
|
+
(name: string) => {
|
|
989
|
+
// Archive API already called by TerminalPanel — just clear selection
|
|
990
|
+
if (selectedStory === name) {
|
|
991
|
+
setSelectedStory(null);
|
|
992
|
+
setSelectedFile(null);
|
|
993
|
+
}
|
|
994
|
+
},
|
|
995
|
+
[selectedStory],
|
|
996
|
+
);
|
|
723
997
|
|
|
724
998
|
// Resolve the effective provider for the selected story: an optimistic/new
|
|
725
999
|
// session value (agentProviders) wins, else the persisted list value.
|
|
726
1000
|
const selectedProvider = selectedStory
|
|
727
1001
|
? (agentProviders[selectedStory] ?? storyProviders[selectedStory])
|
|
728
1002
|
: undefined;
|
|
729
|
-
const selectedContentType = resolveSelectedContentType(
|
|
1003
|
+
const selectedContentType = resolveSelectedContentType(
|
|
1004
|
+
selectedStory,
|
|
1005
|
+
storyContentTypes,
|
|
1006
|
+
contentTypeMap.current,
|
|
1007
|
+
);
|
|
730
1008
|
const selectedNeedsProviderRepair = needsLegacyProviderRepair(
|
|
731
1009
|
selectedContentType,
|
|
732
1010
|
selectedProvider,
|
|
@@ -738,160 +1016,307 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
738
1016
|
// ⇒ Whitepaper, genesis.md ⇒ Genesis / Ep 1, plot-NN ⇒ Episodes, else Progress.
|
|
739
1017
|
const isCartoonStory = !!selectedStory && selectedContentType === "cartoon";
|
|
740
1018
|
const activeCartoonTab: CartoonWorkflowTab =
|
|
741
|
-
cartoonView === "story-info"
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1019
|
+
cartoonView === "story-info"
|
|
1020
|
+
? "story-info"
|
|
1021
|
+
: cartoonView === "episodes"
|
|
1022
|
+
? "episodes"
|
|
1023
|
+
: cartoonView === "publish"
|
|
1024
|
+
? "publish"
|
|
1025
|
+
: selectedFile === "structure.md"
|
|
1026
|
+
? "whitepaper"
|
|
1027
|
+
: selectedFile === "genesis.md"
|
|
1028
|
+
? "genesis"
|
|
1029
|
+
: selectedFile && /^plot-\d+\.md$/.test(selectedFile)
|
|
1030
|
+
? "episodes"
|
|
1031
|
+
: "progress";
|
|
748
1032
|
|
|
749
|
-
const handleCartoonNav = useCallback(
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1033
|
+
const handleCartoonNav = useCallback(
|
|
1034
|
+
(tab: CartoonWorkflowTab) => {
|
|
1035
|
+
// Use the current selected story, not latestStoryRef — that ref is only set
|
|
1036
|
+
// by handleSelectStory, so opening another story's file via the LEFT tree
|
|
1037
|
+
// (handleSelectFile) would leave it stale and route file tabs to the wrong
|
|
1038
|
+
// story (#445 RE1). `selectedStory` always reflects the visible story.
|
|
1039
|
+
const story = selectedStory;
|
|
1040
|
+
if (!story) return;
|
|
1041
|
+
switch (tab) {
|
|
1042
|
+
case "progress":
|
|
1043
|
+
setCartoonView(null);
|
|
1044
|
+
setSelectedFile(null);
|
|
1045
|
+
break;
|
|
1046
|
+
case "story-info":
|
|
1047
|
+
setCartoonView("story-info");
|
|
1048
|
+
break;
|
|
1049
|
+
case "episodes":
|
|
1050
|
+
setCartoonView("episodes");
|
|
1051
|
+
break;
|
|
1052
|
+
case "whitepaper":
|
|
1053
|
+
handleSelectFile(story, "structure.md");
|
|
1054
|
+
break;
|
|
1055
|
+
case "genesis":
|
|
1056
|
+
handleSelectFile(story, "genesis.md");
|
|
1057
|
+
break;
|
|
1058
|
+
// Publish opens its own readiness page and stays on the Publish tab (#449),
|
|
1059
|
+
// instead of visually routing to the Genesis file view.
|
|
1060
|
+
case "publish":
|
|
1061
|
+
setCartoonView("publish");
|
|
1062
|
+
break;
|
|
1063
|
+
}
|
|
1064
|
+
},
|
|
1065
|
+
[selectedStory, handleSelectFile],
|
|
1066
|
+
);
|
|
767
1067
|
|
|
768
|
-
const handleWorkflowNextAction = useCallback(
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1068
|
+
const handleWorkflowNextAction = useCallback(
|
|
1069
|
+
(action: CoachUiAction, episodeFile: string | null) => {
|
|
1070
|
+
const story = selectedStory;
|
|
1071
|
+
if (!story) return;
|
|
1072
|
+
const queueWorkflowAction = (nextAction: CoachUiAction) => {
|
|
1073
|
+
workflowActionSeqRef.current += 1;
|
|
1074
|
+
setWorkflowActionRequest({
|
|
1075
|
+
action: nextAction,
|
|
1076
|
+
seq: workflowActionSeqRef.current,
|
|
1077
|
+
});
|
|
1078
|
+
};
|
|
1079
|
+
switch (action) {
|
|
1080
|
+
case "view-progress":
|
|
1081
|
+
setCartoonView(null);
|
|
1082
|
+
setSelectedFile(null);
|
|
1083
|
+
break;
|
|
1084
|
+
case "publish":
|
|
1085
|
+
setCartoonView("publish");
|
|
1086
|
+
break;
|
|
1087
|
+
case "open-cuts":
|
|
1088
|
+
case "open-lettering":
|
|
1089
|
+
case "upload":
|
|
1090
|
+
case "refresh-assets":
|
|
1091
|
+
case "generate-markdown":
|
|
1092
|
+
if (!episodeFile) return;
|
|
1093
|
+
handleSelectFile(story, episodeFile);
|
|
1094
|
+
queueWorkflowAction(action);
|
|
1095
|
+
break;
|
|
1096
|
+
}
|
|
1097
|
+
},
|
|
1098
|
+
[selectedStory, handleSelectFile],
|
|
1099
|
+
);
|
|
788
1100
|
|
|
789
1101
|
// Keep the publish-control seeds in sync after a Story Info save (#439).
|
|
790
|
-
const handleStoryInfoSaved = useCallback(
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1102
|
+
const handleStoryInfoSaved = useCallback(
|
|
1103
|
+
(patch: { genre?: string; language?: string; isNsfw?: boolean }) => {
|
|
1104
|
+
if (!selectedStory) return;
|
|
1105
|
+
if (patch.genre !== undefined)
|
|
1106
|
+
setStoryGenres((prev) => ({
|
|
1107
|
+
...prev,
|
|
1108
|
+
[selectedStory]: patch.genre || undefined,
|
|
1109
|
+
}));
|
|
1110
|
+
if (patch.language !== undefined)
|
|
1111
|
+
setStoryLanguages((prev) => ({
|
|
1112
|
+
...prev,
|
|
1113
|
+
[selectedStory]: patch.language || undefined,
|
|
1114
|
+
}));
|
|
1115
|
+
if (patch.isNsfw !== undefined)
|
|
1116
|
+
setStoryNsfw((prev) => ({ ...prev, [selectedStory]: patch.isNsfw }));
|
|
1117
|
+
},
|
|
1118
|
+
[selectedStory],
|
|
1119
|
+
);
|
|
1120
|
+
|
|
1121
|
+
const handleFocusedLetteringModeChange = useCallback((active: boolean) => {
|
|
1122
|
+
setFocusedLetteringMode(active);
|
|
1123
|
+
setFocusedLetteringWorkspaceVisible(active ? false : true);
|
|
1124
|
+
}, []);
|
|
1125
|
+
|
|
1126
|
+
const hideFocusedLetteringWorkspace =
|
|
1127
|
+
focusedLetteringMode && !focusedLetteringWorkspaceVisible;
|
|
796
1128
|
|
|
797
1129
|
return (
|
|
798
|
-
<div
|
|
1130
|
+
<div
|
|
1131
|
+
ref={containerRef}
|
|
1132
|
+
className="h-[calc(100vh-3.5rem)] flex"
|
|
1133
|
+
data-testid={
|
|
1134
|
+
hideFocusedLetteringWorkspace
|
|
1135
|
+
? "stories-focused-lettering-mode"
|
|
1136
|
+
: "stories-default-layout"
|
|
1137
|
+
}
|
|
1138
|
+
>
|
|
799
1139
|
{/* Story Browser Sidebar */}
|
|
800
|
-
|
|
801
|
-
<
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1140
|
+
{!hideFocusedLetteringWorkspace && (
|
|
1141
|
+
<div className="w-56 border-r border-border flex-shrink-0">
|
|
1142
|
+
<StoryBrowser
|
|
1143
|
+
authFetch={authFetch}
|
|
1144
|
+
selectedStory={selectedStory}
|
|
1145
|
+
selectedFile={selectedFile}
|
|
1146
|
+
onSelectFile={handleSelectFile}
|
|
1147
|
+
onNewStory={handleNewStory}
|
|
1148
|
+
untitledSessions={untitledSessions}
|
|
1149
|
+
/>
|
|
1150
|
+
</div>
|
|
1151
|
+
)}
|
|
810
1152
|
|
|
811
1153
|
{/* Terminal — sized by ratio of available space */}
|
|
812
|
-
|
|
813
|
-
<
|
|
814
|
-
|
|
1154
|
+
{!hideFocusedLetteringWorkspace && (
|
|
1155
|
+
<div
|
|
1156
|
+
className="min-w-0 border-r border-border"
|
|
1157
|
+
style={{ flex: `${ratio} 0 0` }}
|
|
1158
|
+
>
|
|
1159
|
+
<TerminalPanel
|
|
1160
|
+
token={token}
|
|
1161
|
+
storyName={selectedStory}
|
|
1162
|
+
authFetch={authFetch}
|
|
1163
|
+
onSelectStory={handleSelectStory}
|
|
1164
|
+
onDestroySession={handleDestroySession}
|
|
1165
|
+
onArchiveStory={handleArchiveStory}
|
|
1166
|
+
confirmedStories={confirmedStories}
|
|
1167
|
+
renameRef={renameRef}
|
|
1168
|
+
bypassStories={bypassStories}
|
|
1169
|
+
agentProviders={agentProviders}
|
|
1170
|
+
readiness={readiness}
|
|
1171
|
+
contentType={resolveSelectedContentType(
|
|
1172
|
+
selectedStory,
|
|
1173
|
+
storyContentTypes,
|
|
1174
|
+
contentTypeMap.current,
|
|
1175
|
+
)}
|
|
1176
|
+
needsProviderRepair={selectedNeedsProviderRepair}
|
|
1177
|
+
onRepairProvider={handleRepairProvider}
|
|
1178
|
+
/>
|
|
1179
|
+
</div>
|
|
1180
|
+
)}
|
|
815
1181
|
|
|
816
1182
|
{/* Drag Handle */}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1183
|
+
{!hideFocusedLetteringWorkspace && (
|
|
1184
|
+
<div
|
|
1185
|
+
onMouseDown={handleMouseDown}
|
|
1186
|
+
className="flex-shrink-0 flex items-center justify-center hover:bg-border/50 transition-colors"
|
|
1187
|
+
style={{
|
|
1188
|
+
width: HANDLE_PX,
|
|
1189
|
+
cursor: "col-resize",
|
|
1190
|
+
background: "var(--border)",
|
|
1191
|
+
}}
|
|
1192
|
+
>
|
|
1193
|
+
<div className="flex flex-col gap-1">
|
|
1194
|
+
<div
|
|
1195
|
+
className="w-0.5 h-0.5 rounded-full"
|
|
1196
|
+
style={{ background: "var(--text-muted)" }}
|
|
1197
|
+
/>
|
|
1198
|
+
<div
|
|
1199
|
+
className="w-0.5 h-0.5 rounded-full"
|
|
1200
|
+
style={{ background: "var(--text-muted)" }}
|
|
1201
|
+
/>
|
|
1202
|
+
<div
|
|
1203
|
+
className="w-0.5 h-0.5 rounded-full"
|
|
1204
|
+
style={{ background: "var(--text-muted)" }}
|
|
1205
|
+
/>
|
|
1206
|
+
</div>
|
|
826
1207
|
</div>
|
|
827
|
-
|
|
1208
|
+
)}
|
|
828
1209
|
|
|
829
1210
|
{/* Preview — takes remaining space. With a story but no file selected, show
|
|
830
1211
|
the story-level progress overview (#418) instead of the empty state. */}
|
|
831
|
-
<div
|
|
1212
|
+
<div
|
|
1213
|
+
className="min-w-0 min-h-0 flex flex-col"
|
|
1214
|
+
style={
|
|
1215
|
+
hideFocusedLetteringWorkspace
|
|
1216
|
+
? { flex: "1 0 0" }
|
|
1217
|
+
: { flex: `${1 - ratio} 0 0` }
|
|
1218
|
+
}
|
|
1219
|
+
>
|
|
832
1220
|
{/* Cartoon workflow nav (#439) — persistent above the right-panel content. */}
|
|
833
|
-
{isCartoonStory && selectedStory && (
|
|
1221
|
+
{!focusedLetteringMode && isCartoonStory && selectedStory && (
|
|
834
1222
|
<CartoonWorkflowNav
|
|
835
1223
|
storyTitle={storyTitles[selectedStory] || selectedStory}
|
|
836
1224
|
active={activeCartoonTab}
|
|
837
1225
|
onSelect={handleCartoonNav}
|
|
838
1226
|
/>
|
|
839
1227
|
)}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
<
|
|
1228
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
1229
|
+
{isCartoonStory && cartoonView === "story-info" && selectedStory ? (
|
|
1230
|
+
<StoryInfoPage
|
|
843
1231
|
storyName={selectedStory}
|
|
844
1232
|
authFetch={authFetch}
|
|
845
|
-
|
|
846
|
-
|
|
1233
|
+
onSaved={handleStoryInfoSaved}
|
|
1234
|
+
/>
|
|
1235
|
+
) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
|
|
1236
|
+
<EpisodesPage
|
|
1237
|
+
storyName={selectedStory}
|
|
1238
|
+
authFetch={authFetch}
|
|
1239
|
+
onOpenFile={handleSelectFile}
|
|
1240
|
+
/>
|
|
1241
|
+
) : isCartoonStory && cartoonView === "publish" && selectedStory ? (
|
|
1242
|
+
<CartoonPublishPage
|
|
1243
|
+
storyName={selectedStory}
|
|
1244
|
+
authFetch={authFetch}
|
|
1245
|
+
onOpenFile={handleSelectFile}
|
|
847
1246
|
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
1247
|
+
onPublish={handlePublish}
|
|
1248
|
+
publishingFile={publishingFile}
|
|
1249
|
+
genre={storyGenres[selectedStory]}
|
|
1250
|
+
language={storyLanguages[selectedStory]}
|
|
1251
|
+
isNsfw={storyNsfw[selectedStory]}
|
|
1252
|
+
refreshKey={cartoonPublishRefresh}
|
|
1253
|
+
/>
|
|
1254
|
+
) : selectedStory && !selectedFile ? (
|
|
1255
|
+
<StoryProgressPanel
|
|
1256
|
+
storyName={selectedStory}
|
|
1257
|
+
authFetch={authFetch}
|
|
1258
|
+
onOpenFile={handleSelectFile}
|
|
848
1259
|
/>
|
|
1260
|
+
) : (
|
|
1261
|
+
<PreviewPanel
|
|
1262
|
+
storyName={selectedStory}
|
|
1263
|
+
fileName={selectedFile}
|
|
1264
|
+
authFetch={authFetch}
|
|
1265
|
+
onPublish={handlePublish}
|
|
1266
|
+
publishingFile={publishingFile}
|
|
1267
|
+
walletAddress={walletAddress}
|
|
1268
|
+
contentType={
|
|
1269
|
+
resolveSelectedContentType(
|
|
1270
|
+
selectedStory,
|
|
1271
|
+
storyContentTypes,
|
|
1272
|
+
contentTypeMap.current,
|
|
1273
|
+
) || "fiction"
|
|
1274
|
+
}
|
|
1275
|
+
language={selectedStory ? storyLanguages[selectedStory] : undefined}
|
|
1276
|
+
genre={selectedStory ? storyGenres[selectedStory] : undefined}
|
|
1277
|
+
isNsfw={selectedStory ? storyNsfw[selectedStory] : undefined}
|
|
1278
|
+
hasGenesis={
|
|
1279
|
+
selectedStory ? genesisStories.has(selectedStory) : false
|
|
1280
|
+
}
|
|
1281
|
+
onViewProgress={() => setSelectedFile(null)}
|
|
1282
|
+
onOpenFile={(file) =>
|
|
1283
|
+
selectedStory && handleSelectFile(selectedStory, file)
|
|
1284
|
+
}
|
|
1285
|
+
onViewPublish={() => setCartoonView("publish")}
|
|
1286
|
+
focusedLetteringMode={focusedLetteringMode}
|
|
1287
|
+
focusedLetteringWorkspaceVisible={focusedLetteringWorkspaceVisible}
|
|
1288
|
+
onFocusedLetteringModeChange={handleFocusedLetteringModeChange}
|
|
1289
|
+
onFocusedLetteringWorkspaceVisibleChange={
|
|
1290
|
+
setFocusedLetteringWorkspaceVisible
|
|
1291
|
+
}
|
|
1292
|
+
workflowActionRequest={workflowActionRequest}
|
|
1293
|
+
onWorkflowActionHandled={(seq) =>
|
|
1294
|
+
setWorkflowActionRequest((prev) =>
|
|
1295
|
+
prev?.seq === seq ? null : prev,
|
|
1296
|
+
)
|
|
1297
|
+
}
|
|
1298
|
+
/>
|
|
1299
|
+
)}
|
|
1300
|
+
</div>
|
|
1301
|
+
{!focusedLetteringMode && isCartoonStory && selectedStory && (
|
|
1302
|
+
<div
|
|
1303
|
+
className="pointer-events-none absolute bottom-4 right-4 z-10 w-[min(22rem,calc(100%-2rem))]"
|
|
1304
|
+
data-testid="workflow-persistent-next-action"
|
|
1305
|
+
>
|
|
1306
|
+
<div className="pointer-events-auto rounded-xl border border-border/80 bg-background/95 shadow-lg backdrop-blur">
|
|
1307
|
+
<CartoonNextAction
|
|
1308
|
+
storyName={selectedStory}
|
|
1309
|
+
fileName={cartoonView === null ? selectedFile : null}
|
|
1310
|
+
authFetch={authFetch}
|
|
1311
|
+
refreshKey={cartoonPublishRefresh}
|
|
1312
|
+
onCoachAction={handleWorkflowNextAction}
|
|
1313
|
+
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
1314
|
+
/>
|
|
1315
|
+
</div>
|
|
849
1316
|
</div>
|
|
850
1317
|
)}
|
|
851
|
-
{isCartoonStory && cartoonView === "story-info" && selectedStory ? (
|
|
852
|
-
<StoryInfoPage storyName={selectedStory} authFetch={authFetch} onSaved={handleStoryInfoSaved} />
|
|
853
|
-
) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
|
|
854
|
-
<EpisodesPage storyName={selectedStory} authFetch={authFetch} onOpenFile={handleSelectFile} />
|
|
855
|
-
) : isCartoonStory && cartoonView === "publish" && selectedStory ? (
|
|
856
|
-
<CartoonPublishPage
|
|
857
|
-
storyName={selectedStory}
|
|
858
|
-
authFetch={authFetch}
|
|
859
|
-
onOpenFile={handleSelectFile}
|
|
860
|
-
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
861
|
-
onPublish={handlePublish}
|
|
862
|
-
publishingFile={publishingFile}
|
|
863
|
-
genre={storyGenres[selectedStory]}
|
|
864
|
-
language={storyLanguages[selectedStory]}
|
|
865
|
-
isNsfw={storyNsfw[selectedStory]}
|
|
866
|
-
refreshKey={cartoonPublishRefresh}
|
|
867
|
-
/>
|
|
868
|
-
) : selectedStory && !selectedFile ? (
|
|
869
|
-
<StoryProgressPanel
|
|
870
|
-
storyName={selectedStory}
|
|
871
|
-
authFetch={authFetch}
|
|
872
|
-
onOpenFile={handleSelectFile}
|
|
873
|
-
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
874
|
-
/>
|
|
875
|
-
) : (
|
|
876
|
-
<PreviewPanel
|
|
877
|
-
storyName={selectedStory}
|
|
878
|
-
fileName={selectedFile}
|
|
879
|
-
authFetch={authFetch}
|
|
880
|
-
onPublish={handlePublish}
|
|
881
|
-
publishingFile={publishingFile}
|
|
882
|
-
walletAddress={walletAddress}
|
|
883
|
-
contentType={resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current) || "fiction"}
|
|
884
|
-
language={selectedStory ? storyLanguages[selectedStory] : undefined}
|
|
885
|
-
genre={selectedStory ? storyGenres[selectedStory] : undefined}
|
|
886
|
-
isNsfw={selectedStory ? storyNsfw[selectedStory] : undefined}
|
|
887
|
-
hasGenesis={selectedStory ? genesisStories.has(selectedStory) : false}
|
|
888
|
-
onViewProgress={() => setSelectedFile(null)}
|
|
889
|
-
onOpenFile={(file) => selectedStory && handleSelectFile(selectedStory, file)}
|
|
890
|
-
onViewPublish={() => setCartoonView("publish")}
|
|
891
|
-
/>
|
|
892
|
-
)}
|
|
893
1318
|
{publishProgress && (
|
|
894
|
-
<div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
|
|
1319
|
+
<div className="shrink-0 px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
|
|
895
1320
|
{publishProgress}
|
|
896
1321
|
</div>
|
|
897
1322
|
)}
|
|
@@ -900,7 +1325,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
900
1325
|
doesn't disappear on a timer. */}
|
|
901
1326
|
{publishError && (
|
|
902
1327
|
<div
|
|
903
|
-
className="px-3 py-2 bg-error/10 border-t border-error/40 text-xs text-error flex items-start justify-between gap-3"
|
|
1328
|
+
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"
|
|
904
1329
|
data-testid="publish-block-error"
|
|
905
1330
|
role="alert"
|
|
906
1331
|
>
|
|
@@ -918,11 +1343,18 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
918
1343
|
</div>
|
|
919
1344
|
|
|
920
1345
|
{showNewStoryModal && (
|
|
921
|
-
<div
|
|
1346
|
+
<div
|
|
1347
|
+
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
1348
|
+
style={{ background: "rgba(240, 235, 225, 0.9)" }}
|
|
1349
|
+
>
|
|
922
1350
|
<div className="bg-surface border border-border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
|
|
923
|
-
<h3 className="text-sm font-serif font-medium text-foreground text-center">
|
|
1351
|
+
<h3 className="text-sm font-serif font-medium text-foreground text-center">
|
|
1352
|
+
New Story
|
|
1353
|
+
</h3>
|
|
924
1354
|
<label className="block space-y-1">
|
|
925
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1355
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1356
|
+
Title <span className="text-accent">*</span>
|
|
1357
|
+
</span>
|
|
926
1358
|
<input
|
|
927
1359
|
type="text"
|
|
928
1360
|
value={newStoryTitle}
|
|
@@ -933,7 +1365,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
933
1365
|
/>
|
|
934
1366
|
</label>
|
|
935
1367
|
<label className="block space-y-1">
|
|
936
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1368
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1369
|
+
Short description (optional)
|
|
1370
|
+
</span>
|
|
937
1371
|
<input
|
|
938
1372
|
type="text"
|
|
939
1373
|
value={newStoryDescription}
|
|
@@ -944,7 +1378,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
944
1378
|
/>
|
|
945
1379
|
</label>
|
|
946
1380
|
<label className="block space-y-1">
|
|
947
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1381
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1382
|
+
Genre (optional)
|
|
1383
|
+
</span>
|
|
948
1384
|
<select
|
|
949
1385
|
value={newStoryGenre}
|
|
950
1386
|
onChange={(e) => setNewStoryGenre(e.target.value)}
|
|
@@ -952,24 +1388,38 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
952
1388
|
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
953
1389
|
>
|
|
954
1390
|
<option value="">— Select later —</option>
|
|
955
|
-
{GENRES.map((g) =>
|
|
1391
|
+
{GENRES.map((g) => (
|
|
1392
|
+
<option key={g} value={g}>
|
|
1393
|
+
{g}
|
|
1394
|
+
</option>
|
|
1395
|
+
))}
|
|
956
1396
|
</select>
|
|
957
1397
|
</label>
|
|
958
1398
|
<label className="block space-y-1">
|
|
959
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1399
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1400
|
+
Language
|
|
1401
|
+
</span>
|
|
960
1402
|
<select
|
|
961
1403
|
value={newStoryLanguage}
|
|
962
1404
|
onChange={(e) => setNewStoryLanguage(e.target.value)}
|
|
963
1405
|
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
964
1406
|
>
|
|
965
|
-
{LANGUAGES.map((l) =>
|
|
1407
|
+
{LANGUAGES.map((l) => (
|
|
1408
|
+
<option key={l} value={l}>
|
|
1409
|
+
{l}
|
|
1410
|
+
</option>
|
|
1411
|
+
))}
|
|
966
1412
|
</select>
|
|
967
1413
|
</label>
|
|
968
1414
|
<label className="block space-y-1">
|
|
969
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1415
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1416
|
+
Agent mode
|
|
1417
|
+
</span>
|
|
970
1418
|
<select
|
|
971
1419
|
value={newStoryAgentMode}
|
|
972
|
-
onChange={(e) =>
|
|
1420
|
+
onChange={(e) =>
|
|
1421
|
+
setNewStoryAgentMode(e.target.value as "normal" | "bypass")
|
|
1422
|
+
}
|
|
973
1423
|
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
974
1424
|
data-testid="agent-mode-select"
|
|
975
1425
|
>
|
|
@@ -977,53 +1427,97 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
977
1427
|
<option value="bypass">Permissions Bypass (advanced)</option>
|
|
978
1428
|
</select>
|
|
979
1429
|
{newStoryAgentMode === "bypass" && (
|
|
980
|
-
<p
|
|
981
|
-
|
|
1430
|
+
<p
|
|
1431
|
+
className="text-[10px] text-amber-700"
|
|
1432
|
+
data-testid="agent-mode-warning"
|
|
1433
|
+
>
|
|
1434
|
+
Less safe: Claude can run actions without per-command
|
|
1435
|
+
approval.
|
|
982
1436
|
</p>
|
|
983
1437
|
)}
|
|
984
1438
|
</label>
|
|
985
1439
|
<label className="block space-y-1">
|
|
986
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1440
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1441
|
+
Provider
|
|
1442
|
+
</span>
|
|
987
1443
|
<select
|
|
988
1444
|
value={newStoryAgentProvider}
|
|
989
|
-
onChange={(e) =>
|
|
1445
|
+
onChange={(e) =>
|
|
1446
|
+
setNewStoryAgentProvider(e.target.value as "claude" | "codex")
|
|
1447
|
+
}
|
|
990
1448
|
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
991
1449
|
data-testid="agent-provider-select"
|
|
992
1450
|
>
|
|
993
1451
|
<option value="claude">🤖 Claude (default)</option>
|
|
994
1452
|
<option value="codex">🎨 Codex</option>
|
|
995
1453
|
</select>
|
|
996
|
-
<p
|
|
1454
|
+
<p
|
|
1455
|
+
className="text-[10px] text-muted"
|
|
1456
|
+
data-testid="agent-provider-helper"
|
|
1457
|
+
>
|
|
997
1458
|
{newStoryAgentProvider === "codex"
|
|
998
1459
|
? "Codex can generate clean cartoon images directly in the terminal."
|
|
999
1460
|
: "Claude prepares image prompts; you generate and upload clean images externally."}
|
|
1000
1461
|
</p>
|
|
1001
1462
|
</label>
|
|
1002
|
-
<p className="text-xs text-muted text-center">
|
|
1463
|
+
<p className="text-xs text-muted text-center">
|
|
1464
|
+
Choose a content type to create
|
|
1465
|
+
</p>
|
|
1003
1466
|
{!newStoryTitle.trim() && (
|
|
1004
|
-
<p
|
|
1467
|
+
<p
|
|
1468
|
+
className="text-[10px] text-amber-700 text-center"
|
|
1469
|
+
data-testid="new-story-title-required"
|
|
1470
|
+
>
|
|
1471
|
+
Enter a title to create your story.
|
|
1472
|
+
</p>
|
|
1005
1473
|
)}
|
|
1006
1474
|
<div className="grid grid-cols-2 gap-3">
|
|
1007
1475
|
<button
|
|
1008
|
-
onClick={() =>
|
|
1476
|
+
onClick={() =>
|
|
1477
|
+
handleCreateStory(
|
|
1478
|
+
"fiction",
|
|
1479
|
+
newStoryLanguage,
|
|
1480
|
+
newStoryAgentMode,
|
|
1481
|
+
newStoryAgentProvider,
|
|
1482
|
+
)
|
|
1483
|
+
}
|
|
1009
1484
|
disabled={!newStoryTitle.trim()}
|
|
1010
1485
|
data-testid="create-fiction"
|
|
1011
1486
|
className="border border-border rounded-lg p-4 hover:border-accent hover:bg-accent/5 transition-colors text-center space-y-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-border disabled:hover:bg-transparent"
|
|
1012
1487
|
>
|
|
1013
|
-
<p className="text-sm font-serif font-medium text-foreground">
|
|
1014
|
-
|
|
1488
|
+
<p className="text-sm font-serif font-medium text-foreground">
|
|
1489
|
+
Fiction
|
|
1490
|
+
</p>
|
|
1491
|
+
<p className="text-[11px] text-muted">
|
|
1492
|
+
Novels, short stories, poetry
|
|
1493
|
+
</p>
|
|
1015
1494
|
</button>
|
|
1016
1495
|
<div className="space-y-1">
|
|
1017
1496
|
<button
|
|
1018
|
-
onClick={() =>
|
|
1497
|
+
onClick={() =>
|
|
1498
|
+
handleCreateStory(
|
|
1499
|
+
"cartoon",
|
|
1500
|
+
newStoryLanguage,
|
|
1501
|
+
newStoryAgentMode,
|
|
1502
|
+
"codex",
|
|
1503
|
+
)
|
|
1504
|
+
}
|
|
1019
1505
|
disabled={cartoonBlocked || !newStoryTitle.trim()}
|
|
1020
1506
|
data-testid="create-cartoon"
|
|
1021
1507
|
className="w-full border border-border rounded-lg p-4 hover:border-accent hover:bg-accent/5 transition-colors text-center space-y-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-border disabled:hover:bg-transparent"
|
|
1022
1508
|
>
|
|
1023
|
-
<p className="text-sm font-serif font-medium text-foreground">
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1509
|
+
<p className="text-sm font-serif font-medium text-foreground">
|
|
1510
|
+
Cartoon
|
|
1511
|
+
</p>
|
|
1512
|
+
<p className="text-[11px] text-muted">
|
|
1513
|
+
Comics, manga, webtoons
|
|
1514
|
+
</p>
|
|
1515
|
+
<p
|
|
1516
|
+
className="text-[11px] text-muted"
|
|
1517
|
+
data-testid="cartoon-codex-note"
|
|
1518
|
+
>
|
|
1519
|
+
Cartoon mode requires Codex because the clean-image step
|
|
1520
|
+
needs image generation support.
|
|
1027
1521
|
</p>
|
|
1028
1522
|
</button>
|
|
1029
1523
|
{/* Warnings/copy live OUTSIDE the button: a disabled button would
|
|
@@ -1034,8 +1528,10 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1034
1528
|
data-testid="cartoon-codex-warning"
|
|
1035
1529
|
>
|
|
1036
1530
|
Codex was not detected. Install the Codex CLI and sign in
|
|
1037
|
-
(e.g.
|
|
1038
|
-
<span className="font-mono">codex
|
|
1531
|
+
(e.g.{" "}
|
|
1532
|
+
<span className="font-mono">npm i -g @openai/codex</span>{" "}
|
|
1533
|
+
then <span className="font-mono">codex login</span>) to
|
|
1534
|
+
create cartoons.
|
|
1039
1535
|
</p>
|
|
1040
1536
|
)}
|
|
1041
1537
|
{isCodexAuthUnclear(readiness) && (
|
|
@@ -1052,8 +1548,8 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1052
1548
|
readiness.codex.imageGeneration !== "enabled" && (
|
|
1053
1549
|
<div data-testid="cartoon-codex-warning">
|
|
1054
1550
|
<p className="text-[11px] text-amber-700 text-left">
|
|
1055
|
-
Codex is installed but image generation isn't
|
|
1056
|
-
Enable it, then reopen this dialog:
|
|
1551
|
+
Codex is installed but image generation isn't
|
|
1552
|
+
enabled. Enable it, then reopen this dialog:
|
|
1057
1553
|
</p>
|
|
1058
1554
|
<div className="mt-1 flex items-center gap-1">
|
|
1059
1555
|
<code className="flex-1 truncate rounded border border-border bg-surface px-1.5 py-1 text-left text-[10px] font-mono text-foreground">
|