plotlink-ows 1.2.94 → 1.2.95
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/active-wallet.ts +260 -0
- package/app/lib/cartoon-coach.ts +1 -1
- package/app/lib/cartoon-readiness.ts +12 -10
- package/app/lib/story-progress.ts +2 -3
- package/app/routes/dashboard.ts +6 -4
- package/app/routes/publish.ts +56 -23
- package/app/routes/settings.ts +92 -37
- package/app/routes/wallet.ts +58 -30
- package/app/web/components/CartoonNextAction.tsx +145 -0
- package/app/web/components/CartoonPublishPage.tsx +1 -1
- package/app/web/components/CutListPanel.tsx +124 -86
- package/app/web/components/Dashboard.tsx +15 -6
- package/app/web/components/LetteringEditor.tsx +55 -14
- package/app/web/components/PreviewPanel.tsx +2 -1
- package/app/web/components/StoriesPage.tsx +35 -0
- package/app/web/components/StoryProgressPanel.tsx +32 -102
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +63 -35
- package/app/web/dist/assets/{export-cut-nKQ_n2-J.js → export-cut-che5mMWc.js} +1 -1
- package/app/web/dist/assets/index-CcfChGEK.css +32 -0
- package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-BAZGwVwj.js +0 -143
- package/app/web/dist/assets/index-DoXH2OlP.css +0 -32
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { useEffect, useState
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
2
|
import type { StoryProgress, EpisodeProgress, EpisodeState } from "@app-lib/story-progress";
|
|
3
3
|
import type { CartoonChecklistStep } from "@app-lib/cartoon-readiness";
|
|
4
|
-
import {
|
|
4
|
+
import { cartoonWorkflowActiveKey, CartoonNextActionView } from "./CartoonNextAction";
|
|
5
5
|
|
|
6
6
|
interface StoryProgressPanelProps {
|
|
7
7
|
storyName: string;
|
|
8
8
|
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
9
9
|
/** Open a file from the map (the workflow steps link to their file). */
|
|
10
10
|
onOpenFile: (storyName: string, file: string) => void;
|
|
11
|
+
/** Open the Story Info workflow page when metadata/cover is the next gate. */
|
|
12
|
+
onOpenStoryInfo?: () => void;
|
|
11
13
|
/** Bumped by the parent to force a refresh (e.g. after a publish). */
|
|
12
14
|
refreshKey?: number;
|
|
13
15
|
}
|
|
@@ -18,15 +20,13 @@ interface StoryProgressPanelProps {
|
|
|
18
20
|
* For CARTOON stories this is the writer's main production dashboard: a vertical
|
|
19
21
|
* workflow map of numbered sections (Define Story Info → Story Whitepaper →
|
|
20
22
|
* Genesis / Episode 1 → Episode 2 …), each with a checkbox checklist and a clear
|
|
21
|
-
* status. The single next-action CTA
|
|
22
|
-
*
|
|
23
|
-
* webtoon workflow and where they are in it without reading terminal output or
|
|
24
|
-
* raw file names.
|
|
23
|
+
* status. The single next-action CTA stays persistent above the map, while the
|
|
24
|
+
* current section still marks where that action belongs.
|
|
25
25
|
*
|
|
26
26
|
* FICTION keeps the simpler original layout — metadata, setup steps, a chapter
|
|
27
27
|
* list — and is completely unaffected by the cartoon redesign.
|
|
28
28
|
*/
|
|
29
|
-
export function StoryProgressPanel({ storyName, authFetch, onOpenFile, refreshKey = 0 }: StoryProgressPanelProps) {
|
|
29
|
+
export function StoryProgressPanel({ storyName, authFetch, onOpenFile, onOpenStoryInfo, refreshKey = 0 }: StoryProgressPanelProps) {
|
|
30
30
|
const [progress, setProgress] = useState<StoryProgress | null>(null);
|
|
31
31
|
const [loading, setLoading] = useState(true);
|
|
32
32
|
|
|
@@ -56,7 +56,7 @@ export function StoryProgressPanel({ storyName, authFetch, onOpenFile, refreshKe
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
return progress.contentType === "cartoon"
|
|
59
|
-
? <CartoonWorkflowMap progress={progress} storyName={storyName} onOpenFile={onOpenFile} />
|
|
59
|
+
? <CartoonWorkflowMap progress={progress} storyName={storyName} onOpenFile={onOpenFile} onOpenStoryInfo={onOpenStoryInfo} />
|
|
60
60
|
: <FictionProgressView progress={progress} storyName={storyName} onOpenFile={onOpenFile} />;
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -128,13 +128,11 @@ function ChecklistRow({ item }: { item: ChecklistItem }) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
* One numbered workflow section: a status bullet + title + status badge, a
|
|
132
|
-
* checklist
|
|
133
|
-
* header navigates to `openFile` when one is provided (sections whose dedicated
|
|
134
|
-
* page does not exist yet, e.g. Story Info, render a plain header).
|
|
131
|
+
* One numbered workflow section: a status bullet + title + status badge, plus a
|
|
132
|
+
* nested checklist. The header navigates to `openFile` when one is provided.
|
|
135
133
|
*/
|
|
136
134
|
function Section({
|
|
137
|
-
index, title, status, items, fileName, openFile,
|
|
135
|
+
index, title, status, items, fileName, openFile,
|
|
138
136
|
}: {
|
|
139
137
|
index: number;
|
|
140
138
|
title: string;
|
|
@@ -144,8 +142,6 @@ function Section({
|
|
|
144
142
|
fileName?: string | null;
|
|
145
143
|
/** Called to open the section's underlying file, or undefined for no navigation. */
|
|
146
144
|
openFile?: () => void;
|
|
147
|
-
/** The single CTA node, rendered under this section when it is the active one. */
|
|
148
|
-
cta?: ReactNode;
|
|
149
145
|
}) {
|
|
150
146
|
const heading = (
|
|
151
147
|
<div className="flex items-center gap-2 min-w-0">
|
|
@@ -163,7 +159,6 @@ function Section({
|
|
|
163
159
|
<div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
|
|
164
160
|
{items.map((it, i) => <ChecklistRow key={i} item={it} />)}
|
|
165
161
|
</div>
|
|
166
|
-
{cta && <div className="mt-2 ml-1" data-testid="section-cta">{cta}</div>}
|
|
167
162
|
</div>
|
|
168
163
|
);
|
|
169
164
|
}
|
|
@@ -207,89 +202,32 @@ const GENESIS_STUB: EpisodeProgress = {
|
|
|
207
202
|
state: "placeholder", summary: "", published: false, checklist: [], cuts: null,
|
|
208
203
|
};
|
|
209
204
|
|
|
210
|
-
/** The single Story-Info next step, when cover/metadata is the active gate. */
|
|
211
|
-
function storyInfoNextStep(progress: StoryProgress): string {
|
|
212
|
-
if (progress.cover !== "present") {
|
|
213
|
-
return progress.cover === "invalid"
|
|
214
|
-
? "Replace the cover image — it must be a valid WebP or JPEG."
|
|
215
|
-
: "Add a cover image before publishing.";
|
|
216
|
-
}
|
|
217
|
-
const missing: string[] = [];
|
|
218
|
-
if (!progress.metadata.language) missing.push("language");
|
|
219
|
-
if (!progress.metadata.genre) missing.push("genre");
|
|
220
|
-
if (!progress.metadata.title) missing.push("title");
|
|
221
|
-
return `Add the story ${missing.join(" and ") || "details"} before publishing.`;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
205
|
function CartoonWorkflowMap({
|
|
225
|
-
progress, storyName, onOpenFile,
|
|
226
|
-
}: {
|
|
227
|
-
|
|
206
|
+
progress, storyName, onOpenFile, onOpenStoryInfo,
|
|
207
|
+
}: {
|
|
208
|
+
progress: StoryProgress;
|
|
209
|
+
storyName: string;
|
|
210
|
+
onOpenFile: (storyName: string, file: string) => void;
|
|
211
|
+
onOpenStoryInfo?: () => void;
|
|
212
|
+
}) {
|
|
228
213
|
const m = progress.metadata;
|
|
229
214
|
const hasStructure = progress.setup.hasStructure;
|
|
230
|
-
const hasGenesis = progress.setup.hasGenesis;
|
|
231
215
|
const coverDone = progress.cover === "present";
|
|
232
|
-
// Required publish metadata (title/language/genre) still hard-gates the active
|
|
233
|
-
// step. A missing COVER is a publish-readiness recommendation, NOT the primary
|
|
234
|
-
// step (#462) — it's kept out of the active-gate decision while an episode is
|
|
235
|
-
// mid-production, so the cut/lettering production CTA leads instead.
|
|
236
216
|
const metadataIncomplete = !m.title || !m.language || !m.genre;
|
|
237
217
|
const storyInfoIncomplete = metadataIncomplete || !coverDone;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// the one CTA always matches the story-level next action and lands in its own
|
|
246
|
-
// section. `deriveCartoonCoach` agrees on every gate EXCEPT story info (it
|
|
247
|
-
// skips cover/metadata), so we own that gate here; the coach drives the rest.
|
|
248
|
-
// Crucially, every gate maps to a section that is ALWAYS rendered (Whitepaper,
|
|
249
|
-
// the always-present Genesis section, an episode, or the trailing block), so
|
|
250
|
-
// the CTA can never fall through the cracks (#444 review: it vanished when the
|
|
251
|
-
// bible was written but Genesis wasn't).
|
|
252
|
-
let activeKey: string | null;
|
|
253
|
-
if (!hasStructure) activeKey = "whitepaper";
|
|
254
|
-
else if (!hasGenesis) activeKey = "genesis.md";
|
|
255
|
-
else if (metadataIncomplete) activeKey = "story-info";
|
|
256
|
-
// #462: a mid-production episode leads over a missing cover — the cut/lettering
|
|
257
|
-
// production CTA is the primary step. A missing cover only becomes the active
|
|
258
|
-
// step once the active episode's production is complete (no work pending),
|
|
259
|
-
// where it reads as the publish-readiness recommendation.
|
|
260
|
-
else if (productionPending && coach?.episodeFile) activeKey = coach.episodeFile;
|
|
261
|
-
else if (!coverDone) activeKey = "story-info";
|
|
262
|
-
else activeKey = coach?.episodeFile ?? null;
|
|
263
|
-
|
|
264
|
-
// The coach-driven CTA (setup prompts + episode actions), reused from the
|
|
265
|
-
// tested coach view so routing stays byte-identical to the old overview.
|
|
266
|
-
const coachCta = coach ? (
|
|
267
|
-
<WorkflowCoachView
|
|
268
|
-
coach={coach}
|
|
269
|
-
onAction={(action, episodeFile) => {
|
|
218
|
+
const activeKey = cartoonWorkflowActiveKey(progress);
|
|
219
|
+
|
|
220
|
+
const topNextAction = (
|
|
221
|
+
<CartoonNextActionView
|
|
222
|
+
progress={progress}
|
|
223
|
+
onOpenStoryInfo={onOpenStoryInfo}
|
|
224
|
+
onCoachAction={(action, episodeFile) => {
|
|
270
225
|
if (action === "view-progress") return; // already here
|
|
271
226
|
if (episodeFile) onOpenFile(storyName, episodeFile);
|
|
272
227
|
}}
|
|
273
228
|
/>
|
|
274
|
-
) : null;
|
|
275
|
-
|
|
276
|
-
// Story Info owns the CTA when metadata/cover is the gate. There is no
|
|
277
|
-
// dedicated Story Info page yet (#439/§4) and the coach carries no cover
|
|
278
|
-
// action, so this is an informational next-step line (not a route) — still the
|
|
279
|
-
// one and only CTA, placed in the relevant section.
|
|
280
|
-
const storyInfoCta = (
|
|
281
|
-
<div className="flex items-center gap-2 px-3 py-2 bg-accent/5 border border-accent/30 rounded text-xs" data-testid="story-info-cta">
|
|
282
|
-
<span className="rounded-full bg-background px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-accent flex-shrink-0">Story info</span>
|
|
283
|
-
<span className="min-w-0 flex-1 text-foreground"><span className="text-muted">Next: </span><span className="font-medium">{storyInfoNextStep(progress)}</span></span>
|
|
284
|
-
</div>
|
|
285
229
|
);
|
|
286
230
|
|
|
287
|
-
// Return the one CTA for a section, or null — guarantees a single visible CTA.
|
|
288
|
-
const ctaFor = (key: string): ReactNode => {
|
|
289
|
-
if (key !== activeKey) return null;
|
|
290
|
-
return activeKey === "story-info" ? storyInfoCta : coachCta;
|
|
291
|
-
};
|
|
292
|
-
|
|
293
231
|
const infoItems: ChecklistItem[] = [
|
|
294
232
|
{ label: "Public title", status: m.title ? "done" : "todo", detail: m.title ?? null },
|
|
295
233
|
{ label: "Language", status: m.language ? "done" : "todo", detail: m.language ?? null },
|
|
@@ -307,6 +245,9 @@ function CartoonWorkflowMap({
|
|
|
307
245
|
return (
|
|
308
246
|
<div className="h-full overflow-y-auto" data-testid="story-progress-panel">
|
|
309
247
|
<ProgressHeader progress={progress} />
|
|
248
|
+
<div className="border-b border-border" data-testid="persistent-next-action">
|
|
249
|
+
{topNextAction}
|
|
250
|
+
</div>
|
|
310
251
|
<p className="px-4 pt-3 pb-1 text-[11px] font-medium text-muted uppercase tracking-wider">Production Progress</p>
|
|
311
252
|
|
|
312
253
|
<Section
|
|
@@ -314,7 +255,6 @@ function CartoonWorkflowMap({
|
|
|
314
255
|
title="Define Story Info"
|
|
315
256
|
status={infoStatus}
|
|
316
257
|
items={infoItems}
|
|
317
|
-
cta={ctaFor("story-info") ?? undefined}
|
|
318
258
|
/>
|
|
319
259
|
|
|
320
260
|
<Section
|
|
@@ -324,7 +264,6 @@ function CartoonWorkflowMap({
|
|
|
324
264
|
fileName="structure.md"
|
|
325
265
|
openFile={hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined}
|
|
326
266
|
items={[{ label: "Planning document", status: hasStructure ? "done" : "todo", detail: hasStructure ? null : "Not written yet" }]}
|
|
327
|
-
cta={ctaFor("whitepaper") ?? undefined}
|
|
328
267
|
/>
|
|
329
268
|
|
|
330
269
|
{/* Genesis / Episode 1 — always shown (a not-started stub before it's
|
|
@@ -332,29 +271,22 @@ function CartoonWorkflowMap({
|
|
|
332
271
|
{genesisEp ? (
|
|
333
272
|
<EpisodeSection
|
|
334
273
|
index={++idx} ep={genesisEp} isActive={activeKey === genesisEp.file}
|
|
335
|
-
storyName={storyName} onOpenFile={onOpenFile}
|
|
274
|
+
storyName={storyName} onOpenFile={onOpenFile}
|
|
336
275
|
/>
|
|
337
276
|
) : (
|
|
338
277
|
<EpisodeSection
|
|
339
278
|
index={++idx} ep={GENESIS_STUB} isActive={activeKey === "genesis.md"} openingDone={false} canOpen={false}
|
|
340
|
-
storyName={storyName} onOpenFile={onOpenFile}
|
|
279
|
+
storyName={storyName} onOpenFile={onOpenFile}
|
|
341
280
|
/>
|
|
342
281
|
)}
|
|
343
282
|
|
|
344
283
|
{plotEps.map((ep) => (
|
|
345
284
|
<EpisodeSection
|
|
346
285
|
key={ep.file} index={++idx} ep={ep} isActive={activeKey === ep.file}
|
|
347
|
-
storyName={storyName} onOpenFile={onOpenFile}
|
|
286
|
+
storyName={storyName} onOpenFile={onOpenFile}
|
|
348
287
|
/>
|
|
349
288
|
))}
|
|
350
289
|
|
|
351
|
-
{/* All episodes published with nothing queued → a trailing "start next" CTA. */}
|
|
352
|
-
{activeKey === null && coachCta && (
|
|
353
|
-
<div className="px-4 py-2.5 border-b border-border" data-testid="workflow-next-episode">
|
|
354
|
-
<div className="ml-1" data-testid="section-cta">{coachCta}</div>
|
|
355
|
-
</div>
|
|
356
|
-
)}
|
|
357
|
-
|
|
358
290
|
<div className="px-4 py-2 text-[11px] text-muted flex flex-wrap gap-x-3" data-testid="progress-summary">
|
|
359
291
|
<span>{progress.summary.published} published</span>
|
|
360
292
|
<span>{progress.summary.readyToPublish} ready</span>
|
|
@@ -367,14 +299,13 @@ function CartoonWorkflowMap({
|
|
|
367
299
|
|
|
368
300
|
/** A `progress-episode-<file>` section, kept testid-stable so clicking it opens the file. */
|
|
369
301
|
function EpisodeSection({
|
|
370
|
-
index, ep, isActive, storyName, onOpenFile,
|
|
302
|
+
index, ep, isActive, storyName, onOpenFile, openingDone = true, canOpen = true,
|
|
371
303
|
}: {
|
|
372
304
|
index: number;
|
|
373
305
|
ep: EpisodeProgress;
|
|
374
306
|
isActive: boolean;
|
|
375
307
|
storyName: string;
|
|
376
308
|
onOpenFile: (storyName: string, file: string) => void;
|
|
377
|
-
cta?: ReactNode;
|
|
378
309
|
/** Whether the genesis opening text is already written (false for the stub). */
|
|
379
310
|
openingDone?: boolean;
|
|
380
311
|
/** Whether the header navigates to the file (false for the not-yet-written stub). */
|
|
@@ -408,7 +339,6 @@ function EpisodeSection({
|
|
|
408
339
|
<div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
|
|
409
340
|
{items.map((it, i) => <ChecklistRow key={i} item={it} />)}
|
|
410
341
|
</div>
|
|
411
|
-
{cta && <div className="mt-2 ml-1" data-testid="section-cta">{cta}</div>}
|
|
412
342
|
</div>
|
|
413
343
|
);
|
|
414
344
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
1
|
+
import React, { useCallback, useState, useEffect } from "react";
|
|
2
2
|
|
|
3
3
|
const API_BASE = "http://localhost:7777";
|
|
4
4
|
|
|
@@ -7,29 +7,45 @@ interface WalletInfo {
|
|
|
7
7
|
walletId?: string;
|
|
8
8
|
name?: string;
|
|
9
9
|
address?: string;
|
|
10
|
+
activeWallet?: WalletChoice;
|
|
11
|
+
wallets?: WalletChoice[];
|
|
12
|
+
selectionRequired?: boolean;
|
|
10
13
|
ethBalance?: string;
|
|
11
14
|
usdcBalance?: string;
|
|
12
15
|
plotBalance?: string;
|
|
13
16
|
error?: string;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
interface WalletChoice {
|
|
20
|
+
walletId?: string;
|
|
21
|
+
name: string;
|
|
22
|
+
address?: string;
|
|
23
|
+
normalizedAddress?: string;
|
|
24
|
+
source: "ows";
|
|
25
|
+
label: string;
|
|
26
|
+
recognized: boolean;
|
|
27
|
+
active: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export function WalletCard({ token }: { token: string }) {
|
|
17
31
|
const [wallet, setWallet] = useState<WalletInfo | null>(null);
|
|
18
32
|
const [creating, setCreating] = useState(false);
|
|
33
|
+
const [switching, setSwitching] = useState<string | null>(null);
|
|
19
34
|
const [copied, setCopied] = useState(false);
|
|
20
35
|
const [error, setError] = useState<string | null>(null);
|
|
21
36
|
|
|
22
|
-
const authFetch = (url: string, opts?: RequestInit) =>
|
|
23
|
-
fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } })
|
|
37
|
+
const authFetch = useCallback((url: string, opts?: RequestInit) =>
|
|
38
|
+
fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }),
|
|
39
|
+
[token]);
|
|
24
40
|
|
|
25
|
-
const loadWallet = () => {
|
|
41
|
+
const loadWallet = useCallback(() => {
|
|
26
42
|
authFetch(`${API_BASE}/api/wallet`)
|
|
27
43
|
.then((r) => r.json())
|
|
28
44
|
.then((data) => setWallet(data))
|
|
29
45
|
.catch(() => setWallet({ exists: false, error: "Failed to load wallet" }));
|
|
30
|
-
};
|
|
46
|
+
}, [authFetch]);
|
|
31
47
|
|
|
32
|
-
useEffect(() => { loadWallet(); }, []);
|
|
48
|
+
useEffect(() => { loadWallet(); }, [loadWallet]);
|
|
33
49
|
|
|
34
50
|
const handleCreate = async () => {
|
|
35
51
|
setCreating(true);
|
|
@@ -45,6 +61,27 @@ export function WalletCard({ token }: { token: string }) {
|
|
|
45
61
|
setCreating(false);
|
|
46
62
|
};
|
|
47
63
|
|
|
64
|
+
const handleSwitch = async (choice: WalletChoice) => {
|
|
65
|
+
setSwitching(choice.walletId || choice.name);
|
|
66
|
+
setError(null);
|
|
67
|
+
try {
|
|
68
|
+
const res = await authFetch(`${API_BASE}/api/wallet/active`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
walletId: choice.walletId,
|
|
72
|
+
name: choice.name,
|
|
73
|
+
address: choice.normalizedAddress || choice.address,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
const data = await res.json();
|
|
77
|
+
if (!res.ok) throw new Error(data.error || "Wallet switch failed");
|
|
78
|
+
loadWallet();
|
|
79
|
+
} catch (err: unknown) {
|
|
80
|
+
setError(err instanceof Error ? err.message : "Failed to switch wallet");
|
|
81
|
+
}
|
|
82
|
+
setSwitching(null);
|
|
83
|
+
};
|
|
84
|
+
|
|
48
85
|
const copyAddress = () => {
|
|
49
86
|
if (wallet?.address) {
|
|
50
87
|
navigator.clipboard.writeText(wallet.address);
|
|
@@ -63,7 +100,7 @@ export function WalletCard({ token }: { token: string }) {
|
|
|
63
100
|
|
|
64
101
|
{wallet && !wallet.exists && (
|
|
65
102
|
<div className="space-y-3">
|
|
66
|
-
<p className="text-muted text-xs">No wallet created yet. Create one to enable autonomous transactions
|
|
103
|
+
<p className="text-muted text-xs">{wallet.error || "No wallet created yet. Create one to enable autonomous transactions."}</p>
|
|
67
104
|
{error && <p className="text-error text-xs">{error}</p>}
|
|
68
105
|
<button
|
|
69
106
|
onClick={handleCreate}
|
|
@@ -75,15 +112,44 @@ export function WalletCard({ token }: { token: string }) {
|
|
|
75
112
|
</div>
|
|
76
113
|
)}
|
|
77
114
|
|
|
115
|
+
{wallet?.selectionRequired && wallet.wallets && wallet.wallets.length > 0 && (
|
|
116
|
+
<div className="mb-4 space-y-3 rounded border border-amber-600/30 bg-amber-950/10 p-3">
|
|
117
|
+
<p className="text-xs text-amber-700">Multiple OWS wallets found. Select the wallet OWS should use for publishing and signing.</p>
|
|
118
|
+
{wallet.wallets.map((choice) => (
|
|
119
|
+
<div key={choice.walletId || choice.name} className="border-border flex items-center justify-between gap-3 rounded border p-2">
|
|
120
|
+
<div className="min-w-0">
|
|
121
|
+
<p className="text-foreground truncate text-xs font-medium">{choice.name}</p>
|
|
122
|
+
<p className="text-muted truncate text-[10px] font-mono">{choice.address || "No EVM address"}</p>
|
|
123
|
+
</div>
|
|
124
|
+
<button
|
|
125
|
+
onClick={() => handleSwitch(choice)}
|
|
126
|
+
disabled={!choice.address || switching === (choice.walletId || choice.name)}
|
|
127
|
+
className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 rounded border px-2 py-1 text-[10px] font-medium transition-colors"
|
|
128
|
+
>
|
|
129
|
+
{switching === (choice.walletId || choice.name) ? "switching..." : "use"}
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
{error && <p className="text-error text-xs">{error}</p>}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
78
137
|
{wallet && wallet.exists && wallet.address && (
|
|
79
138
|
<div className="space-y-3">
|
|
80
139
|
<div className="flex items-center justify-between">
|
|
81
|
-
<span className="text-muted text-[10px] uppercase tracking-wider">
|
|
140
|
+
<span className="text-muted text-[10px] uppercase tracking-wider">Active Wallet (Base)</span>
|
|
82
141
|
<span className={`rounded border px-1.5 py-0.5 text-[9px] ${wallet.ethBalance && parseFloat(wallet.ethBalance) > 0 ? "border-accent/30 text-accent" : "border-accent-dim/30 text-accent-dim"}`}>
|
|
83
142
|
{wallet.ethBalance && parseFloat(wallet.ethBalance) > 0 ? "active" : "no balance"}
|
|
84
143
|
</span>
|
|
85
144
|
</div>
|
|
86
145
|
|
|
146
|
+
{wallet.name && (
|
|
147
|
+
<div className="flex justify-between text-xs">
|
|
148
|
+
<span className="text-muted">Name</span>
|
|
149
|
+
<span className="text-foreground truncate pl-3 font-mono text-[10px]">{wallet.name}</span>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
87
153
|
<div className="flex items-center gap-2">
|
|
88
154
|
<code className="text-foreground bg-surface rounded px-2 py-1 text-xs font-mono">{truncate(wallet.address)}</code>
|
|
89
155
|
<button onClick={copyAddress} className="text-muted hover:text-accent text-xs transition-colors">
|
|
@@ -110,6 +176,32 @@ export function WalletCard({ token }: { token: string }) {
|
|
|
110
176
|
</div>
|
|
111
177
|
</div>
|
|
112
178
|
|
|
179
|
+
{wallet.wallets && wallet.wallets.length > 1 && (
|
|
180
|
+
<div className="border-border space-y-2 border-t pt-3">
|
|
181
|
+
<p className="text-muted text-[10px] font-medium uppercase tracking-wider">Switch Wallet</p>
|
|
182
|
+
{wallet.wallets.map((choice) => (
|
|
183
|
+
<div key={choice.walletId || choice.name} className="flex items-center justify-between gap-3 text-xs">
|
|
184
|
+
<div className="min-w-0">
|
|
185
|
+
<p className={choice.active ? "text-accent truncate font-medium" : "text-foreground truncate"}>
|
|
186
|
+
{choice.name}{choice.active ? " (active)" : ""}
|
|
187
|
+
</p>
|
|
188
|
+
<p className="text-muted truncate text-[10px] font-mono">{choice.address || "No EVM address"}</p>
|
|
189
|
+
</div>
|
|
190
|
+
{!choice.active && (
|
|
191
|
+
<button
|
|
192
|
+
onClick={() => handleSwitch(choice)}
|
|
193
|
+
disabled={!choice.address || switching === (choice.walletId || choice.name)}
|
|
194
|
+
className="border-border text-muted hover:border-accent hover:text-accent disabled:opacity-40 rounded border px-2 py-1 text-[10px] transition-colors"
|
|
195
|
+
>
|
|
196
|
+
{switching === (choice.walletId || choice.name) ? "..." : "use"}
|
|
197
|
+
</button>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
))}
|
|
201
|
+
{error && <p className="text-error text-xs">{error}</p>}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
|
|
113
205
|
{/* Fund wallet */}
|
|
114
206
|
<div className="border-border border-t pt-3">
|
|
115
207
|
<p className="text-muted mb-2 text-[10px] font-medium uppercase tracking-wider">Fund Wallet</p>
|
|
@@ -118,6 +210,16 @@ export function WalletCard({ token }: { token: string }) {
|
|
|
118
210
|
</div>
|
|
119
211
|
</div>
|
|
120
212
|
)}
|
|
213
|
+
|
|
214
|
+
{wallet?.exists && (
|
|
215
|
+
<button
|
|
216
|
+
onClick={handleCreate}
|
|
217
|
+
disabled={creating}
|
|
218
|
+
className="border-border text-muted hover:border-accent hover:text-accent disabled:opacity-40 mt-4 rounded border px-3 py-1.5 text-[10px] font-medium transition-colors"
|
|
219
|
+
>
|
|
220
|
+
{creating ? "creating..." : "create another wallet"}
|
|
221
|
+
</button>
|
|
222
|
+
)}
|
|
121
223
|
</div>
|
|
122
224
|
);
|
|
123
225
|
}
|
|
@@ -24,53 +24,80 @@ interface WorkflowCoachViewProps {
|
|
|
24
24
|
/** Run an app-driven step (the agent steps copy a prompt instead). */
|
|
25
25
|
onAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
26
26
|
className?: string;
|
|
27
|
+
/** Show a clear completed state instead of disappearing when no coach exists. */
|
|
28
|
+
showEmptyState?: boolean;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
export function WorkflowCoachView({ coach, onAction, className = "" }: WorkflowCoachViewProps) {
|
|
31
|
+
export function WorkflowCoachView({ coach, onAction, className = "", showEmptyState = false }: WorkflowCoachViewProps) {
|
|
30
32
|
// Track the prompt that was copied rather than a bare boolean, so the "Copied!"
|
|
31
33
|
// confirmation derives to false the moment the coach (and its prompt) changes —
|
|
32
34
|
// no reset effect, no stale confirmation under a new stage.
|
|
33
35
|
const [copiedPrompt, setCopiedPrompt] = useState<string | null>(null);
|
|
34
36
|
const copied = copiedPrompt !== null && copiedPrompt === coach?.prompt;
|
|
35
37
|
|
|
36
|
-
if (
|
|
38
|
+
if (coach === undefined) return null;
|
|
39
|
+
if (!coach) {
|
|
40
|
+
if (!showEmptyState) return null;
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
className={`m-3 rounded-lg border border-green-700/25 bg-green-950/5 px-4 py-3 ${className}`}
|
|
44
|
+
data-testid="workflow-coach"
|
|
45
|
+
data-state="complete"
|
|
46
|
+
>
|
|
47
|
+
<div className="flex items-start gap-3">
|
|
48
|
+
<span className="rounded-full bg-green-700/10 px-2 py-1 text-[10px] font-bold uppercase tracking-[0.16em] text-green-700">
|
|
49
|
+
Complete
|
|
50
|
+
</span>
|
|
51
|
+
<div className="min-w-0 flex-1">
|
|
52
|
+
<p className="text-sm font-semibold text-foreground">No next action available</p>
|
|
53
|
+
<p className="mt-0.5 text-xs text-muted">This workflow has no queued next step right now.</p>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
37
59
|
|
|
38
60
|
return (
|
|
39
61
|
<div
|
|
40
|
-
className={`
|
|
62
|
+
className={`m-3 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3 shadow-sm ${className}`}
|
|
41
63
|
data-testid="workflow-coach"
|
|
42
64
|
data-stage={coach.stageLabel}
|
|
43
65
|
data-action-kind={coach.actionKind}
|
|
44
66
|
data-ui-action={coach.uiAction ?? ""}
|
|
45
67
|
>
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
<div className="flex items-center gap-3">
|
|
69
|
+
<div className="min-w-0 flex-1">
|
|
70
|
+
<span className="inline-flex rounded-full bg-background px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent" data-testid="workflow-coach-stage">
|
|
71
|
+
{coach.stageLabel}
|
|
72
|
+
</span>
|
|
73
|
+
<p className="mt-1 text-sm text-foreground" data-testid="workflow-coach-action">
|
|
74
|
+
<span className="font-semibold">Next: </span>
|
|
75
|
+
<span>{coach.action}</span>
|
|
76
|
+
</p>
|
|
77
|
+
{copied && <p className="mt-1 text-[11px] font-medium text-accent">Prompt copied.</p>}
|
|
78
|
+
</div>
|
|
79
|
+
{coach.actionKind === "agent" && coach.prompt ? (
|
|
80
|
+
<button
|
|
81
|
+
onClick={() => {
|
|
82
|
+
if (!coach.prompt) return;
|
|
83
|
+
const prompt = coach.prompt;
|
|
84
|
+
navigator.clipboard?.writeText(prompt).then(() => setCopiedPrompt(prompt)).catch(() => {});
|
|
85
|
+
}}
|
|
86
|
+
data-testid="workflow-coach-copy"
|
|
87
|
+
className="flex-shrink-0 rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim"
|
|
88
|
+
>
|
|
89
|
+
Next Action
|
|
90
|
+
</button>
|
|
91
|
+
) : coach.actionKind === "ui" && coach.uiAction ? (
|
|
92
|
+
<button
|
|
93
|
+
onClick={() => onAction(coach.uiAction!, coach.episodeFile)}
|
|
94
|
+
data-testid="workflow-coach-do"
|
|
95
|
+
className="flex-shrink-0 rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim"
|
|
96
|
+
>
|
|
97
|
+
Next Action
|
|
98
|
+
</button>
|
|
99
|
+
) : null}
|
|
100
|
+
</div>
|
|
74
101
|
</div>
|
|
75
102
|
);
|
|
76
103
|
}
|
|
@@ -83,6 +110,7 @@ interface WorkflowCoachProps {
|
|
|
83
110
|
/** Bumped by the parent to reload after a state change (cut edit / publish). */
|
|
84
111
|
refreshKey?: number;
|
|
85
112
|
onAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
113
|
+
showEmptyState?: boolean;
|
|
86
114
|
}
|
|
87
115
|
|
|
88
116
|
/**
|
|
@@ -92,8 +120,8 @@ interface WorkflowCoachProps {
|
|
|
92
120
|
* previous file's coach can never linger under a different file when the new
|
|
93
121
|
* request fails or 404s (the stale-state-on-error class flagged on #420/#427).
|
|
94
122
|
*/
|
|
95
|
-
export function WorkflowCoach({ storyName, fileName, authFetch, refreshKey = 0, onAction }: WorkflowCoachProps) {
|
|
96
|
-
const [coach, setCoach] = useState<CartoonCoach | null>(
|
|
123
|
+
export function WorkflowCoach({ storyName, fileName, authFetch, refreshKey = 0, onAction, showEmptyState = false }: WorkflowCoachProps) {
|
|
124
|
+
const [coach, setCoach] = useState<CartoonCoach | null | undefined>(undefined);
|
|
97
125
|
|
|
98
126
|
// Reset the coach the instant the target changes (file switch / refresh),
|
|
99
127
|
// during render — React's recommended way to reset state on a changing input.
|
|
@@ -108,7 +136,7 @@ export function WorkflowCoach({ storyName, fileName, authFetch, refreshKey = 0,
|
|
|
108
136
|
const targetKey = JSON.stringify([storyName, fileName ?? "", refreshKey]);
|
|
109
137
|
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
|
110
138
|
if (loadedKey !== targetKey) {
|
|
111
|
-
setCoach(
|
|
139
|
+
setCoach(undefined);
|
|
112
140
|
setLoadedKey(targetKey);
|
|
113
141
|
}
|
|
114
142
|
|
|
@@ -124,5 +152,5 @@ export function WorkflowCoach({ storyName, fileName, authFetch, refreshKey = 0,
|
|
|
124
152
|
return () => { cancelled = true; };
|
|
125
153
|
}, [storyName, fileName, authFetch, refreshKey]);
|
|
126
154
|
|
|
127
|
-
return <WorkflowCoachView coach={coach} onAction={onAction} />;
|
|
155
|
+
return <WorkflowCoachView coach={coach} onAction={onAction} showEmptyState={showEmptyState} />;
|
|
128
156
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{M as w,v as O,t as A,c as M,b as R,s as H,l as I,a as B,d as F}from"./index-
|
|
1
|
+
import{M as w,v as O,t as A,c as M,b as R,s as H,l as I,a as B,d as F}from"./index-Dc2TQ3Ij.js";const z=w;async function G(e){if(typeof document>"u"||!document.fonts||typeof document.fonts.load!="function")return{ready:!0,missing:[]};const n=[];for(const s of e)try{const t=await document.fonts.load(`16px "${s}"`);!t||t.length===0?n.push(s):document.fonts.check(`16px "${s}"`)||n.push(s)}catch{n.push(s)}return{ready:n.length===0,missing:n}}function C(e){return new Promise((n,s)=>{const t=new Image;t.crossOrigin="anonymous",t.onload=()=>n(t),t.onerror=()=>s(new Error("Failed to load image")),t.src=e})}const W="rgba(255, 255, 255, 0.95)",_="#1a1a1a",L="rgba(244, 239, 230, 0.94)",N="rgba(26, 26, 26, 0.55)";function Y(e){return Math.max(2,e*.004)}function K(e,n,s,t,c,d,f){e.beginPath();for(const o of F(n,s,t,c,d,f))o.k==="M"?e.moveTo(o.x,o.y):o.k==="L"?e.lineTo(o.x,o.y):e.arcTo(o.cornerX,o.cornerY,o.x,o.y,o.r);e.closePath()}function P(e,n,s,t,c,d){var f;for(const o of n){const l=o.x*s,i=o.y*t,r=o.width*s,a=o.height*t,y=Y(t);if(o.type==="speech"){const u=R(o,r,a),k=o.tailAnchor?H(l,i,r,a,o.tailAnchor,u):null;K(e,l,i,r,a,k,u),e.fillStyle=W,e.fill(),e.strokeStyle=_,e.lineWidth=y,e.lineJoin="round",e.stroke()}else if(o.type==="narration"){const u=Math.min(r,a)*.12;e.beginPath(),e.roundRect(l,i,r,a,u),e.fillStyle=L,e.fill(),e.strokeStyle=N,e.lineWidth=Math.max(1.5,y*.75),e.lineJoin="round",e.stroke()}const g=o.type==="sfx"?d:c,T=o.type!=="sfx"&&!!o.speaker,h=I((u,k,E=400)=>(e.font=`${E} ${k}px ${g}`,e.measureText(u).width),o.text,r,a,B(o,t,r,a));e.textAlign="center",e.textBaseline="middle";const p=l+r/2,S=T?h.speakerFontSize*1.2:0;T&&(e.fillStyle="#3a3a3a",e.font=`700 ${h.speakerFontSize}px ${g}`,e.fillText(o.speaker,p,i+S/2+a*.04,r-6));const v=i+S,b=a-S,$=h.lines.length*h.lineHeight;let m=v+b/2-$/2+h.lineHeight/2;e.font=`${((f=o.textStyle)==null?void 0:f.fontWeight)??400} ${h.fontSize}px ${g}`;for(const u of h.lines)o.type==="sfx"?(e.fillStyle="#000",e.strokeStyle="#fff",e.lineWidth=3,e.strokeText(u,p,m),e.fillText(u,p,m)):(e.fillStyle="#1a1a1a",e.fillText(u,p,m)),m+=h.lineHeight}}function X(e,n,s,t,c){const d=Math.max(14,Math.min(t*.05,28));e.font=`${d}px ${c}`,e.fillStyle="#1a1a1a",e.textAlign="center",e.textBaseline="middle";const f=[];if(n.dialogue)for(const i of n.dialogue)f.push(`${i.speaker}: ${i.text}`);n.narration&&f.push(n.narration);const o=d*1.6,l=t/2-(f.length-1)*o/2;for(let i=0;i<f.length;i++)e.fillText(f[i],s/2,l+i*o,s-40)}async function Z(e,n,s,t,c,d){const f=O(n);if(!f.valid)throw new Error(f.error??"Overlay geometry is invalid");let o=800,l=600,i=null;if(e)i=await C(e),o=i.naturalWidth,l=i.naturalHeight;else if(d){const y=A(d.aspectRatio);y&&(o=y.width,l=y.height)}const r=document.createElement("canvas");r.width=o,r.height=l;const a=r.getContext("2d");return i?a.drawImage(i,0,0,o,l):(a.fillStyle=(d==null?void 0:d.background)||"#ffffff",a.fillRect(0,0,o,l)),P(a,n,o,l,s,t),c&&n.length===0&&!i&&X(a,c,o,l,s),M(r)}function j(e){return e.size>z?{valid:!1,error:`Image is ${(e.size/1024).toFixed(0)}KB, exceeds 1MB limit`}:{valid:!0}}export{z as MAX_SIZE,G as ensureFontsReady,Z as exportCut,P as renderOverlays,A as textPanelDimensions,j as validateExportSize};
|