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.
@@ -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
- const authFetch = (url: string, opts?: RequestInit) =>
12
- 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);
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&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
+ />
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 &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>
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="flex-1 min-w-0 border-r border-border">
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-96 flex-shrink-0 flex flex-col">
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
- pending: "\u23F3",
29
- draft: "\uD83D\uDCDD",
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
- pending: "text-amber-700",
35
- draft: "text-muted",
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={() => toggleExpand(story.name)}
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>