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/README.md +167 -67
- package/app/lib/publish.ts +134 -32
- package/app/routes/dashboard.ts +64 -13
- package/app/routes/publish.ts +52 -1
- package/app/routes/settings.ts +194 -0
- package/app/routes/stories.ts +75 -8
- package/app/routes/terminal.ts +167 -63
- package/app/server.ts +7 -1
- package/app/web/components/Dashboard.tsx +83 -32
- package/app/web/components/PreviewPanel.tsx +280 -41
- package/app/web/components/Settings.tsx +227 -3
- package/app/web/components/StoriesPage.tsx +121 -8
- package/app/web/components/StoryBrowser.tsx +32 -8
- package/app/web/components/TerminalPanel.tsx +384 -78
- 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 +2 -2
- package/app/web/styles.css +18 -0
- package/bin/plotlink-ows.js +21 -61
- package/package.json +21 -15
- package/scripts/fix-index-status.ts +93 -0
- package/app/web/dist/assets/index-D5gfwaEX.css +0 -32
- package/app/web/dist/assets/index-pBt5Q_bN.js +0 -117
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
|
|
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
|
-
|
|
17
|
-
txHash?: string | null;
|
|
24
|
+
storyName: string;
|
|
18
25
|
storylineId?: number | null;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
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
|
|
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-
|
|
158
|
+
<div className="space-y-3">
|
|
144
159
|
{data.stories.published.map((story) => (
|
|
145
|
-
<div key={story.id} className="bg-surface rounded p-
|
|
146
|
-
<div className="flex items-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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-
|
|
168
|
-
<span className="text-muted">{formatDate(story.
|
|
169
|
-
{story.
|
|
170
|
-
<a
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
148
|
+
const content = activeTab === "edit" ? editContent : (fileData?.content ?? "");
|
|
149
|
+
const charCount = content.length;
|
|
71
150
|
const isGenesis = fileName === "genesis.md";
|
|
72
|
-
const
|
|
73
|
-
const
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<span
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
{
|
|
102
|
-
</
|
|
251
|
+
{saving ? "Saving..." : "Save"}
|
|
252
|
+
</button>
|
|
103
253
|
</div>
|
|
104
|
-
|
|
105
|
-
|
|
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={
|
|
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
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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>
|