From 75e3371a82158c22510b2acb3b0ca2050b907298 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 16 Mar 2026 21:15:34 +0000 Subject: [PATCH 1/2] [#186] Refactor story page: genesis + table of contents - Genesis plot (index 0) displayed prominently at top with full content - Remaining chapters shown as a table of contents with: - Chapter title (or "Chapter {N}" fallback for untitled plots) - Content preview (~100 chars, truncated) - Date - Links to /story/[storylineId]/[plotIndex] for full reading - Sidebar preserved: price chart, trading, donate, rating, share - Mobile responsive (flex layout) Fixes #186 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/story/[storylineId]/page.tsx | 83 +++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 14 deletions(-) diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 2b5fd116..5bde3141 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -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"; @@ -116,6 +117,8 @@ export default async function StoryPage({ params }: { params: Params }) { .returns(); 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 @@ -128,17 +131,20 @@ export default async function StoryPage({ params }: { params: Params }) {
- {/* Story content — primary reading area */} + {/* Story content — genesis + table of contents */}
- {plots.length > 0 ? ( -
- {plots.map((plot) => ( - - ))} -
+ {genesis ? ( + ) : (

No plots yet.

)} + + {chapters.length > 0 && ( + + )}
{/* Sidebar — engagement widgets */} @@ -227,14 +233,12 @@ function StoryHeader({ ); } -function PlotEntry({ plot }: { plot: Plot }) { +function GenesisSection({ plot }: { plot: Plot }) { return ( -
- +
+
- - {plot.plot_index === 0 ? "Genesis" : `Plot #${plot.plot_index}`} - + Genesis {plot.block_timestamp && (
+ + ); +} + +function TableOfContents({ + storylineId, + chapters, +}: { + storylineId: number; + chapters: Plot[]; +}) { + return ( +
+

+ Chapters +

+
+ {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 ( + +
+
+ {chapterTitle} +
+ {preview && ( +

+ {preview} + {ch.content && ch.content.length > 100 ? "…" : ""} +

+ )} +
+
+ {dateStr} +
+ + ); + })} +
+
); } From 4372b80026c86f2af308ed6c1443983532427bcd Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 16 Mar 2026 21:29:18 +0000 Subject: [PATCH 2/2] [#186] Add plot detail page route for TOC links New route /story/[storylineId]/[plotIndex] with: - Breadcrumb navigation back to story page - Chapter header with title (or fallback), author, date - Full plot content - Prev/Next navigation + "Table of Contents" link - OG metadata per chapter - SSR with revalidate=120 - ViewTracker for per-plot view counting Addresses T2a review feedback on PR #227. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../story/[storylineId]/[plotIndex]/page.tsx | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/app/story/[storylineId]/[plotIndex]/page.tsx diff --git a/src/app/story/[storylineId]/[plotIndex]/page.tsx b/src/app/story/[storylineId]/[plotIndex]/page.tsx new file mode 100644 index 00000000..7c3e8e16 --- /dev/null +++ b/src/app/story/[storylineId]/[plotIndex]/page.tsx @@ -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 { + 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 ; + } + + const supabase = createServerClient(); + if (!supabase) return ; + + 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 ; + if (!plot) return ; + + 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 ( +
+ + + {/* Breadcrumb */} + + + {/* Chapter header */} +
+

+ {chapterTitle} +

+
+ by {truncateAddress(sl.writer_address)} + {p.block_timestamp && ( + + )} +
+
+ + {/* Plot content */} + {p.content ? ( +
+ {p.content} +
+ ) : ( +

+ Content unavailable (CID: {p.content_cid}) +

+ )} + + {/* Navigation */} + +
+ ); +} + +function NotFound({ message }: { message: string }) { + return ( +
+

{message}

+
+ ); +}