diff --git a/CLAUDE.md b/CLAUDE.md index b87299f..ed7bc9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ The OWS passphrase is stored in plaintext in `~/.plotlink-ows/.env` as `OWS_PASS | `/api/publish/file` | POST | Publish story on-chain (SSE stream of progress events) | | `/api/publish/retry-index` | POST | Retry indexing for a published file | | `/api/publish/upload-cover` | POST | Upload cover image — FormData `file` field, **WebP or JPEG only**, max 500KB → returns `{ cid }` | +| `/api/publish/upload-plot-image` | POST | Upload plot illustration — FormData `file` field, **WebP or JPEG only**, max 500KB → returns `{ cid, url }` | | `/api/publish/update-storyline` | POST | Update storyline metadata (coverCid, genre, language, isNsfw) | **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. diff --git a/app/lib/generate-claude-md.ts b/app/lib/generate-claude-md.ts index 9ba5e49..bc0a5dd 100644 --- a/app/lib/generate-claude-md.ts +++ b/app/lib/generate-claude-md.ts @@ -43,6 +43,7 @@ For login, the passphrase is hashed with HMAC-SHA256 and compared against the st | \`/api/publish/file\` | POST | Publish story on-chain (SSE stream of progress events) | | \`/api/publish/retry-index\` | POST | Retry indexing for a published file | | \`/api/publish/upload-cover\` | POST | Upload cover image — FormData \`file\` field, **WebP or JPEG only**, max 500KB → returns \`{ cid }\` | +| \`/api/publish/upload-plot-image\` | POST | Upload plot illustration — FormData \`file\` field, **WebP or JPEG only**, max 500KB → returns \`{ cid, url }\` | | \`/api/publish/update-storyline\` | POST | Update storyline metadata (coverCid, genre, language, isNsfw) | **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. diff --git a/app/lib/publish.ts b/app/lib/publish.ts index a028cf4..cf9bae5 100644 --- a/app/lib/publish.ts +++ b/app/lib/publish.ts @@ -461,6 +461,41 @@ export async function uploadCoverImage( return data.cid; } +/** + * Upload a plot illustration image to PlotLink via signed API call. + * Returns the IPFS CID and URL of the uploaded image. + */ +export async function uploadPlotImage( + walletName: string, + walletAddress: `0x${string}`, + imageFile: File, +): Promise<{ cid: string; url: string }> { + const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz"; + const account = createOwsAccount(walletName, walletAddress); + + const timestamp = Date.now(); + const message = `PlotLink: Upload plot image\nTimestamp: ${timestamp}`; + const signature = await account.signMessage({ message }); + + const formData = new FormData(); + formData.append("file", imageFile); + formData.append("message", message); + formData.append("signature", signature); + + const res = await fetch(`${PLOTLINK_URL}/api/upload-plot-image`, { + method: "POST", + body: formData, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})) as Record; + throw new Error(err.error || `Plot image upload failed: HTTP ${res.status}`); + } + + const data = await res.json() as { cid: string; url: string }; + return data; +} + /** * Update storyline metadata (cover, genre, language, NSFW) on PlotLink via signed API call. * Uses createOwsAccount for signing (not raw owsSignMsg). diff --git a/app/routes/publish.ts b/app/routes/publish.ts index e5ead85..28d5393 100644 --- a/app/routes/publish.ts +++ b/app/routes/publish.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; -import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, updateStoryline } from "../lib/publish"; +import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, uploadPlotImage, updateStoryline } from "../lib/publish"; import { keccak256, toBytes } from "viem"; import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet"; @@ -231,6 +231,41 @@ publish.post("/upload-cover", async (c) => { } }); +/** POST /api/publish/upload-plot-image — upload plot illustration with wallet signature */ +publish.post("/upload-plot-image", async (c) => { + try { + const wallets = listAgentWallets(); + const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer")); + if (!wallet) return c.json({ error: "No OWS wallet" }, 400); + + const address = getBaseAddress(wallet); + if (!address) return c.json({ error: "No EVM address on wallet" }, 400); + + const formData = await c.req.formData(); + const file = formData.get("file"); + if (!file || !(file instanceof File)) { + return c.json({ error: "No image file provided" }, 400); + } + + // Validate file size (500KB max) + if (file.size > 500 * 1024) { + return c.json({ error: "Image exceeds 500KB limit" }, 400); + } + + // Validate file type — only WebP and JPEG accepted by the plotlink server + const allowedTypes = ["image/webp", "image/jpeg"]; + if (!allowedTypes.includes(file.type)) { + return c.json({ error: "Only WebP and JPEG images are accepted" }, 400); + } + + const result = await uploadPlotImage(wallet.name, address as `0x${string}`, file); + return c.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Plot image upload failed"; + return c.json({ error: message }, 500); + } +}); + /** POST /api/publish/update-storyline — update storyline metadata with wallet signature */ publish.post("/update-storyline", async (c) => { try { diff --git a/app/web/components/PreviewPanel.tsx b/app/web/components/PreviewPanel.tsx index c4cfb3f..1944158 100644 --- a/app/web/components/PreviewPanel.tsx +++ b/app/web/components/PreviewPanel.tsx @@ -56,6 +56,14 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis const [editSuccess, setEditSuccess] = useState(false); const coverInputRef = useRef(null); + // Inline illustration state + const [showIllustrations, setShowIllustrations] = useState(false); + const [illustrationUploading, setIllustrationUploading] = useState(false); + const [illustrationError, setIllustrationError] = useState(null); + const [uploadedImages, setUploadedImages] = useState>([]); + const [copiedIndex, setCopiedIndex] = useState(null); + const illustrationInputRef = useRef(null); + const prevFileRef = useRef(null); const loadFile = useCallback(async () => { @@ -153,6 +161,45 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis setEditError(null); }, []); + // Handle illustration image upload from File object + const uploadIllustration = useCallback(async (file: File) => { + if (file.size > 500 * 1024) { + setIllustrationError("Image exceeds 500KB limit"); + return; + } + const allowedTypes = ["image/webp", "image/jpeg"]; + if (!allowedTypes.includes(file.type)) { + setIllustrationError("Only WebP and JPEG images are accepted"); + return; + } + setIllustrationUploading(true); + setIllustrationError(null); + try { + const formData = new FormData(); + formData.append("file", file); + const res = await authFetch("/api/publish/upload-plot-image", { + method: "POST", + body: formData, + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Upload failed"); + } + const data = await res.json(); + setUploadedImages((prev) => [...prev, { cid: data.cid, url: data.url }]); + } catch (err) { + setIllustrationError(err instanceof Error ? err.message : "Upload failed"); + } finally { + setIllustrationUploading(false); + if (illustrationInputRef.current) illustrationInputRef.current.value = ""; + } + }, [authFetch]); + + const handleIllustrationInput = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) uploadIllustration(file); + }, [uploadIllustration]); + // Save storyline edits (cover upload + metadata update) const handleEditSave = useCallback(async () => { if (!fileData?.storylineId) return; @@ -215,6 +262,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis setEditError(null); setEditSuccess(false); setEditMetaLoaded(false); + setShowIllustrations(false); + setUploadedImages([]); + setIllustrationError(null); }, [storyName, fileName]); // Fetch current storyline metadata when edit panel opens @@ -407,6 +457,70 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis {saving ? "Saving..." : "Save"} + {/* Inline illustration upload for plot files */} + {isPlot && ( +
+ + {showIllustrations && ( +
+
illustrationInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + const file = e.dataTransfer.files?.[0]; + if (file) uploadIllustration(file); + }} + > + + + {illustrationUploading ? "Uploading..." : "Drop image here or click to browse"} + + WebP/JPEG, max 500KB +
+ {illustrationError && ( + {illustrationError} + )} + {uploadedImages.map((img, i) => ( +
+ Image uploaded! Copy the markdown below and paste it where you want the illustration to appear in your plot: +
+ + ![Scene description]({img.url}) + + +
+
+ ))} +
+ )} +
+ )} )} diff --git a/package.json b/package.json index 3d8de2a..a90fe86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink-ows", - "version": "1.0.29", + "version": "1.0.30", "bin": { "plotlink-ows": "./bin/plotlink-ows.js" },