plotlink-ows 1.0.32 → 1.2.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +811 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +10 -3
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +243 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/publish.ts +209 -28
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1299 -0
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1141 -0
- package/app/web/components/PreviewPanel.tsx +1017 -144
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +710 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +516 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WorkflowCoach.tsx +128 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
- package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
- package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-BFw-v-OZ.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/scripts/e2e-verify.ts +0 -1100
|
@@ -3,7 +3,15 @@ import ReactMarkdown from "react-markdown";
|
|
|
3
3
|
import remarkBreaks from "remark-breaks";
|
|
4
4
|
import remarkGfm from "remark-gfm";
|
|
5
5
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
6
|
-
import { GENRES, LANGUAGES } from "../../../lib/genres";
|
|
6
|
+
import { GENRES, LANGUAGES, canonicalizeGenre } from "../../../lib/genres";
|
|
7
|
+
import { CartoonPreview } from "./CartoonPreview";
|
|
8
|
+
import { CartoonPublishPreview } from "./CartoonPublishPreview";
|
|
9
|
+
import { CutListPanel } from "./CutListPanel";
|
|
10
|
+
import { WorkflowCoach } from "./WorkflowCoach";
|
|
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";
|
|
14
|
+
import { importImageToCompliantBlob } from "../lib/import-image";
|
|
7
15
|
|
|
8
16
|
/** Custom sanitizer matching plotlink.xyz — allows img with src, alt, title */
|
|
9
17
|
const sanitizeSchema = {
|
|
@@ -48,9 +56,32 @@ interface PreviewPanelProps {
|
|
|
48
56
|
storyName: string | null;
|
|
49
57
|
fileName: string | null;
|
|
50
58
|
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
51
|
-
|
|
59
|
+
// Resolves to true only on a confirmed-successful publish (so a selected cover
|
|
60
|
+
// may be dropped); false/void when blocked before the stream (#375) or when the
|
|
61
|
+
// 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>;
|
|
52
63
|
publishingFile?: string | null;
|
|
53
64
|
walletAddress?: string | null;
|
|
65
|
+
contentType?: "fiction" | "cartoon";
|
|
66
|
+
// Publish metadata resolved from .story.json (#424) so the controls seed the
|
|
67
|
+
// story's real values instead of the first-in-list defaults (Romance/English).
|
|
68
|
+
// `language` is the server-resolved language; `genre` is the raw stored label
|
|
69
|
+
// (canonicalized here). Absent ⇒ not set → controls show "Needs metadata".
|
|
70
|
+
language?: string;
|
|
71
|
+
genre?: string;
|
|
72
|
+
isNsfw?: boolean;
|
|
73
|
+
// Whether the story has a genesis.md, so the outline (structure.md) footer can
|
|
74
|
+
// tell "write the Genesis next" from "Genesis already exists, review it" (#422).
|
|
75
|
+
hasGenesis?: boolean;
|
|
76
|
+
// Deselect the file to reveal the story-level progress overview (#418).
|
|
77
|
+
onViewProgress?: () => void;
|
|
78
|
+
// Open a sibling file in this story, so the workflow coach (#429) can route to
|
|
79
|
+
// the episode an action concerns (e.g. from structure.md to the active episode).
|
|
80
|
+
onOpenFile?: (file: string) => void;
|
|
81
|
+
// Navigate to the cartoon Publish tab (#461), where publish readiness/title/
|
|
82
|
+
// issue diagnostics + the publish action now live. Cartoon episode views show a
|
|
83
|
+
// single compact CTA that calls this instead of hosting publish controls.
|
|
84
|
+
onViewPublish?: () => void;
|
|
54
85
|
}
|
|
55
86
|
|
|
56
87
|
interface FileData {
|
|
@@ -67,18 +98,59 @@ interface FileData {
|
|
|
67
98
|
|
|
68
99
|
type Tab = "preview" | "edit";
|
|
69
100
|
|
|
70
|
-
export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publishingFile, walletAddress }: PreviewPanelProps) {
|
|
101
|
+
export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publishingFile, walletAddress, contentType = "fiction", language, genre: genreMeta, isNsfw: nsfwMeta, hasGenesis = false, onViewProgress, onOpenFile, onViewPublish }: PreviewPanelProps) {
|
|
71
102
|
const [fileData, setFileData] = useState<FileData | null>(null);
|
|
72
103
|
const [loading, setLoading] = useState(false);
|
|
73
104
|
const [activeTab, setActiveTab] = useState<Tab>("preview");
|
|
105
|
+
// Cartoon preview sub-mode: "publish" = exact PlotLink-bound markdown;
|
|
106
|
+
// "inspect" = cuts.json planning inspector. Kept distinct so planning prose
|
|
107
|
+
// does not masquerade as publish content (#289).
|
|
108
|
+
const [cartoonPreviewMode, setCartoonPreviewMode] = useState<"publish" | "inspect">("publish");
|
|
109
|
+
// Cartoon Genesis is a hybrid (a prose opening + its own genesis.cuts.json image
|
|
110
|
+
// cuts), so its Edit tab offers two sub-views: the opening-text editor and the
|
|
111
|
+
// cut workspace (#429). Plots use the cut workspace directly; fiction never sees
|
|
112
|
+
// this. Defaults to "text" so opening Edit on Genesis is unchanged; the workflow
|
|
113
|
+
// coach's cut actions switch it to "cuts" so lettering/upload/refresh land on a
|
|
114
|
+
// real, actionable workspace instead of the markdown editor.
|
|
115
|
+
const [genesisEditMode, setGenesisEditMode] = useState<"text" | "cuts">("text");
|
|
116
|
+
// #371: a deep-link request from the Cut Inspector's per-cut CTA into the Edit
|
|
117
|
+
// tab for that exact cut. `seq` makes repeated clicks (even on the same cut)
|
|
118
|
+
// re-trigger the focus/expand effect in CutListPanel; it is cleared once
|
|
119
|
+
// 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);
|
|
121
|
+
const handleEditCut = useCallback((cutId: number, openEditor: boolean) => {
|
|
122
|
+
setActiveTab("edit");
|
|
123
|
+
setCutFocus((prev) => ({ cutId, openEditor, seq: (prev?.seq ?? 0) + 1 }));
|
|
124
|
+
}, []);
|
|
74
125
|
const [editContent, setEditContent] = useState("");
|
|
75
126
|
const [saving, setSaving] = useState(false);
|
|
76
127
|
const [dirty, setDirty] = useState(false);
|
|
77
128
|
const [retrying, setRetrying] = useState(false);
|
|
78
129
|
const [indexTimeLeft, setIndexTimeLeft] = useState<number | null>(null);
|
|
79
|
-
|
|
80
|
-
|
|
130
|
+
// "" ⇒ unset ⇒ "Needs metadata" (no misleading Romance/English default).
|
|
131
|
+
// Seeded from .story.json props / structure.md in the seeding effect (#424).
|
|
132
|
+
const [selectedGenre, setSelectedGenre] = useState("");
|
|
133
|
+
const [selectedLanguage, setSelectedLanguage] = useState("");
|
|
81
134
|
const [isNsfw, setIsNsfw] = useState(false);
|
|
135
|
+
// Coarse readiness stage for the selected cartoon plot — still drives the
|
|
136
|
+
// state-aware footer cut-count (#422) and the workflow coach refresh. The
|
|
137
|
+
// publish-issue list + 6-step checklist moved to the Publish tab (#461), so
|
|
138
|
+
// they're no longer kept here.
|
|
139
|
+
const [cartoonStage, setCartoonStage] = useState<CartoonStage | null>(null);
|
|
140
|
+
const [cartoonAwaitingCount, setCartoonAwaitingCount] = useState(0);
|
|
141
|
+
const [cartoonTotalCuts, setCartoonTotalCuts] = useState(0);
|
|
142
|
+
// Per-cut production tallies (clean/lettered/exported/uploaded) for the compact
|
|
143
|
+
// cartoon status summary in the bottom panel (#420).
|
|
144
|
+
const [cartoonCutProgress, setCartoonCutProgress] = useState<CartoonCutProgress | null>(null);
|
|
145
|
+
// Inputs for resolving + showing the public publish title before publish (#358):
|
|
146
|
+
// the story structure.md content (genesis story title) and the cut plan's
|
|
147
|
+
// episode title (cartoon plot).
|
|
148
|
+
const [structureContent, setStructureContent] = useState<string | null>(null);
|
|
149
|
+
const [cartoonEpisodeTitle, setCartoonEpisodeTitle] = useState<string | null>(null);
|
|
150
|
+
// Bumped whenever the embedded cut editor mutates the cut plan (export/upload/
|
|
151
|
+
// save), so the readiness effect re-fetches and the Episode-steps panel stays
|
|
152
|
+
// in sync with the cut cards after a lettering export (#343).
|
|
153
|
+
const [cutsRefreshKey, setCutsRefreshKey] = useState(0);
|
|
82
154
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
83
155
|
const dirtyRef = useRef(false);
|
|
84
156
|
|
|
@@ -91,9 +163,26 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
91
163
|
const [coverPreview, setCoverPreview] = useState<string | null>(null);
|
|
92
164
|
const [editSaving, setEditSaving] = useState(false);
|
|
93
165
|
const [editMetaLoaded, setEditMetaLoaded] = useState(false);
|
|
166
|
+
// Whether the published storyline already has a cover attached (#337), read
|
|
167
|
+
// from its metadata so the cover step can show an "attached" status.
|
|
168
|
+
const [editHasCover, setEditHasCover] = useState(false);
|
|
94
169
|
const [editError, setEditError] = useState<string | null>(null);
|
|
95
170
|
const [editSuccess, setEditSuccess] = useState(false);
|
|
96
171
|
const coverInputRef = useRef<HTMLInputElement>(null);
|
|
172
|
+
const coverImportInputRef = useRef<HTMLInputElement>(null);
|
|
173
|
+
const [coverImporting, setCoverImporting] = useState(false);
|
|
174
|
+
// Auto-detected agent-created cover (assets/cover.webp|jpg) for genesis (#296).
|
|
175
|
+
// detectedCover = the path actually loaded into the cover selection (status
|
|
176
|
+
// label); detectedCoverWarning = an invalid/oversize detected asset we won't use.
|
|
177
|
+
const [detectedCover, setDetectedCover] = useState<string | null>(null);
|
|
178
|
+
const [detectedCoverWarning, setDetectedCoverWarning] = useState<string | null>(null);
|
|
179
|
+
// Outcome of the generated-cover detection for an unpublished genesis (#312),
|
|
180
|
+
// so the publish flow can state explicitly whether a generated assets/cover.webp
|
|
181
|
+
// will be uploaded as the PlotLink cover, is invalid, or is missing.
|
|
182
|
+
const [coverStatus, setCoverStatus] = useState<"unknown" | "detected" | "selected" | "invalid" | "none">("unknown");
|
|
183
|
+
// Once the writer manually picks or removes a cover, stop auto-applying the
|
|
184
|
+
// detected one (so removal/override sticks and detection doesn't loop).
|
|
185
|
+
const coverUserTouchedRef = useRef(false);
|
|
97
186
|
|
|
98
187
|
// Inline illustration state
|
|
99
188
|
const [showIllustrations, setShowIllustrations] = useState(false);
|
|
@@ -128,7 +217,6 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
128
217
|
|
|
129
218
|
// Initial load
|
|
130
219
|
useEffect(() => {
|
|
131
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch on mount
|
|
132
220
|
setLoading(true);
|
|
133
221
|
loadFile().finally(() => setLoading(false));
|
|
134
222
|
}, [loadFile]);
|
|
@@ -141,31 +229,128 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
141
229
|
return () => clearInterval(interval);
|
|
142
230
|
}, [storyName, fileName, loadFile, activeTab, dirty]);
|
|
143
231
|
|
|
144
|
-
//
|
|
232
|
+
// Genesis-as-Episode-1 cut progress (#422): discover + summarize
|
|
233
|
+
// genesis.cuts.json so the Genesis view reflects its real cut/image state and
|
|
234
|
+
// 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";
|
|
145
237
|
useEffect(() => {
|
|
146
|
-
if (!storyName) return;
|
|
238
|
+
if (!cartoonGenesisForCuts || !storyName) { setGenesisCutProgress(null); return; }
|
|
147
239
|
let cancelled = false;
|
|
148
|
-
authFetch(`/api/stories/${storyName}/
|
|
149
|
-
.then((res) => res.ok ? res.json() : null)
|
|
150
|
-
.then((data) => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
240
|
+
authFetch(`/api/stories/${storyName}/cuts/genesis`)
|
|
241
|
+
.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; };
|
|
245
|
+
}, [cartoonGenesisForCuts, storyName, authFetch]);
|
|
246
|
+
|
|
247
|
+
// Compute cartoon publish readiness for cartoon plot files
|
|
248
|
+
const cartoonPlotForReadiness = contentType === "cartoon" && !!fileName && /^plot-\d+\.md$/.test(fileName);
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!cartoonPlotForReadiness || !storyName || !fileName) {
|
|
251
|
+
setCartoonStage(null);
|
|
252
|
+
setCartoonAwaitingCount(0);
|
|
253
|
+
setCartoonTotalCuts(0);
|
|
254
|
+
setCartoonCutProgress(null);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
let cancelled = false;
|
|
258
|
+
const plotFile = fileName.replace(/\.md$/, "");
|
|
259
|
+
// Clear the prior plot's cut tallies while a new plot loads so a stale
|
|
260
|
+
// summary can never sit beside another episode's state (#420 / @re1).
|
|
261
|
+
setCartoonCutProgress(null);
|
|
262
|
+
(async () => {
|
|
263
|
+
try {
|
|
264
|
+
const [fileRes, cutsRes] = await Promise.all([
|
|
265
|
+
authFetch(`/api/stories/${storyName}/${fileName}`),
|
|
266
|
+
authFetch(`/api/stories/${storyName}/cuts/${plotFile}`),
|
|
267
|
+
]);
|
|
268
|
+
if (cancelled) return;
|
|
269
|
+
if (!cutsRes.ok) {
|
|
270
|
+
setCartoonStage("error");
|
|
271
|
+
setCartoonAwaitingCount(0);
|
|
272
|
+
setCartoonTotalCuts(0);
|
|
273
|
+
setCartoonCutProgress(null);
|
|
274
|
+
return;
|
|
157
275
|
}
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
276
|
+
const cutsData = await cutsRes.json();
|
|
277
|
+
const cuts = cutsData.cuts || [];
|
|
278
|
+
const content = fileRes.ok ? (await fileRes.json()).content ?? "" : "";
|
|
279
|
+
const result = classifyCartoonReadiness(content, cuts);
|
|
280
|
+
if (!cancelled) {
|
|
281
|
+
setCartoonStage(result.stage);
|
|
282
|
+
setCartoonAwaitingCount(result.awaitingCount);
|
|
283
|
+
setCartoonTotalCuts(result.totalCuts);
|
|
284
|
+
setCartoonCutProgress(summarizeCutProgress(cuts));
|
|
285
|
+
// Cut plan's episode title for the publish-title display (#358).
|
|
286
|
+
setCartoonEpisodeTitle(typeof cutsData.title === "string" ? cutsData.title : null);
|
|
163
287
|
}
|
|
164
|
-
}
|
|
288
|
+
} catch {
|
|
289
|
+
if (!cancelled) {
|
|
290
|
+
setCartoonStage("error");
|
|
291
|
+
setCartoonAwaitingCount(0);
|
|
292
|
+
setCartoonTotalCuts(0);
|
|
293
|
+
setCartoonCutProgress(null);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
})();
|
|
297
|
+
return () => { cancelled = true; };
|
|
298
|
+
}, [cartoonPlotForReadiness, storyName, fileName, authFetch, fileData?.content, fileData?.status, cutsRefreshKey]);
|
|
299
|
+
|
|
300
|
+
// Load structure.md once per story — used to resolve the public title before
|
|
301
|
+
// publish (#358) and as a metadata fallback when .story.json lacks genre/
|
|
302
|
+
// language (#424).
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
if (!storyName) { setStructureContent(null); return; }
|
|
305
|
+
let cancelled = false;
|
|
306
|
+
authFetch(`/api/stories/${storyName}/structure.md`)
|
|
307
|
+
.then((res) => res.ok ? res.json() : null)
|
|
308
|
+
.then((data) => { if (!cancelled) setStructureContent(data?.content ?? null); })
|
|
165
309
|
.catch(() => {});
|
|
166
310
|
return () => { cancelled = true; };
|
|
167
311
|
}, [storyName, authFetch]);
|
|
168
312
|
|
|
313
|
+
// Seed the publish metadata controls from the story's real values (#424).
|
|
314
|
+
// Priority: explicit .story.json metadata (props) → structure.md hints → an
|
|
315
|
+
// explicit "Needs metadata" genre (empty) instead of a misleading Romance/
|
|
316
|
+
// English default. Persisted edits round-trip through these same props, so
|
|
317
|
+
// re-seeding settles on the saved value and never clobbers a selection.
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
if (!storyName) return;
|
|
320
|
+
// Genre: canonical .story.json label, else structure.md genre, else unset.
|
|
321
|
+
const metaGenre = canonicalizeGenre(genreMeta);
|
|
322
|
+
let genreVal = metaGenre ?? "";
|
|
323
|
+
if (!metaGenre && structureContent) {
|
|
324
|
+
const match = structureContent.match(/\*{0,2}genre\*{0,2}[:\s]+(.+)/i);
|
|
325
|
+
// Canonicalize so a natural label like "Sci-Fi" preselects "Science
|
|
326
|
+
// Fiction" instead of being silently dropped (#412).
|
|
327
|
+
if (match) genreVal = canonicalizeGenre(match[1].replace(/\*+/g, "").trim()) ?? "";
|
|
328
|
+
}
|
|
329
|
+
setSelectedGenre(genreVal);
|
|
330
|
+
// Language: the server-resolved story language (explicit .story.json or
|
|
331
|
+
// script-detected), else structure.md, else unset ("Needs metadata" — no
|
|
332
|
+
// misleading English default). `language` is undefined when undetermined.
|
|
333
|
+
let langVal = (language && LANGUAGES.find((l) => l.toLowerCase() === language.toLowerCase())) || "";
|
|
334
|
+
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()) || "";
|
|
337
|
+
}
|
|
338
|
+
setSelectedLanguage(langVal);
|
|
339
|
+
setIsNsfw(nsfwMeta ?? false);
|
|
340
|
+
}, [storyName, genreMeta, language, nsfwMeta, structureContent]);
|
|
341
|
+
|
|
342
|
+
// Persist a publish-control edit back to .story.json so it sticks across
|
|
343
|
+
// refresh and the controls stay in sync with story metadata (#424). Best
|
|
344
|
+
// 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]);
|
|
353
|
+
|
|
169
354
|
const handleSave = useCallback(async () => {
|
|
170
355
|
if (!storyName || !fileName) return;
|
|
171
356
|
setSaving(true);
|
|
@@ -183,27 +368,138 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
183
368
|
setSaving(false);
|
|
184
369
|
}, [storyName, fileName, authFetch, editContent]);
|
|
185
370
|
|
|
371
|
+
// Generate the cartoon markdown skeleton from the cut plan, then refresh
|
|
372
|
+
// preview/readiness so the workflow coach's next action advances (#429). The
|
|
373
|
+
// "Prepare episode for publish" UI now lives on the Publish tab / cut workspace
|
|
374
|
+
// (#461); this stays as the coach's `generate-markdown` action handler.
|
|
375
|
+
const handleGenerateMarkdown = useCallback(async () => {
|
|
376
|
+
if (!storyName || !fileName) return;
|
|
377
|
+
const plotFile = fileName.replace(/\.md$/, "");
|
|
378
|
+
try {
|
|
379
|
+
const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`, { method: "POST" });
|
|
380
|
+
if (res.ok) {
|
|
381
|
+
await loadFile();
|
|
382
|
+
// Re-run readiness + reload the workflow coach so the next action moves
|
|
383
|
+
// off "Prepare the episode for publish" once the layout is built (#429).
|
|
384
|
+
setCutsRefreshKey((k) => k + 1);
|
|
385
|
+
}
|
|
386
|
+
} catch { /* ignore */ }
|
|
387
|
+
}, [storyName, fileName, authFetch, loadFile]);
|
|
388
|
+
|
|
389
|
+
// Route a workflow-coach UI action to the right control (#429). When the
|
|
390
|
+
// action concerns a different episode than the open file (e.g. the coach on
|
|
391
|
+
// structure.md points at the active episode), open that file first — the coach
|
|
392
|
+
// there offers the same action in place. Otherwise reveal the control: the cut
|
|
393
|
+
// workspace for letter/export/upload/refresh, the Preview tab for publish (the
|
|
394
|
+
// 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]);
|
|
417
|
+
|
|
186
418
|
// Handle cover image selection
|
|
187
419
|
const handleCoverSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
188
420
|
const file = e.target.files?.[0];
|
|
189
421
|
if (!file) return;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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");
|
|
196
441
|
return;
|
|
197
442
|
}
|
|
198
443
|
setCoverFile(file);
|
|
199
|
-
setCoverPreview(URL.createObjectURL(file));
|
|
444
|
+
setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(file); });
|
|
200
445
|
setEditError(null);
|
|
446
|
+
setCoverStatus("selected");
|
|
201
447
|
}, []);
|
|
202
448
|
|
|
449
|
+
// Import a Codex-generated image (e.g. a large PNG) as the cover (#301). The
|
|
450
|
+
// browser converts/compresses it to a compliant WebP/JPEG <=1MB, then OWS
|
|
451
|
+
// saves it as the deterministic local asset (assets/cover.webp) via
|
|
452
|
+
// import-cover and loads it into the same coverFile the manual picker uses, so
|
|
453
|
+
// the existing publish flow attaches it with no special casing. A source that
|
|
454
|
+
// 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;
|
|
466
|
+
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");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
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");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
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
|
+
|
|
203
499
|
// Handle illustration image upload from File object
|
|
204
500
|
const uploadIllustration = useCallback(async (file: File) => {
|
|
205
|
-
if (file.size >
|
|
206
|
-
setIllustrationError("Image exceeds
|
|
501
|
+
if (file.size > 1024 * 1024) {
|
|
502
|
+
setIllustrationError("Image exceeds 1MB limit");
|
|
207
503
|
return;
|
|
208
504
|
}
|
|
209
505
|
const allowedTypes = ["image/webp", "image/jpeg"];
|
|
@@ -285,6 +581,15 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
285
581
|
|
|
286
582
|
setEditSuccess(true);
|
|
287
583
|
setCoverFile(null);
|
|
584
|
+
// A cover was just attached — reflect it in the cartoon cover status badge
|
|
585
|
+
// immediately, without closing/reopening Edit Story (#337, re1). Drop the
|
|
586
|
+
// local preview so the status reads "attached", not "selected".
|
|
587
|
+
if (coverCid !== undefined) {
|
|
588
|
+
setEditHasCover(true);
|
|
589
|
+
setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
|
|
590
|
+
setCoverStatus("unknown");
|
|
591
|
+
if (coverInputRef.current) coverInputRef.current.value = "";
|
|
592
|
+
}
|
|
288
593
|
setTimeout(() => setEditSuccess(false), 3000);
|
|
289
594
|
} catch (err) {
|
|
290
595
|
setEditError(err instanceof Error ? err.message : "Update failed");
|
|
@@ -304,8 +609,55 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
304
609
|
setShowIllustrations(false);
|
|
305
610
|
setUploadedImages([]);
|
|
306
611
|
setIllustrationError(null);
|
|
612
|
+
setDetectedCover(null);
|
|
613
|
+
setDetectedCoverWarning(null);
|
|
614
|
+
setCoverStatus("unknown");
|
|
615
|
+
coverUserTouchedRef.current = false;
|
|
616
|
+
setGenesisEditMode("text");
|
|
307
617
|
}, [storyName, fileName]);
|
|
308
618
|
|
|
619
|
+
// Auto-detect an agent-created cover (assets/cover.webp|jpg) for an UNPUBLISHED
|
|
620
|
+
// genesis and offer it as the default pre-publish cover (#296). Loads the file
|
|
621
|
+
// into the same coverFile/coverPreview the manual picker uses, so the existing
|
|
622
|
+
// publish flow attaches it (upload-cover → update-storyline) with no special
|
|
623
|
+
// casing. Invalid/oversize detected assets surface as a warning and are NOT used.
|
|
624
|
+
useEffect(() => {
|
|
625
|
+
if (fileName !== "genesis.md" || !storyName) return;
|
|
626
|
+
// Wait for the file to load AND confirm it is genuinely unpublished before
|
|
627
|
+
// touching the shared coverFile/coverPreview. On first render fileData is
|
|
628
|
+
// null, so without this an auto-detected cover could be set before the file
|
|
629
|
+
// load resolves and then leak into the published Edit Story panel (re1).
|
|
630
|
+
if (!fileData) return;
|
|
631
|
+
if (fileData.storylineId || fileData.status === "published" || fileData.status === "published-not-indexed") return;
|
|
632
|
+
if (coverUserTouchedRef.current) return; // a manual pick/removal wins
|
|
633
|
+
let cancelled = false;
|
|
634
|
+
(async () => {
|
|
635
|
+
try {
|
|
636
|
+
const res = await authFetch(`/api/stories/${storyName}/cover-asset`);
|
|
637
|
+
if (cancelled || !res.ok) return;
|
|
638
|
+
const data = await res.json();
|
|
639
|
+
if (cancelled) return;
|
|
640
|
+
if (!data?.found) { setCoverStatus("none"); return; }
|
|
641
|
+
if (!data.valid) {
|
|
642
|
+
setDetectedCoverWarning(data.error || "Detected cover asset is invalid and was not used");
|
|
643
|
+
setCoverStatus("invalid");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const assetRes = await authFetch(`/api/stories/${storyName}/asset/${data.path.replace(/^assets\//, "")}`);
|
|
647
|
+
if (cancelled || !assetRes.ok) return;
|
|
648
|
+
const blob = await assetRes.blob();
|
|
649
|
+
const file = new File([blob], data.path.split("/").pop() || "cover.webp", { type: data.type });
|
|
650
|
+
// Reuse the exact client validation the manual picker uses.
|
|
651
|
+
if (validateCoverImage(file) || cancelled || coverUserTouchedRef.current) return;
|
|
652
|
+
setCoverFile(file);
|
|
653
|
+
setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(file); });
|
|
654
|
+
setDetectedCover(data.path);
|
|
655
|
+
setCoverStatus("detected");
|
|
656
|
+
} catch { /* best-effort: no detected cover */ }
|
|
657
|
+
})();
|
|
658
|
+
return () => { cancelled = true; };
|
|
659
|
+
}, [storyName, fileName, fileData, fileData?.status, fileData?.storylineId, authFetch]);
|
|
660
|
+
|
|
309
661
|
// Fetch current storyline metadata when edit panel opens
|
|
310
662
|
useEffect(() => {
|
|
311
663
|
if (!showEditPanel || !fileData?.storylineId) return;
|
|
@@ -321,7 +673,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
321
673
|
return;
|
|
322
674
|
}
|
|
323
675
|
if (data.genre) {
|
|
324
|
-
const found =
|
|
676
|
+
const found = canonicalizeGenre(data.genre);
|
|
325
677
|
if (found) setEditGenre(found);
|
|
326
678
|
}
|
|
327
679
|
if (data.language) {
|
|
@@ -329,6 +681,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
329
681
|
if (found) setEditLanguage(found);
|
|
330
682
|
}
|
|
331
683
|
if (data.isNsfw !== undefined) setEditNsfw(!!data.isNsfw);
|
|
684
|
+
// Track whether a cover is already attached so the cover step can show
|
|
685
|
+
// an "attached" status for a published cartoon story (#337).
|
|
686
|
+
setEditHasCover(!!(data.coverCid || data.coverUrl || data.cover));
|
|
332
687
|
setEditMetaLoaded(true);
|
|
333
688
|
})
|
|
334
689
|
.catch(() => {
|
|
@@ -394,7 +749,157 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
394
749
|
const charCount = content.length;
|
|
395
750
|
const isGenesis = fileName === "genesis.md";
|
|
396
751
|
const isPlot = fileName ? /^plot-\d+\.md$/.test(fileName) : false;
|
|
752
|
+
const isCartoonPlot = contentType === "cartoon" && isPlot;
|
|
753
|
+
const isCartoonGenesis = contentType === "cartoon" && isGenesis;
|
|
754
|
+
// #461: cartoon episode views (Genesis + plots) hide every publish-control /
|
|
755
|
+
// readiness block — those move to the Publish tab — and show only the opening
|
|
756
|
+
// content, production next-step guidance, and a compact "Review publish" CTA.
|
|
757
|
+
const isCartoonEpisode = isCartoonGenesis || isCartoonPlot;
|
|
397
758
|
const isPublished = fileData?.status === "published" || fileData?.status === "published-not-indexed";
|
|
759
|
+
|
|
760
|
+
// State-aware preview footer guidance (#422). Cut count for the selected
|
|
761
|
+
// episode: Genesis from genesis.cuts.json, a plot from its readiness scan
|
|
762
|
+
// (null until loaded, so we don't flash "not started"). Drives outline/
|
|
763
|
+
// genesis/placeholder-plot guidance; null ⇒ let the per-stage UI speak.
|
|
764
|
+
const episodeCutCount = isCartoonGenesis
|
|
765
|
+
? (genesisCutProgress ? genesisCutProgress.total : null)
|
|
766
|
+
: isCartoonPlot
|
|
767
|
+
? (cartoonStage === null ? null : cartoonTotalCuts)
|
|
768
|
+
: null;
|
|
769
|
+
const footerGuidance = previewFooterGuidance({
|
|
770
|
+
fileName: fileName ?? "",
|
|
771
|
+
contentType,
|
|
772
|
+
hasGenesis,
|
|
773
|
+
isPublished,
|
|
774
|
+
cutCount: episodeCutCount,
|
|
775
|
+
// Pass the Genesis production progress so the footer advances by real stage
|
|
776
|
+
// (clean → letter → export → upload), not just "is anything uploaded" (#451).
|
|
777
|
+
cutProgress: isCartoonGenesis ? genesisCutProgress : null,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// Resolve + validate the public publish title shown before publish (#358).
|
|
781
|
+
// Scoped to cartoon (genesis story title + plot episode title); fiction is
|
|
782
|
+
// unchanged. `resolvedPublishTitle` mirrors what handlePublish/derivePublishTitle
|
|
783
|
+
// will send on-chain; `rawTitleBlocked` is true when it's still a raw filename
|
|
784
|
+
// label ("genesis"/"plot-NN"), which must block publish.
|
|
785
|
+
const showsPublishTitle = (isCartoonGenesis || isCartoonPlot) && !isPublished;
|
|
786
|
+
const resolvedPublishTitle = showsPublishTitle
|
|
787
|
+
? derivePublishTitle({
|
|
788
|
+
fileName: fileName!,
|
|
789
|
+
fileContent: fileData?.content ?? "",
|
|
790
|
+
storySlug: storyName ?? "",
|
|
791
|
+
structureContent,
|
|
792
|
+
contentType: "cartoon",
|
|
793
|
+
episodeTitle: cartoonEpisodeTitle,
|
|
794
|
+
})
|
|
795
|
+
: null;
|
|
796
|
+
const rawTitleBlocked = !!resolvedPublishTitle && isRawFilenameTitle(resolvedPublishTitle, fileName!);
|
|
797
|
+
// #365: a cartoon plot must have an EXPLICIT reader-facing title — a real
|
|
798
|
+
// `# Title` H1 or a non-empty cut-plan title. The friendly "Episode NN"
|
|
799
|
+
// fallback (derivePublishTitle) is diagnostic only and must NOT be published,
|
|
800
|
+
// so a missing explicit title blocks publish (tightens #358's plot path).
|
|
801
|
+
const episodeTitleMissing = isCartoonPlot && !isPublished
|
|
802
|
+
&& !hasExplicitEpisodeTitle({ fileContent: fileData?.content ?? "", episodeTitle: cartoonEpisodeTitle });
|
|
803
|
+
|
|
804
|
+
// Cartoon Genesis prologue readiness (#359, hardened in #400). Genesis is the
|
|
805
|
+
// reader-facing opening readers meet before plot-01, so block a missing H1
|
|
806
|
+
// title AND a too-short / synopsis-shaped / single-dense-block opening that
|
|
807
|
+
// 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";
|
|
813
|
+
|
|
814
|
+
// Cartoon cover readiness badge + requirements (#337). Shown wherever a
|
|
815
|
+
// cartoon writer manages the cover (pre-publish picker and the published Edit
|
|
816
|
+
// Story panel) so the cover step is never silently skipped before publish.
|
|
817
|
+
const COVER_TONE: Record<"muted" | "accent" | "error" | "success", string> = {
|
|
818
|
+
muted: "text-muted",
|
|
819
|
+
accent: "text-accent",
|
|
820
|
+
error: "text-error",
|
|
821
|
+
success: "text-green-700",
|
|
822
|
+
};
|
|
823
|
+
const renderCoverStatus = (attached: boolean) => {
|
|
824
|
+
if (!isCartoonGenesis) return null;
|
|
825
|
+
const r = cartoonCoverReadiness({
|
|
826
|
+
hasSelectedCover: !!coverFile,
|
|
827
|
+
invalid: coverStatus === "invalid",
|
|
828
|
+
attached,
|
|
829
|
+
});
|
|
830
|
+
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>
|
|
833
|
+
{/* Long cover spec/tips collapsed by default (#420) so the panel isn't a
|
|
834
|
+
wall of text; the concise status line above is always visible. */}
|
|
835
|
+
<details className="text-[10px] text-muted" data-testid="cover-details">
|
|
836
|
+
<summary className="cursor-pointer select-none">Cover tips</summary>
|
|
837
|
+
<span className="block mt-0.5" data-testid="cartoon-cover-guidance">{COVER_GUIDANCE}</span>
|
|
838
|
+
</details>
|
|
839
|
+
</div>
|
|
840
|
+
);
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
// The exact public title this file will publish with (#358), shown before the
|
|
844
|
+
// operator clicks publish. Reader-facing label (Story/Episode title), blocks
|
|
845
|
+
// when still a raw filename (#358) or — for cartoon plots — when there is no
|
|
846
|
+
// explicit title and only the diagnostic "Episode NN" fallback remains (#365).
|
|
847
|
+
const titleBlocked = rawTitleBlocked || episodeTitleMissing;
|
|
848
|
+
const renderPublishTitle = () => {
|
|
849
|
+
if (!showsPublishTitle || !resolvedPublishTitle) return null;
|
|
850
|
+
const label = isGenesis ? "Story title" : "Episode title";
|
|
851
|
+
return (
|
|
852
|
+
<div
|
|
853
|
+
className="flex flex-col gap-0.5"
|
|
854
|
+
data-testid="publish-title-preview"
|
|
855
|
+
data-raw={rawTitleBlocked ? "true" : "false"}
|
|
856
|
+
data-blocked={titleBlocked ? "true" : "false"}
|
|
857
|
+
>
|
|
858
|
+
<span className="text-[11px] text-foreground">
|
|
859
|
+
<span className="font-medium">{label}:</span>{" "}
|
|
860
|
+
<span className={titleBlocked ? "text-error font-medium" : "text-foreground"}>{resolvedPublishTitle}</span>
|
|
861
|
+
</span>
|
|
862
|
+
{rawTitleBlocked ? (
|
|
863
|
+
<span className="text-[10px] text-error" data-testid="publish-title-raw-error">
|
|
864
|
+
This would publish as a raw filename. {isGenesis
|
|
865
|
+
? "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.
|
|
867
|
+
</span>
|
|
868
|
+
) : 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.
|
|
871
|
+
</span>
|
|
872
|
+
) : null}
|
|
873
|
+
</div>
|
|
874
|
+
);
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// Cartoon Genesis prologue readiness panel (#359, hardened in #400), shown
|
|
878
|
+
// before publish. Labels Genesis as the reader-facing Story opening / Prologue,
|
|
879
|
+
// blocks a missing title and a thin/synopsis-shaped/single-dense-block opening,
|
|
880
|
+
// and reminds the writer it should bridge into Episode 01. Fiction genesis never
|
|
881
|
+
// renders this (isCartoonGenesis).
|
|
882
|
+
const renderGenesisReadiness = () => {
|
|
883
|
+
if (!genesisReadiness) return null;
|
|
884
|
+
return (
|
|
885
|
+
<div
|
|
886
|
+
className="flex flex-col gap-1 rounded border border-border bg-surface/50 p-2"
|
|
887
|
+
data-testid="cartoon-genesis-readiness"
|
|
888
|
+
data-blocked={genesisBlocked ? "true" : "false"}
|
|
889
|
+
>
|
|
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.
|
|
893
|
+
</span>
|
|
894
|
+
{genesisReadiness.blockers.map((b, i) => (
|
|
895
|
+
<span key={`b-${i}`} className="text-[10px] text-error" data-testid="genesis-readiness-blocker">{b}</span>
|
|
896
|
+
))}
|
|
897
|
+
{genesisReadiness.warnings.map((w, i) => (
|
|
898
|
+
<span key={`w-${i}`} className="text-[10px] text-amber-600" data-testid="genesis-readiness-warning">{w}</span>
|
|
899
|
+
))}
|
|
900
|
+
</div>
|
|
901
|
+
);
|
|
902
|
+
};
|
|
398
903
|
const charLimit = (isGenesis || isPlot) ? 10000 : null;
|
|
399
904
|
// Don't show over-limit warning for already-published files
|
|
400
905
|
const overLimit = !isPublished && charLimit !== null && charCount > charLimit;
|
|
@@ -403,12 +908,52 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
403
908
|
const publishContent = fileData?.content ?? "";
|
|
404
909
|
const imageValidation = !isPublished ? validateImageRefs(publishContent) : { count: 0, warnings: [] };
|
|
405
910
|
|
|
911
|
+
// Plain prose editor (fiction files + the Genesis "Opening text" sub-view).
|
|
912
|
+
const proseEditor = (
|
|
913
|
+
<div className="flex-1 min-h-0 flex flex-col" style={{ background: "var(--paper-bg)" }}>
|
|
914
|
+
<textarea
|
|
915
|
+
ref={textareaRef}
|
|
916
|
+
value={editContent}
|
|
917
|
+
onChange={(e) => { setEditContent(e.target.value); setDirty(true); dirtyRef.current = true; }}
|
|
918
|
+
className="flex-1 min-h-0 w-full resize-none px-4 py-3 text-sm leading-relaxed focus:outline-none"
|
|
919
|
+
style={{
|
|
920
|
+
fontFamily: '"Geist Mono", ui-monospace, monospace',
|
|
921
|
+
background: "var(--paper-bg)",
|
|
922
|
+
color: "var(--text)",
|
|
923
|
+
}}
|
|
924
|
+
spellCheck={false}
|
|
925
|
+
/>
|
|
926
|
+
<div className="px-3 py-1.5 border-t border-border flex items-center justify-between">
|
|
927
|
+
<span className="text-xs text-muted">
|
|
928
|
+
{dirty ? "Unsaved changes" : "No changes"}
|
|
929
|
+
</span>
|
|
930
|
+
<button
|
|
931
|
+
onClick={handleSave}
|
|
932
|
+
disabled={!dirty || saving}
|
|
933
|
+
className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
|
|
934
|
+
>
|
|
935
|
+
{saving ? "Saving..." : "Save"}
|
|
936
|
+
</button>
|
|
937
|
+
</div>
|
|
938
|
+
</div>
|
|
939
|
+
);
|
|
940
|
+
|
|
406
941
|
return (
|
|
407
942
|
<div className="h-full flex flex-col">
|
|
408
943
|
{/* Header with file path + tabs */}
|
|
409
944
|
<div className="border-b border-border">
|
|
410
945
|
<div className="px-3 py-1.5 flex items-center justify-between">
|
|
411
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"
|
|
953
|
+
>
|
|
954
|
+
← Progress
|
|
955
|
+
</button>
|
|
956
|
+
)}
|
|
412
957
|
<span>{storyName}/{fileName}</span>
|
|
413
958
|
{fileData?.status === "published" && (
|
|
414
959
|
<span className="text-green-700 font-medium">Published</span>
|
|
@@ -458,119 +1003,109 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
458
1003
|
</div>
|
|
459
1004
|
</div>
|
|
460
1005
|
|
|
1006
|
+
{/* Persistent cartoon workflow coach (#429): one clear next action across
|
|
1007
|
+
every cartoon file view, derived from real story/episode state. Sits
|
|
1008
|
+
above the content so it stays visible on both the Preview and Edit
|
|
1009
|
+
tabs. Fiction renders nothing (the coach is null), so fiction views are
|
|
1010
|
+
unchanged. */}
|
|
1011
|
+
{contentType === "cartoon" && storyName && fileName && (
|
|
1012
|
+
<WorkflowCoach
|
|
1013
|
+
storyName={storyName}
|
|
1014
|
+
fileName={fileName}
|
|
1015
|
+
authFetch={authFetch}
|
|
1016
|
+
refreshKey={cutsRefreshKey}
|
|
1017
|
+
onAction={handleCoachAction}
|
|
1018
|
+
/>
|
|
1019
|
+
)}
|
|
1020
|
+
|
|
461
1021
|
{/* Content area */}
|
|
462
1022
|
{activeTab === "preview" ? (
|
|
463
|
-
|
|
464
|
-
{
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
1023
|
+
isCartoonPlot ? (
|
|
1024
|
+
<div className="flex-1 min-h-0 flex flex-col" style={{ background: "var(--paper-bg)" }}>
|
|
1025
|
+
{/* Two explicit modes: Publish Preview (exact PlotLink markdown) vs
|
|
1026
|
+
Cut Inspector (cuts.json planning metadata) — see #289. */}
|
|
1027
|
+
<div className="flex gap-1 px-3 py-1 border-b border-border">
|
|
1028
|
+
<button
|
|
1029
|
+
data-testid="cartoon-mode-publish"
|
|
1030
|
+
onClick={() => setCartoonPreviewMode("publish")}
|
|
1031
|
+
className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "publish" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
|
|
469
1032
|
>
|
|
470
|
-
|
|
471
|
-
</
|
|
1033
|
+
Publish Preview
|
|
1034
|
+
</button>
|
|
1035
|
+
<button
|
|
1036
|
+
data-testid="cartoon-mode-inspect"
|
|
1037
|
+
onClick={() => setCartoonPreviewMode("inspect")}
|
|
1038
|
+
className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "inspect" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
|
|
1039
|
+
>
|
|
1040
|
+
Cut Inspector
|
|
1041
|
+
</button>
|
|
1042
|
+
</div>
|
|
1043
|
+
<div className="flex-1 min-h-0">
|
|
1044
|
+
{cartoonPreviewMode === "publish" ? (
|
|
1045
|
+
<CartoonPublishPreview content={fileData?.content ?? ""} stage={cartoonStage} />
|
|
1046
|
+
) : (
|
|
1047
|
+
<CartoonPreview storyName={storyName!} fileName={fileName!} authFetch={authFetch} onEditCut={handleEditCut} />
|
|
1048
|
+
)}
|
|
472
1049
|
</div>
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
)}
|
|
1050
|
+
</div>
|
|
1051
|
+
) : (
|
|
1052
|
+
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4" style={{ background: "var(--paper-bg)" }}>
|
|
1053
|
+
{fileData?.content ? (
|
|
1054
|
+
<div className="prose max-w-none">
|
|
1055
|
+
<ReactMarkdown
|
|
1056
|
+
remarkPlugins={[remarkBreaks, remarkGfm]}
|
|
1057
|
+
rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
|
|
1058
|
+
>
|
|
1059
|
+
{fileData.content}
|
|
1060
|
+
</ReactMarkdown>
|
|
1061
|
+
</div>
|
|
1062
|
+
) : (
|
|
1063
|
+
<p className="text-muted italic">No content</p>
|
|
1064
|
+
)}
|
|
1065
|
+
</div>
|
|
1066
|
+
)
|
|
1067
|
+
) : 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)} />
|
|
476
1070
|
</div>
|
|
477
|
-
) : (
|
|
1071
|
+
) : isCartoonGenesis ? (
|
|
1072
|
+
// Genesis Edit tab: opening-text editor vs. its cut workspace (#429), so
|
|
1073
|
+
// the coach's lettering/upload/refresh actions for Episode 1 are actionable
|
|
1074
|
+
// and Genesis cuts get the same workspace as plots — without losing the
|
|
1075
|
+
// hand-written opening prose editor.
|
|
478
1076
|
<div className="flex-1 min-h-0 flex flex-col" style={{ background: "var(--paper-bg)" }}>
|
|
479
|
-
<
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
color: "var(--text)",
|
|
488
|
-
}}
|
|
489
|
-
spellCheck={false}
|
|
490
|
-
/>
|
|
491
|
-
<div className="px-3 py-1.5 border-t border-border flex items-center justify-between">
|
|
492
|
-
<span className="text-xs text-muted">
|
|
493
|
-
{dirty ? "Unsaved changes" : "No changes"}
|
|
494
|
-
</span>
|
|
1077
|
+
<div className="flex gap-1 px-3 py-1 border-b border-border">
|
|
1078
|
+
<button
|
|
1079
|
+
data-testid="genesis-edit-mode-text"
|
|
1080
|
+
onClick={() => setGenesisEditMode("text")}
|
|
1081
|
+
className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "text" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
|
|
1082
|
+
>
|
|
1083
|
+
Opening text
|
|
1084
|
+
</button>
|
|
495
1085
|
<button
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
className=
|
|
1086
|
+
data-testid="genesis-edit-mode-cuts"
|
|
1087
|
+
onClick={() => setGenesisEditMode("cuts")}
|
|
1088
|
+
className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "cuts" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
|
|
499
1089
|
>
|
|
500
|
-
|
|
1090
|
+
Cuts
|
|
501
1091
|
</button>
|
|
502
1092
|
</div>
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
onChange={(e) => setShowIllustrations(e.target.checked)}
|
|
511
|
-
className="rounded border-border"
|
|
512
|
-
/>
|
|
513
|
-
Add illustrations in the plot
|
|
514
|
-
</label>
|
|
515
|
-
{showIllustrations && (
|
|
516
|
-
<div className="mt-2 flex flex-col gap-2">
|
|
517
|
-
<div
|
|
518
|
-
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"
|
|
519
|
-
onClick={() => illustrationInputRef.current?.click()}
|
|
520
|
-
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
|
521
|
-
onDrop={(e) => {
|
|
522
|
-
e.preventDefault();
|
|
523
|
-
e.stopPropagation();
|
|
524
|
-
const file = e.dataTransfer.files?.[0];
|
|
525
|
-
if (file) uploadIllustration(file);
|
|
526
|
-
}}
|
|
527
|
-
>
|
|
528
|
-
<input
|
|
529
|
-
ref={illustrationInputRef}
|
|
530
|
-
type="file"
|
|
531
|
-
accept="image/webp,image/jpeg"
|
|
532
|
-
onChange={handleIllustrationInput}
|
|
533
|
-
className="hidden"
|
|
534
|
-
/>
|
|
535
|
-
<span className="text-xs text-muted">
|
|
536
|
-
{illustrationUploading ? "Uploading..." : "Drop image here or click to browse"}
|
|
537
|
-
</span>
|
|
538
|
-
<span className="text-xs text-muted">WebP/JPEG, max 500KB</span>
|
|
539
|
-
</div>
|
|
540
|
-
{illustrationError && (
|
|
541
|
-
<span className="text-error text-xs">{illustrationError}</span>
|
|
542
|
-
)}
|
|
543
|
-
{uploadedImages.map((img, i) => (
|
|
544
|
-
<div key={img.cid} className="border border-border rounded p-2 flex flex-col gap-1 bg-surface">
|
|
545
|
-
<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>
|
|
546
|
-
<div className="flex items-center gap-1.5">
|
|
547
|
-
<code className="flex-1 text-xs bg-background px-2 py-1 rounded font-mono break-all">
|
|
548
|
-

|
|
549
|
-
</code>
|
|
550
|
-
<button
|
|
551
|
-
onClick={() => {
|
|
552
|
-
navigator.clipboard.writeText(``);
|
|
553
|
-
setCopiedIndex(i);
|
|
554
|
-
setTimeout(() => setCopiedIndex(null), 2000);
|
|
555
|
-
}}
|
|
556
|
-
className="px-2 py-1 text-xs border border-border rounded hover:bg-surface shrink-0"
|
|
557
|
-
>
|
|
558
|
-
{copiedIndex === i ? "Copied!" : "Copy"}
|
|
559
|
-
</button>
|
|
560
|
-
</div>
|
|
561
|
-
</div>
|
|
562
|
-
))}
|
|
563
|
-
</div>
|
|
564
|
-
)}
|
|
565
|
-
</div>
|
|
566
|
-
)}
|
|
1093
|
+
<div className="flex-1 min-h-0">
|
|
1094
|
+
{genesisEditMode === "cuts" ? (
|
|
1095
|
+
<CutListPanel storyName={storyName!} fileName={fileName!} authFetch={authFetch} language={language} onCutsChanged={() => setCutsRefreshKey((k) => k + 1)} focusRequest={cutFocus} onFocusHandled={() => setCutFocus(null)} />
|
|
1096
|
+
) : (
|
|
1097
|
+
proseEditor
|
|
1098
|
+
)}
|
|
1099
|
+
</div>
|
|
567
1100
|
</div>
|
|
1101
|
+
) : (
|
|
1102
|
+
proseEditor
|
|
568
1103
|
)}
|
|
569
1104
|
|
|
570
1105
|
{/* Action bar */}
|
|
571
1106
|
<div className="px-3 py-2 border-t border-border flex items-center justify-between">
|
|
572
1107
|
{fileName === "structure.md" ? (
|
|
573
|
-
<p className="text-muted text-xs italic">
|
|
1108
|
+
<p className="text-muted text-xs italic" data-testid="footer-guidance">{footerGuidance}</p>
|
|
574
1109
|
) : fileData?.status === "published-not-indexed" ? (
|
|
575
1110
|
<div className="flex flex-col gap-1">
|
|
576
1111
|
<div className="flex items-center gap-2 text-xs">
|
|
@@ -616,8 +1151,23 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
616
1151
|
)}
|
|
617
1152
|
{isPlot && (
|
|
618
1153
|
<button
|
|
619
|
-
onClick={() =>
|
|
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
|
+
}}
|
|
620
1169
|
disabled={!!publishingFile}
|
|
1170
|
+
data-testid="retry-publish-btn"
|
|
621
1171
|
className="px-3 py-1 border border-border text-xs rounded hover:bg-surface disabled:opacity-50"
|
|
622
1172
|
>
|
|
623
1173
|
{publishingFile === fileName ? "Publishing..." : "Retry Publish"}
|
|
@@ -693,6 +1243,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
693
1243
|
{/* Cover image upload */}
|
|
694
1244
|
<div className="flex flex-col gap-1.5">
|
|
695
1245
|
<span className="text-xs font-medium text-foreground">Cover Image</span>
|
|
1246
|
+
{/* Attached/selected/invalid cover status for the published
|
|
1247
|
+
cartoon story (#337). */}
|
|
1248
|
+
{renderCoverStatus(editHasCover)}
|
|
696
1249
|
<div className="flex items-start gap-3">
|
|
697
1250
|
{coverPreview && (
|
|
698
1251
|
<div className="relative">
|
|
@@ -702,7 +1255,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
702
1255
|
className="w-16 h-24 object-cover rounded border border-border"
|
|
703
1256
|
/>
|
|
704
1257
|
<button
|
|
705
|
-
onClick={() => { setCoverFile(null); setCoverPreview(null); if (coverInputRef.current) coverInputRef.current.value = ""; }}
|
|
1258
|
+
onClick={() => { setCoverFile(null); setCoverPreview(null); setDetectedCoverWarning(null); setCoverStatus("unknown"); if (coverInputRef.current) coverInputRef.current.value = ""; }}
|
|
706
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"
|
|
707
1260
|
>
|
|
708
1261
|
x
|
|
@@ -713,11 +1266,12 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
713
1266
|
<input
|
|
714
1267
|
ref={coverInputRef}
|
|
715
1268
|
type="file"
|
|
716
|
-
accept="image/webp,image/jpeg
|
|
1269
|
+
accept="image/webp,image/jpeg"
|
|
717
1270
|
onChange={handleCoverSelect}
|
|
718
1271
|
className="text-xs"
|
|
1272
|
+
data-testid="cover-input"
|
|
719
1273
|
/>
|
|
720
|
-
<span className="text-xs text-muted">WebP/JPEG, max
|
|
1274
|
+
<span className="text-xs text-muted">WebP/JPEG, max 1MB, 600x900px recommended</span>
|
|
721
1275
|
</div>
|
|
722
1276
|
</div>
|
|
723
1277
|
</div>
|
|
@@ -769,23 +1323,271 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
769
1323
|
</div>
|
|
770
1324
|
) : (
|
|
771
1325
|
<div className="flex flex-col gap-2">
|
|
1326
|
+
{/* Creator-facing 6-step production checklist so a first-time user
|
|
1327
|
+
can see which step is current/next without internal jargon
|
|
1328
|
+
(#320, expanded to per-cut granularity in #335). */}
|
|
1329
|
+
{/* Compact cartoon production status (#420): one scannable line of
|
|
1330
|
+
cut/clean/lettered/uploaded tallies, with a link to the full
|
|
1331
|
+
story progress overview (#418). The detailed 6-step guide stays
|
|
1332
|
+
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>
|
|
1343
|
+
)}
|
|
1344
|
+
</div>
|
|
1345
|
+
)}
|
|
1346
|
+
{/* #461: the 6-step production checklist is a publish/production
|
|
1347
|
+
checklist — it now lives on the Publish tab + the cut workspace's
|
|
1348
|
+
FinishEpisodePanel, so it no longer renders under the episode. */}
|
|
1349
|
+
{/* Genesis-as-Episode-1 cut summary (#422): discover + summarize
|
|
1350
|
+
genesis.cuts.json so a writer sees its real cut/image state in the
|
|
1351
|
+
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
|
|
1355
|
+
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-
|
|
1368
|
+
episode placeholder (#422): plan cuts / generate clean images /
|
|
1369
|
+
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>
|
|
1382
|
+
</div>
|
|
1383
|
+
<span className="text-xs text-muted">{footerGuidance}</span>
|
|
1384
|
+
</div>
|
|
1385
|
+
)}
|
|
1386
|
+
{/* #461: the planning-stage "Prepare episode for publish" callout and
|
|
1387
|
+
the awaiting-upload pending callout are publish-readiness states —
|
|
1388
|
+
they now live on the Publish tab. The cartoon episode keeps only
|
|
1389
|
+
production next-step guidance (cartoon-not-started / cut summaries)
|
|
1390
|
+
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
|
+

|
|
1437
|
+
</code>
|
|
1438
|
+
<button
|
|
1439
|
+
onClick={() => {
|
|
1440
|
+
navigator.clipboard.writeText(``);
|
|
1441
|
+
setCopiedIndex(i);
|
|
1442
|
+
setTimeout(() => setCopiedIndex(null), 2000);
|
|
1443
|
+
}}
|
|
1444
|
+
className="px-2 py-1 text-xs border border-border rounded hover:bg-surface shrink-0"
|
|
1445
|
+
>
|
|
1446
|
+
{copiedIndex === i ? "Copied!" : "Copy"}
|
|
1447
|
+
</button>
|
|
1448
|
+
</div>
|
|
1449
|
+
</div>
|
|
1450
|
+
))}
|
|
1451
|
+
</div>
|
|
1452
|
+
)}
|
|
1453
|
+
</div>
|
|
1454
|
+
)}
|
|
1455
|
+
{/* Pre-publish cover picker (#284): a new genesis (esp. cartoon)
|
|
1456
|
+
gets a cover before its first createStoryline. Reuses the same
|
|
1457
|
+
validation/stale-clear as the published Edit Story panel; the
|
|
1458
|
+
selected file is handed to the publish flow, which uploads it and
|
|
1459
|
+
sets it on the storyline once it exists.
|
|
1460
|
+
#450: hidden in the genesis CUT WORKSPACE (Cuts sub-mode) so the
|
|
1461
|
+
cut/lettering editor gets the height — the cover stays available
|
|
1462
|
+
in the Opening-text/Preview view, Story Info, and the Publish page,
|
|
1463
|
+
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
|
|
1468
|
+
step visible before genesis publish so a pilot story isn't
|
|
1469
|
+
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
|
|
1498
|
+
large image) to a compliant cover in-browser, save it as
|
|
1499
|
+
assets/cover.webp, and load it as the selected cover —
|
|
1500
|
+
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
|
|
1519
|
+
explicit. Whenever a cover is selected (auto-detected,
|
|
1520
|
+
imported, or manually picked) it WILL be uploaded as the
|
|
1521
|
+
storyline cover at publish; an invalid or missing generated
|
|
1522
|
+
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 “Import generated image” 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 “Import generated image” — 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>}
|
|
1544
|
+
</div>
|
|
1545
|
+
</div>
|
|
1546
|
+
</div>
|
|
1547
|
+
)}
|
|
1548
|
+
{/* Public title shown + validated before publish (#358). #461: moved
|
|
1549
|
+
to the Publish tab for cartoon — fiction keeps it inline. */}
|
|
1550
|
+
{!isCartoonEpisode && renderPublishTitle()}
|
|
1551
|
+
{/* Cartoon Genesis prologue readiness checklist (#359). #461: moved to
|
|
1552
|
+
the Publish tab; never rendered in the cartoon episode view. */}
|
|
1553
|
+
{!isCartoonEpisode && renderGenesisReadiness()}
|
|
1554
|
+
{/* #461: the genre/language selects + publish button + publish-disabled
|
|
1555
|
+
reasons are publish controls — for cartoon they live on the Publish
|
|
1556
|
+
tab. Fiction keeps the inline publish controls unchanged. */}
|
|
1557
|
+
{!isCartoonEpisode && (
|
|
772
1558
|
<div className="flex items-center gap-2">
|
|
773
|
-
{
|
|
1559
|
+
{/* Genre/language are edited in Story Info for cartoon (#439/#450);
|
|
1560
|
+
the inline selects are fiction-only so the cartoon cut workspace
|
|
1561
|
+
isn't a metadata form. Cartoon publish still reads the persisted
|
|
1562
|
+
genre/language (seeded into these values from .story.json). */}
|
|
1563
|
+
{isGenesis && contentType !== "cartoon" && (
|
|
774
1564
|
<>
|
|
775
1565
|
<select
|
|
776
1566
|
value={selectedGenre}
|
|
777
|
-
|
|
778
|
-
|
|
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"}`}
|
|
779
1573
|
>
|
|
1574
|
+
{/* Explicit unset state — no silent Romance default (#424). */}
|
|
1575
|
+
{!selectedGenre && <option value="" disabled>Needs metadata — select genre</option>}
|
|
780
1576
|
{GENRES.map((g) => (
|
|
781
1577
|
<option key={g} value={g}>{g}</option>
|
|
782
1578
|
))}
|
|
783
1579
|
</select>
|
|
784
1580
|
<select
|
|
785
1581
|
value={selectedLanguage}
|
|
786
|
-
|
|
787
|
-
|
|
1582
|
+
data-testid="publish-language-select"
|
|
1583
|
+
onChange={(e) => {
|
|
1584
|
+
setSelectedLanguage(e.target.value);
|
|
1585
|
+
if (e.target.value) persistPublishMeta({ language: e.target.value });
|
|
1586
|
+
}}
|
|
1587
|
+
className={`px-2 py-1.5 text-xs border rounded bg-surface text-foreground ${selectedLanguage ? "border-border" : "border-amber-500"}`}
|
|
788
1588
|
>
|
|
1589
|
+
{/* Explicit unset state — no silent English default (#424). */}
|
|
1590
|
+
{!selectedLanguage && <option value="" disabled>Needs metadata — select language</option>}
|
|
789
1591
|
{LANGUAGES.map((l) => (
|
|
790
1592
|
<option key={l} value={l}>{l}</option>
|
|
791
1593
|
))}
|
|
@@ -793,23 +1595,90 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
793
1595
|
</>
|
|
794
1596
|
)}
|
|
795
1597
|
<button
|
|
796
|
-
onClick={() => {
|
|
1598
|
+
onClick={async () => {
|
|
797
1599
|
if (!storyName || !fileName) return;
|
|
798
1600
|
if (imageValidation.count > 0) {
|
|
799
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?`;
|
|
800
1602
|
if (!window.confirm(msg)) return;
|
|
801
1603
|
}
|
|
802
|
-
|
|
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 = "";
|
|
1627
|
+
}
|
|
1628
|
+
} else {
|
|
1629
|
+
onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw);
|
|
1630
|
+
}
|
|
803
1631
|
}}
|
|
804
|
-
disabled={!!publishingFile || overLimit}
|
|
1632
|
+
disabled={!!publishingFile || overLimit || titleBlocked || genesisBlocked || (isGenesis && (!selectedGenre || !selectedLanguage)) || (isCartoonPlot && cartoonStage !== "ready")}
|
|
805
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"
|
|
806
1634
|
>
|
|
807
1635
|
{publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
|
|
808
1636
|
</button>
|
|
1637
|
+
{/* Cartoon edits these in Story Info, so point there instead of the
|
|
1638
|
+
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
|
+
)}
|
|
809
1654
|
{overLimit && (
|
|
810
1655
|
<span className="text-error text-xs">Reduce content to publish</span>
|
|
811
1656
|
)}
|
|
1657
|
+
{isCartoonPlot && cartoonStage === "error" && (
|
|
1658
|
+
<span className="text-error text-xs" data-testid="publish-disabled-reason">Fix the issues below before publishing</span>
|
|
1659
|
+
)}
|
|
1660
|
+
{isCartoonPlot && cartoonStage === "planning" && (
|
|
1661
|
+
<span className="text-muted text-xs" data-testid="publish-disabled-reason">Prepare the episode for publish to continue</span>
|
|
1662
|
+
)}
|
|
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>
|
|
1667
|
+
)}
|
|
812
1668
|
</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
|
+
)}
|
|
813
1682
|
{imageValidation.warnings.length > 0 && (
|
|
814
1683
|
<div className="flex flex-col gap-0.5">
|
|
815
1684
|
{imageValidation.warnings.map((w, i) => (
|
|
@@ -817,13 +1686,17 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
817
1686
|
))}
|
|
818
1687
|
</div>
|
|
819
1688
|
)}
|
|
820
|
-
{(
|
|
1689
|
+
{/* Adult-content flag is edited in Story Info for cartoon (#450). */}
|
|
1690
|
+
{isGenesis && contentType !== "cartoon" && (
|
|
821
1691
|
<div className="flex items-center gap-2">
|
|
822
1692
|
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
|
823
1693
|
<input
|
|
824
1694
|
type="checkbox"
|
|
825
1695
|
checked={isNsfw}
|
|
826
|
-
onChange={(e) =>
|
|
1696
|
+
onChange={(e) => {
|
|
1697
|
+
setIsNsfw(e.target.checked);
|
|
1698
|
+
persistPublishMeta({ isNsfw: e.target.checked });
|
|
1699
|
+
}}
|
|
827
1700
|
className="rounded border-border"
|
|
828
1701
|
/>
|
|
829
1702
|
This story contains adult content (18+)
|