diff --git a/src/app/story/[storylineId]/[plotIndex]/page.tsx b/src/app/story/[storylineId]/[plotIndex]/page.tsx index 2b206f07..2749d0b5 100644 --- a/src/app/story/[storylineId]/[plotIndex]/page.tsx +++ b/src/app/story/[storylineId]/[plotIndex]/page.tsx @@ -8,6 +8,7 @@ import { WriterIdentity } from "../../../../components/WriterIdentity"; import { ViewTracker } from "../../../../components/ViewCount"; import { CommentSection } from "../../../../components/CommentSection"; import { StoryContent } from "../../../../components/StoryContent"; +import { ReadingModeWrapper } from "../../../../components/ReadingModeWrapper"; import Link from "next/link"; type Params = Promise<{ storylineId: string; plotIndex: string }>; @@ -73,7 +74,7 @@ export default async function PlotDetailPage({ params }: { params: Params }) { const [{ data: storyline }, { data: plot }, { data: plotRows }] = await Promise.all([ supabase.from("storylines").select("*").eq("storyline_id", sid).eq("hidden", false).eq("contract_address", STORY_FACTORY.toLowerCase()).single(), supabase.from("plots").select("*").eq("storyline_id", sid).eq("plot_index", pidx).eq("hidden", false).eq("contract_address", STORY_FACTORY.toLowerCase()).single(), - supabase.from("plots").select("plot_index").eq("storyline_id", sid).eq("hidden", false).eq("contract_address", STORY_FACTORY.toLowerCase()).order("plot_index", { ascending: true }), + supabase.from("plots").select("plot_index, title, content").eq("storyline_id", sid).eq("hidden", false).eq("contract_address", STORY_FACTORY.toLowerCase()).order("plot_index", { ascending: true }), ]); if (!storyline) return ; @@ -81,7 +82,8 @@ export default async function PlotDetailPage({ params }: { params: Params }) { const sl = storyline as Storyline; const p = plot as Plot; - const allIndexes = (plotRows ?? []).map((r: { plot_index: number }) => r.plot_index); + const allPlots = (plotRows ?? []) as { plot_index: number; title: string; content: string | null }[]; + const allIndexes = allPlots.map((r) => 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; @@ -101,7 +103,19 @@ export default async function PlotDetailPage({ params }: { params: Params }) { {chapterTitle} - {/* Chapter header */} + {/* Reading mode + Chapter header */} +
+ ({ + plotIndex: ap.plot_index, + title: ap.title || (ap.plot_index === 0 ? "Genesis" : `Chapter ${ap.plot_index}`), + content: ap.content, + }))} + initialPlotIndex={pidx} + /> +

{chapterTitle} diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 15934b96..4ea7bbfc 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -10,6 +10,7 @@ import { RatingWidget } from "../../../components/RatingWidget"; import { RatingSummary } from "../../../components/RatingSummary"; import { ShareButtons } from "../../../components/ShareButtons"; import { StoryContent } from "../../../components/StoryContent"; +import { ReadingModeWrapper } from "../../../components/ReadingModeWrapper"; import { getTokenPrice, type TokenPriceInfo } from "../../../../lib/price"; import { RESERVE_LABEL, STORY_FACTORY } from "../../../../lib/contracts/constants"; import { formatPrice, formatSupply } from "../../../../lib/format"; @@ -150,6 +151,18 @@ export default async function StoryPage({ params }: { params: Params }) {
{genesis ? ( <> +
+ ({ + plotIndex: p.plot_index, + title: p.title || (p.plot_index === 0 ? "Genesis" : `Chapter ${p.plot_index}`), + content: p.content, + }))} + initialPlotIndex={0} + /> +
{chapters.length > 0 && ( void; +} + +export function ReadingMode({ + storylineId, + storylineTitle, + chapters, + initialChapterIndex, + onClose, +}: ReadingModeProps) { + const [currentIdx, setCurrentIdx] = useState(initialChapterIndex); + const [showToc, setShowToc] = useState(false); + const contentRef = useRef(null); + + const chapter = chapters[currentIdx]; + const hasPrev = currentIdx > 0; + const hasNext = currentIdx < chapters.length - 1; + + const scrollToTop = useCallback(() => { + contentRef.current?.scrollTo(0, 0); + }, []); + + const goPrev = useCallback(() => { + if (hasPrev) { + setCurrentIdx((i) => i - 1); + scrollToTop(); + } + }, [hasPrev, scrollToTop]); + + const goNext = useCallback(() => { + if (hasNext) { + setCurrentIdx((i) => i + 1); + scrollToTop(); + } + }, [hasNext, scrollToTop]); + + const goToChapter = useCallback((idx: number) => { + setCurrentIdx(idx); + setShowToc(false); + scrollToTop(); + }, [scrollToTop]); + + // Esc to close, arrow keys for navigation + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + if (showToc) setShowToc(false); + else onClose(); + } + if (e.key === "ArrowLeft" && hasPrev) goPrev(); + if (e.key === "ArrowRight" && hasNext) goNext(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose, hasPrev, hasNext, goPrev, goNext, showToc]); + + // Lock body scroll when overlay is open + useEffect(() => { + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = ""; + }; + }, []); + + return ( +
+ {/* Top bar */} +
+
+

{storylineTitle}

+

+ {chapter?.title || `Chapter ${chapter?.plotIndex ?? 0}`} +

+
+
+ + {currentIdx + 1} / {chapters.length} + + +
+
+ + {/* Content area */} +
+
+ {chapter?.content ? ( + + ) : ( +

Content unavailable

+ )} +
+
+ + {/* Bottom navigation */} + + + {/* Table of Contents overlay */} + {showToc && ( +
setShowToc(false)} + > +
e.stopPropagation()} + > +
+

Table of Contents

+ +
+
+ {chapters.map((ch, idx) => ( + + ))} +
+
+
+ )} +
+ ); +} + +/** + * Button to enter reading mode. Place near story content. + */ +export function ReadingModeButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} diff --git a/src/components/ReadingModeWrapper.tsx b/src/components/ReadingModeWrapper.tsx new file mode 100644 index 00000000..f115d479 --- /dev/null +++ b/src/components/ReadingModeWrapper.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useState } from "react"; +import { ReadingMode, ReadingModeButton } from "./ReadingMode"; + +interface Chapter { + plotIndex: number; + title: string; + content: string | null; +} + +/** + * Client wrapper that manages reading mode state. + * Receives serialized chapter data from server components. + */ +export function ReadingModeWrapper({ + storylineId, + storylineTitle, + chapters, + initialPlotIndex, +}: { + storylineId: number; + storylineTitle: string; + chapters: Chapter[]; + initialPlotIndex: number; +}) { + const [active, setActive] = useState(false); + + const initialIdx = chapters.findIndex( + (ch) => ch.plotIndex === initialPlotIndex, + ); + + return ( + <> + setActive(true)} /> + {active && ( + = 0 ? initialIdx : 0} + onClose={() => setActive(false)} + /> + )} + + ); +}