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
@@ -0,0 +1,388 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkBreaks from "remark-breaks";
4
+ import remarkGfm from "remark-gfm";
5
+ import rehypeSanitize from "rehype-sanitize";
6
+
7
+ interface PreviewPanelProps {
8
+ storyName: string | null;
9
+ fileName: string | null;
10
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
11
+ onPublish?: (storyName: string, fileName: string) => void;
12
+ publishingFile?: string | null;
13
+ }
14
+
15
+ interface FileData {
16
+ file: string;
17
+ status: "published" | "published-not-indexed" | "pending" | "draft";
18
+ content: string;
19
+ txHash?: string;
20
+ storylineId?: number;
21
+ plotIndex?: number;
22
+ indexError?: string;
23
+ publishedAt?: string;
24
+ }
25
+
26
+ type Tab = "preview" | "edit";
27
+
28
+ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publishingFile }: PreviewPanelProps) {
29
+ const [fileData, setFileData] = useState<FileData | null>(null);
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);
41
+
42
+ const loadFile = useCallback(async () => {
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
+ }
49
+ try {
50
+ const res = await authFetch(`/api/stories/${storyName}/${fileName}`);
51
+ if (res.ok) {
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
+ }
59
+ }
60
+ } catch { /* ignore */ }
61
+ }, [storyName, fileName, authFetch]);
62
+
63
+ // Initial load
64
+ useEffect(() => {
65
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch on mount
66
+ setLoading(true);
67
+ loadFile().finally(() => setLoading(false));
68
+ }, [loadFile]);
69
+
70
+ // Auto-refresh every 3 seconds (only in preview mode when not dirty)
71
+ useEffect(() => {
72
+ if (!storyName || !fileName) return;
73
+ if (activeTab === "edit" && dirty) return;
74
+ const interval = setInterval(loadFile, 3000);
75
+ return () => clearInterval(interval);
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;
128
+
129
+ if (!storyName || !fileName) {
130
+ return (
131
+ <div className="h-full flex items-center justify-center text-muted">
132
+ <div className="text-center">
133
+ <p className="text-lg font-serif">Select a file to preview</p>
134
+ <p className="text-sm mt-1">Click a story file in the sidebar</p>
135
+ </div>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ if (loading && !fileData) {
141
+ return (
142
+ <div className="h-full flex items-center justify-center text-muted">
143
+ Loading...
144
+ </div>
145
+ );
146
+ }
147
+
148
+ const content = activeTab === "edit" ? editContent : (fileData?.content ?? "");
149
+ const charCount = content.length;
150
+ const isGenesis = fileName === "genesis.md";
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;
156
+
157
+ return (
158
+ <div className="h-full flex flex-col">
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>
184
+ </div>
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>
209
+ </div>
210
+ </div>
211
+
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"
250
+ >
251
+ {saving ? "Saving..." : "Save"}
252
+ </button>
253
+ </div>
254
+ </div>
255
+ )}
256
+
257
+ {/* Action bar */}
258
+ <div className="px-3 py-2 border-t border-border flex items-center justify-between">
259
+ {fileName === "structure.md" ? (
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>
337
+ ) : fileData?.status === "published" ? (
338
+ <div className="flex items-center gap-2 text-xs">
339
+ <span className="text-green-700">Published</span>
340
+ {fileData.storylineId && (
341
+ <a
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
+ })()}
353
+ target="_blank"
354
+ rel="noopener noreferrer"
355
+ className="text-accent underline"
356
+ >
357
+ View on PlotLink
358
+ </a>
359
+ )}
360
+ {fileData.txHash && (
361
+ <a
362
+ href={`https://basescan.org/tx/${fileData.txHash}`}
363
+ target="_blank"
364
+ rel="noopener noreferrer"
365
+ className="text-muted underline"
366
+ >
367
+ BaseScan
368
+ </a>
369
+ )}
370
+ </div>
371
+ ) : (
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>
384
+ )}
385
+ </div>
386
+ </div>
387
+ );
388
+ }