plotlink-ows 0.1.18 → 1.0.4

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/server.ts CHANGED
@@ -17,6 +17,7 @@ import { publishRoutes } from "./routes/publish";
17
17
  import { dashboardRoutes } from "./routes/dashboard";
18
18
  import { terminalRoutes, attachTerminalWs } from "./routes/terminal";
19
19
  import { storiesRoutes } from "./routes/stories";
20
+ import { settingsRoutes } from "./routes/settings";
20
21
  import { initDb } from "./db";
21
22
  import { execSync } from "child_process";
22
23
  import fs from "fs";
@@ -40,6 +41,8 @@ app.use("/api/terminal/*", requireAuth);
40
41
  app.route("/api/terminal", terminalRoutes);
41
42
  app.use("/api/stories/*", requireAuth);
42
43
  app.route("/api/stories", storiesRoutes);
44
+ app.use("/api/settings/*", requireAuth);
45
+ app.route("/api/settings", settingsRoutes);
43
46
 
44
47
  // Health check
45
48
  app.get("/api/health", (c) => c.json({ status: "ok" }));
@@ -82,6 +85,7 @@ async function start() {
82
85
  const { WebSocketServer } = await import("ws");
83
86
  const wss = new WebSocketServer({ noServer: true });
84
87
  // server from serve() IS an http.Server
88
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
85
89
  (server as any).on("upgrade", (req: any, socket: any, head: any) => {
86
90
  const url = new URL(req.url || "", `http://localhost:${port}`);
87
91
  if (url.pathname === "/ws/terminal") {
@@ -91,8 +95,10 @@ async function start() {
91
95
  import("./db").then(async ({ db }) => {
92
96
  const session = await db.session.findUnique({ where: { token: wsToken } });
93
97
  if (!session || session.expiresAt < new Date()) { socket.destroy(); return; }
98
+ const story = url.searchParams.get("story") || undefined;
99
+ const resume = url.searchParams.get("resume") === "true";
94
100
  wss.handleUpgrade(req, socket, head, (ws) => {
95
- attachTerminalWs(ws as unknown as WebSocket);
101
+ attachTerminalWs(ws as unknown as WebSocket, story, resume);
96
102
  });
97
103
  }).catch(() => socket.destroy());
98
104
  }
@@ -9,17 +9,27 @@ interface WalletInfo {
9
9
  usdcBalance: string;
10
10
  }
11
11
 
12
- interface Story {
12
+ interface StoryFile {
13
+ file: string;
14
+ status: string;
15
+ txHash?: string | null;
16
+ gasCostEth?: string | null;
17
+ publishedAt?: string | null;
18
+ }
19
+
20
+ interface StoryGroup {
13
21
  id: string;
14
22
  title: string;
15
23
  genre: string | null;
16
- status: string;
17
- txHash?: string | null;
24
+ storyName: string;
18
25
  storylineId?: number | null;
19
- gasCostEth?: string | null;
20
- gasCostUsd?: string | null;
21
- createdAt: string;
22
- updatedAt?: string;
26
+ plotCount: number;
27
+ publishedFiles: number;
28
+ hasNotIndexed: boolean;
29
+ totalGasCostEth?: string | null;
30
+ totalGasCostUsd?: string | null;
31
+ latestPublishedAt?: string | null;
32
+ files: StoryFile[];
23
33
  }
24
34
 
25
35
  interface DashboardData {
@@ -28,7 +38,7 @@ interface DashboardData {
28
38
  royalties: { earned: string; claimed: string; unclaimed: string; token: string };
29
39
  pnl: { totalCostsEth: string; totalCostsUsd: string; totalRoyaltiesPlot: string; totalRoyaltiesUsd: string; netPnlUsd: string; plotUsdPrice: string };
30
40
  stories: {
31
- published: Story[];
41
+ published: StoryGroup[];
32
42
  totalPublished: number;
33
43
  totalStories: number;
34
44
  totalFiles: number;
@@ -50,7 +60,12 @@ export function Dashboard({ token }: { token: string }) {
50
60
  useEffect(() => { loadDashboard(); }, []);
51
61
 
52
62
  const truncate = (addr: string) => `${addr.slice(0, 6)}...${addr.slice(-4)}`;
53
- const formatDate = (d: string) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
63
+ const formatDate = (d: string | undefined | null) => {
64
+ if (!d) return "Unknown date";
65
+ const date = new Date(d);
66
+ if (isNaN(date.getTime())) return "Unknown date";
67
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
68
+ };
54
69
 
55
70
  if (!data) {
56
71
  return (
@@ -140,38 +155,74 @@ export function Dashboard({ token }: { token: string }) {
140
155
  {data.stories.published.length === 0 ? (
141
156
  <p className="text-muted text-xs">no published stories yet</p>
142
157
  ) : (
143
- <div className="space-y-2">
158
+ <div className="space-y-3">
144
159
  {data.stories.published.map((story) => (
145
- <div key={story.id} className="bg-surface rounded p-3">
146
- <div className="flex items-center justify-between">
160
+ <div key={story.id} className="bg-surface rounded border border-border p-4">
161
+ <div className="flex items-start justify-between">
147
162
  <div>
148
- <span className="text-foreground text-sm font-medium">{story.title}</span>
149
- {story.genre && <span className="text-accent ml-2 text-[10px]">{story.genre}</span>}
163
+ {story.genre && (
164
+ <span className="bg-accent/10 text-accent rounded px-2 py-0.5 text-[10px] font-medium">{story.genre}</span>
165
+ )}
166
+ <h4 className="text-foreground mt-1 text-sm font-serif font-medium">{story.title}</h4>
167
+ <p className="text-muted mt-0.5 text-[10px] font-mono">{story.storyName}</p>
150
168
  </div>
151
169
  <div className="flex items-center gap-2">
152
- <span className="rounded border border-green-700/30 px-1.5 py-0.5 text-[9px] text-accent">published</span>
153
- {story.storylineId ? (
154
- <a
155
- href={`https://plotlink.xyz/story/${story.storylineId}`}
156
- target="_blank"
157
- rel="noopener noreferrer"
158
- className="text-accent text-[10px] underline"
159
- >
160
- view
161
- </a>
162
- ) : (
163
- <a href="https://plotlink.xyz" target="_blank" rel="noopener noreferrer" className="text-accent text-[10px] underline">plotlink.xyz</a>
170
+ {story.hasNotIndexed && (
171
+ <span className="rounded border border-amber-600/30 px-1.5 py-0.5 text-[9px] text-amber-700">not indexed</span>
164
172
  )}
173
+ <span className="rounded border border-green-700/30 px-1.5 py-0.5 text-[9px] text-green-700">
174
+ {story.publishedFiles} published
175
+ </span>
176
+ </div>
177
+ </div>
178
+ <div className="mt-2 grid grid-cols-3 gap-2 text-center">
179
+ <div className="rounded bg-background p-1.5">
180
+ <div className="text-foreground text-sm font-medium">{story.plotCount}</div>
181
+ <div className="text-muted text-[9px]">Plots</div>
165
182
  </div>
183
+ <div className="rounded bg-background p-1.5">
184
+ <div className="text-foreground text-sm font-medium font-mono">
185
+ {story.storylineId ? `#${story.storylineId}` : "—"}
186
+ </div>
187
+ <div className="text-muted text-[9px]">Storyline</div>
188
+ </div>
189
+ <div className="rounded bg-background p-1.5">
190
+ <div className="text-foreground text-sm font-medium">
191
+ {story.totalGasCostEth ?? "—"}
192
+ </div>
193
+ <div className="text-muted text-[9px]">Gas (ETH)</div>
194
+ </div>
195
+ </div>
196
+ {/* Individual files */}
197
+ <div className="mt-2 space-y-1">
198
+ {story.files.map((f) => (
199
+ <div key={f.file} className="flex items-center justify-between text-[10px]">
200
+ <div className="flex items-center gap-1.5">
201
+ <span className={f.status === "published-not-indexed" ? "text-amber-700" : "text-green-700"}>
202
+ {f.status === "published-not-indexed" ? "\u26A0" : "\u2713"}
203
+ </span>
204
+ <span className="text-muted font-mono">{f.file}</span>
205
+ </div>
206
+ {f.txHash && (
207
+ <a href={`https://basescan.org/tx/${f.txHash}`} target="_blank" rel="noopener noreferrer" className="text-muted hover:text-accent font-mono">
208
+ tx:{f.txHash.slice(0, 8)}...
209
+ </a>
210
+ )}
211
+ </div>
212
+ ))}
166
213
  </div>
167
- <div className="mt-1 flex items-center gap-3 text-[10px]">
168
- <span className="text-muted">{formatDate(story.createdAt)}</span>
169
- {story.txHash && (
170
- <a href={`https://basescan.org/tx/${story.txHash}`} target="_blank" rel="noopener noreferrer" className="text-muted hover:text-accent font-mono">
171
- tx:{story.txHash.slice(0, 10)}...
214
+ <div className="mt-2 flex items-center justify-between text-[10px]">
215
+ <span className="text-muted">{formatDate(story.latestPublishedAt)}</span>
216
+ {story.storylineId && (
217
+ <a
218
+ href={`https://plotlink.xyz/story/${story.storylineId}`}
219
+ target="_blank"
220
+ rel="noopener noreferrer"
221
+ className="text-accent underline"
222
+ >
223
+ View on PlotLink
172
224
  </a>
173
225
  )}
174
- {story.gasCostEth && <span className="text-muted">{story.gasCostEth} ETH{story.gasCostUsd ? ` (~$${story.gasCostUsd})` : ""}</span>}
175
226
  </div>
176
227
  </div>
177
228
  ))}
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import ReactMarkdown from "react-markdown";
3
3
  import remarkBreaks from "remark-breaks";
4
4
  import remarkGfm from "remark-gfm";
@@ -14,22 +14,48 @@ interface PreviewPanelProps {
14
14
 
15
15
  interface FileData {
16
16
  file: string;
17
- status: "published" | "pending" | "draft";
17
+ status: "published" | "published-not-indexed" | "pending" | "draft";
18
18
  content: string;
19
19
  txHash?: string;
20
20
  storylineId?: number;
21
+ plotIndex?: number;
22
+ indexError?: string;
23
+ publishedAt?: string;
21
24
  }
22
25
 
26
+ type Tab = "preview" | "edit";
27
+
23
28
  export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publishingFile }: PreviewPanelProps) {
24
29
  const [fileData, setFileData] = useState<FileData | null>(null);
25
30
  const [loading, setLoading] = useState(false);
31
+ const [activeTab, setActiveTab] = useState<Tab>("preview");
32
+ const [editContent, setEditContent] = useState("");
33
+ const [saving, setSaving] = useState(false);
34
+ const [dirty, setDirty] = useState(false);
35
+ const [retrying, setRetrying] = useState(false);
36
+ const [indexTimeLeft, setIndexTimeLeft] = useState<number | null>(null);
37
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
38
+ const dirtyRef = useRef(false);
39
+
40
+ const prevFileRef = useRef<string | null>(null);
26
41
 
27
42
  const loadFile = useCallback(async () => {
28
43
  if (!storyName || !fileName) { setFileData(null); return; }
44
+ const fileKey = `${storyName}/${fileName}`;
45
+ const isNewFile = prevFileRef.current !== fileKey;
46
+ if (isNewFile) {
47
+ prevFileRef.current = fileKey;
48
+ }
29
49
  try {
30
50
  const res = await authFetch(`/api/stories/${storyName}/${fileName}`);
31
51
  if (res.ok) {
32
- setFileData(await res.json());
52
+ const data: FileData = await res.json();
53
+ setFileData(data);
54
+ // Update edit content on new file or when no unsaved changes
55
+ if (isNewFile || !dirtyRef.current) {
56
+ setEditContent(data.content ?? "");
57
+ if (isNewFile) { setDirty(false); dirtyRef.current = false; }
58
+ }
33
59
  }
34
60
  } catch { /* ignore */ }
35
61
  }, [storyName, fileName, authFetch]);
@@ -41,12 +67,64 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
41
67
  loadFile().finally(() => setLoading(false));
42
68
  }, [loadFile]);
43
69
 
44
- // Auto-refresh every 3 seconds
70
+ // Auto-refresh every 3 seconds (only in preview mode when not dirty)
45
71
  useEffect(() => {
46
72
  if (!storyName || !fileName) return;
73
+ if (activeTab === "edit" && dirty) return;
47
74
  const interval = setInterval(loadFile, 3000);
48
75
  return () => clearInterval(interval);
49
- }, [storyName, fileName, loadFile]);
76
+ }, [storyName, fileName, loadFile, activeTab, dirty]);
77
+
78
+ const handleSave = useCallback(async () => {
79
+ if (!storyName || !fileName) return;
80
+ setSaving(true);
81
+ try {
82
+ const res = await authFetch(`/api/stories/${storyName}/${fileName}`, {
83
+ method: "PUT",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify({ content: editContent }),
86
+ });
87
+ if (res.ok) {
88
+ setDirty(false); dirtyRef.current = false;
89
+ setFileData((prev) => prev ? { ...prev, content: editContent } : prev);
90
+ }
91
+ } catch { /* ignore */ }
92
+ setSaving(false);
93
+ }, [storyName, fileName, authFetch, editContent]);
94
+
95
+ // Ctrl+S / Cmd+S to save
96
+ useEffect(() => {
97
+ if (activeTab !== "edit") return;
98
+ const handler = (e: KeyboardEvent) => {
99
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
100
+ e.preventDefault();
101
+ handleSave();
102
+ }
103
+ };
104
+ window.addEventListener("keydown", handler);
105
+ return () => window.removeEventListener("keydown", handler);
106
+ }, [activeTab, handleSave]);
107
+
108
+ // 5-minute countdown for Retry Index button
109
+ useEffect(() => {
110
+ if (fileData?.status !== "published-not-indexed" || !fileData.publishedAt) {
111
+ return;
112
+ }
113
+ const publishedAt = new Date(fileData.publishedAt).getTime();
114
+ const windowMs = 5 * 60 * 1000;
115
+ const update = () => {
116
+ const remaining = Math.max(0, windowMs - (Date.now() - publishedAt));
117
+ setIndexTimeLeft(remaining);
118
+ };
119
+ update();
120
+ const interval = setInterval(update, 1000);
121
+ return () => clearInterval(interval);
122
+ }, [fileData?.status, fileData?.publishedAt]);
123
+
124
+ const indexExpired = indexTimeLeft !== null && indexTimeLeft <= 0;
125
+ const indexCountdown = indexTimeLeft !== null && indexTimeLeft > 0
126
+ ? `${Math.floor(indexTimeLeft / 60000)}:${String(Math.floor((indexTimeLeft % 60000) / 1000)).padStart(2, "0")}`
127
+ : null;
50
128
 
51
129
  if (!storyName || !fileName) {
52
130
  return (
@@ -67,55 +145,211 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
67
145
  );
68
146
  }
69
147
 
70
- const charCount = fileData?.content?.length ?? 0;
148
+ const content = activeTab === "edit" ? editContent : (fileData?.content ?? "");
149
+ const charCount = content.length;
71
150
  const isGenesis = fileName === "genesis.md";
72
- const charLimit = isGenesis ? 1000 : 10000;
73
- const overLimit = charCount > charLimit;
151
+ const isPlot = fileName ? /^plot-\d+\.md$/.test(fileName) : false;
152
+ const isPublished = fileData?.status === "published" || fileData?.status === "published-not-indexed";
153
+ const charLimit = isGenesis ? 1000 : isPlot ? 10000 : null;
154
+ // Don't show over-limit warning for already-published files
155
+ const overLimit = !isPublished && charLimit !== null && charCount > charLimit;
74
156
 
75
157
  return (
76
158
  <div className="h-full flex flex-col">
77
- <div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
78
- <div className="flex items-center gap-2 text-xs font-mono text-muted">
79
- <span>{storyName}/{fileName}</span>
80
- {fileData?.status === "published" && (
81
- <span className="text-green-700 font-medium">Published</span>
82
- )}
83
- {fileData?.status === "pending" && (
84
- <span className="text-amber-700 font-medium">Pending</span>
85
- )}
159
+ {/* Header with file path + tabs */}
160
+ <div className="border-b border-border">
161
+ <div className="px-3 py-1.5 flex items-center justify-between">
162
+ <div className="flex items-center gap-2 text-xs font-mono text-muted">
163
+ <span>{storyName}/{fileName}</span>
164
+ {fileData?.status === "published" && (
165
+ <span className="text-green-700 font-medium">Published</span>
166
+ )}
167
+ {fileData?.status === "published-not-indexed" && (
168
+ <span className="text-amber-700 font-medium" title={fileData.indexError}>Published (not indexed)</span>
169
+ )}
170
+ {fileData?.status === "pending" && (
171
+ <span className="text-amber-700 font-medium">Pending</span>
172
+ )}
173
+ </div>
174
+ <div className="flex items-center gap-2">
175
+ <span className={`text-xs font-mono ${overLimit ? "text-error font-medium" : "text-muted"}`}>
176
+ {charCount.toLocaleString()}{charLimit !== null ? `/${charLimit.toLocaleString()}` : " chars"}
177
+ </span>
178
+ {overLimit && (
179
+ <span className="text-error text-xs font-medium">
180
+ {(charCount - charLimit).toLocaleString()} over limit
181
+ </span>
182
+ )}
183
+ </div>
86
184
  </div>
87
- <div className="flex items-center gap-2">
88
- <span className={`text-xs font-mono ${overLimit ? "text-error" : "text-muted"}`}>
89
- {charCount.toLocaleString()}/{charLimit.toLocaleString()}
90
- </span>
185
+
186
+ {/* Tabs */}
187
+ <div className="flex px-3 gap-1">
188
+ <button
189
+ onClick={() => setActiveTab("preview")}
190
+ className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
191
+ activeTab === "preview"
192
+ ? "border-accent text-accent"
193
+ : "border-transparent text-muted hover:text-foreground"
194
+ }`}
195
+ >
196
+ Preview
197
+ </button>
198
+ <button
199
+ onClick={() => setActiveTab("edit")}
200
+ className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
201
+ activeTab === "edit"
202
+ ? "border-accent text-accent"
203
+ : "border-transparent text-muted hover:text-foreground"
204
+ }`}
205
+ >
206
+ Edit
207
+ {dirty && <span className="ml-1 text-amber-600">*</span>}
208
+ </button>
91
209
  </div>
92
210
  </div>
93
211
 
94
- <div className="flex-1 min-h-0 overflow-y-auto px-6 py-4" style={{ background: "var(--paper-bg)" }}>
95
- {fileData?.content ? (
96
- <div className="prose max-w-none">
97
- <ReactMarkdown
98
- remarkPlugins={[remarkBreaks, remarkGfm]}
99
- rehypePlugins={[rehypeSanitize]}
212
+ {/* Content area */}
213
+ {activeTab === "preview" ? (
214
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 py-4" style={{ background: "var(--paper-bg)" }}>
215
+ {fileData?.content ? (
216
+ <div className="prose max-w-none">
217
+ <ReactMarkdown
218
+ remarkPlugins={[remarkBreaks, remarkGfm]}
219
+ rehypePlugins={[rehypeSanitize]}
220
+ >
221
+ {fileData.content}
222
+ </ReactMarkdown>
223
+ </div>
224
+ ) : (
225
+ <p className="text-muted italic">No content</p>
226
+ )}
227
+ </div>
228
+ ) : (
229
+ <div className="flex-1 min-h-0 flex flex-col" style={{ background: "var(--paper-bg)" }}>
230
+ <textarea
231
+ ref={textareaRef}
232
+ value={editContent}
233
+ onChange={(e) => { setEditContent(e.target.value); setDirty(true); dirtyRef.current = true; }}
234
+ className="flex-1 min-h-0 w-full resize-none px-4 py-3 text-sm leading-relaxed focus:outline-none"
235
+ style={{
236
+ fontFamily: '"Geist Mono", ui-monospace, monospace',
237
+ background: "var(--paper-bg)",
238
+ color: "var(--text)",
239
+ }}
240
+ spellCheck={false}
241
+ />
242
+ <div className="px-3 py-1.5 border-t border-border flex items-center justify-between">
243
+ <span className="text-xs text-muted">
244
+ {dirty ? "Unsaved changes" : "No changes"}
245
+ </span>
246
+ <button
247
+ onClick={handleSave}
248
+ disabled={!dirty || saving}
249
+ className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
100
250
  >
101
- {fileData.content}
102
- </ReactMarkdown>
251
+ {saving ? "Saving..." : "Save"}
252
+ </button>
103
253
  </div>
104
- ) : (
105
- <p className="text-muted italic">No content</p>
106
- )}
107
- </div>
254
+ </div>
255
+ )}
108
256
 
109
257
  {/* Action bar */}
110
258
  <div className="px-3 py-2 border-t border-border flex items-center justify-between">
111
259
  {fileName === "structure.md" ? (
112
260
  <p className="text-muted text-xs italic">This is your story outline — not publishable. Ask AI to write the genesis next.</p>
261
+ ) : fileData?.status === "published-not-indexed" ? (
262
+ <div className="flex flex-col gap-1">
263
+ <div className="flex items-center gap-2 text-xs">
264
+ <span className="text-amber-700">Published on-chain but not indexed on PlotLink</span>
265
+ {!indexExpired && (
266
+ <button
267
+ onClick={async () => {
268
+ if (!storyName || !fileName || !fileData.txHash) return;
269
+ setRetrying(true);
270
+ try {
271
+ const res = await authFetch("/api/publish/retry-index", {
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/json" },
274
+ body: JSON.stringify({
275
+ storyName, fileName,
276
+ txHash: fileData.txHash,
277
+ content: fileData.content,
278
+ storylineId: fileData.storylineId,
279
+ }),
280
+ });
281
+ const data = await res.json();
282
+ if (data.ok) {
283
+ await authFetch(`/api/stories/${storyName}/${fileName}/publish-status`, {
284
+ method: "POST",
285
+ headers: { "Content-Type": "application/json" },
286
+ body: JSON.stringify({
287
+ txHash: fileData.txHash,
288
+ storylineId: fileData.storylineId,
289
+ contentCid: "",
290
+ gasCost: "",
291
+ }),
292
+ });
293
+ loadFile();
294
+ }
295
+ } catch { /* ignore */ }
296
+ setRetrying(false);
297
+ }}
298
+ disabled={retrying}
299
+ className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50"
300
+ >
301
+ {retrying ? "Retrying..." : `Retry Index${indexCountdown ? ` (${indexCountdown})` : ""}`}
302
+ </button>
303
+ )}
304
+ {isPlot && (
305
+ <button
306
+ onClick={() => storyName && fileName && onPublish?.(storyName, fileName)}
307
+ disabled={!!publishingFile}
308
+ className="px-3 py-1 border border-border text-xs rounded hover:bg-surface disabled:opacity-50"
309
+ >
310
+ {publishingFile === fileName ? "Publishing..." : "Retry Publish"}
311
+ </button>
312
+ )}
313
+ {fileData.txHash && (
314
+ <a
315
+ href={`https://basescan.org/tx/${fileData.txHash}`}
316
+ target="_blank"
317
+ rel="noopener noreferrer"
318
+ className="text-muted underline"
319
+ >
320
+ BaseScan
321
+ </a>
322
+ )}
323
+ </div>
324
+ <p className="text-muted text-xs">
325
+ {indexExpired
326
+ ? isPlot
327
+ ? "Index window expired. Use Retry Publish to create a new on-chain tx."
328
+ : "Index window expired. Contact support or re-publish manually."
329
+ : isPlot
330
+ ? "Try Retry Index first (available for 5 min after publish). If that fails, Retry Publish creates a new on-chain tx."
331
+ : "Retry Index is available for 5 min after publish."}
332
+ </p>
333
+ {fileData.indexError && (
334
+ <p className="text-error text-xs">{fileData.indexError}</p>
335
+ )}
336
+ </div>
113
337
  ) : fileData?.status === "published" ? (
114
338
  <div className="flex items-center gap-2 text-xs">
115
339
  <span className="text-green-700">Published</span>
116
340
  {fileData.storylineId && (
117
341
  <a
118
- href={`https://plotlink.xyz/story/${fileData.storylineId}`}
342
+ href={(() => {
343
+ const base = `https://plotlink.xyz/story/${fileData.storylineId}`;
344
+ if (!isPlot) return base;
345
+ // plotIndex convention: contract emits 0-based (genesis=0, plot-01=1)
346
+ // plotlink.xyz URLs use the same 0-based index
347
+ // Filename fallback: plot-01.md → parseInt("01") = 1 (matches contract)
348
+ const idx = fileData.plotIndex != null && fileData.plotIndex > 0
349
+ ? fileData.plotIndex
350
+ : parseInt(fileName?.match(/^plot-(\d+)\.md$/)?.[1] ?? "1");
351
+ return `${base}/${idx}`;
352
+ })()}
119
353
  target="_blank"
120
354
  rel="noopener noreferrer"
121
355
  className="text-accent underline"
@@ -135,13 +369,18 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
135
369
  )}
136
370
  </div>
137
371
  ) : (
138
- <button
139
- onClick={() => storyName && fileName && onPublish?.(storyName, fileName)}
140
- disabled={!!publishingFile || fileData?.status === "published"}
141
- className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
142
- >
143
- {publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
144
- </button>
372
+ <div className="flex items-center gap-2">
373
+ <button
374
+ onClick={() => storyName && fileName && onPublish?.(storyName, fileName)}
375
+ disabled={!!publishingFile || overLimit}
376
+ className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
377
+ >
378
+ {publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
379
+ </button>
380
+ {overLimit && (
381
+ <span className="text-error text-xs">Reduce content to publish</span>
382
+ )}
383
+ </div>
145
384
  )}
146
385
  </div>
147
386
  </div>