plotlink-ows 1.0.33 → 1.2.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/app/lib/active-wallet.ts +260 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +813 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +8 -1
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +242 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/dashboard.ts +6 -4
- package/app/routes/publish.ts +259 -45
- package/app/routes/settings.ts +92 -37
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/routes/wallet.ts +58 -30
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonNextAction.tsx +145 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1337 -0
- package/app/web/components/Dashboard.tsx +15 -6
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1182 -0
- package/app/web/components/PreviewPanel.tsx +952 -78
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +745 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +446 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +156 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-che5mMWc.js +1 -0
- package/app/web/dist/assets/index-CcfChGEK.css +32 -0
- package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-DxATSk7X.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/scripts/e2e-verify.ts +0 -1100
|
@@ -2,6 +2,18 @@ import { useState, useCallback, useRef, useEffect } from "react";
|
|
|
2
2
|
import { StoryBrowser } from "./StoryBrowser";
|
|
3
3
|
import { TerminalPanel } from "./TerminalPanel";
|
|
4
4
|
import { PreviewPanel } from "./PreviewPanel";
|
|
5
|
+
import { StoryProgressPanel } from "./StoryProgressPanel";
|
|
6
|
+
import { CartoonWorkflowNav, type CartoonWorkflowTab } from "./CartoonWorkflowNav";
|
|
7
|
+
import { StoryInfoPage } from "./StoryInfoPage";
|
|
8
|
+
import { EpisodesPage } from "./EpisodesPage";
|
|
9
|
+
import { CartoonPublishPage } from "./CartoonPublishPage";
|
|
10
|
+
import { CartoonNextAction } from "./CartoonNextAction";
|
|
11
|
+
import { LANGUAGES, GENRES } from "../../../lib/genres";
|
|
12
|
+
import { getContentTypeForPublish, resolveSelectedContentType, needsLegacyProviderRepair, attachCoverToStoryline, derivePublishTitle, shouldBlockDuplicatePlotPublish, isRawFilenameTitle, hasExplicitEpisodeTitle, isPreflightBlocked, formatPreflightBlock } from "../lib/publish-helpers";
|
|
13
|
+
import { verifyPublicCartoonTitle, publicTitleWarning } from "../lib/verify-public-title";
|
|
14
|
+
import { isCodexAuthUnclear, CODEX_AUTH_UNCLEAR_MESSAGE, type AgentReadiness } from "@app-lib/agent-readiness";
|
|
15
|
+
import { cartoonGenesisReadiness } from "@app-lib/cartoon-readiness";
|
|
16
|
+
import type { CoachUiAction } from "@app-lib/cartoon-coach";
|
|
5
17
|
|
|
6
18
|
interface StoriesPageProps {
|
|
7
19
|
token: string;
|
|
@@ -36,13 +48,55 @@ function clampRatio(r: number, available: number): number {
|
|
|
36
48
|
export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
37
49
|
const [selectedStory, setSelectedStory] = useState<string | null>(null);
|
|
38
50
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
51
|
+
// Cartoon right-panel workflow nav (#439): a non-file workflow page is open
|
|
52
|
+
// (Story Info / Episodes). null ⇒ the view follows selectedFile (or Progress).
|
|
53
|
+
const [cartoonView, setCartoonView] = useState<"story-info" | "episodes" | "publish" | null>(null);
|
|
39
54
|
const [publishingFile, setPublishingFile] = useState<string | null>(null);
|
|
55
|
+
// Bumped on a confirmed publish so the cartoon Publish page re-reads readiness
|
|
56
|
+
// and advances to the next episode (#461).
|
|
57
|
+
const [cartoonPublishRefresh, setCartoonPublishRefresh] = useState(0);
|
|
40
58
|
const [publishProgress, setPublishProgress] = useState<string>("");
|
|
59
|
+
// Durable publish blocker (#375): unlike the transient publishProgress text,
|
|
60
|
+
// this stays visible until the writer dismisses it or starts a new publish, so
|
|
61
|
+
// an insufficient-balance preflight block doesn't silently vanish.
|
|
62
|
+
const [publishError, setPublishError] = useState<string | null>(null);
|
|
41
63
|
const [walletAddress, setWalletAddress] = useState<string | null>(null);
|
|
42
64
|
const [ratio, setRatio] = useState(loadRatio);
|
|
43
65
|
const [untitledSessions, setUntitledSessions] = useState<string[]>([]);
|
|
66
|
+
const [showNewStoryModal, setShowNewStoryModal] = useState(false);
|
|
67
|
+
const [newStoryTitle, setNewStoryTitle] = useState("");
|
|
68
|
+
const [newStoryDescription, setNewStoryDescription] = useState("");
|
|
69
|
+
const [newStoryGenre, setNewStoryGenre] = useState("");
|
|
70
|
+
const [newStoryLanguage, setNewStoryLanguage] = useState("English");
|
|
71
|
+
const [newStoryAgentMode, setNewStoryAgentMode] = useState<"normal" | "bypass">("normal");
|
|
72
|
+
const [newStoryAgentProvider, setNewStoryAgentProvider] = useState<"claude" | "codex">("claude");
|
|
73
|
+
const [readiness, setReadiness] = useState<AgentReadiness | null>(null);
|
|
74
|
+
const [codexEnableCopied, setCodexEnableCopied] = useState(false);
|
|
75
|
+
const [bypassStories, setBypassStories] = useState<Record<string, boolean>>({});
|
|
76
|
+
const [agentProviders, setAgentProviders] = useState<Record<string, "claude" | "codex">>({});
|
|
77
|
+
// Track confirmed stories (those with structure.md) for Archive gating
|
|
78
|
+
const [confirmedStories, setConfirmedStories] = useState<Set<string>>(new Set());
|
|
79
|
+
// Stories that already have a genesis.md — so the outline footer can suggest
|
|
80
|
+
// reviewing Genesis rather than writing it again (#422).
|
|
81
|
+
const [genesisStories, setGenesisStories] = useState<Set<string>>(new Set());
|
|
82
|
+
const [storyContentTypes, setStoryContentTypes] = useState<Record<string, "fiction" | "cartoon">>({});
|
|
83
|
+
// `undefined` ⇒ language couldn't be determined for the story → the publish
|
|
84
|
+
// panel shows "Needs metadata" rather than defaulting to English (#424).
|
|
85
|
+
const [storyLanguages, setStoryLanguages] = useState<Record<string, string | undefined>>({});
|
|
86
|
+
// Publish metadata from .story.json (#424) so the publish controls seed real
|
|
87
|
+
// values. `undefined` ⇒ not set in .story.json → client shows "Needs metadata".
|
|
88
|
+
const [storyGenres, setStoryGenres] = useState<Record<string, string | undefined>>({});
|
|
89
|
+
const [storyNsfw, setStoryNsfw] = useState<Record<string, boolean | undefined>>({});
|
|
90
|
+
const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
|
|
91
|
+
// Provider recorded on each persisted story (read-only, from /api/stories).
|
|
92
|
+
// Absent ⇒ legacy story with no provider (defaults to Claude at launch).
|
|
93
|
+
const [storyProviders, setStoryProviders] = useState<Record<string, "claude" | "codex" | undefined>>({});
|
|
94
|
+
const contentTypeMap = useRef<Map<string, "fiction" | "cartoon">>(new Map());
|
|
95
|
+
const languageMap = useRef<Map<string, string>>(new Map());
|
|
96
|
+
const agentModeMap = useRef<Map<string, "normal" | "bypass">>(new Map());
|
|
97
|
+
const agentProviderMap = useRef<Map<string, "claude" | "codex">>(new Map());
|
|
44
98
|
const knownStoriesRef = useRef<Set<string>>(new Set());
|
|
45
|
-
const renameRef = useRef<((oldName: string, newName: string) => Promise<boolean>) | null>(null);
|
|
99
|
+
const renameRef = useRef<((oldName: string, newName: string, meta?: { contentType?: "fiction" | "cartoon"; language?: string; agentMode?: "normal" | "bypass"; agentProvider?: "claude" | "codex" }) => Promise<boolean>) | null>(null);
|
|
46
100
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
47
101
|
const dragging = useRef(false);
|
|
48
102
|
|
|
@@ -54,6 +108,15 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
54
108
|
.catch(() => {});
|
|
55
109
|
}, [authFetch]);
|
|
56
110
|
|
|
111
|
+
// Best-effort agent-readiness probe for cartoon-mode guidance. Failures leave
|
|
112
|
+
// readiness null (no warning shown); this never blocks fiction or cartoon.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
authFetch("/api/agent/readiness")
|
|
115
|
+
.then((res) => res.ok ? res.json() : null)
|
|
116
|
+
.then((data) => { if (data) setReadiness(data); })
|
|
117
|
+
.catch(() => {});
|
|
118
|
+
}, [authFetch]);
|
|
119
|
+
|
|
57
120
|
// Persist ratio to localStorage
|
|
58
121
|
useEffect(() => {
|
|
59
122
|
try { localStorage.setItem(STORAGE_KEY, String(ratio)); } catch { /* ignore */ }
|
|
@@ -73,12 +136,51 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
73
136
|
}, []);
|
|
74
137
|
|
|
75
138
|
const handleNewStory = useCallback(() => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
139
|
+
setNewStoryTitle("");
|
|
140
|
+
setNewStoryDescription("");
|
|
141
|
+
setNewStoryGenre("");
|
|
142
|
+
setNewStoryAgentMode("normal");
|
|
143
|
+
setNewStoryAgentProvider("claude");
|
|
144
|
+
setShowNewStoryModal(true);
|
|
80
145
|
}, []);
|
|
81
146
|
|
|
147
|
+
// Guided New Story (#423): create the named story up front from the chosen
|
|
148
|
+
// title/metadata (server writes .story.json + CLAUDE.md), then land on the
|
|
149
|
+
// story progress overview (#418) so the user sees what's next + a copy-paste
|
|
150
|
+
// first prompt — no need to ask the agent to rename an "Untitled" project.
|
|
151
|
+
const handleCreateStory = useCallback(async (contentType: "fiction" | "cartoon", language: string, agentMode: "normal" | "bypass", agentProvider: "claude" | "codex") => {
|
|
152
|
+
const title = newStoryTitle.trim();
|
|
153
|
+
if (!title) return; // guarded by the disabled Create buttons
|
|
154
|
+
const provider = contentType === "cartoon" ? "codex" : agentProvider;
|
|
155
|
+
try {
|
|
156
|
+
const res = await authFetch("/api/stories/create", {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "Content-Type": "application/json" },
|
|
159
|
+
body: JSON.stringify({
|
|
160
|
+
title,
|
|
161
|
+
description: newStoryDescription.trim() || undefined,
|
|
162
|
+
language,
|
|
163
|
+
genre: newStoryGenre || undefined,
|
|
164
|
+
contentType,
|
|
165
|
+
agentMode,
|
|
166
|
+
agentProvider: provider,
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
if (!res.ok) return;
|
|
170
|
+
const data = await res.json();
|
|
171
|
+
setShowNewStoryModal(false);
|
|
172
|
+
// Prime the client maps so gating/labels are right before the next poll.
|
|
173
|
+
setStoryContentTypes((prev) => ({ ...prev, [data.name]: contentType }));
|
|
174
|
+
setStoryLanguages((prev) => ({ ...prev, [data.name]: language }));
|
|
175
|
+
if (newStoryGenre) setStoryGenres((prev) => ({ ...prev, [data.name]: newStoryGenre }));
|
|
176
|
+
setAgentProviders((prev) => ({ ...prev, [data.name]: provider }));
|
|
177
|
+
if (agentMode === "bypass") setBypassStories((prev) => ({ ...prev, [data.name]: true }));
|
|
178
|
+
// Land on the progress overview for the named story.
|
|
179
|
+
setSelectedStory(data.name);
|
|
180
|
+
setSelectedFile(null);
|
|
181
|
+
} catch { /* leave the modal open on failure */ }
|
|
182
|
+
}, [authFetch, newStoryTitle, newStoryDescription, newStoryGenre]);
|
|
183
|
+
|
|
82
184
|
// Poll for new stories and auto-transition untitled sessions
|
|
83
185
|
useEffect(() => {
|
|
84
186
|
if (untitledSessions.length === 0) return;
|
|
@@ -97,12 +199,41 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
97
199
|
if (!knownStoriesRef.current.has(name) && untitledSessions.length > 0) {
|
|
98
200
|
// New story appeared — rename the oldest untitled session to the story name
|
|
99
201
|
const oldName = untitledSessions[0];
|
|
202
|
+
// Read the pending session's metadata BEFORE the rename so it can be
|
|
203
|
+
// persisted atomically with the rename server-side (#295).
|
|
204
|
+
const ct = contentTypeMap.current.get(oldName) || "fiction";
|
|
205
|
+
const lang = languageMap.current.get(oldName) || "English";
|
|
206
|
+
const mode = agentModeMap.current.get(oldName) || "normal";
|
|
207
|
+
const provider = agentProviderMap.current.get(oldName) || "claude";
|
|
100
208
|
let renamed = false;
|
|
101
209
|
if (renameRef.current) {
|
|
102
|
-
renamed = await renameRef.current(oldName, name
|
|
210
|
+
renamed = await renameRef.current(oldName, name, {
|
|
211
|
+
contentType: ct, language: lang, agentMode: mode, agentProvider: provider,
|
|
212
|
+
}).catch(() => false);
|
|
103
213
|
}
|
|
104
214
|
if (renamed) {
|
|
105
215
|
setUntitledSessions((prev) => prev.slice(1));
|
|
216
|
+
contentTypeMap.current.delete(oldName);
|
|
217
|
+
languageMap.current.delete(oldName);
|
|
218
|
+
agentModeMap.current.delete(oldName);
|
|
219
|
+
agentProviderMap.current.delete(oldName);
|
|
220
|
+
if (mode === "bypass") {
|
|
221
|
+
setBypassStories((prev) => {
|
|
222
|
+
const next = { ...prev, [name]: true };
|
|
223
|
+
delete next[oldName];
|
|
224
|
+
return next;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
setAgentProviders((prev) => {
|
|
228
|
+
const next = { ...prev, [name]: provider };
|
|
229
|
+
delete next[oldName];
|
|
230
|
+
return next;
|
|
231
|
+
});
|
|
232
|
+
authFetch(`/api/stories/${name}/metadata`, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: { "Content-Type": "application/json" },
|
|
235
|
+
body: JSON.stringify({ contentType: ct, language: lang, agentMode: mode, agentProvider: provider }),
|
|
236
|
+
}).catch(() => {});
|
|
106
237
|
}
|
|
107
238
|
setSelectedStory(name);
|
|
108
239
|
setSelectedFile(null);
|
|
@@ -132,6 +263,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
132
263
|
const handleSelectFile = useCallback((storyName: string, fileName: string) => {
|
|
133
264
|
setSelectedStory(storyName);
|
|
134
265
|
setSelectedFile(fileName);
|
|
266
|
+
setCartoonView(null); // a file view supersedes a non-file workflow page
|
|
135
267
|
}, []);
|
|
136
268
|
|
|
137
269
|
const latestStoryRef = useRef<string | null>(null);
|
|
@@ -140,13 +272,16 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
140
272
|
latestStoryRef.current = name;
|
|
141
273
|
setSelectedStory(name);
|
|
142
274
|
setSelectedFile(null);
|
|
143
|
-
|
|
275
|
+
setCartoonView(null);
|
|
276
|
+
// Cartoon stories land on the story-level progress overview (#418). Fiction
|
|
277
|
+
// PRESERVES the existing auto-open-latest-file behavior (fiction can still
|
|
278
|
+
// reach the overview via the "Progress" button).
|
|
144
279
|
try {
|
|
145
280
|
const res = await authFetch(`/api/stories/${name}`);
|
|
146
281
|
if (res.ok && latestStoryRef.current === name) {
|
|
147
282
|
const data = await res.json();
|
|
283
|
+
if (data.contentType === "cartoon") return; // overview
|
|
148
284
|
const files: { file: string }[] = data.files || [];
|
|
149
|
-
// Priority: highest plot → genesis → structure → first
|
|
150
285
|
const plots = files
|
|
151
286
|
.map((f) => ({ file: f.file, num: f.file.match(/^plot-(\d+)\.md$/)?.[1] }))
|
|
152
287
|
.filter((p) => p.num != null)
|
|
@@ -157,7 +292,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
157
292
|
?? files[0]?.file;
|
|
158
293
|
if (latest && latestStoryRef.current === name) setSelectedFile(latest);
|
|
159
294
|
}
|
|
160
|
-
} catch { /* ignore */ }
|
|
295
|
+
} catch { /* ignore — stays on overview */ }
|
|
161
296
|
}, [authFetch]);
|
|
162
297
|
|
|
163
298
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
@@ -186,9 +321,21 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
186
321
|
window.addEventListener("mouseup", onMouseUp);
|
|
187
322
|
}, []);
|
|
188
323
|
|
|
189
|
-
const handlePublish = useCallback(async (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => {
|
|
324
|
+
const handlePublish = useCallback(async (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean, coverFile?: File | null) => {
|
|
190
325
|
setPublishingFile(fileName);
|
|
191
326
|
setPublishProgress("Reading file...");
|
|
327
|
+
setPublishError(null); // clear any prior durable block on a fresh attempt (#375)
|
|
328
|
+
let coverAttachFailed = false;
|
|
329
|
+
// Durable #379 public-title verification warning, set after indexing if
|
|
330
|
+
// PlotLink indexed a raw/generic public title (surfaced even though the
|
|
331
|
+
// publish itself succeeded — the metadata is immutable).
|
|
332
|
+
let titleVerifyWarning: string | null = null;
|
|
333
|
+
// Whether the publish actually SUCCEEDED on-chain (the SSE `done` event with a
|
|
334
|
+
// txHash). Returned to the caller so PreviewPanel drops the selected genesis
|
|
335
|
+
// cover ONLY on a confirmed-successful publish. A publish that is blocked
|
|
336
|
+
// before the stream (#375) OR opens then fails/errors before `done` (#376)
|
|
337
|
+
// leaves this false, so the writer's cover stays selected for the retry.
|
|
338
|
+
let succeeded = false;
|
|
192
339
|
|
|
193
340
|
try {
|
|
194
341
|
// Get file content
|
|
@@ -196,13 +343,93 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
196
343
|
if (!fileRes.ok) throw new Error("Failed to read file");
|
|
197
344
|
const fileData = await fileRes.json();
|
|
198
345
|
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
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.",
|
|
384
|
+
);
|
|
385
|
+
setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Defense-in-depth (#365, tightened #368): a cartoon plot must have an
|
|
390
|
+
// explicit reader-facing title (cut-plan title or a real H1) that is NOT a
|
|
391
|
+
// generic "Episode NN"/"Chapter NN"/"plot-NN" placeholder. Block the action
|
|
392
|
+
// too, not just the panel.
|
|
393
|
+
if (publishContentType === "cartoon" && fileName.match(/^plot-\d+\.md$/)
|
|
394
|
+
&& !hasExplicitEpisodeTitle({ fileContent: fileData.content, episodeTitle })) {
|
|
395
|
+
setPublishProgress(
|
|
396
|
+
"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.",
|
|
397
|
+
);
|
|
398
|
+
setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Defense-in-depth (#359, hardened in #400): a cartoon Genesis is the
|
|
403
|
+
// reader-facing opening, so block publish when it isn't a real story
|
|
404
|
+
// opening (missing H1, synopsis/outline shape, too short, or a single dense
|
|
405
|
+
// block) even if the panel guard is bypassed. Surface the specific blocker.
|
|
406
|
+
if (publishContentType === "cartoon" && fileName === "genesis.md") {
|
|
407
|
+
const genesisBlockers = cartoonGenesisReadiness(fileData.content).blockers;
|
|
408
|
+
if (genesisBlockers.length > 0) {
|
|
409
|
+
setPublishProgress(
|
|
410
|
+
`Genesis is the reader-facing Story opening — fix it before publishing: ${genesisBlockers[0]}`,
|
|
411
|
+
);
|
|
412
|
+
setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
202
416
|
|
|
203
417
|
// For plot files, find the storylineId from the genesis publish status
|
|
204
418
|
let storylineId: number | undefined;
|
|
205
419
|
if (fileName.match(/^plot-\d+\.md$/)) {
|
|
420
|
+
// #332: never mint a second chainPlot for a plot that already has an
|
|
421
|
+
// on-chain chapter recorded — a duplicate chainPlot creates a permanent
|
|
422
|
+
// extra chapter on PlotLink. fileData carries the retained txHash/
|
|
423
|
+
// plotIndex even when a later content edit reset status to "pending".
|
|
424
|
+
// The published-not-indexed recovery path is exempt (handled in the
|
|
425
|
+
// preview UI behind an explicit duplicate-risk confirm).
|
|
426
|
+
if (shouldBlockDuplicatePlotPublish(fileData)) {
|
|
427
|
+
setPublishProgress(
|
|
428
|
+
"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).",
|
|
429
|
+
);
|
|
430
|
+
setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
206
433
|
try {
|
|
207
434
|
const storyRes = await authFetch(`/api/stories/${storyName}`);
|
|
208
435
|
if (storyRes.ok) {
|
|
@@ -215,16 +442,42 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
215
442
|
if (!storylineId) {
|
|
216
443
|
setPublishProgress("Error: Publish genesis first to create the storyline");
|
|
217
444
|
setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 3000);
|
|
218
|
-
return;
|
|
445
|
+
return false;
|
|
219
446
|
}
|
|
220
447
|
}
|
|
221
448
|
|
|
449
|
+
// #375: gate on wallet balance BEFORE opening the publish stream. The
|
|
450
|
+
// pilot's publish proceeded into "Broadcasting transaction..." despite
|
|
451
|
+
// preflight already reporting insufficient ETH, then returned to draft with
|
|
452
|
+
// no durable error. Run preflight here and, if the OWS wallet can't cover at
|
|
453
|
+
// least the creation fee (or is otherwise not ready), block with a durable,
|
|
454
|
+
// obvious inline error instead of calling /api/publish/file. A preflight
|
|
455
|
+
// network/HTTP error is NOT treated as a block — fall through so a flaky
|
|
456
|
+
// preflight can't stop an otherwise-fundable publish (the stream surfaces
|
|
457
|
+
// its own error).
|
|
458
|
+
setPublishProgress("Checking wallet balance...");
|
|
459
|
+
try {
|
|
460
|
+
const preRes = await authFetch("/api/publish/preflight");
|
|
461
|
+
if (preRes.ok) {
|
|
462
|
+
const pre = await preRes.json();
|
|
463
|
+
if (isPreflightBlocked(pre)) {
|
|
464
|
+
setPublishError(formatPreflightBlock(pre));
|
|
465
|
+
setPublishingFile(null);
|
|
466
|
+
setPublishProgress("");
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} catch { /* preflight unreachable — don't hard-block; let the publish stream report */ }
|
|
471
|
+
|
|
222
472
|
// Run publish flow via SSE
|
|
223
473
|
setPublishProgress("Publishing...");
|
|
224
474
|
const publishRes = await authFetch("/api/publish/file", {
|
|
225
475
|
method: "POST",
|
|
226
476
|
headers: { "Content-Type": "application/json" },
|
|
227
|
-
body: JSON.stringify({
|
|
477
|
+
body: JSON.stringify({
|
|
478
|
+
storyName, fileName, title, content: fileData.content, genre, language, isNsfw, storylineId,
|
|
479
|
+
...(getContentTypeForPublish(storyContentTypes, storyName, storylineId) ? { contentType: getContentTypeForPublish(storyContentTypes, storyName, storylineId) } : {}),
|
|
480
|
+
}),
|
|
228
481
|
});
|
|
229
482
|
|
|
230
483
|
if (!publishRes.ok) {
|
|
@@ -247,6 +500,12 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
247
500
|
const data = JSON.parse(line.slice(6));
|
|
248
501
|
if (data.step) setPublishProgress(data.message || data.step);
|
|
249
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;
|
|
250
509
|
// Update publish status with gasCost
|
|
251
510
|
await authFetch(`/api/stories/${storyName}/${fileName}/publish-status`, {
|
|
252
511
|
method: "POST",
|
|
@@ -261,13 +520,66 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
261
520
|
authorAddress: walletAddress,
|
|
262
521
|
}),
|
|
263
522
|
});
|
|
523
|
+
// Advance the cartoon Publish page to the next episode (#461).
|
|
524
|
+
setCartoonPublishRefresh((n) => n + 1);
|
|
525
|
+
|
|
526
|
+
// Pre-publish cover (#284): a new genesis can't carry a cover
|
|
527
|
+
// through createStoryline, so once the storyline exists, attach
|
|
528
|
+
// the selected cover via upload-cover + update-storyline. Best-
|
|
529
|
+
// effort — a failure leaves the storyline published with no
|
|
530
|
+
// cover, settable later via Edit Story.
|
|
531
|
+
if (coverFile && fileName === "genesis.md" && data.storylineId) {
|
|
532
|
+
setPublishProgress("Uploading cover...");
|
|
533
|
+
let coverCid: string | null = null;
|
|
534
|
+
try {
|
|
535
|
+
coverCid = await attachCoverToStoryline(authFetch, data.storylineId, coverFile);
|
|
536
|
+
} catch { /* non-fatal: storyline is already published */ }
|
|
537
|
+
// A null result means the cover was not attached (upload or
|
|
538
|
+
// update-storyline failed). The storyline is published either
|
|
539
|
+
// way; tell the user so they can retry from Edit Story.
|
|
540
|
+
if (!coverCid) coverAttachFailed = true;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// #379: end-to-end public-title verification. Local guards ensure
|
|
544
|
+
// OWS sends a reader-facing title, but the pilot showed PlotLink
|
|
545
|
+
// can still index a raw "genesis"/"plot-NN" title. There is no
|
|
546
|
+
// public JSON read endpoint, so an OWS server route reads the
|
|
547
|
+
// rendered public page's og:title (no CORS) and returns the
|
|
548
|
+
// indexed title; verify it here. Inconclusive reads (page
|
|
549
|
+
// unreachable / no title) never warn — only a confirmed
|
|
550
|
+
// raw/generic public title does. The publish is already on-chain +
|
|
551
|
+
// immutable, so this can only warn.
|
|
552
|
+
if (publishContentType === "cartoon" && data.storylineId) {
|
|
553
|
+
try {
|
|
554
|
+
const isPlot = fileName !== "genesis.md";
|
|
555
|
+
const q = `storylineId=${data.storylineId}` +
|
|
556
|
+
(isPlot && data.plotIndex != null ? `&plotIndex=${data.plotIndex}` : "");
|
|
557
|
+
const pubRes = await authFetch(`/api/publish/public-title?${q}`);
|
|
558
|
+
if (pubRes.ok) {
|
|
559
|
+
const pub = await pubRes.json();
|
|
560
|
+
const detail = isPlot
|
|
561
|
+
? { plots: pub.plotTitle != null ? [{ plotIndex: data.plotIndex, title: pub.plotTitle }] : [] }
|
|
562
|
+
: { title: pub.storylineTitle };
|
|
563
|
+
const verdict = verifyPublicCartoonTitle({ fileName, detail, plotIndex: data.plotIndex });
|
|
564
|
+
if (!verdict.ok) titleVerifyWarning = publicTitleWarning(verdict);
|
|
565
|
+
}
|
|
566
|
+
} catch { /* inconclusive — don't false-warn on a read failure */ }
|
|
567
|
+
}
|
|
264
568
|
}
|
|
265
569
|
} catch { /* ignore partial SSE */ }
|
|
266
570
|
}
|
|
267
571
|
}
|
|
268
572
|
}
|
|
269
573
|
|
|
270
|
-
|
|
574
|
+
// A failed public-title verification (#379) is a durable warning that
|
|
575
|
+
// outranks the transient "Published!" line — the metadata is immutable, so
|
|
576
|
+
// the writer must know the next publish needs corrected metadata.
|
|
577
|
+
if (titleVerifyWarning) setPublishError(titleVerifyWarning);
|
|
578
|
+
setPublishProgress(
|
|
579
|
+
coverAttachFailed
|
|
580
|
+
? "Published, but cover upload failed — set it later from Edit Story."
|
|
581
|
+
: "Published!",
|
|
582
|
+
);
|
|
271
583
|
} catch (err: unknown) {
|
|
272
584
|
const message = err instanceof Error ? err.message : "Publish failed";
|
|
273
585
|
setPublishProgress(`Error: ${message}`);
|
|
@@ -277,42 +589,130 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
277
589
|
setPublishProgress("");
|
|
278
590
|
}, 3000);
|
|
279
591
|
}
|
|
280
|
-
|
|
592
|
+
// Tell PreviewPanel whether it may drop the selected cover. Clear ONLY when
|
|
593
|
+
// the publish is confirmed on-chain AND the cover was actually attached:
|
|
594
|
+
// - pre-stream block (#375) or failed/aborted publish (#376) → succeeded false → keep,
|
|
595
|
+
// - published on-chain but the cover upload/attach failed (#376/re1) →
|
|
596
|
+
// coverAttachFailed true → keep, so the writer doesn't silently lose the
|
|
597
|
+
// cover that never made it onto the storyline (settable via Edit Story).
|
|
598
|
+
// A publish with no selected cover (coverAttachFailed stays false) clears as
|
|
599
|
+
// before once it succeeds.
|
|
600
|
+
return succeeded && !coverAttachFailed;
|
|
601
|
+
}, [authFetch, storyContentTypes, walletAddress]);
|
|
281
602
|
|
|
282
603
|
const handleDestroySession = useCallback((name: string) => {
|
|
283
604
|
if (name.startsWith("_new_")) {
|
|
284
605
|
setUntitledSessions((prev) => prev.filter((id) => id !== name));
|
|
606
|
+
contentTypeMap.current.delete(name);
|
|
607
|
+
languageMap.current.delete(name);
|
|
608
|
+
agentModeMap.current.delete(name);
|
|
609
|
+
agentProviderMap.current.delete(name);
|
|
610
|
+
setBypassStories((prev) => {
|
|
611
|
+
if (!(name in prev)) return prev;
|
|
612
|
+
const next = { ...prev };
|
|
613
|
+
delete next[name];
|
|
614
|
+
return next;
|
|
615
|
+
});
|
|
616
|
+
setAgentProviders((prev) => {
|
|
617
|
+
if (!(name in prev)) return prev;
|
|
618
|
+
const next = { ...prev };
|
|
619
|
+
delete next[name];
|
|
620
|
+
return next;
|
|
621
|
+
});
|
|
285
622
|
}
|
|
286
623
|
}, []);
|
|
287
624
|
|
|
288
|
-
// Track confirmed stories (those with structure.md) for Archive gating
|
|
289
|
-
const [confirmedStories, setConfirmedStories] = useState<Set<string>>(new Set());
|
|
290
625
|
useEffect(() => {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
626
|
+
const updateFromStories = (stories: { name: string; title?: string | null; hasStructure: boolean; hasGenesis?: boolean; contentType?: "fiction" | "cartoon"; language?: string; genre?: string; isNsfw?: boolean; agentProvider?: "claude" | "codex" }[]) => {
|
|
627
|
+
setConfirmedStories(new Set(stories.filter((s) => s.hasStructure).map((s) => s.name)));
|
|
628
|
+
setGenesisStories(new Set(stories.filter((s) => s.hasGenesis).map((s) => s.name)));
|
|
629
|
+
const ct: Record<string, "fiction" | "cartoon"> = {};
|
|
630
|
+
const lang: Record<string, string | undefined> = {};
|
|
631
|
+
const genre: Record<string, string | undefined> = {};
|
|
632
|
+
const nsfw: Record<string, boolean | undefined> = {};
|
|
633
|
+
const prov: Record<string, "claude" | "codex" | undefined> = {};
|
|
634
|
+
const titles: Record<string, string> = {};
|
|
635
|
+
for (const s of stories) {
|
|
636
|
+
ct[s.name] = s.contentType || "fiction";
|
|
637
|
+
// Preserve absence (vs. defaulting to English/Romance) so the publish
|
|
638
|
+
// panel can tell "set to X" from "not set yet" and show Needs metadata (#424).
|
|
639
|
+
lang[s.name] = s.language;
|
|
640
|
+
genre[s.name] = s.genre;
|
|
641
|
+
nsfw[s.name] = s.isNsfw;
|
|
642
|
+
prov[s.name] = s.agentProvider;
|
|
643
|
+
if (s.title) titles[s.name] = s.title;
|
|
298
644
|
}
|
|
645
|
+
setStoryContentTypes(ct);
|
|
646
|
+
setStoryLanguages(lang);
|
|
647
|
+
setStoryGenres(genre);
|
|
648
|
+
setStoryNsfw(nsfw);
|
|
649
|
+
setStoryProviders(prov);
|
|
650
|
+
setStoryTitles(titles);
|
|
651
|
+
};
|
|
652
|
+
authFetch("/api/stories").then((res) => res.ok ? res.json() : null).then((data) => {
|
|
653
|
+
if (data?.stories) updateFromStories(data.stories);
|
|
299
654
|
}).catch(() => {});
|
|
300
655
|
const interval = setInterval(async () => {
|
|
301
656
|
try {
|
|
302
657
|
const res = await authFetch("/api/stories");
|
|
303
658
|
if (res.ok) {
|
|
304
659
|
const data = await res.json();
|
|
305
|
-
|
|
306
|
-
(data.stories as { name: string; hasStructure: boolean }[])
|
|
307
|
-
.filter((s) => s.hasStructure)
|
|
308
|
-
.map((s) => s.name)
|
|
309
|
-
));
|
|
660
|
+
updateFromStories(data.stories);
|
|
310
661
|
}
|
|
311
662
|
} catch { /* ignore */ }
|
|
312
663
|
}, 5000);
|
|
313
664
|
return () => clearInterval(interval);
|
|
314
665
|
}, [authFetch]);
|
|
315
666
|
|
|
667
|
+
// Codex readiness for cartoon gating. `codexReady` requires Codex installed
|
|
668
|
+
// AND image_generation effectively enabled. `cartoonBlocked` only disables
|
|
669
|
+
// create once readiness has actually loaded and is not ready — when readiness
|
|
670
|
+
// is still null (loading or probe-endpoint failure) we DO NOT block, to avoid
|
|
671
|
+
// permanently bricking cartoon if the probe errors.
|
|
672
|
+
const codexReady =
|
|
673
|
+
!!readiness && readiness.codex.installed && readiness.codex.imageGeneration === "enabled";
|
|
674
|
+
const cartoonBlocked = !!readiness && !codexReady;
|
|
675
|
+
|
|
676
|
+
const copyCodexEnable = useCallback(async () => {
|
|
677
|
+
try {
|
|
678
|
+
await navigator.clipboard.writeText("codex features enable image_generation");
|
|
679
|
+
setCodexEnableCopied(true);
|
|
680
|
+
setTimeout(() => setCodexEnableCopied(false), 2000);
|
|
681
|
+
} catch { /* clipboard unavailable */ }
|
|
682
|
+
}, []);
|
|
683
|
+
|
|
684
|
+
// Explicit, scoped repair for a legacy cartoon story with no recorded
|
|
685
|
+
// provider: set THIS story's `agentProvider` to "codex". Reuses the metadata
|
|
686
|
+
// route, whose `...existing` spread preserves language/agentMode. Does NOT
|
|
687
|
+
// touch fiction, does NOT bulk-migrate. Optimistically updates local provider
|
|
688
|
+
// state so launch gating sees codex immediately, then re-fetches.
|
|
689
|
+
const handleRepairProvider = useCallback(async () => {
|
|
690
|
+
if (!selectedStory || selectedStory.startsWith("_new_")) return;
|
|
691
|
+
const name = selectedStory;
|
|
692
|
+
const res = await authFetch(`/api/stories/${name}/metadata`, {
|
|
693
|
+
method: "POST",
|
|
694
|
+
headers: { "Content-Type": "application/json" },
|
|
695
|
+
body: JSON.stringify({ contentType: "cartoon", agentProvider: "codex" }),
|
|
696
|
+
});
|
|
697
|
+
if (!res.ok) return;
|
|
698
|
+
setStoryProviders((prev) => ({ ...prev, [name]: "codex" }));
|
|
699
|
+
setAgentProviders((prev) => ({ ...prev, [name]: "codex" }));
|
|
700
|
+
// Re-fetch so the list state reflects the persisted provider.
|
|
701
|
+
try {
|
|
702
|
+
const listRes = await authFetch("/api/stories");
|
|
703
|
+
if (listRes.ok) {
|
|
704
|
+
const data = await listRes.json();
|
|
705
|
+
if (data?.stories) {
|
|
706
|
+
const prov: Record<string, "claude" | "codex" | undefined> = {};
|
|
707
|
+
for (const s of data.stories as { name: string; agentProvider?: "claude" | "codex" }[]) {
|
|
708
|
+
prov[s.name] = s.agentProvider;
|
|
709
|
+
}
|
|
710
|
+
setStoryProviders(prov);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
} catch { /* ignore */ }
|
|
714
|
+
}, [authFetch, selectedStory]);
|
|
715
|
+
|
|
316
716
|
const handleArchiveStory = useCallback((name: string) => {
|
|
317
717
|
// Archive API already called by TerminalPanel — just clear selection
|
|
318
718
|
if (selectedStory === name) {
|
|
@@ -321,6 +721,79 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
321
721
|
}
|
|
322
722
|
}, [selectedStory]);
|
|
323
723
|
|
|
724
|
+
// Resolve the effective provider for the selected story: an optimistic/new
|
|
725
|
+
// session value (agentProviders) wins, else the persisted list value.
|
|
726
|
+
const selectedProvider = selectedStory
|
|
727
|
+
? (agentProviders[selectedStory] ?? storyProviders[selectedStory])
|
|
728
|
+
: undefined;
|
|
729
|
+
const selectedContentType = resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current);
|
|
730
|
+
const selectedNeedsProviderRepair = needsLegacyProviderRepair(
|
|
731
|
+
selectedContentType,
|
|
732
|
+
selectedProvider,
|
|
733
|
+
selectedStory,
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
// Cartoon-only right-panel workflow nav (#439). The active tab follows the
|
|
737
|
+
// open non-file view (Story Info / Episodes) or the closest file: structure.md
|
|
738
|
+
// ⇒ Whitepaper, genesis.md ⇒ Genesis / Ep 1, plot-NN ⇒ Episodes, else Progress.
|
|
739
|
+
const isCartoonStory = !!selectedStory && selectedContentType === "cartoon";
|
|
740
|
+
const activeCartoonTab: CartoonWorkflowTab =
|
|
741
|
+
cartoonView === "story-info" ? "story-info"
|
|
742
|
+
: cartoonView === "episodes" ? "episodes"
|
|
743
|
+
: cartoonView === "publish" ? "publish"
|
|
744
|
+
: selectedFile === "structure.md" ? "whitepaper"
|
|
745
|
+
: selectedFile === "genesis.md" ? "genesis"
|
|
746
|
+
: selectedFile && /^plot-\d+\.md$/.test(selectedFile) ? "episodes"
|
|
747
|
+
: "progress";
|
|
748
|
+
|
|
749
|
+
const handleCartoonNav = useCallback((tab: CartoonWorkflowTab) => {
|
|
750
|
+
// Use the current selected story, not latestStoryRef — that ref is only set
|
|
751
|
+
// by handleSelectStory, so opening another story's file via the LEFT tree
|
|
752
|
+
// (handleSelectFile) would leave it stale and route file tabs to the wrong
|
|
753
|
+
// story (#445 RE1). `selectedStory` always reflects the visible story.
|
|
754
|
+
const story = selectedStory;
|
|
755
|
+
if (!story) return;
|
|
756
|
+
switch (tab) {
|
|
757
|
+
case "progress": setCartoonView(null); setSelectedFile(null); break;
|
|
758
|
+
case "story-info": setCartoonView("story-info"); break;
|
|
759
|
+
case "episodes": setCartoonView("episodes"); break;
|
|
760
|
+
case "whitepaper": handleSelectFile(story, "structure.md"); break;
|
|
761
|
+
case "genesis": handleSelectFile(story, "genesis.md"); break;
|
|
762
|
+
// Publish opens its own readiness page and stays on the Publish tab (#449),
|
|
763
|
+
// instead of visually routing to the Genesis file view.
|
|
764
|
+
case "publish": setCartoonView("publish"); break;
|
|
765
|
+
}
|
|
766
|
+
}, [selectedStory, handleSelectFile]);
|
|
767
|
+
|
|
768
|
+
const handleWorkflowNextAction = useCallback((action: CoachUiAction, episodeFile: string | null) => {
|
|
769
|
+
const story = selectedStory;
|
|
770
|
+
if (!story) return;
|
|
771
|
+
switch (action) {
|
|
772
|
+
case "view-progress":
|
|
773
|
+
setCartoonView(null);
|
|
774
|
+
setSelectedFile(null);
|
|
775
|
+
break;
|
|
776
|
+
case "publish":
|
|
777
|
+
setCartoonView("publish");
|
|
778
|
+
break;
|
|
779
|
+
case "open-cuts":
|
|
780
|
+
case "open-lettering":
|
|
781
|
+
case "upload":
|
|
782
|
+
case "refresh-assets":
|
|
783
|
+
case "generate-markdown":
|
|
784
|
+
if (episodeFile) handleSelectFile(story, episodeFile);
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}, [selectedStory, handleSelectFile]);
|
|
788
|
+
|
|
789
|
+
// Keep the publish-control seeds in sync after a Story Info save (#439).
|
|
790
|
+
const handleStoryInfoSaved = useCallback((patch: { genre?: string; language?: string; isNsfw?: boolean }) => {
|
|
791
|
+
if (!selectedStory) return;
|
|
792
|
+
if (patch.genre !== undefined) setStoryGenres((prev) => ({ ...prev, [selectedStory]: patch.genre || undefined }));
|
|
793
|
+
if (patch.language !== undefined) setStoryLanguages((prev) => ({ ...prev, [selectedStory]: patch.language || undefined }));
|
|
794
|
+
if (patch.isNsfw !== undefined) setStoryNsfw((prev) => ({ ...prev, [selectedStory]: patch.isNsfw }));
|
|
795
|
+
}, [selectedStory]);
|
|
796
|
+
|
|
324
797
|
return (
|
|
325
798
|
<div ref={containerRef} className="h-[calc(100vh-3.5rem)] flex">
|
|
326
799
|
{/* Story Browser Sidebar */}
|
|
@@ -337,7 +810,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
337
810
|
|
|
338
811
|
{/* Terminal — sized by ratio of available space */}
|
|
339
812
|
<div className="min-w-0 border-r border-border" style={{ flex: `${ratio} 0 0` }}>
|
|
340
|
-
<TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} onArchiveStory={handleArchiveStory} confirmedStories={confirmedStories} renameRef={renameRef} />
|
|
813
|
+
<TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} onArchiveStory={handleArchiveStory} confirmedStories={confirmedStories} renameRef={renameRef} bypassStories={bypassStories} agentProviders={agentProviders} readiness={readiness} contentType={resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current)} needsProviderRepair={selectedNeedsProviderRepair} onRepairProvider={handleRepairProvider} />
|
|
341
814
|
</div>
|
|
342
815
|
|
|
343
816
|
{/* Drag Handle */}
|
|
@@ -353,8 +826,53 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
353
826
|
</div>
|
|
354
827
|
</div>
|
|
355
828
|
|
|
356
|
-
{/* Preview — takes remaining space
|
|
829
|
+
{/* Preview — takes remaining space. With a story but no file selected, show
|
|
830
|
+
the story-level progress overview (#418) instead of the empty state. */}
|
|
357
831
|
<div className="min-w-0 flex flex-col" style={{ flex: `${1 - ratio} 0 0` }}>
|
|
832
|
+
{/* Cartoon workflow nav (#439) — persistent above the right-panel content. */}
|
|
833
|
+
{isCartoonStory && selectedStory && (
|
|
834
|
+
<CartoonWorkflowNav
|
|
835
|
+
storyTitle={storyTitles[selectedStory] || selectedStory}
|
|
836
|
+
active={activeCartoonTab}
|
|
837
|
+
onSelect={handleCartoonNav}
|
|
838
|
+
/>
|
|
839
|
+
)}
|
|
840
|
+
{isCartoonStory && selectedStory && cartoonView !== null && (
|
|
841
|
+
<div className="flex-shrink-0 border-b border-border" data-testid="workflow-context-next-action">
|
|
842
|
+
<CartoonNextAction
|
|
843
|
+
storyName={selectedStory}
|
|
844
|
+
authFetch={authFetch}
|
|
845
|
+
refreshKey={cartoonPublishRefresh}
|
|
846
|
+
onCoachAction={handleWorkflowNextAction}
|
|
847
|
+
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
848
|
+
/>
|
|
849
|
+
</div>
|
|
850
|
+
)}
|
|
851
|
+
{isCartoonStory && cartoonView === "story-info" && selectedStory ? (
|
|
852
|
+
<StoryInfoPage storyName={selectedStory} authFetch={authFetch} onSaved={handleStoryInfoSaved} />
|
|
853
|
+
) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
|
|
854
|
+
<EpisodesPage storyName={selectedStory} authFetch={authFetch} onOpenFile={handleSelectFile} />
|
|
855
|
+
) : isCartoonStory && cartoonView === "publish" && selectedStory ? (
|
|
856
|
+
<CartoonPublishPage
|
|
857
|
+
storyName={selectedStory}
|
|
858
|
+
authFetch={authFetch}
|
|
859
|
+
onOpenFile={handleSelectFile}
|
|
860
|
+
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
861
|
+
onPublish={handlePublish}
|
|
862
|
+
publishingFile={publishingFile}
|
|
863
|
+
genre={storyGenres[selectedStory]}
|
|
864
|
+
language={storyLanguages[selectedStory]}
|
|
865
|
+
isNsfw={storyNsfw[selectedStory]}
|
|
866
|
+
refreshKey={cartoonPublishRefresh}
|
|
867
|
+
/>
|
|
868
|
+
) : selectedStory && !selectedFile ? (
|
|
869
|
+
<StoryProgressPanel
|
|
870
|
+
storyName={selectedStory}
|
|
871
|
+
authFetch={authFetch}
|
|
872
|
+
onOpenFile={handleSelectFile}
|
|
873
|
+
onOpenStoryInfo={() => setCartoonView("story-info")}
|
|
874
|
+
/>
|
|
875
|
+
) : (
|
|
358
876
|
<PreviewPanel
|
|
359
877
|
storyName={selectedStory}
|
|
360
878
|
fileName={selectedFile}
|
|
@@ -362,13 +880,207 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
362
880
|
onPublish={handlePublish}
|
|
363
881
|
publishingFile={publishingFile}
|
|
364
882
|
walletAddress={walletAddress}
|
|
883
|
+
contentType={resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current) || "fiction"}
|
|
884
|
+
language={selectedStory ? storyLanguages[selectedStory] : undefined}
|
|
885
|
+
genre={selectedStory ? storyGenres[selectedStory] : undefined}
|
|
886
|
+
isNsfw={selectedStory ? storyNsfw[selectedStory] : undefined}
|
|
887
|
+
hasGenesis={selectedStory ? genesisStories.has(selectedStory) : false}
|
|
888
|
+
onViewProgress={() => setSelectedFile(null)}
|
|
889
|
+
onOpenFile={(file) => selectedStory && handleSelectFile(selectedStory, file)}
|
|
890
|
+
onViewPublish={() => setCartoonView("publish")}
|
|
365
891
|
/>
|
|
892
|
+
)}
|
|
366
893
|
{publishProgress && (
|
|
367
894
|
<div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
|
|
368
895
|
{publishProgress}
|
|
369
896
|
</div>
|
|
370
897
|
)}
|
|
898
|
+
{/* Durable publish blocker (#375) — stays until dismissed or the next
|
|
899
|
+
publish attempt, so an insufficient-balance block is obvious and
|
|
900
|
+
doesn't disappear on a timer. */}
|
|
901
|
+
{publishError && (
|
|
902
|
+
<div
|
|
903
|
+
className="px-3 py-2 bg-error/10 border-t border-error/40 text-xs text-error flex items-start justify-between gap-3"
|
|
904
|
+
data-testid="publish-block-error"
|
|
905
|
+
role="alert"
|
|
906
|
+
>
|
|
907
|
+
<span>{publishError}</span>
|
|
908
|
+
<button
|
|
909
|
+
type="button"
|
|
910
|
+
onClick={() => setPublishError(null)}
|
|
911
|
+
className="shrink-0 text-error/70 hover:text-error underline"
|
|
912
|
+
data-testid="publish-block-error-dismiss"
|
|
913
|
+
>
|
|
914
|
+
Dismiss
|
|
915
|
+
</button>
|
|
916
|
+
</div>
|
|
917
|
+
)}
|
|
371
918
|
</div>
|
|
919
|
+
|
|
920
|
+
{showNewStoryModal && (
|
|
921
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
|
|
922
|
+
<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">New Story</h3>
|
|
924
|
+
<label className="block space-y-1">
|
|
925
|
+
<span className="text-[10px] font-medium text-muted">Title <span className="text-accent">*</span></span>
|
|
926
|
+
<input
|
|
927
|
+
type="text"
|
|
928
|
+
value={newStoryTitle}
|
|
929
|
+
onChange={(e) => setNewStoryTitle(e.target.value)}
|
|
930
|
+
placeholder="e.g. 신의 세포"
|
|
931
|
+
data-testid="new-story-title"
|
|
932
|
+
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
933
|
+
/>
|
|
934
|
+
</label>
|
|
935
|
+
<label className="block space-y-1">
|
|
936
|
+
<span className="text-[10px] font-medium text-muted">Short description (optional)</span>
|
|
937
|
+
<input
|
|
938
|
+
type="text"
|
|
939
|
+
value={newStoryDescription}
|
|
940
|
+
onChange={(e) => setNewStoryDescription(e.target.value)}
|
|
941
|
+
placeholder="One line about the story"
|
|
942
|
+
data-testid="new-story-description"
|
|
943
|
+
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
944
|
+
/>
|
|
945
|
+
</label>
|
|
946
|
+
<label className="block space-y-1">
|
|
947
|
+
<span className="text-[10px] font-medium text-muted">Genre (optional)</span>
|
|
948
|
+
<select
|
|
949
|
+
value={newStoryGenre}
|
|
950
|
+
onChange={(e) => setNewStoryGenre(e.target.value)}
|
|
951
|
+
data-testid="new-story-genre"
|
|
952
|
+
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
953
|
+
>
|
|
954
|
+
<option value="">— Select later —</option>
|
|
955
|
+
{GENRES.map((g) => <option key={g} value={g}>{g}</option>)}
|
|
956
|
+
</select>
|
|
957
|
+
</label>
|
|
958
|
+
<label className="block space-y-1">
|
|
959
|
+
<span className="text-[10px] font-medium text-muted">Language</span>
|
|
960
|
+
<select
|
|
961
|
+
value={newStoryLanguage}
|
|
962
|
+
onChange={(e) => setNewStoryLanguage(e.target.value)}
|
|
963
|
+
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
964
|
+
>
|
|
965
|
+
{LANGUAGES.map((l) => <option key={l} value={l}>{l}</option>)}
|
|
966
|
+
</select>
|
|
967
|
+
</label>
|
|
968
|
+
<label className="block space-y-1">
|
|
969
|
+
<span className="text-[10px] font-medium text-muted">Agent mode</span>
|
|
970
|
+
<select
|
|
971
|
+
value={newStoryAgentMode}
|
|
972
|
+
onChange={(e) => setNewStoryAgentMode(e.target.value as "normal" | "bypass")}
|
|
973
|
+
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
974
|
+
data-testid="agent-mode-select"
|
|
975
|
+
>
|
|
976
|
+
<option value="normal">Normal (approve each action)</option>
|
|
977
|
+
<option value="bypass">Permissions Bypass (advanced)</option>
|
|
978
|
+
</select>
|
|
979
|
+
{newStoryAgentMode === "bypass" && (
|
|
980
|
+
<p className="text-[10px] text-amber-700" data-testid="agent-mode-warning">
|
|
981
|
+
Less safe: Claude can run actions without per-command approval.
|
|
982
|
+
</p>
|
|
983
|
+
)}
|
|
984
|
+
</label>
|
|
985
|
+
<label className="block space-y-1">
|
|
986
|
+
<span className="text-[10px] font-medium text-muted">Provider</span>
|
|
987
|
+
<select
|
|
988
|
+
value={newStoryAgentProvider}
|
|
989
|
+
onChange={(e) => setNewStoryAgentProvider(e.target.value as "claude" | "codex")}
|
|
990
|
+
className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
991
|
+
data-testid="agent-provider-select"
|
|
992
|
+
>
|
|
993
|
+
<option value="claude">🤖 Claude (default)</option>
|
|
994
|
+
<option value="codex">🎨 Codex</option>
|
|
995
|
+
</select>
|
|
996
|
+
<p className="text-[10px] text-muted" data-testid="agent-provider-helper">
|
|
997
|
+
{newStoryAgentProvider === "codex"
|
|
998
|
+
? "Codex can generate clean cartoon images directly in the terminal."
|
|
999
|
+
: "Claude prepares image prompts; you generate and upload clean images externally."}
|
|
1000
|
+
</p>
|
|
1001
|
+
</label>
|
|
1002
|
+
<p className="text-xs text-muted text-center">Choose a content type to create</p>
|
|
1003
|
+
{!newStoryTitle.trim() && (
|
|
1004
|
+
<p className="text-[10px] text-amber-700 text-center" data-testid="new-story-title-required">Enter a title to create your story.</p>
|
|
1005
|
+
)}
|
|
1006
|
+
<div className="grid grid-cols-2 gap-3">
|
|
1007
|
+
<button
|
|
1008
|
+
onClick={() => handleCreateStory("fiction", newStoryLanguage, newStoryAgentMode, newStoryAgentProvider)}
|
|
1009
|
+
disabled={!newStoryTitle.trim()}
|
|
1010
|
+
data-testid="create-fiction"
|
|
1011
|
+
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
|
+
>
|
|
1013
|
+
<p className="text-sm font-serif font-medium text-foreground">Fiction</p>
|
|
1014
|
+
<p className="text-[11px] text-muted">Novels, short stories, poetry</p>
|
|
1015
|
+
</button>
|
|
1016
|
+
<div className="space-y-1">
|
|
1017
|
+
<button
|
|
1018
|
+
onClick={() => handleCreateStory("cartoon", newStoryLanguage, newStoryAgentMode, "codex")}
|
|
1019
|
+
disabled={cartoonBlocked || !newStoryTitle.trim()}
|
|
1020
|
+
data-testid="create-cartoon"
|
|
1021
|
+
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
|
+
>
|
|
1023
|
+
<p className="text-sm font-serif font-medium text-foreground">Cartoon</p>
|
|
1024
|
+
<p className="text-[11px] text-muted">Comics, manga, webtoons</p>
|
|
1025
|
+
<p className="text-[11px] text-muted" data-testid="cartoon-codex-note">
|
|
1026
|
+
Cartoon mode requires Codex because the clean-image step needs image generation support.
|
|
1027
|
+
</p>
|
|
1028
|
+
</button>
|
|
1029
|
+
{/* Warnings/copy live OUTSIDE the button: a disabled button would
|
|
1030
|
+
otherwise swallow clicks on the Copy control. */}
|
|
1031
|
+
{readiness && !readiness.codex.installed && (
|
|
1032
|
+
<p
|
|
1033
|
+
className="text-[11px] text-amber-700 text-left"
|
|
1034
|
+
data-testid="cartoon-codex-warning"
|
|
1035
|
+
>
|
|
1036
|
+
Codex was not detected. Install the Codex CLI and sign in
|
|
1037
|
+
(e.g. <span className="font-mono">npm i -g @openai/codex</span> then{" "}
|
|
1038
|
+
<span className="font-mono">codex login</span>) to create cartoons.
|
|
1039
|
+
</p>
|
|
1040
|
+
)}
|
|
1041
|
+
{isCodexAuthUnclear(readiness) && (
|
|
1042
|
+
<p
|
|
1043
|
+
className="text-[11px] text-amber-700 text-left"
|
|
1044
|
+
data-testid="cartoon-codex-auth-unknown"
|
|
1045
|
+
>
|
|
1046
|
+
{CODEX_AUTH_UNCLEAR_MESSAGE}
|
|
1047
|
+
</p>
|
|
1048
|
+
)}
|
|
1049
|
+
{readiness &&
|
|
1050
|
+
readiness.codex.installed &&
|
|
1051
|
+
!isCodexAuthUnclear(readiness) &&
|
|
1052
|
+
readiness.codex.imageGeneration !== "enabled" && (
|
|
1053
|
+
<div data-testid="cartoon-codex-warning">
|
|
1054
|
+
<p className="text-[11px] text-amber-700 text-left">
|
|
1055
|
+
Codex is installed but image generation isn't enabled.
|
|
1056
|
+
Enable it, then reopen this dialog:
|
|
1057
|
+
</p>
|
|
1058
|
+
<div className="mt-1 flex items-center gap-1">
|
|
1059
|
+
<code className="flex-1 truncate rounded border border-border bg-surface px-1.5 py-1 text-left text-[10px] font-mono text-foreground">
|
|
1060
|
+
codex features enable image_generation
|
|
1061
|
+
</code>
|
|
1062
|
+
<button
|
|
1063
|
+
type="button"
|
|
1064
|
+
data-testid="copy-codex-enable"
|
|
1065
|
+
onClick={copyCodexEnable}
|
|
1066
|
+
className="rounded border border-border px-2 py-1 text-[10px] text-muted hover:border-accent hover:text-accent transition-colors"
|
|
1067
|
+
>
|
|
1068
|
+
{codexEnableCopied ? "Copied!" : "Copy"}
|
|
1069
|
+
</button>
|
|
1070
|
+
</div>
|
|
1071
|
+
</div>
|
|
1072
|
+
)}
|
|
1073
|
+
</div>
|
|
1074
|
+
</div>
|
|
1075
|
+
<button
|
|
1076
|
+
onClick={() => setShowNewStoryModal(false)}
|
|
1077
|
+
className="w-full px-3 py-1.5 text-xs text-muted hover:text-foreground hover:bg-surface rounded text-center"
|
|
1078
|
+
>
|
|
1079
|
+
Cancel
|
|
1080
|
+
</button>
|
|
1081
|
+
</div>
|
|
1082
|
+
</div>
|
|
1083
|
+
)}
|
|
372
1084
|
</div>
|
|
373
1085
|
);
|
|
374
1086
|
}
|