plotlink-ows 1.2.96 → 1.2.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/lib/cartoon-production-status.ts +77 -0
- package/app/web/components/CartoonNextAction.tsx +154 -36
- package/app/web/components/CartoonProductionStatus.tsx +142 -0
- package/app/web/components/CartoonPublishPage.tsx +34 -25
- package/app/web/components/CutListPanel.tsx +250 -196
- package/app/web/components/CutOverlayPreview.tsx +306 -0
- package/app/web/components/EpisodesPage.tsx +24 -0
- package/app/web/components/FinishEpisodePanel.tsx +21 -108
- package/app/web/components/LetteringEditor.tsx +180 -109
- package/app/web/components/PreviewPanel.tsx +58 -63
- package/app/web/components/StoriesPage.tsx +99 -78
- package/app/web/components/StoryInfoPage.tsx +31 -14
- package/app/web/components/StoryProgressPanel.tsx +19 -23
- package/app/web/dist/assets/{export-cut-BqZI0-Rv.js → export-cut-Cj-cOtan.js} +1 -1
- package/app/web/dist/assets/index-CCeEYE7p.css +32 -0
- package/app/web/dist/assets/index-CF7pE09m.js +141 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-C43toXVm.js +0 -141
- package/app/web/dist/assets/index-CcfChGEK.css +0 -32
|
@@ -224,6 +224,7 @@ export function LetteringEditor({
|
|
|
224
224
|
const [exporting, setExporting] = useState(false);
|
|
225
225
|
const [exportError, setExportError] = useState<string | null>(null);
|
|
226
226
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
227
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
227
228
|
const [imageBounds, setImageBounds] = useState({
|
|
228
229
|
x: 0,
|
|
229
230
|
y: 0,
|
|
@@ -681,6 +682,23 @@ export function LetteringEditor({
|
|
|
681
682
|
displayFontFamily,
|
|
682
683
|
]);
|
|
683
684
|
const warningCount = Object.keys(overlayWarnings).length;
|
|
685
|
+
const checklistChips: Array<{
|
|
686
|
+
key: string;
|
|
687
|
+
label: string;
|
|
688
|
+
done: boolean;
|
|
689
|
+
}> = [
|
|
690
|
+
{ key: "clean-image", label: "Clean", done: checklist.hasCleanImage },
|
|
691
|
+
{ key: "script-text", label: "Script", done: checklist.hasScriptText },
|
|
692
|
+
{
|
|
693
|
+
key: "bubbles",
|
|
694
|
+
label: checklist.bubblesPlaced
|
|
695
|
+
? `Bubbles ${checklist.bubblesPlaced}`
|
|
696
|
+
: "Bubbles",
|
|
697
|
+
done: checklist.bubblesPlaced > 0,
|
|
698
|
+
},
|
|
699
|
+
{ key: "exported", label: "Exported", done: checklist.exported },
|
|
700
|
+
{ key: "uploaded", label: "Uploaded", done: checklist.uploaded },
|
|
701
|
+
];
|
|
684
702
|
|
|
685
703
|
const isTextPanel = cut.kind === "text";
|
|
686
704
|
const isNarrationCut = !cut.cleanImagePath;
|
|
@@ -708,42 +726,81 @@ export function LetteringEditor({
|
|
|
708
726
|
data-testid="focused-lettering-editor"
|
|
709
727
|
>
|
|
710
728
|
{/* Toolbar */}
|
|
711
|
-
<div
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
</span>
|
|
717
|
-
<span className="text-xs font-mono text-muted">
|
|
718
|
-
{targetLabel ?? `Cut #${cut.id}`}
|
|
719
|
-
</span>
|
|
720
|
-
</div>
|
|
721
|
-
<p className="mt-0.5 text-[11px] text-muted">
|
|
722
|
-
Place bubbles, captions, SFX, or between-scene card text, then save
|
|
723
|
-
back to the full cut review.
|
|
724
|
-
</p>
|
|
725
|
-
<span className="text-[10px] text-muted" data-testid="overlay-count">
|
|
726
|
-
{overlays.length} overlays
|
|
727
|
-
</span>
|
|
728
|
-
</div>
|
|
729
|
-
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
729
|
+
<div
|
|
730
|
+
className="px-3 py-2 border-b border-border bg-surface/40 flex items-center justify-between gap-2 flex-wrap"
|
|
731
|
+
data-testid="lettering-toolbar"
|
|
732
|
+
>
|
|
733
|
+
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
|
730
734
|
<button
|
|
731
735
|
onClick={onClose}
|
|
732
|
-
className="px-
|
|
736
|
+
className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:text-foreground"
|
|
733
737
|
data-testid="return-to-cut-review-btn"
|
|
734
738
|
>
|
|
735
739
|
Back to cut review
|
|
736
740
|
</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.16em] text-accent">
|
|
742
|
+
Focused lettering editor
|
|
743
|
+
</span>
|
|
744
|
+
<span className="text-[11px] font-mono text-muted">
|
|
745
|
+
{targetLabel ?? `Cut #${cut.id}`}
|
|
746
|
+
</span>
|
|
747
|
+
<span
|
|
748
|
+
className="rounded-full border border-border bg-background px-2 py-0.5 text-[10px] text-muted"
|
|
749
|
+
data-testid="overlay-count"
|
|
750
|
+
>
|
|
751
|
+
{overlays.length} overlays
|
|
752
|
+
</span>
|
|
753
|
+
{checklistChips.map((chip) => (
|
|
754
|
+
<span
|
|
755
|
+
key={chip.key}
|
|
756
|
+
data-testid={`lettering-check-${chip.key}`}
|
|
757
|
+
data-done={chip.done ? "true" : "false"}
|
|
758
|
+
className={`rounded-full border px-2 py-0.5 text-[10px] ${
|
|
759
|
+
chip.done
|
|
760
|
+
? "border-green-700/30 bg-green-700/10 text-green-700"
|
|
761
|
+
: "border-border bg-background text-muted"
|
|
762
|
+
}`}
|
|
763
|
+
>
|
|
764
|
+
{chip.done ? "✓ " : "○ "}
|
|
765
|
+
{chip.label}
|
|
766
|
+
</span>
|
|
767
|
+
))}
|
|
768
|
+
{cut.aiDraft?.status === "generated" && (
|
|
769
|
+
<span
|
|
770
|
+
className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] text-accent"
|
|
771
|
+
data-testid="ai-draft-current-target"
|
|
772
|
+
>
|
|
773
|
+
AI draft ready
|
|
774
|
+
</span>
|
|
775
|
+
)}
|
|
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
|
+
</div>
|
|
785
|
+
<div className="flex items-center gap-1.5 flex-wrap justify-end">
|
|
737
786
|
{onToggleWorkspaceVisible && (
|
|
738
787
|
<button
|
|
739
788
|
onClick={onToggleWorkspaceVisible}
|
|
740
|
-
className="px-
|
|
789
|
+
className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:border-accent hover:text-accent"
|
|
741
790
|
data-testid="toggle-work-area-btn"
|
|
742
791
|
>
|
|
743
792
|
{workspaceVisible ? "Hide work area" : "Show work area"}
|
|
744
793
|
</button>
|
|
745
794
|
)}
|
|
746
|
-
<
|
|
795
|
+
<button
|
|
796
|
+
type="button"
|
|
797
|
+
onClick={() => setShowHelp((prev) => !prev)}
|
|
798
|
+
className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:border-accent hover:text-accent"
|
|
799
|
+
data-testid="lettering-help-toggle"
|
|
800
|
+
>
|
|
801
|
+
{showHelp ? "Hide help" : "Help"}
|
|
802
|
+
</button>
|
|
803
|
+
<div className="flex items-center gap-1">
|
|
747
804
|
<button
|
|
748
805
|
onClick={() => addOverlay("speech")}
|
|
749
806
|
className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
@@ -767,15 +824,19 @@ export function LetteringEditor({
|
|
|
767
824
|
</button>
|
|
768
825
|
</div>
|
|
769
826
|
{exportError && (
|
|
770
|
-
<span className="text-[10px] text-error">
|
|
827
|
+
<span className="text-[10px] text-error max-w-[18rem]">
|
|
828
|
+
{exportError}
|
|
829
|
+
</span>
|
|
771
830
|
)}
|
|
772
831
|
{saveError && (
|
|
773
|
-
<span className="text-[10px] text-error">
|
|
832
|
+
<span className="text-[10px] text-error max-w-[18rem]">
|
|
833
|
+
{saveError}
|
|
834
|
+
</span>
|
|
774
835
|
)}
|
|
775
836
|
<button
|
|
776
837
|
onClick={handleExport}
|
|
777
838
|
disabled={exporting}
|
|
778
|
-
className="px-
|
|
839
|
+
className="px-2.5 py-1 text-[11px] border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50"
|
|
779
840
|
data-testid="export-btn"
|
|
780
841
|
>
|
|
781
842
|
{exporting ? "Exporting..." : "Export"}
|
|
@@ -784,14 +845,14 @@ export function LetteringEditor({
|
|
|
784
845
|
onClick={() => {
|
|
785
846
|
void handleSave();
|
|
786
847
|
}}
|
|
787
|
-
className="px-
|
|
848
|
+
className="px-2.5 py-1 text-[11px] bg-accent text-white rounded hover:bg-accent-dim"
|
|
788
849
|
data-testid="save-lettering-btn"
|
|
789
850
|
>
|
|
790
851
|
Save
|
|
791
852
|
</button>
|
|
792
853
|
<button
|
|
793
854
|
onClick={onClose}
|
|
794
|
-
className="px-
|
|
855
|
+
className="px-2.5 py-1 text-[11px] text-muted hover:text-foreground border border-border rounded"
|
|
795
856
|
data-testid="cancel-lettering-btn"
|
|
796
857
|
>
|
|
797
858
|
Cancel
|
|
@@ -799,6 +860,17 @@ export function LetteringEditor({
|
|
|
799
860
|
</div>
|
|
800
861
|
</div>
|
|
801
862
|
|
|
863
|
+
{showHelp && (
|
|
864
|
+
<div
|
|
865
|
+
className="px-3 py-1.5 border-b border-border bg-background text-[10px] text-muted"
|
|
866
|
+
data-testid="lettering-help-panel"
|
|
867
|
+
>
|
|
868
|
+
Add or select a bubble, edit it in the inspector, then Save to return
|
|
869
|
+
to cut review. Use Export after the overlay layout is ready. Text cards
|
|
870
|
+
use narration overlays on the canvas.
|
|
871
|
+
</div>
|
|
872
|
+
)}
|
|
873
|
+
|
|
802
874
|
{invalidOverlayCount > 0 && !acknowledgedInvalid ? (
|
|
803
875
|
<div
|
|
804
876
|
className="px-3 py-1 border-b border-border bg-error/10 text-[10px] text-error flex items-center gap-2 flex-wrap"
|
|
@@ -856,40 +928,9 @@ export function LetteringEditor({
|
|
|
856
928
|
</div>
|
|
857
929
|
)}
|
|
858
930
|
|
|
859
|
-
{/* Per-cut lettering checklist (#336): shows how far this cut has come so
|
|
860
|
-
the writer can finish it from the editor without inspecting cuts.json. */}
|
|
861
|
-
<div
|
|
862
|
-
className="px-3 py-1 border-b border-border flex items-center gap-3 flex-wrap text-[10px] text-muted"
|
|
863
|
-
data-testid="lettering-checklist"
|
|
864
|
-
>
|
|
865
|
-
{(
|
|
866
|
-
[
|
|
867
|
-
["clean-image", "Clean image", checklist.hasCleanImage],
|
|
868
|
-
["script-text", "Script text", checklist.hasScriptText],
|
|
869
|
-
[
|
|
870
|
-
"bubbles",
|
|
871
|
-
`Bubbles placed${checklist.bubblesPlaced ? ` (${checklist.bubblesPlaced})` : ""}`,
|
|
872
|
-
checklist.bubblesPlaced > 0,
|
|
873
|
-
],
|
|
874
|
-
["exported", "Final exported", checklist.exported],
|
|
875
|
-
["uploaded", "Uploaded", checklist.uploaded],
|
|
876
|
-
] as [string, string, boolean][]
|
|
877
|
-
).map(([key, label, done]) => (
|
|
878
|
-
<span
|
|
879
|
-
key={key}
|
|
880
|
-
data-testid={`lettering-check-${key}`}
|
|
881
|
-
data-done={done ? "true" : "false"}
|
|
882
|
-
className={`flex items-center gap-1 ${done ? "text-green-700" : "text-muted/70"}`}
|
|
883
|
-
>
|
|
884
|
-
<span aria-hidden>{done ? "✓" : "○"}</span>
|
|
885
|
-
{label}
|
|
886
|
-
</span>
|
|
887
|
-
))}
|
|
888
|
-
</div>
|
|
889
|
-
|
|
890
931
|
{/* Stale-export warning (#336, re1): bubbles changed since the recorded
|
|
891
932
|
export/upload, so the final image/uploaded URL are out of date. The
|
|
892
|
-
|
|
933
|
+
compact toolbar chips already mark export/upload incomplete; this says why. */}
|
|
893
934
|
{staleExport && (
|
|
894
935
|
<div
|
|
895
936
|
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
@@ -1175,55 +1216,16 @@ export function LetteringEditor({
|
|
|
1175
1216
|
|
|
1176
1217
|
{/* Inspector panel */}
|
|
1177
1218
|
<div className="w-64 border-l border-border p-3 overflow-y-auto flex-shrink-0">
|
|
1178
|
-
{cut.aiDraft?.status === "generated" && (
|
|
1179
|
-
<div
|
|
1180
|
-
className="mb-3 rounded border border-accent/30 bg-accent/5 p-2 space-y-1"
|
|
1181
|
-
data-testid="ai-draft-current-target"
|
|
1182
|
-
>
|
|
1183
|
-
<p className="text-[10px] font-bold uppercase tracking-[0.14em] text-accent">
|
|
1184
|
-
AI draft ready
|
|
1185
|
-
</p>
|
|
1186
|
-
<p className="text-[11px] text-muted">
|
|
1187
|
-
These first-pass overlays came from the cut script. Review and
|
|
1188
|
-
tune them here before exporting the final panel.
|
|
1189
|
-
</p>
|
|
1190
|
-
</div>
|
|
1191
|
-
)}
|
|
1192
|
-
{/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
|
|
1193
|
-
SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
|
|
1194
|
-
{scriptLines.length > 0 && (
|
|
1195
|
-
<div className="mb-3 space-y-1.5" data-testid="script-insert-panel">
|
|
1196
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1197
|
-
From script
|
|
1198
|
-
</span>
|
|
1199
|
-
<div className="flex flex-col gap-1">
|
|
1200
|
-
{scriptLines.map((line) => (
|
|
1201
|
-
<button
|
|
1202
|
-
key={line.key}
|
|
1203
|
-
onClick={() => addScriptLine(line)}
|
|
1204
|
-
data-testid={`script-insert-${line.key}`}
|
|
1205
|
-
title={`Add ${line.type} overlay with this text`}
|
|
1206
|
-
className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
1207
|
-
>
|
|
1208
|
-
<span className="font-medium text-accent">
|
|
1209
|
-
+ {TYPE_LABEL[line.type]}
|
|
1210
|
-
</span>{" "}
|
|
1211
|
-
<span className="text-muted">
|
|
1212
|
-
{line.speaker ? `${line.speaker}: ` : ""}
|
|
1213
|
-
{line.text.length > 32
|
|
1214
|
-
? `${line.text.slice(0, 32)}…`
|
|
1215
|
-
: line.text}
|
|
1216
|
-
</span>
|
|
1217
|
-
</button>
|
|
1218
|
-
))}
|
|
1219
|
-
</div>
|
|
1220
|
-
</div>
|
|
1221
|
-
)}
|
|
1222
1219
|
{selectedOverlay ? (
|
|
1223
1220
|
<div className="space-y-3">
|
|
1224
|
-
<
|
|
1225
|
-
|
|
1226
|
-
|
|
1221
|
+
<div className="flex items-center justify-between gap-2">
|
|
1222
|
+
<p className="text-xs font-medium text-foreground">
|
|
1223
|
+
{TYPE_LABEL[selectedOverlay.type]}
|
|
1224
|
+
</p>
|
|
1225
|
+
<span className="text-[10px] text-muted">
|
|
1226
|
+
#{overlays.findIndex((o) => o.id === selectedOverlay.id) + 1}
|
|
1227
|
+
</span>
|
|
1228
|
+
</div>
|
|
1227
1229
|
|
|
1228
1230
|
{selectedOverlay.speaker !== undefined && (
|
|
1229
1231
|
<label className="block space-y-1">
|
|
@@ -1629,11 +1631,80 @@ export function LetteringEditor({
|
|
|
1629
1631
|
>
|
|
1630
1632
|
{confirmDelete ? "Click again to delete" : "Delete"}
|
|
1631
1633
|
</button>
|
|
1634
|
+
|
|
1635
|
+
{scriptLines.length > 0 && (
|
|
1636
|
+
<div
|
|
1637
|
+
className="space-y-1.5 border-t border-border pt-3"
|
|
1638
|
+
data-testid="script-insert-panel"
|
|
1639
|
+
>
|
|
1640
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1641
|
+
Add from script
|
|
1642
|
+
</span>
|
|
1643
|
+
<div className="flex flex-col gap-1">
|
|
1644
|
+
{scriptLines.map((line) => (
|
|
1645
|
+
<button
|
|
1646
|
+
key={line.key}
|
|
1647
|
+
onClick={() => addScriptLine(line)}
|
|
1648
|
+
data-testid={`script-insert-${line.key}`}
|
|
1649
|
+
title={`Add ${line.type} overlay with this text`}
|
|
1650
|
+
className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
1651
|
+
>
|
|
1652
|
+
<span className="font-medium text-accent">
|
|
1653
|
+
+ {TYPE_LABEL[line.type]}
|
|
1654
|
+
</span>{" "}
|
|
1655
|
+
<span className="text-muted">
|
|
1656
|
+
{line.speaker ? `${line.speaker}: ` : ""}
|
|
1657
|
+
{line.text.length > 32
|
|
1658
|
+
? `${line.text.slice(0, 32)}…`
|
|
1659
|
+
: line.text}
|
|
1660
|
+
</span>
|
|
1661
|
+
</button>
|
|
1662
|
+
))}
|
|
1663
|
+
</div>
|
|
1664
|
+
</div>
|
|
1665
|
+
)}
|
|
1632
1666
|
</div>
|
|
1633
1667
|
) : (
|
|
1634
|
-
<
|
|
1635
|
-
|
|
1636
|
-
|
|
1668
|
+
<div className="space-y-3">
|
|
1669
|
+
<p className="text-xs text-muted" data-testid="inspector-empty">
|
|
1670
|
+
Select or add an overlay to inspect it.
|
|
1671
|
+
</p>
|
|
1672
|
+
{cut.aiDraft?.status === "generated" && (
|
|
1673
|
+
<div className="rounded border border-accent/30 bg-accent/5 p-2 text-[10px] text-muted">
|
|
1674
|
+
AI drafted overlays are editable here before export.
|
|
1675
|
+
</div>
|
|
1676
|
+
)}
|
|
1677
|
+
{/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
|
|
1678
|
+
SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
|
|
1679
|
+
{scriptLines.length > 0 && (
|
|
1680
|
+
<div className="space-y-1.5" data-testid="script-insert-panel">
|
|
1681
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1682
|
+
Add from script
|
|
1683
|
+
</span>
|
|
1684
|
+
<div className="flex flex-col gap-1">
|
|
1685
|
+
{scriptLines.map((line) => (
|
|
1686
|
+
<button
|
|
1687
|
+
key={line.key}
|
|
1688
|
+
onClick={() => addScriptLine(line)}
|
|
1689
|
+
data-testid={`script-insert-${line.key}`}
|
|
1690
|
+
title={`Add ${line.type} overlay with this text`}
|
|
1691
|
+
className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
1692
|
+
>
|
|
1693
|
+
<span className="font-medium text-accent">
|
|
1694
|
+
+ {TYPE_LABEL[line.type]}
|
|
1695
|
+
</span>{" "}
|
|
1696
|
+
<span className="text-muted">
|
|
1697
|
+
{line.speaker ? `${line.speaker}: ` : ""}
|
|
1698
|
+
{line.text.length > 32
|
|
1699
|
+
? `${line.text.slice(0, 32)}…`
|
|
1700
|
+
: line.text}
|
|
1701
|
+
</span>
|
|
1702
|
+
</button>
|
|
1703
|
+
))}
|
|
1704
|
+
</div>
|
|
1705
|
+
</div>
|
|
1706
|
+
)}
|
|
1707
|
+
</div>
|
|
1637
1708
|
)}
|
|
1638
1709
|
</div>
|
|
1639
1710
|
</div>
|
|
@@ -4,11 +4,10 @@ import remarkBreaks from "remark-breaks";
|
|
|
4
4
|
import remarkGfm from "remark-gfm";
|
|
5
5
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
6
6
|
import { GENRES, LANGUAGES, canonicalizeGenre } from "../../../lib/genres";
|
|
7
|
+
import type { CoachUiAction } from "@app-lib/cartoon-coach";
|
|
7
8
|
import { CartoonPreview } from "./CartoonPreview";
|
|
8
9
|
import { CartoonPublishPreview } from "./CartoonPublishPreview";
|
|
9
10
|
import { CutListPanel } from "./CutListPanel";
|
|
10
|
-
import { WorkflowCoach } from "./WorkflowCoach";
|
|
11
|
-
import type { CoachUiAction } from "@app-lib/cartoon-coach";
|
|
12
11
|
import {
|
|
13
12
|
classifyCartoonReadiness,
|
|
14
13
|
cartoonGenesisReadiness,
|
|
@@ -120,6 +119,11 @@ interface PreviewPanelProps {
|
|
|
120
119
|
onFocusedLetteringModeChange?: (active: boolean) => void;
|
|
121
120
|
/** Restore/fold the wider app work area while staying in the editor. */
|
|
122
121
|
onFocusedLetteringWorkspaceVisibleChange?: (visible: boolean) => void;
|
|
122
|
+
workflowActionRequest?: {
|
|
123
|
+
action: CoachUiAction;
|
|
124
|
+
seq: number;
|
|
125
|
+
} | null;
|
|
126
|
+
onWorkflowActionHandled?: (seq: number) => void;
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
interface FileData {
|
|
@@ -136,6 +140,13 @@ interface FileData {
|
|
|
136
140
|
|
|
137
141
|
type Tab = "preview" | "edit";
|
|
138
142
|
|
|
143
|
+
function workflowActionNeedsCuts(action: CoachUiAction | null | undefined): boolean {
|
|
144
|
+
return action === "open-cuts"
|
|
145
|
+
|| action === "open-lettering"
|
|
146
|
+
|| action === "upload"
|
|
147
|
+
|| action === "refresh-assets";
|
|
148
|
+
}
|
|
149
|
+
|
|
139
150
|
export function PreviewPanel({
|
|
140
151
|
storyName,
|
|
141
152
|
fileName,
|
|
@@ -155,6 +166,8 @@ export function PreviewPanel({
|
|
|
155
166
|
focusedLetteringWorkspaceVisible = false,
|
|
156
167
|
onFocusedLetteringModeChange,
|
|
157
168
|
onFocusedLetteringWorkspaceVisibleChange,
|
|
169
|
+
workflowActionRequest = null,
|
|
170
|
+
onWorkflowActionHandled,
|
|
158
171
|
}: PreviewPanelProps) {
|
|
159
172
|
const [fileData, setFileData] = useState<FileData | null>(null);
|
|
160
173
|
const [loading, setLoading] = useState(false);
|
|
@@ -269,6 +282,11 @@ export function PreviewPanel({
|
|
|
269
282
|
const illustrationInputRef = useRef<HTMLInputElement>(null);
|
|
270
283
|
|
|
271
284
|
const prevFileRef = useRef<string | null>(null);
|
|
285
|
+
const appliedWorkflowSeqRef = useRef(0);
|
|
286
|
+
const pendingWorkflowCutsRef = useRef(false);
|
|
287
|
+
pendingWorkflowCutsRef.current = workflowActionNeedsCuts(
|
|
288
|
+
workflowActionRequest?.action,
|
|
289
|
+
);
|
|
272
290
|
|
|
273
291
|
const loadFile = useCallback(async () => {
|
|
274
292
|
if (!storyName || !fileName) {
|
|
@@ -533,43 +551,31 @@ export function PreviewPanel({
|
|
|
533
551
|
}
|
|
534
552
|
}, [storyName, fileName, authFetch, loadFile]);
|
|
535
553
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
(action: CoachUiAction, episodeFile: string | null) => {
|
|
544
|
-
if (action === "view-progress") {
|
|
554
|
+
useEffect(() => {
|
|
555
|
+
if (!workflowActionRequest) return;
|
|
556
|
+
if (workflowActionRequest.seq === appliedWorkflowSeqRef.current) return;
|
|
557
|
+
appliedWorkflowSeqRef.current = workflowActionRequest.seq;
|
|
558
|
+
|
|
559
|
+
switch (workflowActionRequest.action) {
|
|
560
|
+
case "view-progress":
|
|
545
561
|
onViewProgress?.();
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
case "generate-markdown":
|
|
564
|
-
handleGenerateMarkdown();
|
|
565
|
-
break;
|
|
566
|
-
case "publish":
|
|
567
|
-
setActiveTab("preview");
|
|
568
|
-
break;
|
|
569
|
-
}
|
|
570
|
-
},
|
|
571
|
-
[fileName, onViewProgress, onOpenFile, handleGenerateMarkdown],
|
|
572
|
-
);
|
|
562
|
+
break;
|
|
563
|
+
case "open-cuts":
|
|
564
|
+
case "open-lettering":
|
|
565
|
+
case "upload":
|
|
566
|
+
case "refresh-assets":
|
|
567
|
+
setActiveTab("edit");
|
|
568
|
+
setGenesisEditMode("cuts");
|
|
569
|
+
break;
|
|
570
|
+
case "generate-markdown":
|
|
571
|
+
handleGenerateMarkdown();
|
|
572
|
+
break;
|
|
573
|
+
case "publish":
|
|
574
|
+
setActiveTab("preview");
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
onWorkflowActionHandled?.(workflowActionRequest.seq);
|
|
578
|
+
}, [workflowActionRequest, onViewProgress, handleGenerateMarkdown, onWorkflowActionHandled]);
|
|
573
579
|
|
|
574
580
|
// Handle cover image selection
|
|
575
581
|
const handleCoverSelect = useCallback(
|
|
@@ -811,7 +817,7 @@ export function PreviewPanel({
|
|
|
811
817
|
setDetectedCoverWarning(null);
|
|
812
818
|
setCoverStatus("unknown");
|
|
813
819
|
coverUserTouchedRef.current = false;
|
|
814
|
-
setGenesisEditMode("text");
|
|
820
|
+
setGenesisEditMode(pendingWorkflowCutsRef.current ? "cuts" : "text");
|
|
815
821
|
}, [storyName, fileName]);
|
|
816
822
|
|
|
817
823
|
// Auto-detect an agent-created cover (assets/cover.webp|jpg) for an UNPUBLISHED
|
|
@@ -1213,7 +1219,8 @@ export function PreviewPanel({
|
|
|
1213
1219
|
// Plain prose editor (fiction files + the Genesis "Opening text" sub-view).
|
|
1214
1220
|
const proseEditor = (
|
|
1215
1221
|
<div
|
|
1216
|
-
className="flex-1 min-h-0 flex flex-col"
|
|
1222
|
+
className="flex-1 min-h-0 flex flex-col overflow-hidden"
|
|
1223
|
+
data-testid="prose-editor-shell"
|
|
1217
1224
|
style={{ background: "var(--paper-bg)" }}
|
|
1218
1225
|
>
|
|
1219
1226
|
<textarea
|
|
@@ -1231,8 +1238,12 @@ export function PreviewPanel({
|
|
|
1231
1238
|
color: "var(--text)",
|
|
1232
1239
|
}}
|
|
1233
1240
|
spellCheck={false}
|
|
1241
|
+
data-testid="prose-editor-textarea"
|
|
1234
1242
|
/>
|
|
1235
|
-
<div
|
|
1243
|
+
<div
|
|
1244
|
+
className="shrink-0 px-3 py-1.5 border-t border-border flex items-center justify-between bg-surface/95"
|
|
1245
|
+
data-testid="prose-editor-savebar"
|
|
1246
|
+
>
|
|
1236
1247
|
<span className="text-xs text-muted">
|
|
1237
1248
|
{dirty ? "Unsaved changes" : "No changes"}
|
|
1238
1249
|
</span>
|
|
@@ -1248,7 +1259,7 @@ export function PreviewPanel({
|
|
|
1248
1259
|
);
|
|
1249
1260
|
|
|
1250
1261
|
return (
|
|
1251
|
-
<div className="h-full flex flex-col">
|
|
1262
|
+
<div className="h-full min-h-0 flex flex-col">
|
|
1252
1263
|
{/* Header with file path + tabs */}
|
|
1253
1264
|
{!hideFocusedEditorChrome && (
|
|
1254
1265
|
<div className="border-b border-border">
|
|
@@ -1326,25 +1337,6 @@ export function PreviewPanel({
|
|
|
1326
1337
|
</div>
|
|
1327
1338
|
)}
|
|
1328
1339
|
|
|
1329
|
-
{/* Persistent cartoon workflow coach (#429): one clear next action across
|
|
1330
|
-
every cartoon file view, derived from real story/episode state. Sits
|
|
1331
|
-
above the content so it stays visible on both the Preview and Edit
|
|
1332
|
-
tabs. Fiction renders nothing (the coach is null), so fiction views are
|
|
1333
|
-
unchanged. */}
|
|
1334
|
-
{!hideFocusedEditorChrome &&
|
|
1335
|
-
contentType === "cartoon" &&
|
|
1336
|
-
storyName &&
|
|
1337
|
-
fileName && (
|
|
1338
|
-
<WorkflowCoach
|
|
1339
|
-
storyName={storyName}
|
|
1340
|
-
fileName={fileName}
|
|
1341
|
-
authFetch={authFetch}
|
|
1342
|
-
refreshKey={cutsRefreshKey}
|
|
1343
|
-
onAction={handleCoachAction}
|
|
1344
|
-
showEmptyState
|
|
1345
|
-
/>
|
|
1346
|
-
)}
|
|
1347
|
-
|
|
1348
1340
|
{/* Content area */}
|
|
1349
1341
|
{activeTab === "preview" ? (
|
|
1350
1342
|
isCartoonPlot ? (
|
|
@@ -1448,7 +1440,7 @@ export function PreviewPanel({
|
|
|
1448
1440
|
Cuts
|
|
1449
1441
|
</button>
|
|
1450
1442
|
</div>
|
|
1451
|
-
<div className="flex-1 min-h-0">
|
|
1443
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
1452
1444
|
{genesisEditMode === "cuts" ? (
|
|
1453
1445
|
<CutListPanel
|
|
1454
1446
|
storyName={storyName!}
|
|
@@ -1475,7 +1467,10 @@ export function PreviewPanel({
|
|
|
1475
1467
|
|
|
1476
1468
|
{/* Action bar */}
|
|
1477
1469
|
{!hideFocusedEditorChrome && (
|
|
1478
|
-
<div
|
|
1470
|
+
<div
|
|
1471
|
+
className="shrink-0 px-3 py-2 border-t border-border flex items-center justify-between bg-surface/95"
|
|
1472
|
+
data-testid="preview-panel-footer"
|
|
1473
|
+
>
|
|
1479
1474
|
{fileName === "structure.md" ? (
|
|
1480
1475
|
<p
|
|
1481
1476
|
className="text-muted text-xs italic"
|