From 4987dd05cadf839d6c957c75c35f77f1dfcfcbf4 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 13:32:09 +0000 Subject: [PATCH 1/2] [#206] Implement Create Storyline flow with 5-state publishing machine - Create /create route with title, content textarea (Unicode char counter), and 72h deadline toggle - Implement usePublishStoryline hook with 5 states: uploading -> confirming -> pending -> indexing -> published - Wire Filebase upload via /api/upload server route (keeps S3 keys server-side) - Call createStoryline() on StoryFactory via wagmi writeContractAsync - Trigger storyline indexer after tx confirmation - Cache CID in ref for retry (skip re-upload on wallet rejection) - Update STORY_FACTORY constant to read from NEXT_PUBLIC_CONTRACT_ADDRESS env - Wallet-gated with ConnectWallet prompt - Terminal aesthetic form styling Fixes #206 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/contracts/constants.ts | 3 +- src/app/api/upload/route.ts | 20 +++++ src/app/create/page.tsx | 164 ++++++++++++++++++++++++++++++++++++ src/hooks/usePublish.ts | 119 ++++++++++++++++++++++++++ 4 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 src/app/api/upload/route.ts create mode 100644 src/app/create/page.tsx create mode 100644 src/hooks/usePublish.ts diff --git a/lib/contracts/constants.ts b/lib/contracts/constants.ts index 3b147818..3aa322dc 100644 --- a/lib/contracts/constants.ts +++ b/lib/contracts/constants.ts @@ -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; diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 00000000..edcaaad7 --- /dev/null +++ b/src/app/api/upload/route.ts @@ -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 }); + } +} diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx new file mode 100644 index 00000000..67ae38a2 --- /dev/null +++ b/src/app/create/page.tsx @@ -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 = { + 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 ( +
+

+ Connect your wallet to create a storyline. +

+ +
+ ); + } + + if (state === "published") { + return ( +
+

Storyline created!

+
+ + Discover + + +
+
+ ); + } + + const busy = state !== "idle" && state !== "error"; + + return ( +
+

+ Create Storyline +

+ +
{ + e.preventDefault(); + if (canSubmit) publish(title.trim(), content, hasDeadline); + }} + className="mt-8 space-y-6" + > + {/* Title */} +
+ + 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" + /> +
+ + {/* Content */} +
+ +