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.
- package/README.md +185 -93
- package/app/db.ts +1 -1
- package/app/lib/paths.ts +0 -2
- package/app/lib/publish.ts +257 -44
- package/app/prisma/schema.prisma +0 -36
- package/app/routes/dashboard.ts +105 -57
- package/app/routes/publish.ts +107 -25
- package/app/routes/settings.ts +194 -0
- package/app/routes/stories.ts +223 -0
- package/app/routes/terminal.ts +258 -0
- package/app/routes/wallet.ts +40 -10
- package/app/server.ts +35 -81
- package/app/web/App.tsx +4 -6
- package/app/web/components/Dashboard.tsx +98 -79
- package/app/web/components/Layout.tsx +70 -103
- package/app/web/components/PreviewPanel.tsx +388 -0
- package/app/web/components/Settings.tsx +210 -67
- package/app/web/components/StoriesPage.tsx +270 -0
- package/app/web/components/StoryBrowser.tsx +161 -0
- package/app/web/components/TerminalPanel.tsx +428 -0
- package/app/web/components/WalletCard.tsx +14 -8
- package/app/web/dist/assets/index-BuOxhUWG.css +32 -0
- package/app/web/dist/assets/index-De8CpT47.js +129 -0
- package/app/web/dist/index.html +3 -3
- package/app/web/dist/plotlink-logo.svg +5 -0
- package/app/web/public/plotlink-logo.svg +5 -0
- package/app/web/styles.css +18 -0
- package/bin/plotlink-ows.js +18 -62
- package/package.json +15 -6
- package/scripts/fix-index-status.ts +93 -0
- package/app/lib/llm-client.ts +0 -265
- package/app/lib/writer-prompt.ts +0 -44
- package/app/routes/chat.ts +0 -135
- package/app/routes/config.ts +0 -210
- package/app/routes/oauth.ts +0 -150
- package/app/web/components/Chat.tsx +0 -272
- package/app/web/components/LLMSetup.tsx +0 -291
- package/app/web/components/Publish.tsx +0 -245
- package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
- 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
|
+
}
|