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
191 changes: 191 additions & 0 deletions src/app/chain/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"use client";

import { useState } from "react";
import { useAccount } from "wagmi";
import { useQuery } from "@tanstack/react-query";
import { ConnectWallet } from "../../components/ConnectWallet";
import {
validateContentLength,
MIN_CONTENT_LENGTH,
MAX_CONTENT_LENGTH,
} from "../../../lib/content";
import { supabase, type Storyline } from "../../../lib/supabase";
import { useChainPlot } from "../../hooks/useChainPlot";
import 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",
};

async function fetchWriterStorylines(address: string): Promise<Storyline[]> {
if (!supabase) return [];
const { data } = await supabase
.from("storylines")
.select("*")
.eq("writer_address", address.toLowerCase())
.eq("hidden", false)
.eq("sunset", false)
.order("block_timestamp", { ascending: false })
.returns<Storyline[]>();
return data ?? [];
}

export default function ChainPlotPage() {
const { address, isConnected } = useAccount();
const [storylineId, setStorylineId] = useState<number | null>(null);
const [content, setContent] = useState("");

const { data: storylines = [], isLoading: loadingStorylines } = useQuery({
queryKey: ["writer-active-storylines", address],
queryFn: () => fetchWriterStorylines(address!),
enabled: isConnected && !!address,
});

const { state, error, chainPlot, reset } = useChainPlot();
const { valid, charCount } = validateContentLength(content);
const canSubmit =
(state === "idle" || state === "error") &&
storylineId !== null &&
valid;

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 chain a plot.
</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">Plot chained!</h1>
<div className="flex gap-3">
{storylineId && (
<Link
href={`/story/${storylineId}`}
className="border-border text-muted hover:text-foreground rounded border px-4 py-2 text-sm transition-colors"
>
View story
</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"
>
Chain 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">
Chain Plot
</h1>

<form
onSubmit={(e) => {
e.preventDefault();
if (canSubmit) chainPlot(storylineId, content);
}}
className="mt-8 space-y-6"
>
{/* Storyline selector */}
<div>
<label className="text-foreground mb-2 block text-sm">
Storyline
</label>
{loadingStorylines ? (
<p className="text-muted text-sm">Loading storylines...</p>
) : storylines.length === 0 ? (
<p className="text-muted text-sm">
No active storylines.{" "}
<Link href="/create" className="text-accent hover:underline">
Create one
</Link>
</p>
) : (
<select
value={storylineId ?? ""}
onChange={(e) =>
setStorylineId(e.target.value ? Number(e.target.value) : null)
}
disabled={busy}
className="border-border bg-surface text-foreground w-full rounded border px-3 py-2 text-sm focus:border-accent focus:outline-none disabled:opacity-50"
>
<option value="">Select a storyline</option>
{storylines.map((s) => (
<option key={s.id} value={s.storyline_id}>
{s.title} ({s.plot_count}{" "}
{s.plot_count === 1 ? "plot" : "plots"})
</option>
))}
</select>
)}
</div>

{/* Content */}
<div>
<label className="text-foreground mb-2 block text-sm">
Next Chapter
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
rows={12}
placeholder="Write the next 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 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>

{/* 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] : "Chain Plot"}
</button>
</form>
</div>
);
}
19 changes: 16 additions & 3 deletions src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
MIN_CONTENT_LENGTH,
MAX_CONTENT_LENGTH,
} from "../../../lib/content";
import { usePublishStoryline, type PublishState } from "../../hooks/usePublish";
import { usePublish, type PublishState } from "../../hooks/usePublish";
import { storyFactoryAbi } from "../../../lib/contracts/abi";
import { STORY_FACTORY } from "../../../lib/contracts/constants";
import Link from "next/link";

const STATE_LABELS: Record<PublishState, string> = {
Expand All @@ -27,7 +29,7 @@ export default function CreateStorylinePage() {
const [content, setContent] = useState("");
const [hasDeadline, setHasDeadline] = useState(false);

const { state, error, publish, reset } = usePublishStoryline();
const { state, error, execute, reset } = usePublish();
const { valid, charCount } = validateContentLength(content);
const titleValid = title.trim().length > 0;
const canSubmit =
Expand Down Expand Up @@ -79,7 +81,18 @@ export default function CreateStorylinePage() {
<form
onSubmit={(e) => {
e.preventDefault();
if (canSubmit) publish(title.trim(), content, hasDeadline);
if (canSubmit)
execute({
content,
uploadKeyPrefix: "plotlink/genesis",
indexerRoute: "/api/index/storyline",
buildWriteCall: (cid, contentHash) => ({
address: STORY_FACTORY,
abi: storyFactoryAbi as unknown as [],
functionName: "createStoryline",
args: [title.trim(), cid, contentHash, hasDeadline],
}),
});
}}
className="mt-8 space-y-6"
>
Expand Down
33 changes: 33 additions & 0 deletions src/hooks/useChainPlot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { useCallback } from "react";
import { usePublish } from "./usePublish";
import { storyFactoryAbi } from "../../lib/contracts/abi";
import { STORY_FACTORY } from "../../lib/contracts/constants";

/**
* Chain a plot to an existing storyline (P3-3).
* Reuses the shared publishing state machine from usePublish.
*/
export function useChainPlot() {
const { state, error, txHash, execute, reset } = usePublish();

const chainPlot = useCallback(
async (storylineId: number, content: string) => {
await execute({
content,
uploadKeyPrefix: `plotlink/plots/${storylineId}`,
indexerRoute: "/api/index/plot",
buildWriteCall: (cid, contentHash) => ({
address: STORY_FACTORY,
abi: storyFactoryAbi as unknown as [],
functionName: "chainPlot",
args: [BigInt(storylineId), cid, contentHash],
}),
});
},
[execute],
);

return { state, error, txHash, chainPlot, reset };
}
65 changes: 35 additions & 30 deletions src/hooks/usePublish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@

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";
import type { Hex, Abi } from "viem";

export type PublishState =
| "idle"
Expand All @@ -17,30 +15,39 @@ export type PublishState =
| "published"
| "error";

interface PublishResult {
state: PublishState;
error: string | null;
txHash: Hex | undefined;
publish: (
title: string,
content: string,
hasDeadline: boolean,
) => Promise<void>;
reset: () => void;
interface WriteCall {
address: `0x${string}`;
abi: Abi;
functionName: string;
args: readonly unknown[];
}

export function usePublishStoryline(): PublishResult {
interface PublishOptions {
content: string;
uploadKeyPrefix: string;
indexerRoute: string;
buildWriteCall: (cid: string, contentHash: Hex) => WriteCall;
}

/**
* Shared publishing state machine for StoryFactory write flows.
*
* Manages the 5-state flow: uploading -> confirming -> pending -> indexing -> published.
* Caches CID keyed by content hash for retry (skips re-upload if content unchanged).
*/
export function usePublish() {
const [state, setState] = useState<PublishState>("idle");
const [error, setError] = useState<string | null>(null);
const [txHash, setTxHash] = useState<Hex | undefined>(undefined);
const cachedCid = useRef<{ cid: string; contentHash: string } | null>(null);

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

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

// 1. Upload to IPFS (reuse cached CID only if content unchanged)
let cid: string;
Expand All @@ -55,8 +62,8 @@ export function usePublishStoryline(): PublishResult {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content,
key: `plotlink/genesis/${Date.now()}.txt`,
content: opts.content,
key: `${opts.uploadKeyPrefix}/${Date.now()}.txt`,
}),
});
if (!res.ok) throw new Error("IPFS upload failed");
Expand All @@ -67,24 +74,21 @@ export function usePublishStoryline(): PublishResult {

// 2. Submit tx to wallet
setState("confirming");
const writeCall = opts.buildWriteCall(cid, contentHash);

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

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

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

// 5. Done
Expand All @@ -103,8 +107,9 @@ export function usePublishStoryline(): PublishResult {
const reset = useCallback(() => {
setState("idle");
setError(null);
setTxHash(undefined);
cachedCid.current = null;
}, []);

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