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

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

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