From 77dfaabd9b6d288aff78850db4e194d793eb769a Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 17:10:32 +0000 Subject: [PATCH 1/2] =?UTF-8?q?[#570]=20Add=20reading=20mode=20=E2=80=94?= =?UTF-8?q?=20full-screen=20immersive=20story=20reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReadingMode component: full-screen overlay with paper background, centered content column (max 720px), clean typography - Bottom nav: Prev / Contents / Next with chapter progress indicator - Table of Contents slide-up overlay listing all chapters - Esc key or X button to exit, arrow keys for prev/next - Body scroll lock while overlay is active - ReadingModeWrapper bridges server/client boundary - "Reading Mode" button on both genesis and chapter pages - Plot detail page now fetches title+content for all chapters (enables full reading mode navigation) - Mobile responsive with thumb-friendly bottom nav Fixes #570 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../story/[storylineId]/[plotIndex]/page.tsx | 20 +- src/app/story/[storylineId]/page.tsx | 13 ++ src/components/ReadingMode.tsx | 203 ++++++++++++++++++ src/components/ReadingModeWrapper.tsx | 47 ++++ 4 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 src/components/ReadingMode.tsx create mode 100644 src/components/ReadingModeWrapper.tsx 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 chapter = chapters[currentIdx]; + const hasPrev = currentIdx > 0; + const hasNext = currentIdx < chapters.length - 1; + + const goPrev = useCallback(() => { + if (hasPrev) { + setCurrentIdx((i) => i - 1); + window.scrollTo(0, 0); + } + }, [hasPrev]); + + const goNext = useCallback(() => { + if (hasNext) { + setCurrentIdx((i) => i + 1); + window.scrollTo(0, 0); + } + }, [hasNext]); + + const goToChapter = useCallback((idx: number) => { + setCurrentIdx(idx); + setShowToc(false); + window.scrollTo(0, 0); + }, []); + + // 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)} + /> + )} + + ); +} From dce3256ef01e6abe3723e4869e71f30fbaa609f0 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 17:13:51 +0000 Subject: [PATCH 2/2] [#570] Fix scroll: reset overlay container scrollTop via ref, not window Prev/Next/ToC navigation now scrolls the overlay's overflow-y-auto container instead of window.scrollTo. Preserves underlying page scroll position when exiting reading mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/ReadingMode.tsx | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/ReadingMode.tsx b/src/components/ReadingMode.tsx index d6120293..96461342 100644 --- a/src/components/ReadingMode.tsx +++ b/src/components/ReadingMode.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import { StoryContent } from "./StoryContent"; interface Chapter { @@ -26,30 +26,35 @@ export function ReadingMode({ }: 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); - window.scrollTo(0, 0); + scrollToTop(); } - }, [hasPrev]); + }, [hasPrev, scrollToTop]); const goNext = useCallback(() => { if (hasNext) { setCurrentIdx((i) => i + 1); - window.scrollTo(0, 0); + scrollToTop(); } - }, [hasNext]); + }, [hasNext, scrollToTop]); const goToChapter = useCallback((idx: number) => { setCurrentIdx(idx); setShowToc(false); - window.scrollTo(0, 0); - }, []); + scrollToTop(); + }, [scrollToTop]); // Esc to close, arrow keys for navigation useEffect(() => { @@ -98,7 +103,7 @@ export function ReadingMode({ {/* Content area */} -
+
{chapter?.content ? (