plotlink-ows 1.0.28 → 1.0.30
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/app/lib/generate-claude-md.ts +139 -0
- package/app/lib/publish.ts +35 -0
- package/app/routes/publish.ts +36 -1
- package/app/server.ts +4 -0
- package/app/web/components/PreviewPanel.tsx +114 -0
- package/app/web/dist/assets/{index-9T4gFznD.js → index-ClPr6pbO.js} +40 -40
- package/app/web/dist/assets/{index-CXg4YULp.css → index-QJp_wjOl.css} +1 -1
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { CONFIG_DIR } from "./paths";
|
|
4
|
+
|
|
5
|
+
const CLAUDE_MD_PATH = path.join(CONFIG_DIR, "CLAUDE.md");
|
|
6
|
+
|
|
7
|
+
/** Read the installed package version from package.json */
|
|
8
|
+
function getVersion(): string {
|
|
9
|
+
const pkgPath = path.join(__dirname, "..", "..", "package.json");
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version;
|
|
12
|
+
} catch {
|
|
13
|
+
return "unknown";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function generateContent(version: string, port: number): string {
|
|
18
|
+
return `# PlotLink OWS — Local Writer API (v${version})
|
|
19
|
+
|
|
20
|
+
> Auto-generated by PlotLink OWS on startup. Do not edit manually.
|
|
21
|
+
|
|
22
|
+
Local writer app running at \`http://localhost:${port}\`.
|
|
23
|
+
All endpoints except auth use \`Authorization: Bearer {token}\` headers.
|
|
24
|
+
|
|
25
|
+
## Authentication
|
|
26
|
+
|
|
27
|
+
The OWS passphrase is stored in \`~/.plotlink-ows/.env\` as \`OWS_PASSPHRASE\`.
|
|
28
|
+
For login, the passphrase is hashed with HMAC-SHA256 and compared against the stored hash.
|
|
29
|
+
|
|
30
|
+
| Endpoint | Method | Auth | Purpose |
|
|
31
|
+
|----------|--------|------|---------|
|
|
32
|
+
| \`/api/auth/status\` | GET | No | Check if passphrase is configured |
|
|
33
|
+
| \`/api/auth/setup\` | POST | No | First-run passphrase setup (≥4 chars) → returns \`{ token }\` |
|
|
34
|
+
| \`/api/auth/login\` | POST | No | Login with passphrase → returns \`{ token }\` (24h TTL) |
|
|
35
|
+
| \`/api/auth/verify\` | GET | Bearer | Check token validity |
|
|
36
|
+
| \`/api/auth/reset-passphrase\` | POST | Bearer | Update passphrase |
|
|
37
|
+
|
|
38
|
+
## Publishing
|
|
39
|
+
|
|
40
|
+
| Endpoint | Method | Purpose |
|
|
41
|
+
|----------|--------|---------|
|
|
42
|
+
| \`/api/publish/preflight\` | GET | Check wallet balance, Filebase config |
|
|
43
|
+
| \`/api/publish/file\` | POST | Publish story on-chain (SSE stream of progress events) |
|
|
44
|
+
| \`/api/publish/retry-index\` | POST | Retry indexing for a published file |
|
|
45
|
+
| \`/api/publish/upload-cover\` | POST | Upload cover image — FormData \`file\` field, **WebP or JPEG only**, max 500KB → returns \`{ cid }\` |
|
|
46
|
+
| \`/api/publish/upload-plot-image\` | POST | Upload plot illustration — FormData \`file\` field, **WebP or JPEG only**, max 500KB → returns \`{ cid, url }\` |
|
|
47
|
+
| \`/api/publish/update-storyline\` | POST | Update storyline metadata (coverCid, genre, language, isNsfw) |
|
|
48
|
+
|
|
49
|
+
**Publish flow:** Upload to IPFS → estimate gas → sign with OWS wallet → broadcast → confirm → index on plotlink.xyz (8s delay + 10 retries × 30s). Genesis files call \`createStoryline\`, plot files (\`plot-*.md\`) call \`chainPlot\`. Content limit: 10K chars.
|
|
50
|
+
|
|
51
|
+
**Cover update workflow:**
|
|
52
|
+
1. \`POST /api/publish/upload-cover\` with image file → get \`cid\`
|
|
53
|
+
2. \`POST /api/publish/update-storyline\` with \`{ storylineId, coverCid: cid }\` → updates on plotlink.xyz
|
|
54
|
+
|
|
55
|
+
**Metadata update workflow:**
|
|
56
|
+
1. \`POST /api/publish/update-storyline\` with \`{ storylineId, genre?, language?, isNsfw? }\`
|
|
57
|
+
|
|
58
|
+
Both upload-cover and update-storyline sign messages with the OWS wallet.
|
|
59
|
+
|
|
60
|
+
## Stories
|
|
61
|
+
|
|
62
|
+
| Endpoint | Method | Purpose |
|
|
63
|
+
|----------|--------|---------|
|
|
64
|
+
| \`/api/stories\` | GET | List all stories |
|
|
65
|
+
| \`/api/stories/archived\` | GET | List archived stories |
|
|
66
|
+
| \`/api/stories/archive\` | POST | Archive a story \`{ name }\` |
|
|
67
|
+
| \`/api/stories/restore\` | POST | Restore archived story \`{ name }\` |
|
|
68
|
+
| \`/api/stories/:name\` | GET | Story detail with file contents |
|
|
69
|
+
| \`/api/stories/:name/:file\` | GET | Single file content and publish status |
|
|
70
|
+
| \`/api/stories/:name/:file\` | PUT | Update file content \`{ content }\` |
|
|
71
|
+
| \`/api/stories/:name/:file/publish-status\` | POST | Record publish result (txHash, storylineId, etc.) |
|
|
72
|
+
| \`/api/stories/:name/:file/mark-not-indexed\` | POST | Mark file as not indexed \`{ indexError? }\` |
|
|
73
|
+
|
|
74
|
+
## Terminal
|
|
75
|
+
|
|
76
|
+
| Endpoint | Method | Purpose |
|
|
77
|
+
|----------|--------|---------|
|
|
78
|
+
| \`/api/terminal/spawn\` | POST | Spawn Claude CLI session for a story \`{ storyName?, resume? }\` |
|
|
79
|
+
| \`/api/terminal/session/:storyName\` | GET | Get stored session ID for a story |
|
|
80
|
+
| \`/api/terminal/status\` | GET | List all active terminal sessions |
|
|
81
|
+
| \`/api/terminal/rename\` | POST | Rename session \`{ oldName, newName }\` |
|
|
82
|
+
| \`/api/terminal/stop\` | POST | Kill default PTY (legacy) |
|
|
83
|
+
| \`/api/terminal/:storyName\` | DELETE | Kill a story's PTY |
|
|
84
|
+
| \`/api/terminal/:storyName/discard\` | DELETE | Kill PTY and clean metadata |
|
|
85
|
+
| \`/ws/terminal\` | WebSocket | Live PTY relay \`?token={token}&story={name}&resume={bool}\` |
|
|
86
|
+
|
|
87
|
+
## Other Endpoints
|
|
88
|
+
|
|
89
|
+
| Endpoint | Method | Purpose |
|
|
90
|
+
|----------|--------|---------|
|
|
91
|
+
| \`/api/wallet\` | GET | Wallet info and balances (ETH, USDC, PLOT) |
|
|
92
|
+
| \`/api/wallet/create\` | POST | Create OWS wallet if not exists |
|
|
93
|
+
| \`/api/dashboard\` | GET | Writer dashboard stats (stories, costs, royalties) |
|
|
94
|
+
| \`/api/settings/register-agent\` | POST | Register wallet on ERC-8004 |
|
|
95
|
+
| \`/api/settings/generate-binding\` | POST | Generate wallet binding proof |
|
|
96
|
+
| \`/api/settings/link-status\` | GET | Check ERC-8004 registration status |
|
|
97
|
+
| \`/api/health\` | GET | Health check |
|
|
98
|
+
|
|
99
|
+
## Story File Structure
|
|
100
|
+
|
|
101
|
+
Stories live in \`~/.plotlink-ows/stories/{story-name}/\`:
|
|
102
|
+
|
|
103
|
+
\`\`\`
|
|
104
|
+
stories/{story-name}/
|
|
105
|
+
structure.md # Outline, characters, arc
|
|
106
|
+
genesis.md # Synopsis hook (~1000 chars)
|
|
107
|
+
plot-01.md # Chapter 1 (max 10K chars)
|
|
108
|
+
...
|
|
109
|
+
\`\`\`
|
|
110
|
+
|
|
111
|
+
## Supported Genres (21)
|
|
112
|
+
|
|
113
|
+
Romance, Fantasy, Science Fiction, Mystery, Thriller, Horror, Adventure, Historical Fiction, Contemporary Lit, Humor, Poetry, Non-Fiction, Fanfiction, Short Story, Paranormal, Werewolf, LGBTQ+, New Adult, Teen Fiction, Diverse Lit, Others
|
|
114
|
+
|
|
115
|
+
## Supported Languages (11)
|
|
116
|
+
|
|
117
|
+
English, Chinese, Korean, Japanese, Spanish, French, Hindi, Arabic, Portuguese, Russian, Others
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Write/update ~/.plotlink-ows/CLAUDE.md on startup.
|
|
123
|
+
* Skips the write if the file already contains the current version stamp.
|
|
124
|
+
*/
|
|
125
|
+
export function generateClaudeMd(): void {
|
|
126
|
+
const version = getVersion();
|
|
127
|
+
const port = Number(process.env.APP_PORT) || 7777;
|
|
128
|
+
|
|
129
|
+
// Check if the file already matches the current version
|
|
130
|
+
if (fs.existsSync(CLAUDE_MD_PATH)) {
|
|
131
|
+
try {
|
|
132
|
+
const firstLine = fs.readFileSync(CLAUDE_MD_PATH, "utf-8").split("\n")[0];
|
|
133
|
+
if (firstLine.includes(`(v${version})`)) return;
|
|
134
|
+
} catch { /* regenerate on read error */ }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fs.writeFileSync(CLAUDE_MD_PATH, generateContent(version, port), "utf-8");
|
|
138
|
+
console.log(` Updated ~/.plotlink-ows/CLAUDE.md (v${version})`);
|
|
139
|
+
}
|
package/app/lib/publish.ts
CHANGED
|
@@ -461,6 +461,41 @@ export async function uploadCoverImage(
|
|
|
461
461
|
return data.cid;
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Upload a plot illustration image to PlotLink via signed API call.
|
|
466
|
+
* Returns the IPFS CID and URL of the uploaded image.
|
|
467
|
+
*/
|
|
468
|
+
export async function uploadPlotImage(
|
|
469
|
+
walletName: string,
|
|
470
|
+
walletAddress: `0x${string}`,
|
|
471
|
+
imageFile: File,
|
|
472
|
+
): Promise<{ cid: string; url: string }> {
|
|
473
|
+
const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
|
|
474
|
+
const account = createOwsAccount(walletName, walletAddress);
|
|
475
|
+
|
|
476
|
+
const timestamp = Date.now();
|
|
477
|
+
const message = `PlotLink: Upload plot image\nTimestamp: ${timestamp}`;
|
|
478
|
+
const signature = await account.signMessage({ message });
|
|
479
|
+
|
|
480
|
+
const formData = new FormData();
|
|
481
|
+
formData.append("file", imageFile);
|
|
482
|
+
formData.append("message", message);
|
|
483
|
+
formData.append("signature", signature);
|
|
484
|
+
|
|
485
|
+
const res = await fetch(`${PLOTLINK_URL}/api/upload-plot-image`, {
|
|
486
|
+
method: "POST",
|
|
487
|
+
body: formData,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (!res.ok) {
|
|
491
|
+
const err = await res.json().catch(() => ({})) as Record<string, string>;
|
|
492
|
+
throw new Error(err.error || `Plot image upload failed: HTTP ${res.status}`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const data = await res.json() as { cid: string; url: string };
|
|
496
|
+
return data;
|
|
497
|
+
}
|
|
498
|
+
|
|
464
499
|
/**
|
|
465
500
|
* Update storyline metadata (cover, genre, language, NSFW) on PlotLink via signed API call.
|
|
466
501
|
* Uses createOwsAccount for signing (not raw owsSignMsg).
|
package/app/routes/publish.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { streamSSE } from "hono/streaming";
|
|
3
|
-
import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, updateStoryline } from "../lib/publish";
|
|
3
|
+
import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, uploadPlotImage, updateStoryline } from "../lib/publish";
|
|
4
4
|
import { keccak256, toBytes } from "viem";
|
|
5
5
|
import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
|
|
6
6
|
|
|
@@ -231,6 +231,41 @@ publish.post("/upload-cover", async (c) => {
|
|
|
231
231
|
}
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
/** POST /api/publish/upload-plot-image — upload plot illustration with wallet signature */
|
|
235
|
+
publish.post("/upload-plot-image", async (c) => {
|
|
236
|
+
try {
|
|
237
|
+
const wallets = listAgentWallets();
|
|
238
|
+
const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
|
|
239
|
+
if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
|
|
240
|
+
|
|
241
|
+
const address = getBaseAddress(wallet);
|
|
242
|
+
if (!address) return c.json({ error: "No EVM address on wallet" }, 400);
|
|
243
|
+
|
|
244
|
+
const formData = await c.req.formData();
|
|
245
|
+
const file = formData.get("file");
|
|
246
|
+
if (!file || !(file instanceof File)) {
|
|
247
|
+
return c.json({ error: "No image file provided" }, 400);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Validate file size (500KB max)
|
|
251
|
+
if (file.size > 500 * 1024) {
|
|
252
|
+
return c.json({ error: "Image exceeds 500KB limit" }, 400);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Validate file type — only WebP and JPEG accepted by the plotlink server
|
|
256
|
+
const allowedTypes = ["image/webp", "image/jpeg"];
|
|
257
|
+
if (!allowedTypes.includes(file.type)) {
|
|
258
|
+
return c.json({ error: "Only WebP and JPEG images are accepted" }, 400);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const result = await uploadPlotImage(wallet.name, address as `0x${string}`, file);
|
|
262
|
+
return c.json(result);
|
|
263
|
+
} catch (err: unknown) {
|
|
264
|
+
const message = err instanceof Error ? err.message : "Plot image upload failed";
|
|
265
|
+
return c.json({ error: message }, 500);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
234
269
|
/** POST /api/publish/update-storyline — update storyline metadata with wallet signature */
|
|
235
270
|
publish.post("/update-storyline", async (c) => {
|
|
236
271
|
try {
|
package/app/server.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { terminalRoutes, attachTerminalWs } from "./routes/terminal";
|
|
|
23
23
|
import { storiesRoutes } from "./routes/stories";
|
|
24
24
|
import { settingsRoutes } from "./routes/settings";
|
|
25
25
|
import { initDb } from "./db";
|
|
26
|
+
import { generateClaudeMd } from "./lib/generate-claude-md";
|
|
26
27
|
import { execSync } from "child_process";
|
|
27
28
|
import fs from "fs";
|
|
28
29
|
|
|
@@ -117,6 +118,9 @@ async function start() {
|
|
|
117
118
|
// Auto-migrate from old package-relative paths
|
|
118
119
|
migrateOldData();
|
|
119
120
|
|
|
121
|
+
// Generate/update ~/.plotlink-ows/CLAUDE.md for agent discovery
|
|
122
|
+
generateClaudeMd();
|
|
123
|
+
|
|
120
124
|
// Run Prisma db push to ensure schema is up to date
|
|
121
125
|
const schemaPath = path.join(__dirname, "prisma", "schema.prisma");
|
|
122
126
|
execSync(`npx prisma db push --schema ${schemaPath} --skip-generate`, {
|
|
@@ -56,6 +56,14 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
56
56
|
const [editSuccess, setEditSuccess] = useState(false);
|
|
57
57
|
const coverInputRef = useRef<HTMLInputElement>(null);
|
|
58
58
|
|
|
59
|
+
// Inline illustration state
|
|
60
|
+
const [showIllustrations, setShowIllustrations] = useState(false);
|
|
61
|
+
const [illustrationUploading, setIllustrationUploading] = useState(false);
|
|
62
|
+
const [illustrationError, setIllustrationError] = useState<string | null>(null);
|
|
63
|
+
const [uploadedImages, setUploadedImages] = useState<Array<{ cid: string; url: string }>>([]);
|
|
64
|
+
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
|
65
|
+
const illustrationInputRef = useRef<HTMLInputElement>(null);
|
|
66
|
+
|
|
59
67
|
const prevFileRef = useRef<string | null>(null);
|
|
60
68
|
|
|
61
69
|
const loadFile = useCallback(async () => {
|
|
@@ -153,6 +161,45 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
153
161
|
setEditError(null);
|
|
154
162
|
}, []);
|
|
155
163
|
|
|
164
|
+
// Handle illustration image upload from File object
|
|
165
|
+
const uploadIllustration = useCallback(async (file: File) => {
|
|
166
|
+
if (file.size > 500 * 1024) {
|
|
167
|
+
setIllustrationError("Image exceeds 500KB limit");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const allowedTypes = ["image/webp", "image/jpeg"];
|
|
171
|
+
if (!allowedTypes.includes(file.type)) {
|
|
172
|
+
setIllustrationError("Only WebP and JPEG images are accepted");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
setIllustrationUploading(true);
|
|
176
|
+
setIllustrationError(null);
|
|
177
|
+
try {
|
|
178
|
+
const formData = new FormData();
|
|
179
|
+
formData.append("file", file);
|
|
180
|
+
const res = await authFetch("/api/publish/upload-plot-image", {
|
|
181
|
+
method: "POST",
|
|
182
|
+
body: formData,
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
const err = await res.json();
|
|
186
|
+
throw new Error(err.error || "Upload failed");
|
|
187
|
+
}
|
|
188
|
+
const data = await res.json();
|
|
189
|
+
setUploadedImages((prev) => [...prev, { cid: data.cid, url: data.url }]);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
setIllustrationError(err instanceof Error ? err.message : "Upload failed");
|
|
192
|
+
} finally {
|
|
193
|
+
setIllustrationUploading(false);
|
|
194
|
+
if (illustrationInputRef.current) illustrationInputRef.current.value = "";
|
|
195
|
+
}
|
|
196
|
+
}, [authFetch]);
|
|
197
|
+
|
|
198
|
+
const handleIllustrationInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
199
|
+
const file = e.target.files?.[0];
|
|
200
|
+
if (file) uploadIllustration(file);
|
|
201
|
+
}, [uploadIllustration]);
|
|
202
|
+
|
|
156
203
|
// Save storyline edits (cover upload + metadata update)
|
|
157
204
|
const handleEditSave = useCallback(async () => {
|
|
158
205
|
if (!fileData?.storylineId) return;
|
|
@@ -215,6 +262,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
215
262
|
setEditError(null);
|
|
216
263
|
setEditSuccess(false);
|
|
217
264
|
setEditMetaLoaded(false);
|
|
265
|
+
setShowIllustrations(false);
|
|
266
|
+
setUploadedImages([]);
|
|
267
|
+
setIllustrationError(null);
|
|
218
268
|
}, [storyName, fileName]);
|
|
219
269
|
|
|
220
270
|
// Fetch current storyline metadata when edit panel opens
|
|
@@ -407,6 +457,70 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
|
|
|
407
457
|
{saving ? "Saving..." : "Save"}
|
|
408
458
|
</button>
|
|
409
459
|
</div>
|
|
460
|
+
{/* Inline illustration upload for plot files */}
|
|
461
|
+
{isPlot && (
|
|
462
|
+
<div className="px-3 py-1.5 border-t border-border">
|
|
463
|
+
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
|
|
464
|
+
<input
|
|
465
|
+
type="checkbox"
|
|
466
|
+
checked={showIllustrations}
|
|
467
|
+
onChange={(e) => setShowIllustrations(e.target.checked)}
|
|
468
|
+
className="rounded border-border"
|
|
469
|
+
/>
|
|
470
|
+
Add illustrations in the plot
|
|
471
|
+
</label>
|
|
472
|
+
{showIllustrations && (
|
|
473
|
+
<div className="mt-2 flex flex-col gap-2">
|
|
474
|
+
<div
|
|
475
|
+
className="border-2 border-dashed border-border rounded p-3 flex flex-col items-center gap-1.5 cursor-pointer hover:border-accent transition-colors"
|
|
476
|
+
onClick={() => illustrationInputRef.current?.click()}
|
|
477
|
+
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
|
478
|
+
onDrop={(e) => {
|
|
479
|
+
e.preventDefault();
|
|
480
|
+
e.stopPropagation();
|
|
481
|
+
const file = e.dataTransfer.files?.[0];
|
|
482
|
+
if (file) uploadIllustration(file);
|
|
483
|
+
}}
|
|
484
|
+
>
|
|
485
|
+
<input
|
|
486
|
+
ref={illustrationInputRef}
|
|
487
|
+
type="file"
|
|
488
|
+
accept="image/webp,image/jpeg"
|
|
489
|
+
onChange={handleIllustrationInput}
|
|
490
|
+
className="hidden"
|
|
491
|
+
/>
|
|
492
|
+
<span className="text-xs text-muted">
|
|
493
|
+
{illustrationUploading ? "Uploading..." : "Drop image here or click to browse"}
|
|
494
|
+
</span>
|
|
495
|
+
<span className="text-xs text-muted">WebP/JPEG, max 500KB</span>
|
|
496
|
+
</div>
|
|
497
|
+
{illustrationError && (
|
|
498
|
+
<span className="text-error text-xs">{illustrationError}</span>
|
|
499
|
+
)}
|
|
500
|
+
{uploadedImages.map((img, i) => (
|
|
501
|
+
<div key={img.cid} className="border border-border rounded p-2 flex flex-col gap-1 bg-surface">
|
|
502
|
+
<span className="text-xs text-green-700">Image uploaded! Copy the markdown below and paste it where you want the illustration to appear in your plot:</span>
|
|
503
|
+
<div className="flex items-center gap-1.5">
|
|
504
|
+
<code className="flex-1 text-xs bg-background px-2 py-1 rounded font-mono break-all">
|
|
505
|
+

|
|
506
|
+
</code>
|
|
507
|
+
<button
|
|
508
|
+
onClick={() => {
|
|
509
|
+
navigator.clipboard.writeText(``);
|
|
510
|
+
setCopiedIndex(i);
|
|
511
|
+
setTimeout(() => setCopiedIndex(null), 2000);
|
|
512
|
+
}}
|
|
513
|
+
className="px-2 py-1 text-xs border border-border rounded hover:bg-surface shrink-0"
|
|
514
|
+
>
|
|
515
|
+
{copiedIndex === i ? "Copied!" : "Copy"}
|
|
516
|
+
</button>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
))}
|
|
520
|
+
</div>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
)}
|
|
410
524
|
</div>
|
|
411
525
|
)}
|
|
412
526
|
|