plotlink-ows 1.2.95 → 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.
@@ -0,0 +1,306 @@
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 observer = new ResizeObserver(() => {
107
+ setStageSize({
108
+ width: el.clientWidth,
109
+ height: el.clientHeight,
110
+ });
111
+ });
112
+ observer.observe(el);
113
+ return () => observer.disconnect();
114
+ }, []);
115
+
116
+ const measureWidth = useCallback(
117
+ (fontFamily: string) =>
118
+ (text: string, fontSize: number, fontWeight: 400 | 700 = 400): number => {
119
+ if (!measureContext) return text.length * fontSize * 0.5;
120
+ measureContext.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
121
+ return measureContext.measureText(text).width;
122
+ },
123
+ [measureContext],
124
+ );
125
+
126
+ const stage = (
127
+ <div
128
+ className={className ?? "w-full rounded border border-border bg-white"}
129
+ data-testid={testId}
130
+ ref={stageRef}
131
+ style={{
132
+ aspectRatio: `${naturalSize.width} / ${naturalSize.height}`,
133
+ maxHeight: "32rem",
134
+ }}
135
+ >
136
+ <div className="relative w-full h-full overflow-hidden rounded border border-border bg-white">
137
+ {assetPath ? (
138
+ asset.error || (!asset.loading && !asset.url) ? (
139
+ <div className="w-full h-full flex items-center justify-center text-[10px] text-muted bg-surface/40">
140
+ Image not available
141
+ </div>
142
+ ) : asset.url ? (
143
+ <img
144
+ src={asset.url}
145
+ alt={alt}
146
+ className="absolute inset-0 w-full h-full object-contain"
147
+ draggable={false}
148
+ onLoad={(e) => {
149
+ const width = e.currentTarget.naturalWidth || naturalSize.width;
150
+ const height = e.currentTarget.naturalHeight || naturalSize.height;
151
+ if (width > 0 && height > 0) setNaturalSize({ width, height });
152
+ }}
153
+ />
154
+ ) : (
155
+ <div className="w-full h-full flex items-center justify-center text-[10px] text-muted bg-surface/40">
156
+ Loading image…
157
+ </div>
158
+ )
159
+ ) : (
160
+ <div
161
+ className="absolute inset-0"
162
+ style={{ background: background || "#101820" }}
163
+ />
164
+ )}
165
+
166
+ {stageSize.width > 0 && stageSize.height > 0 && overlays.length > 0 && (
167
+ <>
168
+ <svg
169
+ className="absolute inset-0 w-full h-full pointer-events-none"
170
+ data-testid={testId ? `${testId}-overlay-layer` : undefined}
171
+ >
172
+ {overlays.map((overlay) => {
173
+ if (overlay.type !== "speech") return null;
174
+ const ox = overlay.x * stageSize.width;
175
+ const oy = overlay.y * stageSize.height;
176
+ const ow = overlay.width * stageSize.width;
177
+ const oh = overlay.height * stageSize.height;
178
+ const radius = balloonRadiusForOverlay(overlay, ow, oh);
179
+ const tail = overlay.tailAnchor
180
+ ? speechTailPoints(ox, oy, ow, oh, overlay.tailAnchor, radius)
181
+ : null;
182
+ const strokeW = Math.max(1.25, stageSize.height * 0.004);
183
+ return (
184
+ <path
185
+ key={overlay.id}
186
+ data-testid={testId ? `${testId}-overlay-${overlay.id}` : undefined}
187
+ d={balloonPathD(ox, oy, ow, oh, tail, radius)}
188
+ className="fill-white/95 stroke-[#1a1a1a]"
189
+ strokeWidth={strokeW}
190
+ strokeLinejoin="round"
191
+ />
192
+ );
193
+ })}
194
+ </svg>
195
+ {overlays.map((overlay) => {
196
+ const left = overlay.x * stageSize.width;
197
+ const top = overlay.y * stageSize.height;
198
+ const width = overlay.width * stageSize.width;
199
+ const height = overlay.height * stageSize.height;
200
+ const fontFamily =
201
+ overlay.type === "sfx" ? displayFontFamily : bodyFontFamily;
202
+ const isSpeech = overlay.type === "speech";
203
+ const isNarration = overlay.type === "narration";
204
+ const hasSpeaker = overlay.type !== "sfx" && !!overlay.speaker;
205
+ return (
206
+ <div
207
+ key={overlay.id}
208
+ className={`absolute rounded overflow-hidden ${
209
+ isSpeech ? "" : "border-2"
210
+ } ${
211
+ overlay.type === "narration"
212
+ ? "border-muted/40 bg-[#f4efe6]/85 rounded-md"
213
+ : overlay.type === "sfx"
214
+ ? "border-accent/40"
215
+ : ""
216
+ }`}
217
+ style={{ left, top, width, height }}
218
+ >
219
+ {!overlay.text ? (
220
+ <span
221
+ className="block truncate px-1 text-[9px] text-muted"
222
+ style={{ fontFamily }}
223
+ >
224
+ {overlay.type}
225
+ </span>
226
+ ) : !fontsReady ? (
227
+ <div
228
+ className="absolute inset-0 flex items-center justify-center px-1 text-center break-words"
229
+ style={{
230
+ fontFamily,
231
+ fontSize: Math.max(8, Math.min(height * 0.05, 14)),
232
+ fontWeight: overlay.textStyle?.fontWeight ?? 400,
233
+ }}
234
+ >
235
+ {hasSpeaker ? `${overlay.speaker}: ${overlay.text}` : overlay.text}
236
+ </div>
237
+ ) : (
238
+ (() => {
239
+ const layout = layoutBubbleText(
240
+ measureWidth(fontFamily),
241
+ overlay.text,
242
+ width,
243
+ height,
244
+ bubbleLayoutOptionsForOverlay(
245
+ overlay,
246
+ stageSize.height,
247
+ width,
248
+ height,
249
+ ),
250
+ );
251
+ return (
252
+ <div
253
+ className="absolute inset-0 flex flex-col items-center justify-center px-1 text-center"
254
+ style={{ fontFamily }}
255
+ >
256
+ {hasSpeaker && (
257
+ <span
258
+ className="block font-bold text-[#3a3a3a]"
259
+ style={{
260
+ fontSize: layout.speakerFontSize,
261
+ lineHeight: 1.2,
262
+ }}
263
+ >
264
+ {overlay.speaker}
265
+ </span>
266
+ )}
267
+ <span
268
+ className="text-[#1a1a1a]"
269
+ style={{
270
+ fontSize: layout.fontSize,
271
+ lineHeight: `${layout.lineHeight}px`,
272
+ fontWeight: overlay.textStyle?.fontWeight ?? 400,
273
+ }}
274
+ >
275
+ {layout.lines.map((line, i) => (
276
+ <span key={i} className="block">
277
+ {line}
278
+ </span>
279
+ ))}
280
+ </span>
281
+ </div>
282
+ );
283
+ })()
284
+ )}
285
+ </div>
286
+ );
287
+ })}
288
+ </>
289
+ )}
290
+ </div>
291
+ </div>
292
+ );
293
+
294
+ if (!onClick) return stage;
295
+
296
+ return (
297
+ <button
298
+ type="button"
299
+ onClick={onClick}
300
+ className="block w-full text-left"
301
+ data-testid={testId ? `${testId}-open` : undefined}
302
+ >
303
+ {stage}
304
+ </button>
305
+ );
306
+ }
@@ -45,10 +45,34 @@ 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
+
48
53
  return (
49
54
  <div className="h-full overflow-y-auto px-4 py-4" data-testid="episodes-page">
50
55
  <h2 className="text-base font-serif text-foreground">Episodes</h2>
51
56
  <p className="mt-0.5 text-[11px] text-muted">Genesis is Episode 1; each plot file is the next episode.</p>
57
+ <div className="mt-3 flex flex-wrap gap-1.5 text-[10px]" data-testid="episodes-summary">
58
+ <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
59
+ {episodes.length} total
60
+ </span>
61
+ <span className="rounded-full border border-border bg-background px-2 py-0.5 text-muted">
62
+ {activeCount} active
63
+ </span>
64
+ <span className="rounded-full border border-green-700/30 bg-green-700/10 px-2 py-0.5 text-green-700">
65
+ {publishedCount} published
66
+ </span>
67
+ <span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-accent">
68
+ {readyCount} ready
69
+ </span>
70
+ {blockedCount > 0 && (
71
+ <span className="rounded-full border border-error/30 bg-error/10 px-2 py-0.5 text-error">
72
+ {blockedCount} need fixes
73
+ </span>
74
+ )}
75
+ </div>
52
76
 
53
77
  {episodes.length === 0 ? (
54
78
  <p className="mt-4 text-xs text-muted italic" data-testid="episodes-empty">No episodes yet — write the Genesis to start Episode 1.</p>
@@ -1,8 +1,5 @@
1
- import { groupCartoonIssues, type CartoonChecklist } from "@app-lib/cartoon-readiness";
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
- * The end-to-end pilot showed the production tail (export upload prepare
37
- * markdown publish) was technically complete but fragmented: a writer had to know
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,94 +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
- : ready
45
+ : markdownReady
85
46
  ? "Episode ready to publish"
86
47
  : "Finish episode";
87
48
 
88
49
  return (
89
- <div
90
- className="px-3 py-2 border-b border-border bg-surface/50 space-y-2 flex-shrink-0"
91
- data-testid="finish-episode-panel"
92
- >
93
- <div className="flex items-center justify-between gap-2">
94
- <span className="text-[11px] font-medium text-foreground">Finish episode</span>
95
- {checklist.nextStep && (
96
- <span className="text-[10px] text-muted truncate" data-testid="finish-next-step">
97
- Next: {checklist.nextStep}
98
- </span>
99
- )}
100
- </div>
101
-
102
- {/* Writer-language step status — the exact webtoon production sequence. */}
103
- <ol className="flex flex-wrap gap-1.5">
104
- {steps.map((s) => (
105
- <li
106
- key={s.key}
107
- data-testid={`finish-step-${s.key}`}
108
- data-status={s.status}
109
- className={`flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] ${
110
- s.status === "current"
111
- ? "border-accent/40 bg-accent/10 text-accent"
112
- : s.status === "done"
113
- ? "border-border bg-background/70 text-foreground"
114
- : "border-border/70 bg-background/40 text-muted"
115
- }`}
116
- >
117
- <span aria-hidden>{STATUS_MARK[s.status]}</span>
118
- <span>{s.label}</span>
119
- {s.detail && <span className="text-muted">· {s.detail}</span>}
120
- </li>
121
- ))}
122
- </ol>
123
-
124
- <button
125
- onClick={onFinish}
126
- disabled={finishing || !canFinish}
127
- data-testid="finish-episode-btn"
128
- title="Upload the exported final panels, then prepare the episode for publishing — picks up where it left off"
129
- className="px-3 py-1 text-xs border border-accent/40 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
130
- >
131
- {buttonLabel}
132
- </button>
133
-
134
- {/* Blockers grouped by the step that fixes them, not a flat red list. */}
135
- {groups.length > 0 && (
136
- <div className="space-y-1.5" data-testid="finish-issues">
137
- {groups.map((g) => (
138
- <div key={g.key} data-testid={`finish-issue-group-${g.key}`} className="text-[10px]">
139
- <p className="font-medium text-amber-700">{g.title}</p>
140
- <ul className="ml-3 list-disc text-muted">
141
- {g.lines.map((line, i) => (
142
- <li key={i}>{line}</li>
143
- ))}
144
- </ul>
145
- </div>
146
- ))}
147
- </div>
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={(
63
+ <button
64
+ onClick={onFinish}
65
+ disabled={finishing || !canFinish}
66
+ data-testid="finish-episode-btn"
67
+ title="Upload the exported final panels, then prepare the episode for publishing — picks up where it left off"
68
+ className="px-2.5 py-1 text-[11px] border border-accent/40 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
69
+ >
70
+ {buttonLabel}
71
+ </button>
148
72
  )}
149
- </div>
73
+ />
150
74
  );
151
75
  }