plotlink-ows 1.2.97 → 1.2.99
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 +17 -0
- package/app/routes/dashboard.ts +73 -6
- package/app/routes/wallet.ts +189 -5
- package/app/web/components/CartoonWorkflowNav.tsx +3 -7
- package/app/web/components/CutListPanel.tsx +252 -177
- package/app/web/components/CutOverlayPreview.tsx +9 -1
- package/app/web/components/Dashboard.tsx +40 -0
- package/app/web/components/EpisodesPage.tsx +10 -4
- package/app/web/components/LetteringEditor.tsx +81 -37
- package/app/web/components/PreviewPanel.tsx +145 -154
- package/app/web/components/StoriesPage.tsx +10 -17
- package/app/web/components/StoryBrowser.tsx +53 -19
- package/app/web/components/StoryProgressPanel.tsx +12 -5
- package/app/web/components/WalletCard.tsx +169 -0
- package/app/web/dist/assets/{export-cut-Cj-cOtan.js → export-cut-uimRac8k.js} +1 -1
- package/app/web/dist/assets/index-9RO6eX-I.css +32 -0
- package/app/web/dist/assets/index-D-nLoQ_K.js +141 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/preflight.mjs +10 -0
- package/app/web/dist/assets/index-CCeEYE7p.css +0 -32
- package/app/web/dist/assets/index-CF7pE09m.js +0 -141
|
@@ -90,6 +90,11 @@ interface LetteringEditorProps {
|
|
|
90
90
|
workspaceVisible?: boolean;
|
|
91
91
|
/** Toggle the surrounding app work area while staying in the editor. */
|
|
92
92
|
onToggleWorkspaceVisible?: () => void;
|
|
93
|
+
/** Move to adjacent cuts while staying in the focused editor. */
|
|
94
|
+
onPreviousCut?: () => void;
|
|
95
|
+
onNextCut?: () => void;
|
|
96
|
+
hasPreviousCut?: boolean;
|
|
97
|
+
hasNextCut?: boolean;
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
const TYPE_LABEL: Record<OverlayType, string> = {
|
|
@@ -138,6 +143,10 @@ export function LetteringEditor({
|
|
|
138
143
|
returnOnSave = false,
|
|
139
144
|
workspaceVisible = false,
|
|
140
145
|
onToggleWorkspaceVisible,
|
|
146
|
+
onPreviousCut,
|
|
147
|
+
onNextCut,
|
|
148
|
+
hasPreviousCut = false,
|
|
149
|
+
hasNextCut = false,
|
|
141
150
|
}: LetteringEditorProps) {
|
|
142
151
|
const bodyFont = getDefaultFont(language);
|
|
143
152
|
const displayFont = getDisplayFont();
|
|
@@ -244,6 +253,17 @@ export function LetteringEditor({
|
|
|
244
253
|
origH: number;
|
|
245
254
|
} | null>(null);
|
|
246
255
|
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
const nextOverlays = overlayNormalization.overlays as Overlay[];
|
|
258
|
+
setOverlays(nextOverlays);
|
|
259
|
+
setSelectedId(null);
|
|
260
|
+
setAcknowledgedInvalid(false);
|
|
261
|
+
setConfirmDelete(false);
|
|
262
|
+
setExportError(null);
|
|
263
|
+
setSaveError(null);
|
|
264
|
+
setExportBaselineSig(overlaysSignature(nextOverlays));
|
|
265
|
+
}, [cut.id, overlayNormalization]);
|
|
266
|
+
|
|
247
267
|
const updateImageBounds = useCallback(() => {
|
|
248
268
|
const container = containerRef.current;
|
|
249
269
|
if (!container) return;
|
|
@@ -727,19 +747,19 @@ export function LetteringEditor({
|
|
|
727
747
|
>
|
|
728
748
|
{/* Toolbar */}
|
|
729
749
|
<div
|
|
730
|
-
className="px-3 py-
|
|
750
|
+
className="px-3 py-1.5 border-b border-border bg-surface/55 grid grid-cols-[minmax(14rem,1fr)_auto_minmax(12rem,1fr)] items-center gap-2"
|
|
731
751
|
data-testid="lettering-toolbar"
|
|
732
752
|
>
|
|
733
|
-
<div className="flex items-center gap-1.5
|
|
753
|
+
<div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
|
|
734
754
|
<button
|
|
735
755
|
onClick={onClose}
|
|
736
756
|
className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:text-foreground"
|
|
737
757
|
data-testid="return-to-cut-review-btn"
|
|
738
758
|
>
|
|
739
|
-
|
|
759
|
+
Cut review
|
|
740
760
|
</button>
|
|
741
|
-
<span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.
|
|
742
|
-
|
|
761
|
+
<span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent whitespace-nowrap">
|
|
762
|
+
Lettering
|
|
743
763
|
</span>
|
|
744
764
|
<span className="text-[11px] font-mono text-muted">
|
|
745
765
|
{targetLabel ?? `Cut #${cut.id}`}
|
|
@@ -756,6 +776,10 @@ export function LetteringEditor({
|
|
|
756
776
|
data-testid={`lettering-check-${chip.key}`}
|
|
757
777
|
data-done={chip.done ? "true" : "false"}
|
|
758
778
|
className={`rounded-full border px-2 py-0.5 text-[10px] ${
|
|
779
|
+
chip.key === "exported" || chip.key === "uploaded"
|
|
780
|
+
? "hidden xl:inline-flex"
|
|
781
|
+
: ""
|
|
782
|
+
} ${
|
|
759
783
|
chip.done
|
|
760
784
|
? "border-green-700/30 bg-green-700/10 text-green-700"
|
|
761
785
|
: "border-border bg-background text-muted"
|
|
@@ -765,6 +789,7 @@ export function LetteringEditor({
|
|
|
765
789
|
{chip.label}
|
|
766
790
|
</span>
|
|
767
791
|
))}
|
|
792
|
+
<span className="sr-only">Focused lettering editor</span>
|
|
768
793
|
{cut.aiDraft?.status === "generated" && (
|
|
769
794
|
<span
|
|
770
795
|
className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] text-accent"
|
|
@@ -773,16 +798,31 @@ export function LetteringEditor({
|
|
|
773
798
|
AI draft ready
|
|
774
799
|
</span>
|
|
775
800
|
)}
|
|
776
|
-
{staleExport && (
|
|
777
|
-
<span
|
|
778
|
-
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-700"
|
|
779
|
-
data-testid="lettering-stale-chip"
|
|
780
|
-
>
|
|
781
|
-
Re-export needed
|
|
782
|
-
</span>
|
|
783
|
-
)}
|
|
784
801
|
</div>
|
|
785
|
-
<div className="flex items-center gap-1
|
|
802
|
+
<div className="flex items-center justify-center gap-1 rounded border border-border bg-background px-1 py-0.5">
|
|
803
|
+
<button
|
|
804
|
+
onClick={() => addOverlay("speech")}
|
|
805
|
+
className="px-2.5 py-1 text-[11px] rounded hover:bg-accent/10 hover:text-accent"
|
|
806
|
+
data-testid="add-speech"
|
|
807
|
+
>
|
|
808
|
+
Speech
|
|
809
|
+
</button>
|
|
810
|
+
<button
|
|
811
|
+
onClick={() => addOverlay("narration")}
|
|
812
|
+
className="px-2.5 py-1 text-[11px] rounded hover:bg-accent/10 hover:text-accent"
|
|
813
|
+
data-testid="add-narration"
|
|
814
|
+
>
|
|
815
|
+
Narration
|
|
816
|
+
</button>
|
|
817
|
+
<button
|
|
818
|
+
onClick={() => addOverlay("sfx")}
|
|
819
|
+
className="px-2.5 py-1 text-[11px] rounded hover:bg-accent/10 hover:text-accent"
|
|
820
|
+
data-testid="add-sfx"
|
|
821
|
+
>
|
|
822
|
+
SFX
|
|
823
|
+
</button>
|
|
824
|
+
</div>
|
|
825
|
+
<div className="flex items-center gap-1.5 justify-end min-w-0">
|
|
786
826
|
{onToggleWorkspaceVisible && (
|
|
787
827
|
<button
|
|
788
828
|
onClick={onToggleWorkspaceVisible}
|
|
@@ -800,29 +840,6 @@ export function LetteringEditor({
|
|
|
800
840
|
>
|
|
801
841
|
{showHelp ? "Hide help" : "Help"}
|
|
802
842
|
</button>
|
|
803
|
-
<div className="flex items-center gap-1">
|
|
804
|
-
<button
|
|
805
|
-
onClick={() => addOverlay("speech")}
|
|
806
|
-
className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
807
|
-
data-testid="add-speech"
|
|
808
|
-
>
|
|
809
|
-
Speech
|
|
810
|
-
</button>
|
|
811
|
-
<button
|
|
812
|
-
onClick={() => addOverlay("narration")}
|
|
813
|
-
className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
814
|
-
data-testid="add-narration"
|
|
815
|
-
>
|
|
816
|
-
Narration
|
|
817
|
-
</button>
|
|
818
|
-
<button
|
|
819
|
-
onClick={() => addOverlay("sfx")}
|
|
820
|
-
className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
821
|
-
data-testid="add-sfx"
|
|
822
|
-
>
|
|
823
|
-
SFX
|
|
824
|
-
</button>
|
|
825
|
-
</div>
|
|
826
843
|
{exportError && (
|
|
827
844
|
<span className="text-[10px] text-error max-w-[18rem]">
|
|
828
845
|
{exportError}
|
|
@@ -1036,6 +1053,33 @@ export function LetteringEditor({
|
|
|
1036
1053
|
</div>
|
|
1037
1054
|
)}
|
|
1038
1055
|
|
|
1056
|
+
{(onPreviousCut || onNextCut) && (
|
|
1057
|
+
<>
|
|
1058
|
+
<button
|
|
1059
|
+
type="button"
|
|
1060
|
+
onClick={onPreviousCut}
|
|
1061
|
+
disabled={!hasPreviousCut}
|
|
1062
|
+
className="absolute left-3 top-1/2 z-20 flex h-12 w-8 -translate-y-1/2 items-center justify-center rounded border border-border bg-background/85 text-2xl text-accent shadow-sm hover:bg-background disabled:opacity-30 disabled:hover:bg-background/85"
|
|
1063
|
+
data-testid="previous-cut-btn"
|
|
1064
|
+
aria-label="Previous cut"
|
|
1065
|
+
>
|
|
1066
|
+
<span aria-hidden>‹</span>
|
|
1067
|
+
<span className="sr-only">Previous cut</span>
|
|
1068
|
+
</button>
|
|
1069
|
+
<button
|
|
1070
|
+
type="button"
|
|
1071
|
+
onClick={onNextCut}
|
|
1072
|
+
disabled={!hasNextCut}
|
|
1073
|
+
className="absolute right-3 top-1/2 z-20 flex h-12 w-8 -translate-y-1/2 items-center justify-center rounded border border-border bg-background/85 text-2xl text-accent shadow-sm hover:bg-background disabled:opacity-30 disabled:hover:bg-background/85"
|
|
1074
|
+
data-testid="next-cut-btn"
|
|
1075
|
+
aria-label="Next cut"
|
|
1076
|
+
>
|
|
1077
|
+
<span aria-hidden>›</span>
|
|
1078
|
+
<span className="sr-only">Next cut</span>
|
|
1079
|
+
</button>
|
|
1080
|
+
</>
|
|
1081
|
+
)}
|
|
1082
|
+
|
|
1039
1083
|
{/* Speech balloons, drawn under the overlay boxes (which carry the
|
|
1040
1084
|
text + drag/resize handles) so the box sits on top of the fill.
|
|
1041
1085
|
Body + tail are ONE integrated <path> per bubble (#327), mirroring
|
|
@@ -5,8 +5,6 @@ import remarkGfm from "remark-gfm";
|
|
|
5
5
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
6
6
|
import { GENRES, LANGUAGES, canonicalizeGenre } from "../../../lib/genres";
|
|
7
7
|
import type { CoachUiAction } from "@app-lib/cartoon-coach";
|
|
8
|
-
import { CartoonPreview } from "./CartoonPreview";
|
|
9
|
-
import { CartoonPublishPreview } from "./CartoonPublishPreview";
|
|
10
8
|
import { CutListPanel } from "./CutListPanel";
|
|
11
9
|
import {
|
|
12
10
|
classifyCartoonReadiness,
|
|
@@ -147,6 +145,37 @@ function workflowActionNeedsCuts(action: CoachUiAction | null | undefined): bool
|
|
|
147
145
|
|| action === "refresh-assets";
|
|
148
146
|
}
|
|
149
147
|
|
|
148
|
+
function cartoonEpisodeLabel(fileName: string | null): string {
|
|
149
|
+
if (fileName === "genesis.md") return "epi-01 (Genesis)";
|
|
150
|
+
const m = fileName?.match(/^plot-(\d+)\.md$/);
|
|
151
|
+
if (!m) return fileName ?? "";
|
|
152
|
+
const episodeNumber = parseInt(m[1], 10) + 1;
|
|
153
|
+
return `epi-${String(episodeNumber).padStart(2, "0")}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractMarkdownTitle(content: string | null | undefined): string | null {
|
|
157
|
+
const title = content?.match(/^#\s+(.+?)\s*$/m)?.[1]?.trim();
|
|
158
|
+
return title || null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cartoonEpisodeNumberLabel(fileName: string | null): string | null {
|
|
162
|
+
if (fileName === "genesis.md") return "Episode 1";
|
|
163
|
+
const m = fileName?.match(/^plot-(\d+)\.md$/);
|
|
164
|
+
if (!m) return null;
|
|
165
|
+
return `Episode ${parseInt(m[1], 10) + 1}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function cartoonEpisodeHeaderTitle(
|
|
169
|
+
fileName: string | null,
|
|
170
|
+
title: string | null,
|
|
171
|
+
): string | null {
|
|
172
|
+
const trimmedTitle = title?.trim();
|
|
173
|
+
if (!trimmedTitle) return null;
|
|
174
|
+
if (/^Episode\s+\d+\s*[—-]\s*/i.test(trimmedTitle)) return trimmedTitle;
|
|
175
|
+
const episodeLabel = cartoonEpisodeNumberLabel(fileName);
|
|
176
|
+
return episodeLabel ? `${episodeLabel} — ${trimmedTitle}` : trimmedTitle;
|
|
177
|
+
}
|
|
178
|
+
|
|
150
179
|
export function PreviewPanel({
|
|
151
180
|
storyName,
|
|
152
181
|
fileName,
|
|
@@ -172,21 +201,6 @@ export function PreviewPanel({
|
|
|
172
201
|
const [fileData, setFileData] = useState<FileData | null>(null);
|
|
173
202
|
const [loading, setLoading] = useState(false);
|
|
174
203
|
const [activeTab, setActiveTab] = useState<Tab>("preview");
|
|
175
|
-
// Cartoon preview sub-mode: "publish" = exact PlotLink-bound markdown;
|
|
176
|
-
// "inspect" = cuts.json planning inspector. Kept distinct so planning prose
|
|
177
|
-
// does not masquerade as publish content (#289).
|
|
178
|
-
const [cartoonPreviewMode, setCartoonPreviewMode] = useState<
|
|
179
|
-
"publish" | "inspect"
|
|
180
|
-
>("publish");
|
|
181
|
-
// Cartoon Genesis is a hybrid (a prose opening + its own genesis.cuts.json image
|
|
182
|
-
// cuts), so its Edit tab offers two sub-views: the opening-text editor and the
|
|
183
|
-
// cut workspace (#429). Plots use the cut workspace directly; fiction never sees
|
|
184
|
-
// this. Defaults to "text" so opening Edit on Genesis is unchanged; the workflow
|
|
185
|
-
// coach's cut actions switch it to "cuts" so lettering/upload/refresh land on a
|
|
186
|
-
// real, actionable workspace instead of the markdown editor.
|
|
187
|
-
const [genesisEditMode, setGenesisEditMode] = useState<"text" | "cuts">(
|
|
188
|
-
"text",
|
|
189
|
-
);
|
|
190
204
|
// #371: a deep-link request from the Cut Inspector's per-cut CTA into the Edit
|
|
191
205
|
// tab for that exact cut. `seq` makes repeated clicks (even on the same cut)
|
|
192
206
|
// re-trigger the focus/expand effect in CutListPanel; it is cleared once
|
|
@@ -196,10 +210,6 @@ export function PreviewPanel({
|
|
|
196
210
|
openEditor: boolean;
|
|
197
211
|
seq: number;
|
|
198
212
|
} | null>(null);
|
|
199
|
-
const handleEditCut = useCallback((cutId: number, openEditor: boolean) => {
|
|
200
|
-
setActiveTab("edit");
|
|
201
|
-
setCutFocus((prev) => ({ cutId, openEditor, seq: (prev?.seq ?? 0) + 1 }));
|
|
202
|
-
}, []);
|
|
203
213
|
const [editContent, setEditContent] = useState("");
|
|
204
214
|
const [saving, setSaving] = useState(false);
|
|
205
215
|
const [dirty, setDirty] = useState(false);
|
|
@@ -402,9 +412,9 @@ export function PreviewPanel({
|
|
|
402
412
|
setCartoonTotalCuts(result.totalCuts);
|
|
403
413
|
setCartoonCutProgress(summarizeCutProgress(cuts));
|
|
404
414
|
// Cut plan's episode title for the publish-title display (#358).
|
|
405
|
-
|
|
406
|
-
typeof cutsData.title === "string" ? cutsData.title :
|
|
407
|
-
);
|
|
415
|
+
const cutsTitle =
|
|
416
|
+
typeof cutsData.title === "string" ? cutsData.title.trim() : "";
|
|
417
|
+
setCartoonEpisodeTitle(cutsTitle || extractMarkdownTitle(content));
|
|
408
418
|
}
|
|
409
419
|
} catch {
|
|
410
420
|
if (!cancelled) {
|
|
@@ -565,7 +575,6 @@ export function PreviewPanel({
|
|
|
565
575
|
case "upload":
|
|
566
576
|
case "refresh-assets":
|
|
567
577
|
setActiveTab("edit");
|
|
568
|
-
setGenesisEditMode("cuts");
|
|
569
578
|
break;
|
|
570
579
|
case "generate-markdown":
|
|
571
580
|
handleGenerateMarkdown();
|
|
@@ -817,7 +826,7 @@ export function PreviewPanel({
|
|
|
817
826
|
setDetectedCoverWarning(null);
|
|
818
827
|
setCoverStatus("unknown");
|
|
819
828
|
coverUserTouchedRef.current = false;
|
|
820
|
-
|
|
829
|
+
if (pendingWorkflowCutsRef.current) setActiveTab("edit");
|
|
821
830
|
}, [storyName, fileName]);
|
|
822
831
|
|
|
823
832
|
// Auto-detect an agent-created cover (assets/cover.webp|jpg) for an UNPUBLISHED
|
|
@@ -1000,6 +1009,13 @@ export function PreviewPanel({
|
|
|
1000
1009
|
// readiness block — those move to the Publish tab — and show only the opening
|
|
1001
1010
|
// content, production next-step guidance, and a compact "Review publish" CTA.
|
|
1002
1011
|
const isCartoonEpisode = isCartoonGenesis || isCartoonPlot;
|
|
1012
|
+
const cartoonEpisodeDisplayTitle =
|
|
1013
|
+
isCartoonEpisode
|
|
1014
|
+
? cartoonEpisodeHeaderTitle(
|
|
1015
|
+
fileName,
|
|
1016
|
+
cartoonEpisodeTitle || extractMarkdownTitle(fileData?.content),
|
|
1017
|
+
)
|
|
1018
|
+
: null;
|
|
1003
1019
|
const isPublished =
|
|
1004
1020
|
fileData?.status === "published" ||
|
|
1005
1021
|
fileData?.status === "published-not-indexed";
|
|
@@ -1263,20 +1279,34 @@ export function PreviewPanel({
|
|
|
1263
1279
|
{/* Header with file path + tabs */}
|
|
1264
1280
|
{!hideFocusedEditorChrome && (
|
|
1265
1281
|
<div className="border-b border-border">
|
|
1266
|
-
<div
|
|
1267
|
-
|
|
1282
|
+
<div
|
|
1283
|
+
className={
|
|
1284
|
+
isCartoonEpisode
|
|
1285
|
+
? "px-3 py-1.5 flex items-center justify-between gap-3"
|
|
1286
|
+
: "px-3 py-1.5 flex items-center justify-between"
|
|
1287
|
+
}
|
|
1288
|
+
>
|
|
1289
|
+
<div className="flex items-center gap-2 text-xs text-muted min-w-0">
|
|
1268
1290
|
{onViewProgress && (
|
|
1269
1291
|
<button
|
|
1270
1292
|
onClick={onViewProgress}
|
|
1271
1293
|
data-testid="view-progress-btn"
|
|
1272
|
-
className="text-accent hover:underline
|
|
1294
|
+
className="text-accent hover:underline"
|
|
1273
1295
|
title="Story progress overview"
|
|
1274
1296
|
>
|
|
1275
1297
|
← Progress
|
|
1276
1298
|
</button>
|
|
1277
1299
|
)}
|
|
1278
|
-
<span
|
|
1279
|
-
{
|
|
1300
|
+
<span
|
|
1301
|
+
className={
|
|
1302
|
+
isCartoonEpisode
|
|
1303
|
+
? "font-medium text-foreground truncate"
|
|
1304
|
+
: "font-mono"
|
|
1305
|
+
}
|
|
1306
|
+
>
|
|
1307
|
+
{isCartoonEpisode
|
|
1308
|
+
? `${cartoonEpisodeLabel(fileName)}${cartoonEpisodeDisplayTitle ? ` · ${cartoonEpisodeDisplayTitle}` : ""}`
|
|
1309
|
+
: `${storyName}/${fileName}`}
|
|
1280
1310
|
</span>
|
|
1281
1311
|
{fileData?.status === "published" && (
|
|
1282
1312
|
<span className="text-green-700 font-medium">Published</span>
|
|
@@ -1293,89 +1323,95 @@ export function PreviewPanel({
|
|
|
1293
1323
|
<span className="text-amber-700 font-medium">Pending</span>
|
|
1294
1324
|
)}
|
|
1295
1325
|
</div>
|
|
1296
|
-
|
|
1297
|
-
<
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1326
|
+
{isCartoonEpisode ? (
|
|
1327
|
+
<div className="flex items-center gap-1 rounded border border-border bg-surface/40 p-0.5">
|
|
1328
|
+
<button
|
|
1329
|
+
onClick={() => setActiveTab("preview")}
|
|
1330
|
+
className={`px-2.5 py-0.5 text-[11px] font-medium rounded transition-colors ${
|
|
1331
|
+
activeTab === "preview"
|
|
1332
|
+
? "bg-accent text-white"
|
|
1333
|
+
: "text-muted hover:text-foreground"
|
|
1334
|
+
}`}
|
|
1335
|
+
>
|
|
1336
|
+
Preview
|
|
1337
|
+
</button>
|
|
1338
|
+
<button
|
|
1339
|
+
onClick={() => setActiveTab("edit")}
|
|
1340
|
+
className={`px-2.5 py-0.5 text-[11px] font-medium rounded transition-colors ${
|
|
1341
|
+
activeTab === "edit"
|
|
1342
|
+
? "bg-accent text-white"
|
|
1343
|
+
: "text-muted hover:text-foreground"
|
|
1344
|
+
}`}
|
|
1345
|
+
>
|
|
1346
|
+
Edit
|
|
1347
|
+
{dirty && <span className="ml-1 text-amber-200">*</span>}
|
|
1348
|
+
</button>
|
|
1349
|
+
</div>
|
|
1350
|
+
) : (
|
|
1351
|
+
<div className="flex items-center gap-2">
|
|
1352
|
+
<span
|
|
1353
|
+
className={`text-xs font-mono ${overLimit ? "text-error font-medium" : "text-muted"}`}
|
|
1354
|
+
>
|
|
1355
|
+
{charCount.toLocaleString()}
|
|
1356
|
+
{charLimit !== null
|
|
1357
|
+
? `/${charLimit.toLocaleString()}`
|
|
1358
|
+
: " chars"}
|
|
1308
1359
|
</span>
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
onClick={() => setActiveTab("preview")}
|
|
1317
|
-
className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
|
|
1318
|
-
activeTab === "preview"
|
|
1319
|
-
? "border-accent text-accent"
|
|
1320
|
-
: "border-transparent text-muted hover:text-foreground"
|
|
1321
|
-
}`}
|
|
1322
|
-
>
|
|
1323
|
-
Preview
|
|
1324
|
-
</button>
|
|
1325
|
-
<button
|
|
1326
|
-
onClick={() => setActiveTab("edit")}
|
|
1327
|
-
className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
|
|
1328
|
-
activeTab === "edit"
|
|
1329
|
-
? "border-accent text-accent"
|
|
1330
|
-
: "border-transparent text-muted hover:text-foreground"
|
|
1331
|
-
}`}
|
|
1332
|
-
>
|
|
1333
|
-
Edit
|
|
1334
|
-
{dirty && <span className="ml-1 text-amber-600">*</span>}
|
|
1335
|
-
</button>
|
|
1360
|
+
{overLimit && (
|
|
1361
|
+
<span className="text-error text-xs font-medium">
|
|
1362
|
+
{(charCount - charLimit).toLocaleString()} over limit
|
|
1363
|
+
</span>
|
|
1364
|
+
)}
|
|
1365
|
+
</div>
|
|
1366
|
+
)}
|
|
1336
1367
|
</div>
|
|
1368
|
+
{!isCartoonEpisode && (
|
|
1369
|
+
<div className="flex px-3 gap-1">
|
|
1370
|
+
<button
|
|
1371
|
+
onClick={() => setActiveTab("preview")}
|
|
1372
|
+
className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
|
|
1373
|
+
activeTab === "preview"
|
|
1374
|
+
? "border-accent text-accent"
|
|
1375
|
+
: "border-transparent text-muted hover:text-foreground"
|
|
1376
|
+
}`}
|
|
1377
|
+
>
|
|
1378
|
+
Preview
|
|
1379
|
+
</button>
|
|
1380
|
+
<button
|
|
1381
|
+
onClick={() => setActiveTab("edit")}
|
|
1382
|
+
className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
|
|
1383
|
+
activeTab === "edit"
|
|
1384
|
+
? "border-accent text-accent"
|
|
1385
|
+
: "border-transparent text-muted hover:text-foreground"
|
|
1386
|
+
}`}
|
|
1387
|
+
>
|
|
1388
|
+
Edit
|
|
1389
|
+
{dirty && <span className="ml-1 text-amber-600">*</span>}
|
|
1390
|
+
</button>
|
|
1391
|
+
</div>
|
|
1392
|
+
)}
|
|
1337
1393
|
</div>
|
|
1338
1394
|
)}
|
|
1339
1395
|
|
|
1340
1396
|
{/* Content area */}
|
|
1341
1397
|
{activeTab === "preview" ? (
|
|
1342
|
-
|
|
1398
|
+
isCartoonEpisode ? (
|
|
1343
1399
|
<div
|
|
1344
1400
|
className="flex-1 min-h-0 flex flex-col"
|
|
1345
1401
|
style={{ background: "var(--paper-bg)" }}
|
|
1346
1402
|
>
|
|
1347
|
-
{/* Two explicit modes: Publish Preview (exact PlotLink markdown) vs
|
|
1348
|
-
Cut Inspector (cuts.json planning metadata) — see #289. */}
|
|
1349
|
-
<div className="flex gap-1 px-3 py-1 border-b border-border">
|
|
1350
|
-
<button
|
|
1351
|
-
data-testid="cartoon-mode-publish"
|
|
1352
|
-
onClick={() => setCartoonPreviewMode("publish")}
|
|
1353
|
-
className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "publish" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
|
|
1354
|
-
>
|
|
1355
|
-
Publish Preview
|
|
1356
|
-
</button>
|
|
1357
|
-
<button
|
|
1358
|
-
data-testid="cartoon-mode-inspect"
|
|
1359
|
-
onClick={() => setCartoonPreviewMode("inspect")}
|
|
1360
|
-
className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "inspect" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
|
|
1361
|
-
>
|
|
1362
|
-
Cut Inspector
|
|
1363
|
-
</button>
|
|
1364
|
-
</div>
|
|
1365
1403
|
<div className="flex-1 min-h-0">
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
/>
|
|
1378
|
-
)}
|
|
1404
|
+
<CutListPanel
|
|
1405
|
+
storyName={storyName!}
|
|
1406
|
+
fileName={fileName!}
|
|
1407
|
+
authFetch={authFetch}
|
|
1408
|
+
language={language}
|
|
1409
|
+
mode="preview"
|
|
1410
|
+
onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
|
|
1411
|
+
focusRequest={cutFocus}
|
|
1412
|
+
onFocusHandled={() => setCutFocus(null)}
|
|
1413
|
+
compactEpisodeChrome
|
|
1414
|
+
/>
|
|
1379
1415
|
</div>
|
|
1380
1416
|
</div>
|
|
1381
1417
|
) : (
|
|
@@ -1397,7 +1433,7 @@ export function PreviewPanel({
|
|
|
1397
1433
|
)}
|
|
1398
1434
|
</div>
|
|
1399
1435
|
)
|
|
1400
|
-
) :
|
|
1436
|
+
) : isCartoonEpisode ? (
|
|
1401
1437
|
<div
|
|
1402
1438
|
className="flex-1 min-h-[22rem] overflow-hidden"
|
|
1403
1439
|
style={{ background: "var(--paper-bg)" }}
|
|
@@ -1407,66 +1443,23 @@ export function PreviewPanel({
|
|
|
1407
1443
|
fileName={fileName!}
|
|
1408
1444
|
authFetch={authFetch}
|
|
1409
1445
|
language={language}
|
|
1446
|
+
mode="edit"
|
|
1410
1447
|
onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
|
|
1411
1448
|
focusRequest={cutFocus}
|
|
1412
1449
|
onFocusHandled={() => setCutFocus(null)}
|
|
1413
1450
|
onFocusedLetteringModeChange={onFocusedLetteringModeChange}
|
|
1414
1451
|
workspaceVisible={focusedLetteringWorkspaceVisible}
|
|
1415
1452
|
onWorkspaceVisibleChange={onFocusedLetteringWorkspaceVisibleChange}
|
|
1453
|
+
onExitFocusedEditor={() => setActiveTab("preview")}
|
|
1454
|
+
compactEpisodeChrome
|
|
1416
1455
|
/>
|
|
1417
1456
|
</div>
|
|
1418
|
-
) : isCartoonGenesis ? (
|
|
1419
|
-
// Genesis Edit tab: opening-text editor vs. its cut workspace (#429), so
|
|
1420
|
-
// the coach's lettering/upload/refresh actions for Episode 1 are actionable
|
|
1421
|
-
// and Genesis cuts get the same workspace as plots — without losing the
|
|
1422
|
-
// hand-written opening prose editor.
|
|
1423
|
-
<div
|
|
1424
|
-
className="flex-1 min-h-0 flex flex-col"
|
|
1425
|
-
style={{ background: "var(--paper-bg)" }}
|
|
1426
|
-
>
|
|
1427
|
-
<div className="flex gap-1 px-3 py-1 border-b border-border">
|
|
1428
|
-
<button
|
|
1429
|
-
data-testid="genesis-edit-mode-text"
|
|
1430
|
-
onClick={() => setGenesisEditMode("text")}
|
|
1431
|
-
className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "text" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
|
|
1432
|
-
>
|
|
1433
|
-
Opening text
|
|
1434
|
-
</button>
|
|
1435
|
-
<button
|
|
1436
|
-
data-testid="genesis-edit-mode-cuts"
|
|
1437
|
-
onClick={() => setGenesisEditMode("cuts")}
|
|
1438
|
-
className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "cuts" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
|
|
1439
|
-
>
|
|
1440
|
-
Cuts
|
|
1441
|
-
</button>
|
|
1442
|
-
</div>
|
|
1443
|
-
<div className="flex-1 min-h-0 flex flex-col">
|
|
1444
|
-
{genesisEditMode === "cuts" ? (
|
|
1445
|
-
<CutListPanel
|
|
1446
|
-
storyName={storyName!}
|
|
1447
|
-
fileName={fileName!}
|
|
1448
|
-
authFetch={authFetch}
|
|
1449
|
-
language={language}
|
|
1450
|
-
onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
|
|
1451
|
-
focusRequest={cutFocus}
|
|
1452
|
-
onFocusHandled={() => setCutFocus(null)}
|
|
1453
|
-
onFocusedLetteringModeChange={onFocusedLetteringModeChange}
|
|
1454
|
-
workspaceVisible={focusedLetteringWorkspaceVisible}
|
|
1455
|
-
onWorkspaceVisibleChange={
|
|
1456
|
-
onFocusedLetteringWorkspaceVisibleChange
|
|
1457
|
-
}
|
|
1458
|
-
/>
|
|
1459
|
-
) : (
|
|
1460
|
-
proseEditor
|
|
1461
|
-
)}
|
|
1462
|
-
</div>
|
|
1463
|
-
</div>
|
|
1464
1457
|
) : (
|
|
1465
1458
|
proseEditor
|
|
1466
1459
|
)}
|
|
1467
1460
|
|
|
1468
1461
|
{/* Action bar */}
|
|
1469
|
-
{!hideFocusedEditorChrome && (
|
|
1462
|
+
{!hideFocusedEditorChrome && !isCartoonEpisode && (
|
|
1470
1463
|
<div
|
|
1471
1464
|
className="shrink-0 px-3 py-2 border-t border-border flex items-center justify-between bg-surface/95"
|
|
1472
1465
|
data-testid="preview-panel-footer"
|
|
@@ -1939,9 +1932,7 @@ export function PreviewPanel({
|
|
|
1939
1932
|
cut/lettering editor gets the height — the cover stays available
|
|
1940
1933
|
in the Opening-text/Preview view, Story Info, and the Publish page,
|
|
1941
1934
|
and the auto-detect effect still loads it for publish. */}
|
|
1942
|
-
{isGenesis &&
|
|
1943
|
-
contentType !== "cartoon" &&
|
|
1944
|
-
!(activeTab === "edit" && genesisEditMode === "cuts") && (
|
|
1935
|
+
{isGenesis && contentType !== "cartoon" && (
|
|
1945
1936
|
<div
|
|
1946
1937
|
className="flex flex-col gap-1.5"
|
|
1947
1938
|
data-testid="prepublish-cover"
|
|
@@ -2077,7 +2068,7 @@ export function PreviewPanel({
|
|
|
2077
2068
|
</div>
|
|
2078
2069
|
</div>
|
|
2079
2070
|
</div>
|
|
2080
|
-
|
|
2071
|
+
)}
|
|
2081
2072
|
{/* Public title shown + validated before publish (#358). #461: moved
|
|
2082
2073
|
to the Publish tab for cartoon — fiction keeps it inline. */}
|
|
2083
2074
|
{!isCartoonEpisode && renderPublishTitle()}
|
|
@@ -1011,9 +1011,8 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1011
1011
|
selectedStory,
|
|
1012
1012
|
);
|
|
1013
1013
|
|
|
1014
|
-
// Cartoon-only right-panel workflow nav
|
|
1015
|
-
//
|
|
1016
|
-
// ⇒ Whitepaper, genesis.md ⇒ Genesis / Ep 1, plot-NN ⇒ Episodes, else Progress.
|
|
1014
|
+
// Cartoon-only right-panel workflow nav. Cartoon users work from episodes in
|
|
1015
|
+
// the left browser; the top nav stays at story/workflow level only.
|
|
1017
1016
|
const isCartoonStory = !!selectedStory && selectedContentType === "cartoon";
|
|
1018
1017
|
const activeCartoonTab: CartoonWorkflowTab =
|
|
1019
1018
|
cartoonView === "story-info"
|
|
@@ -1022,13 +1021,10 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1022
1021
|
? "episodes"
|
|
1023
1022
|
: cartoonView === "publish"
|
|
1024
1023
|
? "publish"
|
|
1025
|
-
: selectedFile
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
: selectedFile && /^plot-\d+\.md$/.test(selectedFile)
|
|
1030
|
-
? "episodes"
|
|
1031
|
-
: "progress";
|
|
1024
|
+
: selectedFile &&
|
|
1025
|
+
(selectedFile === "genesis.md" || /^plot-\d+\.md$/.test(selectedFile))
|
|
1026
|
+
? "episodes"
|
|
1027
|
+
: "progress";
|
|
1032
1028
|
|
|
1033
1029
|
const handleCartoonNav = useCallback(
|
|
1034
1030
|
(tab: CartoonWorkflowTab) => {
|
|
@@ -1049,12 +1045,6 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1049
1045
|
case "episodes":
|
|
1050
1046
|
setCartoonView("episodes");
|
|
1051
1047
|
break;
|
|
1052
|
-
case "whitepaper":
|
|
1053
|
-
handleSelectFile(story, "structure.md");
|
|
1054
|
-
break;
|
|
1055
|
-
case "genesis":
|
|
1056
|
-
handleSelectFile(story, "genesis.md");
|
|
1057
|
-
break;
|
|
1058
1048
|
// Publish opens its own readiness page and stays on the Publish tab (#449),
|
|
1059
1049
|
// instead of visually routing to the Genesis file view.
|
|
1060
1050
|
case "publish":
|
|
@@ -1298,7 +1288,10 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
1298
1288
|
/>
|
|
1299
1289
|
)}
|
|
1300
1290
|
</div>
|
|
1301
|
-
{!focusedLetteringMode &&
|
|
1291
|
+
{!focusedLetteringMode &&
|
|
1292
|
+
isCartoonStory &&
|
|
1293
|
+
selectedStory &&
|
|
1294
|
+
!selectedFile && (
|
|
1302
1295
|
<div
|
|
1303
1296
|
className="pointer-events-none absolute bottom-4 right-4 z-10 w-[min(22rem,calc(100%-2rem))]"
|
|
1304
1297
|
data-testid="workflow-persistent-next-action"
|