plotlink-ows 0.1.15 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +185 -93
  2. package/app/db.ts +1 -1
  3. package/app/lib/paths.ts +0 -2
  4. package/app/lib/publish.ts +257 -44
  5. package/app/prisma/schema.prisma +0 -36
  6. package/app/routes/dashboard.ts +105 -57
  7. package/app/routes/publish.ts +107 -25
  8. package/app/routes/settings.ts +194 -0
  9. package/app/routes/stories.ts +223 -0
  10. package/app/routes/terminal.ts +258 -0
  11. package/app/routes/wallet.ts +40 -10
  12. package/app/server.ts +35 -81
  13. package/app/web/App.tsx +4 -6
  14. package/app/web/components/Dashboard.tsx +98 -79
  15. package/app/web/components/Layout.tsx +70 -103
  16. package/app/web/components/PreviewPanel.tsx +388 -0
  17. package/app/web/components/Settings.tsx +210 -67
  18. package/app/web/components/StoriesPage.tsx +270 -0
  19. package/app/web/components/StoryBrowser.tsx +161 -0
  20. package/app/web/components/TerminalPanel.tsx +428 -0
  21. package/app/web/components/WalletCard.tsx +14 -8
  22. package/app/web/dist/assets/index-BuOxhUWG.css +32 -0
  23. package/app/web/dist/assets/index-De8CpT47.js +129 -0
  24. package/app/web/dist/index.html +3 -3
  25. package/app/web/dist/plotlink-logo.svg +5 -0
  26. package/app/web/public/plotlink-logo.svg +5 -0
  27. package/app/web/styles.css +18 -0
  28. package/bin/plotlink-ows.js +18 -62
  29. package/package.json +15 -6
  30. package/scripts/fix-index-status.ts +93 -0
  31. package/app/lib/llm-client.ts +0 -265
  32. package/app/lib/writer-prompt.ts +0 -44
  33. package/app/routes/chat.ts +0 -135
  34. package/app/routes/config.ts +0 -210
  35. package/app/routes/oauth.ts +0 -150
  36. package/app/web/components/Chat.tsx +0 -272
  37. package/app/web/components/LLMSetup.tsx +0 -291
  38. package/app/web/components/Publish.tsx +0 -245
  39. package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
  40. package/app/web/dist/assets/index-CJiiaLHs.js +0 -9
@@ -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
- const API_BASE = "http://localhost:7777";
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
- const authFetch = (url: string, opts?: RequestInit) =>
18
- fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
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(`${API_BASE}/api/config/llm`)
32
+ authFetch("/api/settings/link-status")
22
33
  .then((r) => r.json())
23
- .then((data) => setLlmConfig(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 activeProvider = (llmConfig?.llm as Record<string, unknown>)?.activeProvider as string | undefined;
27
- const activeModel = (llmConfig?.llm as Record<string, unknown>)?.activeModel as string | undefined;
28
-
29
- const handleSaveSpendCap = async () => {
30
- setSavingCap(true);
31
- setCapSaved(false);
32
- // Persist spending cap to agent.config.json
33
- await authFetch(`${API_BASE}/api/config/llm`, {
34
- method: "POST",
35
- body: JSON.stringify({ provider: activeProvider || "anthropic", model: activeModel || "", spendCap: Number(spendCap) }),
36
- });
37
- setSavingCap(false);
38
- setCapSaved(true);
39
- setTimeout(() => setCapSaved(false), 2000);
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(`${API_BASE}/api/auth/reset-passphrase`, {
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
- {/* LLM Config */}
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">LLM Provider</h3>
80
- {llmConfig ? (
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
- <div className="flex justify-between text-xs">
83
- <span className="text-muted">Provider</span>
84
- <span className="text-foreground font-medium">{activeProvider || "not configured"}</span>
152
+ <p className="text-muted text-xs">
153
+ Register this AI writer on-chain via ERC-8004. Uses your OWS wallet&apos;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
- <div className="flex justify-between text-xs">
87
- <span className="text-muted">Model</span>
88
- <span className="text-foreground font-medium">{activeModel || "—"}</span>
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
- <div className="flex justify-between text-xs">
91
- <span className="text-muted">Configured</span>
92
- <span className="text-foreground font-medium">{llmConfig.configured.join(", ") || "none"}</span>
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={onChangeLLM}
96
- className="border-accent text-accent hover:bg-accent/10 w-full rounded border px-4 py-2 text-xs font-medium transition-colors"
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
- change provider / model
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
- <p className="text-muted text-xs">loading...</p>
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 &quot;Generate Binding Code&quot;</p>
214
+ <p>3. Copy the code and paste it on plotlink.xyz &rarr; Agents &rarr; 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 &quot;Link AI Writer&quot; 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
+ }