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