plotlink-ows 1.2.97 → 1.2.98
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/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/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/dist/assets/{export-cut-Cj-cOtan.js → export-cut-DVpOZ5AO.js} +1 -1
- package/app/web/dist/assets/index-CoG6WKyb.js +141 -0
- package/app/web/dist/assets/index-H5_FM885.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-CCeEYE7p.css +0 -32
- package/app/web/dist/assets/index-CF7pE09m.js +0 -141
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A normal webtoon creator should not need the file tree: this compact tab bar
|
|
5
5
|
* sits above the right-panel content whenever a CARTOON story is selected and
|
|
6
|
-
* routes between the workflow pages — Progress, Story Info,
|
|
7
|
-
* Episode
|
|
8
|
-
*
|
|
6
|
+
* routes between the workflow pages — Progress, Story Info, Episodes, Publish.
|
|
7
|
+
* Episode files are selected from the left story browser; opening any episode
|
|
8
|
+
* keeps the workflow tab on Episodes.
|
|
9
9
|
*
|
|
10
10
|
* Fiction renders no nav (the caller only mounts this for cartoon stories), so
|
|
11
11
|
* the fiction UX is unchanged.
|
|
@@ -14,16 +14,12 @@
|
|
|
14
14
|
export type CartoonWorkflowTab =
|
|
15
15
|
| "progress"
|
|
16
16
|
| "story-info"
|
|
17
|
-
| "whitepaper"
|
|
18
|
-
| "genesis"
|
|
19
17
|
| "episodes"
|
|
20
18
|
| "publish";
|
|
21
19
|
|
|
22
20
|
const TABS: { key: CartoonWorkflowTab; label: string }[] = [
|
|
23
21
|
{ key: "progress", label: "Progress" },
|
|
24
22
|
{ key: "story-info", label: "Story Info" },
|
|
25
|
-
{ key: "whitepaper", label: "Whitepaper" },
|
|
26
|
-
{ key: "genesis", label: "Genesis / Ep 1" },
|
|
27
23
|
{ key: "episodes", label: "Episodes" },
|
|
28
24
|
{ key: "publish", label: "Publish" },
|
|
29
25
|
];
|
|
@@ -111,6 +111,12 @@ interface CutListPanelProps {
|
|
|
111
111
|
workspaceVisible?: boolean;
|
|
112
112
|
/** Restore/fold the wider app work area while staying in the editor. */
|
|
113
113
|
onWorkspaceVisibleChange?: (visible: boolean) => void;
|
|
114
|
+
/** Episode-centric cartoon screen mode: preview board or direct lettering edit. */
|
|
115
|
+
mode?: "preview" | "edit";
|
|
116
|
+
/** Called when the focused editor closes back to the episode cut board. */
|
|
117
|
+
onExitFocusedEditor?: () => void;
|
|
118
|
+
/** Let the parent episode header own title/status chrome; render tools at the scroll end. */
|
|
119
|
+
compactEpisodeChrome?: boolean;
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
type CutStatus = "missing" | "clean" | "lettered" | "uploaded" | "text";
|
|
@@ -804,6 +810,9 @@ export function CutListPanel({
|
|
|
804
810
|
onFocusedLetteringModeChange,
|
|
805
811
|
workspaceVisible = false,
|
|
806
812
|
onWorkspaceVisibleChange,
|
|
813
|
+
mode = "preview",
|
|
814
|
+
onExitFocusedEditor,
|
|
815
|
+
compactEpisodeChrome = false,
|
|
807
816
|
}: CutListPanelProps) {
|
|
808
817
|
const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
|
|
809
818
|
// Latest onCutsChanged in a ref so loadCuts can notify the parent without
|
|
@@ -816,6 +825,7 @@ export function CutListPanel({
|
|
|
816
825
|
const [error, setError] = useState<string | null>(null);
|
|
817
826
|
const [expandedCut, setExpandedCut] = useState<number | null>(null);
|
|
818
827
|
const [editingCutId, setEditingCutId] = useState<number | null>(null);
|
|
828
|
+
const autoOpenedEditRef = useRef(false);
|
|
819
829
|
const [generating, setGenerating] = useState(false);
|
|
820
830
|
const [genWarnings, setGenWarnings] = useState<string[]>([]);
|
|
821
831
|
const [uploading, setUploading] = useState(false);
|
|
@@ -888,6 +898,30 @@ export function CutListPanel({
|
|
|
888
898
|
return () => onFocusedLetteringModeChange?.(false);
|
|
889
899
|
}, [editingCutId, onFocusedLetteringModeChange]);
|
|
890
900
|
|
|
901
|
+
useEffect(() => {
|
|
902
|
+
if (mode !== "edit") {
|
|
903
|
+
autoOpenedEditRef.current = false;
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (
|
|
907
|
+
autoOpenedEditRef.current ||
|
|
908
|
+
editingCutId !== null ||
|
|
909
|
+
!cutsFile?.cuts.length
|
|
910
|
+
) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const firstEditable =
|
|
914
|
+
cutsFile.cuts.find(
|
|
915
|
+
(cut) =>
|
|
916
|
+
cut.cleanImagePath ||
|
|
917
|
+
cut.narration ||
|
|
918
|
+
cut.dialogue.length > 0 ||
|
|
919
|
+
isTextPanel(cut),
|
|
920
|
+
) ?? cutsFile.cuts[0];
|
|
921
|
+
autoOpenedEditRef.current = true;
|
|
922
|
+
setEditingCutId(firstEditable.id);
|
|
923
|
+
}, [mode, editingCutId, cutsFile]);
|
|
924
|
+
|
|
891
925
|
// Scroll a deep-linked, expanded cut into view once its row is on screen. Runs
|
|
892
926
|
// when the target is set and again after the cut plan loads (rows mount). Best
|
|
893
927
|
// effort: `scrollIntoView` is a no-op/undefined under jsdom.
|
|
@@ -912,7 +946,14 @@ export function CutListPanel({
|
|
|
912
946
|
return;
|
|
913
947
|
}
|
|
914
948
|
const parsed = await res.json();
|
|
915
|
-
|
|
949
|
+
const normalized: CutsFile = {
|
|
950
|
+
...parsed,
|
|
951
|
+
version: typeof parsed?.version === "number" ? parsed.version : 1,
|
|
952
|
+
plotFile:
|
|
953
|
+
typeof parsed?.plotFile === "string" ? parsed.plotFile : plotFile,
|
|
954
|
+
cuts: Array.isArray(parsed?.cuts) ? parsed.cuts : [],
|
|
955
|
+
};
|
|
956
|
+
setCutsFile(normalized);
|
|
916
957
|
setError(null);
|
|
917
958
|
// Read the episode's publish markdown + on-chain status so the Finish panel
|
|
918
959
|
// can show "Episode sequence prepared" / "Ready to publish" / "Published"
|
|
@@ -926,7 +967,7 @@ export function CutListPanel({
|
|
|
926
967
|
const fd = await fileRes.json();
|
|
927
968
|
const content: string =
|
|
928
969
|
typeof fd?.content === "string" ? fd.content : "";
|
|
929
|
-
const cuts =
|
|
970
|
+
const cuts = normalized.cuts;
|
|
930
971
|
const markdownReady =
|
|
931
972
|
content.length > 0 && checkMarkdownReadiness(content, cuts).ready;
|
|
932
973
|
const published =
|
|
@@ -1480,6 +1521,10 @@ export function CutListPanel({
|
|
|
1480
1521
|
editingCutId !== null
|
|
1481
1522
|
? cutsFile.cuts.find((c) => c.id === editingCutId)
|
|
1482
1523
|
: null;
|
|
1524
|
+
const editingCutIndex =
|
|
1525
|
+
editingCutId !== null
|
|
1526
|
+
? cutsFile.cuts.findIndex((c) => c.id === editingCutId)
|
|
1527
|
+
: -1;
|
|
1483
1528
|
|
|
1484
1529
|
if (editingCut) {
|
|
1485
1530
|
return (
|
|
@@ -1516,8 +1561,19 @@ export function CutListPanel({
|
|
|
1516
1561
|
}
|
|
1517
1562
|
}}
|
|
1518
1563
|
onExported={() => loadCuts()}
|
|
1564
|
+
hasPreviousCut={editingCutIndex > 0}
|
|
1565
|
+
hasNextCut={editingCutIndex >= 0 && editingCutIndex < cutsFile.cuts.length - 1}
|
|
1566
|
+
onPreviousCut={() => {
|
|
1567
|
+
const previous = cutsFile.cuts[editingCutIndex - 1];
|
|
1568
|
+
if (previous) setEditingCutId(previous.id);
|
|
1569
|
+
}}
|
|
1570
|
+
onNextCut={() => {
|
|
1571
|
+
const next = cutsFile.cuts[editingCutIndex + 1];
|
|
1572
|
+
if (next) setEditingCutId(next.id);
|
|
1573
|
+
}}
|
|
1519
1574
|
onClose={() => {
|
|
1520
1575
|
setEditingCutId(null);
|
|
1576
|
+
onExitFocusedEditor?.();
|
|
1521
1577
|
loadCuts();
|
|
1522
1578
|
}}
|
|
1523
1579
|
/>
|
|
@@ -1612,88 +1668,55 @@ export function CutListPanel({
|
|
|
1612
1668
|
).length;
|
|
1613
1669
|
const assetSummary = workspaceAssetSummary(assetDiagnostics);
|
|
1614
1670
|
|
|
1615
|
-
|
|
1671
|
+
const compactEndSummary = (
|
|
1616
1672
|
<div
|
|
1617
|
-
className="
|
|
1618
|
-
data-testid="cut-
|
|
1673
|
+
className="rounded border border-border bg-surface/45 px-3 py-2 text-xs text-muted"
|
|
1674
|
+
data-testid="cut-board-end-summary"
|
|
1619
1675
|
>
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
>
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1676
|
+
<span className="font-medium text-foreground">
|
|
1677
|
+
{episodeLabel}
|
|
1678
|
+
{episodeTitle ? ` · ${episodeTitle}` : ""}
|
|
1679
|
+
</span>
|
|
1680
|
+
<span className="ml-2">
|
|
1681
|
+
{boardSummary.cuts} cuts · {boardSummary.converted} clean ·{" "}
|
|
1682
|
+
{boardSummary.lettered} lettered · {boardSummary.uploaded} uploaded
|
|
1683
|
+
</span>
|
|
1684
|
+
</div>
|
|
1685
|
+
);
|
|
1686
|
+
|
|
1687
|
+
const workspaceTools = (
|
|
1688
|
+
<details
|
|
1689
|
+
className="border-b border-border bg-surface/35 flex-shrink-0"
|
|
1690
|
+
data-testid="cut-workspace-tools"
|
|
1691
|
+
>
|
|
1692
|
+
<summary className="list-none cursor-pointer px-3 py-1 hover:bg-surface/50">
|
|
1693
|
+
<div className="flex items-center gap-2 justify-between">
|
|
1694
|
+
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-[10px]">
|
|
1695
|
+
<span className="rounded-full border border-accent/30 bg-background px-2 py-0.5 font-medium text-accent">
|
|
1696
|
+
Workflow: {workflowLabel}
|
|
1697
|
+
</span>
|
|
1698
|
+
{assetSummary && (
|
|
1699
|
+
<span
|
|
1700
|
+
className="rounded-full border border-border bg-background px-2 py-0.5 text-muted"
|
|
1701
|
+
data-testid="workspace-asset-summary"
|
|
1702
|
+
>
|
|
1703
|
+
Assets: {assetSummary}
|
|
1630
1704
|
</span>
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
>
|
|
1639
|
-
{boardSummary.cuts} cuts · {boardSummary.artwork} artwork found ·{" "}
|
|
1640
|
-
{boardSummary.converted} converted · {boardSummary.lettered}{" "}
|
|
1641
|
-
lettered · {boardSummary.uploaded} uploaded
|
|
1642
|
-
</div>
|
|
1705
|
+
)}
|
|
1706
|
+
{conversionJobs.length > 0 && (
|
|
1707
|
+
<span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-amber-700">
|
|
1708
|
+
Convert {conversionJobs.length} PNG
|
|
1709
|
+
{conversionJobs.length === 1 ? "" : "s"}
|
|
1710
|
+
</span>
|
|
1711
|
+
)}
|
|
1643
1712
|
</div>
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
disabled={aiDraftingAll}
|
|
1648
|
-
data-testid="ai-draft-all-btn"
|
|
1649
|
-
className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
|
|
1650
|
-
>
|
|
1651
|
-
{aiDraftingAll
|
|
1652
|
-
? "Drafting…"
|
|
1653
|
-
: `AI draft all unlettered (${aiDraftEligibleCount})`}
|
|
1654
|
-
</button>
|
|
1655
|
-
)}
|
|
1713
|
+
<span className="flex-shrink-0 rounded border border-border bg-background px-2 py-1 text-[10px] text-muted">
|
|
1714
|
+
Workspace tools
|
|
1715
|
+
</span>
|
|
1656
1716
|
</div>
|
|
1657
|
-
</
|
|
1658
|
-
<
|
|
1659
|
-
className="
|
|
1660
|
-
data-testid="cut-workspace-tools"
|
|
1661
|
-
>
|
|
1662
|
-
<summary className="list-none cursor-pointer px-3 py-2 hover:bg-surface/50">
|
|
1663
|
-
<div className="flex items-start gap-3 justify-between">
|
|
1664
|
-
<div className="min-w-0 space-y-1">
|
|
1665
|
-
<div className="flex flex-wrap items-center gap-1.5 text-[10px]">
|
|
1666
|
-
<span className="rounded-full border border-accent/30 bg-background px-2 py-0.5 font-medium text-accent">
|
|
1667
|
-
Workflow: {workflowLabel}
|
|
1668
|
-
</span>
|
|
1669
|
-
{assetSummary && (
|
|
1670
|
-
<span
|
|
1671
|
-
className="rounded-full border border-border bg-background px-2 py-0.5 text-muted"
|
|
1672
|
-
data-testid="workspace-asset-summary"
|
|
1673
|
-
>
|
|
1674
|
-
Assets: {assetSummary}
|
|
1675
|
-
</span>
|
|
1676
|
-
)}
|
|
1677
|
-
{conversionJobs.length > 0 && (
|
|
1678
|
-
<span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-amber-700">
|
|
1679
|
-
Convert {conversionJobs.length} PNG
|
|
1680
|
-
{conversionJobs.length === 1 ? "" : "s"}
|
|
1681
|
-
</span>
|
|
1682
|
-
)}
|
|
1683
|
-
</div>
|
|
1684
|
-
<p className="text-[10px] text-muted">
|
|
1685
|
-
Per-cut actions stay on each card. Open workspace tools for
|
|
1686
|
-
publish prep, asset recovery, narration cards, and blocked-step
|
|
1687
|
-
details.
|
|
1688
|
-
</p>
|
|
1689
|
-
</div>
|
|
1690
|
-
<span className="flex-shrink-0 rounded border border-border bg-background px-2 py-1 text-[10px] text-muted">
|
|
1691
|
-
Workspace tools
|
|
1692
|
-
</span>
|
|
1693
|
-
</div>
|
|
1694
|
-
</summary>
|
|
1695
|
-
<div className="border-t border-border px-3 py-2 space-y-3">
|
|
1696
|
-
<div className="flex flex-wrap items-center gap-2 text-[10px]">
|
|
1717
|
+
</summary>
|
|
1718
|
+
<div className="border-t border-border px-3 py-2 space-y-3">
|
|
1719
|
+
<div className="flex flex-wrap items-center gap-2 text-[10px]">
|
|
1697
1720
|
<span className="font-mono text-muted">
|
|
1698
1721
|
{cutsFile.cuts.length} cuts
|
|
1699
1722
|
</span>
|
|
@@ -1777,111 +1800,156 @@ export function CutListPanel({
|
|
|
1777
1800
|
>
|
|
1778
1801
|
{uploadProgress || "Upload & Prepare for Publish"}
|
|
1779
1802
|
</button>
|
|
1803
|
+
</div>
|
|
1804
|
+
<div className="mt-1 text-[10px] text-muted">
|
|
1805
|
+
Use <span className="text-accent">Add narration/text panel</span>{" "}
|
|
1806
|
+
for a narration or title card. It becomes a solid card exported as a
|
|
1807
|
+
final image.
|
|
1808
|
+
</div>
|
|
1809
|
+
{conversionJobs.length > 0 && (
|
|
1810
|
+
<div
|
|
1811
|
+
className="rounded border border-amber-500/40 bg-amber-500/10 p-2 text-[11px]"
|
|
1812
|
+
data-testid="convert-artwork"
|
|
1813
|
+
>
|
|
1814
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
1815
|
+
<span
|
|
1816
|
+
className="font-medium text-amber-700"
|
|
1817
|
+
data-testid="convert-artwork-count"
|
|
1818
|
+
>
|
|
1819
|
+
{conversionJobs.length} PNG image
|
|
1820
|
+
{conversionJobs.length === 1 ? "" : "s"} found
|
|
1821
|
+
</span>
|
|
1822
|
+
<button
|
|
1823
|
+
onClick={() => convertAll(conversionJobs)}
|
|
1824
|
+
disabled={converting}
|
|
1825
|
+
data-testid="convert-all-btn"
|
|
1826
|
+
className="ml-auto px-2 py-0.5 border border-amber-500/50 text-amber-800 rounded hover:bg-amber-500/20 disabled:opacity-50"
|
|
1827
|
+
>
|
|
1828
|
+
{converting ? "Converting…" : "Convert all to WebP"}
|
|
1829
|
+
</button>
|
|
1830
|
+
</div>
|
|
1831
|
+
<p className="mt-1 text-[10px] text-muted">
|
|
1832
|
+
PNG artwork is fine while drafting. Convert it before
|
|
1833
|
+
lettering/export so PlotLink can publish it safely.
|
|
1834
|
+
</p>
|
|
1835
|
+
{convertResult && (
|
|
1836
|
+
<p
|
|
1837
|
+
className="mt-1 text-[10px] text-muted"
|
|
1838
|
+
data-testid="convert-result"
|
|
1839
|
+
>
|
|
1840
|
+
{convertResult}
|
|
1841
|
+
</p>
|
|
1842
|
+
)}
|
|
1843
|
+
{conversionIssues.length > 0 && (
|
|
1844
|
+
<details
|
|
1845
|
+
className="mt-1"
|
|
1846
|
+
data-testid="convert-technical-details"
|
|
1847
|
+
>
|
|
1848
|
+
<summary className="text-[10px] text-muted cursor-pointer">
|
|
1849
|
+
Conversion notes
|
|
1850
|
+
</summary>
|
|
1851
|
+
<ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
|
|
1852
|
+
{conversionIssues.map((m, i) => (
|
|
1853
|
+
<li key={i}>{m}</li>
|
|
1854
|
+
))}
|
|
1855
|
+
</ul>
|
|
1856
|
+
</details>
|
|
1857
|
+
)}
|
|
1780
1858
|
</div>
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1859
|
+
)}
|
|
1860
|
+
{assetDiagnostics &&
|
|
1861
|
+
assetDiagnostics.length > 0 &&
|
|
1862
|
+
(() => {
|
|
1863
|
+
const s = summarizeAssetDiagnostics(assetDiagnostics);
|
|
1864
|
+
const missing = assetDiagnostics.filter(
|
|
1865
|
+
(d) => d.state === "missing",
|
|
1866
|
+
);
|
|
1867
|
+
return (
|
|
1868
|
+
<div
|
|
1869
|
+
className="rounded border border-border bg-background p-2 text-[10px]"
|
|
1870
|
+
data-testid="asset-diagnostics"
|
|
1871
|
+
>
|
|
1792
1872
|
<span
|
|
1793
|
-
className="
|
|
1794
|
-
data-testid="
|
|
1873
|
+
className="text-muted"
|
|
1874
|
+
data-testid="asset-diag-summary"
|
|
1795
1875
|
>
|
|
1796
|
-
{
|
|
1797
|
-
{
|
|
1876
|
+
Assets: {s.uploaded} uploaded · {s.finalReady} final ·{" "}
|
|
1877
|
+
{s.cleanReady} clean · {s.planned} planned
|
|
1878
|
+
{s.needsConversion > 0
|
|
1879
|
+
? ` · ${s.needsConversion} needs conversion`
|
|
1880
|
+
: ""}
|
|
1881
|
+
{s.missing > 0 ? ` · ${s.missing} missing` : ""}
|
|
1798
1882
|
</span>
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
</button>
|
|
1807
|
-
</div>
|
|
1808
|
-
<p className="mt-1 text-[10px] text-muted">
|
|
1809
|
-
PNG artwork is fine while drafting. Convert it before
|
|
1810
|
-
lettering/export so PlotLink can publish it safely.
|
|
1811
|
-
</p>
|
|
1812
|
-
{convertResult && (
|
|
1813
|
-
<p
|
|
1814
|
-
className="mt-1 text-[10px] text-muted"
|
|
1815
|
-
data-testid="convert-result"
|
|
1816
|
-
>
|
|
1817
|
-
{convertResult}
|
|
1818
|
-
</p>
|
|
1819
|
-
)}
|
|
1820
|
-
{conversionIssues.length > 0 && (
|
|
1821
|
-
<details
|
|
1822
|
-
className="mt-1"
|
|
1823
|
-
data-testid="convert-technical-details"
|
|
1824
|
-
>
|
|
1825
|
-
<summary className="text-[10px] text-muted cursor-pointer">
|
|
1826
|
-
Conversion notes
|
|
1827
|
-
</summary>
|
|
1828
|
-
<ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
|
|
1829
|
-
{conversionIssues.map((m, i) => (
|
|
1830
|
-
<li key={i}>{m}</li>
|
|
1883
|
+
{missing.length > 0 && (
|
|
1884
|
+
<ul
|
|
1885
|
+
className="mt-1 ml-3 list-disc text-error"
|
|
1886
|
+
data-testid="asset-diag-issues"
|
|
1887
|
+
>
|
|
1888
|
+
{missing.map((d) => (
|
|
1889
|
+
<li key={d.cutId}>{d.issue}</li>
|
|
1831
1890
|
))}
|
|
1832
1891
|
</ul>
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1892
|
+
)}
|
|
1893
|
+
</div>
|
|
1894
|
+
);
|
|
1895
|
+
})()}
|
|
1896
|
+
<FinishEpisodePanel
|
|
1897
|
+
checklist={finishChecklist}
|
|
1898
|
+
issues={genWarnings}
|
|
1899
|
+
onFinish={finishEpisode}
|
|
1900
|
+
finishing={uploading}
|
|
1901
|
+
progressText={uploadProgress}
|
|
1902
|
+
canFinish={canFinish}
|
|
1903
|
+
markdownReady={episodeState.markdownReady}
|
|
1904
|
+
published={episodeState.published}
|
|
1905
|
+
/>
|
|
1906
|
+
</div>
|
|
1907
|
+
</details>
|
|
1908
|
+
);
|
|
1909
|
+
|
|
1910
|
+
return (
|
|
1911
|
+
<div
|
|
1912
|
+
className="h-full min-h-[22rem] flex flex-col overflow-hidden"
|
|
1913
|
+
data-testid="cut-list-panel"
|
|
1914
|
+
>
|
|
1915
|
+
{/* Episode header + creator-facing progress summary (#440). */}
|
|
1916
|
+
{!compactEpisodeChrome && (
|
|
1917
|
+
<div
|
|
1918
|
+
className="px-3 py-1 border-b border-border flex-shrink-0"
|
|
1919
|
+
data-testid="cut-board-header"
|
|
1920
|
+
>
|
|
1921
|
+
<div className="flex items-center gap-3 justify-between">
|
|
1922
|
+
<div className="flex items-center gap-2 min-w-0 text-xs">
|
|
1923
|
+
<span className="font-serif text-foreground truncate">
|
|
1924
|
+
{episodeLabel}
|
|
1925
|
+
</span>
|
|
1926
|
+
{episodeTitle && (
|
|
1927
|
+
<span className="text-muted truncate">· {episodeTitle}</span>
|
|
1928
|
+
)}
|
|
1929
|
+
<span
|
|
1930
|
+
className="text-[10px] text-muted whitespace-nowrap"
|
|
1931
|
+
data-testid="cut-board-summary"
|
|
1932
|
+
>
|
|
1933
|
+
{boardSummary.cuts} cuts · {boardSummary.converted} clean ·{" "}
|
|
1934
|
+
{boardSummary.lettered} lettered · {boardSummary.uploaded} uploaded
|
|
1935
|
+
</span>
|
|
1936
|
+
</div>
|
|
1937
|
+
{aiDraftEligibleCount > 0 && (
|
|
1938
|
+
<button
|
|
1939
|
+
onClick={draftAllUnletteredCuts}
|
|
1940
|
+
disabled={aiDraftingAll}
|
|
1941
|
+
data-testid="ai-draft-all-btn"
|
|
1942
|
+
className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
|
|
1943
|
+
>
|
|
1944
|
+
{aiDraftingAll
|
|
1945
|
+
? "Drafting…"
|
|
1946
|
+
: `AI draft all unlettered (${aiDraftEligibleCount})`}
|
|
1947
|
+
</button>
|
|
1836
1948
|
)}
|
|
1837
|
-
{assetDiagnostics &&
|
|
1838
|
-
assetDiagnostics.length > 0 &&
|
|
1839
|
-
(() => {
|
|
1840
|
-
const s = summarizeAssetDiagnostics(assetDiagnostics);
|
|
1841
|
-
const missing = assetDiagnostics.filter(
|
|
1842
|
-
(d) => d.state === "missing",
|
|
1843
|
-
);
|
|
1844
|
-
return (
|
|
1845
|
-
<div
|
|
1846
|
-
className="rounded border border-border bg-background p-2 text-[10px]"
|
|
1847
|
-
data-testid="asset-diagnostics"
|
|
1848
|
-
>
|
|
1849
|
-
<span
|
|
1850
|
-
className="text-muted"
|
|
1851
|
-
data-testid="asset-diag-summary"
|
|
1852
|
-
>
|
|
1853
|
-
Assets: {s.uploaded} uploaded · {s.finalReady} final ·{" "}
|
|
1854
|
-
{s.cleanReady} clean · {s.planned} planned
|
|
1855
|
-
{s.needsConversion > 0
|
|
1856
|
-
? ` · ${s.needsConversion} needs conversion`
|
|
1857
|
-
: ""}
|
|
1858
|
-
{s.missing > 0 ? ` · ${s.missing} missing` : ""}
|
|
1859
|
-
</span>
|
|
1860
|
-
{missing.length > 0 && (
|
|
1861
|
-
<ul
|
|
1862
|
-
className="mt-1 ml-3 list-disc text-error"
|
|
1863
|
-
data-testid="asset-diag-issues"
|
|
1864
|
-
>
|
|
1865
|
-
{missing.map((d) => (
|
|
1866
|
-
<li key={d.cutId}>{d.issue}</li>
|
|
1867
|
-
))}
|
|
1868
|
-
</ul>
|
|
1869
|
-
)}
|
|
1870
|
-
</div>
|
|
1871
|
-
);
|
|
1872
|
-
})()}
|
|
1873
|
-
<FinishEpisodePanel
|
|
1874
|
-
checklist={finishChecklist}
|
|
1875
|
-
issues={genWarnings}
|
|
1876
|
-
onFinish={finishEpisode}
|
|
1877
|
-
finishing={uploading}
|
|
1878
|
-
progressText={uploadProgress}
|
|
1879
|
-
canFinish={canFinish}
|
|
1880
|
-
markdownReady={episodeState.markdownReady}
|
|
1881
|
-
published={episodeState.published}
|
|
1882
|
-
/>
|
|
1883
1949
|
</div>
|
|
1884
|
-
</
|
|
1950
|
+
</div>
|
|
1951
|
+
)}
|
|
1952
|
+
{!compactEpisodeChrome && workspaceTools}
|
|
1885
1953
|
{/* Stale bubble-renderer warning (#381): a final image lettered before the
|
|
1886
1954
|
current seamless-tail renderer may show the old separate-tail seam.
|
|
1887
1955
|
Mark those cuts so the writer re-exports (open lettering → Export) and
|
|
@@ -1903,11 +1971,12 @@ export function CutListPanel({
|
|
|
1903
1971
|
Codex generation is complete even if the terminal session is still
|
|
1904
1972
|
connected — no more guessing whether it is still Working. */}
|
|
1905
1973
|
{detectConfirmed &&
|
|
1974
|
+
!compactEpisodeChrome &&
|
|
1906
1975
|
imageCutCount > 0 &&
|
|
1907
1976
|
stats.missing === 0 &&
|
|
1908
1977
|
staleByCut.size === 0 && (
|
|
1909
1978
|
<div
|
|
1910
|
-
className="px-3 py-
|
|
1979
|
+
className="px-3 py-0.5 border-b border-border bg-green-600/10 text-[10px] text-green-700 flex items-center gap-1 flex-shrink-0"
|
|
1911
1980
|
data-testid="clean-assets-ready"
|
|
1912
1981
|
>
|
|
1913
1982
|
<span aria-hidden>✓</span>
|
|
@@ -1989,6 +2058,12 @@ export function CutListPanel({
|
|
|
1989
2058
|
disabled={addingPanel}
|
|
1990
2059
|
onAdd={() => addTextPanelAt(cutsFile.cuts.length)}
|
|
1991
2060
|
/>
|
|
2061
|
+
{compactEpisodeChrome && (
|
|
2062
|
+
<>
|
|
2063
|
+
{compactEndSummary}
|
|
2064
|
+
{workspaceTools}
|
|
2065
|
+
</>
|
|
2066
|
+
)}
|
|
1992
2067
|
</div>
|
|
1993
2068
|
</div>
|
|
1994
2069
|
);
|
|
@@ -103,11 +103,19 @@ export function CutOverlayPreview({
|
|
|
103
103
|
useEffect(() => {
|
|
104
104
|
const el = stageRef.current;
|
|
105
105
|
if (!el) return;
|
|
106
|
-
const
|
|
106
|
+
const update = () => {
|
|
107
107
|
setStageSize({
|
|
108
108
|
width: el.clientWidth,
|
|
109
109
|
height: el.clientHeight,
|
|
110
110
|
});
|
|
111
|
+
};
|
|
112
|
+
if (typeof ResizeObserver === "undefined") {
|
|
113
|
+
update();
|
|
114
|
+
window.addEventListener("resize", update);
|
|
115
|
+
return () => window.removeEventListener("resize", update);
|
|
116
|
+
}
|
|
117
|
+
const observer = new ResizeObserver(() => {
|
|
118
|
+
update();
|
|
111
119
|
});
|
|
112
120
|
observer.observe(el);
|
|
113
121
|
return () => observer.disconnect();
|
|
@@ -49,11 +49,18 @@ export function EpisodesPage({ storyName, authFetch, onOpenFile }: EpisodesPageP
|
|
|
49
49
|
const activeCount = episodes.filter((ep) => !ep.published).length;
|
|
50
50
|
const blockedCount = episodes.filter((ep) => ep.state === "blocked").length;
|
|
51
51
|
const readyCount = episodes.filter((ep) => ep.state === "ready").length;
|
|
52
|
+
const displayLabel = (ep: EpisodeProgress) => {
|
|
53
|
+
if (ep.file === "genesis.md") return "epi-01 (Genesis)";
|
|
54
|
+
const m = ep.file.match(/^plot-(\d+)\.md$/);
|
|
55
|
+
if (!m) return ep.label;
|
|
56
|
+
const episodeNumber = parseInt(m[1], 10) + 1;
|
|
57
|
+
return `epi-${String(episodeNumber).padStart(2, "0")}`;
|
|
58
|
+
};
|
|
52
59
|
|
|
53
60
|
return (
|
|
54
61
|
<div className="h-full overflow-y-auto px-4 py-4" data-testid="episodes-page">
|
|
55
62
|
<h2 className="text-base font-serif text-foreground">Episodes</h2>
|
|
56
|
-
<p className="mt-0.5 text-[11px] text-muted">
|
|
63
|
+
<p className="mt-0.5 text-[11px] text-muted">Open an episode to preview its cuts or edit lettering.</p>
|
|
57
64
|
<div className="mt-3 flex flex-wrap gap-1.5 text-[10px]" data-testid="episodes-summary">
|
|
58
65
|
<span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
|
|
59
66
|
{episodes.length} total
|
|
@@ -75,7 +82,7 @@ export function EpisodesPage({ storyName, authFetch, onOpenFile }: EpisodesPageP
|
|
|
75
82
|
</div>
|
|
76
83
|
|
|
77
84
|
{episodes.length === 0 ? (
|
|
78
|
-
<p className="mt-4 text-xs text-muted italic" data-testid="episodes-empty">No episodes yet
|
|
85
|
+
<p className="mt-4 text-xs text-muted italic" data-testid="episodes-empty">No episodes yet.</p>
|
|
79
86
|
) : (
|
|
80
87
|
<ol className="mt-3 flex flex-col gap-1">
|
|
81
88
|
{episodes.map((ep) => (
|
|
@@ -88,9 +95,8 @@ export function EpisodesPage({ storyName, authFetch, onOpenFile }: EpisodesPageP
|
|
|
88
95
|
>
|
|
89
96
|
<span className="min-w-0 flex-1">
|
|
90
97
|
<span className="flex items-center gap-1.5">
|
|
91
|
-
<span className="text-xs font-medium text-foreground">{ep
|
|
98
|
+
<span className="text-xs font-medium text-foreground">{displayLabel(ep)}</span>
|
|
92
99
|
{ep.title && <span className="text-[11px] text-muted truncate">· {ep.title}</span>}
|
|
93
|
-
<span className="ml-auto text-[10px] text-muted">{ep.file}</span>
|
|
94
100
|
</span>
|
|
95
101
|
<span className="block text-[11px] text-muted">{ep.summary}</span>
|
|
96
102
|
</span>
|