plotlink-ows 1.0.24 → 1.0.26

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.
@@ -3,13 +3,15 @@ import ReactMarkdown from "react-markdown";
3
3
  import remarkBreaks from "remark-breaks";
4
4
  import remarkGfm from "remark-gfm";
5
5
  import rehypeSanitize from "rehype-sanitize";
6
+ import { GENRES, LANGUAGES } from "../../../lib/genres";
6
7
 
7
8
  interface PreviewPanelProps {
8
9
  storyName: string | null;
9
10
  fileName: string | null;
10
11
  authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
11
- onPublish?: (storyName: string, fileName: string) => void;
12
+ onPublish?: (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => void;
12
13
  publishingFile?: string | null;
14
+ walletAddress?: string | null;
13
15
  }
14
16
 
15
17
  interface FileData {
@@ -21,11 +23,12 @@ interface FileData {
21
23
  plotIndex?: number;
22
24
  indexError?: string;
23
25
  publishedAt?: string;
26
+ authorAddress?: string;
24
27
  }
25
28
 
26
29
  type Tab = "preview" | "edit";
27
30
 
28
- export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publishingFile }: PreviewPanelProps) {
31
+ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publishingFile, walletAddress }: PreviewPanelProps) {
29
32
  const [fileData, setFileData] = useState<FileData | null>(null);
30
33
  const [loading, setLoading] = useState(false);
31
34
  const [activeTab, setActiveTab] = useState<Tab>("preview");
@@ -34,9 +37,25 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
34
37
  const [dirty, setDirty] = useState(false);
35
38
  const [retrying, setRetrying] = useState(false);
36
39
  const [indexTimeLeft, setIndexTimeLeft] = useState<number | null>(null);
40
+ const [selectedGenre, setSelectedGenre] = useState(GENRES[0]);
41
+ const [selectedLanguage, setSelectedLanguage] = useState(LANGUAGES[0]);
42
+ const [isNsfw, setIsNsfw] = useState(false);
37
43
  const textareaRef = useRef<HTMLTextAreaElement>(null);
38
44
  const dirtyRef = useRef(false);
39
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
+
40
59
  const prevFileRef = useRef<string | null>(null);
41
60
 
42
61
  const loadFile = useCallback(async () => {
@@ -75,6 +94,31 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
75
94
  return () => clearInterval(interval);
76
95
  }, [storyName, fileName, loadFile, activeTab, dirty]);
77
96
 
97
+ // Auto-detect genre from structure.md when story changes
98
+ useEffect(() => {
99
+ if (!storyName) return;
100
+ let cancelled = false;
101
+ authFetch(`/api/stories/${storyName}/structure.md`)
102
+ .then((res) => res.ok ? res.json() : null)
103
+ .then((data) => {
104
+ if (cancelled || !data?.content) return;
105
+ const match = data.content.match(/\*{0,2}genre\*{0,2}[:\s]+(.+)/i);
106
+ if (match) {
107
+ const detected = match[1].replace(/\*+/g, "").trim();
108
+ const found = GENRES.find((g) => g.toLowerCase() === detected.toLowerCase());
109
+ if (found) setSelectedGenre(found);
110
+ }
111
+ const langMatch = data.content.match(/\*{0,2}language\*{0,2}[:\s]+(.+)/i);
112
+ if (langMatch) {
113
+ const detected = langMatch[1].replace(/\*+/g, "").trim();
114
+ const found = LANGUAGES.find((l) => l.toLowerCase() === detected.toLowerCase());
115
+ if (found) setSelectedLanguage(found);
116
+ }
117
+ })
118
+ .catch(() => {});
119
+ return () => { cancelled = true; };
120
+ }, [storyName, authFetch]);
121
+
78
122
  const handleSave = useCallback(async () => {
79
123
  if (!storyName || !fileName) return;
80
124
  setSaving(true);
@@ -92,6 +136,118 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
92
136
  setSaving(false);
93
137
  }, [storyName, fileName, authFetch, editContent]);
94
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
+
95
251
  // Ctrl+S / Cmd+S to save
96
252
  useEffect(() => {
97
253
  if (activeTab !== "edit") return;
@@ -303,7 +459,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
303
459
  )}
304
460
  {isPlot && (
305
461
  <button
306
- onClick={() => storyName && fileName && onPublish?.(storyName, fileName)}
462
+ onClick={() => storyName && fileName && onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw)}
307
463
  disabled={!!publishingFile}
308
464
  className="px-3 py-1 border border-border text-xs rounded hover:bg-surface disabled:opacity-50"
309
465
  >
@@ -335,50 +491,176 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
335
491
  )}
336
492
  </div>
337
493
  ) : fileData?.status === "published" ? (
338
- <div className="flex items-center gap-2 text-xs">
339
- <span className="text-green-700">Published</span>
340
- {fileData.storylineId && (
341
- <a
342
- href={(() => {
343
- const base = `https://plotlink.xyz/story/${fileData.storylineId}`;
344
- if (!isPlot) return base;
345
- // plotIndex convention: contract emits 0-based (genesis=0, plot-01=1)
346
- // plotlink.xyz URLs use the same 0-based index
347
- // Filename fallback: plot-01.md → parseInt("01") = 1 (matches contract)
348
- const idx = fileData.plotIndex != null && fileData.plotIndex > 0
349
- ? fileData.plotIndex
350
- : parseInt(fileName?.match(/^plot-(\d+)\.md$/)?.[1] ?? "1");
351
- return `${base}/${idx}`;
352
- })()}
353
- target="_blank"
354
- rel="noopener noreferrer"
355
- className="text-accent underline"
356
- >
357
- View on PlotLink
358
- </a>
359
- )}
360
- {fileData.txHash && (
361
- <a
362
- href={`https://basescan.org/tx/${fileData.txHash}`}
363
- target="_blank"
364
- rel="noopener noreferrer"
365
- className="text-muted underline"
366
- >
367
- BaseScan
368
- </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>
369
611
  )}
370
612
  </div>
371
613
  ) : (
372
- <div className="flex items-center gap-2">
373
- <button
374
- onClick={() => storyName && fileName && onPublish?.(storyName, fileName)}
375
- disabled={!!publishingFile || overLimit}
376
- className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
377
- >
378
- {publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
379
- </button>
380
- {overLimit && (
381
- <span className="text-error text-xs">Reduce content to publish</span>
614
+ <div className="flex flex-col gap-2">
615
+ <div className="flex items-center gap-2">
616
+ {(isGenesis) && (
617
+ <>
618
+ <select
619
+ value={selectedGenre}
620
+ onChange={(e) => setSelectedGenre(e.target.value)}
621
+ className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
622
+ >
623
+ {GENRES.map((g) => (
624
+ <option key={g} value={g}>{g}</option>
625
+ ))}
626
+ </select>
627
+ <select
628
+ value={selectedLanguage}
629
+ onChange={(e) => setSelectedLanguage(e.target.value)}
630
+ className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
631
+ >
632
+ {LANGUAGES.map((l) => (
633
+ <option key={l} value={l}>{l}</option>
634
+ ))}
635
+ </select>
636
+ </>
637
+ )}
638
+ <button
639
+ onClick={() => storyName && fileName && onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw)}
640
+ disabled={!!publishingFile || overLimit}
641
+ className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
642
+ >
643
+ {publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
644
+ </button>
645
+ {overLimit && (
646
+ <span className="text-error text-xs">Reduce content to publish</span>
647
+ )}
648
+ </div>
649
+ {(isGenesis) && (
650
+ <div className="flex items-center gap-2">
651
+ <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
652
+ <input
653
+ type="checkbox"
654
+ checked={isNsfw}
655
+ onChange={(e) => setIsNsfw(e.target.checked)}
656
+ className="rounded border-border"
657
+ />
658
+ This story contains adult content (18+)
659
+ </label>
660
+ {isNsfw && (
661
+ <span className="text-xs text-amber-600">Adult content will be hidden from the default browse view.</span>
662
+ )}
663
+ </div>
382
664
  )}
383
665
  </div>
384
666
  )}
@@ -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 */ }
@@ -177,7 +186,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
177
186
  window.addEventListener("mouseup", onMouseUp);
178
187
  }, []);
179
188
 
180
- const handlePublish = useCallback(async (storyName: string, fileName: string) => {
189
+ const handlePublish = useCallback(async (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => {
181
190
  setPublishingFile(fileName);
182
191
  setPublishProgress("Reading file...");
183
192
 
@@ -191,17 +200,6 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
191
200
  const titleMatch = fileData.content.match(/^#\s+(.+)$/m);
192
201
  const title = titleMatch ? titleMatch[1].slice(0, 60) : fileName.replace(".md", "");
193
202
 
194
- // Determine genre from structure.md if available
195
- let genre = "Fiction";
196
- try {
197
- const structRes = await authFetch(`/api/stories/${storyName}/structure.md`);
198
- if (structRes.ok) {
199
- const structData = await structRes.json();
200
- const genreMatch = structData.content.match(/genre[:\s]+(.+)/i);
201
- if (genreMatch) genre = genreMatch[1].trim().slice(0, 30);
202
- }
203
- } catch { /* ignore */ }
204
-
205
203
  // For plot files, find the storylineId from the genesis publish status
206
204
  let storylineId: number | undefined;
207
205
  if (fileName.match(/^plot-\d+\.md$/)) {
@@ -226,7 +224,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
226
224
  const publishRes = await authFetch("/api/publish/file", {
227
225
  method: "POST",
228
226
  headers: { "Content-Type": "application/json" },
229
- body: JSON.stringify({ storyName, fileName, title, content: fileData.content, genre, storylineId }),
227
+ body: JSON.stringify({ storyName, fileName, title, content: fileData.content, genre, language, isNsfw, storylineId }),
230
228
  });
231
229
 
232
230
  if (!publishRes.ok) {
@@ -260,6 +258,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
260
258
  contentCid: data.contentCid,
261
259
  gasCost: data.gasCost,
262
260
  indexError: data.indexError,
261
+ authorAddress: walletAddress,
263
262
  }),
264
263
  });
265
264
  }
@@ -362,6 +361,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
362
361
  authFetch={authFetch}
363
362
  onPublish={handlePublish}
364
363
  publishingFile={publishingFile}
364
+ walletAddress={walletAddress}
365
365
  />
366
366
  {publishProgress && (
367
367
  <div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">