| 1 | import React, { useEffect, useState } from 'react'
|
|---|
| 2 | import { useParams, useNavigate } from 'react-router-dom'
|
|---|
| 3 | import { ArrowLeft, ArrowRight, BookOpen, Eye, ChevronLeft, ChevronRight } from 'lucide-react'
|
|---|
| 4 | import { useStoryStore } from '../../store/storyStore'
|
|---|
| 5 | import { Button } from '../../components/ui/Button'
|
|---|
| 6 |
|
|---|
| 7 | export const ChapterReadPage: React.FC = () => {
|
|---|
| 8 | const { storyId, chapterId } = useParams<{ storyId: string; chapterId: string }>()
|
|---|
| 9 | const navigate = useNavigate()
|
|---|
| 10 | const { stories, chapters, incrementViewCount } = useStoryStore()
|
|---|
| 11 | const [scrollProgress, setScrollProgress] = useState(0)
|
|---|
| 12 | const [viewed, setViewed] = useState(false)
|
|---|
| 13 |
|
|---|
| 14 | const story = stories.find(s => s.story_id === Number(storyId))
|
|---|
| 15 | const chapter = chapters.find(c => c.chapter_id === Number(chapterId))
|
|---|
| 16 | const storyChapters = chapters
|
|---|
| 17 | .filter(c => c.story_id === Number(storyId) && c.is_published)
|
|---|
| 18 | .sort((a, b) => a.chapter_number - b.chapter_number)
|
|---|
| 19 |
|
|---|
| 20 | const currentIndex = storyChapters.findIndex(c => c.chapter_id === Number(chapterId))
|
|---|
| 21 | const prevChapter = currentIndex > 0 ? storyChapters[currentIndex - 1] : null
|
|---|
| 22 | const nextChapter = currentIndex < storyChapters.length - 1 ? storyChapters[currentIndex + 1] : null
|
|---|
| 23 |
|
|---|
| 24 | // Track scroll progress
|
|---|
| 25 | useEffect(() => {
|
|---|
| 26 | const handler = () => {
|
|---|
| 27 | const el = document.documentElement
|
|---|
| 28 | const progress = (el.scrollTop / (el.scrollHeight - el.clientHeight)) * 100
|
|---|
| 29 | setScrollProgress(Math.min(100, progress))
|
|---|
| 30 | // Increment view count when 30% read
|
|---|
| 31 | if (progress > 30 && !viewed) {
|
|---|
| 32 | setViewed(true)
|
|---|
| 33 | if (chapter) incrementViewCount(chapter.chapter_id)
|
|---|
| 34 | }
|
|---|
| 35 | }
|
|---|
| 36 | window.addEventListener('scroll', handler)
|
|---|
| 37 | return () => window.removeEventListener('scroll', handler)
|
|---|
| 38 | }, [chapter, viewed, incrementViewCount])
|
|---|
| 39 |
|
|---|
| 40 | if (!story || !chapter) {
|
|---|
| 41 | return (
|
|---|
| 42 | <div className="max-w-4xl mx-auto px-4 py-20 text-center">
|
|---|
| 43 | <h2 className="text-2xl text-white mb-4">Chapter not found</h2>
|
|---|
| 44 | <Button onClick={() => navigate('/browse')}>Browse Stories</Button>
|
|---|
| 45 | </div>
|
|---|
| 46 | )
|
|---|
| 47 | }
|
|---|
| 48 |
|
|---|
| 49 | return (
|
|---|
| 50 | <div className="min-h-screen">
|
|---|
| 51 | {/* Progress bar */}
|
|---|
| 52 | <div className="fixed top-0 left-0 right-0 z-50 h-1 bg-slate-800">
|
|---|
| 53 | <div
|
|---|
| 54 | className="h-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all duration-100"
|
|---|
| 55 | style={{ width: `${scrollProgress}%` }}
|
|---|
| 56 | />
|
|---|
| 57 | </div>
|
|---|
| 58 |
|
|---|
| 59 | {/* Top nav */}
|
|---|
| 60 | <div className="sticky top-1 z-40 glass border-b border-white/5">
|
|---|
| 61 | <div className="max-w-3xl mx-auto px-4 py-3 flex items-center justify-between">
|
|---|
| 62 | <button
|
|---|
| 63 | onClick={() => navigate(`/story/${story.story_id}`)}
|
|---|
| 64 | className="flex items-center gap-2 text-slate-400 hover:text-white text-sm transition-colors"
|
|---|
| 65 | >
|
|---|
| 66 | <ArrowLeft size={16} />
|
|---|
| 67 | <span className="hidden sm:block">{story.title}</span>
|
|---|
| 68 | </button>
|
|---|
| 69 |
|
|---|
| 70 | <div className="text-center">
|
|---|
| 71 | <p className="text-white text-sm font-medium">{chapter.title}</p>
|
|---|
| 72 | <p className="text-slate-500 text-xs">Chapter {chapter.chapter_number}</p>
|
|---|
| 73 | </div>
|
|---|
| 74 |
|
|---|
| 75 | <div className="flex items-center gap-1 text-slate-400 text-xs">
|
|---|
| 76 | <Eye size={12} />
|
|---|
| 77 | {scrollProgress.toFixed(0)}%
|
|---|
| 78 | </div>
|
|---|
| 79 | </div>
|
|---|
| 80 | </div>
|
|---|
| 81 |
|
|---|
| 82 | {/* Content */}
|
|---|
| 83 | <div className="max-w-3xl mx-auto px-4 py-12">
|
|---|
| 84 | {/* Chapter header */}
|
|---|
| 85 | <div className="text-center mb-12">
|
|---|
| 86 | <p className="text-indigo-400 text-sm font-medium mb-2 uppercase tracking-widest">
|
|---|
| 87 | Chapter {chapter.chapter_number}
|
|---|
| 88 | </p>
|
|---|
| 89 | <h1 className="font-serif text-3xl sm:text-4xl font-bold text-white mb-4">
|
|---|
| 90 | {chapter.title}
|
|---|
| 91 | </h1>
|
|---|
| 92 | <div className="flex items-center justify-center gap-4 text-slate-500 text-sm">
|
|---|
| 93 | <span className="flex items-center gap-1">
|
|---|
| 94 | <BookOpen size={13} />
|
|---|
| 95 | {chapter.word_count.toLocaleString()} words
|
|---|
| 96 | </span>
|
|---|
| 97 | <span className="flex items-center gap-1">
|
|---|
| 98 | <Eye size={13} />
|
|---|
| 99 | {chapter.view_count.toLocaleString()} reads
|
|---|
| 100 | </span>
|
|---|
| 101 | </div>
|
|---|
| 102 | <div className="mt-4 w-16 h-px bg-gradient-to-r from-transparent via-indigo-500 to-transparent mx-auto" />
|
|---|
| 103 | </div>
|
|---|
| 104 |
|
|---|
| 105 | {/* Chapter content */}
|
|---|
| 106 | <div className="prose prose-lg prose-invert max-w-none">
|
|---|
| 107 | {chapter.content.split('\n\n').map((para, i) => (
|
|---|
| 108 | <p
|
|---|
| 109 | key={i}
|
|---|
| 110 | className="text-slate-300 leading-relaxed text-lg mb-6 first-letter:text-4xl first-letter:font-serif first-letter:font-bold first-letter:text-white first-letter:float-left first-letter:mr-2 first-letter:mt-1"
|
|---|
| 111 | style={i !== 0 ? { textIndent: '2rem' } : undefined}
|
|---|
| 112 | >
|
|---|
| 113 | {para}
|
|---|
| 114 | </p>
|
|---|
| 115 | ))}
|
|---|
| 116 | </div>
|
|---|
| 117 |
|
|---|
| 118 | {/* Chapter navigation */}
|
|---|
| 119 | <div className="mt-16 pt-8 border-t border-slate-700">
|
|---|
| 120 | <div className="flex items-center justify-between gap-4">
|
|---|
| 121 | {prevChapter ? (
|
|---|
| 122 | <button
|
|---|
| 123 | onClick={() => navigate(`/story/${storyId}/chapter/${prevChapter.chapter_id}`)}
|
|---|
| 124 | className="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl hover:border-indigo-500/50 transition-all group flex-1 max-w-[45%]"
|
|---|
| 125 | >
|
|---|
| 126 | <ChevronLeft size={18} className="text-slate-400 group-hover:text-indigo-400 flex-shrink-0" />
|
|---|
| 127 | <div className="text-left min-w-0">
|
|---|
| 128 | <p className="text-xs text-slate-500">Previous</p>
|
|---|
| 129 | <p className="text-white text-sm truncate">{prevChapter.title}</p>
|
|---|
| 130 | </div>
|
|---|
| 131 | </button>
|
|---|
| 132 | ) : (
|
|---|
| 133 | <div className="flex-1" />
|
|---|
| 134 | )}
|
|---|
| 135 |
|
|---|
| 136 | <Button
|
|---|
| 137 | variant="ghost"
|
|---|
| 138 | size="sm"
|
|---|
| 139 | onClick={() => navigate(`/story/${story.story_id}`)}
|
|---|
| 140 | >
|
|---|
| 141 | Contents
|
|---|
| 142 | </Button>
|
|---|
| 143 |
|
|---|
| 144 | {nextChapter ? (
|
|---|
| 145 | <button
|
|---|
| 146 | onClick={() => navigate(`/story/${storyId}/chapter/${nextChapter.chapter_id}`)}
|
|---|
| 147 | className="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl hover:border-indigo-500/50 transition-all group flex-1 max-w-[45%] justify-end"
|
|---|
| 148 | >
|
|---|
| 149 | <div className="text-right min-w-0">
|
|---|
| 150 | <p className="text-xs text-slate-500">Next</p>
|
|---|
| 151 | <p className="text-white text-sm truncate">{nextChapter.title}</p>
|
|---|
| 152 | </div>
|
|---|
| 153 | <ChevronRight size={18} className="text-slate-400 group-hover:text-indigo-400 flex-shrink-0" />
|
|---|
| 154 | </button>
|
|---|
| 155 | ) : (
|
|---|
| 156 | <div className="flex-1 text-center">
|
|---|
| 157 | <p className="text-slate-500 text-sm">End of story</p>
|
|---|
| 158 | </div>
|
|---|
| 159 | )}
|
|---|
| 160 | </div>
|
|---|
| 161 | </div>
|
|---|
| 162 | </div>
|
|---|
| 163 | </div>
|
|---|
| 164 | )
|
|---|
| 165 | }
|
|---|