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.
- package/app/lib/publish.ts +71 -0
- package/app/routes/publish.ts +77 -1
- package/app/routes/stories.ts +3 -0
- package/app/web/components/PreviewPanel.tsx +245 -32
- package/app/web/components/StoriesPage.tsx +11 -0
- package/app/web/dist/assets/{index-DWqOuJeA.js → index-9T4gFznD.js} +40 -40
- package/app/web/dist/assets/index-CXg4YULp.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-DHjiVVCV.css +0 -32
package/app/lib/publish.ts
CHANGED
|
@@ -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
|
+
}
|
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 } 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 };
|
package/app/routes/stories.ts
CHANGED
|
@@ -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
|
|
368
|
-
<
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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">
|