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