From 9c62f7866f99abb4ec7549b2b0eca9963d615fe0 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 09:52:32 +0000 Subject: [PATCH] [#200] Build story page with reading experience, deadline countdown, and sunset state - Create /story/[storylineId] route with server-side data fetching - Fetch storyline + plots from Supabase, render continuous reading layout - Show writer address (truncated), plot count, agent badge, and title - Add DeadlineCountdown client component with live HH:MM:SS countdown (72h from last_plot_time, updates every second) - Show "Story complete" sunset state when storyline is sunset - Display "Deadline expired" when countdown reaches zero - Filter hidden content (content moderation) - Fallback for missing plot content (shows CID reference) - Terminal aesthetic throughout (monospace, green accents, dark surfaces) Fixes #200 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/story/[storylineId]/page.tsx | 131 +++++++++++++++++++++++++++ src/components/DeadlineCountdown.tsx | 45 +++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/app/story/[storylineId]/page.tsx create mode 100644 src/components/DeadlineCountdown.tsx diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx new file mode 100644 index 00000000..4ee1c667 --- /dev/null +++ b/src/app/story/[storylineId]/page.tsx @@ -0,0 +1,131 @@ +import { createServerClient, type Storyline, type Plot } from "../../../../lib/supabase"; +import { DeadlineCountdown } from "../../../components/DeadlineCountdown"; + +type Params = Promise<{ storylineId: string }>; + +function truncateAddress(address: string): string { + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +export default async function StoryPage({ params }: { params: Params }) { + const { storylineId } = await params; + const id = Number(storylineId); + + if (isNaN(id) || id <= 0) { + return ; + } + + const supabase = createServerClient(); + if (!supabase) { + return ; + } + + const { data: storyline } = await supabase + .from("storylines") + .select("*") + .eq("storyline_id", id) + .eq("hidden", false) + .single(); + + if (!storyline) { + return ; + } + + const { data: plotRows } = await supabase + .from("plots") + .select("*") + .eq("storyline_id", id) + .eq("hidden", false) + .order("plot_index", { ascending: true }) + .returns(); + + const plots = plotRows ?? []; + + return ( +
+ +
+ {plots.map((plot) => ( + + ))} +
+ {plots.length === 0 && ( +

No plots yet.

+ )} +
+ ); +} + +function StoryHeader({ storyline }: { storyline: Storyline }) { + return ( +
+

+ {storyline.title} +

+
+ + by{" "} + + {truncateAddress(storyline.writer_address)} + + + + {storyline.plot_count} {storyline.plot_count === 1 ? "plot" : "plots"} + + {storyline.writer_type === 1 && ( + + agent + + )} +
+ {storyline.sunset ? ( +
+ Story complete + + {storyline.plot_count} {storyline.plot_count === 1 ? "plot" : "plots"} total + +
+ ) : storyline.has_deadline && storyline.last_plot_time ? ( + + ) : null} +
+ ); +} + +function PlotEntry({ plot }: { plot: Plot }) { + return ( +
+
+ + {plot.plot_index === 0 ? "Genesis" : `Plot #${plot.plot_index}`} + + {plot.block_timestamp && ( + + )} +
+ {plot.content ? ( +
+ {plot.content} +
+ ) : ( +

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

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

{message}

+
+ ); +} diff --git a/src/components/DeadlineCountdown.tsx b/src/components/DeadlineCountdown.tsx new file mode 100644 index 00000000..32ab7c14 --- /dev/null +++ b/src/components/DeadlineCountdown.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState, useEffect } from "react"; + +const DEADLINE_HOURS = 72; + +export function DeadlineCountdown({ lastPlotTime }: { lastPlotTime: string }) { + const [remaining, setRemaining] = useState(() => calcRemaining(lastPlotTime)); + + useEffect(() => { + const interval = setInterval(() => { + setRemaining(calcRemaining(lastPlotTime)); + }, 1000); + return () => clearInterval(interval); + }, [lastPlotTime]); + + if (remaining <= 0) { + return ( +
+ Deadline expired +
+ ); + } + + const hours = Math.floor(remaining / 3600); + const minutes = Math.floor((remaining % 3600) / 60); + const seconds = remaining % 60; + + return ( +
+ Deadline: + + {String(hours).padStart(2, "0")}:{String(minutes).padStart(2, "0")}: + {String(seconds).padStart(2, "0")} + + remaining +
+ ); +} + +function calcRemaining(lastPlotTime: string): number { + const deadline = + new Date(lastPlotTime).getTime() + DEADLINE_HOURS * 60 * 60 * 1000; + return Math.max(0, Math.floor((deadline - Date.now()) / 1000)); +}