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
package/app/routes/settings.ts
CHANGED
|
@@ -2,28 +2,66 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { createPublicClient, createWalletClient, http, decodeEventLog } from "viem";
|
|
3
3
|
import { base } from "viem/chains";
|
|
4
4
|
import { erc8004Abi } from "../../packages/cli/src/sdk/abi";
|
|
5
|
-
import {
|
|
5
|
+
import { resolveActiveWallet } from "../lib/active-wallet";
|
|
6
6
|
import { createOwsAccount } from "../lib/publish";
|
|
7
|
-
import { db } from "../db";
|
|
8
|
-
import {
|
|
9
|
-
signMessage as owsSignMsg,
|
|
10
|
-
} from "@open-wallet-standard/core";
|
|
11
7
|
import { CONFIG_DIR } from "../lib/paths";
|
|
12
8
|
import fs from "fs";
|
|
13
9
|
import path from "path";
|
|
14
10
|
|
|
15
11
|
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
12
|
+
type Config = Record<string, unknown>;
|
|
16
13
|
|
|
17
|
-
function readConfig():
|
|
14
|
+
function readConfig(): Config {
|
|
18
15
|
try { return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); } catch { return {}; }
|
|
19
16
|
}
|
|
20
17
|
|
|
21
|
-
function writeConfig(updates:
|
|
18
|
+
function writeConfig(updates: Config) {
|
|
22
19
|
const config = readConfig();
|
|
23
20
|
Object.assign(config, updates);
|
|
24
21
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
25
22
|
}
|
|
26
23
|
|
|
24
|
+
function normalizeAddress(address: unknown): string | null {
|
|
25
|
+
return typeof address === "string" && /^0x[a-fA-F0-9]{40}$/.test(address)
|
|
26
|
+
? address.toLowerCase()
|
|
27
|
+
: null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getWalletAgentConfig(
|
|
31
|
+
config: Config,
|
|
32
|
+
wallet: { walletId?: string; name: string; address: string },
|
|
33
|
+
selectableWalletCount: number,
|
|
34
|
+
): Config | null {
|
|
35
|
+
if (!config.agentId) return null;
|
|
36
|
+
|
|
37
|
+
const cachedAddress = normalizeAddress(config.agentWalletAddress);
|
|
38
|
+
const activeAddress = normalizeAddress(wallet.address);
|
|
39
|
+
if (cachedAddress && activeAddress) {
|
|
40
|
+
return cachedAddress === activeAddress ? config : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof config.agentWalletId === "string" && wallet.walletId) {
|
|
44
|
+
return config.agentWalletId === wallet.walletId ? config : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof config.agentWalletName === "string") {
|
|
48
|
+
return config.agentWalletName === wallet.name ? config : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Backwards compatibility for pre-#196 installs: an unscoped cache can only
|
|
52
|
+
// be trusted when there is no wallet-switching ambiguity.
|
|
53
|
+
return selectableWalletCount <= 1 ? config : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function walletAgentConfig(wallet: { walletId?: string; name: string; address: string }, updates: Config): Config {
|
|
57
|
+
return {
|
|
58
|
+
...updates,
|
|
59
|
+
agentWalletAddress: wallet.address.toLowerCase(),
|
|
60
|
+
agentWalletName: wallet.name,
|
|
61
|
+
...(wallet.walletId ? { agentWalletId: wallet.walletId } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
27
65
|
const ERC_8004 = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" as const;
|
|
28
66
|
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
|
|
29
67
|
|
|
@@ -43,33 +81,37 @@ settings.post("/generate-binding", async (c) => {
|
|
|
43
81
|
}
|
|
44
82
|
|
|
45
83
|
try {
|
|
46
|
-
const
|
|
47
|
-
const wallet =
|
|
48
|
-
if (!wallet)
|
|
84
|
+
const resolvedWallet = await resolveActiveWallet();
|
|
85
|
+
const wallet = resolvedWallet.activeWallet;
|
|
86
|
+
if (!wallet) {
|
|
87
|
+
return c.json({
|
|
88
|
+
error: resolvedWallet.error || "No OWS wallet found. Create one in Wallet settings first.",
|
|
89
|
+
selectionRequired: resolvedWallet.selectionRequired,
|
|
90
|
+
wallets: resolvedWallet.wallets,
|
|
91
|
+
}, 400);
|
|
92
|
+
}
|
|
49
93
|
|
|
50
|
-
const owsWallet =
|
|
94
|
+
const owsWallet = wallet.address;
|
|
51
95
|
if (!owsWallet) return c.json({ error: "No EVM address on wallet" }, 400);
|
|
52
96
|
|
|
53
97
|
const message = `I authorize ${body.humanWallet} as my PlotLink owner. Wallet: ${owsWallet}`;
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
const result = owsSignMsg(wallet.name, "eip155:8453", message, passphrase);
|
|
57
|
-
const signature = result.signature.startsWith("0x") ? result.signature : `0x${result.signature}`;
|
|
98
|
+
const account = createOwsAccount(wallet.name, owsWallet as `0x${string}`);
|
|
99
|
+
const signature = await account.signMessage({ message });
|
|
58
100
|
|
|
59
101
|
// Include agent data from config.json if available
|
|
60
|
-
const config = readConfig();
|
|
102
|
+
const config = getWalletAgentConfig(readConfig(), wallet, resolvedWallet.wallets.filter((w) => w.address).length);
|
|
61
103
|
|
|
62
104
|
return c.json({
|
|
63
105
|
message,
|
|
64
106
|
signature,
|
|
65
107
|
owsWallet,
|
|
66
|
-
agentId: config
|
|
67
|
-
agentName: (config
|
|
68
|
-
agentDescription: (config
|
|
69
|
-
agentGenre: (config
|
|
70
|
-
agentLlmModel: (config
|
|
71
|
-
agentRegisteredBy: (config
|
|
72
|
-
agentRegisteredAt: (config
|
|
108
|
+
agentId: config?.agentId ? Number(config.agentId) : undefined,
|
|
109
|
+
agentName: (config?.agentName as string) || undefined,
|
|
110
|
+
agentDescription: (config?.agentDescription as string) || undefined,
|
|
111
|
+
agentGenre: (config?.agentGenre as string) || undefined,
|
|
112
|
+
agentLlmModel: (config?.agentLlmModel as string) || undefined,
|
|
113
|
+
agentRegisteredBy: (config?.agentRegisteredBy as string) || undefined,
|
|
114
|
+
agentRegisteredAt: (config?.agentRegisteredAt as string) || undefined,
|
|
73
115
|
});
|
|
74
116
|
} catch (err: unknown) {
|
|
75
117
|
const msg = err instanceof Error ? err.message : "Failed to generate binding proof";
|
|
@@ -89,11 +131,17 @@ settings.post("/register-agent", async (c) => {
|
|
|
89
131
|
}
|
|
90
132
|
|
|
91
133
|
try {
|
|
92
|
-
const
|
|
93
|
-
const wallet =
|
|
94
|
-
if (!wallet)
|
|
134
|
+
const resolvedWallet = await resolveActiveWallet();
|
|
135
|
+
const wallet = resolvedWallet.activeWallet;
|
|
136
|
+
if (!wallet) {
|
|
137
|
+
return c.json({
|
|
138
|
+
error: resolvedWallet.error || "No OWS wallet found. Create one in Wallet settings first.",
|
|
139
|
+
selectionRequired: resolvedWallet.selectionRequired,
|
|
140
|
+
wallets: resolvedWallet.wallets,
|
|
141
|
+
}, 400);
|
|
142
|
+
}
|
|
95
143
|
|
|
96
|
-
const owsAddress =
|
|
144
|
+
const owsAddress = wallet.address;
|
|
97
145
|
if (!owsAddress) return c.json({ error: "No EVM address on wallet" }, 400);
|
|
98
146
|
|
|
99
147
|
// Check if already registered
|
|
@@ -160,7 +208,7 @@ settings.post("/register-agent", async (c) => {
|
|
|
160
208
|
}
|
|
161
209
|
|
|
162
210
|
// Cache full tokenURI data in config.json (survives npx reinstalls, no Prisma dependency)
|
|
163
|
-
writeConfig({
|
|
211
|
+
writeConfig(walletAgentConfig(wallet, {
|
|
164
212
|
agentId,
|
|
165
213
|
agentName: body.name.trim(),
|
|
166
214
|
agentDescription: body.description.trim(),
|
|
@@ -168,7 +216,7 @@ settings.post("/register-agent", async (c) => {
|
|
|
168
216
|
agentLlmModel: "Claude",
|
|
169
217
|
agentRegisteredBy: "plotlink-ows",
|
|
170
218
|
agentRegisteredAt: registeredAt,
|
|
171
|
-
});
|
|
219
|
+
}));
|
|
172
220
|
|
|
173
221
|
return c.json({
|
|
174
222
|
agentId,
|
|
@@ -184,16 +232,23 @@ settings.post("/register-agent", async (c) => {
|
|
|
184
232
|
/** GET /api/settings/link-status — check if OWS wallet is registered on ERC-8004 */
|
|
185
233
|
settings.get("/link-status", async (c) => {
|
|
186
234
|
try {
|
|
187
|
-
const
|
|
188
|
-
const wallet =
|
|
189
|
-
if (!wallet)
|
|
235
|
+
const resolvedWallet = await resolveActiveWallet();
|
|
236
|
+
const wallet = resolvedWallet.activeWallet;
|
|
237
|
+
if (!wallet) {
|
|
238
|
+
return c.json({
|
|
239
|
+
linked: false,
|
|
240
|
+
error: resolvedWallet.error || "No wallet",
|
|
241
|
+
selectionRequired: resolvedWallet.selectionRequired,
|
|
242
|
+
wallets: resolvedWallet.wallets,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
190
245
|
|
|
191
|
-
const address =
|
|
246
|
+
const address = wallet.address;
|
|
192
247
|
if (!address) return c.json({ linked: false, error: "No EVM address" });
|
|
193
248
|
|
|
194
249
|
// Check config.json cache first (survives npx reinstalls + RPC rate limits)
|
|
195
|
-
const config = readConfig();
|
|
196
|
-
if (config
|
|
250
|
+
const config = getWalletAgentConfig(readConfig(), wallet, resolvedWallet.wallets.filter((w) => w.address).length);
|
|
251
|
+
if (config?.agentId) {
|
|
197
252
|
return c.json({ linked: true, agentId: Number(config.agentId), owsWallet: address });
|
|
198
253
|
}
|
|
199
254
|
|
|
@@ -207,7 +262,7 @@ settings.get("/link-status", async (c) => {
|
|
|
207
262
|
}) as bigint;
|
|
208
263
|
|
|
209
264
|
if (agentId > 0n) {
|
|
210
|
-
writeConfig({ agentId: Number(agentId) });
|
|
265
|
+
writeConfig(walletAgentConfig(wallet, { agentId: Number(agentId) }));
|
|
211
266
|
return c.json({ linked: true, agentId: Number(agentId), owsWallet: address });
|
|
212
267
|
}
|
|
213
268
|
} catch { /* agentIdByWallet may revert if not bound */ }
|
|
@@ -235,7 +290,7 @@ settings.get("/link-status", async (c) => {
|
|
|
235
290
|
} catch { /* ERC-721 Enumerable not supported */ }
|
|
236
291
|
|
|
237
292
|
if (agentId !== undefined) {
|
|
238
|
-
writeConfig({ agentId });
|
|
293
|
+
writeConfig(walletAgentConfig(wallet, { agentId }));
|
|
239
294
|
}
|
|
240
295
|
return c.json({ linked: true, agentId, owsWallet: address });
|
|
241
296
|
}
|
package/app/routes/wallet.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import { ENV_FILE } from "../lib/paths";
|
|
4
|
+
import { nextPlotlinkWalletName, resolveActiveWallet, selectActiveWallet, toPublicActiveWallet } from "../lib/active-wallet";
|
|
4
5
|
|
|
5
6
|
const envPath = ENV_FILE;
|
|
6
7
|
|
|
@@ -21,18 +22,8 @@ function readEnvPassphrase(): string | null {
|
|
|
21
22
|
/** GET /api/wallet — get wallet info */
|
|
22
23
|
wallet.get("/", async (c) => {
|
|
23
24
|
try {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
// Try to find existing wallet
|
|
27
|
-
const { listAgentWallets } = await import("../../lib/ows/wallet");
|
|
28
|
-
const wallets = listAgentWallets();
|
|
29
|
-
const plotlinkWallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
|
|
30
|
-
|
|
31
|
-
if (!plotlinkWallet) {
|
|
32
|
-
return c.json({ exists: false });
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const address = getBaseAddress(plotlinkWallet);
|
|
25
|
+
const resolved = await resolveActiveWallet();
|
|
26
|
+
const activeWallet = resolved.activeWallet;
|
|
36
27
|
|
|
37
28
|
// Fetch balances on Base via RPC
|
|
38
29
|
let ethBalance = "0";
|
|
@@ -40,8 +31,8 @@ wallet.get("/", async (c) => {
|
|
|
40
31
|
let plotBalance = "0";
|
|
41
32
|
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
|
|
42
33
|
|
|
43
|
-
if (address) {
|
|
44
|
-
const addrPadded = "000000000000000000000000" + address.slice(2).toLowerCase();
|
|
34
|
+
if (activeWallet?.address) {
|
|
35
|
+
const addrPadded = "000000000000000000000000" + activeWallet.address.slice(2).toLowerCase();
|
|
45
36
|
const balanceOfSig = "0x70a08231" + addrPadded;
|
|
46
37
|
|
|
47
38
|
try {
|
|
@@ -49,7 +40,7 @@ wallet.get("/", async (c) => {
|
|
|
49
40
|
const ethRes = await fetch(rpcUrl, {
|
|
50
41
|
method: "POST",
|
|
51
42
|
headers: { "Content-Type": "application/json" },
|
|
52
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }),
|
|
43
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [activeWallet.address, "latest"] }),
|
|
53
44
|
});
|
|
54
45
|
const ethData = await ethRes.json() as { result?: string };
|
|
55
46
|
if (ethData.result && ethData.result !== "0x" && ethData.result !== "0x0") {
|
|
@@ -82,15 +73,27 @@ wallet.get("/", async (c) => {
|
|
|
82
73
|
} catch { /* balance fetch best-effort */ }
|
|
83
74
|
}
|
|
84
75
|
|
|
76
|
+
if (!activeWallet) {
|
|
77
|
+
return c.json({
|
|
78
|
+
exists: resolved.wallets.length > 0,
|
|
79
|
+
selectionRequired: resolved.selectionRequired,
|
|
80
|
+
error: resolved.error,
|
|
81
|
+
wallets: resolved.wallets,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
85
|
return c.json({
|
|
86
86
|
exists: true,
|
|
87
|
-
walletId:
|
|
88
|
-
name:
|
|
89
|
-
address,
|
|
87
|
+
walletId: activeWallet.walletId,
|
|
88
|
+
name: activeWallet.name,
|
|
89
|
+
address: activeWallet.address,
|
|
90
|
+
activeWallet: toPublicActiveWallet(activeWallet),
|
|
91
|
+
selectionRequired: false,
|
|
92
|
+
wallets: resolved.wallets,
|
|
90
93
|
ethBalance,
|
|
91
94
|
usdcBalance,
|
|
92
95
|
plotBalance,
|
|
93
|
-
accounts:
|
|
96
|
+
accounts: activeWallet.wallet.accounts,
|
|
94
97
|
});
|
|
95
98
|
} catch (err: unknown) {
|
|
96
99
|
const message = err instanceof Error ? err.message : "Failed to get wallet";
|
|
@@ -98,6 +101,30 @@ wallet.get("/", async (c) => {
|
|
|
98
101
|
}
|
|
99
102
|
});
|
|
100
103
|
|
|
104
|
+
/** POST /api/wallet/active — select active OWS wallet */
|
|
105
|
+
wallet.post("/active", async (c) => {
|
|
106
|
+
const body = await c.req.json<{ walletId?: string; name?: string; address?: string }>();
|
|
107
|
+
if (!body.walletId && !body.name && !body.address) {
|
|
108
|
+
return c.json({ error: "walletId, name, or address required" }, 400);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const resolved = await selectActiveWallet(body);
|
|
112
|
+
if (!resolved.activeWallet) {
|
|
113
|
+
return c.json({
|
|
114
|
+
error: resolved.error || "Could not select wallet",
|
|
115
|
+
selectionRequired: resolved.selectionRequired,
|
|
116
|
+
wallets: resolved.wallets,
|
|
117
|
+
}, 400);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return c.json({
|
|
121
|
+
ok: true,
|
|
122
|
+
activeWallet: toPublicActiveWallet(resolved.activeWallet),
|
|
123
|
+
wallets: resolved.wallets,
|
|
124
|
+
selectionRequired: false,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
101
128
|
/** POST /api/wallet/create — create OWS wallet */
|
|
102
129
|
wallet.post("/create", async (c) => {
|
|
103
130
|
try {
|
|
@@ -106,20 +133,21 @@ wallet.post("/create", async (c) => {
|
|
|
106
133
|
return c.json({ error: "Passphrase not configured" }, 400);
|
|
107
134
|
}
|
|
108
135
|
|
|
109
|
-
const { createAgentWallet,
|
|
136
|
+
const { createAgentWallet, listAgentWallets } = await import("../../lib/ows/wallet");
|
|
110
137
|
|
|
111
|
-
// Check if wallet already exists
|
|
112
138
|
const wallets = listAgentWallets();
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return c.json({ walletId: existing.id, address, alreadyExisted: true });
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const wallet = createAgentWallet("plotlink-writer", passphrase);
|
|
120
|
-
const address = getBaseAddress(wallet);
|
|
139
|
+
const name = nextPlotlinkWalletName(wallets);
|
|
140
|
+
const createdWallet = createAgentWallet(name, passphrase);
|
|
141
|
+
const resolved = await selectActiveWallet({ walletId: createdWallet.id, name: createdWallet.name });
|
|
121
142
|
|
|
122
|
-
return c.json({
|
|
143
|
+
return c.json({
|
|
144
|
+
walletId: resolved.activeWallet?.walletId ?? createdWallet.id,
|
|
145
|
+
name: createdWallet.name,
|
|
146
|
+
address: resolved.activeWallet?.address,
|
|
147
|
+
activeWallet: resolved.activeWallet ? toPublicActiveWallet(resolved.activeWallet) : null,
|
|
148
|
+
wallets: resolved.wallets,
|
|
149
|
+
alreadyExisted: false,
|
|
150
|
+
});
|
|
123
151
|
} catch (err: unknown) {
|
|
124
152
|
const message = err instanceof Error ? err.message : "Wallet creation failed";
|
|
125
153
|
return c.json({ error: message }, 500);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { StoryProgress } from "@app-lib/story-progress";
|
|
3
|
+
import type { CoachUiAction } from "@app-lib/cartoon-coach";
|
|
4
|
+
import { WorkflowCoachView } from "./WorkflowCoach";
|
|
5
|
+
|
|
6
|
+
export function storyInfoNextStep(progress: StoryProgress): string {
|
|
7
|
+
if (progress.cover !== "present") {
|
|
8
|
+
return progress.cover === "invalid"
|
|
9
|
+
? "Replace the cover image - it must be a valid WebP or JPEG."
|
|
10
|
+
: "Add a cover image before publishing.";
|
|
11
|
+
}
|
|
12
|
+
const missing: string[] = [];
|
|
13
|
+
if (!progress.metadata.language) missing.push("language");
|
|
14
|
+
if (!progress.metadata.genre) missing.push("genre");
|
|
15
|
+
if (!progress.metadata.title) missing.push("title");
|
|
16
|
+
return `Add the story ${missing.join(" and ") || "details"} before publishing.`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function cartoonWorkflowActiveKey(progress: StoryProgress): string | null {
|
|
20
|
+
const coach = progress.coach ?? null;
|
|
21
|
+
const m = progress.metadata;
|
|
22
|
+
const hasStructure = progress.setup.hasStructure;
|
|
23
|
+
const hasGenesis = progress.setup.hasGenesis;
|
|
24
|
+
const coverDone = progress.cover === "present";
|
|
25
|
+
const metadataIncomplete = !m.title || !m.language || !m.genre;
|
|
26
|
+
const activeEp = progress.episodes.find((e) => !e.published) ?? null;
|
|
27
|
+
const productionPending = !!activeEp && activeEp.state !== "ready";
|
|
28
|
+
|
|
29
|
+
if (!hasStructure) return "whitepaper";
|
|
30
|
+
if (!hasGenesis) return "genesis.md";
|
|
31
|
+
if (metadataIncomplete) return "story-info";
|
|
32
|
+
if (productionPending && coach?.episodeFile) return coach.episodeFile;
|
|
33
|
+
if (!coverDone) return "story-info";
|
|
34
|
+
return coach?.episodeFile ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function StoryInfoNextActionCard({
|
|
38
|
+
progress,
|
|
39
|
+
onOpenStoryInfo,
|
|
40
|
+
}: {
|
|
41
|
+
progress: StoryProgress;
|
|
42
|
+
onOpenStoryInfo?: () => void;
|
|
43
|
+
}) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="m-3 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3 shadow-sm" data-testid="story-info-cta">
|
|
46
|
+
<div className="flex items-center gap-3">
|
|
47
|
+
<div className="min-w-0 flex-1">
|
|
48
|
+
<span className="inline-flex rounded-full bg-background px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent">
|
|
49
|
+
Story info
|
|
50
|
+
</span>
|
|
51
|
+
<p className="mt-1 text-sm text-foreground" data-testid="story-info-next-action">
|
|
52
|
+
<span className="font-semibold">Next: </span>
|
|
53
|
+
<span>{storyInfoNextStep(progress)}</span>
|
|
54
|
+
</p>
|
|
55
|
+
</div>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={onOpenStoryInfo}
|
|
59
|
+
disabled={!onOpenStoryInfo}
|
|
60
|
+
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 disabled:cursor-not-allowed disabled:opacity-50"
|
|
61
|
+
data-testid="story-info-next-action-btn"
|
|
62
|
+
>
|
|
63
|
+
Next Action
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function CartoonNextActionView({
|
|
71
|
+
progress,
|
|
72
|
+
onCoachAction,
|
|
73
|
+
onOpenStoryInfo,
|
|
74
|
+
}: {
|
|
75
|
+
progress: StoryProgress;
|
|
76
|
+
onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
77
|
+
onOpenStoryInfo?: () => void;
|
|
78
|
+
}) {
|
|
79
|
+
const activeKey = cartoonWorkflowActiveKey(progress);
|
|
80
|
+
if (activeKey === "story-info") {
|
|
81
|
+
return <StoryInfoNextActionCard progress={progress} onOpenStoryInfo={onOpenStoryInfo} />;
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
<WorkflowCoachView
|
|
85
|
+
coach={progress.coach ?? null}
|
|
86
|
+
showEmptyState
|
|
87
|
+
onAction={onCoachAction}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function CartoonNextAction({
|
|
93
|
+
storyName,
|
|
94
|
+
authFetch,
|
|
95
|
+
refreshKey = 0,
|
|
96
|
+
onCoachAction,
|
|
97
|
+
onOpenStoryInfo,
|
|
98
|
+
}: {
|
|
99
|
+
storyName: string;
|
|
100
|
+
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
101
|
+
refreshKey?: number;
|
|
102
|
+
onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
103
|
+
onOpenStoryInfo?: () => void;
|
|
104
|
+
}) {
|
|
105
|
+
const [progress, setProgress] = useState<StoryProgress | null | undefined>(undefined);
|
|
106
|
+
|
|
107
|
+
const targetKey = JSON.stringify([storyName, refreshKey]);
|
|
108
|
+
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
|
109
|
+
if (loadedKey !== targetKey) {
|
|
110
|
+
setProgress(undefined);
|
|
111
|
+
setLoadedKey(targetKey);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
let cancelled = false;
|
|
116
|
+
authFetch(`/api/stories/${storyName}/progress`)
|
|
117
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
118
|
+
.then((data: StoryProgress | null) => {
|
|
119
|
+
if (!cancelled) setProgress(isValidProgress(data) ? data : null);
|
|
120
|
+
})
|
|
121
|
+
.catch(() => {
|
|
122
|
+
if (!cancelled) setProgress(null);
|
|
123
|
+
});
|
|
124
|
+
return () => { cancelled = true; };
|
|
125
|
+
}, [storyName, authFetch, refreshKey]);
|
|
126
|
+
|
|
127
|
+
if (progress === undefined) return null;
|
|
128
|
+
if (!progress) {
|
|
129
|
+
return <WorkflowCoachView coach={null} showEmptyState onAction={onCoachAction} />;
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
<CartoonNextActionView
|
|
133
|
+
progress={progress}
|
|
134
|
+
onCoachAction={onCoachAction}
|
|
135
|
+
onOpenStoryInfo={onOpenStoryInfo}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isValidProgress(data: StoryProgress | null): data is StoryProgress {
|
|
141
|
+
return !!data
|
|
142
|
+
&& !!data.metadata
|
|
143
|
+
&& !!data.setup
|
|
144
|
+
&& Array.isArray(data.episodes);
|
|
145
|
+
}
|
|
@@ -178,7 +178,7 @@ export function CartoonPublishPage({ storyName, authFetch, onOpenFile, onOpenSto
|
|
|
178
178
|
{ label: "Opening text ready", status: "done" }, // the episode exists once it appears here
|
|
179
179
|
{ label: "Cut plan", status: c && c.total > 0 ? "done" : "todo", detail: c ? `${c.total} cut${c.total === 1 ? "" : "s"} planned` : "not started" },
|
|
180
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.
|
|
181
|
+
{ label: "Cuts lettered", status: c && c.total > 0 && c.withText === c.total ? "done" : "todo", detail: c ? `${c.withText} / ${c.total}` : null },
|
|
182
182
|
{ label: "Final images exported", status: c && c.total > 0 && c.exported === c.total ? "done" : "todo", detail: c ? `${c.exported} / ${c.total}` : null },
|
|
183
183
|
{ label: "Final images uploaded", status: c && c.total > 0 && c.uploaded === c.total ? "done" : "todo", detail: c ? `${c.uploaded} / ${c.total}` : null },
|
|
184
184
|
{ label: "Cover image", status: coverDone ? "done" : "todo", detail: coverDone ? null : "recommended before publishing" },
|