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.
@@ -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">Genesis is Episode 1; each plot file is the next episode.</p>
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 — write the Genesis to start Episode 1.</p>
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.label}</span>
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 { 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,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
- : ready
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
- <div
93
- className="px-3 py-1.5 border-b border-border bg-surface/50 flex-shrink-0"
94
- data-testid="finish-episode-panel"
95
- >
96
- <div className="flex flex-wrap items-center gap-2">
97
- <span className="text-[11px] font-medium text-foreground">Finish episode</span>
98
- {checklist.nextStep && (
99
- <span className="min-w-0 flex-1 text-[10px] text-muted truncate" data-testid="finish-next-step">
100
- Next: {checklist.nextStep}
101
- </span>
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-0.5 text-[11px] border border-accent/40 text-accent rounded hover:bg-accent/5 disabled:opacity-50"
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
- </div>
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
  }