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.
- package/README.md +44 -0
- package/app/lib/publish.ts +85 -5
- package/app/routes/publish.ts +81 -1
- package/app/routes/stories.ts +3 -0
- package/app/web/components/Layout.tsx +11 -0
- package/app/web/components/PreviewPanel.tsx +326 -44
- package/app/web/components/StoriesPage.tsx +13 -13
- package/app/web/dist/assets/index-9T4gFznD.js +130 -0
- package/app/web/dist/assets/index-CXg4YULp.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/packages/cli/src/commands/create.ts +3 -0
- package/app/web/dist/assets/index-CU2rL-Z3.js +0 -130
- package/app/web/dist/assets/index-DHjiVVCV.css +0 -32
|
@@ -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
|
|
339
|
-
<
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
373
|
-
<
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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">
|