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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,13 @@ You focus on the writing. The human handles publishing.

### After Publishing

After a storyline is created, inform the user they can customize it on the PlotLink website:
After a storyline is created, inform the user they can customize it directly in the OWS app:

- **Story URL**: `https://plotlink.xyz/story/{storylineId}`
- The **Edit Details** button is available to the story author when connected with their wallet
- The **Edit Story** button appears in the preview panel for published genesis files
- Editable fields: cover image (WebP/JPEG, max 500KB, recommended 600x900px), genre, language, NSFW flag
- Uploading a cover image significantly improves the story's visibility on PlotLink
- All edits are signed with the OWS wallet and sent to PlotLink automatically

## Content Flags

Expand Down
71 changes: 71 additions & 0 deletions app/lib/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,74 @@ export async function publishPlot(

return { txHash, contentCid, storylineId, plotIndex: confirmation.plotIndex >= 0 ? confirmation.plotIndex : undefined, gasCost: confirmation.gasCost, indexError };
}

/**
* Upload a cover image to PlotLink via signed API call.
* Uses createOwsAccount for signing (not raw owsSignMsg).
* Returns the IPFS CID of the uploaded image.
*/
export async function uploadCoverImage(
walletName: string,
walletAddress: `0x${string}`,
imageFile: File,
): Promise<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 cover 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-cover`, {
method: "POST",
body: formData,
});

if (!res.ok) {
const err = await res.json().catch(() => ({})) as Record<string, string>;
throw new Error(err.error || `Cover upload failed: HTTP ${res.status}`);
}

const data = await res.json() as { cid: string };
return data.cid;
}

/**
* Update storyline metadata (cover, genre, language, NSFW) on PlotLink via signed API call.
* Uses createOwsAccount for signing (not raw owsSignMsg).
* Message format must match: /^PlotLink: Update storyline #(\d+)\nTimestamp: (\d+)$/
*/
export async function updateStoryline(
walletName: string,
walletAddress: `0x${string}`,
storylineId: number,
updates: { coverCid?: string | null; genre?: string; language?: string; isNsfw?: boolean },
): Promise<void> {
const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
const account = createOwsAccount(walletName, walletAddress);

const timestamp = Date.now();
const message = `PlotLink: Update storyline #${storylineId}\nTimestamp: ${timestamp}`;
const signature = await account.signMessage({ message });

const res = await fetch(`${PLOTLINK_URL}/api/storyline/update`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
storylineId,
signature,
message,
...updates,
}),
});

if (!res.ok) {
const err = await res.json().catch(() => ({})) as Record<string, string>;
throw new Error(err.error || `Storyline update failed: HTTP ${res.status}`);
}
}
77 changes: 76 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 } from "../lib/publish";
import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost, uploadCoverImage, updateStoryline } from "../lib/publish";
import { keccak256, toBytes } from "viem";
import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";

Expand Down Expand Up @@ -196,4 +196,79 @@ publish.post("/retry-index", async (c) => {
}
});

/** POST /api/publish/upload-cover — upload cover image with wallet signature */
publish.post("/upload-cover", 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
if (!file.type.startsWith("image/")) {
return c.json({ error: "File must be an image (WebP or JPEG recommended)" }, 400);
}

const cid = await uploadCoverImage(wallet.name, address as `0x${string}`, file);
return c.json({ cid });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Cover 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 {
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 body = await c.req.json<{
storylineId: number;
coverCid?: string | null;
genre?: string;
language?: string;
isNsfw?: boolean;
}>();

if (!body.storylineId) {
return c.json({ error: "storylineId required" }, 400);
}

await updateStoryline(
wallet.name,
address as `0x${string}`,
body.storylineId,
{
coverCid: body.coverCid,
genre: body.genre,
language: body.language,
isNsfw: body.isNsfw,
},
);

return c.json({ ok: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Update failed";
return c.json({ error: message }, 500);
}
});

export { publish as publishRoutes };
3 changes: 3 additions & 0 deletions app/routes/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface FileStatus {
publishedAt?: string;
gasCost?: string;
indexError?: string;
authorAddress?: string;
}

interface StoryInfo {
Expand Down Expand Up @@ -243,6 +244,7 @@ stories.post("/:name/:file/publish-status", async (c) => {
contentCid: string;
gasCost?: string;
indexError?: string;
authorAddress?: string;
}>();

const status = readPublishStatus(storyDir);
Expand All @@ -255,6 +257,7 @@ stories.post("/:name/:file/publish-status", async (c) => {
plotIndex: body.plotIndex ?? existing?.plotIndex,
contentCid: body.contentCid || existing?.contentCid,
gasCost: body.gasCost || existing?.gasCost,
authorAddress: body.authorAddress || existing?.authorAddress,
publishedAt: new Date().toISOString(),
...(body.indexError ? { indexError: body.indexError } : {}),
};
Expand Down
Loading
Loading