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
162 changes: 162 additions & 0 deletions src/app/story/[storylineId]/[plotIndex]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { type Metadata } from "next";
import { createServerClient, type Storyline, type Plot } from "../../../../../lib/supabase";
import { truncateAddress } from "../../../../../lib/utils";
import { ViewTracker } from "../../../../components/ViewCount";
import Link from "next/link";

type Params = Promise<{ storylineId: string; plotIndex: string }>;

const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

export const revalidate = 120;

export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { storylineId, plotIndex } = await params;
const sid = Number(storylineId);
const pidx = Number(plotIndex);

if (isNaN(sid) || sid <= 0 || isNaN(pidx) || pidx < 0) return {};

const supabase = createServerClient();
if (!supabase) return {};

const [{ data: storyline }, { data: plot }] = await Promise.all([
supabase.from("storylines").select("*").eq("storyline_id", sid).eq("hidden", false).single(),
supabase.from("plots").select("*").eq("storyline_id", sid).eq("plot_index", pidx).eq("hidden", false).single(),
]);

if (!storyline || !plot) return {};

const sl = storyline as Storyline;
const p = plot as Plot;
const chapterTitle = p.title || `Chapter ${pidx}`;
const preview = p.content ? p.content.slice(0, 160) : "";

return {
title: `${chapterTitle} — ${sl.title} — PlotLink`,
description: preview || `A chapter of ${sl.title} by ${truncateAddress(sl.writer_address)}`,
openGraph: {
title: `${chapterTitle} — ${sl.title}`,
description: preview,
url: `${appUrl}/story/${sid}/${pidx}`,
},
};
}

export default async function PlotDetailPage({ params }: { params: Params }) {
const { storylineId, plotIndex } = await params;
const sid = Number(storylineId);
const pidx = Number(plotIndex);

if (isNaN(sid) || sid <= 0 || isNaN(pidx) || pidx < 0) {
return <NotFound message="Invalid plot URL" />;
}

const supabase = createServerClient();
if (!supabase) return <NotFound message="Database unavailable" />;

const [{ data: storyline }, { data: plot }, { data: plotRows }] = await Promise.all([
supabase.from("storylines").select("*").eq("storyline_id", sid).eq("hidden", false).single(),
supabase.from("plots").select("*").eq("storyline_id", sid).eq("plot_index", pidx).eq("hidden", false).single(),
supabase.from("plots").select("plot_index").eq("storyline_id", sid).eq("hidden", false).order("plot_index", { ascending: true }),
]);

if (!storyline) return <NotFound message="Storyline not found" />;
if (!plot) return <NotFound message="Chapter not found" />;

const sl = storyline as Storyline;
const p = plot as Plot;
const allIndexes = (plotRows ?? []).map((r: { plot_index: number }) => r.plot_index);
const currentPos = allIndexes.indexOf(pidx);
const prevIndex = currentPos > 0 ? allIndexes[currentPos - 1] : null;
const nextIndex = currentPos < allIndexes.length - 1 ? allIndexes[currentPos + 1] : null;

const chapterTitle = p.title || (pidx === 0 ? "Genesis" : `Chapter ${pidx}`);

return (
<div className="mx-auto max-w-3xl px-6 py-10">
<ViewTracker storylineId={sid} plotIndex={pidx} />

{/* Breadcrumb */}
<nav className="text-muted mb-6 text-xs">
<Link href={`/story/${sid}`} className="hover:text-accent transition-colors">
{sl.title}
</Link>
<span className="mx-2">›</span>
<span className="text-foreground">{chapterTitle}</span>
</nav>

{/* Chapter header */}
<header className="border-border mb-8 border-b pb-4">
<h1 className="text-accent text-xl font-bold tracking-tight">
{chapterTitle}
</h1>
<div className="text-muted mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
<span>by {truncateAddress(sl.writer_address)}</span>
{p.block_timestamp && (
<time dateTime={p.block_timestamp}>
{new Date(p.block_timestamp).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</time>
)}
</div>
</header>

{/* Plot content */}
{p.content ? (
<div className="text-foreground whitespace-pre-wrap text-sm leading-relaxed">
{p.content}
</div>
) : (
<p className="text-muted text-sm italic">
Content unavailable (CID: {p.content_cid})
</p>
)}

{/* Navigation */}
<nav className="border-border mt-10 flex items-center justify-between border-t pt-6">
{prevIndex !== null ? (
<Link
href={`/story/${sid}/${prevIndex}`}
className="border-border text-muted hover:text-foreground rounded border px-4 py-2 text-xs transition-colors"
>
&larr; Previous
</Link>
) : (
<span />
)}
<Link
href={`/story/${sid}`}
className="text-muted hover:text-accent text-xs transition-colors"
>
Table of Contents
</Link>
{nextIndex !== null ? (
<Link
href={`/story/${sid}/${nextIndex}`}
className="border-border text-muted hover:text-foreground rounded border px-4 py-2 text-xs transition-colors"
>
Next &rarr;
</Link>
) : (
<span />
)}
</nav>
</div>
);
}

function NotFound({ message }: { message: string }) {
return (
<div className="flex min-h-[calc(100vh-2.75rem)] flex-col items-center justify-center px-6">
<p className="text-muted text-sm">{message}</p>
</div>
);
}
83 changes: 69 additions & 14 deletions src/app/story/[storylineId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getTokenPrice, type TokenPriceInfo } from "../../../../lib/price";
import { RESERVE_LABEL } from "../../../../lib/contracts/constants";
import { type Address } from "viem";
import { truncateAddress } from "../../../../lib/utils";
import Link from "next/link";
import { AgentBadge } from "../../../components/AgentBadge";
import { WriterIdentity } from "../../../components/WriterIdentity";
import { ViewCount, ViewTracker } from "../../../components/ViewCount";
Expand Down Expand Up @@ -116,6 +117,8 @@ export default async function StoryPage({ params }: { params: Params }) {
.returns<Plot[]>();

const plots = plotRows ?? [];
const genesis = plots.find((p) => p.plot_index === 0) ?? null;
const chapters = plots.filter((p) => p.plot_index > 0);

const sl = storyline as Storyline;
const priceInfo = sl.token_address
Expand All @@ -128,17 +131,20 @@ export default async function StoryPage({ params }: { params: Params }) {
<StoryHeader storyline={storyline} priceInfo={priceInfo} />

<div className="mt-8 grid grid-cols-1 gap-10 lg:grid-cols-[1fr_320px]">
{/* Story content — primary reading area */}
{/* Story content — genesis + table of contents */}
<main>
{plots.length > 0 ? (
<div className="space-y-10">
{plots.map((plot) => (
<PlotEntry key={plot.id} plot={plot} />
))}
</div>
{genesis ? (
<GenesisSection plot={genesis} />
) : (
<p className="text-muted text-sm">No plots yet.</p>
)}

{chapters.length > 0 && (
<TableOfContents
storylineId={id}
chapters={chapters}
/>
)}
</main>

{/* Sidebar — engagement widgets */}
Expand Down Expand Up @@ -227,14 +233,12 @@ function StoryHeader({
);
}

function PlotEntry({ plot }: { plot: Plot }) {
function GenesisSection({ plot }: { plot: Plot }) {
return (
<article className="border-border border-b pb-8 last:border-b-0">
<ViewTracker storylineId={plot.storyline_id} plotIndex={plot.plot_index} />
<section>
<ViewTracker storylineId={plot.storyline_id} plotIndex={0} />
<div className="text-muted mb-3 flex items-baseline gap-3 text-xs">
<span className="text-accent-dim font-medium">
{plot.plot_index === 0 ? "Genesis" : `Plot #${plot.plot_index}`}
</span>
<span className="text-accent-dim font-medium">Genesis</span>
{plot.block_timestamp && (
<time dateTime={plot.block_timestamp}>
{new Date(plot.block_timestamp).toLocaleDateString("en-US", {
Expand All @@ -254,7 +258,58 @@ function PlotEntry({ plot }: { plot: Plot }) {
Content unavailable (CID: {plot.content_cid})
</p>
)}
</article>
</section>
);
}

function TableOfContents({
storylineId,
chapters,
}: {
storylineId: number;
chapters: Plot[];
}) {
return (
<section className="mt-10">
<h2 className="text-foreground mb-4 text-sm font-semibold uppercase tracking-wider">
Chapters
</h2>
<div className="divide-border divide-y">
{chapters.map((ch) => {
const chapterTitle = ch.title || `Chapter ${ch.plot_index}`;
const preview = ch.content ? ch.content.slice(0, 100) : "";
const dateStr = ch.block_timestamp
? new Date(ch.block_timestamp).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
: null;

return (
<Link
key={ch.id}
href={`/story/${storylineId}/${ch.plot_index}`}
className="hover:bg-surface/50 flex items-start justify-between gap-4 py-3 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="text-foreground text-sm font-medium">
{chapterTitle}
</div>
{preview && (
<p className="text-muted mt-0.5 truncate text-xs">
{preview}
{ch.content && ch.content.length > 100 ? "…" : ""}
</p>
)}
</div>
<div className="text-muted shrink-0 text-xs">
{dateStr}
</div>
</Link>
);
})}
</div>
</section>
);
}

Expand Down
Loading