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
70 changes: 44 additions & 26 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -210,45 +210,63 @@ code, pre, .font-mono {
}
}

/* Reading Mode page-flip transitions (3D book-style) */
/* Reading Mode — two-phase book page turn */
.page-flip-container {
perspective: 1200px;
perspective: 1500px;
}

@keyframes flip-out-forward {
0% { transform: rotateY(0deg); opacity: 1; }
100% { transform: rotateY(-90deg); opacity: 0; }
.page-flip-stack {
position: relative;
min-height: 100%;
}
@keyframes flip-out-backward {
0% { transform: rotateY(0deg); opacity: 1; }
100% { transform: rotateY(90deg); opacity: 0; }

.page-flip-page {
position: relative;
will-change: transform, opacity;
}
@keyframes flip-in-from-right {
0% { transform: rotateY(90deg); opacity: 0; }
100% { transform: rotateY(0deg); opacity: 1; }

/* Outgoing page stacks on top and flips away */
.page-outgoing {
position: absolute;
inset: 0;
z-index: 2;
backface-visibility: hidden;
overflow: hidden;
}
@keyframes flip-in-from-left {
0% { transform: rotateY(-90deg); opacity: 0; }
100% { transform: rotateY(0deg); opacity: 1; }

/* Incoming page: subtle fade-in from underneath */
.page-incoming {
animation: page-reveal 500ms ease-out;
}

/* Outgoing page: flips away */
.page-flip-out-left {
transform-origin: left center;
animation: flip-out-forward 200ms ease-in forwards;
@keyframes page-reveal {
0% { opacity: 0.4; }
60% { opacity: 0.8; }
100% { opacity: 1; }
}
.page-flip-out-right {
transform-origin: right center;
animation: flip-out-backward 200ms ease-in forwards;

/* Next: outgoing page flips from right edge (like turning a book page forward) */
@keyframes flip-out-forward {
0% { transform: rotateY(0deg); box-shadow: -4px 0 16px rgba(0,0,0,0.1); }
40% { box-shadow: -10px 0 30px rgba(0,0,0,0.2); }
100% { transform: rotateY(-120deg); box-shadow: none; }
}

/* Prev: outgoing page flips from left edge (like turning a book page backward) */
@keyframes flip-out-backward {
0% { transform: rotateY(0deg); box-shadow: 4px 0 16px rgba(0,0,0,0.1); }
40% { box-shadow: 10px 0 30px rgba(0,0,0,0.2); }
100% { transform: rotateY(120deg); box-shadow: none; }
}
/* Incoming page: flips into view */
.page-flip-in-left {

.page-flip-out-left {
transform-origin: left center;
animation: flip-in-from-right 200ms ease-out;
animation: flip-out-forward 500ms ease-in forwards;
}
.page-flip-in-right {

.page-flip-out-right {
transform-origin: right center;
animation: flip-in-from-left 200ms ease-out;
animation: flip-out-backward 500ms ease-in forwards;
}

/* Custom select dropdown styling */
Expand Down
80 changes: 55 additions & 25 deletions src/components/ReadingMode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
}

export function ReadingMode({
storylineId,

Check warning on line 22 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,
Expand All @@ -28,14 +28,17 @@
const [currentIdx, setCurrentIdx] = useState(initialChapterIndex);
const [showToc, setShowToc] = useState(false);
const [flipDir, setFlipDir] = useState<"left" | "right" | null>(null);
const [flipPhase, setFlipPhase] = useState<"out" | "in" | null>(null);
const [outgoingIdx, setOutgoingIdx] = useState<number | null>(null);
const [outgoingScroll, setOutgoingScroll] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
const stackRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef(0);
const touchStartY = useRef(0);
const flipping = useRef(false);
const { isMiniApp } = usePlatformDetection();

const chapter = chapters[currentIdx];
const outgoingChapter = outgoingIdx !== null ? chapters[outgoingIdx] : null;
const hasPrev = currentIdx > 0;
const hasNext = currentIdx < chapters.length - 1;

Expand All @@ -46,21 +49,28 @@
const navigate = useCallback((idx: number, dir: "left" | "right" | null) => {
if (flipping.current) return;
flipping.current = true;
// Phase 1: flip out the current page
// Capture scroll offset so the outgoing page can render at its old position
const scrollOffset = contentRef.current?.scrollTop ?? 0;
setOutgoingScroll(scrollOffset);
// Freeze container height so layout is stable during the flip
if (stackRef.current) {
stackRef.current.style.minHeight = `${stackRef.current.offsetHeight}px`;
}
// Set outgoing, swap to incoming, and scroll to top immediately
setOutgoingIdx(currentIdx);
setFlipDir(dir);
setFlipPhase("out");
setCurrentIdx(idx);
scrollToTop();
// After animation: unfreeze height, clean up
setTimeout(() => {
// Phase 2: swap content and flip in the new page
setCurrentIdx(idx);
scrollToTop();
setFlipPhase("in");
setTimeout(() => {
setFlipDir(null);
setFlipPhase(null);
flipping.current = false;
}, 200);
}, 200);
}, [scrollToTop]);
if (stackRef.current) {
stackRef.current.style.minHeight = "";
}
setOutgoingIdx(null);
setFlipDir(null);
flipping.current = false;
}, 500);
}, [currentIdx, scrollToTop]);

const goPrev = useCallback(() => {
if (hasPrev) navigate(currentIdx - 1, "right");
Expand Down Expand Up @@ -139,17 +149,37 @@
}
}}
>
<div className={`mx-auto max-w-[720px] px-6 py-8 sm:px-8 sm:py-12 ${
flipPhase === "out" && flipDir === "left" ? "page-flip-out-left"
: flipPhase === "out" && flipDir === "right" ? "page-flip-out-right"
: flipPhase === "in" && flipDir === "left" ? "page-flip-in-left"
: flipPhase === "in" && flipDir === "right" ? "page-flip-in-right"
: ""
}`}>
{chapter?.content ? (
<StoryContent content={chapter.content} />
) : (
<p className="text-muted text-sm italic">Content unavailable</p>
<div ref={stackRef} className="page-flip-stack">
{/* Incoming page (underneath) */}
<div className={`page-flip-page ${outgoingIdx !== null ? "page-incoming" : ""}`}>
<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>

{/* Outgoing page (on top, flipping away at its old scroll offset) */}
{outgoingChapter && flipDir && (
<div
className={`page-flip-page page-outgoing ${
flipDir === "left" ? "page-flip-out-left" : "page-flip-out-right"
}`}
style={{
background: "var(--paper-bg, #F5F0E8)",
top: `-${outgoingScroll}px`,
}}
>
<div className="mx-auto max-w-[720px] px-6 py-8 sm:px-8 sm:py-12">
{outgoingChapter.content ? (
<StoryContent content={outgoingChapter.content} />
) : (
<p className="text-muted text-sm italic">Content unavailable</p>
)}
</div>
</div>
)}
</div>
</div>
Expand Down
Loading