plotlink-ows 1.2.94 → 1.2.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/lib/active-wallet.ts +260 -0
- package/app/lib/cartoon-coach.ts +1 -1
- package/app/lib/cartoon-readiness.ts +12 -10
- package/app/lib/cuts.ts +135 -18
- package/app/lib/lettering-status.ts +64 -6
- package/app/lib/story-progress.ts +2 -3
- package/app/routes/dashboard.ts +6 -4
- package/app/routes/publish.ts +56 -23
- package/app/routes/settings.ts +92 -37
- package/app/routes/wallet.ts +58 -30
- package/app/web/components/CartoonNextAction.tsx +145 -0
- package/app/web/components/CartoonPublishPage.tsx +1 -1
- package/app/web/components/CutListPanel.tsx +1198 -488
- package/app/web/components/Dashboard.tsx +15 -6
- package/app/web/components/FinishEpisodePanel.tsx +57 -46
- package/app/web/components/LetteringEditor.tsx +867 -366
- package/app/web/components/PreviewPanel.tsx +1459 -844
- package/app/web/components/StoriesPage.tsx +985 -475
- package/app/web/components/StoryProgressPanel.tsx +32 -102
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +63 -35
- package/app/web/dist/assets/{export-cut-nKQ_n2-J.js → export-cut-BqZI0-Rv.js} +1 -1
- package/app/web/dist/assets/index-C43toXVm.js +141 -0
- package/app/web/dist/assets/index-CcfChGEK.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-BAZGwVwj.js +0 -143
- package/app/web/dist/assets/index-DoXH2OlP.css +0 -32
|
@@ -9,8 +9,22 @@ import { CartoonPublishPreview } from "./CartoonPublishPreview";
|
|
|
9
9
|
import { CutListPanel } from "./CutListPanel";
|
|
10
10
|
import { WorkflowCoach } from "./WorkflowCoach";
|
|
11
11
|
import type { CoachUiAction } from "@app-lib/cartoon-coach";
|
|
12
|
-
import {
|
|
13
|
-
|
|
12
|
+
import {
|
|
13
|
+
classifyCartoonReadiness,
|
|
14
|
+
cartoonGenesisReadiness,
|
|
15
|
+
summarizeCutProgress,
|
|
16
|
+
previewFooterGuidance,
|
|
17
|
+
type CartoonReadinessStage as CartoonStage,
|
|
18
|
+
type CartoonCutProgress,
|
|
19
|
+
} from "@app-lib/cartoon-readiness";
|
|
20
|
+
import {
|
|
21
|
+
validateCoverImage,
|
|
22
|
+
cartoonCoverReadiness,
|
|
23
|
+
COVER_GUIDANCE,
|
|
24
|
+
derivePublishTitle,
|
|
25
|
+
isRawFilenameTitle,
|
|
26
|
+
hasExplicitEpisodeTitle,
|
|
27
|
+
} from "../lib/publish-helpers";
|
|
14
28
|
import { importImageToCompliantBlob } from "../lib/import-image";
|
|
15
29
|
|
|
16
30
|
/** Custom sanitizer matching plotlink.xyz — allows img with src, alt, title */
|
|
@@ -25,7 +39,9 @@ const sanitizeSchema = {
|
|
|
25
39
|
const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/";
|
|
26
40
|
|
|
27
41
|
/** Find all markdown image references in content */
|
|
28
|
-
function findImageRefs(
|
|
42
|
+
function findImageRefs(
|
|
43
|
+
text: string,
|
|
44
|
+
): Array<{ full: string; alt: string; url: string }> {
|
|
29
45
|
const results: Array<{ full: string; alt: string; url: string }> = [];
|
|
30
46
|
const re = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
31
47
|
let m;
|
|
@@ -36,18 +52,25 @@ function findImageRefs(text: string): Array<{ full: string; alt: string; url: st
|
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
/** Validate image references for publishing */
|
|
39
|
-
function validateImageRefs(text: string): {
|
|
55
|
+
function validateImageRefs(text: string): {
|
|
56
|
+
count: number;
|
|
57
|
+
warnings: string[];
|
|
58
|
+
} {
|
|
40
59
|
const refs = findImageRefs(text);
|
|
41
60
|
const warnings: string[] = [];
|
|
42
61
|
for (const ref of refs) {
|
|
43
62
|
if (!ref.url.startsWith(IPFS_GATEWAY)) {
|
|
44
|
-
warnings.push(
|
|
63
|
+
warnings.push(
|
|
64
|
+
`Non-IPFS image URL: ${ref.url.length > 60 ? ref.url.slice(0, 60) + "..." : ref.url}`,
|
|
65
|
+
);
|
|
45
66
|
}
|
|
46
67
|
}
|
|
47
68
|
// Check for malformed image markdown (missing closing bracket/paren)
|
|
48
69
|
const malformed = text.match(/!\[[^\]]*\]\([^)]*$|!\[[^\]]*$(?!\])/gm);
|
|
49
70
|
if (malformed) {
|
|
50
|
-
warnings.push(
|
|
71
|
+
warnings.push(
|
|
72
|
+
"Malformed image markdown detected — check brackets and parentheses",
|
|
73
|
+
);
|
|
51
74
|
}
|
|
52
75
|
return { count: refs.length, warnings };
|
|
53
76
|
}
|
|
@@ -59,7 +82,14 @@ interface PreviewPanelProps {
|
|
|
59
82
|
// Resolves to true only on a confirmed-successful publish (so a selected cover
|
|
60
83
|
// may be dropped); false/void when blocked before the stream (#375) or when the
|
|
61
84
|
// publish fails/aborts before completing (#376), so the cover is kept.
|
|
62
|
-
onPublish?: (
|
|
85
|
+
onPublish?: (
|
|
86
|
+
storyName: string,
|
|
87
|
+
fileName: string,
|
|
88
|
+
genre: string,
|
|
89
|
+
language: string,
|
|
90
|
+
isNsfw: boolean,
|
|
91
|
+
coverFile?: File | null,
|
|
92
|
+
) => void | Promise<boolean | void>;
|
|
63
93
|
publishingFile?: string | null;
|
|
64
94
|
walletAddress?: string | null;
|
|
65
95
|
contentType?: "fiction" | "cartoon";
|
|
@@ -82,6 +112,14 @@ interface PreviewPanelProps {
|
|
|
82
112
|
// issue diagnostics + the publish action now live. Cartoon episode views show a
|
|
83
113
|
// single compact CTA that calls this instead of hosting publish controls.
|
|
84
114
|
onViewPublish?: () => void;
|
|
115
|
+
/** Whether the right panel is currently in focused cartoon lettering mode. */
|
|
116
|
+
focusedLetteringMode?: boolean;
|
|
117
|
+
/** Whether the wider app work area is restored while editing. */
|
|
118
|
+
focusedLetteringWorkspaceVisible?: boolean;
|
|
119
|
+
/** Enter/leave focused cartoon lettering mode. */
|
|
120
|
+
onFocusedLetteringModeChange?: (active: boolean) => void;
|
|
121
|
+
/** Restore/fold the wider app work area while staying in the editor. */
|
|
122
|
+
onFocusedLetteringWorkspaceVisibleChange?: (visible: boolean) => void;
|
|
85
123
|
}
|
|
86
124
|
|
|
87
125
|
interface FileData {
|
|
@@ -98,26 +136,53 @@ interface FileData {
|
|
|
98
136
|
|
|
99
137
|
type Tab = "preview" | "edit";
|
|
100
138
|
|
|
101
|
-
export function PreviewPanel({
|
|
139
|
+
export function PreviewPanel({
|
|
140
|
+
storyName,
|
|
141
|
+
fileName,
|
|
142
|
+
authFetch,
|
|
143
|
+
onPublish,
|
|
144
|
+
publishingFile,
|
|
145
|
+
walletAddress,
|
|
146
|
+
contentType = "fiction",
|
|
147
|
+
language,
|
|
148
|
+
genre: genreMeta,
|
|
149
|
+
isNsfw: nsfwMeta,
|
|
150
|
+
hasGenesis = false,
|
|
151
|
+
onViewProgress,
|
|
152
|
+
onOpenFile,
|
|
153
|
+
onViewPublish,
|
|
154
|
+
focusedLetteringMode = false,
|
|
155
|
+
focusedLetteringWorkspaceVisible = false,
|
|
156
|
+
onFocusedLetteringModeChange,
|
|
157
|
+
onFocusedLetteringWorkspaceVisibleChange,
|
|
158
|
+
}: PreviewPanelProps) {
|
|
102
159
|
const [fileData, setFileData] = useState<FileData | null>(null);
|
|
103
160
|
const [loading, setLoading] = useState(false);
|
|
104
161
|
const [activeTab, setActiveTab] = useState<Tab>("preview");
|
|
105
162
|
// Cartoon preview sub-mode: "publish" = exact PlotLink-bound markdown;
|
|
106
163
|
// "inspect" = cuts.json planning inspector. Kept distinct so planning prose
|
|
107
164
|
// does not masquerade as publish content (#289).
|
|
108
|
-
const [cartoonPreviewMode, setCartoonPreviewMode] = useState<
|
|
165
|
+
const [cartoonPreviewMode, setCartoonPreviewMode] = useState<
|
|
166
|
+
"publish" | "inspect"
|
|
167
|
+
>("publish");
|
|
109
168
|
// Cartoon Genesis is a hybrid (a prose opening + its own genesis.cuts.json image
|
|
110
169
|
// cuts), so its Edit tab offers two sub-views: the opening-text editor and the
|
|
111
170
|
// cut workspace (#429). Plots use the cut workspace directly; fiction never sees
|
|
112
171
|
// this. Defaults to "text" so opening Edit on Genesis is unchanged; the workflow
|
|
113
172
|
// coach's cut actions switch it to "cuts" so lettering/upload/refresh land on a
|
|
114
173
|
// real, actionable workspace instead of the markdown editor.
|
|
115
|
-
const [genesisEditMode, setGenesisEditMode] = useState<"text" | "cuts">(
|
|
174
|
+
const [genesisEditMode, setGenesisEditMode] = useState<"text" | "cuts">(
|
|
175
|
+
"text",
|
|
176
|
+
);
|
|
116
177
|
// #371: a deep-link request from the Cut Inspector's per-cut CTA into the Edit
|
|
117
178
|
// tab for that exact cut. `seq` makes repeated clicks (even on the same cut)
|
|
118
179
|
// re-trigger the focus/expand effect in CutListPanel; it is cleared once
|
|
119
180
|
// CutListPanel has applied it so re-entering the Edit tab manually is unaffected.
|
|
120
|
-
const [cutFocus, setCutFocus] = useState<{
|
|
181
|
+
const [cutFocus, setCutFocus] = useState<{
|
|
182
|
+
cutId: number;
|
|
183
|
+
openEditor: boolean;
|
|
184
|
+
seq: number;
|
|
185
|
+
} | null>(null);
|
|
121
186
|
const handleEditCut = useCallback((cutId: number, openEditor: boolean) => {
|
|
122
187
|
setActiveTab("edit");
|
|
123
188
|
setCutFocus((prev) => ({ cutId, openEditor, seq: (prev?.seq ?? 0) + 1 }));
|
|
@@ -141,12 +206,15 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
141
206
|
const [cartoonTotalCuts, setCartoonTotalCuts] = useState(0);
|
|
142
207
|
// Per-cut production tallies (clean/lettered/exported/uploaded) for the compact
|
|
143
208
|
// cartoon status summary in the bottom panel (#420).
|
|
144
|
-
const [cartoonCutProgress, setCartoonCutProgress] =
|
|
209
|
+
const [cartoonCutProgress, setCartoonCutProgress] =
|
|
210
|
+
useState<CartoonCutProgress | null>(null);
|
|
145
211
|
// Inputs for resolving + showing the public publish title before publish (#358):
|
|
146
212
|
// the story structure.md content (genesis story title) and the cut plan's
|
|
147
213
|
// episode title (cartoon plot).
|
|
148
214
|
const [structureContent, setStructureContent] = useState<string | null>(null);
|
|
149
|
-
const [cartoonEpisodeTitle, setCartoonEpisodeTitle] = useState<string | null>(
|
|
215
|
+
const [cartoonEpisodeTitle, setCartoonEpisodeTitle] = useState<string | null>(
|
|
216
|
+
null,
|
|
217
|
+
);
|
|
150
218
|
// Bumped whenever the embedded cut editor mutates the cut plan (export/upload/
|
|
151
219
|
// save), so the readiness effect re-fetches and the Episode-steps panel stays
|
|
152
220
|
// in sync with the cut cards after a lettering export (#343).
|
|
@@ -175,11 +243,15 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
175
243
|
// detectedCover = the path actually loaded into the cover selection (status
|
|
176
244
|
// label); detectedCoverWarning = an invalid/oversize detected asset we won't use.
|
|
177
245
|
const [detectedCover, setDetectedCover] = useState<string | null>(null);
|
|
178
|
-
const [detectedCoverWarning, setDetectedCoverWarning] = useState<
|
|
246
|
+
const [detectedCoverWarning, setDetectedCoverWarning] = useState<
|
|
247
|
+
string | null
|
|
248
|
+
>(null);
|
|
179
249
|
// Outcome of the generated-cover detection for an unpublished genesis (#312),
|
|
180
250
|
// so the publish flow can state explicitly whether a generated assets/cover.webp
|
|
181
251
|
// will be uploaded as the PlotLink cover, is invalid, or is missing.
|
|
182
|
-
const [coverStatus, setCoverStatus] = useState<
|
|
252
|
+
const [coverStatus, setCoverStatus] = useState<
|
|
253
|
+
"unknown" | "detected" | "selected" | "invalid" | "none"
|
|
254
|
+
>("unknown");
|
|
183
255
|
// Once the writer manually picks or removes a cover, stop auto-applying the
|
|
184
256
|
// detected one (so removal/override sticks and detection doesn't loop).
|
|
185
257
|
const coverUserTouchedRef = useRef(false);
|
|
@@ -187,15 +259,22 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
187
259
|
// Inline illustration state
|
|
188
260
|
const [showIllustrations, setShowIllustrations] = useState(false);
|
|
189
261
|
const [illustrationUploading, setIllustrationUploading] = useState(false);
|
|
190
|
-
const [illustrationError, setIllustrationError] = useState<string | null>(
|
|
191
|
-
|
|
262
|
+
const [illustrationError, setIllustrationError] = useState<string | null>(
|
|
263
|
+
null,
|
|
264
|
+
);
|
|
265
|
+
const [uploadedImages, setUploadedImages] = useState<
|
|
266
|
+
Array<{ cid: string; url: string }>
|
|
267
|
+
>([]);
|
|
192
268
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
|
193
269
|
const illustrationInputRef = useRef<HTMLInputElement>(null);
|
|
194
270
|
|
|
195
271
|
const prevFileRef = useRef<string | null>(null);
|
|
196
272
|
|
|
197
273
|
const loadFile = useCallback(async () => {
|
|
198
|
-
if (!storyName || !fileName) {
|
|
274
|
+
if (!storyName || !fileName) {
|
|
275
|
+
setFileData(null);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
199
278
|
const fileKey = `${storyName}/${fileName}`;
|
|
200
279
|
const isNewFile = prevFileRef.current !== fileKey;
|
|
201
280
|
if (isNewFile) {
|
|
@@ -209,10 +288,15 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
209
288
|
// Update edit content on new file or when no unsaved changes
|
|
210
289
|
if (isNewFile || !dirtyRef.current) {
|
|
211
290
|
setEditContent(data.content ?? "");
|
|
212
|
-
if (isNewFile) {
|
|
291
|
+
if (isNewFile) {
|
|
292
|
+
setDirty(false);
|
|
293
|
+
dirtyRef.current = false;
|
|
294
|
+
}
|
|
213
295
|
}
|
|
214
296
|
}
|
|
215
|
-
} catch {
|
|
297
|
+
} catch {
|
|
298
|
+
/* ignore */
|
|
299
|
+
}
|
|
216
300
|
}, [storyName, fileName, authFetch]);
|
|
217
301
|
|
|
218
302
|
// Initial load
|
|
@@ -232,20 +316,35 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
232
316
|
// Genesis-as-Episode-1 cut progress (#422): discover + summarize
|
|
233
317
|
// genesis.cuts.json so the Genesis view reflects its real cut/image state and
|
|
234
318
|
// the footer can guide "plan cuts" vs "generate clean images".
|
|
235
|
-
const [genesisCutProgress, setGenesisCutProgress] =
|
|
236
|
-
|
|
319
|
+
const [genesisCutProgress, setGenesisCutProgress] =
|
|
320
|
+
useState<CartoonCutProgress | null>(null);
|
|
321
|
+
const cartoonGenesisForCuts =
|
|
322
|
+
contentType === "cartoon" && fileName === "genesis.md";
|
|
237
323
|
useEffect(() => {
|
|
238
|
-
if (!cartoonGenesisForCuts || !storyName) {
|
|
324
|
+
if (!cartoonGenesisForCuts || !storyName) {
|
|
325
|
+
setGenesisCutProgress(null);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
239
328
|
let cancelled = false;
|
|
240
329
|
authFetch(`/api/stories/${storyName}/cuts/genesis`)
|
|
241
330
|
.then((res) => (res.ok ? res.json() : null))
|
|
242
|
-
.then((data) => {
|
|
243
|
-
|
|
244
|
-
|
|
331
|
+
.then((data) => {
|
|
332
|
+
if (!cancelled)
|
|
333
|
+
setGenesisCutProgress(
|
|
334
|
+
data ? summarizeCutProgress(data.cuts || []) : null,
|
|
335
|
+
);
|
|
336
|
+
})
|
|
337
|
+
.catch(() => {
|
|
338
|
+
if (!cancelled) setGenesisCutProgress(null);
|
|
339
|
+
});
|
|
340
|
+
return () => {
|
|
341
|
+
cancelled = true;
|
|
342
|
+
};
|
|
245
343
|
}, [cartoonGenesisForCuts, storyName, authFetch]);
|
|
246
344
|
|
|
247
345
|
// Compute cartoon publish readiness for cartoon plot files
|
|
248
|
-
const cartoonPlotForReadiness =
|
|
346
|
+
const cartoonPlotForReadiness =
|
|
347
|
+
contentType === "cartoon" && !!fileName && /^plot-\d+\.md$/.test(fileName);
|
|
249
348
|
useEffect(() => {
|
|
250
349
|
if (!cartoonPlotForReadiness || !storyName || !fileName) {
|
|
251
350
|
setCartoonStage(null);
|
|
@@ -275,7 +374,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
275
374
|
}
|
|
276
375
|
const cutsData = await cutsRes.json();
|
|
277
376
|
const cuts = cutsData.cuts || [];
|
|
278
|
-
const content = fileRes.ok
|
|
377
|
+
const content = fileRes.ok
|
|
378
|
+
? ((await fileRes.json()).content ?? "")
|
|
379
|
+
: "";
|
|
279
380
|
const result = classifyCartoonReadiness(content, cuts);
|
|
280
381
|
if (!cancelled) {
|
|
281
382
|
setCartoonStage(result.stage);
|
|
@@ -283,7 +384,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
283
384
|
setCartoonTotalCuts(result.totalCuts);
|
|
284
385
|
setCartoonCutProgress(summarizeCutProgress(cuts));
|
|
285
386
|
// Cut plan's episode title for the publish-title display (#358).
|
|
286
|
-
setCartoonEpisodeTitle(
|
|
387
|
+
setCartoonEpisodeTitle(
|
|
388
|
+
typeof cutsData.title === "string" ? cutsData.title : null,
|
|
389
|
+
);
|
|
287
390
|
}
|
|
288
391
|
} catch {
|
|
289
392
|
if (!cancelled) {
|
|
@@ -294,20 +397,37 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
294
397
|
}
|
|
295
398
|
}
|
|
296
399
|
})();
|
|
297
|
-
return () => {
|
|
298
|
-
|
|
400
|
+
return () => {
|
|
401
|
+
cancelled = true;
|
|
402
|
+
};
|
|
403
|
+
}, [
|
|
404
|
+
cartoonPlotForReadiness,
|
|
405
|
+
storyName,
|
|
406
|
+
fileName,
|
|
407
|
+
authFetch,
|
|
408
|
+
fileData?.content,
|
|
409
|
+
fileData?.status,
|
|
410
|
+
cutsRefreshKey,
|
|
411
|
+
]);
|
|
299
412
|
|
|
300
413
|
// Load structure.md once per story — used to resolve the public title before
|
|
301
414
|
// publish (#358) and as a metadata fallback when .story.json lacks genre/
|
|
302
415
|
// language (#424).
|
|
303
416
|
useEffect(() => {
|
|
304
|
-
if (!storyName) {
|
|
417
|
+
if (!storyName) {
|
|
418
|
+
setStructureContent(null);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
305
421
|
let cancelled = false;
|
|
306
422
|
authFetch(`/api/stories/${storyName}/structure.md`)
|
|
307
|
-
.then((res) => res.ok ? res.json() : null)
|
|
308
|
-
.then((data) => {
|
|
423
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
424
|
+
.then((data) => {
|
|
425
|
+
if (!cancelled) setStructureContent(data?.content ?? null);
|
|
426
|
+
})
|
|
309
427
|
.catch(() => {});
|
|
310
|
-
return () => {
|
|
428
|
+
return () => {
|
|
429
|
+
cancelled = true;
|
|
430
|
+
};
|
|
311
431
|
}, [storyName, authFetch]);
|
|
312
432
|
|
|
313
433
|
// Seed the publish metadata controls from the story's real values (#424).
|
|
@@ -324,16 +444,28 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
324
444
|
const match = structureContent.match(/\*{0,2}genre\*{0,2}[:\s]+(.+)/i);
|
|
325
445
|
// Canonicalize so a natural label like "Sci-Fi" preselects "Science
|
|
326
446
|
// Fiction" instead of being silently dropped (#412).
|
|
327
|
-
if (match)
|
|
447
|
+
if (match)
|
|
448
|
+
genreVal = canonicalizeGenre(match[1].replace(/\*+/g, "").trim()) ?? "";
|
|
328
449
|
}
|
|
329
450
|
setSelectedGenre(genreVal);
|
|
330
451
|
// Language: the server-resolved story language (explicit .story.json or
|
|
331
452
|
// script-detected), else structure.md, else unset ("Needs metadata" — no
|
|
332
453
|
// misleading English default). `language` is undefined when undetermined.
|
|
333
|
-
let langVal =
|
|
454
|
+
let langVal =
|
|
455
|
+
(language &&
|
|
456
|
+
LANGUAGES.find((l) => l.toLowerCase() === language.toLowerCase())) ||
|
|
457
|
+
"";
|
|
334
458
|
if (!langVal && structureContent) {
|
|
335
|
-
const langMatch = structureContent.match(
|
|
336
|
-
|
|
459
|
+
const langMatch = structureContent.match(
|
|
460
|
+
/\*{0,2}language\*{0,2}[:\s]+(.+)/i,
|
|
461
|
+
);
|
|
462
|
+
if (langMatch)
|
|
463
|
+
langVal =
|
|
464
|
+
LANGUAGES.find(
|
|
465
|
+
(l) =>
|
|
466
|
+
l.toLowerCase() ===
|
|
467
|
+
langMatch[1].replace(/\*+/g, "").trim().toLowerCase(),
|
|
468
|
+
) || "";
|
|
337
469
|
}
|
|
338
470
|
setSelectedLanguage(langVal);
|
|
339
471
|
setIsNsfw(nsfwMeta ?? false);
|
|
@@ -342,14 +474,19 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
342
474
|
// Persist a publish-control edit back to .story.json so it sticks across
|
|
343
475
|
// refresh and the controls stay in sync with story metadata (#424). Best
|
|
344
476
|
// effort: a failure still leaves the selection applied to this publish.
|
|
345
|
-
const persistPublishMeta = useCallback(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
477
|
+
const persistPublishMeta = useCallback(
|
|
478
|
+
(patch: { genre?: string; language?: string; isNsfw?: boolean }) => {
|
|
479
|
+
if (!storyName) return;
|
|
480
|
+
authFetch(`/api/stories/${storyName}/publish-metadata`, {
|
|
481
|
+
method: "POST",
|
|
482
|
+
headers: { "Content-Type": "application/json" },
|
|
483
|
+
body: JSON.stringify(patch),
|
|
484
|
+
}).catch(() => {
|
|
485
|
+
/* best-effort */
|
|
486
|
+
});
|
|
487
|
+
},
|
|
488
|
+
[storyName, authFetch],
|
|
489
|
+
);
|
|
353
490
|
|
|
354
491
|
const handleSave = useCallback(async () => {
|
|
355
492
|
if (!storyName || !fileName) return;
|
|
@@ -361,10 +498,15 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
361
498
|
body: JSON.stringify({ content: editContent }),
|
|
362
499
|
});
|
|
363
500
|
if (res.ok) {
|
|
364
|
-
setDirty(false);
|
|
365
|
-
|
|
501
|
+
setDirty(false);
|
|
502
|
+
dirtyRef.current = false;
|
|
503
|
+
setFileData((prev) =>
|
|
504
|
+
prev ? { ...prev, content: editContent } : prev,
|
|
505
|
+
);
|
|
366
506
|
}
|
|
367
|
-
} catch {
|
|
507
|
+
} catch {
|
|
508
|
+
/* ignore */
|
|
509
|
+
}
|
|
368
510
|
setSaving(false);
|
|
369
511
|
}, [storyName, fileName, authFetch, editContent]);
|
|
370
512
|
|
|
@@ -376,14 +518,19 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
376
518
|
if (!storyName || !fileName) return;
|
|
377
519
|
const plotFile = fileName.replace(/\.md$/, "");
|
|
378
520
|
try {
|
|
379
|
-
const res = await authFetch(
|
|
521
|
+
const res = await authFetch(
|
|
522
|
+
`/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`,
|
|
523
|
+
{ method: "POST" },
|
|
524
|
+
);
|
|
380
525
|
if (res.ok) {
|
|
381
526
|
await loadFile();
|
|
382
527
|
// Re-run readiness + reload the workflow coach so the next action moves
|
|
383
528
|
// off "Prepare the episode for publish" once the layout is built (#429).
|
|
384
529
|
setCutsRefreshKey((k) => k + 1);
|
|
385
530
|
}
|
|
386
|
-
} catch {
|
|
531
|
+
} catch {
|
|
532
|
+
/* ignore */
|
|
533
|
+
}
|
|
387
534
|
}, [storyName, fileName, authFetch, loadFile]);
|
|
388
535
|
|
|
389
536
|
// Route a workflow-coach UI action to the right control (#429). When the
|
|
@@ -392,59 +539,77 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
392
539
|
// there offers the same action in place. Otherwise reveal the control: the cut
|
|
393
540
|
// workspace for letter/export/upload/refresh, the Preview tab for publish (the
|
|
394
541
|
// writer still confirms the irreversible publish), or run Prepare directly.
|
|
395
|
-
const handleCoachAction = useCallback(
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
542
|
+
const handleCoachAction = useCallback(
|
|
543
|
+
(action: CoachUiAction, episodeFile: string | null) => {
|
|
544
|
+
if (action === "view-progress") {
|
|
545
|
+
onViewProgress?.();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (episodeFile && episodeFile !== fileName) {
|
|
549
|
+
onOpenFile?.(episodeFile);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
switch (action) {
|
|
553
|
+
case "open-cuts":
|
|
554
|
+
case "open-lettering":
|
|
555
|
+
case "upload":
|
|
556
|
+
case "refresh-assets":
|
|
557
|
+
setActiveTab("edit");
|
|
558
|
+
// For Genesis the Edit tab defaults to the opening-text editor; switch to
|
|
559
|
+
// the cut workspace so the lettering/upload/refresh action is actionable.
|
|
560
|
+
// No-op for plots (the cut workspace is the only Edit view).
|
|
561
|
+
setGenesisEditMode("cuts");
|
|
562
|
+
break;
|
|
563
|
+
case "generate-markdown":
|
|
564
|
+
handleGenerateMarkdown();
|
|
565
|
+
break;
|
|
566
|
+
case "publish":
|
|
567
|
+
setActiveTab("preview");
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
[fileName, onViewProgress, onOpenFile, handleGenerateMarkdown],
|
|
572
|
+
);
|
|
417
573
|
|
|
418
574
|
// Handle cover image selection
|
|
419
|
-
const handleCoverSelect = useCallback(
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
575
|
+
const handleCoverSelect = useCallback(
|
|
576
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
577
|
+
const file = e.target.files?.[0];
|
|
578
|
+
if (!file) return;
|
|
579
|
+
// A manual pick overrides any auto-detected cover and stops re-detection.
|
|
580
|
+
coverUserTouchedRef.current = true;
|
|
581
|
+
setDetectedCover(null);
|
|
582
|
+
setDetectedCoverWarning(null);
|
|
583
|
+
// Reject oversized / non-WebP-JPEG covers at selection so the writer gets
|
|
584
|
+
// immediate feedback instead of a late error at save (the server enforces
|
|
585
|
+
// the same WebP/JPEG ≤1MB constraint).
|
|
586
|
+
const error = validateCoverImage(file);
|
|
587
|
+
if (error) {
|
|
588
|
+
// Discard any previously-queued valid cover and clear the input, so an
|
|
589
|
+
// invalid re-selection can't leave a stale cover that Save would still
|
|
590
|
+
// upload contrary to the user's latest choice (#281 follow-up).
|
|
591
|
+
setCoverFile(null);
|
|
592
|
+
setCoverPreview((prev) => {
|
|
593
|
+
if (prev) URL.revokeObjectURL(prev);
|
|
594
|
+
return null;
|
|
595
|
+
});
|
|
596
|
+
if (coverInputRef.current) coverInputRef.current.value = "";
|
|
597
|
+
setEditError(error);
|
|
598
|
+
// Surface the rejected pick in the cartoon cover-status badge (#337), not
|
|
599
|
+
// just the inline error, so the cover step clearly reads "can't be used".
|
|
600
|
+
setCoverStatus("invalid");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
setCoverFile(file);
|
|
604
|
+
setCoverPreview((prev) => {
|
|
605
|
+
if (prev) URL.revokeObjectURL(prev);
|
|
606
|
+
return URL.createObjectURL(file);
|
|
607
|
+
});
|
|
608
|
+
setEditError(null);
|
|
609
|
+
setCoverStatus("selected");
|
|
610
|
+
},
|
|
611
|
+
[],
|
|
612
|
+
);
|
|
448
613
|
|
|
449
614
|
// Import a Codex-generated image (e.g. a large PNG) as the cover (#301). The
|
|
450
615
|
// browser converts/compresses it to a compliant WebP/JPEG <=1MB, then OWS
|
|
@@ -452,88 +617,111 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
452
617
|
// import-cover and loads it into the same coverFile the manual picker uses, so
|
|
453
618
|
// the existing publish flow attaches it with no special casing. A source that
|
|
454
619
|
// cannot be decoded/compressed surfaces a clear error and saves nothing.
|
|
455
|
-
const handleCoverImport = useCallback(
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
let blob: Blob;
|
|
620
|
+
const handleCoverImport = useCallback(
|
|
621
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
622
|
+
const file = e.target.files?.[0];
|
|
623
|
+
if (coverImportInputRef.current) coverImportInputRef.current.value = "";
|
|
624
|
+
if (!file || !storyName) return;
|
|
625
|
+
// A deliberate import overrides any auto-detected cover, like a manual pick.
|
|
626
|
+
coverUserTouchedRef.current = true;
|
|
627
|
+
setDetectedCover(null);
|
|
628
|
+
setCoverImporting(true);
|
|
629
|
+
setEditError(null);
|
|
466
630
|
try {
|
|
467
|
-
blob
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
631
|
+
let blob: Blob;
|
|
632
|
+
try {
|
|
633
|
+
blob = await importImageToCompliantBlob(file);
|
|
634
|
+
} catch (err) {
|
|
635
|
+
setCoverFile(null);
|
|
636
|
+
setCoverPreview((prev) => {
|
|
637
|
+
if (prev) URL.revokeObjectURL(prev);
|
|
638
|
+
return null;
|
|
639
|
+
});
|
|
640
|
+
setEditError(
|
|
641
|
+
err instanceof Error ? err.message : "Could not import image",
|
|
642
|
+
);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const ext = blob.type === "image/jpeg" ? "jpg" : "webp";
|
|
646
|
+
const imported = new File([blob], `cover.${ext}`, { type: blob.type });
|
|
647
|
+
const formData = new FormData();
|
|
648
|
+
formData.append("file", imported);
|
|
649
|
+
const res = await authFetch(`/api/stories/${storyName}/import-cover`, {
|
|
650
|
+
method: "POST",
|
|
651
|
+
body: formData,
|
|
652
|
+
});
|
|
653
|
+
if (!res.ok) {
|
|
654
|
+
const data = await res.json().catch(() => ({}));
|
|
655
|
+
setEditError(data.error || "Cover import failed");
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
setCoverFile(imported);
|
|
659
|
+
setCoverPreview((prev) => {
|
|
660
|
+
if (prev) URL.revokeObjectURL(prev);
|
|
661
|
+
return URL.createObjectURL(imported);
|
|
662
|
+
});
|
|
663
|
+
setDetectedCoverWarning(null);
|
|
664
|
+
setCoverStatus("selected");
|
|
665
|
+
setEditError(null);
|
|
666
|
+
} catch {
|
|
667
|
+
setEditError("Cover import failed");
|
|
668
|
+
} finally {
|
|
669
|
+
setCoverImporting(false);
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
[storyName, authFetch],
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
// Handle illustration image upload from File object
|
|
676
|
+
const uploadIllustration = useCallback(
|
|
677
|
+
async (file: File) => {
|
|
678
|
+
if (file.size > 1024 * 1024) {
|
|
679
|
+
setIllustrationError("Image exceeds 1MB limit");
|
|
472
680
|
return;
|
|
473
681
|
}
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
formData.append("file", imported);
|
|
478
|
-
const res = await authFetch(`/api/stories/${storyName}/import-cover`, {
|
|
479
|
-
method: "POST",
|
|
480
|
-
body: formData,
|
|
481
|
-
});
|
|
482
|
-
if (!res.ok) {
|
|
483
|
-
const data = await res.json().catch(() => ({}));
|
|
484
|
-
setEditError(data.error || "Cover import failed");
|
|
682
|
+
const allowedTypes = ["image/webp", "image/jpeg"];
|
|
683
|
+
if (!allowedTypes.includes(file.type)) {
|
|
684
|
+
setIllustrationError("Only WebP and JPEG images are accepted");
|
|
485
685
|
return;
|
|
486
686
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const formData = new FormData();
|
|
514
|
-
formData.append("file", file);
|
|
515
|
-
const res = await authFetch("/api/publish/upload-plot-image", {
|
|
516
|
-
method: "POST",
|
|
517
|
-
body: formData,
|
|
518
|
-
});
|
|
519
|
-
if (!res.ok) {
|
|
520
|
-
const err = await res.json();
|
|
521
|
-
throw new Error(err.error || "Upload failed");
|
|
687
|
+
setIllustrationUploading(true);
|
|
688
|
+
setIllustrationError(null);
|
|
689
|
+
try {
|
|
690
|
+
const formData = new FormData();
|
|
691
|
+
formData.append("file", file);
|
|
692
|
+
const res = await authFetch("/api/publish/upload-plot-image", {
|
|
693
|
+
method: "POST",
|
|
694
|
+
body: formData,
|
|
695
|
+
});
|
|
696
|
+
if (!res.ok) {
|
|
697
|
+
const err = await res.json();
|
|
698
|
+
throw new Error(err.error || "Upload failed");
|
|
699
|
+
}
|
|
700
|
+
const data = await res.json();
|
|
701
|
+
setUploadedImages((prev) => [
|
|
702
|
+
...prev,
|
|
703
|
+
{ cid: data.cid, url: data.url },
|
|
704
|
+
]);
|
|
705
|
+
} catch (err) {
|
|
706
|
+
setIllustrationError(
|
|
707
|
+
err instanceof Error ? err.message : "Upload failed",
|
|
708
|
+
);
|
|
709
|
+
} finally {
|
|
710
|
+
setIllustrationUploading(false);
|
|
711
|
+
if (illustrationInputRef.current)
|
|
712
|
+
illustrationInputRef.current.value = "";
|
|
522
713
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
setIllustrationError(err instanceof Error ? err.message : "Upload failed");
|
|
527
|
-
} finally {
|
|
528
|
-
setIllustrationUploading(false);
|
|
529
|
-
if (illustrationInputRef.current) illustrationInputRef.current.value = "";
|
|
530
|
-
}
|
|
531
|
-
}, [authFetch]);
|
|
714
|
+
},
|
|
715
|
+
[authFetch],
|
|
716
|
+
);
|
|
532
717
|
|
|
533
|
-
const handleIllustrationInput = useCallback(
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
718
|
+
const handleIllustrationInput = useCallback(
|
|
719
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
720
|
+
const file = e.target.files?.[0];
|
|
721
|
+
if (file) uploadIllustration(file);
|
|
722
|
+
},
|
|
723
|
+
[uploadIllustration],
|
|
724
|
+
);
|
|
537
725
|
|
|
538
726
|
// Save storyline edits (cover upload + metadata update)
|
|
539
727
|
const handleEditSave = useCallback(async () => {
|
|
@@ -586,7 +774,10 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
586
774
|
// local preview so the status reads "attached", not "selected".
|
|
587
775
|
if (coverCid !== undefined) {
|
|
588
776
|
setEditHasCover(true);
|
|
589
|
-
setCoverPreview((prev) => {
|
|
777
|
+
setCoverPreview((prev) => {
|
|
778
|
+
if (prev) URL.revokeObjectURL(prev);
|
|
779
|
+
return null;
|
|
780
|
+
});
|
|
590
781
|
setCoverStatus("unknown");
|
|
591
782
|
if (coverInputRef.current) coverInputRef.current.value = "";
|
|
592
783
|
}
|
|
@@ -596,7 +787,14 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
596
787
|
} finally {
|
|
597
788
|
setEditSaving(false);
|
|
598
789
|
}
|
|
599
|
-
}, [
|
|
790
|
+
}, [
|
|
791
|
+
fileData?.storylineId,
|
|
792
|
+
coverFile,
|
|
793
|
+
editGenre,
|
|
794
|
+
editLanguage,
|
|
795
|
+
editNsfw,
|
|
796
|
+
authFetch,
|
|
797
|
+
]);
|
|
600
798
|
|
|
601
799
|
// Reset edit panel state when changing files
|
|
602
800
|
useEffect(() => {
|
|
@@ -628,7 +826,12 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
628
826
|
// null, so without this an auto-detected cover could be set before the file
|
|
629
827
|
// load resolves and then leak into the published Edit Story panel (re1).
|
|
630
828
|
if (!fileData) return;
|
|
631
|
-
if (
|
|
829
|
+
if (
|
|
830
|
+
fileData.storylineId ||
|
|
831
|
+
fileData.status === "published" ||
|
|
832
|
+
fileData.status === "published-not-indexed"
|
|
833
|
+
)
|
|
834
|
+
return;
|
|
632
835
|
if (coverUserTouchedRef.current) return; // a manual pick/removal wins
|
|
633
836
|
let cancelled = false;
|
|
634
837
|
(async () => {
|
|
@@ -637,26 +840,56 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
637
840
|
if (cancelled || !res.ok) return;
|
|
638
841
|
const data = await res.json();
|
|
639
842
|
if (cancelled) return;
|
|
640
|
-
if (!data?.found) {
|
|
843
|
+
if (!data?.found) {
|
|
844
|
+
setCoverStatus("none");
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
641
847
|
if (!data.valid) {
|
|
642
|
-
setDetectedCoverWarning(
|
|
848
|
+
setDetectedCoverWarning(
|
|
849
|
+
data.error || "Detected cover asset is invalid and was not used",
|
|
850
|
+
);
|
|
643
851
|
setCoverStatus("invalid");
|
|
644
852
|
return;
|
|
645
853
|
}
|
|
646
|
-
const assetRes = await authFetch(
|
|
854
|
+
const assetRes = await authFetch(
|
|
855
|
+
`/api/stories/${storyName}/asset/${data.path.replace(/^assets\//, "")}`,
|
|
856
|
+
);
|
|
647
857
|
if (cancelled || !assetRes.ok) return;
|
|
648
858
|
const blob = await assetRes.blob();
|
|
649
|
-
const file = new File(
|
|
859
|
+
const file = new File(
|
|
860
|
+
[blob],
|
|
861
|
+
data.path.split("/").pop() || "cover.webp",
|
|
862
|
+
{ type: data.type },
|
|
863
|
+
);
|
|
650
864
|
// Reuse the exact client validation the manual picker uses.
|
|
651
|
-
if (
|
|
865
|
+
if (
|
|
866
|
+
validateCoverImage(file) ||
|
|
867
|
+
cancelled ||
|
|
868
|
+
coverUserTouchedRef.current
|
|
869
|
+
)
|
|
870
|
+
return;
|
|
652
871
|
setCoverFile(file);
|
|
653
|
-
setCoverPreview((prev) => {
|
|
872
|
+
setCoverPreview((prev) => {
|
|
873
|
+
if (prev) URL.revokeObjectURL(prev);
|
|
874
|
+
return URL.createObjectURL(file);
|
|
875
|
+
});
|
|
654
876
|
setDetectedCover(data.path);
|
|
655
877
|
setCoverStatus("detected");
|
|
656
|
-
} catch {
|
|
878
|
+
} catch {
|
|
879
|
+
/* best-effort: no detected cover */
|
|
880
|
+
}
|
|
657
881
|
})();
|
|
658
|
-
return () => {
|
|
659
|
-
|
|
882
|
+
return () => {
|
|
883
|
+
cancelled = true;
|
|
884
|
+
};
|
|
885
|
+
}, [
|
|
886
|
+
storyName,
|
|
887
|
+
fileName,
|
|
888
|
+
fileData,
|
|
889
|
+
fileData?.status,
|
|
890
|
+
fileData?.storylineId,
|
|
891
|
+
authFetch,
|
|
892
|
+
]);
|
|
660
893
|
|
|
661
894
|
// Fetch current storyline metadata when edit panel opens
|
|
662
895
|
useEffect(() => {
|
|
@@ -665,7 +898,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
665
898
|
const PLOTLINK_URL = "https://plotlink.xyz";
|
|
666
899
|
let cancelled = false;
|
|
667
900
|
fetch(`${PLOTLINK_URL}/api/storyline/${fileData.storylineId}`)
|
|
668
|
-
.then((res) => res.ok ? res.json() : null)
|
|
901
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
669
902
|
.then((data) => {
|
|
670
903
|
if (cancelled) return;
|
|
671
904
|
if (!data) {
|
|
@@ -677,7 +910,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
677
910
|
if (found) setEditGenre(found);
|
|
678
911
|
}
|
|
679
912
|
if (data.language) {
|
|
680
|
-
const found = LANGUAGES.find(
|
|
913
|
+
const found = LANGUAGES.find(
|
|
914
|
+
(l) => l.toLowerCase() === data.language.toLowerCase(),
|
|
915
|
+
);
|
|
681
916
|
if (found) setEditLanguage(found);
|
|
682
917
|
}
|
|
683
918
|
if (data.isNsfw !== undefined) setEditNsfw(!!data.isNsfw);
|
|
@@ -689,7 +924,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
689
924
|
.catch(() => {
|
|
690
925
|
if (!cancelled) setEditError("Could not load current story metadata");
|
|
691
926
|
});
|
|
692
|
-
return () => {
|
|
927
|
+
return () => {
|
|
928
|
+
cancelled = true;
|
|
929
|
+
};
|
|
693
930
|
}, [showEditPanel, fileData?.storylineId]);
|
|
694
931
|
|
|
695
932
|
// Ctrl+S / Cmd+S to save
|
|
@@ -722,9 +959,10 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
722
959
|
}, [fileData?.status, fileData?.publishedAt]);
|
|
723
960
|
|
|
724
961
|
const indexExpired = indexTimeLeft !== null && indexTimeLeft <= 0;
|
|
725
|
-
const indexCountdown =
|
|
726
|
-
|
|
727
|
-
|
|
962
|
+
const indexCountdown =
|
|
963
|
+
indexTimeLeft !== null && indexTimeLeft > 0
|
|
964
|
+
? `${Math.floor(indexTimeLeft / 60000)}:${String(Math.floor((indexTimeLeft % 60000) / 1000)).padStart(2, "0")}`
|
|
965
|
+
: null;
|
|
728
966
|
|
|
729
967
|
if (!storyName || !fileName) {
|
|
730
968
|
return (
|
|
@@ -745,7 +983,8 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
745
983
|
);
|
|
746
984
|
}
|
|
747
985
|
|
|
748
|
-
const content =
|
|
986
|
+
const content =
|
|
987
|
+
activeTab === "edit" ? editContent : (fileData?.content ?? "");
|
|
749
988
|
const charCount = content.length;
|
|
750
989
|
const isGenesis = fileName === "genesis.md";
|
|
751
990
|
const isPlot = fileName ? /^plot-\d+\.md$/.test(fileName) : false;
|
|
@@ -755,16 +994,23 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
755
994
|
// readiness block — those move to the Publish tab — and show only the opening
|
|
756
995
|
// content, production next-step guidance, and a compact "Review publish" CTA.
|
|
757
996
|
const isCartoonEpisode = isCartoonGenesis || isCartoonPlot;
|
|
758
|
-
const isPublished =
|
|
997
|
+
const isPublished =
|
|
998
|
+
fileData?.status === "published" ||
|
|
999
|
+
fileData?.status === "published-not-indexed";
|
|
1000
|
+
const hideFocusedEditorChrome = focusedLetteringMode && isCartoonEpisode;
|
|
759
1001
|
|
|
760
1002
|
// State-aware preview footer guidance (#422). Cut count for the selected
|
|
761
1003
|
// episode: Genesis from genesis.cuts.json, a plot from its readiness scan
|
|
762
1004
|
// (null until loaded, so we don't flash "not started"). Drives outline/
|
|
763
1005
|
// genesis/placeholder-plot guidance; null ⇒ let the per-stage UI speak.
|
|
764
1006
|
const episodeCutCount = isCartoonGenesis
|
|
765
|
-
?
|
|
1007
|
+
? genesisCutProgress
|
|
1008
|
+
? genesisCutProgress.total
|
|
1009
|
+
: null
|
|
766
1010
|
: isCartoonPlot
|
|
767
|
-
?
|
|
1011
|
+
? cartoonStage === null
|
|
1012
|
+
? null
|
|
1013
|
+
: cartoonTotalCuts
|
|
768
1014
|
: null;
|
|
769
1015
|
const footerGuidance = previewFooterGuidance({
|
|
770
1016
|
fileName: fileName ?? "",
|
|
@@ -793,23 +1039,33 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
793
1039
|
episodeTitle: cartoonEpisodeTitle,
|
|
794
1040
|
})
|
|
795
1041
|
: null;
|
|
796
|
-
const rawTitleBlocked =
|
|
1042
|
+
const rawTitleBlocked =
|
|
1043
|
+
!!resolvedPublishTitle &&
|
|
1044
|
+
isRawFilenameTitle(resolvedPublishTitle, fileName!);
|
|
797
1045
|
// #365: a cartoon plot must have an EXPLICIT reader-facing title — a real
|
|
798
1046
|
// `# Title` H1 or a non-empty cut-plan title. The friendly "Episode NN"
|
|
799
1047
|
// fallback (derivePublishTitle) is diagnostic only and must NOT be published,
|
|
800
1048
|
// so a missing explicit title blocks publish (tightens #358's plot path).
|
|
801
|
-
const episodeTitleMissing =
|
|
802
|
-
&&
|
|
1049
|
+
const episodeTitleMissing =
|
|
1050
|
+
isCartoonPlot &&
|
|
1051
|
+
!isPublished &&
|
|
1052
|
+
!hasExplicitEpisodeTitle({
|
|
1053
|
+
fileContent: fileData?.content ?? "",
|
|
1054
|
+
episodeTitle: cartoonEpisodeTitle,
|
|
1055
|
+
});
|
|
803
1056
|
|
|
804
1057
|
// Cartoon Genesis prologue readiness (#359, hardened in #400). Genesis is the
|
|
805
1058
|
// reader-facing opening readers meet before plot-01, so block a missing H1
|
|
806
1059
|
// title AND a too-short / synopsis-shaped / single-dense-block opening that
|
|
807
1060
|
// doesn't read as a real story opening. Cartoon-only; fiction is unchanged.
|
|
808
|
-
const genesisReadiness =
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
const
|
|
1061
|
+
const genesisReadiness =
|
|
1062
|
+
isCartoonGenesis && !isPublished
|
|
1063
|
+
? cartoonGenesisReadiness(fileData?.content ?? "")
|
|
1064
|
+
: null;
|
|
1065
|
+
const genesisBlocked =
|
|
1066
|
+
!!genesisReadiness && genesisReadiness.blockers.length > 0;
|
|
1067
|
+
const cartoonStatusCardClass =
|
|
1068
|
+
"w-full max-w-[32rem] rounded-xl border px-3 py-3";
|
|
813
1069
|
|
|
814
1070
|
// Cartoon cover readiness badge + requirements (#337). Shown wherever a
|
|
815
1071
|
// cartoon writer manages the cover (pre-publish picker and the published Edit
|
|
@@ -828,13 +1084,21 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
828
1084
|
attached,
|
|
829
1085
|
});
|
|
830
1086
|
return (
|
|
831
|
-
<div
|
|
832
|
-
|
|
1087
|
+
<div
|
|
1088
|
+
className="flex flex-col gap-0.5"
|
|
1089
|
+
data-testid="cartoon-cover-status"
|
|
1090
|
+
data-state={r.state}
|
|
1091
|
+
>
|
|
1092
|
+
<span className={`text-[11px] font-medium ${COVER_TONE[r.tone]}`}>
|
|
1093
|
+
{r.label}
|
|
1094
|
+
</span>
|
|
833
1095
|
{/* Long cover spec/tips collapsed by default (#420) so the panel isn't a
|
|
834
1096
|
wall of text; the concise status line above is always visible. */}
|
|
835
1097
|
<details className="text-[10px] text-muted" data-testid="cover-details">
|
|
836
1098
|
<summary className="cursor-pointer select-none">Cover tips</summary>
|
|
837
|
-
<span className="block mt-0.5" data-testid="cartoon-cover-guidance">
|
|
1099
|
+
<span className="block mt-0.5" data-testid="cartoon-cover-guidance">
|
|
1100
|
+
{COVER_GUIDANCE}
|
|
1101
|
+
</span>
|
|
838
1102
|
</details>
|
|
839
1103
|
</div>
|
|
840
1104
|
);
|
|
@@ -857,17 +1121,34 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
857
1121
|
>
|
|
858
1122
|
<span className="text-[11px] text-foreground">
|
|
859
1123
|
<span className="font-medium">{label}:</span>{" "}
|
|
860
|
-
<span
|
|
1124
|
+
<span
|
|
1125
|
+
className={
|
|
1126
|
+
titleBlocked ? "text-error font-medium" : "text-foreground"
|
|
1127
|
+
}
|
|
1128
|
+
>
|
|
1129
|
+
{resolvedPublishTitle}
|
|
1130
|
+
</span>
|
|
861
1131
|
</span>
|
|
862
1132
|
{rawTitleBlocked ? (
|
|
863
|
-
<span
|
|
864
|
-
|
|
1133
|
+
<span
|
|
1134
|
+
className="text-[10px] text-error"
|
|
1135
|
+
data-testid="publish-title-raw-error"
|
|
1136
|
+
>
|
|
1137
|
+
This would publish as a raw filename.{" "}
|
|
1138
|
+
{isGenesis
|
|
865
1139
|
? "Add a real “# Title” heading to genesis.md"
|
|
866
|
-
: "Set a title in the cut plan (or add a “# Title” to the episode)"}
|
|
1140
|
+
: "Set a title in the cut plan (or add a “# Title” to the episode)"}{" "}
|
|
1141
|
+
before publishing.
|
|
867
1142
|
</span>
|
|
868
1143
|
) : episodeTitleMissing ? (
|
|
869
|
-
<span
|
|
870
|
-
|
|
1144
|
+
<span
|
|
1145
|
+
className="text-[10px] text-error"
|
|
1146
|
+
data-testid="publish-title-episode-required"
|
|
1147
|
+
>
|
|
1148
|
+
“{resolvedPublishTitle}” is a generic placeholder, not a
|
|
1149
|
+
reader-facing title, so it can’t be published. Set a real episode
|
|
1150
|
+
title in the cut plan (or add a “# Title” to the episode) — e.g.
|
|
1151
|
+
“Episode 01 — The Couple Coupon” — before publishing.
|
|
871
1152
|
</span>
|
|
872
1153
|
) : null}
|
|
873
1154
|
</div>
|
|
@@ -887,34 +1168,62 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
887
1168
|
data-testid="cartoon-genesis-readiness"
|
|
888
1169
|
data-blocked={genesisBlocked ? "true" : "false"}
|
|
889
1170
|
>
|
|
890
|
-
<span className="text-[11px] font-medium text-foreground">
|
|
891
|
-
|
|
892
|
-
|
|
1171
|
+
<span className="text-[11px] font-medium text-foreground">
|
|
1172
|
+
Story opening (Prologue)
|
|
1173
|
+
</span>
|
|
1174
|
+
<span
|
|
1175
|
+
className="text-[10px] text-muted"
|
|
1176
|
+
data-testid="genesis-readiness-hint"
|
|
1177
|
+
>
|
|
1178
|
+
Genesis is the first thing readers see. Write it as the story
|
|
1179
|
+
opening/prologue, not a synopsis — set up the premise and stakes, then
|
|
1180
|
+
bridge into Episode 01.
|
|
893
1181
|
</span>
|
|
894
1182
|
{genesisReadiness.blockers.map((b, i) => (
|
|
895
|
-
<span
|
|
1183
|
+
<span
|
|
1184
|
+
key={`b-${i}`}
|
|
1185
|
+
className="text-[10px] text-error"
|
|
1186
|
+
data-testid="genesis-readiness-blocker"
|
|
1187
|
+
>
|
|
1188
|
+
{b}
|
|
1189
|
+
</span>
|
|
896
1190
|
))}
|
|
897
1191
|
{genesisReadiness.warnings.map((w, i) => (
|
|
898
|
-
<span
|
|
1192
|
+
<span
|
|
1193
|
+
key={`w-${i}`}
|
|
1194
|
+
className="text-[10px] text-amber-600"
|
|
1195
|
+
data-testid="genesis-readiness-warning"
|
|
1196
|
+
>
|
|
1197
|
+
{w}
|
|
1198
|
+
</span>
|
|
899
1199
|
))}
|
|
900
1200
|
</div>
|
|
901
1201
|
);
|
|
902
1202
|
};
|
|
903
|
-
const charLimit =
|
|
1203
|
+
const charLimit = isGenesis || isPlot ? 10000 : null;
|
|
904
1204
|
// Don't show over-limit warning for already-published files
|
|
905
1205
|
const overLimit = !isPublished && charLimit !== null && charCount > charLimit;
|
|
906
1206
|
|
|
907
1207
|
// Pre-publish image validation for pending content
|
|
908
1208
|
const publishContent = fileData?.content ?? "";
|
|
909
|
-
const imageValidation = !isPublished
|
|
1209
|
+
const imageValidation = !isPublished
|
|
1210
|
+
? validateImageRefs(publishContent)
|
|
1211
|
+
: { count: 0, warnings: [] };
|
|
910
1212
|
|
|
911
1213
|
// Plain prose editor (fiction files + the Genesis "Opening text" sub-view).
|
|
912
1214
|
const proseEditor = (
|
|
913
|
-
<div
|
|
1215
|
+
<div
|
|
1216
|
+
className="flex-1 min-h-0 flex flex-col"
|
|
1217
|
+
style={{ background: "var(--paper-bg)" }}
|
|
1218
|
+
>
|
|
914
1219
|
<textarea
|
|
915
1220
|
ref={textareaRef}
|
|
916
1221
|
value={editContent}
|
|
917
|
-
onChange={(e) => {
|
|
1222
|
+
onChange={(e) => {
|
|
1223
|
+
setEditContent(e.target.value);
|
|
1224
|
+
setDirty(true);
|
|
1225
|
+
dirtyRef.current = true;
|
|
1226
|
+
}}
|
|
918
1227
|
className="flex-1 min-h-0 w-full resize-none px-4 py-3 text-sm leading-relaxed focus:outline-none"
|
|
919
1228
|
style={{
|
|
920
1229
|
fontFamily: '"Geist Mono", ui-monospace, monospace',
|
|
@@ -941,87 +1250,108 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
941
1250
|
return (
|
|
942
1251
|
<div className="h-full flex flex-col">
|
|
943
1252
|
{/* Header with file path + tabs */}
|
|
944
|
-
|
|
945
|
-
<div className="
|
|
946
|
-
<div className="
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1253
|
+
{!hideFocusedEditorChrome && (
|
|
1254
|
+
<div className="border-b border-border">
|
|
1255
|
+
<div className="px-3 py-1.5 flex items-center justify-between">
|
|
1256
|
+
<div className="flex items-center gap-2 text-xs font-mono text-muted">
|
|
1257
|
+
{onViewProgress && (
|
|
1258
|
+
<button
|
|
1259
|
+
onClick={onViewProgress}
|
|
1260
|
+
data-testid="view-progress-btn"
|
|
1261
|
+
className="text-accent hover:underline font-sans"
|
|
1262
|
+
title="Story progress overview"
|
|
1263
|
+
>
|
|
1264
|
+
← Progress
|
|
1265
|
+
</button>
|
|
1266
|
+
)}
|
|
1267
|
+
<span>
|
|
1268
|
+
{storyName}/{fileName}
|
|
1269
|
+
</span>
|
|
1270
|
+
{fileData?.status === "published" && (
|
|
1271
|
+
<span className="text-green-700 font-medium">Published</span>
|
|
1272
|
+
)}
|
|
1273
|
+
{fileData?.status === "published-not-indexed" && (
|
|
1274
|
+
<span
|
|
1275
|
+
className="text-amber-700 font-medium"
|
|
1276
|
+
title={fileData.indexError}
|
|
1277
|
+
>
|
|
1278
|
+
Published (not indexed)
|
|
1279
|
+
</span>
|
|
1280
|
+
)}
|
|
1281
|
+
{fileData?.status === "pending" && (
|
|
1282
|
+
<span className="text-amber-700 font-medium">Pending</span>
|
|
1283
|
+
)}
|
|
1284
|
+
</div>
|
|
1285
|
+
<div className="flex items-center gap-2">
|
|
1286
|
+
<span
|
|
1287
|
+
className={`text-xs font-mono ${overLimit ? "text-error font-medium" : "text-muted"}`}
|
|
953
1288
|
>
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
{fileData?.status === "published" && (
|
|
959
|
-
<span className="text-green-700 font-medium">Published</span>
|
|
960
|
-
)}
|
|
961
|
-
{fileData?.status === "published-not-indexed" && (
|
|
962
|
-
<span className="text-amber-700 font-medium" title={fileData.indexError}>Published (not indexed)</span>
|
|
963
|
-
)}
|
|
964
|
-
{fileData?.status === "pending" && (
|
|
965
|
-
<span className="text-amber-700 font-medium">Pending</span>
|
|
966
|
-
)}
|
|
967
|
-
</div>
|
|
968
|
-
<div className="flex items-center gap-2">
|
|
969
|
-
<span className={`text-xs font-mono ${overLimit ? "text-error font-medium" : "text-muted"}`}>
|
|
970
|
-
{charCount.toLocaleString()}{charLimit !== null ? `/${charLimit.toLocaleString()}` : " chars"}
|
|
971
|
-
</span>
|
|
972
|
-
{overLimit && (
|
|
973
|
-
<span className="text-error text-xs font-medium">
|
|
974
|
-
{(charCount - charLimit).toLocaleString()} over limit
|
|
1289
|
+
{charCount.toLocaleString()}
|
|
1290
|
+
{charLimit !== null
|
|
1291
|
+
? `/${charLimit.toLocaleString()}`
|
|
1292
|
+
: " chars"}
|
|
975
1293
|
</span>
|
|
976
|
-
|
|
1294
|
+
{overLimit && (
|
|
1295
|
+
<span className="text-error text-xs font-medium">
|
|
1296
|
+
{(charCount - charLimit).toLocaleString()} over limit
|
|
1297
|
+
</span>
|
|
1298
|
+
)}
|
|
1299
|
+
</div>
|
|
977
1300
|
</div>
|
|
978
|
-
</div>
|
|
979
1301
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1302
|
+
{/* Tabs */}
|
|
1303
|
+
<div className="flex px-3 gap-1">
|
|
1304
|
+
<button
|
|
1305
|
+
onClick={() => setActiveTab("preview")}
|
|
1306
|
+
className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
|
|
1307
|
+
activeTab === "preview"
|
|
1308
|
+
? "border-accent text-accent"
|
|
1309
|
+
: "border-transparent text-muted hover:text-foreground"
|
|
1310
|
+
}`}
|
|
1311
|
+
>
|
|
1312
|
+
Preview
|
|
1313
|
+
</button>
|
|
1314
|
+
<button
|
|
1315
|
+
onClick={() => setActiveTab("edit")}
|
|
1316
|
+
className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
|
|
1317
|
+
activeTab === "edit"
|
|
1318
|
+
? "border-accent text-accent"
|
|
1319
|
+
: "border-transparent text-muted hover:text-foreground"
|
|
1320
|
+
}`}
|
|
1321
|
+
>
|
|
1322
|
+
Edit
|
|
1323
|
+
{dirty && <span className="ml-1 text-amber-600">*</span>}
|
|
1324
|
+
</button>
|
|
1325
|
+
</div>
|
|
1003
1326
|
</div>
|
|
1004
|
-
|
|
1327
|
+
)}
|
|
1005
1328
|
|
|
1006
1329
|
{/* Persistent cartoon workflow coach (#429): one clear next action across
|
|
1007
1330
|
every cartoon file view, derived from real story/episode state. Sits
|
|
1008
1331
|
above the content so it stays visible on both the Preview and Edit
|
|
1009
1332
|
tabs. Fiction renders nothing (the coach is null), so fiction views are
|
|
1010
1333
|
unchanged. */}
|
|
1011
|
-
{
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1334
|
+
{!hideFocusedEditorChrome &&
|
|
1335
|
+
contentType === "cartoon" &&
|
|
1336
|
+
storyName &&
|
|
1337
|
+
fileName && (
|
|
1338
|
+
<WorkflowCoach
|
|
1339
|
+
storyName={storyName}
|
|
1340
|
+
fileName={fileName}
|
|
1341
|
+
authFetch={authFetch}
|
|
1342
|
+
refreshKey={cutsRefreshKey}
|
|
1343
|
+
onAction={handleCoachAction}
|
|
1344
|
+
showEmptyState
|
|
1345
|
+
/>
|
|
1346
|
+
)}
|
|
1020
1347
|
|
|
1021
1348
|
{/* Content area */}
|
|
1022
1349
|
{activeTab === "preview" ? (
|
|
1023
1350
|
isCartoonPlot ? (
|
|
1024
|
-
<div
|
|
1351
|
+
<div
|
|
1352
|
+
className="flex-1 min-h-0 flex flex-col"
|
|
1353
|
+
style={{ background: "var(--paper-bg)" }}
|
|
1354
|
+
>
|
|
1025
1355
|
{/* Two explicit modes: Publish Preview (exact PlotLink markdown) vs
|
|
1026
1356
|
Cut Inspector (cuts.json planning metadata) — see #289. */}
|
|
1027
1357
|
<div className="flex gap-1 px-3 py-1 border-b border-border">
|
|
@@ -1042,14 +1372,25 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
1042
1372
|
</div>
|
|
1043
1373
|
<div className="flex-1 min-h-0">
|
|
1044
1374
|
{cartoonPreviewMode === "publish" ? (
|
|
1045
|
-
<CartoonPublishPreview
|
|
1375
|
+
<CartoonPublishPreview
|
|
1376
|
+
content={fileData?.content ?? ""}
|
|
1377
|
+
stage={cartoonStage}
|
|
1378
|
+
/>
|
|
1046
1379
|
) : (
|
|
1047
|
-
<CartoonPreview
|
|
1380
|
+
<CartoonPreview
|
|
1381
|
+
storyName={storyName!}
|
|
1382
|
+
fileName={fileName!}
|
|
1383
|
+
authFetch={authFetch}
|
|
1384
|
+
onEditCut={handleEditCut}
|
|
1385
|
+
/>
|
|
1048
1386
|
)}
|
|
1049
1387
|
</div>
|
|
1050
1388
|
</div>
|
|
1051
1389
|
) : (
|
|
1052
|
-
<div
|
|
1390
|
+
<div
|
|
1391
|
+
className="flex-1 min-h-0 overflow-y-auto px-6 py-4"
|
|
1392
|
+
style={{ background: "var(--paper-bg)" }}
|
|
1393
|
+
>
|
|
1053
1394
|
{fileData?.content ? (
|
|
1054
1395
|
<div className="prose max-w-none">
|
|
1055
1396
|
<ReactMarkdown
|
|
@@ -1065,15 +1406,32 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
1065
1406
|
</div>
|
|
1066
1407
|
)
|
|
1067
1408
|
) : isCartoonPlot ? (
|
|
1068
|
-
<div
|
|
1069
|
-
|
|
1409
|
+
<div
|
|
1410
|
+
className="flex-1 min-h-[22rem] overflow-hidden"
|
|
1411
|
+
style={{ background: "var(--paper-bg)" }}
|
|
1412
|
+
>
|
|
1413
|
+
<CutListPanel
|
|
1414
|
+
storyName={storyName!}
|
|
1415
|
+
fileName={fileName!}
|
|
1416
|
+
authFetch={authFetch}
|
|
1417
|
+
language={language}
|
|
1418
|
+
onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
|
|
1419
|
+
focusRequest={cutFocus}
|
|
1420
|
+
onFocusHandled={() => setCutFocus(null)}
|
|
1421
|
+
onFocusedLetteringModeChange={onFocusedLetteringModeChange}
|
|
1422
|
+
workspaceVisible={focusedLetteringWorkspaceVisible}
|
|
1423
|
+
onWorkspaceVisibleChange={onFocusedLetteringWorkspaceVisibleChange}
|
|
1424
|
+
/>
|
|
1070
1425
|
</div>
|
|
1071
1426
|
) : isCartoonGenesis ? (
|
|
1072
1427
|
// Genesis Edit tab: opening-text editor vs. its cut workspace (#429), so
|
|
1073
1428
|
// the coach's lettering/upload/refresh actions for Episode 1 are actionable
|
|
1074
1429
|
// and Genesis cuts get the same workspace as plots — without losing the
|
|
1075
1430
|
// hand-written opening prose editor.
|
|
1076
|
-
<div
|
|
1431
|
+
<div
|
|
1432
|
+
className="flex-1 min-h-0 flex flex-col"
|
|
1433
|
+
style={{ background: "var(--paper-bg)" }}
|
|
1434
|
+
>
|
|
1077
1435
|
<div className="flex gap-1 px-3 py-1 border-b border-border">
|
|
1078
1436
|
<button
|
|
1079
1437
|
data-testid="genesis-edit-mode-text"
|
|
@@ -1092,7 +1450,20 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
1092
1450
|
</div>
|
|
1093
1451
|
<div className="flex-1 min-h-0">
|
|
1094
1452
|
{genesisEditMode === "cuts" ? (
|
|
1095
|
-
<CutListPanel
|
|
1453
|
+
<CutListPanel
|
|
1454
|
+
storyName={storyName!}
|
|
1455
|
+
fileName={fileName!}
|
|
1456
|
+
authFetch={authFetch}
|
|
1457
|
+
language={language}
|
|
1458
|
+
onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
|
|
1459
|
+
focusRequest={cutFocus}
|
|
1460
|
+
onFocusHandled={() => setCutFocus(null)}
|
|
1461
|
+
onFocusedLetteringModeChange={onFocusedLetteringModeChange}
|
|
1462
|
+
workspaceVisible={focusedLetteringWorkspaceVisible}
|
|
1463
|
+
onWorkspaceVisibleChange={
|
|
1464
|
+
onFocusedLetteringWorkspaceVisibleChange
|
|
1465
|
+
}
|
|
1466
|
+
/>
|
|
1096
1467
|
) : (
|
|
1097
1468
|
proseEditor
|
|
1098
1469
|
)}
|
|
@@ -1103,356 +1474,468 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
1103
1474
|
)}
|
|
1104
1475
|
|
|
1105
1476
|
{/* Action bar */}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
{
|
|
1114
|
-
<button
|
|
1115
|
-
onClick={async () => {
|
|
1116
|
-
if (!storyName || !fileName || !fileData.txHash) return;
|
|
1117
|
-
setRetrying(true);
|
|
1118
|
-
try {
|
|
1119
|
-
const res = await authFetch("/api/publish/retry-index", {
|
|
1120
|
-
method: "POST",
|
|
1121
|
-
headers: { "Content-Type": "application/json" },
|
|
1122
|
-
body: JSON.stringify({
|
|
1123
|
-
storyName, fileName,
|
|
1124
|
-
txHash: fileData.txHash,
|
|
1125
|
-
content: fileData.content,
|
|
1126
|
-
storylineId: fileData.storylineId,
|
|
1127
|
-
}),
|
|
1128
|
-
});
|
|
1129
|
-
const data = await res.json();
|
|
1130
|
-
if (data.ok) {
|
|
1131
|
-
await authFetch(`/api/stories/${storyName}/${fileName}/publish-status`, {
|
|
1132
|
-
method: "POST",
|
|
1133
|
-
headers: { "Content-Type": "application/json" },
|
|
1134
|
-
body: JSON.stringify({
|
|
1135
|
-
txHash: fileData.txHash,
|
|
1136
|
-
storylineId: fileData.storylineId,
|
|
1137
|
-
contentCid: "",
|
|
1138
|
-
gasCost: "",
|
|
1139
|
-
}),
|
|
1140
|
-
});
|
|
1141
|
-
loadFile();
|
|
1142
|
-
}
|
|
1143
|
-
} catch { /* ignore */ }
|
|
1144
|
-
setRetrying(false);
|
|
1145
|
-
}}
|
|
1146
|
-
disabled={retrying}
|
|
1147
|
-
className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50"
|
|
1148
|
-
>
|
|
1149
|
-
{retrying ? "Retrying..." : `Retry Index${indexCountdown ? ` (${indexCountdown})` : ""}`}
|
|
1150
|
-
</button>
|
|
1151
|
-
)}
|
|
1152
|
-
{isPlot && (
|
|
1153
|
-
<button
|
|
1154
|
-
onClick={() => {
|
|
1155
|
-
if (!storyName || !fileName) return;
|
|
1156
|
-
// #332: Retry Publish mints a NEW on-chain chainPlot. The
|
|
1157
|
-
// tx for this episode already exists (status is
|
|
1158
|
-
// published-not-indexed), so this is only for the rare case
|
|
1159
|
-
// where indexing never recovers — require an explicit
|
|
1160
|
-
// duplicate-risk confirm so it can't be clicked by reflex
|
|
1161
|
-
// instead of Retry Index, which would create a permanent
|
|
1162
|
-
// duplicate chapter on PlotLink.
|
|
1163
|
-
const ok = window.confirm(
|
|
1164
|
-
"This episode is already on-chain — try “Retry Index” first.\n\nRetry Publish creates a NEW on-chain transaction and a SECOND, permanent chapter on PlotLink (PlotLink content is immutable). Only do this if the chapter never appeared after indexing.\n\nCreate a new on-chain chapter anyway?",
|
|
1165
|
-
);
|
|
1166
|
-
if (!ok) return;
|
|
1167
|
-
onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw);
|
|
1168
|
-
}}
|
|
1169
|
-
disabled={!!publishingFile}
|
|
1170
|
-
data-testid="retry-publish-btn"
|
|
1171
|
-
className="px-3 py-1 border border-border text-xs rounded hover:bg-surface disabled:opacity-50"
|
|
1172
|
-
>
|
|
1173
|
-
{publishingFile === fileName ? "Publishing..." : "Retry Publish"}
|
|
1174
|
-
</button>
|
|
1175
|
-
)}
|
|
1176
|
-
{fileData.txHash && (
|
|
1177
|
-
<a
|
|
1178
|
-
href={`https://basescan.org/tx/${fileData.txHash}`}
|
|
1179
|
-
target="_blank"
|
|
1180
|
-
rel="noopener noreferrer"
|
|
1181
|
-
className="text-muted underline"
|
|
1182
|
-
>
|
|
1183
|
-
BaseScan
|
|
1184
|
-
</a>
|
|
1185
|
-
)}
|
|
1186
|
-
</div>
|
|
1187
|
-
<p className="text-muted text-xs">
|
|
1188
|
-
{indexExpired
|
|
1189
|
-
? isPlot
|
|
1190
|
-
? "Index window expired. Use Retry Publish to create a new on-chain tx."
|
|
1191
|
-
: "Index window expired. Contact support or re-publish manually."
|
|
1192
|
-
: isPlot
|
|
1193
|
-
? "Try Retry Index first (available for 5 min after publish). If that fails, Retry Publish creates a new on-chain tx."
|
|
1194
|
-
: "Retry Index is available for 5 min after publish."}
|
|
1477
|
+
{!hideFocusedEditorChrome && (
|
|
1478
|
+
<div className="px-3 py-2 border-t border-border flex items-center justify-between">
|
|
1479
|
+
{fileName === "structure.md" ? (
|
|
1480
|
+
<p
|
|
1481
|
+
className="text-muted text-xs italic"
|
|
1482
|
+
data-testid="footer-guidance"
|
|
1483
|
+
>
|
|
1484
|
+
{footerGuidance}
|
|
1195
1485
|
</p>
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1486
|
+
) : fileData?.status === "published-not-indexed" ? (
|
|
1487
|
+
<div className="flex flex-col gap-1">
|
|
1488
|
+
<div className="flex items-center gap-2 text-xs">
|
|
1489
|
+
<span className="text-amber-700">
|
|
1490
|
+
Published on-chain but not indexed on PlotLink
|
|
1491
|
+
</span>
|
|
1492
|
+
{!indexExpired && (
|
|
1493
|
+
<button
|
|
1494
|
+
onClick={async () => {
|
|
1495
|
+
if (!storyName || !fileName || !fileData.txHash) return;
|
|
1496
|
+
setRetrying(true);
|
|
1497
|
+
try {
|
|
1498
|
+
const res = await authFetch(
|
|
1499
|
+
"/api/publish/retry-index",
|
|
1500
|
+
{
|
|
1501
|
+
method: "POST",
|
|
1502
|
+
headers: { "Content-Type": "application/json" },
|
|
1503
|
+
body: JSON.stringify({
|
|
1504
|
+
storyName,
|
|
1505
|
+
fileName,
|
|
1506
|
+
txHash: fileData.txHash,
|
|
1507
|
+
content: fileData.content,
|
|
1508
|
+
storylineId: fileData.storylineId,
|
|
1509
|
+
}),
|
|
1510
|
+
},
|
|
1511
|
+
);
|
|
1512
|
+
const data = await res.json();
|
|
1513
|
+
if (data.ok) {
|
|
1514
|
+
await authFetch(
|
|
1515
|
+
`/api/stories/${storyName}/${fileName}/publish-status`,
|
|
1516
|
+
{
|
|
1517
|
+
method: "POST",
|
|
1518
|
+
headers: { "Content-Type": "application/json" },
|
|
1519
|
+
body: JSON.stringify({
|
|
1520
|
+
txHash: fileData.txHash,
|
|
1521
|
+
storylineId: fileData.storylineId,
|
|
1522
|
+
contentCid: "",
|
|
1523
|
+
gasCost: "",
|
|
1524
|
+
}),
|
|
1525
|
+
},
|
|
1526
|
+
);
|
|
1527
|
+
loadFile();
|
|
1528
|
+
}
|
|
1529
|
+
} catch {
|
|
1530
|
+
/* ignore */
|
|
1531
|
+
}
|
|
1532
|
+
setRetrying(false);
|
|
1533
|
+
}}
|
|
1534
|
+
disabled={retrying}
|
|
1535
|
+
className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50"
|
|
1536
|
+
>
|
|
1537
|
+
{retrying
|
|
1538
|
+
? "Retrying..."
|
|
1539
|
+
: `Retry Index${indexCountdown ? ` (${indexCountdown})` : ""}`}
|
|
1540
|
+
</button>
|
|
1541
|
+
)}
|
|
1542
|
+
{isPlot && (
|
|
1543
|
+
<button
|
|
1544
|
+
onClick={() => {
|
|
1545
|
+
if (!storyName || !fileName) return;
|
|
1546
|
+
// #332: Retry Publish mints a NEW on-chain chainPlot. The
|
|
1547
|
+
// tx for this episode already exists (status is
|
|
1548
|
+
// published-not-indexed), so this is only for the rare case
|
|
1549
|
+
// where indexing never recovers — require an explicit
|
|
1550
|
+
// duplicate-risk confirm so it can't be clicked by reflex
|
|
1551
|
+
// instead of Retry Index, which would create a permanent
|
|
1552
|
+
// duplicate chapter on PlotLink.
|
|
1553
|
+
const ok = window.confirm(
|
|
1554
|
+
"This episode is already on-chain — try “Retry Index” first.\n\nRetry Publish creates a NEW on-chain transaction and a SECOND, permanent chapter on PlotLink (PlotLink content is immutable). Only do this if the chapter never appeared after indexing.\n\nCreate a new on-chain chapter anyway?",
|
|
1555
|
+
);
|
|
1556
|
+
if (!ok) return;
|
|
1557
|
+
onPublish?.(
|
|
1558
|
+
storyName,
|
|
1559
|
+
fileName,
|
|
1560
|
+
selectedGenre,
|
|
1561
|
+
selectedLanguage,
|
|
1562
|
+
isNsfw,
|
|
1563
|
+
);
|
|
1564
|
+
}}
|
|
1565
|
+
disabled={!!publishingFile}
|
|
1566
|
+
data-testid="retry-publish-btn"
|
|
1567
|
+
className="px-3 py-1 border border-border text-xs rounded hover:bg-surface disabled:opacity-50"
|
|
1568
|
+
>
|
|
1569
|
+
{publishingFile === fileName
|
|
1570
|
+
? "Publishing..."
|
|
1571
|
+
: "Retry Publish"}
|
|
1572
|
+
</button>
|
|
1573
|
+
)}
|
|
1574
|
+
{fileData.txHash && (
|
|
1575
|
+
<a
|
|
1576
|
+
href={`https://basescan.org/tx/${fileData.txHash}`}
|
|
1577
|
+
target="_blank"
|
|
1578
|
+
rel="noopener noreferrer"
|
|
1579
|
+
className="text-muted underline"
|
|
1580
|
+
>
|
|
1581
|
+
BaseScan
|
|
1582
|
+
</a>
|
|
1583
|
+
)}
|
|
1584
|
+
</div>
|
|
1585
|
+
<p className="text-muted text-xs">
|
|
1586
|
+
{indexExpired
|
|
1587
|
+
? isPlot
|
|
1588
|
+
? "Index window expired. Use Retry Publish to create a new on-chain tx."
|
|
1589
|
+
: "Index window expired. Contact support or re-publish manually."
|
|
1590
|
+
: isPlot
|
|
1591
|
+
? "Try Retry Index first (available for 5 min after publish). If that fails, Retry Publish creates a new on-chain tx."
|
|
1592
|
+
: "Retry Index is available for 5 min after publish."}
|
|
1593
|
+
</p>
|
|
1594
|
+
{fileData.indexError && (
|
|
1595
|
+
<p className="text-error text-xs">{fileData.indexError}</p>
|
|
1238
1596
|
)}
|
|
1239
1597
|
</div>
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
<div className="
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
<
|
|
1246
|
-
|
|
1598
|
+
) : fileData?.status === "published" ? (
|
|
1599
|
+
<div className="flex flex-col gap-2">
|
|
1600
|
+
<div className="flex items-center gap-2 text-xs">
|
|
1601
|
+
<span className="text-green-700">Published</span>
|
|
1602
|
+
{fileData.storylineId && (
|
|
1603
|
+
<a
|
|
1604
|
+
href={(() => {
|
|
1605
|
+
const base = `https://plotlink.xyz/story/${fileData.storylineId}`;
|
|
1606
|
+
if (!isPlot) return base;
|
|
1607
|
+
const idx =
|
|
1608
|
+
fileData.plotIndex != null && fileData.plotIndex > 0
|
|
1609
|
+
? fileData.plotIndex
|
|
1610
|
+
: parseInt(
|
|
1611
|
+
fileName?.match(/^plot-(\d+)\.md$/)?.[1] ?? "1",
|
|
1612
|
+
);
|
|
1613
|
+
return `${base}/${idx}`;
|
|
1614
|
+
})()}
|
|
1615
|
+
target="_blank"
|
|
1616
|
+
rel="noopener noreferrer"
|
|
1617
|
+
className="text-accent underline"
|
|
1618
|
+
>
|
|
1619
|
+
View on PlotLink
|
|
1620
|
+
</a>
|
|
1621
|
+
)}
|
|
1622
|
+
{fileData.txHash && (
|
|
1623
|
+
<a
|
|
1624
|
+
href={`https://basescan.org/tx/${fileData.txHash}`}
|
|
1625
|
+
target="_blank"
|
|
1626
|
+
rel="noopener noreferrer"
|
|
1627
|
+
className="text-muted underline"
|
|
1628
|
+
>
|
|
1629
|
+
BaseScan
|
|
1630
|
+
</a>
|
|
1631
|
+
)}
|
|
1632
|
+
{isGenesis &&
|
|
1633
|
+
walletAddress &&
|
|
1634
|
+
fileData.storylineId &&
|
|
1635
|
+
(!fileData.authorAddress ||
|
|
1636
|
+
fileData.authorAddress.toLowerCase() ===
|
|
1637
|
+
walletAddress.toLowerCase()) && (
|
|
1638
|
+
<button
|
|
1639
|
+
onClick={() => setShowEditPanel((v) => !v)}
|
|
1640
|
+
className="px-2 py-0.5 border border-border text-xs rounded hover:bg-surface"
|
|
1641
|
+
>
|
|
1642
|
+
{showEditPanel ? "Close Edit" : "Edit Story"}
|
|
1643
|
+
</button>
|
|
1644
|
+
)}
|
|
1645
|
+
</div>
|
|
1646
|
+
{/* Edit panel for published genesis files */}
|
|
1647
|
+
{showEditPanel && isGenesis && fileData.storylineId && (
|
|
1648
|
+
<div className="border border-border rounded p-3 flex flex-col gap-3 bg-surface">
|
|
1649
|
+
{/* Cover image upload */}
|
|
1650
|
+
<div className="flex flex-col gap-1.5">
|
|
1651
|
+
<span className="text-xs font-medium text-foreground">
|
|
1652
|
+
Cover Image
|
|
1653
|
+
</span>
|
|
1654
|
+
{/* Attached/selected/invalid cover status for the published
|
|
1247
1655
|
cartoon story (#337). */}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1656
|
+
{renderCoverStatus(editHasCover)}
|
|
1657
|
+
<div className="flex items-start gap-3">
|
|
1658
|
+
{coverPreview && (
|
|
1659
|
+
<div className="relative">
|
|
1660
|
+
<img
|
|
1661
|
+
src={coverPreview}
|
|
1662
|
+
alt="Cover preview"
|
|
1663
|
+
className="w-16 h-24 object-cover rounded border border-border"
|
|
1664
|
+
/>
|
|
1665
|
+
<button
|
|
1666
|
+
onClick={() => {
|
|
1667
|
+
setCoverFile(null);
|
|
1668
|
+
setCoverPreview(null);
|
|
1669
|
+
setDetectedCoverWarning(null);
|
|
1670
|
+
setCoverStatus("unknown");
|
|
1671
|
+
if (coverInputRef.current)
|
|
1672
|
+
coverInputRef.current.value = "";
|
|
1673
|
+
}}
|
|
1674
|
+
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-error text-white rounded-full text-xs flex items-center justify-center"
|
|
1675
|
+
>
|
|
1676
|
+
x
|
|
1677
|
+
</button>
|
|
1678
|
+
</div>
|
|
1679
|
+
)}
|
|
1680
|
+
<div className="flex flex-col gap-1">
|
|
1681
|
+
<input
|
|
1682
|
+
ref={coverInputRef}
|
|
1683
|
+
type="file"
|
|
1684
|
+
accept="image/webp,image/jpeg"
|
|
1685
|
+
onChange={handleCoverSelect}
|
|
1686
|
+
className="text-xs"
|
|
1687
|
+
data-testid="cover-input"
|
|
1256
1688
|
/>
|
|
1257
|
-
<
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
>
|
|
1261
|
-
x
|
|
1262
|
-
</button>
|
|
1689
|
+
<span className="text-xs text-muted">
|
|
1690
|
+
WebP/JPEG, max 1MB, 600x900px recommended
|
|
1691
|
+
</span>
|
|
1263
1692
|
</div>
|
|
1264
|
-
)}
|
|
1265
|
-
<div className="flex flex-col gap-1">
|
|
1266
|
-
<input
|
|
1267
|
-
ref={coverInputRef}
|
|
1268
|
-
type="file"
|
|
1269
|
-
accept="image/webp,image/jpeg"
|
|
1270
|
-
onChange={handleCoverSelect}
|
|
1271
|
-
className="text-xs"
|
|
1272
|
-
data-testid="cover-input"
|
|
1273
|
-
/>
|
|
1274
|
-
<span className="text-xs text-muted">WebP/JPEG, max 1MB, 600x900px recommended</span>
|
|
1275
1693
|
</div>
|
|
1276
1694
|
</div>
|
|
1695
|
+
{/* Genre & Language */}
|
|
1696
|
+
<div className="flex items-center gap-2">
|
|
1697
|
+
<select
|
|
1698
|
+
value={editGenre}
|
|
1699
|
+
onChange={(e) => setEditGenre(e.target.value)}
|
|
1700
|
+
className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
|
|
1701
|
+
>
|
|
1702
|
+
{GENRES.map((g) => (
|
|
1703
|
+
<option key={g} value={g}>
|
|
1704
|
+
{g}
|
|
1705
|
+
</option>
|
|
1706
|
+
))}
|
|
1707
|
+
</select>
|
|
1708
|
+
<select
|
|
1709
|
+
value={editLanguage}
|
|
1710
|
+
onChange={(e) => setEditLanguage(e.target.value)}
|
|
1711
|
+
className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
|
|
1712
|
+
>
|
|
1713
|
+
{LANGUAGES.map((l) => (
|
|
1714
|
+
<option key={l} value={l}>
|
|
1715
|
+
{l}
|
|
1716
|
+
</option>
|
|
1717
|
+
))}
|
|
1718
|
+
</select>
|
|
1719
|
+
</div>
|
|
1720
|
+
{/* NSFW toggle */}
|
|
1721
|
+
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
|
1722
|
+
<input
|
|
1723
|
+
type="checkbox"
|
|
1724
|
+
checked={editNsfw}
|
|
1725
|
+
onChange={(e) => setEditNsfw(e.target.checked)}
|
|
1726
|
+
className="rounded border-border"
|
|
1727
|
+
/>
|
|
1728
|
+
This story contains adult content (18+)
|
|
1729
|
+
</label>
|
|
1730
|
+
{/* Save / status */}
|
|
1731
|
+
<div className="flex items-center gap-2">
|
|
1732
|
+
<button
|
|
1733
|
+
onClick={handleEditSave}
|
|
1734
|
+
disabled={editSaving || !editMetaLoaded}
|
|
1735
|
+
className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50"
|
|
1736
|
+
>
|
|
1737
|
+
{editSaving
|
|
1738
|
+
? "Saving..."
|
|
1739
|
+
: !editMetaLoaded
|
|
1740
|
+
? "Loading..."
|
|
1741
|
+
: "Save Changes"}
|
|
1742
|
+
</button>
|
|
1743
|
+
{editSuccess && (
|
|
1744
|
+
<span className="text-green-700 text-xs">Updated!</span>
|
|
1745
|
+
)}
|
|
1746
|
+
{editError && (
|
|
1747
|
+
<span className="text-error text-xs">{editError}</span>
|
|
1748
|
+
)}
|
|
1749
|
+
</div>
|
|
1277
1750
|
</div>
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
|
|
1284
|
-
>
|
|
1285
|
-
{GENRES.map((g) => (
|
|
1286
|
-
<option key={g} value={g}>{g}</option>
|
|
1287
|
-
))}
|
|
1288
|
-
</select>
|
|
1289
|
-
<select
|
|
1290
|
-
value={editLanguage}
|
|
1291
|
-
onChange={(e) => setEditLanguage(e.target.value)}
|
|
1292
|
-
className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
|
|
1293
|
-
>
|
|
1294
|
-
{LANGUAGES.map((l) => (
|
|
1295
|
-
<option key={l} value={l}>{l}</option>
|
|
1296
|
-
))}
|
|
1297
|
-
</select>
|
|
1298
|
-
</div>
|
|
1299
|
-
{/* NSFW toggle */}
|
|
1300
|
-
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
|
1301
|
-
<input
|
|
1302
|
-
type="checkbox"
|
|
1303
|
-
checked={editNsfw}
|
|
1304
|
-
onChange={(e) => setEditNsfw(e.target.checked)}
|
|
1305
|
-
className="rounded border-border"
|
|
1306
|
-
/>
|
|
1307
|
-
This story contains adult content (18+)
|
|
1308
|
-
</label>
|
|
1309
|
-
{/* Save / status */}
|
|
1310
|
-
<div className="flex items-center gap-2">
|
|
1311
|
-
<button
|
|
1312
|
-
onClick={handleEditSave}
|
|
1313
|
-
disabled={editSaving || !editMetaLoaded}
|
|
1314
|
-
className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50"
|
|
1315
|
-
>
|
|
1316
|
-
{editSaving ? "Saving..." : !editMetaLoaded ? "Loading..." : "Save Changes"}
|
|
1317
|
-
</button>
|
|
1318
|
-
{editSuccess && <span className="text-green-700 text-xs">Updated!</span>}
|
|
1319
|
-
{editError && <span className="text-error text-xs">{editError}</span>}
|
|
1320
|
-
</div>
|
|
1321
|
-
</div>
|
|
1322
|
-
)}
|
|
1323
|
-
</div>
|
|
1324
|
-
) : (
|
|
1325
|
-
<div className="flex flex-col gap-2">
|
|
1326
|
-
{/* Creator-facing 6-step production checklist so a first-time user
|
|
1751
|
+
)}
|
|
1752
|
+
</div>
|
|
1753
|
+
) : (
|
|
1754
|
+
<div className="flex flex-col gap-2">
|
|
1755
|
+
{/* Creator-facing 6-step production checklist so a first-time user
|
|
1327
1756
|
can see which step is current/next without internal jargon
|
|
1328
1757
|
(#320, expanded to per-cut granularity in #335). */}
|
|
1329
|
-
|
|
1758
|
+
{/* Compact cartoon production status (#420): one scannable line of
|
|
1330
1759
|
cut/clean/lettered/uploaded tallies, with a link to the full
|
|
1331
1760
|
story progress overview (#418). The detailed 6-step guide stays
|
|
1332
1761
|
below. */}
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1762
|
+
{isCartoonPlot &&
|
|
1763
|
+
cartoonCutProgress &&
|
|
1764
|
+
cartoonCutProgress.total > 0 && (
|
|
1765
|
+
<div
|
|
1766
|
+
className="flex items-center flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted"
|
|
1767
|
+
data-testid="cartoon-status-summary"
|
|
1768
|
+
>
|
|
1769
|
+
<span>
|
|
1770
|
+
Cuts:{" "}
|
|
1771
|
+
<span className="text-foreground font-medium">
|
|
1772
|
+
{cartoonCutProgress.total}
|
|
1773
|
+
</span>
|
|
1774
|
+
</span>
|
|
1775
|
+
<span>
|
|
1776
|
+
Clean:{" "}
|
|
1777
|
+
<span className="text-foreground font-medium">
|
|
1778
|
+
{cartoonCutProgress.withClean}/
|
|
1779
|
+
{cartoonCutProgress.needClean}
|
|
1780
|
+
</span>
|
|
1781
|
+
</span>
|
|
1782
|
+
<span>
|
|
1783
|
+
Lettered:{" "}
|
|
1784
|
+
<span className="text-foreground font-medium">
|
|
1785
|
+
{cartoonCutProgress.withText}/{cartoonCutProgress.total}
|
|
1786
|
+
</span>
|
|
1787
|
+
</span>
|
|
1788
|
+
<span>
|
|
1789
|
+
Uploaded:{" "}
|
|
1790
|
+
<span className="text-foreground font-medium">
|
|
1791
|
+
{cartoonCutProgress.uploaded}/{cartoonCutProgress.total}
|
|
1792
|
+
</span>
|
|
1793
|
+
</span>
|
|
1794
|
+
{onViewProgress && (
|
|
1795
|
+
<button
|
|
1796
|
+
onClick={onViewProgress}
|
|
1797
|
+
className="ml-auto text-accent hover:underline"
|
|
1798
|
+
data-testid="status-view-progress"
|
|
1799
|
+
>
|
|
1800
|
+
View progress →
|
|
1801
|
+
</button>
|
|
1802
|
+
)}
|
|
1803
|
+
</div>
|
|
1343
1804
|
)}
|
|
1344
|
-
|
|
1345
|
-
)}
|
|
1346
|
-
{/* #461: the 6-step production checklist is a publish/production
|
|
1805
|
+
{/* #461: the 6-step production checklist is a publish/production
|
|
1347
1806
|
checklist — it now lives on the Publish tab + the cut workspace's
|
|
1348
1807
|
FinishEpisodePanel, so it no longer renders under the episode. */}
|
|
1349
|
-
|
|
1808
|
+
{/* Genesis-as-Episode-1 cut summary (#422): discover + summarize
|
|
1350
1809
|
genesis.cuts.json so a writer sees its real cut/image state in the
|
|
1351
1810
|
readiness UI instead of treating Genesis as text-only. */}
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1811
|
+
{isCartoonGenesis && genesisCutProgress && (
|
|
1812
|
+
<div
|
|
1813
|
+
className="text-xs text-muted"
|
|
1814
|
+
data-testid="genesis-cuts-summary"
|
|
1815
|
+
>
|
|
1816
|
+
{/* Distinguish clean art / lettering / final-export / upload so the
|
|
1355
1817
|
state never collapses to just "uploaded" (#451). */}
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1818
|
+
Episode 1 (Genesis) cuts: {genesisCutProgress.total} planned
|
|
1819
|
+
{genesisCutProgress.total > 0 && (
|
|
1820
|
+
<>
|
|
1821
|
+
{" "}
|
|
1822
|
+
· {genesisCutProgress.withClean} clean ·{" "}
|
|
1823
|
+
{genesisCutProgress.withText} lettered ·{" "}
|
|
1824
|
+
{genesisCutProgress.exported} exported ·{" "}
|
|
1825
|
+
{genesisCutProgress.uploaded} uploaded
|
|
1826
|
+
</>
|
|
1827
|
+
)}
|
|
1828
|
+
</div>
|
|
1829
|
+
)}
|
|
1830
|
+
{/* State-aware guidance for a not-yet-produced Genesis or a future-
|
|
1368
1831
|
episode placeholder (#422): plan cuts / generate clean images /
|
|
1369
1832
|
expand the cut plan — never a misleading "ready to publish". */}
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1833
|
+
{(isCartoonGenesis || isCartoonPlot) && footerGuidance && (
|
|
1834
|
+
<div
|
|
1835
|
+
className={`${cartoonStatusCardClass} flex flex-col gap-1 border-border bg-surface/50`}
|
|
1836
|
+
data-testid="cartoon-not-started"
|
|
1837
|
+
>
|
|
1838
|
+
<div className="flex items-center gap-2">
|
|
1839
|
+
<span className="rounded-full bg-background px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted">
|
|
1840
|
+
{episodeCutCount === 0 ? "Not started" : "Next step"}
|
|
1841
|
+
</span>
|
|
1842
|
+
<span className="text-xs font-medium text-foreground">
|
|
1843
|
+
{isCartoonGenesis
|
|
1844
|
+
? "Genesis (Episode 1)"
|
|
1845
|
+
: "Future episode"}
|
|
1846
|
+
</span>
|
|
1847
|
+
</div>
|
|
1848
|
+
<span className="text-xs text-muted">{footerGuidance}</span>
|
|
1382
1849
|
</div>
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
)}
|
|
1386
|
-
{/* #461: the planning-stage "Prepare episode for publish" callout and
|
|
1850
|
+
)}
|
|
1851
|
+
{/* #461: the planning-stage "Prepare episode for publish" callout and
|
|
1387
1852
|
the awaiting-upload pending callout are publish-readiness states —
|
|
1388
1853
|
they now live on the Publish tab. The cartoon episode keeps only
|
|
1389
1854
|
production next-step guidance (cartoon-not-started / cut summaries)
|
|
1390
1855
|
plus the compact "Review publish checklist" CTA below. */}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
<div className="flex items-center gap-1.5">
|
|
1435
|
-
<code className="flex-1 text-xs bg-background px-2 py-1 rounded font-mono break-all">
|
|
1436
|
-

|
|
1437
|
-
</code>
|
|
1438
|
-
<button
|
|
1439
|
-
onClick={() => {
|
|
1440
|
-
navigator.clipboard.writeText(``);
|
|
1441
|
-
setCopiedIndex(i);
|
|
1442
|
-
setTimeout(() => setCopiedIndex(null), 2000);
|
|
1443
|
-
}}
|
|
1444
|
-
className="px-2 py-1 text-xs border border-border rounded hover:bg-surface shrink-0"
|
|
1445
|
-
>
|
|
1446
|
-
{copiedIndex === i ? "Copied!" : "Copy"}
|
|
1447
|
-
</button>
|
|
1448
|
-
</div>
|
|
1856
|
+
{/* Inline illustration upload for plot files (Preview tab only) */}
|
|
1857
|
+
{isPlot && !isCartoonPlot && activeTab === "preview" && (
|
|
1858
|
+
<div>
|
|
1859
|
+
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
|
1860
|
+
<input
|
|
1861
|
+
type="checkbox"
|
|
1862
|
+
checked={showIllustrations}
|
|
1863
|
+
onChange={(e) => setShowIllustrations(e.target.checked)}
|
|
1864
|
+
className="rounded border-border"
|
|
1865
|
+
/>
|
|
1866
|
+
Add illustrations in the plot
|
|
1867
|
+
</label>
|
|
1868
|
+
{showIllustrations && (
|
|
1869
|
+
<div className="mt-2 flex flex-col gap-2">
|
|
1870
|
+
<div
|
|
1871
|
+
className="border-2 border-dashed border-border rounded p-3 flex flex-col items-center gap-1.5 cursor-pointer hover:border-accent transition-colors"
|
|
1872
|
+
onClick={() => illustrationInputRef.current?.click()}
|
|
1873
|
+
onDragOver={(e) => {
|
|
1874
|
+
e.preventDefault();
|
|
1875
|
+
e.stopPropagation();
|
|
1876
|
+
}}
|
|
1877
|
+
onDrop={(e) => {
|
|
1878
|
+
e.preventDefault();
|
|
1879
|
+
e.stopPropagation();
|
|
1880
|
+
const file = e.dataTransfer.files?.[0];
|
|
1881
|
+
if (file) uploadIllustration(file);
|
|
1882
|
+
}}
|
|
1883
|
+
>
|
|
1884
|
+
<input
|
|
1885
|
+
ref={illustrationInputRef}
|
|
1886
|
+
type="file"
|
|
1887
|
+
accept="image/webp,image/jpeg"
|
|
1888
|
+
onChange={handleIllustrationInput}
|
|
1889
|
+
className="hidden"
|
|
1890
|
+
/>
|
|
1891
|
+
<span className="text-xs text-muted">
|
|
1892
|
+
{illustrationUploading
|
|
1893
|
+
? "Uploading..."
|
|
1894
|
+
: "Drop image here or click to browse"}
|
|
1895
|
+
</span>
|
|
1896
|
+
<span className="text-xs text-muted">
|
|
1897
|
+
WebP/JPEG, max 1MB
|
|
1898
|
+
</span>
|
|
1449
1899
|
</div>
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1900
|
+
{illustrationError && (
|
|
1901
|
+
<span className="text-error text-xs">
|
|
1902
|
+
{illustrationError}
|
|
1903
|
+
</span>
|
|
1904
|
+
)}
|
|
1905
|
+
{uploadedImages.map((img, i) => (
|
|
1906
|
+
<div
|
|
1907
|
+
key={img.cid}
|
|
1908
|
+
className="border border-border rounded p-2 flex flex-col gap-1 bg-surface"
|
|
1909
|
+
>
|
|
1910
|
+
<span className="text-xs text-green-700">
|
|
1911
|
+
Image uploaded! Copy the markdown below and paste it
|
|
1912
|
+
where you want the illustration to appear in your
|
|
1913
|
+
plot:
|
|
1914
|
+
</span>
|
|
1915
|
+
<div className="flex items-center gap-1.5">
|
|
1916
|
+
<code className="flex-1 text-xs bg-background px-2 py-1 rounded font-mono break-all">
|
|
1917
|
+

|
|
1918
|
+
</code>
|
|
1919
|
+
<button
|
|
1920
|
+
onClick={() => {
|
|
1921
|
+
navigator.clipboard.writeText(
|
|
1922
|
+
``,
|
|
1923
|
+
);
|
|
1924
|
+
setCopiedIndex(i);
|
|
1925
|
+
setTimeout(() => setCopiedIndex(null), 2000);
|
|
1926
|
+
}}
|
|
1927
|
+
className="px-2 py-1 text-xs border border-border rounded hover:bg-surface shrink-0"
|
|
1928
|
+
>
|
|
1929
|
+
{copiedIndex === i ? "Copied!" : "Copy"}
|
|
1930
|
+
</button>
|
|
1931
|
+
</div>
|
|
1932
|
+
</div>
|
|
1933
|
+
))}
|
|
1934
|
+
</div>
|
|
1935
|
+
)}
|
|
1936
|
+
</div>
|
|
1937
|
+
)}
|
|
1938
|
+
{/* Pre-publish cover picker (#284): a new genesis (esp. cartoon)
|
|
1456
1939
|
gets a cover before its first createStoryline. Reuses the same
|
|
1457
1940
|
validation/stale-clear as the published Edit Story panel; the
|
|
1458
1941
|
selected file is handed to the publish flow, which uploads it and
|
|
@@ -1461,254 +1944,386 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
1461
1944
|
cut/lettering editor gets the height — the cover stays available
|
|
1462
1945
|
in the Opening-text/Preview view, Story Info, and the Publish page,
|
|
1463
1946
|
and the auto-detect effect still loads it for publish. */}
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1947
|
+
{isGenesis &&
|
|
1948
|
+
contentType !== "cartoon" &&
|
|
1949
|
+
!(activeTab === "edit" && genesisEditMode === "cuts") && (
|
|
1950
|
+
<div
|
|
1951
|
+
className="flex flex-col gap-1.5"
|
|
1952
|
+
data-testid="prepublish-cover"
|
|
1953
|
+
>
|
|
1954
|
+
<span className="text-xs font-medium text-foreground">
|
|
1955
|
+
Cover Image{" "}
|
|
1956
|
+
<span className="text-muted font-normal">(optional)</span>
|
|
1957
|
+
</span>
|
|
1958
|
+
{/* Cartoon cover readiness + requirements (#337): keep the cover
|
|
1468
1959
|
step visible before genesis publish so a pilot story isn't
|
|
1469
1960
|
published coverless by accident. */}
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1961
|
+
{renderCoverStatus(false)}
|
|
1962
|
+
<div className="flex items-start gap-3">
|
|
1963
|
+
{coverPreview && (
|
|
1964
|
+
<div className="relative">
|
|
1965
|
+
<img
|
|
1966
|
+
src={coverPreview}
|
|
1967
|
+
alt="Cover preview"
|
|
1968
|
+
className="w-16 h-24 object-cover rounded border border-border"
|
|
1969
|
+
/>
|
|
1970
|
+
<button
|
|
1971
|
+
onClick={() => {
|
|
1972
|
+
coverUserTouchedRef.current = true;
|
|
1973
|
+
setDetectedCover(null);
|
|
1974
|
+
setDetectedCoverWarning(null);
|
|
1975
|
+
setCoverStatus("unknown");
|
|
1976
|
+
setCoverFile(null);
|
|
1977
|
+
setCoverPreview((prev) => {
|
|
1978
|
+
if (prev) URL.revokeObjectURL(prev);
|
|
1979
|
+
return null;
|
|
1980
|
+
});
|
|
1981
|
+
if (coverInputRef.current)
|
|
1982
|
+
coverInputRef.current.value = "";
|
|
1983
|
+
}}
|
|
1984
|
+
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-error text-white rounded-full text-xs flex items-center justify-center"
|
|
1985
|
+
>
|
|
1986
|
+
x
|
|
1987
|
+
</button>
|
|
1988
|
+
</div>
|
|
1989
|
+
)}
|
|
1990
|
+
<div className="flex flex-col gap-1">
|
|
1991
|
+
<input
|
|
1992
|
+
ref={coverInputRef}
|
|
1993
|
+
type="file"
|
|
1994
|
+
accept="image/webp,image/jpeg"
|
|
1995
|
+
onChange={handleCoverSelect}
|
|
1996
|
+
className="text-xs"
|
|
1997
|
+
data-testid="prepublish-cover-input"
|
|
1998
|
+
/>
|
|
1999
|
+
<span className="text-xs text-muted">
|
|
2000
|
+
WebP/JPEG, max 1MB, 600x900px recommended
|
|
2001
|
+
</span>
|
|
2002
|
+
{/* Codex-image import (#301): convert a generated PNG (or any
|
|
1498
2003
|
large image) to a compliant cover in-browser, save it as
|
|
1499
2004
|
assets/cover.webp, and load it as the selected cover —
|
|
1500
2005
|
no agent-side shell image tools. */}
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
2006
|
+
<input
|
|
2007
|
+
ref={coverImportInputRef}
|
|
2008
|
+
type="file"
|
|
2009
|
+
accept="image/png,image/webp,image/jpeg"
|
|
2010
|
+
onChange={handleCoverImport}
|
|
2011
|
+
className="hidden"
|
|
2012
|
+
data-testid="prepublish-cover-import-input"
|
|
2013
|
+
/>
|
|
2014
|
+
<button
|
|
2015
|
+
type="button"
|
|
2016
|
+
onClick={() => coverImportInputRef.current?.click()}
|
|
2017
|
+
disabled={coverImporting}
|
|
2018
|
+
className="self-start px-2 py-1 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
|
|
2019
|
+
data-testid="prepublish-cover-import"
|
|
2020
|
+
>
|
|
2021
|
+
{coverImporting
|
|
2022
|
+
? "Importing…"
|
|
2023
|
+
: "Import generated image (PNG ok)"}
|
|
2024
|
+
</button>
|
|
2025
|
+
{/* #312: make the generated-cover → PlotLink-cover connection
|
|
1519
2026
|
explicit. Whenever a cover is selected (auto-detected,
|
|
1520
2027
|
imported, or manually picked) it WILL be uploaded as the
|
|
1521
2028
|
storyline cover at publish; an invalid or missing generated
|
|
1522
2029
|
cover gets a clear action. */}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
2030
|
+
{coverFile && (
|
|
2031
|
+
<span
|
|
2032
|
+
className="text-green-700 text-xs"
|
|
2033
|
+
data-testid="prepublish-cover-will-upload"
|
|
2034
|
+
>
|
|
2035
|
+
This cover will be uploaded as the PlotLink
|
|
2036
|
+
storyline cover when you publish.
|
|
2037
|
+
</span>
|
|
2038
|
+
)}
|
|
2039
|
+
{detectedCover && (
|
|
2040
|
+
<span
|
|
2041
|
+
className="text-accent text-xs"
|
|
2042
|
+
data-testid="prepublish-cover-detected"
|
|
2043
|
+
>
|
|
2044
|
+
Auto-detected generated cover {detectedCover} — pick
|
|
2045
|
+
a file to override.
|
|
2046
|
+
</span>
|
|
2047
|
+
)}
|
|
2048
|
+
{detectedCoverWarning && (
|
|
2049
|
+
<span
|
|
2050
|
+
className="text-amber-700 text-xs"
|
|
2051
|
+
data-testid="prepublish-cover-detected-warning"
|
|
2052
|
+
>
|
|
2053
|
+
{detectedCoverWarning} Use “Import generated
|
|
2054
|
+
image” below to convert/compress it, or pick a
|
|
2055
|
+
file.
|
|
2056
|
+
</span>
|
|
2057
|
+
)}
|
|
2058
|
+
{contentType === "cartoon" &&
|
|
2059
|
+
coverStatus === "none" &&
|
|
2060
|
+
!coverFile && (
|
|
2061
|
+
<span
|
|
2062
|
+
className="text-muted text-xs"
|
|
2063
|
+
data-testid="prepublish-cover-none"
|
|
2064
|
+
>
|
|
2065
|
+
No generated cover detected. Create{" "}
|
|
2066
|
+
<span className="font-mono">
|
|
2067
|
+
assets/cover.webp
|
|
2068
|
+
</span>{" "}
|
|
2069
|
+
or use “Import generated image” — it
|
|
2070
|
+
will be uploaded as the PlotLink storyline cover
|
|
2071
|
+
when you publish.
|
|
2072
|
+
</span>
|
|
2073
|
+
)}
|
|
2074
|
+
{editError && (
|
|
2075
|
+
<span
|
|
2076
|
+
className="text-error text-xs"
|
|
2077
|
+
data-testid="prepublish-cover-error"
|
|
2078
|
+
>
|
|
2079
|
+
{editError}
|
|
2080
|
+
</span>
|
|
2081
|
+
)}
|
|
2082
|
+
</div>
|
|
2083
|
+
</div>
|
|
1544
2084
|
</div>
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
)}
|
|
1548
|
-
{/* Public title shown + validated before publish (#358). #461: moved
|
|
2085
|
+
)}
|
|
2086
|
+
{/* Public title shown + validated before publish (#358). #461: moved
|
|
1549
2087
|
to the Publish tab for cartoon — fiction keeps it inline. */}
|
|
1550
|
-
|
|
1551
|
-
|
|
2088
|
+
{!isCartoonEpisode && renderPublishTitle()}
|
|
2089
|
+
{/* Cartoon Genesis prologue readiness checklist (#359). #461: moved to
|
|
1552
2090
|
the Publish tab; never rendered in the cartoon episode view. */}
|
|
1553
|
-
|
|
1554
|
-
|
|
2091
|
+
{!isCartoonEpisode && renderGenesisReadiness()}
|
|
2092
|
+
{/* #461: the genre/language selects + publish button + publish-disabled
|
|
1555
2093
|
reasons are publish controls — for cartoon they live on the Publish
|
|
1556
2094
|
tab. Fiction keeps the inline publish controls unchanged. */}
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
2095
|
+
{!isCartoonEpisode && (
|
|
2096
|
+
<div className="flex items-center gap-2">
|
|
2097
|
+
{/* Genre/language are edited in Story Info for cartoon (#439/#450);
|
|
1560
2098
|
the inline selects are fiction-only so the cartoon cut workspace
|
|
1561
2099
|
isn't a metadata form. Cartoon publish still reads the persisted
|
|
1562
2100
|
genre/language (seeded into these values from .story.json). */}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
2101
|
+
{isGenesis && contentType !== "cartoon" && (
|
|
2102
|
+
<>
|
|
2103
|
+
<select
|
|
2104
|
+
value={selectedGenre}
|
|
2105
|
+
data-testid="publish-genre-select"
|
|
2106
|
+
onChange={(e) => {
|
|
2107
|
+
setSelectedGenre(e.target.value);
|
|
2108
|
+
if (e.target.value)
|
|
2109
|
+
persistPublishMeta({ genre: e.target.value });
|
|
2110
|
+
}}
|
|
2111
|
+
className={`px-2 py-1.5 text-xs border rounded bg-surface text-foreground ${selectedGenre ? "border-border" : "border-amber-500"}`}
|
|
2112
|
+
>
|
|
2113
|
+
{/* Explicit unset state — no silent Romance default (#424). */}
|
|
2114
|
+
{!selectedGenre && (
|
|
2115
|
+
<option value="" disabled>
|
|
2116
|
+
Needs metadata — select genre
|
|
2117
|
+
</option>
|
|
2118
|
+
)}
|
|
2119
|
+
{GENRES.map((g) => (
|
|
2120
|
+
<option key={g} value={g}>
|
|
2121
|
+
{g}
|
|
2122
|
+
</option>
|
|
2123
|
+
))}
|
|
2124
|
+
</select>
|
|
2125
|
+
<select
|
|
2126
|
+
value={selectedLanguage}
|
|
2127
|
+
data-testid="publish-language-select"
|
|
2128
|
+
onChange={(e) => {
|
|
2129
|
+
setSelectedLanguage(e.target.value);
|
|
2130
|
+
if (e.target.value)
|
|
2131
|
+
persistPublishMeta({ language: e.target.value });
|
|
2132
|
+
}}
|
|
2133
|
+
className={`px-2 py-1.5 text-xs border rounded bg-surface text-foreground ${selectedLanguage ? "border-border" : "border-amber-500"}`}
|
|
2134
|
+
>
|
|
2135
|
+
{/* Explicit unset state — no silent English default (#424). */}
|
|
2136
|
+
{!selectedLanguage && (
|
|
2137
|
+
<option value="" disabled>
|
|
2138
|
+
Needs metadata — select language
|
|
2139
|
+
</option>
|
|
2140
|
+
)}
|
|
2141
|
+
{LANGUAGES.map((l) => (
|
|
2142
|
+
<option key={l} value={l}>
|
|
2143
|
+
{l}
|
|
2144
|
+
</option>
|
|
2145
|
+
))}
|
|
2146
|
+
</select>
|
|
2147
|
+
</>
|
|
2148
|
+
)}
|
|
2149
|
+
<button
|
|
2150
|
+
onClick={async () => {
|
|
2151
|
+
if (!storyName || !fileName) return;
|
|
2152
|
+
if (imageValidation.count > 0) {
|
|
2153
|
+
const msg = `This plot contains ${imageValidation.count} illustration(s). Content is immutable after publishing — image references cannot be changed or removed.\n\nPlease verify illustrations appear correctly in Preview before continuing.\n\nPublish now?`;
|
|
2154
|
+
if (!window.confirm(msg)) return;
|
|
2155
|
+
}
|
|
2156
|
+
// Genesis carries the optional pre-publish cover (#284); plot
|
|
2157
|
+
// files never do. Only pass the 6th arg when a cover is
|
|
2158
|
+
// actually selected, so the no-cover call signature (and
|
|
2159
|
+
// existing fiction/plot publish behavior) is unchanged.
|
|
2160
|
+
// The cover may be a manual pick OR an auto-detected
|
|
2161
|
+
// assets/cover.webp loaded into coverFile (#296) — both flow
|
|
2162
|
+
// through the same attach path.
|
|
2163
|
+
const cover = isGenesis ? coverFile : null;
|
|
2164
|
+
if (cover) {
|
|
2165
|
+
// Drop the local cover selection ONLY on a confirmed-successful
|
|
2166
|
+
// publish (onPublish resolves truthy). A publish blocked before
|
|
2167
|
+
// the stream (#375) or one that opens then fails before `done`
|
|
2168
|
+
// (#376) resolves falsy, so the writer's selected/auto-detected
|
|
2169
|
+
// cover stays put for the retry.
|
|
2170
|
+
const published = await onPublish?.(
|
|
2171
|
+
storyName,
|
|
2172
|
+
fileName,
|
|
2173
|
+
selectedGenre,
|
|
2174
|
+
selectedLanguage,
|
|
2175
|
+
isNsfw,
|
|
2176
|
+
cover,
|
|
2177
|
+
);
|
|
2178
|
+
if (published) {
|
|
2179
|
+
coverUserTouchedRef.current = true;
|
|
2180
|
+
setDetectedCover(null);
|
|
2181
|
+
setDetectedCoverWarning(null);
|
|
2182
|
+
setCoverStatus("unknown");
|
|
2183
|
+
setCoverFile(null);
|
|
2184
|
+
setCoverPreview((prev) => {
|
|
2185
|
+
if (prev) URL.revokeObjectURL(prev);
|
|
2186
|
+
return null;
|
|
2187
|
+
});
|
|
2188
|
+
if (coverInputRef.current)
|
|
2189
|
+
coverInputRef.current.value = "";
|
|
2190
|
+
}
|
|
2191
|
+
} else {
|
|
2192
|
+
onPublish?.(
|
|
2193
|
+
storyName,
|
|
2194
|
+
fileName,
|
|
2195
|
+
selectedGenre,
|
|
2196
|
+
selectedLanguage,
|
|
2197
|
+
isNsfw,
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
1586
2200
|
}}
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
</select>
|
|
1595
|
-
</>
|
|
1596
|
-
)}
|
|
1597
|
-
<button
|
|
1598
|
-
onClick={async () => {
|
|
1599
|
-
if (!storyName || !fileName) return;
|
|
1600
|
-
if (imageValidation.count > 0) {
|
|
1601
|
-
const msg = `This plot contains ${imageValidation.count} illustration(s). Content is immutable after publishing — image references cannot be changed or removed.\n\nPlease verify illustrations appear correctly in Preview before continuing.\n\nPublish now?`;
|
|
1602
|
-
if (!window.confirm(msg)) return;
|
|
1603
|
-
}
|
|
1604
|
-
// Genesis carries the optional pre-publish cover (#284); plot
|
|
1605
|
-
// files never do. Only pass the 6th arg when a cover is
|
|
1606
|
-
// actually selected, so the no-cover call signature (and
|
|
1607
|
-
// existing fiction/plot publish behavior) is unchanged.
|
|
1608
|
-
// The cover may be a manual pick OR an auto-detected
|
|
1609
|
-
// assets/cover.webp loaded into coverFile (#296) — both flow
|
|
1610
|
-
// through the same attach path.
|
|
1611
|
-
const cover = isGenesis ? coverFile : null;
|
|
1612
|
-
if (cover) {
|
|
1613
|
-
// Drop the local cover selection ONLY on a confirmed-successful
|
|
1614
|
-
// publish (onPublish resolves truthy). A publish blocked before
|
|
1615
|
-
// the stream (#375) or one that opens then fails before `done`
|
|
1616
|
-
// (#376) resolves falsy, so the writer's selected/auto-detected
|
|
1617
|
-
// cover stays put for the retry.
|
|
1618
|
-
const published = await onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw, cover);
|
|
1619
|
-
if (published) {
|
|
1620
|
-
coverUserTouchedRef.current = true;
|
|
1621
|
-
setDetectedCover(null);
|
|
1622
|
-
setDetectedCoverWarning(null);
|
|
1623
|
-
setCoverStatus("unknown");
|
|
1624
|
-
setCoverFile(null);
|
|
1625
|
-
setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
|
|
1626
|
-
if (coverInputRef.current) coverInputRef.current.value = "";
|
|
2201
|
+
disabled={
|
|
2202
|
+
!!publishingFile ||
|
|
2203
|
+
overLimit ||
|
|
2204
|
+
titleBlocked ||
|
|
2205
|
+
genesisBlocked ||
|
|
2206
|
+
(isGenesis && (!selectedGenre || !selectedLanguage)) ||
|
|
2207
|
+
(isCartoonPlot && cartoonStage !== "ready")
|
|
1627
2208
|
}
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
{publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
|
|
1636
|
-
</button>
|
|
1637
|
-
{/* Cartoon edits these in Story Info, so point there instead of the
|
|
2209
|
+
className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
|
|
2210
|
+
>
|
|
2211
|
+
{publishingFile === fileName
|
|
2212
|
+
? "Publishing..."
|
|
2213
|
+
: "Publish to PlotLink"}
|
|
2214
|
+
</button>
|
|
2215
|
+
{/* Cartoon edits these in Story Info, so point there instead of the
|
|
1638
2216
|
hidden inline selects (#450); fiction keeps the inline guidance. */}
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
2217
|
+
{isGenesis &&
|
|
2218
|
+
contentType === "cartoon" &&
|
|
2219
|
+
(!selectedGenre || !selectedLanguage) && (
|
|
2220
|
+
<span
|
|
2221
|
+
className="text-amber-600 text-xs"
|
|
2222
|
+
data-testid="cartoon-metadata-needs-story-info"
|
|
2223
|
+
>
|
|
2224
|
+
Set the genre and language in Story Info before
|
|
2225
|
+
publishing
|
|
2226
|
+
</span>
|
|
2227
|
+
)}
|
|
2228
|
+
{isGenesis && contentType !== "cartoon" && !selectedGenre && (
|
|
2229
|
+
<span
|
|
2230
|
+
className="text-amber-600 text-xs"
|
|
2231
|
+
data-testid="genre-needs-metadata"
|
|
2232
|
+
>
|
|
2233
|
+
Needs metadata — choose a genre before publishing
|
|
2234
|
+
</span>
|
|
2235
|
+
)}
|
|
2236
|
+
{isGenesis &&
|
|
2237
|
+
contentType !== "cartoon" &&
|
|
2238
|
+
selectedGenre &&
|
|
2239
|
+
!selectedLanguage && (
|
|
2240
|
+
<span
|
|
2241
|
+
className="text-amber-600 text-xs"
|
|
2242
|
+
data-testid="language-needs-metadata"
|
|
2243
|
+
>
|
|
2244
|
+
Needs metadata — choose a language before publishing
|
|
2245
|
+
</span>
|
|
2246
|
+
)}
|
|
2247
|
+
{overLimit && (
|
|
2248
|
+
<span className="text-error text-xs">
|
|
2249
|
+
Reduce content to publish
|
|
2250
|
+
</span>
|
|
2251
|
+
)}
|
|
2252
|
+
{isCartoonPlot && cartoonStage === "error" && (
|
|
2253
|
+
<span
|
|
2254
|
+
className="text-error text-xs"
|
|
2255
|
+
data-testid="publish-disabled-reason"
|
|
2256
|
+
>
|
|
2257
|
+
Fix the issues below before publishing
|
|
2258
|
+
</span>
|
|
2259
|
+
)}
|
|
2260
|
+
{isCartoonPlot && cartoonStage === "planning" && (
|
|
2261
|
+
<span
|
|
2262
|
+
className="text-muted text-xs"
|
|
2263
|
+
data-testid="publish-disabled-reason"
|
|
2264
|
+
>
|
|
2265
|
+
Prepare the episode for publish to continue
|
|
2266
|
+
</span>
|
|
2267
|
+
)}
|
|
2268
|
+
{isCartoonPlot && cartoonStage === "awaiting-upload" && (
|
|
2269
|
+
<span
|
|
2270
|
+
className="text-muted text-xs"
|
|
2271
|
+
data-testid="publish-disabled-reason"
|
|
2272
|
+
>
|
|
2273
|
+
Upload all final images, then “Prepare episode for
|
|
2274
|
+
publish” — {cartoonAwaitingCount} of {cartoonTotalCuts}{" "}
|
|
2275
|
+
still need an uploaded image
|
|
2276
|
+
</span>
|
|
2277
|
+
)}
|
|
2278
|
+
</div>
|
|
1656
2279
|
)}
|
|
1657
|
-
{
|
|
1658
|
-
|
|
2280
|
+
{/* #461: the grouped publish-readiness issues card (#360/#421) is a
|
|
2281
|
+
publish checklist — it now renders on the Publish tab. The cartoon
|
|
2282
|
+
episode view links there via the compact CTA below. */}
|
|
2283
|
+
{isCartoonEpisode && (
|
|
2284
|
+
<button
|
|
2285
|
+
onClick={() => onViewPublish?.()}
|
|
2286
|
+
data-testid="cartoon-review-publish"
|
|
2287
|
+
className="self-start rounded border border-accent/40 px-3 py-1 text-xs text-accent hover:bg-accent/5 transition-colors"
|
|
2288
|
+
>
|
|
2289
|
+
Review publish checklist →
|
|
2290
|
+
</button>
|
|
1659
2291
|
)}
|
|
1660
|
-
{
|
|
1661
|
-
<
|
|
2292
|
+
{imageValidation.warnings.length > 0 && (
|
|
2293
|
+
<div className="flex flex-col gap-0.5">
|
|
2294
|
+
{imageValidation.warnings.map((w, i) => (
|
|
2295
|
+
<span key={i} className="text-amber-600 text-xs">
|
|
2296
|
+
{w}
|
|
2297
|
+
</span>
|
|
2298
|
+
))}
|
|
2299
|
+
</div>
|
|
1662
2300
|
)}
|
|
1663
|
-
{
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
2301
|
+
{/* Adult-content flag is edited in Story Info for cartoon (#450). */}
|
|
2302
|
+
{isGenesis && contentType !== "cartoon" && (
|
|
2303
|
+
<div className="flex items-center gap-2">
|
|
2304
|
+
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
|
2305
|
+
<input
|
|
2306
|
+
type="checkbox"
|
|
2307
|
+
checked={isNsfw}
|
|
2308
|
+
onChange={(e) => {
|
|
2309
|
+
setIsNsfw(e.target.checked);
|
|
2310
|
+
persistPublishMeta({ isNsfw: e.target.checked });
|
|
2311
|
+
}}
|
|
2312
|
+
className="rounded border-border"
|
|
2313
|
+
/>
|
|
2314
|
+
This story contains adult content (18+)
|
|
2315
|
+
</label>
|
|
2316
|
+
{isNsfw && (
|
|
2317
|
+
<span className="text-xs text-amber-600">
|
|
2318
|
+
Adult content will be hidden from the default browse view.
|
|
2319
|
+
</span>
|
|
2320
|
+
)}
|
|
2321
|
+
</div>
|
|
1667
2322
|
)}
|
|
1668
2323
|
</div>
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
episode view links there via the compact CTA below. */}
|
|
1673
|
-
{isCartoonEpisode && (
|
|
1674
|
-
<button
|
|
1675
|
-
onClick={() => onViewPublish?.()}
|
|
1676
|
-
data-testid="cartoon-review-publish"
|
|
1677
|
-
className="self-start rounded border border-accent/40 px-3 py-1 text-xs text-accent hover:bg-accent/5 transition-colors"
|
|
1678
|
-
>
|
|
1679
|
-
Review publish checklist →
|
|
1680
|
-
</button>
|
|
1681
|
-
)}
|
|
1682
|
-
{imageValidation.warnings.length > 0 && (
|
|
1683
|
-
<div className="flex flex-col gap-0.5">
|
|
1684
|
-
{imageValidation.warnings.map((w, i) => (
|
|
1685
|
-
<span key={i} className="text-amber-600 text-xs">{w}</span>
|
|
1686
|
-
))}
|
|
1687
|
-
</div>
|
|
1688
|
-
)}
|
|
1689
|
-
{/* Adult-content flag is edited in Story Info for cartoon (#450). */}
|
|
1690
|
-
{isGenesis && contentType !== "cartoon" && (
|
|
1691
|
-
<div className="flex items-center gap-2">
|
|
1692
|
-
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
|
1693
|
-
<input
|
|
1694
|
-
type="checkbox"
|
|
1695
|
-
checked={isNsfw}
|
|
1696
|
-
onChange={(e) => {
|
|
1697
|
-
setIsNsfw(e.target.checked);
|
|
1698
|
-
persistPublishMeta({ isNsfw: e.target.checked });
|
|
1699
|
-
}}
|
|
1700
|
-
className="rounded border-border"
|
|
1701
|
-
/>
|
|
1702
|
-
This story contains adult content (18+)
|
|
1703
|
-
</label>
|
|
1704
|
-
{isNsfw && (
|
|
1705
|
-
<span className="text-xs text-amber-600">Adult content will be hidden from the default browse view.</span>
|
|
1706
|
-
)}
|
|
1707
|
-
</div>
|
|
1708
|
-
)}
|
|
1709
|
-
</div>
|
|
1710
|
-
)}
|
|
1711
|
-
</div>
|
|
2324
|
+
)}
|
|
2325
|
+
</div>
|
|
2326
|
+
)}
|
|
1712
2327
|
</div>
|
|
1713
2328
|
);
|
|
1714
2329
|
}
|