Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions app/lib/generate-claude-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions app/lib/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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).
Expand Down
37 changes: 36 additions & 1 deletion app/routes/publish.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 {
Expand Down
114 changes: 114 additions & 0 deletions app/web/components/PreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
const [editSuccess, setEditSuccess] = useState(false);
const coverInputRef = useRef<HTMLInputElement>(null);

// Inline illustration state
const [showIllustrations, setShowIllustrations] = useState(false);
const [illustrationUploading, setIllustrationUploading] = useState(false);
const [illustrationError, setIllustrationError] = useState<string | null>(null);
const [uploadedImages, setUploadedImages] = useState<Array<{ cid: string; url: string }>>([]);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const illustrationInputRef = useRef<HTMLInputElement>(null);

const prevFileRef = useRef<string | null>(null);

const loadFile = useCallback(async () => {
Expand Down Expand Up @@ -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<HTMLInputElement>) => {
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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -407,6 +457,70 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
{saving ? "Saving..." : "Save"}
</button>
</div>
{/* Inline illustration upload for plot files */}
{isPlot && (
<div className="px-3 py-1.5 border-t border-border">
<label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
<input
type="checkbox"
checked={showIllustrations}
onChange={(e) => setShowIllustrations(e.target.checked)}
className="rounded border-border"
/>
Add illustrations in the plot
</label>
{showIllustrations && (
<div className="mt-2 flex flex-col gap-2">
<div
className="border-2 border-dashed border-border rounded p-3 flex flex-col items-center gap-1.5 cursor-pointer hover:border-accent transition-colors"
onClick={() => 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);
}}
>
<input
ref={illustrationInputRef}
type="file"
accept="image/webp,image/jpeg"
onChange={handleIllustrationInput}
className="hidden"
/>
<span className="text-xs text-muted">
{illustrationUploading ? "Uploading..." : "Drop image here or click to browse"}
</span>
<span className="text-xs text-muted">WebP/JPEG, max 500KB</span>
</div>
{illustrationError && (
<span className="text-error text-xs">{illustrationError}</span>
)}
{uploadedImages.map((img, i) => (
<div key={img.cid} className="border border-border rounded p-2 flex flex-col gap-1 bg-surface">
<span className="text-xs text-green-700">Image uploaded! Copy the markdown below and paste it where you want the illustration to appear in your plot:</span>
<div className="flex items-center gap-1.5">
<code className="flex-1 text-xs bg-background px-2 py-1 rounded font-mono break-all">
![Scene description]({img.url})
</code>
<button
onClick={() => {
navigator.clipboard.writeText(`![Scene description](${img.url})`);
setCopiedIndex(i);
setTimeout(() => setCopiedIndex(null), 2000);
}}
className="px-2 py-1 text-xs border border-border rounded hover:bg-surface shrink-0"
>
{copiedIndex === i ? "Copied!" : "Copy"}
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink-ows",
"version": "1.0.29",
"version": "1.0.30",
"bin": {
"plotlink-ows": "./bin/plotlink-ows.js"
},
Expand Down
Loading