plotlink-ows 0.1.15 → 1.0.0

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.
Files changed (40) hide show
  1. package/README.md +185 -93
  2. package/app/db.ts +1 -1
  3. package/app/lib/paths.ts +0 -2
  4. package/app/lib/publish.ts +257 -44
  5. package/app/prisma/schema.prisma +0 -36
  6. package/app/routes/dashboard.ts +105 -57
  7. package/app/routes/publish.ts +107 -25
  8. package/app/routes/settings.ts +194 -0
  9. package/app/routes/stories.ts +223 -0
  10. package/app/routes/terminal.ts +258 -0
  11. package/app/routes/wallet.ts +40 -10
  12. package/app/server.ts +35 -81
  13. package/app/web/App.tsx +4 -6
  14. package/app/web/components/Dashboard.tsx +98 -79
  15. package/app/web/components/Layout.tsx +70 -103
  16. package/app/web/components/PreviewPanel.tsx +388 -0
  17. package/app/web/components/Settings.tsx +210 -67
  18. package/app/web/components/StoriesPage.tsx +270 -0
  19. package/app/web/components/StoryBrowser.tsx +161 -0
  20. package/app/web/components/TerminalPanel.tsx +428 -0
  21. package/app/web/components/WalletCard.tsx +14 -8
  22. package/app/web/dist/assets/index-BuOxhUWG.css +32 -0
  23. package/app/web/dist/assets/index-De8CpT47.js +129 -0
  24. package/app/web/dist/index.html +3 -3
  25. package/app/web/dist/plotlink-logo.svg +5 -0
  26. package/app/web/public/plotlink-logo.svg +5 -0
  27. package/app/web/styles.css +18 -0
  28. package/bin/plotlink-ows.js +18 -62
  29. package/package.json +15 -6
  30. package/scripts/fix-index-status.ts +93 -0
  31. package/app/lib/llm-client.ts +0 -265
  32. package/app/lib/writer-prompt.ts +0 -44
  33. package/app/routes/chat.ts +0 -135
  34. package/app/routes/config.ts +0 -210
  35. package/app/routes/oauth.ts +0 -150
  36. package/app/web/components/Chat.tsx +0 -272
  37. package/app/web/components/LLMSetup.tsx +0 -291
  38. package/app/web/components/Publish.tsx +0 -245
  39. package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
  40. package/app/web/dist/assets/index-CJiiaLHs.js +0 -9
@@ -1,38 +1,67 @@
1
1
  /**
2
2
  * PlotLink publish flow — uploads content to IPFS and publishes on-chain via OWS wallet.
3
3
  */
4
- import { createPublicClient, http, encodeFunctionData, keccak256, toBytes, decodeEventLog, type Hex } from "viem";
4
+ import { createPublicClient, createWalletClient, http, keccak256, toBytes, decodeEventLog, serializeTransaction, type Hex } from "viem";
5
5
  import { base } from "viem/chains";
6
- import { STORY_FACTORY_ABI, mcv2BondAbi } from "../../packages/cli/src/sdk/abi";
7
- import { uploadWithRetry } from "../../packages/cli/src/sdk/ipfs";
8
- import { signAndSendAgent } from "../../lib/ows/wallet";
6
+ import { toAccount } from "viem/accounts";
7
+ import { storyFactoryAbi, mcv2BondAbi } from "../../packages/cli/src/sdk/abi";
8
+ import {
9
+ signTransaction as owsSignTx,
10
+ signMessage as owsSignMsg,
11
+ } from "@open-wallet-standard/core";
9
12
 
10
13
  // Contract addresses (Base mainnet)
11
14
  const STORY_FACTORY = "0x9D2AE1E99D0A6300bfcCF41A82260374e38744Cf" as const;
12
15
  const MCV2_BOND = "0xc5a076cad94176c2996B32d8466Be1cE757FAa27" as const;
13
16
 
17
+ const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
14
18
  const publicClient = createPublicClient({
15
19
  chain: base,
16
- transport: http(process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org"),
20
+ transport: http(rpcUrl),
17
21
  });
18
22
 
19
- function getFilebaseConfig() {
20
- return {
21
- accessKey: process.env.FILEBASE_ACCESS_KEY || "",
22
- secretKey: process.env.FILEBASE_SECRET_KEY || "",
23
- bucket: process.env.FILEBASE_BUCKET || "",
24
- };
23
+ /** Parse OWS signature into viem-compatible r,s,v */
24
+ function parseEvmSignature(sigHex: string, recoveryId?: number): { r: Hex; s: Hex; v: bigint } {
25
+ const sig = sigHex.startsWith("0x") ? sigHex.slice(2) : sigHex;
26
+ const r = `0x${sig.slice(0, 64)}` as Hex;
27
+ const s = `0x${sig.slice(64, 128)}` as Hex;
28
+ // recovery id from OWS or from the last byte of signature
29
+ const v = recoveryId !== undefined ? BigInt(recoveryId + 27) : BigInt(parseInt(sig.slice(128, 130), 16));
30
+ return { r, s, v };
31
+ }
32
+
33
+ /** Create a viem-compatible account backed by OWS wallet (same pattern as claw-on-chain) */
34
+ export function createOwsAccount(walletName: string, address: `0x${string}`) {
35
+ const passphrase = process.env.OWS_PASSPHRASE;
36
+ return toAccount({
37
+ address,
38
+ signMessage: async ({ message }) => {
39
+ const msg = typeof message === "string" ? message : typeof message.raw === "string" ? message.raw : Buffer.from(message.raw).toString("hex");
40
+ const result = owsSignMsg(walletName, "eip155:8453", msg, passphrase);
41
+ return (result.signature.startsWith("0x") ? result.signature : `0x${result.signature}`) as Hex;
42
+ },
43
+ signTransaction: async (tx) => {
44
+ const unsigned = serializeTransaction(tx);
45
+ const hexWithout0x = unsigned.startsWith("0x") ? unsigned.slice(2) : unsigned;
46
+ const result = owsSignTx(walletName, "eip155:8453", hexWithout0x, passphrase);
47
+ const sig = parseEvmSignature(result.signature, result.recoveryId);
48
+ return serializeTransaction(tx, sig);
49
+ },
50
+ signTypedData: async () => { throw new Error("signTypedData not implemented"); },
51
+ });
25
52
  }
26
53
 
27
54
  export interface PublishResult {
28
55
  txHash: string;
29
56
  contentCid: string;
30
57
  storylineId?: number;
58
+ plotIndex?: number;
31
59
  gasCost?: string;
60
+ indexError?: string;
32
61
  }
33
62
 
34
63
  export interface PublishProgress {
35
- step: "uploading" | "estimating" | "signing" | "broadcasting" | "confirming" | "done" | "error";
64
+ step: "uploading" | "estimating" | "signing" | "broadcasting" | "confirming" | "indexing" | "done" | "error";
36
65
  message: string;
37
66
  txHash?: string;
38
67
  contentCid?: string;
@@ -40,21 +69,98 @@ export interface PublishProgress {
40
69
  error?: string;
41
70
  }
42
71
 
72
+ // Indexing retry tuning (per #103 RCA — combo A+B):
73
+ // - 8s initial delay lets the on-chain tx propagate to plotlink.xyz's RPC
74
+ // and gives Filebase → public IPFS gateway propagation a head start.
75
+ // - 10 attempts × 30s interval ≈ 4.5 min total, fitting inside plotlink.xyz's
76
+ // 5-min indexable window so users don't escalate to a full Retry Publish
77
+ // (which would mint another chainPlot tx and inflate the on-chain index).
78
+ const INDEX_INITIAL_DELAY_MS = 8_000;
79
+ const INDEX_RETRY_ATTEMPTS = 10;
80
+ const INDEX_RETRY_INTERVAL_MS = 30_000;
81
+
43
82
  /**
44
- * Upload story content to IPFS via Filebase.
83
+ * POST to plotlink.xyz's indexer with an initial delay and a retry loop.
84
+ * Streams "Indexing… (attempt N/M)" progress so the publish flow surfaces
85
+ * the in-progress state instead of an immediate failure.
86
+ *
87
+ * Returns undefined on success, or the final error message after all
88
+ * attempts have failed.
45
89
  */
46
- export async function uploadToIPFS(content: string, title: string, genre?: string): Promise<string> {
47
- const filebaseConfig = getFilebaseConfig();
48
- if (!filebaseConfig.accessKey || !filebaseConfig.secretKey) {
49
- throw new Error("Filebase not configured. Set FILEBASE_ACCESS_KEY and FILEBASE_SECRET_KEY in .env");
90
+ async function indexWithDelayAndRetry(
91
+ endpoint: "plot" | "storyline",
92
+ body: Record<string, unknown>,
93
+ onProgress: (progress: PublishProgress) => void,
94
+ txHash: string,
95
+ contentCid: string,
96
+ ): Promise<string | undefined> {
97
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
98
+ const url = `${PLOTLINK_URL}/api/index/${endpoint}`;
99
+
100
+ onProgress({
101
+ step: "indexing",
102
+ message: `Indexing… waiting ${INDEX_INITIAL_DELAY_MS / 1000}s for on-chain propagation`,
103
+ txHash,
104
+ contentCid,
105
+ });
106
+ await new Promise((r) => setTimeout(r, INDEX_INITIAL_DELAY_MS));
107
+
108
+ let lastError: string | undefined;
109
+ for (let attempt = 1; attempt <= INDEX_RETRY_ATTEMPTS; attempt++) {
110
+ onProgress({
111
+ step: "indexing",
112
+ message: `Indexing… (attempt ${attempt}/${INDEX_RETRY_ATTEMPTS})`,
113
+ txHash,
114
+ contentCid,
115
+ });
116
+
117
+ try {
118
+ const indexRes = await fetch(url, {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/json" },
121
+ body: JSON.stringify(body),
122
+ });
123
+ const indexBody = await indexRes.json().catch(() => ({})) as Record<string, string>;
124
+ if (indexRes.ok && !indexBody.error) {
125
+ return undefined;
126
+ }
127
+ lastError = indexBody.error || `Indexing failed: HTTP ${indexRes.status}`;
128
+ } catch (err) {
129
+ lastError = err instanceof Error ? err.message : "Indexing request failed";
130
+ }
131
+
132
+ if (attempt < INDEX_RETRY_ATTEMPTS) {
133
+ await new Promise((r) => setTimeout(r, INDEX_RETRY_INTERVAL_MS));
134
+ }
50
135
  }
51
136
 
137
+ console.error(`Indexing failed for tx ${txHash} after ${INDEX_RETRY_ATTEMPTS} attempts:`, lastError);
138
+ return lastError;
139
+ }
140
+
141
+ /**
142
+ * Upload story content to IPFS via PlotLink's API (plotlink.xyz/api/upload).
143
+ * PlotLink handles Filebase credentials server-side.
144
+ */
145
+ export async function uploadToIPFS(content: string, title: string, genre?: string): Promise<string> {
146
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
52
147
  const metadata = JSON.stringify({ title, genre, content });
53
148
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40);
54
149
  const key = `plotlink/storylines/${Date.now()}-${slug}.json`;
55
150
 
56
- const cid = await uploadWithRetry(metadata, key, filebaseConfig);
57
- return cid;
151
+ const res = await fetch(`${PLOTLINK_URL}/api/upload`, {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json" },
154
+ body: JSON.stringify({ content: metadata, key }),
155
+ });
156
+
157
+ if (!res.ok) {
158
+ const err = await res.json().catch(() => ({})) as Record<string, string>;
159
+ throw new Error(err.error || `Upload failed: HTTP ${res.status}`);
160
+ }
161
+
162
+ const data = await res.json() as { cid: string };
163
+ return data.cid;
58
164
  }
59
165
 
60
166
  /**
@@ -85,7 +191,7 @@ export async function estimatePublishCost(
85
191
  to: STORY_FACTORY,
86
192
  value: creationFee,
87
193
  data: encodeFunctionData({
88
- abi: STORY_FACTORY_ABI,
194
+ abi: storyFactoryAbi,
89
195
  functionName: "createStoryline",
90
196
  args: [title, contentCid, contentHash, true],
91
197
  }),
@@ -110,9 +216,9 @@ export async function getEthBalance(address: string): Promise<bigint> {
110
216
  }
111
217
 
112
218
  /**
113
- * Wait for transaction confirmation and decode storylineId from event.
219
+ * Wait for tx confirmation and compute gas cost.
114
220
  */
115
- async function waitForConfirmation(txHash: string): Promise<{ storylineId: number; gasCost: string }> {
221
+ async function waitForReceipt(txHash: string) {
116
222
  const receipt = await publicClient.waitForTransactionReceipt({
117
223
  hash: txHash as `0x${string}`,
118
224
  });
@@ -123,8 +229,6 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
123
229
 
124
230
  // Compute actual total cost: gasUsed * effectiveGasPrice + tx value (creation fee)
125
231
  const gasOnly = receipt.gasUsed * receipt.effectiveGasPrice;
126
- const txValue = receipt.logs.length > 0 ? BigInt(0) : BigInt(0); // value is in the tx itself
127
- // Include creation fee from tx value — read from the original transaction
128
232
  let creationFeeUsed = BigInt(0);
129
233
  try {
130
234
  const tx = await publicClient.getTransaction({ hash: txHash as `0x${string}` });
@@ -132,11 +236,19 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
132
236
  } catch { /* best effort */ }
133
237
  const gasCost = (gasOnly + creationFeeUsed).toString();
134
238
 
135
- // Decode StorylineCreated event to get storylineId
239
+ return { receipt, gasCost };
240
+ }
241
+
242
+ /**
243
+ * Wait for storyline creation confirmation — decodes StorylineCreated event.
244
+ */
245
+ async function waitForStorylineConfirmation(txHash: string): Promise<{ storylineId: number; gasCost: string }> {
246
+ const { receipt, gasCost } = await waitForReceipt(txHash);
247
+
136
248
  for (const log of receipt.logs) {
137
249
  try {
138
250
  const decoded = decodeEventLog({
139
- abi: STORY_FACTORY_ABI,
251
+ abi: storyFactoryAbi,
140
252
  data: log.data,
141
253
  topics: log.topics,
142
254
  });
@@ -148,6 +260,30 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
148
260
  throw new Error("Transaction succeeded but StorylineCreated event not found");
149
261
  }
150
262
 
263
+ /**
264
+ * Wait for plot chain confirmation — decodes PlotChained event.
265
+ */
266
+ async function waitForPlotConfirmation(txHash: string): Promise<{ plotIndex: number; gasCost: string }> {
267
+ const { receipt, gasCost } = await waitForReceipt(txHash);
268
+
269
+ for (const log of receipt.logs) {
270
+ try {
271
+ const decoded = decodeEventLog({
272
+ abi: storyFactoryAbi,
273
+ data: log.data,
274
+ topics: log.topics,
275
+ });
276
+ if (decoded.eventName === "PlotChained") {
277
+ // plotIndex is 0-based: genesis=0, plot-01=1, plot-02=2, etc.
278
+ // This matches plotlink.xyz URL convention: /story/{id}/{plotIndex}
279
+ return { plotIndex: Number((decoded.args as { plotIndex: bigint }).plotIndex), gasCost };
280
+ }
281
+ } catch { /* not our event */ }
282
+ }
283
+ // If we can't find PlotChained but receipt succeeded, still return (best effort)
284
+ return { plotIndex: -1, gasCost };
285
+ }
286
+
151
287
  /**
152
288
  * Publish a new storyline to PlotLink on-chain.
153
289
  */
@@ -168,37 +304,114 @@ export async function publishStoryline(
168
304
  onProgress({ step: "estimating", message: "Fetching creation fee and estimating gas..." });
169
305
  const creationFee = await getCreationFee();
170
306
 
171
- // Step 3: Build transaction with creation fee as value
172
- const calldata = encodeFunctionData({
173
- abi: STORY_FACTORY_ABI,
307
+ // Step 3: Create OWS-backed viem wallet client
308
+ onProgress({ step: "signing", message: "Signing transaction with OWS wallet..." });
309
+ const { listAgentWallets, getBaseAddress } = await import("../../lib/ows/wallet");
310
+ const wallets = listAgentWallets();
311
+ const owsWallet = wallets.find((w) => w.name === walletName);
312
+ if (!owsWallet) throw new Error("OWS wallet not found");
313
+ const address = getBaseAddress(owsWallet);
314
+ if (!address) throw new Error("No EVM address on wallet");
315
+
316
+ const account = createOwsAccount(walletName, address as `0x${string}`);
317
+ const walletClient = createWalletClient({ account, chain: base, transport: http(rpcUrl) });
318
+
319
+ // Step 4: Write contract via viem (handles signing + broadcasting)
320
+ onProgress({ step: "broadcasting", message: "Broadcasting transaction..." });
321
+ const txHash = await walletClient.writeContract({
322
+ address: STORY_FACTORY,
323
+ abi: storyFactoryAbi,
174
324
  functionName: "createStoryline",
175
325
  args: [title, contentCid, contentHash, true],
326
+ value: creationFee,
176
327
  });
177
328
 
178
- // Step 4: Sign and send via OWS
179
- onProgress({ step: "signing", message: "Signing transaction with OWS wallet..." });
180
- const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
329
+ // Step 5: Wait for confirmation and decode storylineId
330
+ onProgress({ step: "confirming", message: "Waiting for confirmation...", txHash, contentCid });
331
+ const confirmation = await waitForStorylineConfirmation(txHash);
181
332
 
182
- const txHex = JSON.stringify({
183
- to: STORY_FACTORY,
184
- data: calldata,
185
- value: `0x${creationFee.toString(16)}`,
333
+ // Index on PlotLink with delay + retry (per #103 RCA — combo A+B).
334
+ // Streams "Indexing…" progress so the user does not escalate to Retry Publish.
335
+ const indexError = await indexWithDelayAndRetry(
336
+ "storyline",
337
+ { txHash, content, genre },
338
+ onProgress,
339
+ txHash,
340
+ contentCid,
341
+ );
342
+
343
+ onProgress({
344
+ step: "done",
345
+ message: `Published! Storyline #${confirmation.storylineId}`,
346
+ txHash,
347
+ contentCid,
348
+ storylineId: confirmation.storylineId,
186
349
  });
187
350
 
351
+ return { txHash, contentCid, storylineId: confirmation.storylineId, gasCost: confirmation.gasCost, indexError };
352
+ }
353
+
354
+ /**
355
+ * Chain a new plot to an existing storyline on-chain.
356
+ */
357
+ export async function publishPlot(
358
+ walletName: string,
359
+ storylineId: number,
360
+ title: string,
361
+ content: string,
362
+ genre: string | undefined,
363
+ onProgress: (progress: PublishProgress) => void,
364
+ ): Promise<PublishResult> {
365
+ // Step 1: Upload to IPFS
366
+ onProgress({ step: "uploading", message: "Uploading plot to IPFS..." });
367
+ const contentCid = await uploadToIPFS(content, title, genre);
368
+
369
+ // Step 2: Compute content hash
370
+ const contentHash = keccak256(toBytes(content));
371
+
372
+ // Step 3: Create OWS-backed viem wallet client
373
+ onProgress({ step: "signing", message: "Signing transaction with OWS wallet..." });
374
+ const { listAgentWallets, getBaseAddress } = await import("../../lib/ows/wallet");
375
+ const wallets = listAgentWallets();
376
+ const owsWallet = wallets.find((w) => w.name === walletName);
377
+ if (!owsWallet) throw new Error("OWS wallet not found");
378
+ const address = getBaseAddress(owsWallet);
379
+ if (!address) throw new Error("No EVM address on wallet");
380
+
381
+ const account = createOwsAccount(walletName, address as `0x${string}`);
382
+ const walletClient = createWalletClient({ account, chain: base, transport: http(rpcUrl) });
383
+
384
+ // Step 4: Write contract via viem
188
385
  onProgress({ step: "broadcasting", message: "Broadcasting transaction..." });
189
- const result = signAndSendAgent(walletName, txHex, undefined, rpcUrl);
386
+ const txHash = await walletClient.writeContract({
387
+ address: STORY_FACTORY,
388
+ abi: storyFactoryAbi,
389
+ functionName: "chainPlot",
390
+ args: [BigInt(storylineId), title, contentCid, contentHash],
391
+ });
190
392
 
191
- // Step 5: Wait for confirmation and decode storylineId
192
- onProgress({ step: "confirming", message: "Waiting for confirmation...", txHash: result.txHash, contentCid });
193
- const confirmation = await waitForConfirmation(result.txHash);
393
+ // Step 5: Wait for plot confirmation
394
+ onProgress({ step: "confirming", message: "Waiting for confirmation...", txHash, contentCid });
395
+ const confirmation = await waitForPlotConfirmation(txHash);
396
+
397
+ // Index on PlotLink with delay + retry (per #103 RCA — combo A+B).
398
+ // Pass content as fallback because IPFS stores JSON metadata wrapper,
399
+ // but on-chain hash is keccak256 of raw content only.
400
+ const indexError = await indexWithDelayAndRetry(
401
+ "plot",
402
+ { txHash, content },
403
+ onProgress,
404
+ txHash,
405
+ contentCid,
406
+ );
194
407
 
195
408
  onProgress({
196
409
  step: "done",
197
- message: `Published! Storyline #${confirmation.storylineId}`,
198
- txHash: result.txHash,
410
+ message: `Plot chained to storyline #${storylineId}`,
411
+ txHash,
199
412
  contentCid,
200
- storylineId: confirmation.storylineId,
413
+ storylineId,
201
414
  });
202
415
 
203
- return { txHash: result.txHash, contentCid, storylineId: confirmation.storylineId, gasCost: confirmation.gasCost };
416
+ return { txHash, contentCid, storylineId, plotIndex: confirmation.plotIndex >= 0 ? confirmation.plotIndex : undefined, gasCost: confirmation.gasCost, indexError };
204
417
  }
@@ -19,39 +19,3 @@ model Setting {
19
19
  key String @id
20
20
  value String
21
21
  }
22
-
23
- model StorySession {
24
- id String @id @default(cuid())
25
- title String @default("Untitled Story")
26
- genre String?
27
- status String @default("active") // active, finalized, archived
28
- createdAt DateTime @default(now())
29
- updatedAt DateTime @updatedAt
30
- messages Message[]
31
- drafts Draft[]
32
- }
33
-
34
- model Message {
35
- id String @id @default(cuid())
36
- sessionId String
37
- session StorySession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
38
- role String // user, assistant, system
39
- content String
40
- createdAt DateTime @default(now())
41
- }
42
-
43
- model Draft {
44
- id String @id @default(cuid())
45
- sessionId String
46
- session StorySession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
47
- title String
48
- content String
49
- genre String?
50
- status String @default("draft") // draft, ready, published
51
- txHash String?
52
- storylineId Int?
53
- contentCid String?
54
- gasCost String? // ETH cost in wei
55
- createdAt DateTime @default(now())
56
- updatedAt DateTime @updatedAt
57
- }
@@ -1,10 +1,12 @@
1
1
  import { Hono } from "hono";
2
2
  import { createPublicClient, http } from "viem";
3
3
  import { base } from "viem/chains";
4
- import { db } from "../db";
4
+ import fs from "fs";
5
+ import path from "path";
5
6
  import { getEthBalance } from "../lib/publish";
6
7
  import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
7
8
  import { mcv2BondAbi } from "../../packages/cli/src/sdk/abi";
9
+ import { STORIES_DIR, readPublishStatus } from "./stories";
8
10
 
9
11
  const MCV2_BOND = "0xc5a076cad94176c2996B32d8466Be1cE757FAa27" as const;
10
12
  // Reserve token for PlotLink bonding curves (PLOT token on Base mainnet)
@@ -19,10 +21,63 @@ const dashboard = new Hono();
19
21
 
20
22
  /** GET /api/dashboard — writer dashboard data */
21
23
  dashboard.get("/", async (c) => {
22
- // Get all drafts (published and unpublished)
23
- const drafts = await db.draft.findMany({
24
- orderBy: { createdAt: "desc" },
25
- });
24
+ // Scan stories/ for publish status
25
+ interface PublishedFile {
26
+ storyName: string;
27
+ file: string;
28
+ storyTitle: string;
29
+ storyGenre: string | null;
30
+ plotCount: number;
31
+ status?: string;
32
+ txHash?: string;
33
+ storylineId?: number;
34
+ contentCid?: string;
35
+ publishedAt?: string;
36
+ gasCost?: string;
37
+ }
38
+ const publishedFiles: PublishedFile[] = [];
39
+ let totalFiles = 0;
40
+ let totalStories = 0;
41
+
42
+ if (fs.existsSync(STORIES_DIR)) {
43
+ const dirs = fs.readdirSync(STORIES_DIR, { withFileTypes: true })
44
+ .filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== "_example");
45
+ totalStories = dirs.length;
46
+
47
+ for (const dir of dirs) {
48
+ const storyDir = path.join(STORIES_DIR, dir.name);
49
+ const status = readPublishStatus(storyDir);
50
+ const mdFiles = fs.readdirSync(storyDir).filter((f) => f.endsWith(".md"));
51
+ totalFiles += mdFiles.length;
52
+
53
+ // Read story title and genre from structure.md or genesis.md
54
+ let storyTitle = dir.name;
55
+ let storyGenre: string | null = null;
56
+ try {
57
+ const structPath = path.join(storyDir, "structure.md");
58
+ const genesisPath = path.join(storyDir, "genesis.md");
59
+ if (fs.existsSync(structPath)) {
60
+ const content = fs.readFileSync(structPath, "utf-8");
61
+ const titleMatch = content.match(/^#\s+(.+)$/m);
62
+ if (titleMatch) storyTitle = titleMatch[1];
63
+ const genreMatch = content.match(/genre[:\s]+(.+)/i);
64
+ if (genreMatch) storyGenre = genreMatch[1].trim();
65
+ } else if (fs.existsSync(genesisPath)) {
66
+ const content = fs.readFileSync(genesisPath, "utf-8");
67
+ const titleMatch = content.match(/^#\s+(.+)$/m);
68
+ if (titleMatch) storyTitle = titleMatch[1];
69
+ }
70
+ } catch { /* best effort */ }
71
+
72
+ const plotCount = mdFiles.filter((f) => /^plot-\d+\.md$/.test(f)).length;
73
+
74
+ for (const [file, info] of Object.entries(status)) {
75
+ if (info.status === "published" || info.status === "published-not-indexed") {
76
+ publishedFiles.push({ storyName: dir.name, file, storyTitle, storyGenre, plotCount, ...info });
77
+ }
78
+ }
79
+ }
80
+ }
26
81
 
27
82
  // Get wallet info
28
83
  let walletInfo = null;
@@ -61,13 +116,9 @@ dashboard.get("/", async (c) => {
61
116
  }
62
117
  } catch { /* wallet not available */ }
63
118
 
64
- // Published stories with cost data
65
- const published = drafts.filter((d) => d.status === "published");
66
- const unpublished = drafts.filter((d) => d.status !== "published");
67
-
68
- // Compute total costs
69
- const totalGasCostWei = published.reduce((sum, d) => {
70
- if (d.gasCost) return sum + BigInt(d.gasCost);
119
+ // Compute total costs from published files
120
+ const totalGasCostWei = publishedFiles.reduce((sum, f) => {
121
+ if (f.gasCost) return sum + BigInt(f.gasCost);
71
122
  return sum;
72
123
  }, BigInt(0));
73
124
  const totalGasCostEth = (Number(totalGasCostWei) / 1e18).toFixed(6);
@@ -111,49 +162,61 @@ dashboard.get("/", async (c) => {
111
162
  const totalRoyaltiesUsd = parseFloat(royaltiesEarned) * plotUsdPrice;
112
163
  const netPnlUsd = totalRoyaltiesUsd - totalCostUsd;
113
164
 
114
- // Session stats
115
- const sessions = await db.storySession.findMany({
116
- include: { _count: { select: { messages: true } } },
117
- });
118
-
119
165
  return c.json({
120
166
  wallet: walletInfo,
121
167
  stories: {
122
- published: published.map((d) => ({
123
- id: d.id,
124
- title: d.title,
125
- genre: d.genre,
126
- status: d.status,
127
- txHash: d.txHash,
128
- storylineId: d.storylineId,
129
- contentCid: d.contentCid,
130
- gasCost: d.gasCost,
131
- gasCostEth: d.gasCost ? (Number(BigInt(d.gasCost)) / 1e18).toFixed(6) : null,
132
- gasCostUsd: d.gasCost && ethUsdPrice ? ((Number(BigInt(d.gasCost)) / 1e18) * ethUsdPrice).toFixed(2) : null,
133
- createdAt: d.createdAt,
134
- updatedAt: d.updatedAt,
135
- })),
136
- drafts: unpublished.map((d) => ({
137
- id: d.id,
138
- title: d.title,
139
- genre: d.genre,
140
- status: d.status,
141
- createdAt: d.createdAt,
142
- })),
143
- totalPublished: published.length,
144
- totalDrafts: unpublished.length,
168
+ published: (() => {
169
+ // Group by storylineId when present, fall back to storyName
170
+ const grouped = new Map<string, typeof publishedFiles>();
171
+ for (const f of publishedFiles) {
172
+ const key = f.storylineId ? `sid:${f.storylineId}` : `name:${f.storyName}`;
173
+ const group = grouped.get(key) || [];
174
+ group.push(f);
175
+ grouped.set(key, group);
176
+ }
177
+ return [...grouped.entries()].map(([, files]) => {
178
+ const first = files[0];
179
+ const totalGas = files.reduce((sum, f) => f.gasCost ? sum + BigInt(f.gasCost) : sum, BigInt(0));
180
+ const latestDate = files.reduce((latest, f) =>
181
+ f.publishedAt && (!latest || f.publishedAt > latest) ? f.publishedAt : latest, null as string | null);
182
+ const hasNotIndexed = files.some((f) => f.status === "published-not-indexed");
183
+ return {
184
+ id: first.storylineId ? `sid:${first.storylineId}` : first.storyName,
185
+ title: first.storyTitle,
186
+ genre: first.storyGenre,
187
+ storyName: first.storyName,
188
+ storylineId: first.storylineId,
189
+ plotCount: first.plotCount,
190
+ publishedFiles: files.length,
191
+ hasNotIndexed,
192
+ totalGasCostEth: totalGas > 0 ? (Number(totalGas) / 1e18).toFixed(6) : null,
193
+ totalGasCostUsd: totalGas > 0 && ethUsdPrice ? ((Number(totalGas) / 1e18) * ethUsdPrice).toFixed(2) : null,
194
+ latestPublishedAt: latestDate,
195
+ files: files.map((f) => ({
196
+ file: f.file,
197
+ status: f.status || "published",
198
+ txHash: f.txHash,
199
+ gasCostEth: f.gasCost ? (Number(BigInt(f.gasCost)) / 1e18).toFixed(6) : null,
200
+ publishedAt: f.publishedAt || null,
201
+ })),
202
+ };
203
+ });
204
+ })(),
205
+ totalPublished: publishedFiles.length,
206
+ totalStories,
207
+ totalFiles,
208
+ pendingFiles: totalFiles - publishedFiles.length,
145
209
  },
146
210
  costs: {
147
211
  totalGasCostWei: totalGasCostWei.toString(),
148
212
  totalGasCostEth,
149
213
  totalCostUsd: totalCostUsd.toFixed(2),
150
214
  ethUsdPrice,
151
- storiesPublished: published.length,
215
+ storiesPublished: publishedFiles.length,
152
216
  },
153
217
  royalties: {
154
218
  earned: royaltiesEarned,
155
219
  claimed: royaltiesClaimed,
156
- // unclaimed = earned - claimed (already correct since earned = unclaimed + claimed)
157
220
  unclaimed: (parseFloat(royaltiesEarned) - parseFloat(royaltiesClaimed)).toFixed(6),
158
221
  token: "PLOT",
159
222
  },
@@ -165,22 +228,7 @@ dashboard.get("/", async (c) => {
165
228
  netPnlUsd: netPnlUsd.toFixed(2),
166
229
  plotUsdPrice: plotUsdPrice.toFixed(4),
167
230
  },
168
- sessions: {
169
- total: sessions.length,
170
- totalMessages: sessions.reduce((sum, s) => sum + s._count.messages, 0),
171
- },
172
231
  });
173
232
  });
174
233
 
175
- /** DELETE /api/dashboard/drafts/:id — delete a draft */
176
- dashboard.delete("/drafts/:id", async (c) => {
177
- const id = c.req.param("id");
178
- try {
179
- await db.draft.delete({ where: { id } });
180
- return c.json({ success: true });
181
- } catch {
182
- return c.json({ error: "Draft not found" }, 404);
183
- }
184
- });
185
-
186
234
  export { dashboard as dashboardRoutes };