plotlink-ows 1.0.25 → 1.0.28

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.
@@ -424,3 +424,74 @@ export async function publishPlot(
424
424
 
425
425
  return { txHash, contentCid, storylineId, plotIndex: confirmation.plotIndex >= 0 ? confirmation.plotIndex : undefined, gasCost: confirmation.gasCost, indexError };
426
426
  }
427
+
428
+ /**
429
+ * Upload a cover image to PlotLink via signed API call.
430
+ * Uses createOwsAccount for signing (not raw owsSignMsg).
431
+ * Returns the IPFS CID of the uploaded image.
432
+ */
433
+ export async function uploadCoverImage(
434
+ walletName: string,
435
+ walletAddress: `0x${string}`,
436
+ imageFile: File,
437
+ ): Promise<string> {
438
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
439
+ const account = createOwsAccount(walletName, walletAddress);
440
+
441
+ const timestamp = Date.now();
442
+ const message = `PlotLink: Upload cover image\nTimestamp: ${timestamp}`;
443
+ const signature = await account.signMessage({ message });
444
+
445
+ const formData = new FormData();
446
+ formData.append("file", imageFile);
447
+ formData.append("message", message);
448
+ formData.append("signature", signature);
449
+
450
+ const res = await fetch(`${PLOTLINK_URL}/api/upload-cover`, {
451
+ method: "POST",
452
+ body: formData,
453
+ });
454
+
455
+ if (!res.ok) {
456
+ const err = await res.json().catch(() => ({})) as Record<string, string>;
457
+ throw new Error(err.error || `Cover upload failed: HTTP ${res.status}`);
458
+ }
459
+
460
+ const data = await res.json() as { cid: string };
461
+ return data.cid;
462
+ }
463
+
464
+ /**
465
+ * Update storyline metadata (cover, genre, language, NSFW) on PlotLink via signed API call.
466
+ * Uses createOwsAccount for signing (not raw owsSignMsg).
467
+ * Message format must match: /^PlotLink: Update storyline #(\d+)\nTimestamp: (\d+)$/
468
+ */
469
+ export async function updateStoryline(
470
+ walletName: string,
471
+ walletAddress: `0x${string}`,
472
+ storylineId: number,
473
+ updates: { coverCid?: string | null; genre?: string; language?: string; isNsfw?: boolean },
474
+ ): Promise<void> {
475
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
476
+ const account = createOwsAccount(walletName, walletAddress);
477
+
478
+ const timestamp = Date.now();
479
+ const message = `PlotLink: Update storyline #${storylineId}\nTimestamp: ${timestamp}`;
480
+ const signature = await account.signMessage({ message });
481
+
482
+ const res = await fetch(`${PLOTLINK_URL}/api/storyline/update`, {
483
+ method: "POST",
484
+ headers: { "Content-Type": "application/json" },
485
+ body: JSON.stringify({
486
+ storylineId,
487
+ signature,
488
+ message,
489
+ ...updates,
490
+ }),
491
+ });
492
+
493
+ if (!res.ok) {
494
+ const err = await res.json().catch(() => ({})) as Record<string, string>;
495
+ throw new Error(err.error || `Storyline update failed: HTTP ${res.status}`);
496
+ }
497
+ }
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { streamSSE } from "hono/streaming";
3
- import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost } from "../lib/publish";
3
+ import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, updateStoryline } from "../lib/publish";
4
4
  import { keccak256, toBytes } from "viem";
5
5
  import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
6
6
 
@@ -196,4 +196,80 @@ publish.post("/retry-index", async (c) => {
196
196
  }
197
197
  });
198
198
 
199
+ /** POST /api/publish/upload-cover — upload cover image with wallet signature */
200
+ publish.post("/upload-cover", async (c) => {
201
+ try {
202
+ const wallets = listAgentWallets();
203
+ const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
204
+ if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
205
+
206
+ const address = getBaseAddress(wallet);
207
+ if (!address) return c.json({ error: "No EVM address on wallet" }, 400);
208
+
209
+ const formData = await c.req.formData();
210
+ const file = formData.get("file");
211
+ if (!file || !(file instanceof File)) {
212
+ return c.json({ error: "No image file provided" }, 400);
213
+ }
214
+
215
+ // Validate file size (500KB max)
216
+ if (file.size > 500 * 1024) {
217
+ return c.json({ error: "Image exceeds 500KB limit" }, 400);
218
+ }
219
+
220
+ // Validate file type — only WebP and JPEG accepted by the plotlink server
221
+ const allowedTypes = ["image/webp", "image/jpeg"];
222
+ if (!allowedTypes.includes(file.type)) {
223
+ return c.json({ error: "Only WebP and JPEG images are accepted" }, 400);
224
+ }
225
+
226
+ const cid = await uploadCoverImage(wallet.name, address as `0x${string}`, file);
227
+ return c.json({ cid });
228
+ } catch (err: unknown) {
229
+ const message = err instanceof Error ? err.message : "Cover upload failed";
230
+ return c.json({ error: message }, 500);
231
+ }
232
+ });
233
+
234
+ /** POST /api/publish/update-storyline — update storyline metadata with wallet signature */
235
+ publish.post("/update-storyline", 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 body = await c.req.json<{
245
+ storylineId: number;
246
+ coverCid?: string | null;
247
+ genre?: string;
248
+ language?: string;
249
+ isNsfw?: boolean;
250
+ }>();
251
+
252
+ if (!body.storylineId) {
253
+ return c.json({ error: "storylineId required" }, 400);
254
+ }
255
+
256
+ await updateStoryline(
257
+ wallet.name,
258
+ address as `0x${string}`,
259
+ body.storylineId,
260
+ {
261
+ coverCid: body.coverCid,
262
+ genre: body.genre,
263
+ language: body.language,
264
+ isNsfw: body.isNsfw,
265
+ },
266
+ );
267
+
268
+ return c.json({ ok: true });
269
+ } catch (err: unknown) {
270
+ const message = err instanceof Error ? err.message : "Update failed";
271
+ return c.json({ error: message }, 500);
272
+ }
273
+ });
274
+
199
275
  export { publish as publishRoutes };
@@ -23,6 +23,7 @@ interface FileStatus {
23
23
  publishedAt?: string;
24
24
  gasCost?: string;
25
25
  indexError?: string;
26
+ authorAddress?: string;
26
27
  }
27
28
 
28
29
  interface StoryInfo {
@@ -243,6 +244,7 @@ stories.post("/:name/:file/publish-status", async (c) => {
243
244
  contentCid: string;
244
245
  gasCost?: string;
245
246
  indexError?: string;
247
+ authorAddress?: string;
246
248
  }>();
247
249
 
248
250
  const status = readPublishStatus(storyDir);
@@ -255,6 +257,7 @@ stories.post("/:name/:file/publish-status", async (c) => {
255
257
  plotIndex: body.plotIndex ?? existing?.plotIndex,
256
258
  contentCid: body.contentCid || existing?.contentCid,
257
259
  gasCost: body.gasCost || existing?.gasCost,
260
+ authorAddress: body.authorAddress || existing?.authorAddress,
258
261
  publishedAt: new Date().toISOString(),
259
262
  ...(body.indexError ? { indexError: body.indexError } : {}),
260
263
  };
@@ -11,6 +11,7 @@ interface PreviewPanelProps {
11
11
  authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
12
12
  onPublish?: (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => void;
13
13
  publishingFile?: string | null;
14
+ walletAddress?: string | null;
14
15
  }
15
16
 
16
17
  interface FileData {
@@ -22,11 +23,12 @@ interface FileData {
22
23
  plotIndex?: number;
23
24
  indexError?: string;
24
25
  publishedAt?: string;
26
+ authorAddress?: string;
25
27
  }
26
28
 
27
29
  type Tab = "preview" | "edit";
28
30
 
29
- export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publishingFile }: PreviewPanelProps) {
31
+ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publishingFile, walletAddress }: PreviewPanelProps) {
30
32
  const [fileData, setFileData] = useState<FileData | null>(null);
31
33
  const [loading, setLoading] = useState(false);
32
34
  const [activeTab, setActiveTab] = useState<Tab>("preview");
@@ -41,6 +43,19 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
41
43
  const textareaRef = useRef<HTMLTextAreaElement>(null);
42
44
  const dirtyRef = useRef(false);
43
45
 
46
+ // Edit panel state for published stories
47
+ const [showEditPanel, setShowEditPanel] = useState(false);
48
+ const [editGenre, setEditGenre] = useState(GENRES[0] as string);
49
+ const [editLanguage, setEditLanguage] = useState(LANGUAGES[0] as string);
50
+ const [editNsfw, setEditNsfw] = useState(false);
51
+ const [coverFile, setCoverFile] = useState<File | null>(null);
52
+ const [coverPreview, setCoverPreview] = useState<string | null>(null);
53
+ const [editSaving, setEditSaving] = useState(false);
54
+ const [editMetaLoaded, setEditMetaLoaded] = useState(false);
55
+ const [editError, setEditError] = useState<string | null>(null);
56
+ const [editSuccess, setEditSuccess] = useState(false);
57
+ const coverInputRef = useRef<HTMLInputElement>(null);
58
+
44
59
  const prevFileRef = useRef<string | null>(null);
45
60
 
46
61
  const loadFile = useCallback(async () => {
@@ -121,6 +136,118 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
121
136
  setSaving(false);
122
137
  }, [storyName, fileName, authFetch, editContent]);
123
138
 
139
+ // Handle cover image selection
140
+ const handleCoverSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
141
+ const file = e.target.files?.[0];
142
+ if (!file) return;
143
+ if (file.size > 500 * 1024) {
144
+ setEditError("Image exceeds 500KB limit");
145
+ return;
146
+ }
147
+ if (!file.type.startsWith("image/")) {
148
+ setEditError("File must be an image");
149
+ return;
150
+ }
151
+ setCoverFile(file);
152
+ setCoverPreview(URL.createObjectURL(file));
153
+ setEditError(null);
154
+ }, []);
155
+
156
+ // Save storyline edits (cover upload + metadata update)
157
+ const handleEditSave = useCallback(async () => {
158
+ if (!fileData?.storylineId) return;
159
+ setEditSaving(true);
160
+ setEditError(null);
161
+ setEditSuccess(false);
162
+
163
+ try {
164
+ let coverCid: string | undefined;
165
+
166
+ // Upload cover image if selected
167
+ if (coverFile) {
168
+ const formData = new FormData();
169
+ formData.append("file", coverFile);
170
+ const uploadRes = await authFetch("/api/publish/upload-cover", {
171
+ method: "POST",
172
+ body: formData,
173
+ });
174
+ if (!uploadRes.ok) {
175
+ const err = await uploadRes.json();
176
+ throw new Error(err.error || "Cover upload failed");
177
+ }
178
+ const uploadData = await uploadRes.json();
179
+ coverCid = uploadData.cid;
180
+ }
181
+
182
+ // Update storyline metadata
183
+ const updateRes = await authFetch("/api/publish/update-storyline", {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/json" },
186
+ body: JSON.stringify({
187
+ storylineId: fileData.storylineId,
188
+ ...(coverCid !== undefined && { coverCid }),
189
+ genre: editGenre,
190
+ language: editLanguage,
191
+ isNsfw: editNsfw,
192
+ }),
193
+ });
194
+
195
+ if (!updateRes.ok) {
196
+ const err = await updateRes.json();
197
+ throw new Error(err.error || "Update failed");
198
+ }
199
+
200
+ setEditSuccess(true);
201
+ setCoverFile(null);
202
+ setTimeout(() => setEditSuccess(false), 3000);
203
+ } catch (err) {
204
+ setEditError(err instanceof Error ? err.message : "Update failed");
205
+ } finally {
206
+ setEditSaving(false);
207
+ }
208
+ }, [fileData?.storylineId, coverFile, editGenre, editLanguage, editNsfw, authFetch]);
209
+
210
+ // Reset edit panel state when changing files
211
+ useEffect(() => {
212
+ setShowEditPanel(false);
213
+ setCoverFile(null);
214
+ setCoverPreview(null);
215
+ setEditError(null);
216
+ setEditSuccess(false);
217
+ setEditMetaLoaded(false);
218
+ }, [storyName, fileName]);
219
+
220
+ // Fetch current storyline metadata when edit panel opens
221
+ useEffect(() => {
222
+ if (!showEditPanel || !fileData?.storylineId) return;
223
+ setEditMetaLoaded(false);
224
+ const PLOTLINK_URL = "https://plotlink.xyz";
225
+ let cancelled = false;
226
+ fetch(`${PLOTLINK_URL}/api/storyline/${fileData.storylineId}`)
227
+ .then((res) => res.ok ? res.json() : null)
228
+ .then((data) => {
229
+ if (cancelled) return;
230
+ if (!data) {
231
+ setEditError("Could not load current story metadata");
232
+ return;
233
+ }
234
+ if (data.genre) {
235
+ const found = GENRES.find((g) => g.toLowerCase() === data.genre.toLowerCase());
236
+ if (found) setEditGenre(found);
237
+ }
238
+ if (data.language) {
239
+ const found = LANGUAGES.find((l) => l.toLowerCase() === data.language.toLowerCase());
240
+ if (found) setEditLanguage(found);
241
+ }
242
+ if (data.isNsfw !== undefined) setEditNsfw(!!data.isNsfw);
243
+ setEditMetaLoaded(true);
244
+ })
245
+ .catch(() => {
246
+ if (!cancelled) setEditError("Could not load current story metadata");
247
+ });
248
+ return () => { cancelled = true; };
249
+ }, [showEditPanel, fileData?.storylineId]);
250
+
124
251
  // Ctrl+S / Cmd+S to save
125
252
  useEffect(() => {
126
253
  if (activeTab !== "edit") return;
@@ -364,37 +491,123 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
364
491
  )}
365
492
  </div>
366
493
  ) : fileData?.status === "published" ? (
367
- <div className="flex items-center gap-2 text-xs">
368
- <span className="text-green-700">Published</span>
369
- {fileData.storylineId && (
370
- <a
371
- href={(() => {
372
- const base = `https://plotlink.xyz/story/${fileData.storylineId}`;
373
- if (!isPlot) return base;
374
- // plotIndex convention: contract emits 0-based (genesis=0, plot-01=1)
375
- // plotlink.xyz URLs use the same 0-based index
376
- // Filename fallback: plot-01.md → parseInt("01") = 1 (matches contract)
377
- const idx = fileData.plotIndex != null && fileData.plotIndex > 0
378
- ? fileData.plotIndex
379
- : parseInt(fileName?.match(/^plot-(\d+)\.md$/)?.[1] ?? "1");
380
- return `${base}/${idx}`;
381
- })()}
382
- target="_blank"
383
- rel="noopener noreferrer"
384
- className="text-accent underline"
385
- >
386
- View on PlotLink
387
- </a>
388
- )}
389
- {fileData.txHash && (
390
- <a
391
- href={`https://basescan.org/tx/${fileData.txHash}`}
392
- target="_blank"
393
- rel="noopener noreferrer"
394
- className="text-muted underline"
395
- >
396
- BaseScan
397
- </a>
494
+ <div className="flex flex-col gap-2">
495
+ <div className="flex items-center gap-2 text-xs">
496
+ <span className="text-green-700">Published</span>
497
+ {fileData.storylineId && (
498
+ <a
499
+ href={(() => {
500
+ const base = `https://plotlink.xyz/story/${fileData.storylineId}`;
501
+ if (!isPlot) return base;
502
+ const idx = fileData.plotIndex != null && fileData.plotIndex > 0
503
+ ? fileData.plotIndex
504
+ : parseInt(fileName?.match(/^plot-(\d+)\.md$/)?.[1] ?? "1");
505
+ return `${base}/${idx}`;
506
+ })()}
507
+ target="_blank"
508
+ rel="noopener noreferrer"
509
+ className="text-accent underline"
510
+ >
511
+ View on PlotLink
512
+ </a>
513
+ )}
514
+ {fileData.txHash && (
515
+ <a
516
+ href={`https://basescan.org/tx/${fileData.txHash}`}
517
+ target="_blank"
518
+ rel="noopener noreferrer"
519
+ className="text-muted underline"
520
+ >
521
+ BaseScan
522
+ </a>
523
+ )}
524
+ {isGenesis && walletAddress && fileData.storylineId && (!fileData.authorAddress || fileData.authorAddress.toLowerCase() === walletAddress.toLowerCase()) && (
525
+ <button
526
+ onClick={() => setShowEditPanel((v) => !v)}
527
+ className="px-2 py-0.5 border border-border text-xs rounded hover:bg-surface"
528
+ >
529
+ {showEditPanel ? "Close Edit" : "Edit Story"}
530
+ </button>
531
+ )}
532
+ </div>
533
+ {/* Edit panel for published genesis files */}
534
+ {showEditPanel && isGenesis && fileData.storylineId && (
535
+ <div className="border border-border rounded p-3 flex flex-col gap-3 bg-surface">
536
+ {/* Cover image upload */}
537
+ <div className="flex flex-col gap-1.5">
538
+ <span className="text-xs font-medium text-foreground">Cover Image</span>
539
+ <div className="flex items-start gap-3">
540
+ {coverPreview && (
541
+ <div className="relative">
542
+ <img
543
+ src={coverPreview}
544
+ alt="Cover preview"
545
+ className="w-16 h-24 object-cover rounded border border-border"
546
+ />
547
+ <button
548
+ onClick={() => { setCoverFile(null); setCoverPreview(null); if (coverInputRef.current) coverInputRef.current.value = ""; }}
549
+ className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-error text-white rounded-full text-xs flex items-center justify-center"
550
+ >
551
+ x
552
+ </button>
553
+ </div>
554
+ )}
555
+ <div className="flex flex-col gap-1">
556
+ <input
557
+ ref={coverInputRef}
558
+ type="file"
559
+ accept="image/webp,image/jpeg,image/png"
560
+ onChange={handleCoverSelect}
561
+ className="text-xs"
562
+ />
563
+ <span className="text-xs text-muted">WebP/JPEG, max 500KB, 600x900px recommended</span>
564
+ </div>
565
+ </div>
566
+ </div>
567
+ {/* Genre & Language */}
568
+ <div className="flex items-center gap-2">
569
+ <select
570
+ value={editGenre}
571
+ onChange={(e) => setEditGenre(e.target.value)}
572
+ className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
573
+ >
574
+ {GENRES.map((g) => (
575
+ <option key={g} value={g}>{g}</option>
576
+ ))}
577
+ </select>
578
+ <select
579
+ value={editLanguage}
580
+ onChange={(e) => setEditLanguage(e.target.value)}
581
+ className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
582
+ >
583
+ {LANGUAGES.map((l) => (
584
+ <option key={l} value={l}>{l}</option>
585
+ ))}
586
+ </select>
587
+ </div>
588
+ {/* NSFW toggle */}
589
+ <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
590
+ <input
591
+ type="checkbox"
592
+ checked={editNsfw}
593
+ onChange={(e) => setEditNsfw(e.target.checked)}
594
+ className="rounded border-border"
595
+ />
596
+ This story contains adult content (18+)
597
+ </label>
598
+ {/* Save / status */}
599
+ <div className="flex items-center gap-2">
600
+ <button
601
+ onClick={handleEditSave}
602
+ disabled={editSaving || !editMetaLoaded}
603
+ className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50"
604
+ >
605
+ {editSaving ? "Saving..." : !editMetaLoaded ? "Loading..." : "Save Changes"}
606
+ </button>
607
+ {editSuccess && <span className="text-green-700 text-xs">Updated!</span>}
608
+ {editError && <span className="text-error text-xs">{editError}</span>}
609
+ </div>
610
+ </div>
398
611
  )}
399
612
  </div>
400
613
  ) : (
@@ -38,6 +38,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
38
38
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
39
39
  const [publishingFile, setPublishingFile] = useState<string | null>(null);
40
40
  const [publishProgress, setPublishProgress] = useState<string>("");
41
+ const [walletAddress, setWalletAddress] = useState<string | null>(null);
41
42
  const [ratio, setRatio] = useState(loadRatio);
42
43
  const [untitledSessions, setUntitledSessions] = useState<string[]>([]);
43
44
  const knownStoriesRef = useRef<Set<string>>(new Set());
@@ -45,6 +46,14 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
45
46
  const containerRef = useRef<HTMLDivElement>(null);
46
47
  const dragging = useRef(false);
47
48
 
49
+ // Fetch wallet address for edit panel authorship check
50
+ useEffect(() => {
51
+ authFetch("/api/wallet")
52
+ .then((res) => res.ok ? res.json() : null)
53
+ .then((data) => { if (data?.address) setWalletAddress(data.address); })
54
+ .catch(() => {});
55
+ }, [authFetch]);
56
+
48
57
  // Persist ratio to localStorage
49
58
  useEffect(() => {
50
59
  try { localStorage.setItem(STORAGE_KEY, String(ratio)); } catch { /* ignore */ }
@@ -249,6 +258,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
249
258
  contentCid: data.contentCid,
250
259
  gasCost: data.gasCost,
251
260
  indexError: data.indexError,
261
+ authorAddress: walletAddress,
252
262
  }),
253
263
  });
254
264
  }
@@ -351,6 +361,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
351
361
  authFetch={authFetch}
352
362
  onPublish={handlePublish}
353
363
  publishingFile={publishingFile}
364
+ walletAddress={walletAddress}
354
365
  />
355
366
  {publishProgress && (
356
367
  <div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">