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

import { useState } from "react";
import { useAccount } from "wagmi";
import { useQuery } from "@tanstack/react-query";
import {
validateContentLength,
MIN_CONTENT_LENGTH,
MAX_CONTENT_LENGTH,
} from "../../../lib/content";
import { supabase, type Storyline } from "../../../lib/supabase";
import { STORY_FACTORY } from "../../../lib/contracts/constants";
import { useChainPlot } from "../../hooks/useChainPlot";
import { usePublishIntent } from "../../hooks/usePublishIntent";
import { RecoveryBanner } from "../../components/RecoveryBanner";
import type { PublishState } from "../../hooks/usePublish";
import Link from "next/link";
import { ConnectWallet } from "../../components/ConnectWallet";
import { Select } from "../../components/Select";

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)
.eq("contract_address", STORY_FACTORY.toLowerCase())
.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 [title, setTitle] = useState("");
const [content, setContent] = useState("");

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

const { pendingIntent, saveIntent, persistTxHash, clearIntent, attemptRetry } =
usePublishIntent();
const { state, error, chainPlot, reset } = useChainPlot({
onIntentSave: saveIntent,
onTxConfirmed: persistTxHash,
onIndexed: clearIntent,
});
const { valid, charCount } = validateContentLength(content);
const titleValid = title.trim().length > 0;
const canSubmit =
(state === "idle" || state === "error") &&
storylineId !== null &&
titleValid &&
valid;

if (!isConnected) {
return (
<div className="flex min-h-[calc(100vh-2.75rem)] 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-[calc(100vh-2.75rem)] 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>
);
import { redirect } from "next/navigation";

export default async function ChainRedirect({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const params = await searchParams;
const sp = new URLSearchParams({ tab: "chain" });
for (const [key, value] of Object.entries(params)) {
if (key !== "tab") sp.set(key, value);
}

const busy = state !== "idle" && state !== "error";
const noStoryline = storylineId === null;

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>

{pendingIntent && (
<div className="mt-6">
<RecoveryBanner
intent={pendingIntent}
onRetry={attemptRetry}
onDismiss={clearIntent}
/>
</div>
)}

<form
onSubmit={(e) => {
e.preventDefault();
if (canSubmit) chainPlot(storylineId, content, title);
}}
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 != null ? String(storylineId) : ""}
onChange={(v) => setStorylineId(v ? Number(v) : null)}
disabled={busy}
placeholder="Select a storyline"
options={storylines.map((s) => ({
value: String(s.storyline_id),
label: `${s.title} (${s.plot_count} ${s.plot_count === 1 ? "plot" : "plots"})`,
}))}
/>
)}
</div>

{/* Chapter title */}
<div>
<label className="text-foreground mb-2 block text-sm">
Chapter Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value.slice(0, 100))}
disabled={busy || noStoryline}
placeholder={noStoryline ? "Select a storyline first" : "e.g. The Silent Storm"}
maxLength={100}
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 className="mt-1 flex justify-between text-xs">
{!titleValid && content.length > 0 ? (
<span className="text-error">Title is required</span>
) : (
<span />
)}
<span className="text-muted">{title.length} / 100 chars</span>
</div>
</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 || noStoryline}
rows={12}
placeholder={noStoryline ? "Select a storyline above to chain a plot" : "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>
);
redirect(`/create?${sp.toString()}`);
}
Loading
Loading