plotlink-ows 0.1.18 → 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 +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 +16 -61
- package/package.json +7 -2
- 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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from "react";
|
|
1
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { WalletCard } from "./WalletCard";
|
|
3
3
|
|
|
4
4
|
export function Settings({ token, onLogout }: { token: string; onLogout: () => void }) {
|
|
@@ -8,8 +8,84 @@ export function Settings({ token, onLogout }: { token: string; onLogout: () => v
|
|
|
8
8
|
const [passphraseSuccess, setPassphraseSuccess] = useState(false);
|
|
9
9
|
const [savingPassphrase, setSavingPassphrase] = useState(false);
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Agent identity registration
|
|
12
|
+
const [linkStatus, setLinkStatus] = useState<{ linked: boolean; agentId?: number; owsWallet?: string; owner?: string } | null>(null);
|
|
13
|
+
const [agentName, setAgentName] = useState("AI Writer");
|
|
14
|
+
const [agentDescription, setAgentDescription] = useState("");
|
|
15
|
+
const [agentGenre, setAgentGenre] = useState("");
|
|
16
|
+
const [registering, setRegistering] = useState(false);
|
|
17
|
+
const [registerError, setRegisterError] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
// Link to PlotLink (binding proof for DB link)
|
|
20
|
+
const [humanWallet, setHumanWallet] = useState("");
|
|
21
|
+
const [bindingResult, setBindingResult] = useState<{ message: string; signature: string; owsWallet: string } | null>(null);
|
|
22
|
+
const [generating, setGenerating] = useState(false);
|
|
23
|
+
const [bindingError, setBindingError] = useState<string | null>(null);
|
|
24
|
+
const [copied, setCopied] = useState<"signature" | "wallet" | null>(null);
|
|
25
|
+
|
|
26
|
+
const authFetch = useCallback((url: string, opts?: RequestInit) =>
|
|
27
|
+
fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }),
|
|
28
|
+
[token]);
|
|
29
|
+
|
|
30
|
+
// Check link status on mount
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
authFetch("/api/settings/link-status")
|
|
33
|
+
.then((r) => r.json())
|
|
34
|
+
.then((data) => setLinkStatus(data))
|
|
35
|
+
.catch(() => setLinkStatus({ linked: false }));
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const handleRegisterAgent = async () => {
|
|
40
|
+
if (!agentName.trim()) { setRegisterError("Agent name is required"); return; }
|
|
41
|
+
if (!agentDescription.trim()) { setRegisterError("Description is required"); return; }
|
|
42
|
+
setRegistering(true);
|
|
43
|
+
setRegisterError(null);
|
|
44
|
+
try {
|
|
45
|
+
const res = await authFetch("/api/settings/register-agent", {
|
|
46
|
+
method: "POST",
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
name: agentName,
|
|
49
|
+
description: agentDescription,
|
|
50
|
+
...(agentGenre.trim() && { genre: agentGenre }),
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
if (!res.ok) throw new Error(data.error || "Registration failed");
|
|
55
|
+
setLinkStatus({ linked: true, agentId: data.agentId, owsWallet: data.owsWallet });
|
|
56
|
+
} catch (err: unknown) {
|
|
57
|
+
setRegisterError(err instanceof Error ? err.message : "Registration failed");
|
|
58
|
+
}
|
|
59
|
+
setRegistering(false);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleGenerateBinding = async () => {
|
|
63
|
+
if (!humanWallet.trim() || !/^0x[a-fA-F0-9]{40}$/.test(humanWallet)) {
|
|
64
|
+
setBindingError("Enter a valid wallet address (0x...)");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setGenerating(true);
|
|
68
|
+
setBindingError(null);
|
|
69
|
+
setBindingResult(null);
|
|
70
|
+
try {
|
|
71
|
+
const res = await authFetch("/api/settings/generate-binding", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
body: JSON.stringify({ humanWallet }),
|
|
74
|
+
});
|
|
75
|
+
const data = await res.json();
|
|
76
|
+
if (!res.ok) throw new Error(data.error || "Failed to generate binding code");
|
|
77
|
+
setBindingResult(data);
|
|
78
|
+
} catch (err: unknown) {
|
|
79
|
+
setBindingError(err instanceof Error ? err.message : "Failed to generate binding code");
|
|
80
|
+
}
|
|
81
|
+
setGenerating(false);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const copyToClipboard = async (text: string, field: "signature" | "wallet") => {
|
|
85
|
+
await navigator.clipboard.writeText(text);
|
|
86
|
+
setCopied(field);
|
|
87
|
+
setTimeout(() => setCopied(null), 2000);
|
|
88
|
+
};
|
|
13
89
|
|
|
14
90
|
const handleResetPassphrase = async () => {
|
|
15
91
|
setPassphraseError(null);
|
|
@@ -46,6 +122,154 @@ export function Settings({ token, onLogout }: { token: string; onLogout: () => v
|
|
|
46
122
|
<div className="mx-auto max-w-lg space-y-6 p-6">
|
|
47
123
|
<h2 className="text-accent text-lg font-bold">Settings</h2>
|
|
48
124
|
|
|
125
|
+
{/* Agent Identity */}
|
|
126
|
+
<div className="border-border rounded border p-4">
|
|
127
|
+
<h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">Agent Identity</h3>
|
|
128
|
+
{linkStatus?.linked ? (
|
|
129
|
+
<div className="space-y-2">
|
|
130
|
+
<div className="flex items-center gap-2">
|
|
131
|
+
<span className="text-sm font-medium text-accent">Registered</span>
|
|
132
|
+
<span className="text-muted text-xs">Agent #{linkStatus.agentId}</span>
|
|
133
|
+
</div>
|
|
134
|
+
{linkStatus.owsWallet && (
|
|
135
|
+
<p className="text-muted text-xs font-mono">
|
|
136
|
+
Wallet: {linkStatus.owsWallet.slice(0, 6)}...{linkStatus.owsWallet.slice(-4)}
|
|
137
|
+
</p>
|
|
138
|
+
)}
|
|
139
|
+
{linkStatus.owner && (
|
|
140
|
+
<p className="text-muted text-xs font-mono">
|
|
141
|
+
Owner: {linkStatus.owner.slice(0, 6)}...{linkStatus.owner.slice(-4)}
|
|
142
|
+
</p>
|
|
143
|
+
)}
|
|
144
|
+
<p className="text-muted text-xs">
|
|
145
|
+
<a href={`https://plotlink.xyz/agents/${linkStatus.agentId}`} target="_blank" rel="noopener noreferrer" className="text-accent underline">
|
|
146
|
+
View agent profile on plotlink.xyz
|
|
147
|
+
</a>
|
|
148
|
+
</p>
|
|
149
|
+
</div>
|
|
150
|
+
) : (
|
|
151
|
+
<div className="space-y-3">
|
|
152
|
+
<p className="text-muted text-xs">
|
|
153
|
+
Register this AI writer on-chain via ERC-8004. Uses your OWS wallet's existing ETH balance for gas.
|
|
154
|
+
</p>
|
|
155
|
+
|
|
156
|
+
<div>
|
|
157
|
+
<label className="text-muted text-xs block mb-1">Name</label>
|
|
158
|
+
<input
|
|
159
|
+
value={agentName}
|
|
160
|
+
onChange={(e) => setAgentName(e.target.value)}
|
|
161
|
+
placeholder="AI Writer"
|
|
162
|
+
className="bg-surface border-border text-foreground placeholder:text-muted/50 w-full rounded border px-3 py-2 text-sm outline-none focus:border-accent"
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div>
|
|
167
|
+
<label className="text-muted text-xs block mb-1">Description</label>
|
|
168
|
+
<input
|
|
169
|
+
value={agentDescription}
|
|
170
|
+
onChange={(e) => setAgentDescription(e.target.value)}
|
|
171
|
+
placeholder="An AI writing assistant for fiction stories"
|
|
172
|
+
className="bg-surface border-border text-foreground placeholder:text-muted/50 w-full rounded border px-3 py-2 text-sm outline-none focus:border-accent"
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div>
|
|
177
|
+
<label className="text-muted text-xs block mb-1">Genre (optional)</label>
|
|
178
|
+
<input
|
|
179
|
+
value={agentGenre}
|
|
180
|
+
onChange={(e) => setAgentGenre(e.target.value)}
|
|
181
|
+
placeholder="e.g. Fiction, Sci-Fi, Fantasy"
|
|
182
|
+
className="bg-surface border-border text-foreground placeholder:text-muted/50 w-full rounded border px-3 py-2 text-sm outline-none focus:border-accent"
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{registerError && <p className="text-error text-xs">{registerError}</p>}
|
|
187
|
+
|
|
188
|
+
<button
|
|
189
|
+
onClick={handleRegisterAgent}
|
|
190
|
+
disabled={registering || !agentName.trim() || !agentDescription.trim()}
|
|
191
|
+
className="bg-accent text-white hover:bg-accent-dim disabled:opacity-50 w-full rounded px-4 py-2 text-sm font-medium transition-colors"
|
|
192
|
+
>
|
|
193
|
+
{registering ? "Registering..." : "Register Agent Identity"}
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{/* Link to PlotLink */}
|
|
200
|
+
<div className="border-border rounded border p-4">
|
|
201
|
+
<h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">Link to PlotLink</h3>
|
|
202
|
+
{linkStatus?.owner ? (
|
|
203
|
+
<p className="text-muted text-xs">
|
|
204
|
+
Linked to owner <span className="font-mono">{linkStatus.owner.slice(0, 6)}...{linkStatus.owner.slice(-4)}</span>
|
|
205
|
+
</p>
|
|
206
|
+
) : (
|
|
207
|
+
<div className="space-y-3">
|
|
208
|
+
<p className="text-muted text-xs">
|
|
209
|
+
Link this OWS wallet to your PlotLink account so your stories appear under your profile on plotlink.xyz.
|
|
210
|
+
</p>
|
|
211
|
+
<div className="text-muted text-xs space-y-1 pl-3">
|
|
212
|
+
<p>1. Enter your PlotLink wallet address below</p>
|
|
213
|
+
<p>2. Click "Generate Binding Code"</p>
|
|
214
|
+
<p>3. Copy the code and paste it on plotlink.xyz → Agents → Link AI Writer</p>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<input
|
|
218
|
+
value={humanWallet}
|
|
219
|
+
onChange={(e) => setHumanWallet(e.target.value)}
|
|
220
|
+
placeholder="Your PlotLink wallet address (0x...)"
|
|
221
|
+
className="bg-surface border-border text-foreground placeholder:text-muted/50 w-full rounded border px-3 py-2 text-sm outline-none focus:border-accent font-mono"
|
|
222
|
+
/>
|
|
223
|
+
|
|
224
|
+
{bindingError && <p className="text-error text-xs">{bindingError}</p>}
|
|
225
|
+
|
|
226
|
+
<button
|
|
227
|
+
onClick={handleGenerateBinding}
|
|
228
|
+
disabled={generating || !humanWallet.trim()}
|
|
229
|
+
className="bg-accent text-white hover:bg-accent-dim disabled:opacity-50 w-full rounded px-4 py-2 text-sm font-medium transition-colors"
|
|
230
|
+
>
|
|
231
|
+
{generating ? "Generating..." : "Generate Binding Code"}
|
|
232
|
+
</button>
|
|
233
|
+
|
|
234
|
+
{bindingResult && (
|
|
235
|
+
<div className="space-y-3 mt-3">
|
|
236
|
+
<div>
|
|
237
|
+
<label className="text-muted text-xs block mb-1">Binding Code (signature)</label>
|
|
238
|
+
<div className="relative">
|
|
239
|
+
<div className="bg-surface border-border rounded border p-2 text-xs font-mono break-all text-foreground pr-16">
|
|
240
|
+
{bindingResult.signature}
|
|
241
|
+
</div>
|
|
242
|
+
<button
|
|
243
|
+
onClick={() => copyToClipboard(bindingResult.signature, "signature")}
|
|
244
|
+
className="absolute top-1 right-1 text-xs px-2 py-1 rounded border border-border text-muted hover:text-accent hover:border-accent transition-colors"
|
|
245
|
+
>
|
|
246
|
+
{copied === "signature" ? "Copied!" : "Copy"}
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
<div>
|
|
251
|
+
<label className="text-muted text-xs block mb-1">OWS Wallet Address</label>
|
|
252
|
+
<div className="relative">
|
|
253
|
+
<div className="bg-surface border-border rounded border p-2 text-xs font-mono break-all text-foreground pr-16">
|
|
254
|
+
{bindingResult.owsWallet}
|
|
255
|
+
</div>
|
|
256
|
+
<button
|
|
257
|
+
onClick={() => copyToClipboard(bindingResult.owsWallet, "wallet")}
|
|
258
|
+
className="absolute top-1 right-1 text-xs px-2 py-1 rounded border border-border text-muted hover:text-accent hover:border-accent transition-colors"
|
|
259
|
+
>
|
|
260
|
+
{copied === "wallet" ? "Copied!" : "Copy"}
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
<p className="text-xs text-accent">
|
|
265
|
+
Now go to plotlink.xyz/agents and paste both values in the "Link AI Writer" section.
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
|
|
49
273
|
{/* Wallet */}
|
|
50
274
|
<WalletCard token={token} />
|
|
51
275
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
2
|
import { StoryBrowser } from "./StoryBrowser";
|
|
3
3
|
import { TerminalPanel } from "./TerminalPanel";
|
|
4
4
|
import { PreviewPanel } from "./PreviewPanel";
|
|
@@ -8,17 +8,115 @@ interface StoriesPageProps {
|
|
|
8
8
|
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
const STORAGE_KEY = "plotlink-panel-ratio";
|
|
12
|
+
const DEFAULT_RATIO = 0.6; // terminal gets 60% of available space
|
|
13
|
+
const MIN_PANEL_PX = 300;
|
|
14
|
+
const SIDEBAR_PX = 224; // w-56
|
|
15
|
+
const HANDLE_PX = 6;
|
|
16
|
+
|
|
17
|
+
function loadRatio(): number {
|
|
18
|
+
try {
|
|
19
|
+
const v = localStorage.getItem(STORAGE_KEY);
|
|
20
|
+
if (v) {
|
|
21
|
+
const n = parseFloat(v);
|
|
22
|
+
if (n > 0 && n < 1) return n;
|
|
23
|
+
}
|
|
24
|
+
} catch { /* ignore */ }
|
|
25
|
+
return DEFAULT_RATIO;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function clampRatio(r: number, available: number): number {
|
|
29
|
+
if (available <= 0) return r;
|
|
30
|
+
const minR = MIN_PANEL_PX / available;
|
|
31
|
+
const maxR = 1 - MIN_PANEL_PX / available;
|
|
32
|
+
if (minR >= maxR) return 0.5; // panels can't both fit, split evenly
|
|
33
|
+
return Math.min(maxR, Math.max(minR, r));
|
|
34
|
+
}
|
|
35
|
+
|
|
11
36
|
export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
12
37
|
const [selectedStory, setSelectedStory] = useState<string | null>(null);
|
|
13
38
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
14
39
|
const [publishingFile, setPublishingFile] = useState<string | null>(null);
|
|
15
40
|
const [publishProgress, setPublishProgress] = useState<string>("");
|
|
41
|
+
const [ratio, setRatio] = useState(loadRatio);
|
|
42
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
const dragging = useRef(false);
|
|
44
|
+
|
|
45
|
+
// Persist ratio to localStorage
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
try { localStorage.setItem(STORAGE_KEY, String(ratio)); } catch { /* ignore */ }
|
|
48
|
+
}, [ratio]);
|
|
49
|
+
|
|
50
|
+
// Clamp ratio on window resize so panels stay above MIN_PANEL_PX
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const onResize = () => {
|
|
53
|
+
if (!containerRef.current) return;
|
|
54
|
+
const available = containerRef.current.getBoundingClientRect().width - SIDEBAR_PX - HANDLE_PX;
|
|
55
|
+
setRatio((prev) => clampRatio(prev, available));
|
|
56
|
+
};
|
|
57
|
+
window.addEventListener("resize", onResize);
|
|
58
|
+
// Also clamp on mount in case stored ratio is out of range for current window
|
|
59
|
+
onResize();
|
|
60
|
+
return () => window.removeEventListener("resize", onResize);
|
|
61
|
+
}, []);
|
|
16
62
|
|
|
17
63
|
const handleSelectFile = useCallback((storyName: string, fileName: string) => {
|
|
18
64
|
setSelectedStory(storyName);
|
|
19
65
|
setSelectedFile(fileName);
|
|
20
66
|
}, []);
|
|
21
67
|
|
|
68
|
+
const latestStoryRef = useRef<string | null>(null);
|
|
69
|
+
|
|
70
|
+
const handleSelectStory = useCallback(async (name: string) => {
|
|
71
|
+
latestStoryRef.current = name;
|
|
72
|
+
setSelectedStory(name);
|
|
73
|
+
setSelectedFile(null);
|
|
74
|
+
// Auto-select latest file for this story
|
|
75
|
+
try {
|
|
76
|
+
const res = await authFetch(`/api/stories/${name}`);
|
|
77
|
+
if (res.ok && latestStoryRef.current === name) {
|
|
78
|
+
const data = await res.json();
|
|
79
|
+
const files: { file: string }[] = data.files || [];
|
|
80
|
+
// Priority: highest plot → genesis → structure → first
|
|
81
|
+
const plots = files
|
|
82
|
+
.map((f) => ({ file: f.file, num: f.file.match(/^plot-(\d+)\.md$/)?.[1] }))
|
|
83
|
+
.filter((p) => p.num != null)
|
|
84
|
+
.sort((a, b) => parseInt(b.num!) - parseInt(a.num!));
|
|
85
|
+
const latest = plots[0]?.file
|
|
86
|
+
?? (files.find((f) => f.file === "genesis.md")?.file)
|
|
87
|
+
?? (files.find((f) => f.file === "structure.md")?.file)
|
|
88
|
+
?? files[0]?.file;
|
|
89
|
+
if (latest && latestStoryRef.current === name) setSelectedFile(latest);
|
|
90
|
+
}
|
|
91
|
+
} catch { /* ignore */ }
|
|
92
|
+
}, [authFetch]);
|
|
93
|
+
|
|
94
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
dragging.current = true;
|
|
97
|
+
document.body.style.cursor = "col-resize";
|
|
98
|
+
document.body.style.userSelect = "none";
|
|
99
|
+
|
|
100
|
+
const onMouseMove = (ev: MouseEvent) => {
|
|
101
|
+
if (!dragging.current || !containerRef.current) return;
|
|
102
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
103
|
+
const available = rect.width - SIDEBAR_PX - HANDLE_PX;
|
|
104
|
+
const x = ev.clientX - rect.left - SIDEBAR_PX;
|
|
105
|
+
setRatio(clampRatio(x / available, available));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const onMouseUp = () => {
|
|
109
|
+
dragging.current = false;
|
|
110
|
+
document.body.style.cursor = "";
|
|
111
|
+
document.body.style.userSelect = "";
|
|
112
|
+
window.removeEventListener("mousemove", onMouseMove);
|
|
113
|
+
window.removeEventListener("mouseup", onMouseUp);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
window.addEventListener("mousemove", onMouseMove);
|
|
117
|
+
window.addEventListener("mouseup", onMouseUp);
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
22
120
|
const handlePublish = useCallback(async (storyName: string, fileName: string) => {
|
|
23
121
|
setPublishingFile(fileName);
|
|
24
122
|
setPublishProgress("Reading file...");
|
|
@@ -89,7 +187,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
89
187
|
for (const line of lines) {
|
|
90
188
|
try {
|
|
91
189
|
const data = JSON.parse(line.slice(6));
|
|
92
|
-
if (data.step) setPublishProgress(data.step);
|
|
190
|
+
if (data.step) setPublishProgress(data.message || data.step);
|
|
93
191
|
if (data.step === "done" && data.txHash) {
|
|
94
192
|
// Update publish status with gasCost
|
|
95
193
|
await authFetch(`/api/stories/${storyName}/${fileName}/publish-status`, {
|
|
@@ -98,8 +196,10 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
98
196
|
body: JSON.stringify({
|
|
99
197
|
txHash: data.txHash,
|
|
100
198
|
storylineId: data.storylineId,
|
|
199
|
+
plotIndex: data.plotIndex,
|
|
101
200
|
contentCid: data.contentCid,
|
|
102
201
|
gasCost: data.gasCost,
|
|
202
|
+
indexError: data.indexError,
|
|
103
203
|
}),
|
|
104
204
|
});
|
|
105
205
|
}
|
|
@@ -121,7 +221,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
121
221
|
}, [authFetch]);
|
|
122
222
|
|
|
123
223
|
return (
|
|
124
|
-
<div className="h-[calc(100vh-3.5rem)] flex">
|
|
224
|
+
<div ref={containerRef} className="h-[calc(100vh-3.5rem)] flex">
|
|
125
225
|
{/* Story Browser Sidebar */}
|
|
126
226
|
<div className="w-56 border-r border-border flex-shrink-0">
|
|
127
227
|
<StoryBrowser
|
|
@@ -132,13 +232,26 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
132
232
|
/>
|
|
133
233
|
</div>
|
|
134
234
|
|
|
135
|
-
{/* Terminal */}
|
|
136
|
-
<div className="
|
|
137
|
-
<TerminalPanel token={token} />
|
|
235
|
+
{/* Terminal — sized by ratio of available space */}
|
|
236
|
+
<div className="min-w-0 border-r border-border" style={{ flex: `${ratio} 0 0` }}>
|
|
237
|
+
<TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} />
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
{/* Drag Handle */}
|
|
241
|
+
<div
|
|
242
|
+
onMouseDown={handleMouseDown}
|
|
243
|
+
className="flex-shrink-0 flex items-center justify-center hover:bg-border/50 transition-colors"
|
|
244
|
+
style={{ width: HANDLE_PX, cursor: "col-resize", background: "var(--border)" }}
|
|
245
|
+
>
|
|
246
|
+
<div className="flex flex-col gap-1">
|
|
247
|
+
<div className="w-0.5 h-0.5 rounded-full" style={{ background: "var(--text-muted)" }} />
|
|
248
|
+
<div className="w-0.5 h-0.5 rounded-full" style={{ background: "var(--text-muted)" }} />
|
|
249
|
+
<div className="w-0.5 h-0.5 rounded-full" style={{ background: "var(--text-muted)" }} />
|
|
250
|
+
</div>
|
|
138
251
|
</div>
|
|
139
252
|
|
|
140
|
-
{/* Preview */}
|
|
141
|
-
<div className="w-
|
|
253
|
+
{/* Preview — takes remaining space */}
|
|
254
|
+
<div className="min-w-0 flex flex-col" style={{ flex: `${1 - ratio} 0 0` }}>
|
|
142
255
|
<PreviewPanel
|
|
143
256
|
storyName={selectedStory}
|
|
144
257
|
fileName={selectedFile}
|
|
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react";
|
|
|
2
2
|
|
|
3
3
|
interface FileStatus {
|
|
4
4
|
file: string;
|
|
5
|
-
status: "published" | "pending" | "draft";
|
|
5
|
+
status: "published" | "published-not-indexed" | "pending" | "draft";
|
|
6
6
|
txHash?: string;
|
|
7
7
|
storylineId?: number;
|
|
8
8
|
}
|
|
@@ -24,15 +24,17 @@ interface StoryBrowserProps {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const STATUS_ICON: Record<string, string> = {
|
|
27
|
-
published: "\u2713",
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
"published": "\u2713",
|
|
28
|
+
"published-not-indexed": "\u26A0",
|
|
29
|
+
"pending": "\u23F3",
|
|
30
|
+
"draft": "\uD83D\uDCDD",
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
const STATUS_COLOR: Record<string, string> = {
|
|
33
|
-
published: "text-green-700",
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
"published": "text-green-700",
|
|
35
|
+
"published-not-indexed": "text-amber-700",
|
|
36
|
+
"pending": "text-amber-700",
|
|
37
|
+
"draft": "text-muted",
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectFile }: StoryBrowserProps) {
|
|
@@ -64,6 +66,19 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
64
66
|
}
|
|
65
67
|
}, [selectedStory]);
|
|
66
68
|
|
|
69
|
+
const getLatestFile = (files: FileStatus[]): string | null => {
|
|
70
|
+
// Latest plot by highest number
|
|
71
|
+
const plots = files
|
|
72
|
+
.map((f) => ({ file: f.file, num: f.file.match(/^plot-(\d+)\.md$/)?.[1] }))
|
|
73
|
+
.filter((p) => p.num != null)
|
|
74
|
+
.sort((a, b) => parseInt(b.num!) - parseInt(a.num!));
|
|
75
|
+
if (plots.length > 0) return plots[0].file;
|
|
76
|
+
// Fallback: genesis, then structure
|
|
77
|
+
if (files.some((f) => f.file === "genesis.md")) return "genesis.md";
|
|
78
|
+
if (files.some((f) => f.file === "structure.md")) return "structure.md";
|
|
79
|
+
return files[0]?.file ?? null;
|
|
80
|
+
};
|
|
81
|
+
|
|
67
82
|
const toggleExpand = (name: string) => {
|
|
68
83
|
setExpanded((prev) => {
|
|
69
84
|
const next = new Set(prev);
|
|
@@ -73,6 +88,15 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
73
88
|
});
|
|
74
89
|
};
|
|
75
90
|
|
|
91
|
+
const handleStoryClick = (story: StoryInfo) => {
|
|
92
|
+
toggleExpand(story.name);
|
|
93
|
+
// Auto-select latest file when expanding (not when collapsing)
|
|
94
|
+
if (!expanded.has(story.name)) {
|
|
95
|
+
const latest = getLatestFile(story.files);
|
|
96
|
+
if (latest) onSelectFile(story.name, latest);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
76
100
|
// Sort files: structure first, genesis, then plots in order
|
|
77
101
|
const sortFiles = (files: FileStatus[]) => {
|
|
78
102
|
const order = (f: string) => {
|
|
@@ -100,7 +124,7 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
100
124
|
stories.filter((s) => s.name !== "_example").map((story) => (
|
|
101
125
|
<div key={story.name}>
|
|
102
126
|
<button
|
|
103
|
-
onClick={() =>
|
|
127
|
+
onClick={() => handleStoryClick(story)}
|
|
104
128
|
className="w-full px-3 py-2 text-left flex items-center gap-2 hover:bg-surface text-sm"
|
|
105
129
|
>
|
|
106
130
|
<span className="text-xs text-muted">{expanded.has(story.name) ? "\u25BC" : "\u25B6"}</span>
|