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