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
|
@@ -1,42 +1,90 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
1
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { WalletCard } from "./WalletCard";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export function Settings({ token, onLogout, onChangeLLM }: { token: string; onLogout: () => void; onChangeLLM: () => void }) {
|
|
7
|
-
const [llmConfig, setLlmConfig] = useState<{ llm: Record<string, unknown>; configured: string[] } | null>(null);
|
|
8
|
-
const [spendCap, setSpendCap] = useState<string>("10");
|
|
9
|
-
const [savingCap, setSavingCap] = useState(false);
|
|
10
|
-
const [capSaved, setCapSaved] = useState(false);
|
|
4
|
+
export function Settings({ token, onLogout }: { token: string; onLogout: () => void }) {
|
|
11
5
|
const [newPassphrase, setNewPassphrase] = useState("");
|
|
12
6
|
const [confirmPassphrase, setConfirmPassphrase] = useState("");
|
|
13
7
|
const [passphraseError, setPassphraseError] = useState<string | null>(null);
|
|
14
8
|
const [passphraseSuccess, setPassphraseSuccess] = useState(false);
|
|
15
9
|
const [savingPassphrase, setSavingPassphrase] = useState(false);
|
|
16
10
|
|
|
17
|
-
|
|
18
|
-
|
|
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);
|
|
19
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
|
|
20
31
|
useEffect(() => {
|
|
21
|
-
authFetch(
|
|
32
|
+
authFetch("/api/settings/link-status")
|
|
22
33
|
.then((r) => r.json())
|
|
23
|
-
.then((data) =>
|
|
34
|
+
.then((data) => setLinkStatus(data))
|
|
35
|
+
.catch(() => setLinkStatus({ linked: false }));
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
24
37
|
}, []);
|
|
25
38
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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);
|
|
40
88
|
};
|
|
41
89
|
|
|
42
90
|
const handleResetPassphrase = async () => {
|
|
@@ -52,7 +100,7 @@ export function Settings({ token, onLogout, onChangeLLM }: { token: string; onLo
|
|
|
52
100
|
}
|
|
53
101
|
setSavingPassphrase(true);
|
|
54
102
|
try {
|
|
55
|
-
const res = await authFetch(
|
|
103
|
+
const res = await authFetch("/api/auth/reset-passphrase", {
|
|
56
104
|
method: "POST",
|
|
57
105
|
body: JSON.stringify({ passphrase: newPassphrase }),
|
|
58
106
|
});
|
|
@@ -74,62 +122,157 @@ export function Settings({ token, onLogout, onChangeLLM }: { token: string; onLo
|
|
|
74
122
|
<div className="mx-auto max-w-lg space-y-6 p-6">
|
|
75
123
|
<h2 className="text-accent text-lg font-bold">Settings</h2>
|
|
76
124
|
|
|
77
|
-
{/*
|
|
125
|
+
{/* Agent Identity */}
|
|
78
126
|
<div className="border-border rounded border p-4">
|
|
79
|
-
<h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">
|
|
80
|
-
{
|
|
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
|
+
) : (
|
|
81
151
|
<div className="space-y-3">
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
/>
|
|
85
164
|
</div>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
<
|
|
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
|
+
/>
|
|
89
174
|
</div>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<
|
|
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
|
+
/>
|
|
93
184
|
</div>
|
|
185
|
+
|
|
186
|
+
{registerError && <p className="text-error text-xs">{registerError}</p>}
|
|
187
|
+
|
|
94
188
|
<button
|
|
95
|
-
onClick={
|
|
96
|
-
|
|
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"
|
|
97
192
|
>
|
|
98
|
-
|
|
193
|
+
{registering ? "Registering..." : "Register Agent Identity"}
|
|
99
194
|
</button>
|
|
100
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>
|
|
101
206
|
) : (
|
|
102
|
-
<
|
|
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>
|
|
103
270
|
)}
|
|
104
271
|
</div>
|
|
105
272
|
|
|
106
273
|
{/* Wallet */}
|
|
107
274
|
<WalletCard token={token} />
|
|
108
275
|
|
|
109
|
-
{/* Spending Cap */}
|
|
110
|
-
<div className="border-border rounded border p-4">
|
|
111
|
-
<h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">Spending Cap</h3>
|
|
112
|
-
<div className="flex items-center gap-2">
|
|
113
|
-
<span className="text-muted text-xs">$</span>
|
|
114
|
-
<input
|
|
115
|
-
type="number"
|
|
116
|
-
value={spendCap}
|
|
117
|
-
onChange={(e) => setSpendCap(e.target.value)}
|
|
118
|
-
min="0"
|
|
119
|
-
step="1"
|
|
120
|
-
className="bg-surface border-border text-foreground w-24 rounded border px-2 py-1.5 text-sm outline-none focus:border-accent"
|
|
121
|
-
/>
|
|
122
|
-
<span className="text-muted text-xs">USDC per session</span>
|
|
123
|
-
<button
|
|
124
|
-
onClick={handleSaveSpendCap}
|
|
125
|
-
disabled={savingCap}
|
|
126
|
-
className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 ml-auto rounded border px-3 py-1.5 text-xs font-medium transition-colors"
|
|
127
|
-
>
|
|
128
|
-
{savingCap ? "saving..." : capSaved ? "saved" : "save"}
|
|
129
|
-
</button>
|
|
130
|
-
</div>
|
|
131
|
-
</div>
|
|
132
|
-
|
|
133
276
|
{/* Reset Passphrase */}
|
|
134
277
|
<div className="border-border rounded border p-4">
|
|
135
278
|
<h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">Reset Passphrase</h3>
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
|
+
import { StoryBrowser } from "./StoryBrowser";
|
|
3
|
+
import { TerminalPanel } from "./TerminalPanel";
|
|
4
|
+
import { PreviewPanel } from "./PreviewPanel";
|
|
5
|
+
|
|
6
|
+
interface StoriesPageProps {
|
|
7
|
+
token: string;
|
|
8
|
+
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
9
|
+
}
|
|
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
|
+
|
|
36
|
+
export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
37
|
+
const [selectedStory, setSelectedStory] = useState<string | null>(null);
|
|
38
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
39
|
+
const [publishingFile, setPublishingFile] = useState<string | null>(null);
|
|
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
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const handleSelectFile = useCallback((storyName: string, fileName: string) => {
|
|
64
|
+
setSelectedStory(storyName);
|
|
65
|
+
setSelectedFile(fileName);
|
|
66
|
+
}, []);
|
|
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
|
+
|
|
120
|
+
const handlePublish = useCallback(async (storyName: string, fileName: string) => {
|
|
121
|
+
setPublishingFile(fileName);
|
|
122
|
+
setPublishProgress("Reading file...");
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Get file content
|
|
126
|
+
const fileRes = await authFetch(`/api/stories/${storyName}/${fileName}`);
|
|
127
|
+
if (!fileRes.ok) throw new Error("Failed to read file");
|
|
128
|
+
const fileData = await fileRes.json();
|
|
129
|
+
|
|
130
|
+
// Extract title from first heading or filename
|
|
131
|
+
const titleMatch = fileData.content.match(/^#\s+(.+)$/m);
|
|
132
|
+
const title = titleMatch ? titleMatch[1].slice(0, 60) : fileName.replace(".md", "");
|
|
133
|
+
|
|
134
|
+
// Determine genre from structure.md if available
|
|
135
|
+
let genre = "Fiction";
|
|
136
|
+
try {
|
|
137
|
+
const structRes = await authFetch(`/api/stories/${storyName}/structure.md`);
|
|
138
|
+
if (structRes.ok) {
|
|
139
|
+
const structData = await structRes.json();
|
|
140
|
+
const genreMatch = structData.content.match(/genre[:\s]+(.+)/i);
|
|
141
|
+
if (genreMatch) genre = genreMatch[1].trim().slice(0, 30);
|
|
142
|
+
}
|
|
143
|
+
} catch { /* ignore */ }
|
|
144
|
+
|
|
145
|
+
// For plot files, find the storylineId from the genesis publish status
|
|
146
|
+
let storylineId: number | undefined;
|
|
147
|
+
if (fileName.match(/^plot-\d+\.md$/)) {
|
|
148
|
+
try {
|
|
149
|
+
const storyRes = await authFetch(`/api/stories/${storyName}`);
|
|
150
|
+
if (storyRes.ok) {
|
|
151
|
+
const storyData = await storyRes.json();
|
|
152
|
+
const genesis = storyData.files.find((f: { file: string; storylineId?: number }) =>
|
|
153
|
+
f.file === "genesis.md" && f.storylineId);
|
|
154
|
+
storylineId = genesis?.storylineId;
|
|
155
|
+
}
|
|
156
|
+
} catch { /* ignore */ }
|
|
157
|
+
if (!storylineId) {
|
|
158
|
+
setPublishProgress("Error: Publish genesis first to create the storyline");
|
|
159
|
+
setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 3000);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Run publish flow via SSE
|
|
165
|
+
setPublishProgress("Publishing...");
|
|
166
|
+
const publishRes = await authFetch("/api/publish/file", {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "Content-Type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ storyName, fileName, title, content: fileData.content, genre, storylineId }),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (!publishRes.ok) {
|
|
173
|
+
const err = await publishRes.json();
|
|
174
|
+
throw new Error(err.error || "Publish failed");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Read SSE stream
|
|
178
|
+
const reader = publishRes.body?.getReader();
|
|
179
|
+
const decoder = new TextDecoder();
|
|
180
|
+
|
|
181
|
+
if (reader) {
|
|
182
|
+
while (true) {
|
|
183
|
+
const { done, value } = await reader.read();
|
|
184
|
+
if (done) break;
|
|
185
|
+
const text = decoder.decode(value);
|
|
186
|
+
const lines = text.split("\n").filter((l) => l.startsWith("data: "));
|
|
187
|
+
for (const line of lines) {
|
|
188
|
+
try {
|
|
189
|
+
const data = JSON.parse(line.slice(6));
|
|
190
|
+
if (data.step) setPublishProgress(data.message || data.step);
|
|
191
|
+
if (data.step === "done" && data.txHash) {
|
|
192
|
+
// Update publish status with gasCost
|
|
193
|
+
await authFetch(`/api/stories/${storyName}/${fileName}/publish-status`, {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: { "Content-Type": "application/json" },
|
|
196
|
+
body: JSON.stringify({
|
|
197
|
+
txHash: data.txHash,
|
|
198
|
+
storylineId: data.storylineId,
|
|
199
|
+
plotIndex: data.plotIndex,
|
|
200
|
+
contentCid: data.contentCid,
|
|
201
|
+
gasCost: data.gasCost,
|
|
202
|
+
indexError: data.indexError,
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
} catch { /* ignore partial SSE */ }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
setPublishProgress("Published!");
|
|
212
|
+
} catch (err: unknown) {
|
|
213
|
+
const message = err instanceof Error ? err.message : "Publish failed";
|
|
214
|
+
setPublishProgress(`Error: ${message}`);
|
|
215
|
+
} finally {
|
|
216
|
+
setTimeout(() => {
|
|
217
|
+
setPublishingFile(null);
|
|
218
|
+
setPublishProgress("");
|
|
219
|
+
}, 3000);
|
|
220
|
+
}
|
|
221
|
+
}, [authFetch]);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div ref={containerRef} className="h-[calc(100vh-3.5rem)] flex">
|
|
225
|
+
{/* Story Browser Sidebar */}
|
|
226
|
+
<div className="w-56 border-r border-border flex-shrink-0">
|
|
227
|
+
<StoryBrowser
|
|
228
|
+
authFetch={authFetch}
|
|
229
|
+
selectedStory={selectedStory}
|
|
230
|
+
selectedFile={selectedFile}
|
|
231
|
+
onSelectFile={handleSelectFile}
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
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>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{/* Preview — takes remaining space */}
|
|
254
|
+
<div className="min-w-0 flex flex-col" style={{ flex: `${1 - ratio} 0 0` }}>
|
|
255
|
+
<PreviewPanel
|
|
256
|
+
storyName={selectedStory}
|
|
257
|
+
fileName={selectedFile}
|
|
258
|
+
authFetch={authFetch}
|
|
259
|
+
onPublish={handlePublish}
|
|
260
|
+
publishingFile={publishingFile}
|
|
261
|
+
/>
|
|
262
|
+
{publishProgress && (
|
|
263
|
+
<div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
|
|
264
|
+
{publishProgress}
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|