| [b62cefc] | 1 | import React, { useState } from 'react'
|
|---|
| 2 | import { useParams, useNavigate } from 'react-router-dom'
|
|---|
| [99c1e45] | 3 | import { ArrowLeft, BookOpen, Users, Calendar, Plus, BookmarkPlus } from 'lucide-react'
|
|---|
| [b62cefc] | 4 | import { useStoryStore } from '../../store/storyStore'
|
|---|
| 5 | import { useAuthStore } from '../../store/authStore'
|
|---|
| 6 | import { useUIStore } from '../../store/uiStore'
|
|---|
| 7 | import { Button } from '../../components/ui/Button'
|
|---|
| 8 | import { GenreBadge } from '../../components/ui/Badge'
|
|---|
| 9 | import { Avatar } from '../../components/ui/Avatar'
|
|---|
| 10 | import { ChapterList } from '../../components/story/ChapterList'
|
|---|
| 11 | import { CommentSection } from '../../components/story/CommentSection'
|
|---|
| 12 | import { LikeButton } from '../../components/story/LikeButton'
|
|---|
| 13 | import { Modal } from '../../components/ui/Modal'
|
|---|
| 14 | import { getGenreGradient } from '../../components/story/GenreBadge'
|
|---|
| 15 | import { ReadingListItem } from '../../types'
|
|---|
| 16 |
|
|---|
| 17 | export const StoryDetailPage: React.FC = () => {
|
|---|
| 18 | const { id } = useParams<{ id: string }>()
|
|---|
| 19 | const navigate = useNavigate()
|
|---|
| 20 | const { stories, chapters, collaborations, readingLists, addStoryToList, createReadingList } = useStoryStore()
|
|---|
| 21 | const { currentUser } = useAuthStore()
|
|---|
| 22 | const { addToast } = useUIStore()
|
|---|
| 23 | const [listModalOpen, setListModalOpen] = useState(false)
|
|---|
| 24 | const [newListName, setNewListName] = useState('')
|
|---|
| [73b69b2] | 25 | const [liveLikes, setLiveLikes] = useState<number | null>(null)
|
|---|
| 26 | const [liveComments, setLiveComments] = useState<number | null>(null)
|
|---|
| [b62cefc] | 27 |
|
|---|
| 28 | const story = stories.find(s => s.story_id === Number(id))
|
|---|
| 29 | if (!story) {
|
|---|
| 30 | return (
|
|---|
| 31 | <div className="max-w-4xl mx-auto px-4 py-20 text-center">
|
|---|
| 32 | <h2 className="text-2xl text-white mb-4">Story not found</h2>
|
|---|
| 33 | <Button onClick={() => navigate('/browse')}>Browse Stories</Button>
|
|---|
| 34 | </div>
|
|---|
| 35 | )
|
|---|
| 36 | }
|
|---|
| 37 |
|
|---|
| 38 | const storyChapters = chapters.filter(c => c.story_id === story.story_id)
|
|---|
| 39 | const storyCollabs = collaborations.filter(c => c.story_id === story.story_id)
|
|---|
| 40 | const gradient = getGenreGradient(story.genres[0])
|
|---|
| 41 | const myLists = currentUser ? readingLists.filter(l => l.user_id === currentUser.user_id) : []
|
|---|
| 42 |
|
|---|
| [acf690c] | 43 | const handleAddToList = async (listId: number) => {
|
|---|
| [b62cefc] | 44 | const list = readingLists.find(l => l.list_id === listId)
|
|---|
| 45 | if (!list) return
|
|---|
| 46 | if (list.stories.some(s => s.story_id === story.story_id)) {
|
|---|
| 47 | addToast('Already in this list', 'info')
|
|---|
| 48 | return
|
|---|
| 49 | }
|
|---|
| 50 | const item: ReadingListItem = {
|
|---|
| 51 | item_id: Date.now(),
|
|---|
| 52 | list_id: listId,
|
|---|
| 53 | story_id: story.story_id,
|
|---|
| 54 | story_title: story.title,
|
|---|
| 55 | author_username: story.author_username,
|
|---|
| 56 | added_at: new Date().toISOString(),
|
|---|
| 57 | genres: story.genres,
|
|---|
| 58 | }
|
|---|
| [acf690c] | 59 | try {
|
|---|
| 60 | await addStoryToList(listId, item)
|
|---|
| 61 | addToast(`Added to "${list.name}"!`)
|
|---|
| 62 | } catch (err: any) {
|
|---|
| 63 | const msg = err?.response?.data?.message || ''
|
|---|
| 64 | if (msg.includes('already') || msg.includes('duplicate') || err?.response?.status === 400) {
|
|---|
| 65 | addToast('Already in this list', 'info')
|
|---|
| 66 | } else {
|
|---|
| 67 | addToast('Failed to add to list.', 'error')
|
|---|
| 68 | }
|
|---|
| 69 | }
|
|---|
| [b62cefc] | 70 | setListModalOpen(false)
|
|---|
| 71 | }
|
|---|
| 72 |
|
|---|
| [acf690c] | 73 | const handleCreateList = async () => {
|
|---|
| [b62cefc] | 74 | if (!newListName.trim() || !currentUser) return
|
|---|
| 75 | const newList = {
|
|---|
| 76 | list_id: Date.now(),
|
|---|
| 77 | user_id: currentUser.user_id,
|
|---|
| 78 | username: currentUser.username,
|
|---|
| 79 | name: newListName.trim(),
|
|---|
| 80 | is_public: false,
|
|---|
| 81 | created_at: new Date().toISOString(),
|
|---|
| [acf690c] | 82 | stories: [],
|
|---|
| 83 | }
|
|---|
| 84 | try {
|
|---|
| 85 | const realListId = await createReadingList(newList)
|
|---|
| 86 | await addStoryToList(realListId, {
|
|---|
| [b62cefc] | 87 | item_id: Date.now() + 1,
|
|---|
| [acf690c] | 88 | list_id: realListId,
|
|---|
| [b62cefc] | 89 | story_id: story.story_id,
|
|---|
| 90 | story_title: story.title,
|
|---|
| 91 | author_username: story.author_username,
|
|---|
| 92 | added_at: new Date().toISOString(),
|
|---|
| 93 | genres: story.genres,
|
|---|
| [acf690c] | 94 | })
|
|---|
| 95 | addToast(`Created "${newListName}" and added story!`)
|
|---|
| 96 | } catch {
|
|---|
| 97 | addToast('Failed to create list.', 'error')
|
|---|
| [b62cefc] | 98 | }
|
|---|
| 99 | setNewListName('')
|
|---|
| 100 | setListModalOpen(false)
|
|---|
| 101 | }
|
|---|
| 102 |
|
|---|
| 103 | const isOwner = currentUser?.user_id === story.user_id
|
|---|
| [7fbb91c] | 104 | const isCollaborator = currentUser ? storyCollabs.some(c => c.user_id === currentUser.user_id) : false
|
|---|
| [b62cefc] | 105 |
|
|---|
| 106 | return (
|
|---|
| 107 | <div className="max-w-5xl mx-auto px-4 py-8">
|
|---|
| 108 | {/* Back */}
|
|---|
| 109 | <button
|
|---|
| 110 | onClick={() => navigate(-1)}
|
|---|
| 111 | className="flex items-center gap-2 text-slate-400 hover:text-white mb-6 text-sm transition-colors"
|
|---|
| 112 | >
|
|---|
| 113 | <ArrowLeft size={16} />
|
|---|
| 114 | Back
|
|---|
| 115 | </button>
|
|---|
| 116 |
|
|---|
| 117 | {/* Hero */}
|
|---|
| 118 | <div className={`relative rounded-2xl overflow-hidden mb-8 bg-gradient-to-br ${gradient}`}>
|
|---|
| [99c1e45] | 119 | {story.cover_image && (
|
|---|
| 120 | <img src={story.cover_image} alt={story.title} className="absolute inset-0 w-full h-full object-cover opacity-30" />
|
|---|
| 121 | )}
|
|---|
| [b62cefc] | 122 | <div className="absolute inset-0 bg-gradient-to-t from-slate-950/90 via-slate-950/30 to-transparent" />
|
|---|
| 123 | <div className="relative p-8 sm:p-12">
|
|---|
| 124 | <div className="flex flex-wrap gap-2 mb-4">
|
|---|
| 125 | {story.genres.map(g => <GenreBadge key={g} genre={g} />)}
|
|---|
| 126 | {story.mature_content && (
|
|---|
| 127 | <span className="px-2 py-0.5 text-xs font-medium rounded-full border bg-rose-500/20 text-rose-400 border-rose-500/30">
|
|---|
| 128 | 18+
|
|---|
| 129 | </span>
|
|---|
| 130 | )}
|
|---|
| 131 | </div>
|
|---|
| 132 | <h1 className="font-serif text-3xl sm:text-4xl font-bold text-white mb-3">{story.title}</h1>
|
|---|
| 133 | <p className="text-slate-300 text-lg mb-6 leading-relaxed max-w-2xl">{story.short_description}</p>
|
|---|
| 134 |
|
|---|
| 135 | <div className="flex items-center gap-4 flex-wrap">
|
|---|
| 136 | <div className="flex items-center gap-2">
|
|---|
| 137 | <Avatar name={story.author_username} size="sm" />
|
|---|
| 138 | <span className="text-white text-sm font-medium">{story.author_username}</span>
|
|---|
| 139 | </div>
|
|---|
| 140 | <div className="flex items-center gap-1 text-slate-400 text-sm">
|
|---|
| 141 | <BookOpen size={14} />
|
|---|
| 142 | {story.total_chapters} chapters
|
|---|
| 143 | </div>
|
|---|
| 144 | <div className="flex items-center gap-1 text-slate-400 text-sm">
|
|---|
| 145 | <Calendar size={14} />
|
|---|
| 146 | {new Date(story.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
|---|
| 147 | </div>
|
|---|
| 148 | </div>
|
|---|
| 149 | </div>
|
|---|
| 150 | </div>
|
|---|
| 151 |
|
|---|
| 152 | <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|---|
| 153 | {/* Main content */}
|
|---|
| 154 | <div className="lg:col-span-2 space-y-8">
|
|---|
| 155 | {/* Action bar */}
|
|---|
| 156 | <div className="flex items-center gap-3 flex-wrap">
|
|---|
| [73b69b2] | 157 | <LikeButton storyId={story.story_id} authorUserId={story.user_id} totalLikes={story.total_likes} onCountChange={setLiveLikes} />
|
|---|
| [b62cefc] | 158 | {currentUser && (
|
|---|
| 159 | <Button variant="secondary" size="sm" onClick={() => setListModalOpen(true)}>
|
|---|
| 160 | <BookmarkPlus size={14} />
|
|---|
| 161 | Save to List
|
|---|
| 162 | </Button>
|
|---|
| 163 | )}
|
|---|
| [7fbb91c] | 164 | {(isOwner || isCollaborator) && (
|
|---|
| [b62cefc] | 165 | <Button variant="ghost" size="sm" onClick={() => navigate(`/writer/edit-story/${story.story_id}`)}>
|
|---|
| 166 | Edit Story
|
|---|
| 167 | </Button>
|
|---|
| 168 | )}
|
|---|
| 169 | </div>
|
|---|
| 170 |
|
|---|
| [99c1e45] | 171 | {/* Story content */}
|
|---|
| 172 | {story.content && (
|
|---|
| 173 | <div className="bg-slate-800/50 border border-slate-700 rounded-2xl p-6">
|
|---|
| 174 | <p className="text-slate-200 leading-relaxed font-serif whitespace-pre-wrap">{story.content}</p>
|
|---|
| 175 | </div>
|
|---|
| 176 | )}
|
|---|
| 177 |
|
|---|
| [b62cefc] | 178 | {/* Chapters */}
|
|---|
| 179 | <div>
|
|---|
| 180 | <div className="flex items-center justify-between mb-4">
|
|---|
| 181 | <h2 className="font-serif text-xl font-bold text-white">Chapters</h2>
|
|---|
| [7fbb91c] | 182 | {(isOwner || isCollaborator) && (
|
|---|
| [b62cefc] | 183 | <Button size="sm" onClick={() => navigate(`/writer/create-chapter/${story.story_id}`)}>
|
|---|
| 184 | <Plus size={14} />
|
|---|
| 185 | Add Chapter
|
|---|
| 186 | </Button>
|
|---|
| 187 | )}
|
|---|
| 188 | </div>
|
|---|
| 189 | <ChapterList chapters={storyChapters} storyId={story.story_id} />
|
|---|
| 190 | </div>
|
|---|
| 191 |
|
|---|
| 192 | {/* Comments */}
|
|---|
| 193 | <div className="border-t border-slate-700 pt-8">
|
|---|
| [73b69b2] | 194 | <CommentSection storyId={story.story_id} authorUserId={story.user_id} onCountChange={setLiveComments} />
|
|---|
| [b62cefc] | 195 | </div>
|
|---|
| 196 | </div>
|
|---|
| 197 |
|
|---|
| 198 | {/* Sidebar */}
|
|---|
| 199 | <div className="space-y-6">
|
|---|
| 200 | {/* Story info */}
|
|---|
| 201 | <div className="bg-slate-800 border border-slate-700 rounded-2xl p-5">
|
|---|
| 202 | <h3 className="text-white font-semibold mb-4">Story Info</h3>
|
|---|
| 203 | <div className="space-y-3 text-sm">
|
|---|
| 204 | <div className="flex justify-between">
|
|---|
| 205 | <span className="text-slate-400">Status</span>
|
|---|
| 206 | <span className="text-white capitalize">{story.status}</span>
|
|---|
| 207 | </div>
|
|---|
| 208 | <div className="flex justify-between">
|
|---|
| 209 | <span className="text-slate-400">Chapters</span>
|
|---|
| 210 | <span className="text-white">{story.total_chapters}</span>
|
|---|
| 211 | </div>
|
|---|
| 212 | <div className="flex justify-between">
|
|---|
| 213 | <span className="text-slate-400">Likes</span>
|
|---|
| [73b69b2] | 214 | <span className="text-white">{(liveLikes ?? story.total_likes).toLocaleString()}</span>
|
|---|
| [b62cefc] | 215 | </div>
|
|---|
| 216 | <div className="flex justify-between">
|
|---|
| 217 | <span className="text-slate-400">Comments</span>
|
|---|
| [73b69b2] | 218 | <span className="text-white">{liveComments ?? story.total_comments}</span>
|
|---|
| [b62cefc] | 219 | </div>
|
|---|
| [a8f4a2d] | 220 | <div className="flex justify-between">
|
|---|
| 221 | <span className="text-slate-400">Views</span>
|
|---|
| 222 | <span className="text-white">{story.total_views.toLocaleString()}</span>
|
|---|
| 223 | </div>
|
|---|
| [b62cefc] | 224 | <div className="flex justify-between">
|
|---|
| 225 | <span className="text-slate-400">Published</span>
|
|---|
| 226 | <span className="text-white">{new Date(story.created_at).toLocaleDateString()}</span>
|
|---|
| 227 | </div>
|
|---|
| 228 | </div>
|
|---|
| 229 | </div>
|
|---|
| 230 |
|
|---|
| 231 | {/* Collaborators */}
|
|---|
| 232 | {storyCollabs.length > 0 && (
|
|---|
| 233 | <div className="bg-slate-800 border border-slate-700 rounded-2xl p-5">
|
|---|
| 234 | <div className="flex items-center gap-2 mb-4">
|
|---|
| 235 | <Users size={16} className="text-violet-400" />
|
|---|
| 236 | <h3 className="text-white font-semibold">Collaborators</h3>
|
|---|
| 237 | </div>
|
|---|
| 238 | <div className="space-y-3">
|
|---|
| 239 | {storyCollabs.map(c => (
|
|---|
| 240 | <div key={c.collab_id} className="flex items-center gap-2">
|
|---|
| 241 | <Avatar name={c.name} size="sm" />
|
|---|
| 242 | <div>
|
|---|
| 243 | <p className="text-white text-sm">@{c.username}</p>
|
|---|
| 244 | <p className="text-slate-500 text-xs">{c.role}</p>
|
|---|
| 245 | </div>
|
|---|
| 246 | </div>
|
|---|
| 247 | ))}
|
|---|
| 248 | </div>
|
|---|
| 249 | </div>
|
|---|
| 250 | )}
|
|---|
| 251 | </div>
|
|---|
| 252 | </div>
|
|---|
| 253 |
|
|---|
| 254 | {/* Reading list modal */}
|
|---|
| 255 | <Modal isOpen={listModalOpen} onClose={() => setListModalOpen(false)} title="Save to Reading List">
|
|---|
| 256 | <div className="space-y-3">
|
|---|
| 257 | {myLists.length > 0 ? (
|
|---|
| 258 | <>
|
|---|
| 259 | <p className="text-slate-400 text-sm mb-3">Select a list:</p>
|
|---|
| 260 | {myLists.map(list => (
|
|---|
| 261 | <button
|
|---|
| 262 | key={list.list_id}
|
|---|
| 263 | onClick={() => handleAddToList(list.list_id)}
|
|---|
| 264 | className="w-full flex items-center justify-between p-3 bg-slate-800 rounded-xl border border-slate-700 hover:border-indigo-500/50 transition-colors"
|
|---|
| 265 | >
|
|---|
| 266 | <span className="text-white text-sm">{list.name}</span>
|
|---|
| 267 | <span className="text-slate-500 text-xs">{list.stories.length} stories</span>
|
|---|
| 268 | </button>
|
|---|
| 269 | ))}
|
|---|
| 270 | <div className="border-t border-slate-700 pt-3">
|
|---|
| 271 | <p className="text-slate-400 text-sm mb-2">Or create a new list:</p>
|
|---|
| 272 | <div className="flex gap-2">
|
|---|
| 273 | <input
|
|---|
| 274 | value={newListName}
|
|---|
| 275 | onChange={e => setNewListName(e.target.value)}
|
|---|
| 276 | placeholder="List name..."
|
|---|
| 277 | className="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500"
|
|---|
| 278 | />
|
|---|
| 279 | <Button size="sm" onClick={handleCreateList} disabled={!newListName.trim()}>
|
|---|
| 280 | Create
|
|---|
| 281 | </Button>
|
|---|
| 282 | </div>
|
|---|
| 283 | </div>
|
|---|
| 284 | </>
|
|---|
| 285 | ) : (
|
|---|
| 286 | <div>
|
|---|
| 287 | <p className="text-slate-400 text-sm mb-3">You don't have any reading lists yet. Create one:</p>
|
|---|
| 288 | <div className="flex gap-2">
|
|---|
| 289 | <input
|
|---|
| 290 | value={newListName}
|
|---|
| 291 | onChange={e => setNewListName(e.target.value)}
|
|---|
| 292 | placeholder="My Favorites..."
|
|---|
| 293 | className="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500"
|
|---|
| 294 | />
|
|---|
| 295 | <Button size="sm" onClick={handleCreateList} disabled={!newListName.trim()}>
|
|---|
| 296 | Create
|
|---|
| 297 | </Button>
|
|---|
| 298 | </div>
|
|---|
| 299 | </div>
|
|---|
| 300 | )}
|
|---|
| 301 | </div>
|
|---|
| 302 | </Modal>
|
|---|
| 303 | </div>
|
|---|
| 304 | )
|
|---|
| 305 | }
|
|---|