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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { CartoonChecklist, CartoonChecklistStep } from "./cartoon-readiness";
|
|
2
|
+
|
|
3
|
+
export type CartoonProductionStepKey =
|
|
4
|
+
| CartoonChecklistStep["key"]
|
|
5
|
+
| "assemble"
|
|
6
|
+
| "ready";
|
|
7
|
+
|
|
8
|
+
export interface CartoonProductionStep {
|
|
9
|
+
key: CartoonProductionStepKey;
|
|
10
|
+
label: string;
|
|
11
|
+
status: "done" | "current" | "todo";
|
|
12
|
+
detail: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CartoonProductionStatus {
|
|
16
|
+
steps: CartoonProductionStep[];
|
|
17
|
+
activeStep: CartoonProductionStep | null;
|
|
18
|
+
statusLabel: string | null;
|
|
19
|
+
completedCount: number;
|
|
20
|
+
totalCount: number;
|
|
21
|
+
outstandingCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildCartoonProductionStatus(input: {
|
|
25
|
+
checklist: CartoonChecklist | null;
|
|
26
|
+
markdownReady?: boolean;
|
|
27
|
+
published?: boolean;
|
|
28
|
+
}): CartoonProductionStatus | null {
|
|
29
|
+
const { checklist, markdownReady = false, published = false } = input;
|
|
30
|
+
if (!checklist || checklist.steps.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
const uploadDone =
|
|
33
|
+
checklist.steps.find((step) => step.key === "upload")?.status === "done";
|
|
34
|
+
const ready = uploadDone && markdownReady && !published;
|
|
35
|
+
const assembleStatus: CartoonProductionStep["status"] = published || markdownReady
|
|
36
|
+
? "done"
|
|
37
|
+
: uploadDone
|
|
38
|
+
? "current"
|
|
39
|
+
: "todo";
|
|
40
|
+
const readyStatus: CartoonProductionStep["status"] = published
|
|
41
|
+
? "done"
|
|
42
|
+
: ready
|
|
43
|
+
? "current"
|
|
44
|
+
: "todo";
|
|
45
|
+
|
|
46
|
+
const steps: CartoonProductionStep[] = [
|
|
47
|
+
...checklist.steps.filter((step) => step.key !== "publish"),
|
|
48
|
+
{
|
|
49
|
+
key: "assemble",
|
|
50
|
+
label: "Episode sequence prepared",
|
|
51
|
+
status: assembleStatus,
|
|
52
|
+
detail: null,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: "ready",
|
|
56
|
+
label: published ? "Published to PlotLink" : "Ready to publish",
|
|
57
|
+
status: readyStatus,
|
|
58
|
+
detail: null,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const activeStep =
|
|
63
|
+
steps.find((step) => step.status === "current")
|
|
64
|
+
?? steps.find((step) => step.status === "todo")
|
|
65
|
+
?? steps[steps.length - 1]
|
|
66
|
+
?? null;
|
|
67
|
+
const completedCount = steps.filter((step) => step.status === "done").length;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
steps,
|
|
71
|
+
activeStep,
|
|
72
|
+
statusLabel: activeStep?.label ?? null,
|
|
73
|
+
completedCount,
|
|
74
|
+
totalCount: steps.length,
|
|
75
|
+
outstandingCount: steps.filter((step) => step.status !== "done").length,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
2
|
import type { StoryProgress } from "@app-lib/story-progress";
|
|
3
|
-
import type { CoachUiAction } from "@app-lib/cartoon-coach";
|
|
4
|
-
import { WorkflowCoachView } from "./WorkflowCoach";
|
|
3
|
+
import type { CartoonCoach, CoachUiAction } from "@app-lib/cartoon-coach";
|
|
5
4
|
|
|
6
5
|
export function storyInfoNextStep(progress: StoryProgress): string {
|
|
7
6
|
if (progress.cover !== "present") {
|
|
@@ -34,39 +33,161 @@ export function cartoonWorkflowActiveKey(progress: StoryProgress): string | null
|
|
|
34
33
|
return coach?.episodeFile ?? null;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
function CompactNextActionShell({
|
|
37
|
+
badge,
|
|
38
|
+
tone = "accent",
|
|
39
|
+
summary,
|
|
40
|
+
children,
|
|
41
|
+
note,
|
|
42
|
+
testId,
|
|
40
43
|
}: {
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
badge: string;
|
|
45
|
+
tone?: "accent" | "complete";
|
|
46
|
+
summary: ReactNode;
|
|
47
|
+
children?: ReactNode;
|
|
48
|
+
note?: ReactNode;
|
|
49
|
+
testId: string;
|
|
43
50
|
}) {
|
|
51
|
+
const shellTone =
|
|
52
|
+
tone === "complete"
|
|
53
|
+
? "border-green-700/20 bg-green-950/5"
|
|
54
|
+
: "border-accent/30 bg-background/95";
|
|
55
|
+
const badgeTone =
|
|
56
|
+
tone === "complete"
|
|
57
|
+
? "bg-green-700/10 text-green-700"
|
|
58
|
+
: "bg-accent/10 text-accent";
|
|
44
59
|
return (
|
|
45
|
-
<div
|
|
46
|
-
|
|
60
|
+
<div
|
|
61
|
+
className={`border px-3 py-3 sm:px-4 ${shellTone}`}
|
|
62
|
+
data-testid={testId}
|
|
63
|
+
data-state={tone === "complete" ? "complete" : "active"}
|
|
64
|
+
>
|
|
65
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
47
66
|
<div className="min-w-0 flex-1">
|
|
48
|
-
<span
|
|
49
|
-
|
|
67
|
+
<span
|
|
68
|
+
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] ${badgeTone}`}
|
|
69
|
+
>
|
|
70
|
+
{badge}
|
|
50
71
|
</span>
|
|
51
|
-
<p className="mt-1 text-sm text-foreground"
|
|
52
|
-
|
|
53
|
-
<span>{storyInfoNextStep(progress)}</span>
|
|
54
|
-
</p>
|
|
72
|
+
<p className="mt-1 text-sm text-foreground">{summary}</p>
|
|
73
|
+
{note ? <p className="mt-1 text-[11px] font-medium text-accent">{note}</p> : null}
|
|
55
74
|
</div>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
data-testid="story-info-next-action-btn"
|
|
62
|
-
>
|
|
63
|
-
Next Action
|
|
64
|
-
</button>
|
|
75
|
+
{children ? (
|
|
76
|
+
<div className="flex w-full justify-end sm:w-auto sm:flex-shrink-0">
|
|
77
|
+
{children}
|
|
78
|
+
</div>
|
|
79
|
+
) : null}
|
|
65
80
|
</div>
|
|
66
81
|
</div>
|
|
67
82
|
);
|
|
68
83
|
}
|
|
69
84
|
|
|
85
|
+
function NextActionButton({
|
|
86
|
+
onClick,
|
|
87
|
+
disabled,
|
|
88
|
+
testId,
|
|
89
|
+
}: {
|
|
90
|
+
onClick: () => void;
|
|
91
|
+
disabled?: boolean;
|
|
92
|
+
testId: string;
|
|
93
|
+
}) {
|
|
94
|
+
return (
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={onClick}
|
|
98
|
+
disabled={disabled}
|
|
99
|
+
className="w-full rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
|
|
100
|
+
data-testid={testId}
|
|
101
|
+
>
|
|
102
|
+
Next Action
|
|
103
|
+
</button>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function WorkflowCoachCompact({
|
|
108
|
+
coach,
|
|
109
|
+
onAction,
|
|
110
|
+
}: {
|
|
111
|
+
coach: CartoonCoach | null | undefined;
|
|
112
|
+
onAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
113
|
+
}) {
|
|
114
|
+
const [copiedPrompt, setCopiedPrompt] = useState<string | null>(null);
|
|
115
|
+
const copied = copiedPrompt !== null && copiedPrompt === coach?.prompt;
|
|
116
|
+
|
|
117
|
+
if (coach === undefined) return null;
|
|
118
|
+
if (!coach) {
|
|
119
|
+
return (
|
|
120
|
+
<CompactNextActionShell
|
|
121
|
+
badge="Complete"
|
|
122
|
+
tone="complete"
|
|
123
|
+
summary="No next action available."
|
|
124
|
+
note="This workflow has no queued next step right now."
|
|
125
|
+
testId="cartoon-next-action"
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const button =
|
|
131
|
+
coach.actionKind === "agent" && coach.prompt ? (
|
|
132
|
+
<NextActionButton
|
|
133
|
+
testId="workflow-coach-copy"
|
|
134
|
+
onClick={() => {
|
|
135
|
+
if (!coach.prompt) return;
|
|
136
|
+
const prompt = coach.prompt;
|
|
137
|
+
navigator.clipboard?.writeText(prompt).then(() => setCopiedPrompt(prompt)).catch(() => {});
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
) : coach.actionKind === "ui" && coach.uiAction ? (
|
|
141
|
+
<NextActionButton
|
|
142
|
+
testId="workflow-coach-do"
|
|
143
|
+
onClick={() => onAction(coach.uiAction!, coach.episodeFile)}
|
|
144
|
+
/>
|
|
145
|
+
) : null;
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<CompactNextActionShell
|
|
149
|
+
badge={coach.stageLabel}
|
|
150
|
+
summary={(
|
|
151
|
+
<span data-testid="workflow-coach-action">
|
|
152
|
+
<span className="font-semibold">Next: </span>
|
|
153
|
+
<span>{coach.action}</span>
|
|
154
|
+
</span>
|
|
155
|
+
)}
|
|
156
|
+
note={copied ? "Prompt copied." : undefined}
|
|
157
|
+
testId="cartoon-next-action"
|
|
158
|
+
>
|
|
159
|
+
{button}
|
|
160
|
+
</CompactNextActionShell>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function StoryInfoNextActionCard({
|
|
165
|
+
progress,
|
|
166
|
+
onOpenStoryInfo,
|
|
167
|
+
}: {
|
|
168
|
+
progress: StoryProgress;
|
|
169
|
+
onOpenStoryInfo?: () => void;
|
|
170
|
+
}) {
|
|
171
|
+
return (
|
|
172
|
+
<CompactNextActionShell
|
|
173
|
+
badge="Story info"
|
|
174
|
+
summary={(
|
|
175
|
+
<span data-testid="story-info-next-action">
|
|
176
|
+
<span className="font-semibold">Next: </span>
|
|
177
|
+
<span>{storyInfoNextStep(progress)}</span>
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
testId="story-info-cta"
|
|
181
|
+
>
|
|
182
|
+
<NextActionButton
|
|
183
|
+
testId="story-info-next-action-btn"
|
|
184
|
+
onClick={() => onOpenStoryInfo?.()}
|
|
185
|
+
disabled={!onOpenStoryInfo}
|
|
186
|
+
/>
|
|
187
|
+
</CompactNextActionShell>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
70
191
|
export function CartoonNextActionView({
|
|
71
192
|
progress,
|
|
72
193
|
onCoachAction,
|
|
@@ -80,31 +201,27 @@ export function CartoonNextActionView({
|
|
|
80
201
|
if (activeKey === "story-info") {
|
|
81
202
|
return <StoryInfoNextActionCard progress={progress} onOpenStoryInfo={onOpenStoryInfo} />;
|
|
82
203
|
}
|
|
83
|
-
return
|
|
84
|
-
<WorkflowCoachView
|
|
85
|
-
coach={progress.coach ?? null}
|
|
86
|
-
showEmptyState
|
|
87
|
-
onAction={onCoachAction}
|
|
88
|
-
/>
|
|
89
|
-
);
|
|
204
|
+
return <WorkflowCoachCompact coach={progress.coach ?? null} onAction={onCoachAction} />;
|
|
90
205
|
}
|
|
91
206
|
|
|
92
207
|
export function CartoonNextAction({
|
|
93
208
|
storyName,
|
|
94
209
|
authFetch,
|
|
210
|
+
fileName,
|
|
95
211
|
refreshKey = 0,
|
|
96
212
|
onCoachAction,
|
|
97
213
|
onOpenStoryInfo,
|
|
98
214
|
}: {
|
|
99
215
|
storyName: string;
|
|
100
216
|
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
217
|
+
fileName?: string | null;
|
|
101
218
|
refreshKey?: number;
|
|
102
219
|
onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
103
220
|
onOpenStoryInfo?: () => void;
|
|
104
221
|
}) {
|
|
105
222
|
const [progress, setProgress] = useState<StoryProgress | null | undefined>(undefined);
|
|
106
223
|
|
|
107
|
-
const targetKey = JSON.stringify([storyName, refreshKey]);
|
|
224
|
+
const targetKey = JSON.stringify([storyName, fileName ?? "", refreshKey]);
|
|
108
225
|
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
|
109
226
|
if (loadedKey !== targetKey) {
|
|
110
227
|
setProgress(undefined);
|
|
@@ -113,7 +230,8 @@ export function CartoonNextAction({
|
|
|
113
230
|
|
|
114
231
|
useEffect(() => {
|
|
115
232
|
let cancelled = false;
|
|
116
|
-
|
|
233
|
+
const focus = fileName ? `?focus=${encodeURIComponent(fileName)}` : "";
|
|
234
|
+
authFetch(`/api/stories/${storyName}/progress${focus}`)
|
|
117
235
|
.then((res) => (res.ok ? res.json() : null))
|
|
118
236
|
.then((data: StoryProgress | null) => {
|
|
119
237
|
if (!cancelled) setProgress(isValidProgress(data) ? data : null);
|
|
@@ -122,11 +240,11 @@ export function CartoonNextAction({
|
|
|
122
240
|
if (!cancelled) setProgress(null);
|
|
123
241
|
});
|
|
124
242
|
return () => { cancelled = true; };
|
|
125
|
-
}, [storyName, authFetch, refreshKey]);
|
|
243
|
+
}, [storyName, fileName, authFetch, refreshKey]);
|
|
126
244
|
|
|
127
245
|
if (progress === undefined) return null;
|
|
128
246
|
if (!progress) {
|
|
129
|
-
return <
|
|
247
|
+
return <WorkflowCoachCompact coach={null} onAction={onCoachAction} />;
|
|
130
248
|
}
|
|
131
249
|
return (
|
|
132
250
|
<CartoonNextActionView
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { groupCartoonIssues, type CartoonChecklist } from "@app-lib/cartoon-readiness";
|
|
2
|
+
import { buildCartoonProductionStatus } from "@app-lib/cartoon-production-status";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
const STATUS_MARK: Record<"done" | "current" | "todo", string> = {
|
|
6
|
+
done: "✓",
|
|
7
|
+
current: "▸",
|
|
8
|
+
todo: "○",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
interface CartoonProductionStatusProps {
|
|
12
|
+
checklist: CartoonChecklist | null;
|
|
13
|
+
markdownReady?: boolean;
|
|
14
|
+
published?: boolean;
|
|
15
|
+
issues?: string[];
|
|
16
|
+
title?: string;
|
|
17
|
+
subtitle?: ReactNode;
|
|
18
|
+
action?: ReactNode;
|
|
19
|
+
rootTestId?: string;
|
|
20
|
+
detailsTestId?: string;
|
|
21
|
+
stepTestIdPrefix?: string;
|
|
22
|
+
issuesTestId?: string;
|
|
23
|
+
issueGroupTestIdPrefix?: string;
|
|
24
|
+
detailsLabel?: string;
|
|
25
|
+
summaryTestId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function CartoonProductionStatus({
|
|
29
|
+
checklist,
|
|
30
|
+
markdownReady = false,
|
|
31
|
+
published = false,
|
|
32
|
+
issues = [],
|
|
33
|
+
title = "Episode production",
|
|
34
|
+
subtitle,
|
|
35
|
+
action,
|
|
36
|
+
rootTestId = "cartoon-production-status",
|
|
37
|
+
detailsTestId = "cartoon-production-details",
|
|
38
|
+
stepTestIdPrefix = "cartoon-production-step",
|
|
39
|
+
issuesTestId,
|
|
40
|
+
issueGroupTestIdPrefix,
|
|
41
|
+
detailsLabel,
|
|
42
|
+
summaryTestId,
|
|
43
|
+
}: CartoonProductionStatusProps) {
|
|
44
|
+
const production = buildCartoonProductionStatus({
|
|
45
|
+
checklist,
|
|
46
|
+
markdownReady,
|
|
47
|
+
published,
|
|
48
|
+
});
|
|
49
|
+
if (!production) return null;
|
|
50
|
+
|
|
51
|
+
const groups = groupCartoonIssues(issues);
|
|
52
|
+
const issuesCount = groups.reduce((sum, group) => sum + group.lines.length, 0);
|
|
53
|
+
const statusLabel = production.statusLabel ?? "Review cuts";
|
|
54
|
+
const progressLabel = `${production.completedCount} / ${production.totalCount} steps done`;
|
|
55
|
+
const detailsText = detailsLabel
|
|
56
|
+
?? (production.outstandingCount === 0
|
|
57
|
+
? "Production details"
|
|
58
|
+
: `${production.outstandingCount} step${production.outstandingCount === 1 ? "" : "s"} left`);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
className="rounded-lg border border-border bg-background/80 px-3 py-2"
|
|
63
|
+
data-testid={rootTestId}
|
|
64
|
+
>
|
|
65
|
+
<div className="flex flex-wrap items-start gap-2 justify-between">
|
|
66
|
+
<div className="min-w-0 space-y-1">
|
|
67
|
+
<div className="flex flex-wrap items-center gap-1.5 text-[10px]">
|
|
68
|
+
<span className="font-medium text-foreground">{title}</span>
|
|
69
|
+
<span
|
|
70
|
+
className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 font-medium text-accent"
|
|
71
|
+
data-testid={summaryTestId}
|
|
72
|
+
>
|
|
73
|
+
Active: {statusLabel}
|
|
74
|
+
</span>
|
|
75
|
+
<span className="rounded-full border border-border bg-background px-2 py-0.5 text-muted">
|
|
76
|
+
{progressLabel}
|
|
77
|
+
</span>
|
|
78
|
+
{production.activeStep?.detail && (
|
|
79
|
+
<span className="text-muted">{production.activeStep.detail}</span>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
{subtitle ? (
|
|
83
|
+
<div className="text-[10px] text-muted">{subtitle}</div>
|
|
84
|
+
) : null}
|
|
85
|
+
</div>
|
|
86
|
+
{action ? <div className="flex-shrink-0">{action}</div> : null}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<details className="mt-2" data-testid={detailsTestId}>
|
|
90
|
+
<summary className="cursor-pointer select-none text-[10px] text-muted hover:text-foreground">
|
|
91
|
+
{detailsText}
|
|
92
|
+
{issuesCount > 0 ? ` · ${issuesCount} blocker${issuesCount === 1 ? "" : "s"}` : ""}
|
|
93
|
+
</summary>
|
|
94
|
+
|
|
95
|
+
<div className="mt-1.5 space-y-1.5">
|
|
96
|
+
<ol className="flex flex-wrap gap-1.5">
|
|
97
|
+
{production.steps.map((step) => (
|
|
98
|
+
<li
|
|
99
|
+
key={step.key}
|
|
100
|
+
data-testid={`${stepTestIdPrefix}-${step.key}`}
|
|
101
|
+
data-status={step.status}
|
|
102
|
+
className={`flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] ${
|
|
103
|
+
step.status === "current"
|
|
104
|
+
? "border-accent/40 bg-accent/10 text-accent"
|
|
105
|
+
: step.status === "done"
|
|
106
|
+
? "border-border bg-background/70 text-foreground"
|
|
107
|
+
: "border-border/70 bg-background/40 text-muted"
|
|
108
|
+
}`}
|
|
109
|
+
>
|
|
110
|
+
<span aria-hidden>{STATUS_MARK[step.status]}</span>
|
|
111
|
+
<span>{step.label}</span>
|
|
112
|
+
{step.detail && <span className="text-muted">· {step.detail}</span>}
|
|
113
|
+
</li>
|
|
114
|
+
))}
|
|
115
|
+
</ol>
|
|
116
|
+
|
|
117
|
+
{groups.length > 0 && (
|
|
118
|
+
<div
|
|
119
|
+
className="space-y-1.5"
|
|
120
|
+
data-testid={issuesTestId ?? `${stepTestIdPrefix}-issues`}
|
|
121
|
+
>
|
|
122
|
+
{groups.map((group) => (
|
|
123
|
+
<div
|
|
124
|
+
key={group.key}
|
|
125
|
+
data-testid={`${issueGroupTestIdPrefix ?? `${stepTestIdPrefix}-issue-group`}-${group.key}`}
|
|
126
|
+
className="text-[10px]"
|
|
127
|
+
>
|
|
128
|
+
<p className="font-medium text-amber-700">{group.title}</p>
|
|
129
|
+
<ul className="ml-3 list-disc text-muted">
|
|
130
|
+
{group.lines.map((line, i) => (
|
|
131
|
+
<li key={i}>{line}</li>
|
|
132
|
+
))}
|
|
133
|
+
</ul>
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
</details>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import type { StoryProgress, EpisodeProgress } from "@app-lib/story-progress";
|
|
3
|
-
import { cartoonGenesisReadiness, classifyCartoonReadiness, groupCartoonIssues } from "@app-lib/cartoon-readiness";
|
|
3
|
+
import { cartoonChecklist, cartoonGenesisReadiness, classifyCartoonReadiness, groupCartoonIssues } from "@app-lib/cartoon-readiness";
|
|
4
4
|
import type { Cut } from "@app-lib/cuts";
|
|
5
|
+
import { CartoonProductionStatus } from "./CartoonProductionStatus";
|
|
5
6
|
import { derivePublishTitle, isRawFilenameTitle, hasExplicitEpisodeTitle } from "../lib/publish-helpers";
|
|
6
7
|
|
|
7
8
|
interface CartoonPublishPageProps {
|
|
@@ -23,9 +24,6 @@ interface CartoonPublishPageProps {
|
|
|
23
24
|
refreshKey?: number;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
type CheckState = "done" | "todo";
|
|
27
|
-
interface PublishCheck { label: string; status: CheckState; detail?: string | null }
|
|
28
|
-
|
|
29
27
|
/**
|
|
30
28
|
* Dedicated cartoon "Publish" workflow page (#449, spec §10).
|
|
31
29
|
*
|
|
@@ -172,18 +170,8 @@ export function CartoonPublishPage({ storyName, authFetch, onOpenFile, onOpenSto
|
|
|
172
170
|
);
|
|
173
171
|
}
|
|
174
172
|
|
|
175
|
-
const c = active.cuts;
|
|
176
173
|
const coverDone = progress.cover === "present";
|
|
177
|
-
const
|
|
178
|
-
{ label: "Opening text ready", status: "done" }, // the episode exists once it appears here
|
|
179
|
-
{ label: "Cut plan", status: c && c.total > 0 ? "done" : "todo", detail: c ? `${c.total} cut${c.total === 1 ? "" : "s"} planned` : "not started" },
|
|
180
|
-
{ label: "Clean images converted", status: c && c.needClean > 0 && c.withClean === c.needClean ? "done" : "todo", detail: c ? `${c.withClean} / ${c.needClean}` : null },
|
|
181
|
-
{ label: "Cuts lettered", status: c && c.total > 0 && c.withText === c.total ? "done" : "todo", detail: c ? `${c.withText} / ${c.total}` : null },
|
|
182
|
-
{ label: "Final images exported", status: c && c.total > 0 && c.exported === c.total ? "done" : "todo", detail: c ? `${c.exported} / ${c.total}` : null },
|
|
183
|
-
{ label: "Final images uploaded", status: c && c.total > 0 && c.uploaded === c.total ? "done" : "todo", detail: c ? `${c.uploaded} / ${c.total}` : null },
|
|
184
|
-
{ label: "Cover image", status: coverDone ? "done" : "todo", detail: coverDone ? null : "recommended before publishing" },
|
|
185
|
-
{ label: "Publish to PlotLink", status: active.published ? "done" : "todo" },
|
|
186
|
-
];
|
|
174
|
+
const checklist = cartoonChecklist({ cuts: activeCuts ?? [], published: active.published });
|
|
187
175
|
|
|
188
176
|
const ready = active.state === "ready";
|
|
189
177
|
const blocked = active.state === "blocked";
|
|
@@ -247,17 +235,38 @@ export function CartoonPublishPage({ storyName, authFetch, onOpenFile, onOpenSto
|
|
|
247
235
|
return (
|
|
248
236
|
<div className="h-full overflow-y-auto px-4 py-4" data-testid="cartoon-publish-page">
|
|
249
237
|
<h2 className="text-base font-serif text-foreground">Publish {active.label}</h2>
|
|
250
|
-
<p className="mt-0.5 text-[11px] text-muted">
|
|
238
|
+
<p className="mt-0.5 text-[11px] text-muted">Publish stays focused on readiness and blockers. Open production details only if you need the full step map.</p>
|
|
251
239
|
|
|
252
|
-
<
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
240
|
+
<div className="mt-3 max-w-xl" data-testid="publish-checklist">
|
|
241
|
+
<CartoonProductionStatus
|
|
242
|
+
checklist={checklist}
|
|
243
|
+
markdownReady={ready}
|
|
244
|
+
published={active.published}
|
|
245
|
+
title="Episode production"
|
|
246
|
+
subtitle={coverDone
|
|
247
|
+
? "Cover and publish readiness are checked below."
|
|
248
|
+
: "Episode production is tracked here; cover readiness is checked below."}
|
|
249
|
+
rootTestId="publish-production-status"
|
|
250
|
+
detailsTestId="publish-production-details"
|
|
251
|
+
stepTestIdPrefix="publish-step"
|
|
252
|
+
/>
|
|
253
|
+
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
|
|
254
|
+
<span
|
|
255
|
+
className={`rounded-full border px-2 py-0.5 ${coverDone ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}
|
|
256
|
+
data-testid="publish-cover-status"
|
|
257
|
+
>
|
|
258
|
+
Cover image: {coverDone ? "Ready" : "Missing"}
|
|
259
|
+
</span>
|
|
260
|
+
{isGenesisActive && (
|
|
261
|
+
<span
|
|
262
|
+
className={`rounded-full border px-2 py-0.5 ${metaReady ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}
|
|
263
|
+
data-testid="publish-metadata-status"
|
|
264
|
+
>
|
|
265
|
+
Story info: {metaReady ? "Ready" : "Set genre & language"}
|
|
266
|
+
</span>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
261
270
|
|
|
262
271
|
{/* Migrated episode diagnostics (#461): the publish title (#358), Genesis
|
|
263
272
|
prologue readiness (#359), and grouped publish issues (#360) that used
|