feat: add chapter reader with reading progress bar and navigation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
54
src/app/[game]/[chapter]/page.tsx
Normal file
54
src/app/[game]/[chapter]/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getAllGames, getGame, getChapter, getAdjacentChapters } from "@/lib/content";
|
||||||
|
import ChapterReader from "@/components/ChapterReader";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: { game: string; chapter: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
const games = getAllGames();
|
||||||
|
const paths: { game: string; chapter: string }[] = [];
|
||||||
|
|
||||||
|
for (const gameMeta of games) {
|
||||||
|
const game = getGame(gameMeta.slug);
|
||||||
|
for (const chapter of game.chapters) {
|
||||||
|
paths.push({ game: gameMeta.slug, chapter: chapter.slug });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMetadata({ params }: PageProps) {
|
||||||
|
try {
|
||||||
|
const game = getGame(params.game);
|
||||||
|
const chapter = getChapter(params.game, params.chapter);
|
||||||
|
return {
|
||||||
|
title: `${chapter.title} - ${game.title} | Cronicas de los Reinos`,
|
||||||
|
description: `Capitulo ${chapter.number} de ${game.title}`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { title: "No encontrado" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChapterPage({ params }: PageProps) {
|
||||||
|
try {
|
||||||
|
const game = getGame(params.game);
|
||||||
|
const chapter = getChapter(params.game, params.chapter);
|
||||||
|
const { prev, next } = getAdjacentChapters(params.game, params.chapter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChapterReader
|
||||||
|
game={game}
|
||||||
|
chapter={chapter}
|
||||||
|
totalChapters={game.chapters.length}
|
||||||
|
prevChapter={prev}
|
||||||
|
nextChapter={next}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/components/ChapterReader.tsx
Normal file
96
src/components/ChapterReader.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||||
|
import { GameMeta, Chapter } from "@/types";
|
||||||
|
import ReadingProgress from "./ReadingProgress";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: GameMeta;
|
||||||
|
chapter: Chapter;
|
||||||
|
totalChapters: number;
|
||||||
|
prevChapter: string | null;
|
||||||
|
nextChapter: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChapterReader({
|
||||||
|
game,
|
||||||
|
chapter,
|
||||||
|
totalChapters,
|
||||||
|
prevChapter,
|
||||||
|
nextChapter,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-parchment text-stone-800">
|
||||||
|
<ReadingProgress color={game.accentColor} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="max-w-3xl mx-auto px-6 pt-8 pb-4">
|
||||||
|
<Link
|
||||||
|
href={`/${game.slug}`}
|
||||||
|
className="inline-flex items-center text-stone-500 hover:text-stone-700 font-lora text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<span className="mr-2">←</span> {game.title}
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Chapter heading */}
|
||||||
|
<div className="max-w-3xl mx-auto px-6 pt-8 pb-12 text-center">
|
||||||
|
<p
|
||||||
|
className="font-playfair text-sm font-semibold tracking-widest uppercase mb-3"
|
||||||
|
style={{ color: game.color }}
|
||||||
|
>
|
||||||
|
Capitulo {chapter.number} de {totalChapters}
|
||||||
|
</p>
|
||||||
|
<h1 className="font-playfair text-3xl md:text-4xl font-bold text-stone-800 mb-4">
|
||||||
|
{chapter.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<div className="w-12 h-px bg-stone-300" />
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rotate-45"
|
||||||
|
style={{ backgroundColor: game.accentColor }}
|
||||||
|
/>
|
||||||
|
<div className="w-12 h-px bg-stone-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chapter content */}
|
||||||
|
<article className="max-w-[700px] mx-auto px-6 pb-20 font-lora text-lg leading-relaxed text-stone-700 prose prose-stone prose-lg prose-headings:font-playfair prose-headings:text-stone-800 prose-p:mb-6 prose-p:indent-8 first:prose-p:indent-0">
|
||||||
|
<MDXRemote source={chapter.content} />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Chapter navigation */}
|
||||||
|
<nav className="max-w-[700px] mx-auto px-6 pb-16">
|
||||||
|
<div className="border-t border-stone-300 pt-8 flex justify-between items-center">
|
||||||
|
{prevChapter ? (
|
||||||
|
<Link
|
||||||
|
href={`/${game.slug}/${prevChapter}`}
|
||||||
|
className="font-lora text-sm hover:text-stone-900 transition-colors"
|
||||||
|
style={{ color: game.color }}
|
||||||
|
>
|
||||||
|
← Capitulo anterior
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/${game.slug}`}
|
||||||
|
className="font-lora text-sm text-stone-400 hover:text-stone-600 transition-colors"
|
||||||
|
>
|
||||||
|
Indice
|
||||||
|
</Link>
|
||||||
|
{nextChapter ? (
|
||||||
|
<Link
|
||||||
|
href={`/${game.slug}/${nextChapter}`}
|
||||||
|
className="font-lora text-sm hover:text-stone-900 transition-colors"
|
||||||
|
style={{ color: game.color }}
|
||||||
|
>
|
||||||
|
Siguiente capitulo →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/ReadingProgress.tsx
Normal file
27
src/components/ReadingProgress.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function ReadingProgress({ color }: { color: string }) {
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleScroll() {
|
||||||
|
const scrollTop = window.scrollY;
|
||||||
|
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
setProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 left-0 right-0 h-1 z-50 bg-parchment-dark/50">
|
||||||
|
<div
|
||||||
|
className="h-full transition-[width] duration-150"
|
||||||
|
style={{ width: `${progress}%`, backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user