plotlink-ows 1.2.96 → 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/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/CartoonWorkflowNav.tsx +3 -7
- package/app/web/components/CutListPanel.tsx +372 -243
- package/app/web/components/CutOverlayPreview.tsx +314 -0
- package/app/web/components/EpisodesPage.tsx +34 -4
- package/app/web/components/FinishEpisodePanel.tsx +21 -108
- package/app/web/components/LetteringEditor.tsx +246 -131
- package/app/web/components/PreviewPanel.tsx +200 -214
- package/app/web/components/StoriesPage.tsx +105 -91
- package/app/web/components/StoryBrowser.tsx +53 -19
- package/app/web/components/StoryInfoPage.tsx +31 -14
- package/app/web/components/StoryProgressPanel.tsx +31 -28
- package/app/web/dist/assets/{export-cut-BqZI0-Rv.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-C43toXVm.js +0 -141
- package/app/web/dist/assets/index-CcfChGEK.css +0 -32
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
getDefaultFont,
|
|
4
|
+
getDisplayFont,
|
|
5
|
+
getFontCdnUrl,
|
|
6
|
+
getFontFamily,
|
|
7
|
+
type FontEntry,
|
|
8
|
+
} from "@app-lib/fonts";
|
|
9
|
+
import {
|
|
10
|
+
speechTailPoints,
|
|
11
|
+
balloonPathD,
|
|
12
|
+
bubbleLayoutOptionsForOverlay,
|
|
13
|
+
balloonRadiusForOverlay,
|
|
14
|
+
type Overlay,
|
|
15
|
+
} from "@app-lib/overlays";
|
|
16
|
+
import { layoutBubbleText } from "@app-lib/bubble-text";
|
|
17
|
+
import { textPanelDimensions } from "@app-lib/cuts";
|
|
18
|
+
import { useAuthedAsset } from "./asset-image";
|
|
19
|
+
|
|
20
|
+
type AuthFetch = (url: string, opts?: RequestInit) => Promise<Response>;
|
|
21
|
+
|
|
22
|
+
function loadFont(font: FontEntry) {
|
|
23
|
+
const id = `gfont-${font.googleFontsId}`;
|
|
24
|
+
if (document.getElementById(id)) return;
|
|
25
|
+
const link = document.createElement("link");
|
|
26
|
+
link.id = id;
|
|
27
|
+
link.rel = "stylesheet";
|
|
28
|
+
link.href = getFontCdnUrl(font);
|
|
29
|
+
document.head.appendChild(link);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CutOverlayPreviewProps {
|
|
33
|
+
storyName: string;
|
|
34
|
+
assetPath?: string | null;
|
|
35
|
+
authFetch: AuthFetch;
|
|
36
|
+
alt: string;
|
|
37
|
+
overlays: Overlay[];
|
|
38
|
+
language?: string;
|
|
39
|
+
background?: string;
|
|
40
|
+
aspectRatio?: string;
|
|
41
|
+
className?: string;
|
|
42
|
+
onClick?: () => void;
|
|
43
|
+
testId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function CutOverlayPreview({
|
|
47
|
+
storyName,
|
|
48
|
+
assetPath,
|
|
49
|
+
authFetch,
|
|
50
|
+
alt,
|
|
51
|
+
overlays,
|
|
52
|
+
language = "English",
|
|
53
|
+
background,
|
|
54
|
+
aspectRatio,
|
|
55
|
+
className,
|
|
56
|
+
onClick,
|
|
57
|
+
testId,
|
|
58
|
+
}: CutOverlayPreviewProps) {
|
|
59
|
+
const bodyFont = getDefaultFont(language);
|
|
60
|
+
const displayFont = getDisplayFont();
|
|
61
|
+
const bodyFontFamily = getFontFamily(bodyFont);
|
|
62
|
+
const displayFontFamily = getFontFamily(displayFont);
|
|
63
|
+
const asset = useAuthedAsset(storyName, assetPath, authFetch);
|
|
64
|
+
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
65
|
+
const measureContext = useMemo(
|
|
66
|
+
() =>
|
|
67
|
+
typeof document !== "undefined"
|
|
68
|
+
? document.createElement("canvas").getContext("2d")
|
|
69
|
+
: null,
|
|
70
|
+
[],
|
|
71
|
+
);
|
|
72
|
+
const [fontsReady, setFontsReady] = useState(false);
|
|
73
|
+
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number }>(
|
|
74
|
+
() => textPanelDimensions(aspectRatio) ?? { width: 800, height: 600 },
|
|
75
|
+
);
|
|
76
|
+
const [stageSize, setStageSize] = useState({ width: 0, height: 0 });
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
loadFont(bodyFont);
|
|
80
|
+
loadFont(displayFont);
|
|
81
|
+
}, [bodyFont, displayFont]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
let cancelled = false;
|
|
85
|
+
(async () => {
|
|
86
|
+
try {
|
|
87
|
+
if (document.fonts?.load) {
|
|
88
|
+
await Promise.all([
|
|
89
|
+
document.fonts.load(`16px "${bodyFont.family}"`),
|
|
90
|
+
document.fonts.load(`16px "${displayFont.family}"`),
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
/* best effort */
|
|
95
|
+
}
|
|
96
|
+
if (!cancelled) setFontsReady(true);
|
|
97
|
+
})();
|
|
98
|
+
return () => {
|
|
99
|
+
cancelled = true;
|
|
100
|
+
};
|
|
101
|
+
}, [bodyFont.family, displayFont.family]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const el = stageRef.current;
|
|
105
|
+
if (!el) return;
|
|
106
|
+
const update = () => {
|
|
107
|
+
setStageSize({
|
|
108
|
+
width: el.clientWidth,
|
|
109
|
+
height: el.clientHeight,
|
|
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();
|
|
119
|
+
});
|
|
120
|
+
observer.observe(el);
|
|
121
|
+
return () => observer.disconnect();
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
const measureWidth = useCallback(
|
|
125
|
+
(fontFamily: string) =>
|
|
126
|
+
(text: string, fontSize: number, fontWeight: 400 | 700 = 400): number => {
|
|
127
|
+
if (!measureContext) return text.length * fontSize * 0.5;
|
|
128
|
+
measureContext.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
129
|
+
return measureContext.measureText(text).width;
|
|
130
|
+
},
|
|
131
|
+
[measureContext],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const stage = (
|
|
135
|
+
<div
|
|
136
|
+
className={className ?? "w-full rounded border border-border bg-white"}
|
|
137
|
+
data-testid={testId}
|
|
138
|
+
ref={stageRef}
|
|
139
|
+
style={{
|
|
140
|
+
aspectRatio: `${naturalSize.width} / ${naturalSize.height}`,
|
|
141
|
+
maxHeight: "32rem",
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
<div className="relative w-full h-full overflow-hidden rounded border border-border bg-white">
|
|
145
|
+
{assetPath ? (
|
|
146
|
+
asset.error || (!asset.loading && !asset.url) ? (
|
|
147
|
+
<div className="w-full h-full flex items-center justify-center text-[10px] text-muted bg-surface/40">
|
|
148
|
+
Image not available
|
|
149
|
+
</div>
|
|
150
|
+
) : asset.url ? (
|
|
151
|
+
<img
|
|
152
|
+
src={asset.url}
|
|
153
|
+
alt={alt}
|
|
154
|
+
className="absolute inset-0 w-full h-full object-contain"
|
|
155
|
+
draggable={false}
|
|
156
|
+
onLoad={(e) => {
|
|
157
|
+
const width = e.currentTarget.naturalWidth || naturalSize.width;
|
|
158
|
+
const height = e.currentTarget.naturalHeight || naturalSize.height;
|
|
159
|
+
if (width > 0 && height > 0) setNaturalSize({ width, height });
|
|
160
|
+
}}
|
|
161
|
+
/>
|
|
162
|
+
) : (
|
|
163
|
+
<div className="w-full h-full flex items-center justify-center text-[10px] text-muted bg-surface/40">
|
|
164
|
+
Loading image…
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
) : (
|
|
168
|
+
<div
|
|
169
|
+
className="absolute inset-0"
|
|
170
|
+
style={{ background: background || "#101820" }}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{stageSize.width > 0 && stageSize.height > 0 && overlays.length > 0 && (
|
|
175
|
+
<>
|
|
176
|
+
<svg
|
|
177
|
+
className="absolute inset-0 w-full h-full pointer-events-none"
|
|
178
|
+
data-testid={testId ? `${testId}-overlay-layer` : undefined}
|
|
179
|
+
>
|
|
180
|
+
{overlays.map((overlay) => {
|
|
181
|
+
if (overlay.type !== "speech") return null;
|
|
182
|
+
const ox = overlay.x * stageSize.width;
|
|
183
|
+
const oy = overlay.y * stageSize.height;
|
|
184
|
+
const ow = overlay.width * stageSize.width;
|
|
185
|
+
const oh = overlay.height * stageSize.height;
|
|
186
|
+
const radius = balloonRadiusForOverlay(overlay, ow, oh);
|
|
187
|
+
const tail = overlay.tailAnchor
|
|
188
|
+
? speechTailPoints(ox, oy, ow, oh, overlay.tailAnchor, radius)
|
|
189
|
+
: null;
|
|
190
|
+
const strokeW = Math.max(1.25, stageSize.height * 0.004);
|
|
191
|
+
return (
|
|
192
|
+
<path
|
|
193
|
+
key={overlay.id}
|
|
194
|
+
data-testid={testId ? `${testId}-overlay-${overlay.id}` : undefined}
|
|
195
|
+
d={balloonPathD(ox, oy, ow, oh, tail, radius)}
|
|
196
|
+
className="fill-white/95 stroke-[#1a1a1a]"
|
|
197
|
+
strokeWidth={strokeW}
|
|
198
|
+
strokeLinejoin="round"
|
|
199
|
+
/>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</svg>
|
|
203
|
+
{overlays.map((overlay) => {
|
|
204
|
+
const left = overlay.x * stageSize.width;
|
|
205
|
+
const top = overlay.y * stageSize.height;
|
|
206
|
+
const width = overlay.width * stageSize.width;
|
|
207
|
+
const height = overlay.height * stageSize.height;
|
|
208
|
+
const fontFamily =
|
|
209
|
+
overlay.type === "sfx" ? displayFontFamily : bodyFontFamily;
|
|
210
|
+
const isSpeech = overlay.type === "speech";
|
|
211
|
+
const isNarration = overlay.type === "narration";
|
|
212
|
+
const hasSpeaker = overlay.type !== "sfx" && !!overlay.speaker;
|
|
213
|
+
return (
|
|
214
|
+
<div
|
|
215
|
+
key={overlay.id}
|
|
216
|
+
className={`absolute rounded overflow-hidden ${
|
|
217
|
+
isSpeech ? "" : "border-2"
|
|
218
|
+
} ${
|
|
219
|
+
overlay.type === "narration"
|
|
220
|
+
? "border-muted/40 bg-[#f4efe6]/85 rounded-md"
|
|
221
|
+
: overlay.type === "sfx"
|
|
222
|
+
? "border-accent/40"
|
|
223
|
+
: ""
|
|
224
|
+
}`}
|
|
225
|
+
style={{ left, top, width, height }}
|
|
226
|
+
>
|
|
227
|
+
{!overlay.text ? (
|
|
228
|
+
<span
|
|
229
|
+
className="block truncate px-1 text-[9px] text-muted"
|
|
230
|
+
style={{ fontFamily }}
|
|
231
|
+
>
|
|
232
|
+
{overlay.type}
|
|
233
|
+
</span>
|
|
234
|
+
) : !fontsReady ? (
|
|
235
|
+
<div
|
|
236
|
+
className="absolute inset-0 flex items-center justify-center px-1 text-center break-words"
|
|
237
|
+
style={{
|
|
238
|
+
fontFamily,
|
|
239
|
+
fontSize: Math.max(8, Math.min(height * 0.05, 14)),
|
|
240
|
+
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
241
|
+
}}
|
|
242
|
+
>
|
|
243
|
+
{hasSpeaker ? `${overlay.speaker}: ${overlay.text}` : overlay.text}
|
|
244
|
+
</div>
|
|
245
|
+
) : (
|
|
246
|
+
(() => {
|
|
247
|
+
const layout = layoutBubbleText(
|
|
248
|
+
measureWidth(fontFamily),
|
|
249
|
+
overlay.text,
|
|
250
|
+
width,
|
|
251
|
+
height,
|
|
252
|
+
bubbleLayoutOptionsForOverlay(
|
|
253
|
+
overlay,
|
|
254
|
+
stageSize.height,
|
|
255
|
+
width,
|
|
256
|
+
height,
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
return (
|
|
260
|
+
<div
|
|
261
|
+
className="absolute inset-0 flex flex-col items-center justify-center px-1 text-center"
|
|
262
|
+
style={{ fontFamily }}
|
|
263
|
+
>
|
|
264
|
+
{hasSpeaker && (
|
|
265
|
+
<span
|
|
266
|
+
className="block font-bold text-[#3a3a3a]"
|
|
267
|
+
style={{
|
|
268
|
+
fontSize: layout.speakerFontSize,
|
|
269
|
+
lineHeight: 1.2,
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
{overlay.speaker}
|
|
273
|
+
</span>
|
|
274
|
+
)}
|
|
275
|
+
<span
|
|
276
|
+
className="text-[#1a1a1a]"
|
|
277
|
+
style={{
|
|
278
|
+
fontSize: layout.fontSize,
|
|
279
|
+
lineHeight: `${layout.lineHeight}px`,
|
|
280
|
+
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
{layout.lines.map((line, i) => (
|
|
284
|
+
<span key={i} className="block">
|
|
285
|
+
{line}
|
|
286
|
+
</span>
|
|
287
|
+
))}
|
|
288
|
+
</span>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
})()
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
})}
|
|
296
|
+
</>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (!onClick) return stage;
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
onClick={onClick}
|
|
308
|
+
className="block w-full text-left"
|
|
309
|
+
data-testid={testId ? `${testId}-open` : undefined}
|
|
310
|
+
>
|
|
311
|
+
{stage}
|
|
312
|
+
</button>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
@@ -45,13 +45,44 @@ export function EpisodesPage({ storyName, authFetch, onOpenFile }: EpisodesPageP
|
|
|
45
45
|
return <div className="h-full flex items-center justify-center text-muted text-sm">Could not load episodes.</div>;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const publishedCount = episodes.filter((ep) => ep.published).length;
|
|
49
|
+
const activeCount = episodes.filter((ep) => !ep.published).length;
|
|
50
|
+
const blockedCount = episodes.filter((ep) => ep.state === "blocked").length;
|
|
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
|
+
};
|
|
59
|
+
|
|
48
60
|
return (
|
|
49
61
|
<div className="h-full overflow-y-auto px-4 py-4" data-testid="episodes-page">
|
|
50
62
|
<h2 className="text-base font-serif text-foreground">Episodes</h2>
|
|
51
|
-
<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>
|
|
64
|
+
<div className="mt-3 flex flex-wrap gap-1.5 text-[10px]" data-testid="episodes-summary">
|
|
65
|
+
<span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
|
|
66
|
+
{episodes.length} total
|
|
67
|
+
</span>
|
|
68
|
+
<span className="rounded-full border border-border bg-background px-2 py-0.5 text-muted">
|
|
69
|
+
{activeCount} active
|
|
70
|
+
</span>
|
|
71
|
+
<span className="rounded-full border border-green-700/30 bg-green-700/10 px-2 py-0.5 text-green-700">
|
|
72
|
+
{publishedCount} published
|
|
73
|
+
</span>
|
|
74
|
+
<span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-accent">
|
|
75
|
+
{readyCount} ready
|
|
76
|
+
</span>
|
|
77
|
+
{blockedCount > 0 && (
|
|
78
|
+
<span className="rounded-full border border-error/30 bg-error/10 px-2 py-0.5 text-error">
|
|
79
|
+
{blockedCount} need fixes
|
|
80
|
+
</span>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
52
83
|
|
|
53
84
|
{episodes.length === 0 ? (
|
|
54
|
-
<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>
|
|
55
86
|
) : (
|
|
56
87
|
<ol className="mt-3 flex flex-col gap-1">
|
|
57
88
|
{episodes.map((ep) => (
|
|
@@ -64,9 +95,8 @@ export function EpisodesPage({ storyName, authFetch, onOpenFile }: EpisodesPageP
|
|
|
64
95
|
>
|
|
65
96
|
<span className="min-w-0 flex-1">
|
|
66
97
|
<span className="flex items-center gap-1.5">
|
|
67
|
-
<span className="text-xs font-medium text-foreground">{ep
|
|
98
|
+
<span className="text-xs font-medium text-foreground">{displayLabel(ep)}</span>
|
|
68
99
|
{ep.title && <span className="text-[11px] text-muted truncate">· {ep.title}</span>}
|
|
69
|
-
<span className="ml-auto text-[10px] text-muted">{ep.file}</span>
|
|
70
100
|
</span>
|
|
71
101
|
<span className="block text-[11px] text-muted">{ep.summary}</span>
|
|
72
102
|
</span>
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type StepStatus = "done" | "current" | "todo";
|
|
4
|
-
|
|
5
|
-
const STATUS_MARK: Record<StepStatus, string> = { done: "✓", current: "▸", todo: "○" };
|
|
1
|
+
import type { CartoonChecklist } from "@app-lib/cartoon-readiness";
|
|
2
|
+
import { CartoonProductionStatus } from "./CartoonProductionStatus";
|
|
6
3
|
|
|
7
4
|
interface FinishEpisodePanelProps {
|
|
8
5
|
/** Writer-language production checklist for this episode (null ⇒ not a cartoon plot). */
|
|
@@ -23,28 +20,11 @@ interface FinishEpisodePanelProps {
|
|
|
23
20
|
published?: boolean;
|
|
24
21
|
}
|
|
25
22
|
|
|
26
|
-
interface DisplayStep {
|
|
27
|
-
key: string;
|
|
28
|
-
label: string;
|
|
29
|
-
status: StepStatus;
|
|
30
|
-
detail: string | null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
23
|
/**
|
|
34
24
|
* Guided "Finish episode" flow for a cartoon plot (#414).
|
|
35
25
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* which low-level button to click and read a flat wall of "Cut N: …" errors. This
|
|
39
|
-
* panel makes the tail one guided surface in writer language: it shows the six
|
|
40
|
-
* production steps with live status, offers ONE primary "Finish episode" action
|
|
41
|
-
* that runs the remaining automatable steps in order (resumable — already-uploaded
|
|
42
|
-
* cuts are skipped by the caller), and groups any blockers under the actionable
|
|
43
|
-
* step heading instead of a long red list. The lower-level controls stay available
|
|
44
|
-
* elsewhere in the workspace for manual recovery.
|
|
45
|
-
*
|
|
46
|
-
* Renders nothing when there is no checklist (e.g. a fiction plot or an unparsed
|
|
47
|
-
* cut plan), so it never appears outside the cartoon flow.
|
|
26
|
+
* This now reuses the shared production-status surface so the Cuts workspace and
|
|
27
|
+
* Publish tab speak the same workflow language and active-step text.
|
|
48
28
|
*/
|
|
49
29
|
export function FinishEpisodePanel({
|
|
50
30
|
checklist,
|
|
@@ -58,105 +38,38 @@ export function FinishEpisodePanel({
|
|
|
58
38
|
}: FinishEpisodePanelProps) {
|
|
59
39
|
if (!checklist || checklist.steps.length === 0) return null;
|
|
60
40
|
|
|
61
|
-
const groups = groupCartoonIssues(issues);
|
|
62
|
-
|
|
63
|
-
// The base checklist (plan → upload) models per-cut art/lettering/export/upload
|
|
64
|
-
// progress; it has no notion of the publish markdown being assembled. #414 needs
|
|
65
|
-
// the post-upload tail modelled explicitly, so replace its single "publish" step
|
|
66
|
-
// with two real states: "Episode sequence prepared" (markdown built + ready) and
|
|
67
|
-
// "Ready to publish" (which becomes "Published" once it's on-chain).
|
|
68
|
-
const uploadDone = checklist.steps.find((s) => s.key === "upload")?.status === "done";
|
|
69
|
-
const ready = uploadDone && markdownReady && !published; // ready to publish, not yet published
|
|
70
|
-
|
|
71
|
-
const assembleStatus: StepStatus = published || markdownReady ? "done" : uploadDone ? "current" : "todo";
|
|
72
|
-
const readyStatus: StepStatus = published ? "done" : ready ? "current" : "todo";
|
|
73
|
-
|
|
74
|
-
const steps: DisplayStep[] = [
|
|
75
|
-
...checklist.steps.filter((s) => s.key !== "publish"),
|
|
76
|
-
{ key: "assemble", label: "Episode sequence prepared", status: assembleStatus, detail: null },
|
|
77
|
-
{ key: "ready", label: published ? "Published to PlotLink" : "Ready to publish", status: readyStatus, detail: null },
|
|
78
|
-
];
|
|
79
|
-
|
|
80
41
|
const buttonLabel = finishing
|
|
81
42
|
? progressText || "Finishing…"
|
|
82
43
|
: published
|
|
83
44
|
? "Published ✓"
|
|
84
|
-
:
|
|
45
|
+
: markdownReady
|
|
85
46
|
? "Episode ready to publish"
|
|
86
47
|
: "Finish episode";
|
|
87
48
|
|
|
88
|
-
const outstandingCount = steps.filter((s) => s.status !== "done").length;
|
|
89
|
-
const issuesCount = groups.reduce((sum, g) => sum + g.lines.length, 0);
|
|
90
|
-
|
|
91
49
|
return (
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
50
|
+
<CartoonProductionStatus
|
|
51
|
+
checklist={checklist}
|
|
52
|
+
markdownReady={markdownReady}
|
|
53
|
+
published={published}
|
|
54
|
+
issues={issues}
|
|
55
|
+
title="Episode production"
|
|
56
|
+
subtitle="Per-cut actions stay on each card. Open details for the full workflow and blockers."
|
|
57
|
+
rootTestId="finish-episode-panel"
|
|
58
|
+
detailsTestId="finish-episode-details"
|
|
59
|
+
stepTestIdPrefix="finish-step"
|
|
60
|
+
issuesTestId="finish-issues"
|
|
61
|
+
issueGroupTestIdPrefix="finish-issue-group"
|
|
62
|
+
action={(
|
|
103
63
|
<button
|
|
104
64
|
onClick={onFinish}
|
|
105
65
|
disabled={finishing || !canFinish}
|
|
106
66
|
data-testid="finish-episode-btn"
|
|
107
67
|
title="Upload the exported final panels, then prepare the episode for publishing — picks up where it left off"
|
|
108
|
-
className="px-2.5 py-
|
|
68
|
+
className="px-2.5 py-1 text-[11px] border border-accent/40 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
|
|
109
69
|
>
|
|
110
70
|
{buttonLabel}
|
|
111
71
|
</button>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
<details className="mt-1" data-testid="finish-episode-details">
|
|
115
|
-
<summary className="cursor-pointer select-none text-[10px] text-muted hover:text-foreground">
|
|
116
|
-
{outstandingCount === 0 ? "Progress details" : `${outstandingCount} step${outstandingCount === 1 ? "" : "s"} left`}
|
|
117
|
-
{issuesCount > 0 ? ` · ${issuesCount} blocker${issuesCount === 1 ? "" : "s"}` : ""}
|
|
118
|
-
</summary>
|
|
119
|
-
|
|
120
|
-
<div className="mt-1.5 space-y-1.5">
|
|
121
|
-
{/* Writer-language step status — the exact webtoon production sequence. */}
|
|
122
|
-
<ol className="flex flex-wrap gap-1.5">
|
|
123
|
-
{steps.map((s) => (
|
|
124
|
-
<li
|
|
125
|
-
key={s.key}
|
|
126
|
-
data-testid={`finish-step-${s.key}`}
|
|
127
|
-
data-status={s.status}
|
|
128
|
-
className={`flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] ${
|
|
129
|
-
s.status === "current"
|
|
130
|
-
? "border-accent/40 bg-accent/10 text-accent"
|
|
131
|
-
: s.status === "done"
|
|
132
|
-
? "border-border bg-background/70 text-foreground"
|
|
133
|
-
: "border-border/70 bg-background/40 text-muted"
|
|
134
|
-
}`}
|
|
135
|
-
>
|
|
136
|
-
<span aria-hidden>{STATUS_MARK[s.status]}</span>
|
|
137
|
-
<span>{s.label}</span>
|
|
138
|
-
{s.detail && <span className="text-muted">· {s.detail}</span>}
|
|
139
|
-
</li>
|
|
140
|
-
))}
|
|
141
|
-
</ol>
|
|
142
|
-
|
|
143
|
-
{/* Blockers grouped by the step that fixes them, not a flat red list. */}
|
|
144
|
-
{groups.length > 0 && (
|
|
145
|
-
<div className="space-y-1.5" data-testid="finish-issues">
|
|
146
|
-
{groups.map((g) => (
|
|
147
|
-
<div key={g.key} data-testid={`finish-issue-group-${g.key}`} className="text-[10px]">
|
|
148
|
-
<p className="font-medium text-amber-700">{g.title}</p>
|
|
149
|
-
<ul className="ml-3 list-disc text-muted">
|
|
150
|
-
{g.lines.map((line, i) => (
|
|
151
|
-
<li key={i}>{line}</li>
|
|
152
|
-
))}
|
|
153
|
-
</ul>
|
|
154
|
-
</div>
|
|
155
|
-
))}
|
|
156
|
-
</div>
|
|
157
|
-
)}
|
|
158
|
-
</div>
|
|
159
|
-
</details>
|
|
160
|
-
</div>
|
|
72
|
+
)}
|
|
73
|
+
/>
|
|
161
74
|
);
|
|
162
75
|
}
|