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
3 changes: 2 additions & 1 deletion lib/contracts/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export const BASE_CHAIN_ID = 8453;
/** StoryFactory — storyline + plot management
* Base Sepolia: 0x05C4d59529807316D6fA09cdaA509adDfe85b474
* Base Mainnet: TBD (replace after mainnet deployment) */
export const STORY_FACTORY = "0x0000000000000000000000000000000000000000" as const;
export const STORY_FACTORY = (process.env.NEXT_PUBLIC_CONTRACT_ADDRESS ??
"0x0000000000000000000000000000000000000000") as `0x${string}`;

/** ZapPlotLinkMCV2 — one-click buy (ETH/USDC/HUNT -> storyline token) */
export const ZAP_PLOTLINK = "0x0000000000000000000000000000000000000000" as const;
Expand Down
20 changes: 20 additions & 0 deletions src/app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { uploadWithRetry } from "../../../../lib/filebase";

export async function POST(req: NextRequest) {
try {
const { content, key } = await req.json();
if (!content || !key) {
return NextResponse.json(
{ error: "Missing content or key" },
{ status: 400 },
);
}

const cid = await uploadWithRetry(content, key);
return NextResponse.json({ cid });
} catch (err) {
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}
164 changes: 164 additions & 0 deletions src/app/create/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"use client";

import { useState } from "react";
import { useAccount } from "wagmi";
import { ConnectWallet } from "../../components/ConnectWallet";
import {
validateContentLength,
MIN_CONTENT_LENGTH,
MAX_CONTENT_LENGTH,
} from "../../../lib/content";
import { usePublishStoryline, type PublishState } from "../../hooks/usePublish";
import Link from "next/link";

const STATE_LABELS: Record<PublishState, string> = {
idle: "",
uploading: "Uploading to IPFS...",
confirming: "Confirm in wallet...",
pending: "Publishing to Base...",
indexing: "Indexing...",
published: "Published!",
error: "Error",
};

export default function CreateStorylinePage() {
const { isConnected } = useAccount();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [hasDeadline, setHasDeadline] = useState(false);

const { state, error, publish, reset } = usePublishStoryline();
const { valid, charCount } = validateContentLength(content);
const titleValid = title.trim().length > 0;
const canSubmit =
state === "idle" || state === "error"
? titleValid && valid
: false;

if (!isConnected) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6">
<p className="text-muted text-sm">
Connect your wallet to create a storyline.
</p>
<ConnectWallet />
</div>
);
}

if (state === "published") {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6">
<h1 className="text-accent text-2xl font-bold">Storyline created!</h1>
<div className="flex gap-3">
<Link
href="/discover"
className="border-border text-muted hover:text-foreground rounded border px-4 py-2 text-sm transition-colors"
>
Discover
</Link>
<button
onClick={reset}
className="border-accent text-accent hover:bg-accent hover:text-background rounded border px-4 py-2 text-sm transition-colors"
>
Create another
</button>
</div>
</div>
);
}

const busy = state !== "idle" && state !== "error";

return (
<div className="mx-auto max-w-2xl px-6 py-12">
<h1 className="text-accent text-2xl font-bold tracking-tight">
Create Storyline
</h1>

<form
onSubmit={(e) => {
e.preventDefault();
if (canSubmit) publish(title.trim(), content, hasDeadline);
}}
className="mt-8 space-y-6"
>
{/* Title */}
<div>
<label className="text-foreground mb-2 block text-sm">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={busy}
placeholder="Enter storyline title"
className="border-border bg-surface text-foreground placeholder:text-muted w-full rounded border px-3 py-2 text-sm focus:border-accent focus:outline-none disabled:opacity-50"
/>
</div>

{/* Content */}
<div>
<label className="text-foreground mb-2 block text-sm">
Opening Chapter
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
rows={12}
placeholder="Write the genesis plot (500–10,000 characters)"
className="border-border bg-surface text-foreground placeholder:text-muted w-full resize-y rounded border px-3 py-2 text-sm leading-relaxed focus:border-accent focus:outline-none disabled:opacity-50"
/>
<div className="mt-1 flex justify-between text-xs">
<span
className={
content.length > 0 && !valid ? "text-error" : "text-muted"
}
>
{charCount.toLocaleString()} / {MIN_CONTENT_LENGTH.toLocaleString()}–
{MAX_CONTENT_LENGTH.toLocaleString()} chars
</span>
</div>
</div>

{/* Deadline toggle */}
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={hasDeadline}
onChange={(e) => setHasDeadline(e.target.checked)}
disabled={busy}
className="accent-accent h-4 w-4"
/>
<span className="text-foreground text-sm">
Enable 72h deadline
</span>
<span className="text-muted text-xs">
Story sunsets if no new plot within 72 hours
</span>
</label>

{/* Status */}
{state === "error" && (
<div className="border-error/30 text-error rounded border px-3 py-2 text-xs">
{error}
</div>
)}
{busy && (
<div className="border-border text-muted rounded border px-3 py-2 text-xs">
{STATE_LABELS[state]}
</div>
)}

{/* Submit */}
<button
type="submit"
disabled={!canSubmit || busy}
className="border-accent text-accent hover:bg-accent hover:text-background w-full rounded border py-2.5 text-sm font-medium transition-colors disabled:opacity-50"
>
{busy ? STATE_LABELS[state] : "Publish Storyline"}
</button>
</form>
</div>
);
}
110 changes: 110 additions & 0 deletions src/hooks/usePublish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use client";

import { useState, useCallback, useRef } from "react";
import { useWriteContract } from "wagmi";
import { storyFactoryAbi } from "../../lib/contracts/abi";
import { STORY_FACTORY } from "../../lib/contracts/constants";
import { hashContent } from "../../lib/content";
import { publicClient } from "../../lib/rpc";
import type { Hex } from "viem";

export type PublishState =
| "idle"
| "uploading"
| "confirming"
| "pending"
| "indexing"
| "published"
| "error";

interface PublishResult {
state: PublishState;
error: string | null;
txHash: Hex | undefined;
publish: (
title: string,
content: string,
hasDeadline: boolean,
) => Promise<void>;
reset: () => void;
}

export function usePublishStoryline(): PublishResult {
const [state, setState] = useState<PublishState>("idle");
const [error, setError] = useState<string | null>(null);
const cachedCid = useRef<{ cid: string; contentHash: string } | null>(null);

const { writeContractAsync, data: txHash } = useWriteContract();

const publish = useCallback(
async (title: string, content: string, hasDeadline: boolean) => {
try {
setError(null);
const contentHash = hashContent(content);

// 1. Upload to IPFS (reuse cached CID only if content unchanged)
let cid: string;
if (
cachedCid.current &&
cachedCid.current.contentHash === contentHash
) {
cid = cachedCid.current.cid;
} else {
setState("uploading");
const res = await fetch("/api/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content,
key: `plotlink/genesis/${Date.now()}.txt`,
}),
});
if (!res.ok) throw new Error("IPFS upload failed");
const data = await res.json();
cid = data.cid as string;
cachedCid.current = { cid, contentHash };
}

// 2. Submit tx to wallet
setState("confirming");

const hash = await writeContractAsync({
address: STORY_FACTORY,
abi: storyFactoryAbi,
functionName: "createStoryline",
args: [title, cid, contentHash, hasDeadline],
});

// 3. Wait for tx confirmation via viem publicClient
setState("pending");
await publicClient.waitForTransactionReceipt({ hash });

// 4. Trigger indexer
setState("indexing");
await fetch("/api/index/storyline", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ txHash: hash, content }),
});

// 5. Done
setState("published");
cachedCid.current = null;
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown error";
setError(message);
setState("error");
}
},
[writeContractAsync],
);

const reset = useCallback(() => {
setState("idle");
setError(null);
cachedCid.current = null;
}, []);

return { state, error, txHash, publish, reset };
}
Loading