plotlink-ows 1.2.94 → 1.2.96

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