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
20 changes: 17 additions & 3 deletions src/app/story/[storylineId]/[plotIndex]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -73,15 +74,16 @@ 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 <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 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;
Expand All @@ -101,7 +103,19 @@ export default async function PlotDetailPage({ params }: { params: Params }) {
<span className="text-foreground">{chapterTitle}</span>
</nav>

{/* Chapter header */}
{/* Reading mode + Chapter header */}
<div className="mb-4 flex justify-end">
<ReadingModeWrapper
storylineId={sid}
storylineTitle={sl.title}
chapters={allPlots.map((ap) => ({
plotIndex: ap.plot_index,
title: ap.title || (ap.plot_index === 0 ? "Genesis" : `Chapter ${ap.plot_index}`),
content: ap.content,
}))}
initialPlotIndex={pidx}
/>
</div>
<header className="border-border mb-8 border-b pb-4">
<h1 className="text-accent text-xl font-bold tracking-tight">
{chapterTitle}
Expand Down
13 changes: 13 additions & 0 deletions src/app/story/[storylineId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -150,6 +151,18 @@ export default async function StoryPage({ params }: { params: Params }) {
<main>
{genesis ? (
<>
<div className="mb-4 flex justify-end">
<ReadingModeWrapper
storylineId={id}
storylineTitle={sl.title}
chapters={plots.map((p) => ({
plotIndex: p.plot_index,
title: p.title || (p.plot_index === 0 ? "Genesis" : `Chapter ${p.plot_index}`),
content: p.content,
}))}
initialPlotIndex={0}
/>
</div>
<GenesisSection plot={genesis} />
{chapters.length > 0 && (
<a
Expand Down
208 changes: 208 additions & 0 deletions src/components/ReadingMode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"use client";

import { useEffect, useState, useCallback, useRef } from "react";
import { StoryContent } from "./StoryContent";

interface Chapter {
plotIndex: number;
title: string;
content: string | null;
}

interface ReadingModeProps {
storylineId: number;
storylineTitle: string;
chapters: Chapter[];
initialChapterIndex: number;
onClose: () => void;
}

export function ReadingMode({
storylineId,

Check warning on line 21 in src/components/ReadingMode.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'storylineId' is defined but never used
storylineTitle,
chapters,
initialChapterIndex,
onClose,
}: ReadingModeProps) {
const [currentIdx, setCurrentIdx] = useState(initialChapterIndex);
const [showToc, setShowToc] = useState(false);
const contentRef = useRef<HTMLDivElement>(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 (
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: "var(--paper-bg, #F5F0E8)" }}>
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-3 sm:px-6" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="min-w-0 flex-1">
<p className="text-muted truncate text-xs">{storylineTitle}</p>
<p className="text-foreground truncate text-sm font-medium">
{chapter?.title || `Chapter ${chapter?.plotIndex ?? 0}`}
</p>
</div>
<div className="ml-4 flex items-center gap-3">
<span className="text-muted text-[11px]">
{currentIdx + 1} / {chapters.length}
</span>
<button
onClick={onClose}
className="text-muted hover:text-foreground text-lg transition-colors"
title="Exit reading mode (Esc)"
>
&times;
</button>
</div>
</div>

{/* Content area */}
<div ref={contentRef} className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-[720px] px-6 py-8 sm:px-8 sm:py-12">
{chapter?.content ? (
<StoryContent content={chapter.content} />
) : (
<p className="text-muted text-sm italic">Content unavailable</p>
)}
</div>
</div>

{/* Bottom navigation */}
<nav
className="flex items-center justify-between px-4 py-3 sm:px-6"
style={{ borderTop: "1px solid var(--border)" }}
>
{hasPrev ? (
<button
onClick={goPrev}
className="text-muted hover:text-accent rounded px-3 py-2 text-xs font-medium transition-colors"
>
&larr; Prev
</button>
) : (
<span className="w-16" />
)}

<button
onClick={() => setShowToc(!showToc)}
className="text-muted hover:text-accent rounded px-3 py-2 text-xs font-medium transition-colors"
>
Contents
</button>

{hasNext ? (
<button
onClick={goNext}
className="text-muted hover:text-accent rounded px-3 py-2 text-xs font-medium transition-colors"
>
Next &rarr;
</button>
) : (
<span className="w-16" />
)}
</nav>

{/* Table of Contents overlay */}
{showToc && (
<div
className="fixed inset-0 z-60 flex items-end justify-center sm:items-center"
onClick={() => setShowToc(false)}
>
<div
className="border-border w-full max-w-md rounded-t-lg border sm:rounded-lg"
style={{ background: "var(--paper-bg, #F5F0E8)" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--border)] px-4 py-3">
<h3 className="text-foreground text-sm font-medium">Table of Contents</h3>
<button
onClick={() => setShowToc(false)}
className="text-muted hover:text-foreground transition-colors"
>
&times;
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2">
{chapters.map((ch, idx) => (
<button
key={ch.plotIndex}
onClick={() => goToChapter(idx)}
className={`w-full rounded px-3 py-2 text-left text-xs transition-colors ${
idx === currentIdx
? "bg-accent/10 text-accent font-medium"
: "text-foreground hover:bg-accent/5"
}`}
>
<span className="text-muted mr-2">
{ch.plotIndex === 0 ? "G" : ch.plotIndex}.
</span>
{ch.title || (ch.plotIndex === 0 ? "Genesis" : `Chapter ${ch.plotIndex}`)}
</button>
))}
</div>
</div>
</div>
)}
</div>
);
}

/**
* Button to enter reading mode. Place near story content.
*/
export function ReadingModeButton({ onClick }: { onClick: () => void }) {
return (
<button
onClick={onClick}
className="border-border text-muted hover:text-accent hover:border-accent rounded border px-3 py-1.5 text-[11px] font-medium transition-colors"
>
Reading Mode
</button>
);
}
47 changes: 47 additions & 0 deletions src/components/ReadingModeWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<ReadingModeButton onClick={() => setActive(true)} />
{active && (
<ReadingMode
storylineId={storylineId}
storylineTitle={storylineTitle}
chapters={chapters}
initialChapterIndex={initialIdx >= 0 ? initialIdx : 0}
onClose={() => setActive(false)}
/>
)}
</>
);
}
Loading