plotlink-ows 1.2.94 → 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/active-wallet.ts +260 -0
- package/app/lib/cartoon-coach.ts +1 -1
- package/app/lib/cartoon-readiness.ts +12 -10
- package/app/lib/cuts.ts +135 -18
- package/app/lib/lettering-status.ts +64 -6
- package/app/lib/story-progress.ts +2 -3
- package/app/routes/dashboard.ts +6 -4
- package/app/routes/publish.ts +56 -23
- package/app/routes/settings.ts +92 -37
- package/app/routes/wallet.ts +58 -30
- package/app/web/components/CartoonNextAction.tsx +145 -0
- package/app/web/components/CartoonPublishPage.tsx +1 -1
- package/app/web/components/CutListPanel.tsx +1198 -488
- package/app/web/components/Dashboard.tsx +15 -6
- package/app/web/components/FinishEpisodePanel.tsx +57 -46
- package/app/web/components/LetteringEditor.tsx +867 -366
- package/app/web/components/PreviewPanel.tsx +1459 -844
- package/app/web/components/StoriesPage.tsx +985 -475
- package/app/web/components/StoryProgressPanel.tsx +32 -102
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +63 -35
- package/app/web/dist/assets/{export-cut-nKQ_n2-J.js → export-cut-BqZI0-Rv.js} +1 -1
- package/app/web/dist/assets/index-C43toXVm.js +141 -0
- package/app/web/dist/assets/index-CcfChGEK.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-BAZGwVwj.js +0 -143
- package/app/web/dist/assets/index-DoXH2OlP.css +0 -32
|
@@ -3,15 +3,38 @@ 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";
|
|
13
|
+
import { CartoonNextAction } from "./CartoonNextAction";
|
|
10
14
|
import { LANGUAGES, GENRES } from "../../../lib/genres";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
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";
|
|
14
36
|
import { cartoonGenesisReadiness } from "@app-lib/cartoon-readiness";
|
|
37
|
+
import type { CoachUiAction } from "@app-lib/cartoon-coach";
|
|
15
38
|
|
|
16
39
|
interface StoriesPageProps {
|
|
17
40
|
token: string;
|
|
@@ -31,7 +54,9 @@ function loadRatio(): number {
|
|
|
31
54
|
const n = parseFloat(v);
|
|
32
55
|
if (n > 0 && n < 1) return n;
|
|
33
56
|
}
|
|
34
|
-
} catch {
|
|
57
|
+
} catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
35
60
|
return DEFAULT_RATIO;
|
|
36
61
|
}
|
|
37
62
|
|
|
@@ -48,7 +73,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
48
73
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
49
74
|
// Cartoon right-panel workflow nav (#439): a non-file workflow page is open
|
|
50
75
|
// (Story Info / Episodes). null ⇒ the view follows selectedFile (or Progress).
|
|
51
|
-
const [cartoonView, setCartoonView] = useState<
|
|
76
|
+
const [cartoonView, setCartoonView] = useState<
|
|
77
|
+
"story-info" | "episodes" | "publish" | null
|
|
78
|
+
>(null);
|
|
52
79
|
const [publishingFile, setPublishingFile] = useState<string | null>(null);
|
|
53
80
|
// Bumped on a confirmed publish so the cartoon Publish page re-reads readiness
|
|
54
81
|
// and advances to the next episode (#461).
|
|
@@ -66,43 +93,84 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
66
93
|
const [newStoryDescription, setNewStoryDescription] = useState("");
|
|
67
94
|
const [newStoryGenre, setNewStoryGenre] = useState("");
|
|
68
95
|
const [newStoryLanguage, setNewStoryLanguage] = useState("English");
|
|
69
|
-
const [newStoryAgentMode, setNewStoryAgentMode] = useState<
|
|
70
|
-
|
|
96
|
+
const [newStoryAgentMode, setNewStoryAgentMode] = useState<
|
|
97
|
+
"normal" | "bypass"
|
|
98
|
+
>("normal");
|
|
99
|
+
const [newStoryAgentProvider, setNewStoryAgentProvider] = useState<
|
|
100
|
+
"claude" | "codex"
|
|
101
|
+
>("claude");
|
|
71
102
|
const [readiness, setReadiness] = useState<AgentReadiness | null>(null);
|
|
72
103
|
const [codexEnableCopied, setCodexEnableCopied] = useState(false);
|
|
73
|
-
const [bypassStories, setBypassStories] = useState<Record<string, boolean>>(
|
|
74
|
-
|
|
104
|
+
const [bypassStories, setBypassStories] = useState<Record<string, boolean>>(
|
|
105
|
+
{},
|
|
106
|
+
);
|
|
107
|
+
const [agentProviders, setAgentProviders] = useState<
|
|
108
|
+
Record<string, "claude" | "codex">
|
|
109
|
+
>({});
|
|
75
110
|
// Track confirmed stories (those with structure.md) for Archive gating
|
|
76
|
-
const [confirmedStories, setConfirmedStories] = useState<Set<string>>(
|
|
111
|
+
const [confirmedStories, setConfirmedStories] = useState<Set<string>>(
|
|
112
|
+
new Set(),
|
|
113
|
+
);
|
|
77
114
|
// Stories that already have a genesis.md — so the outline footer can suggest
|
|
78
115
|
// reviewing Genesis rather than writing it again (#422).
|
|
79
116
|
const [genesisStories, setGenesisStories] = useState<Set<string>>(new Set());
|
|
80
|
-
const [storyContentTypes, setStoryContentTypes] = useState<
|
|
117
|
+
const [storyContentTypes, setStoryContentTypes] = useState<
|
|
118
|
+
Record<string, "fiction" | "cartoon">
|
|
119
|
+
>({});
|
|
81
120
|
// `undefined` ⇒ language couldn't be determined for the story → the publish
|
|
82
121
|
// panel shows "Needs metadata" rather than defaulting to English (#424).
|
|
83
|
-
const [storyLanguages, setStoryLanguages] = useState<
|
|
122
|
+
const [storyLanguages, setStoryLanguages] = useState<
|
|
123
|
+
Record<string, string | undefined>
|
|
124
|
+
>({});
|
|
84
125
|
// Publish metadata from .story.json (#424) so the publish controls seed real
|
|
85
126
|
// values. `undefined` ⇒ not set in .story.json → client shows "Needs metadata".
|
|
86
|
-
const [storyGenres, setStoryGenres] = useState<
|
|
87
|
-
|
|
127
|
+
const [storyGenres, setStoryGenres] = useState<
|
|
128
|
+
Record<string, string | undefined>
|
|
129
|
+
>({});
|
|
130
|
+
const [storyNsfw, setStoryNsfw] = useState<
|
|
131
|
+
Record<string, boolean | undefined>
|
|
132
|
+
>({});
|
|
88
133
|
const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
|
|
89
134
|
// Provider recorded on each persisted story (read-only, from /api/stories).
|
|
90
135
|
// Absent ⇒ legacy story with no provider (defaults to Claude at launch).
|
|
91
|
-
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);
|
|
92
146
|
const contentTypeMap = useRef<Map<string, "fiction" | "cartoon">>(new Map());
|
|
93
147
|
const languageMap = useRef<Map<string, string>>(new Map());
|
|
94
148
|
const agentModeMap = useRef<Map<string, "normal" | "bypass">>(new Map());
|
|
95
149
|
const agentProviderMap = useRef<Map<string, "claude" | "codex">>(new Map());
|
|
96
150
|
const knownStoriesRef = useRef<Set<string>>(new Set());
|
|
97
|
-
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);
|
|
98
164
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
99
165
|
const dragging = useRef(false);
|
|
100
166
|
|
|
101
167
|
// Fetch wallet address for edit panel authorship check
|
|
102
168
|
useEffect(() => {
|
|
103
169
|
authFetch("/api/wallet")
|
|
104
|
-
.then((res) => res.ok ? res.json() : null)
|
|
105
|
-
.then((data) => {
|
|
170
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
171
|
+
.then((data) => {
|
|
172
|
+
if (data?.address) setWalletAddress(data.address);
|
|
173
|
+
})
|
|
106
174
|
.catch(() => {});
|
|
107
175
|
}, [authFetch]);
|
|
108
176
|
|
|
@@ -110,21 +178,30 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
110
178
|
// readiness null (no warning shown); this never blocks fiction or cartoon.
|
|
111
179
|
useEffect(() => {
|
|
112
180
|
authFetch("/api/agent/readiness")
|
|
113
|
-
.then((res) => res.ok ? res.json() : null)
|
|
114
|
-
.then((data) => {
|
|
181
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
182
|
+
.then((data) => {
|
|
183
|
+
if (data) setReadiness(data);
|
|
184
|
+
})
|
|
115
185
|
.catch(() => {});
|
|
116
186
|
}, [authFetch]);
|
|
117
187
|
|
|
118
188
|
// Persist ratio to localStorage
|
|
119
189
|
useEffect(() => {
|
|
120
|
-
try {
|
|
190
|
+
try {
|
|
191
|
+
localStorage.setItem(STORAGE_KEY, String(ratio));
|
|
192
|
+
} catch {
|
|
193
|
+
/* ignore */
|
|
194
|
+
}
|
|
121
195
|
}, [ratio]);
|
|
122
196
|
|
|
123
197
|
// Clamp ratio on window resize so panels stay above MIN_PANEL_PX
|
|
124
198
|
useEffect(() => {
|
|
125
199
|
const onResize = () => {
|
|
126
200
|
if (!containerRef.current) return;
|
|
127
|
-
const available =
|
|
201
|
+
const available =
|
|
202
|
+
containerRef.current.getBoundingClientRect().width -
|
|
203
|
+
SIDEBAR_PX -
|
|
204
|
+
HANDLE_PX;
|
|
128
205
|
setRatio((prev) => clampRatio(prev, available));
|
|
129
206
|
};
|
|
130
207
|
window.addEventListener("resize", onResize);
|
|
@@ -146,38 +223,50 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
146
223
|
// title/metadata (server writes .story.json + CLAUDE.md), then land on the
|
|
147
224
|
// story progress overview (#418) so the user sees what's next + a copy-paste
|
|
148
225
|
// first prompt — no need to ask the agent to rename an "Untitled" project.
|
|
149
|
-
const handleCreateStory = useCallback(
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
+
);
|
|
181
270
|
|
|
182
271
|
// Poll for new stories and auto-transition untitled sessions
|
|
183
272
|
useEffect(() => {
|
|
@@ -190,11 +279,14 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
190
279
|
const currentNames = new Set<string>(
|
|
191
280
|
(data.stories as { name: string }[])
|
|
192
281
|
.filter((s) => s.name !== "_example")
|
|
193
|
-
.map((s) => s.name)
|
|
282
|
+
.map((s) => s.name),
|
|
194
283
|
);
|
|
195
284
|
// Detect newly appeared stories
|
|
196
285
|
for (const name of currentNames) {
|
|
197
|
-
if (
|
|
286
|
+
if (
|
|
287
|
+
!knownStoriesRef.current.has(name) &&
|
|
288
|
+
untitledSessions.length > 0
|
|
289
|
+
) {
|
|
198
290
|
// New story appeared — rename the oldest untitled session to the story name
|
|
199
291
|
const oldName = untitledSessions[0];
|
|
200
292
|
// Read the pending session's metadata BEFORE the rename so it can be
|
|
@@ -205,9 +297,14 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
205
297
|
const provider = agentProviderMap.current.get(oldName) || "claude";
|
|
206
298
|
let renamed = false;
|
|
207
299
|
if (renameRef.current) {
|
|
208
|
-
renamed = await renameRef
|
|
209
|
-
|
|
210
|
-
|
|
300
|
+
renamed = await renameRef
|
|
301
|
+
.current(oldName, name, {
|
|
302
|
+
contentType: ct,
|
|
303
|
+
language: lang,
|
|
304
|
+
agentMode: mode,
|
|
305
|
+
agentProvider: provider,
|
|
306
|
+
})
|
|
307
|
+
.catch(() => false);
|
|
211
308
|
}
|
|
212
309
|
if (renamed) {
|
|
213
310
|
setUntitledSessions((prev) => prev.slice(1));
|
|
@@ -230,7 +327,12 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
230
327
|
authFetch(`/api/stories/${name}/metadata`, {
|
|
231
328
|
method: "POST",
|
|
232
329
|
headers: { "Content-Type": "application/json" },
|
|
233
|
-
body: JSON.stringify({
|
|
330
|
+
body: JSON.stringify({
|
|
331
|
+
contentType: ct,
|
|
332
|
+
language: lang,
|
|
333
|
+
agentMode: mode,
|
|
334
|
+
agentProvider: provider,
|
|
335
|
+
}),
|
|
234
336
|
}).catch(() => {});
|
|
235
337
|
}
|
|
236
338
|
setSelectedStory(name);
|
|
@@ -238,60 +340,78 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
238
340
|
}
|
|
239
341
|
}
|
|
240
342
|
knownStoriesRef.current = currentNames;
|
|
241
|
-
} catch {
|
|
343
|
+
} catch {
|
|
344
|
+
/* ignore */
|
|
345
|
+
}
|
|
242
346
|
}, 3000);
|
|
243
347
|
return () => clearInterval(interval);
|
|
244
348
|
}, [authFetch, untitledSessions]);
|
|
245
349
|
|
|
246
350
|
// Initialize known stories on mount
|
|
247
351
|
useEffect(() => {
|
|
248
|
-
authFetch("/api/stories")
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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(() => {});
|
|
259
366
|
}, [authFetch]);
|
|
260
367
|
|
|
261
|
-
const handleSelectFile = useCallback(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
+
);
|
|
266
376
|
|
|
267
377
|
const latestStoryRef = useRef<string | null>(null);
|
|
268
378
|
|
|
269
|
-
const handleSelectStory = useCallback(
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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 */
|
|
292
411
|
}
|
|
293
|
-
}
|
|
294
|
-
|
|
412
|
+
},
|
|
413
|
+
[authFetch],
|
|
414
|
+
);
|
|
295
415
|
|
|
296
416
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
297
417
|
e.preventDefault();
|
|
@@ -319,284 +439,400 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
319
439
|
window.addEventListener("mouseup", onMouseUp);
|
|
320
440
|
}, []);
|
|
321
441
|
|
|
322
|
-
const handlePublish = useCallback(
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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;
|
|
337
465
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const fileData = await fileRes.json();
|
|
343
|
-
|
|
344
|
-
// Derive the publish title (#331). The storyline title is set once at
|
|
345
|
-
// genesis publish and is immutable on-chain, so a headingless genesis.md
|
|
346
|
-
// must not fall back to the bare "genesis" filename. For genesis, fetch
|
|
347
|
-
// structure.md so its `# Title` H1 can stand in, with a prettified folder
|
|
348
|
-
// slug as the last resort. Best-effort: structure.md may be absent.
|
|
349
|
-
const publishContentType = storyContentTypes[storyName];
|
|
350
|
-
let structureContent: string | null = null;
|
|
351
|
-
let episodeTitle: string | null = null;
|
|
352
|
-
if (fileName === "genesis.md") {
|
|
353
|
-
try {
|
|
354
|
-
const structRes = await authFetch(`/api/stories/${storyName}/structure.md`);
|
|
355
|
-
if (structRes.ok) structureContent = (await structRes.json()).content ?? null;
|
|
356
|
-
} catch { /* best effort — fall back to the prettified slug */ }
|
|
357
|
-
} else if (publishContentType === "cartoon" && fileName.match(/^plot-\d+\.md$/)) {
|
|
358
|
-
// Cartoon publish markdown is image-only (no H1), so read the cut plan's
|
|
359
|
-
// episode title to avoid publishing the raw "plot-NN" filename (#347).
|
|
360
|
-
try {
|
|
361
|
-
const cutsRes = await authFetch(`/api/stories/${storyName}/cuts/${fileName.replace(/\.md$/, "")}`);
|
|
362
|
-
if (cutsRes.ok) episodeTitle = (await cutsRes.json()).title ?? null;
|
|
363
|
-
} catch { /* best effort — fall back to a friendly "Episode NN" */ }
|
|
364
|
-
}
|
|
365
|
-
const title = derivePublishTitle({
|
|
366
|
-
fileName,
|
|
367
|
-
fileContent: fileData.content,
|
|
368
|
-
storySlug: storyName,
|
|
369
|
-
structureContent,
|
|
370
|
-
contentType: publishContentType,
|
|
371
|
-
episodeTitle,
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
// Defense-in-depth (#358): never publish a cartoon story/episode whose
|
|
375
|
-
// public title is still a raw filename label ("genesis"/"plot-NN"). The
|
|
376
|
-
// publish panel already blocks this, but guard the action too.
|
|
377
|
-
if (publishContentType === "cartoon" && isRawFilenameTitle(title, fileName)) {
|
|
378
|
-
setPublishProgress(
|
|
379
|
-
fileName === "genesis.md"
|
|
380
|
-
? "Add a real “# Title” heading to genesis.md before publishing — it would otherwise publish as a raw filename."
|
|
381
|
-
: "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}`,
|
|
382
470
|
);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
471
|
+
if (!fileRes.ok) throw new Error("Failed to read file");
|
|
472
|
+
const fileData = await fileRes.json();
|
|
386
473
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
)
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
+
});
|
|
399
515
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
+
) {
|
|
407
523
|
setPublishProgress(
|
|
408
|
-
|
|
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.",
|
|
409
527
|
);
|
|
410
|
-
setTimeout(() => {
|
|
528
|
+
setTimeout(() => {
|
|
529
|
+
setPublishingFile(null);
|
|
530
|
+
setPublishProgress("");
|
|
531
|
+
}, 6000);
|
|
411
532
|
return false;
|
|
412
533
|
}
|
|
413
|
-
}
|
|
414
534
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
+
) {
|
|
425
547
|
setPublishProgress(
|
|
426
|
-
"
|
|
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.",
|
|
427
549
|
);
|
|
428
|
-
setTimeout(() => {
|
|
429
|
-
|
|
550
|
+
setTimeout(() => {
|
|
551
|
+
setPublishingFile(null);
|
|
552
|
+
setPublishProgress("");
|
|
553
|
+
}, 6000);
|
|
554
|
+
return false;
|
|
430
555
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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;
|
|
438
574
|
}
|
|
439
|
-
} catch { /* ignore */ }
|
|
440
|
-
if (!storylineId) {
|
|
441
|
-
setPublishProgress("Error: Publish genesis first to create the storyline");
|
|
442
|
-
setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 3000);
|
|
443
|
-
return false;
|
|
444
575
|
}
|
|
445
|
-
}
|
|
446
576
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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);
|
|
465
617
|
return false;
|
|
466
618
|
}
|
|
467
619
|
}
|
|
468
|
-
} catch { /* preflight unreachable — don't hard-block; let the publish stream report */ }
|
|
469
620
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
+
}
|
|
480
645
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
+
});
|
|
485
675
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
676
|
+
if (!publishRes.ok) {
|
|
677
|
+
const err = await publishRes.json();
|
|
678
|
+
throw new Error(err.error || "Publish failed");
|
|
679
|
+
}
|
|
489
680
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
if (done) break;
|
|
494
|
-
const text = decoder.decode(value);
|
|
495
|
-
const lines = text.split("\n").filter((l) => l.startsWith("data: "));
|
|
496
|
-
for (const line of lines) {
|
|
497
|
-
try {
|
|
498
|
-
const data = JSON.parse(line.slice(6));
|
|
499
|
-
if (data.step) setPublishProgress(data.message || data.step);
|
|
500
|
-
if (data.step === "done" && data.txHash) {
|
|
501
|
-
// Publish confirmed on-chain — the only point at which the cover
|
|
502
|
-
// selection may be dropped (#376). Anything short of this (a
|
|
503
|
-
// pre-stream block, a non-ok response, an error before `done`, or
|
|
504
|
-
// a stream that ends without `done`) leaves `succeeded` false so
|
|
505
|
-
// PreviewPanel keeps the selected/auto-detected cover for retry.
|
|
506
|
-
succeeded = true;
|
|
507
|
-
// Update publish status with gasCost
|
|
508
|
-
await authFetch(`/api/stories/${storyName}/${fileName}/publish-status`, {
|
|
509
|
-
method: "POST",
|
|
510
|
-
headers: { "Content-Type": "application/json" },
|
|
511
|
-
body: JSON.stringify({
|
|
512
|
-
txHash: data.txHash,
|
|
513
|
-
storylineId: data.storylineId,
|
|
514
|
-
plotIndex: data.plotIndex,
|
|
515
|
-
contentCid: data.contentCid,
|
|
516
|
-
gasCost: data.gasCost,
|
|
517
|
-
indexError: data.indexError,
|
|
518
|
-
authorAddress: walletAddress,
|
|
519
|
-
}),
|
|
520
|
-
});
|
|
521
|
-
// Advance the cartoon Publish page to the next episode (#461).
|
|
522
|
-
setCartoonPublishRefresh((n) => n + 1);
|
|
681
|
+
// Read SSE stream
|
|
682
|
+
const reader = publishRes.body?.getReader();
|
|
683
|
+
const decoder = new TextDecoder();
|
|
523
684
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
|
|
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);
|
|
723
|
+
|
|
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 */
|
|
744
|
+
}
|
|
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
|
+
}
|
|
540
750
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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 */
|
|
563
796
|
}
|
|
564
|
-
}
|
|
797
|
+
}
|
|
565
798
|
}
|
|
799
|
+
} catch {
|
|
800
|
+
/* ignore partial SSE */
|
|
566
801
|
}
|
|
567
|
-
}
|
|
802
|
+
}
|
|
568
803
|
}
|
|
569
804
|
}
|
|
570
|
-
}
|
|
571
805
|
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
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
|
+
);
|
|
600
836
|
|
|
601
837
|
const handleDestroySession = useCallback((name: string) => {
|
|
602
838
|
if (name.startsWith("_new_")) {
|
|
@@ -621,9 +857,25 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
621
857
|
}, []);
|
|
622
858
|
|
|
623
859
|
useEffect(() => {
|
|
624
|
-
const updateFromStories = (
|
|
625
|
-
|
|
626
|
-
|
|
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
|
+
);
|
|
627
879
|
const ct: Record<string, "fiction" | "cartoon"> = {};
|
|
628
880
|
const lang: Record<string, string | undefined> = {};
|
|
629
881
|
const genre: Record<string, string | undefined> = {};
|
|
@@ -647,9 +899,12 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
647
899
|
setStoryProviders(prov);
|
|
648
900
|
setStoryTitles(titles);
|
|
649
901
|
};
|
|
650
|
-
authFetch("/api/stories")
|
|
651
|
-
|
|
652
|
-
|
|
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(() => {});
|
|
653
908
|
const interval = setInterval(async () => {
|
|
654
909
|
try {
|
|
655
910
|
const res = await authFetch("/api/stories");
|
|
@@ -657,7 +912,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
657
912
|
const data = await res.json();
|
|
658
913
|
updateFromStories(data.stories);
|
|
659
914
|
}
|
|
660
|
-
} catch {
|
|
915
|
+
} catch {
|
|
916
|
+
/* ignore */
|
|
917
|
+
}
|
|
661
918
|
}, 5000);
|
|
662
919
|
return () => clearInterval(interval);
|
|
663
920
|
}, [authFetch]);
|
|
@@ -668,15 +925,21 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
668
925
|
// is still null (loading or probe-endpoint failure) we DO NOT block, to avoid
|
|
669
926
|
// permanently bricking cartoon if the probe errors.
|
|
670
927
|
const codexReady =
|
|
671
|
-
!!readiness &&
|
|
928
|
+
!!readiness &&
|
|
929
|
+
readiness.codex.installed &&
|
|
930
|
+
readiness.codex.imageGeneration === "enabled";
|
|
672
931
|
const cartoonBlocked = !!readiness && !codexReady;
|
|
673
932
|
|
|
674
933
|
const copyCodexEnable = useCallback(async () => {
|
|
675
934
|
try {
|
|
676
|
-
await navigator.clipboard.writeText(
|
|
935
|
+
await navigator.clipboard.writeText(
|
|
936
|
+
"codex features enable image_generation",
|
|
937
|
+
);
|
|
677
938
|
setCodexEnableCopied(true);
|
|
678
939
|
setTimeout(() => setCodexEnableCopied(false), 2000);
|
|
679
|
-
} catch {
|
|
940
|
+
} catch {
|
|
941
|
+
/* clipboard unavailable */
|
|
942
|
+
}
|
|
680
943
|
}, []);
|
|
681
944
|
|
|
682
945
|
// Explicit, scoped repair for a legacy cartoon story with no recorded
|
|
@@ -702,29 +965,41 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
702
965
|
const data = await listRes.json();
|
|
703
966
|
if (data?.stories) {
|
|
704
967
|
const prov: Record<string, "claude" | "codex" | undefined> = {};
|
|
705
|
-
for (const s of data.stories as {
|
|
968
|
+
for (const s of data.stories as {
|
|
969
|
+
name: string;
|
|
970
|
+
agentProvider?: "claude" | "codex";
|
|
971
|
+
}[]) {
|
|
706
972
|
prov[s.name] = s.agentProvider;
|
|
707
973
|
}
|
|
708
974
|
setStoryProviders(prov);
|
|
709
975
|
}
|
|
710
976
|
}
|
|
711
|
-
} catch {
|
|
977
|
+
} catch {
|
|
978
|
+
/* ignore */
|
|
979
|
+
}
|
|
712
980
|
}, [authFetch, selectedStory]);
|
|
713
981
|
|
|
714
|
-
const handleArchiveStory = useCallback(
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
+
);
|
|
721
992
|
|
|
722
993
|
// Resolve the effective provider for the selected story: an optimistic/new
|
|
723
994
|
// session value (agentProviders) wins, else the persisted list value.
|
|
724
995
|
const selectedProvider = selectedStory
|
|
725
996
|
? (agentProviders[selectedStory] ?? storyProviders[selectedStory])
|
|
726
997
|
: undefined;
|
|
727
|
-
const selectedContentType = resolveSelectedContentType(
|
|
998
|
+
const selectedContentType = resolveSelectedContentType(
|
|
999
|
+
selectedStory,
|
|
1000
|
+
storyContentTypes,
|
|
1001
|
+
contentTypeMap.current,
|
|
1002
|
+
);
|
|
728
1003
|
const selectedNeedsProviderRepair = needsLegacyProviderRepair(
|
|
729
1004
|
selectedContentType,
|
|
730
1005
|
selectedProvider,
|
|
@@ -736,88 +1011,235 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
736
1011
|
// ⇒ Whitepaper, genesis.md ⇒ Genesis / Ep 1, plot-NN ⇒ Episodes, else Progress.
|
|
737
1012
|
const isCartoonStory = !!selectedStory && selectedContentType === "cartoon";
|
|
738
1013
|
const activeCartoonTab: CartoonWorkflowTab =
|
|
739
|
-
cartoonView === "story-info"
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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";
|
|
746
1027
|
|
|
747
|
-
const handleCartoonNav = useCallback(
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
+
);
|
|
1062
|
+
|
|
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
|
+
);
|
|
765
1086
|
|
|
766
1087
|
// Keep the publish-control seeds in sync after a Story Info save (#439).
|
|
767
|
-
const handleStoryInfoSaved = useCallback(
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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;
|
|
773
1114
|
|
|
774
1115
|
return (
|
|
775
|
-
<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
|
+
>
|
|
776
1125
|
{/* Story Browser Sidebar */}
|
|
777
|
-
|
|
778
|
-
<
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
+
)}
|
|
787
1138
|
|
|
788
1139
|
{/* Terminal — sized by ratio of available space */}
|
|
789
|
-
|
|
790
|
-
<
|
|
791
|
-
|
|
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
|
+
)}
|
|
792
1167
|
|
|
793
1168
|
{/* Drag Handle */}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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>
|
|
803
1193
|
</div>
|
|
804
|
-
|
|
1194
|
+
)}
|
|
805
1195
|
|
|
806
1196
|
{/* Preview — takes remaining space. With a story but no file selected, show
|
|
807
1197
|
the story-level progress overview (#418) instead of the empty state. */}
|
|
808
|
-
<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
|
+
>
|
|
809
1206
|
{/* Cartoon workflow nav (#439) — persistent above the right-panel content. */}
|
|
810
|
-
{isCartoonStory && selectedStory && (
|
|
1207
|
+
{!focusedLetteringMode && isCartoonStory && selectedStory && (
|
|
811
1208
|
<CartoonWorkflowNav
|
|
812
1209
|
storyTitle={storyTitles[selectedStory] || selectedStory}
|
|
813
1210
|
active={activeCartoonTab}
|
|
814
1211
|
onSelect={handleCartoonNav}
|
|
815
1212
|
/>
|
|
816
1213
|
)}
|
|
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
|
+
)}
|
|
817
1231
|
{isCartoonStory && cartoonView === "story-info" && selectedStory ? (
|
|
818
|
-
<StoryInfoPage
|
|
1232
|
+
<StoryInfoPage
|
|
1233
|
+
storyName={selectedStory}
|
|
1234
|
+
authFetch={authFetch}
|
|
1235
|
+
onSaved={handleStoryInfoSaved}
|
|
1236
|
+
/>
|
|
819
1237
|
) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
|
|
820
|
-
<EpisodesPage
|
|
1238
|
+
<EpisodesPage
|
|
1239
|
+
storyName={selectedStory}
|
|
1240
|
+
authFetch={authFetch}
|
|
1241
|
+
onOpenFile={handleSelectFile}
|
|
1242
|
+
/>
|
|
821
1243
|
) : isCartoonStory && cartoonView === "publish" && selectedStory ? (
|
|
822
1244
|
<CartoonPublishPage
|
|
823
1245
|
storyName={selectedStory}
|
|
@@ -836,24 +1258,41 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
836
1258
|
storyName={selectedStory}
|
|
837
1259
|
authFetch={authFetch}
|
|
838
1260
|
onOpenFile={handleSelectFile}
|
|
1261
|
+
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
839
1262
|
/>
|
|
840
1263
|
) : (
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
+
/>
|
|
857
1296
|
)}
|
|
858
1297
|
{publishProgress && (
|
|
859
1298
|
<div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
|
|
@@ -883,11 +1322,18 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
883
1322
|
</div>
|
|
884
1323
|
|
|
885
1324
|
{showNewStoryModal && (
|
|
886
|
-
<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
|
+
>
|
|
887
1329
|
<div className="bg-surface border border-border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
|
|
888
|
-
<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>
|
|
889
1333
|
<label className="block space-y-1">
|
|
890
|
-
<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>
|
|
891
1337
|
<input
|
|
892
1338
|
type="text"
|
|
893
1339
|
value={newStoryTitle}
|
|
@@ -898,7 +1344,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
898
1344
|
/>
|
|
899
1345
|
</label>
|
|
900
1346
|
<label className="block space-y-1">
|
|
901
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1347
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1348
|
+
Short description (optional)
|
|
1349
|
+
</span>
|
|
902
1350
|
<input
|
|
903
1351
|
type="text"
|
|
904
1352
|
value={newStoryDescription}
|
|
@@ -909,7 +1357,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
909
1357
|
/>
|
|
910
1358
|
</label>
|
|
911
1359
|
<label className="block space-y-1">
|
|
912
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1360
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1361
|
+
Genre (optional)
|
|
1362
|
+
</span>
|
|
913
1363
|
<select
|
|
914
1364
|
value={newStoryGenre}
|
|
915
1365
|
onChange={(e) => setNewStoryGenre(e.target.value)}
|
|
@@ -917,24 +1367,38 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
917
1367
|
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
918
1368
|
>
|
|
919
1369
|
<option value="">— Select later —</option>
|
|
920
|
-
{GENRES.map((g) =>
|
|
1370
|
+
{GENRES.map((g) => (
|
|
1371
|
+
<option key={g} value={g}>
|
|
1372
|
+
{g}
|
|
1373
|
+
</option>
|
|
1374
|
+
))}
|
|
921
1375
|
</select>
|
|
922
1376
|
</label>
|
|
923
1377
|
<label className="block space-y-1">
|
|
924
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1378
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1379
|
+
Language
|
|
1380
|
+
</span>
|
|
925
1381
|
<select
|
|
926
1382
|
value={newStoryLanguage}
|
|
927
1383
|
onChange={(e) => setNewStoryLanguage(e.target.value)}
|
|
928
1384
|
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
929
1385
|
>
|
|
930
|
-
{LANGUAGES.map((l) =>
|
|
1386
|
+
{LANGUAGES.map((l) => (
|
|
1387
|
+
<option key={l} value={l}>
|
|
1388
|
+
{l}
|
|
1389
|
+
</option>
|
|
1390
|
+
))}
|
|
931
1391
|
</select>
|
|
932
1392
|
</label>
|
|
933
1393
|
<label className="block space-y-1">
|
|
934
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1394
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1395
|
+
Agent mode
|
|
1396
|
+
</span>
|
|
935
1397
|
<select
|
|
936
1398
|
value={newStoryAgentMode}
|
|
937
|
-
onChange={(e) =>
|
|
1399
|
+
onChange={(e) =>
|
|
1400
|
+
setNewStoryAgentMode(e.target.value as "normal" | "bypass")
|
|
1401
|
+
}
|
|
938
1402
|
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
939
1403
|
data-testid="agent-mode-select"
|
|
940
1404
|
>
|
|
@@ -942,53 +1406,97 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
942
1406
|
<option value="bypass">Permissions Bypass (advanced)</option>
|
|
943
1407
|
</select>
|
|
944
1408
|
{newStoryAgentMode === "bypass" && (
|
|
945
|
-
<p
|
|
946
|
-
|
|
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.
|
|
947
1415
|
</p>
|
|
948
1416
|
)}
|
|
949
1417
|
</label>
|
|
950
1418
|
<label className="block space-y-1">
|
|
951
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1419
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1420
|
+
Provider
|
|
1421
|
+
</span>
|
|
952
1422
|
<select
|
|
953
1423
|
value={newStoryAgentProvider}
|
|
954
|
-
onChange={(e) =>
|
|
1424
|
+
onChange={(e) =>
|
|
1425
|
+
setNewStoryAgentProvider(e.target.value as "claude" | "codex")
|
|
1426
|
+
}
|
|
955
1427
|
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
956
1428
|
data-testid="agent-provider-select"
|
|
957
1429
|
>
|
|
958
1430
|
<option value="claude">🤖 Claude (default)</option>
|
|
959
1431
|
<option value="codex">🎨 Codex</option>
|
|
960
1432
|
</select>
|
|
961
|
-
<p
|
|
1433
|
+
<p
|
|
1434
|
+
className="text-[10px] text-muted"
|
|
1435
|
+
data-testid="agent-provider-helper"
|
|
1436
|
+
>
|
|
962
1437
|
{newStoryAgentProvider === "codex"
|
|
963
1438
|
? "Codex can generate clean cartoon images directly in the terminal."
|
|
964
1439
|
: "Claude prepares image prompts; you generate and upload clean images externally."}
|
|
965
1440
|
</p>
|
|
966
1441
|
</label>
|
|
967
|
-
<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>
|
|
968
1445
|
{!newStoryTitle.trim() && (
|
|
969
|
-
<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>
|
|
970
1452
|
)}
|
|
971
1453
|
<div className="grid grid-cols-2 gap-3">
|
|
972
1454
|
<button
|
|
973
|
-
onClick={() =>
|
|
1455
|
+
onClick={() =>
|
|
1456
|
+
handleCreateStory(
|
|
1457
|
+
"fiction",
|
|
1458
|
+
newStoryLanguage,
|
|
1459
|
+
newStoryAgentMode,
|
|
1460
|
+
newStoryAgentProvider,
|
|
1461
|
+
)
|
|
1462
|
+
}
|
|
974
1463
|
disabled={!newStoryTitle.trim()}
|
|
975
1464
|
data-testid="create-fiction"
|
|
976
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"
|
|
977
1466
|
>
|
|
978
|
-
<p className="text-sm font-serif font-medium text-foreground">
|
|
979
|
-
|
|
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>
|
|
980
1473
|
</button>
|
|
981
1474
|
<div className="space-y-1">
|
|
982
1475
|
<button
|
|
983
|
-
onClick={() =>
|
|
1476
|
+
onClick={() =>
|
|
1477
|
+
handleCreateStory(
|
|
1478
|
+
"cartoon",
|
|
1479
|
+
newStoryLanguage,
|
|
1480
|
+
newStoryAgentMode,
|
|
1481
|
+
"codex",
|
|
1482
|
+
)
|
|
1483
|
+
}
|
|
984
1484
|
disabled={cartoonBlocked || !newStoryTitle.trim()}
|
|
985
1485
|
data-testid="create-cartoon"
|
|
986
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"
|
|
987
1487
|
>
|
|
988
|
-
<p className="text-sm font-serif font-medium text-foreground">
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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.
|
|
992
1500
|
</p>
|
|
993
1501
|
</button>
|
|
994
1502
|
{/* Warnings/copy live OUTSIDE the button: a disabled button would
|
|
@@ -999,8 +1507,10 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
999
1507
|
data-testid="cartoon-codex-warning"
|
|
1000
1508
|
>
|
|
1001
1509
|
Codex was not detected. Install the Codex CLI and sign in
|
|
1002
|
-
(e.g.
|
|
1003
|
-
<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.
|
|
1004
1514
|
</p>
|
|
1005
1515
|
)}
|
|
1006
1516
|
{isCodexAuthUnclear(readiness) && (
|
|
@@ -1017,8 +1527,8 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1017
1527
|
readiness.codex.imageGeneration !== "enabled" && (
|
|
1018
1528
|
<div data-testid="cartoon-codex-warning">
|
|
1019
1529
|
<p className="text-[11px] text-amber-700 text-left">
|
|
1020
|
-
Codex is installed but image generation isn't
|
|
1021
|
-
Enable it, then reopen this dialog:
|
|
1530
|
+
Codex is installed but image generation isn't
|
|
1531
|
+
enabled. Enable it, then reopen this dialog:
|
|
1022
1532
|
</p>
|
|
1023
1533
|
<div className="mt-1 flex items-center gap-1">
|
|
1024
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">
|