Changeset acf690c for chapterx-frontend
- Timestamp:
- 03/22/26 17:58:40 (4 months ago)
- Branches:
- main
- Children:
- 73b69b2
- Parents:
- b62cefc
- Location:
- chapterx-frontend/src
- Files:
-
- 1 added
- 8 edited
-
App.tsx (modified) (2 diffs)
-
components/writer/StoryCreationAIPanel.tsx (added)
-
pages/reading-list/ReadingListPage.tsx (modified) (4 diffs)
-
pages/story/StoryDetailPage.tsx (modified) (4 diffs)
-
pages/writer/CreateChapterPage.tsx (modified) (1 diff)
-
pages/writer/CreateStoryPage.tsx (modified) (6 diffs)
-
pages/writer/EditStoryPage.tsx (modified) (1 diff)
-
store/authStore.ts (modified) (3 diffs)
-
store/storyStore.ts (modified) (14 diffs)
Legend:
- Unmodified
- Added
- Removed
-
chapterx-frontend/src/App.tsx
rb62cefc racf690c 1 import React, { Suspense, lazy, ReactNode } from 'react'1 import React, { Suspense, lazy, ReactNode, useEffect } from 'react' 2 2 import { Routes, Route, Navigate } from 'react-router-dom' 3 3 import { useAuthStore } from './store/authStore' 4 4 import { useUIStore } from './store/uiStore' 5 import { useStoryStore } from './store/storyStore' 5 6 import { UserRole } from './types' 6 7 import { Navbar } from './components/layout/Navbar' … … 67 68 68 69 function App() { 70 const { fetchStories, fetchChapters, fetchReadingLists } = useStoryStore() 71 72 useEffect(() => { 73 fetchStories() 74 fetchChapters() 75 fetchReadingLists() 76 }, []) 77 69 78 return ( 70 79 <div className="flex flex-col min-h-screen"> -
chapterx-frontend/src/pages/reading-list/ReadingListPage.tsx
rb62cefc racf690c 1 import React, { useState } from 'react'1 import React, { useState, useEffect } from 'react' 2 2 import { useNavigate } from 'react-router-dom' 3 3 import { Plus, BookOpen, Lock, Globe, Trash2, X } from 'lucide-react' … … 12 12 const navigate = useNavigate() 13 13 const { currentUser } = useAuthStore() 14 const { readingLists, createReadingList, deleteReadingList, removeStoryFromList } = useStoryStore() 14 const { readingLists, fetchReadingLists, createReadingList, deleteReadingList, removeStoryFromList } = useStoryStore() 15 16 useEffect(() => { 17 fetchReadingLists() 18 }, []) 15 19 const { addToast } = useUIStore() 16 20 const [createOpen, setCreateOpen] = useState(false) … … 31 35 const myLists = readingLists.filter(l => l.user_id === currentUser.user_id) 32 36 33 const handleCreate = () => {37 const handleCreate = async () => { 34 38 if (!newListName.trim()) return 35 createReadingList({ 36 list_id: Date.now(), 37 user_id: currentUser.user_id, 38 username: currentUser.username, 39 name: newListName.trim(), 40 description: newListDesc.trim() || undefined, 41 is_public: isPublic, 42 created_at: new Date().toISOString(), 43 stories: [], 44 }) 45 addToast(`"${newListName}" created!`) 39 try { 40 await createReadingList({ 41 list_id: Date.now(), 42 user_id: currentUser.user_id, 43 username: currentUser.username, 44 name: newListName.trim(), 45 description: newListDesc.trim() || undefined, 46 is_public: isPublic, 47 created_at: new Date().toISOString(), 48 stories: [], 49 }) 50 addToast(`"${newListName}" created!`) 51 } catch (err: any) { 52 addToast(err?.response?.data?.message || 'Failed to create list.', 'error') 53 return 54 } 46 55 setNewListName('') 47 56 setNewListDesc('') … … 100 109 </div> 101 110 <button 102 onClick={ () => { deleteReadingList(list.list_id); addToast('List deleted', 'info')}}111 onClick={async () => { try { await deleteReadingList(list.list_id); addToast('List deleted', 'info') } catch { addToast('Failed to delete list.', 'error') } }} 103 112 className="text-slate-500 hover:text-rose-400 transition-colors p-1" 104 113 > -
chapterx-frontend/src/pages/story/StoryDetailPage.tsx
rb62cefc racf690c 39 39 const myLists = currentUser ? readingLists.filter(l => l.user_id === currentUser.user_id) : [] 40 40 41 const handleAddToList = (listId: number) => {41 const handleAddToList = async (listId: number) => { 42 42 const list = readingLists.find(l => l.list_id === listId) 43 43 if (!list) return … … 55 55 genres: story.genres, 56 56 } 57 addStoryToList(listId, item) 58 addToast(`Added to "${list.name}"!`) 57 try { 58 await addStoryToList(listId, item) 59 addToast(`Added to "${list.name}"!`) 60 } catch (err: any) { 61 const msg = err?.response?.data?.message || '' 62 if (msg.includes('already') || msg.includes('duplicate') || err?.response?.status === 400) { 63 addToast('Already in this list', 'info') 64 } else { 65 addToast('Failed to add to list.', 'error') 66 } 67 } 59 68 setListModalOpen(false) 60 69 } 61 70 62 const handleCreateList = () => {71 const handleCreateList = async () => { 63 72 if (!newListName.trim() || !currentUser) return 64 73 const newList = { … … 69 78 is_public: false, 70 79 created_at: new Date().toISOString(), 71 stories: [{ 80 stories: [], 81 } 82 try { 83 const realListId = await createReadingList(newList) 84 await addStoryToList(realListId, { 72 85 item_id: Date.now() + 1, 73 list_id: Date.now(),86 list_id: realListId, 74 87 story_id: story.story_id, 75 88 story_title: story.title, … … 77 90 added_at: new Date().toISOString(), 78 91 genres: story.genres, 79 }], 80 } 81 createReadingList(newList) 82 addToast(`Created "${newListName}" and added story!`) 92 }) 93 addToast(`Created "${newListName}" and added story!`) 94 } catch { 95 addToast('Failed to create list.', 'error') 96 } 83 97 setNewListName('') 84 98 setListModalOpen(false) -
chapterx-frontend/src/pages/writer/CreateChapterPage.tsx
rb62cefc racf690c 56 56 is_published: isPublished, 57 57 } 58 addChapter(chapter) 58 try { 59 await addChapter(chapter) 60 } catch (err: any) { 61 const msg = err?.response?.data?.message || err?.message || 'Failed to save chapter.' 62 addToast(msg, 'error') 63 setLoading(false) 64 return 65 } 59 66 addToast(isPublished ? 'Chapter published!' : 'Chapter saved as draft!') 60 navigate( `/writer/edit-story/${storyId}`)67 navigate(isPublished ? `/story/${storyId}` : `/writer/edit-story/${storyId}`) 61 68 setLoading(false) 62 69 } -
chapterx-frontend/src/pages/writer/CreateStoryPage.tsx
rb62cefc racf690c 1 import React, { useState } from 'react'1 import React, { useState, useRef } from 'react' 2 2 import { useNavigate } from 'react-router-dom' 3 3 import { Feather, ArrowLeft, X } from 'lucide-react' … … 6 6 import { useUIStore } from '../../store/uiStore' 7 7 import { Button } from '../../components/ui/Button' 8 import { StoryCreationAIPanel, StoryCreationAIPanelRef } from '../../components/writer/StoryCreationAIPanel' 8 9 import { Story } from '../../types' 9 10 … … 13 14 const navigate = useNavigate() 14 15 const { currentUser } = useAuthStore() 15 const { addStory } = useStoryStore()16 const { addStory, addSuggestion } = useStoryStore() 16 17 const { addToast } = useUIStore() 18 const aiPanelRef = useRef<StoryCreationAIPanelRef>(null) 17 19 const [form, setForm] = useState({ 18 20 title: '', … … 53 55 total_views: 0, 54 56 } 55 addStory(story) 57 let realId: number 58 try { 59 realId = await addStory(story) 60 } catch (err: any) { 61 const msg = err?.response?.data?.message || err?.message || 'Failed to save story.' 62 addToast(msg, 'error') 63 setLoading(false) 64 return 65 } 66 56 67 addToast(status === 'published' ? 'Story published!' : 'Draft saved!') 57 navigate(`/writer/edit-story/${ story.story_id}`)68 navigate(`/writer/edit-story/${realId}`) 58 69 setLoading(false) 59 70 } … … 73 84 74 85 return ( 75 <div className="max-w- 3xl mx-auto px-4 py-8">86 <div className="max-w-6xl mx-auto px-4 py-8"> 76 87 <div className="flex items-center gap-3 mb-8"> 77 88 <button onClick={() => navigate('/writer')} className="text-slate-400 hover:text-white transition-colors"> … … 84 95 </div> 85 96 86 <div className="space-y-6"> 87 {/* Title */} 88 <div> 89 <label className="block text-sm font-medium text-slate-300 mb-1.5">Story Title *</label> 90 <input 91 value={form.title} 92 onChange={e => setField('title', e.target.value)} 93 placeholder="The Chronicles of Eldoria..." 94 className={`w-full px-4 py-3 bg-slate-800 border rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 text-lg font-serif ${errors.title ? 'border-rose-500' : 'border-slate-700'}`} 95 /> 96 {errors.title && <p className="text-rose-400 text-xs mt-1">{errors.title}</p>} 97 </div> 98 99 {/* Short description */} 100 <div> 101 <label className="block text-sm font-medium text-slate-300 mb-1.5">Short Description * <span className="text-slate-500 font-normal">(shown on story cards)</span></label> 102 <textarea 103 value={form.short_description} 104 onChange={e => setField('short_description', e.target.value)} 105 placeholder="When the last dragon awakens..." 106 rows={3} 107 maxLength={280} 108 className={`w-full px-4 py-3 bg-slate-800 border rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 resize-none ${errors.short_description ? 'border-rose-500' : 'border-slate-700'}`} 109 /> 110 <div className="flex justify-between items-center mt-1"> 111 {errors.short_description ? <p className="text-rose-400 text-xs">{errors.short_description}</p> : <span />} 112 <span className="text-slate-600 text-xs">{form.short_description.length}/280</span> 113 </div> 114 </div> 115 116 {/* Content */} 117 <div> 118 <label className="block text-sm font-medium text-slate-300 mb-1.5">Story Content *</label> 119 <textarea 120 value={form.content} 121 onChange={e => setField('content', e.target.value)} 122 placeholder="Begin your story here. This is the introduction that readers will see..." 123 rows={8} 124 className={`w-full px-4 py-3 bg-slate-800 border rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 resize-y font-serif ${errors.content ? 'border-rose-500' : 'border-slate-700'}`} 125 /> 126 {errors.content && <p className="text-rose-400 text-xs mt-1">{errors.content}</p>} 127 </div> 128 129 {/* Genres */} 130 <div> 131 <label className="block text-sm font-medium text-slate-300 mb-2">Genres * <span className="text-slate-500 font-normal">(select 1-3)</span></label> 132 <div className="flex flex-wrap gap-2 mb-2"> 133 {ALL_GENRES.map(g => ( 134 <button 135 key={g} 136 type="button" 137 onClick={() => toggleGenre(g)} 138 className={`px-3 py-1.5 rounded-full text-sm border transition-all ${ 139 form.genres.includes(g) 140 ? 'bg-indigo-500/30 text-indigo-300 border-indigo-500/50' 141 : 'bg-slate-800 text-slate-400 border-slate-700 hover:border-slate-500' 142 }`} 143 > 144 {form.genres.includes(g) && <span className="mr-1">✓</span>} 145 {g} 146 </button> 147 ))} 148 </div> 149 {form.genres.length > 0 && ( 150 <div className="flex flex-wrap gap-1 mt-1"> 151 {form.genres.map(g => ( 152 <span key={g} className="flex items-center gap-1 text-xs px-2 py-0.5 bg-indigo-500/20 text-indigo-300 rounded-full"> 97 <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> 98 <div className="lg:col-span-2 space-y-6"> 99 {/* Title */} 100 <div> 101 <label className="block text-sm font-medium text-slate-300 mb-1.5">Story Title *</label> 102 <input 103 value={form.title} 104 onChange={e => setField('title', e.target.value)} 105 placeholder="The Chronicles of Eldoria..." 106 className={`w-full px-4 py-3 bg-slate-800 border rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 text-lg font-serif ${errors.title ? 'border-rose-500' : 'border-slate-700'}`} 107 /> 108 {errors.title && <p className="text-rose-400 text-xs mt-1">{errors.title}</p>} 109 </div> 110 111 {/* Short description */} 112 <div> 113 <label className="block text-sm font-medium text-slate-300 mb-1.5">Short Description * <span className="text-slate-500 font-normal">(shown on story cards)</span></label> 114 <textarea 115 value={form.short_description} 116 onChange={e => setField('short_description', e.target.value)} 117 placeholder="When the last dragon awakens..." 118 rows={3} 119 maxLength={280} 120 className={`w-full px-4 py-3 bg-slate-800 border rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 resize-none ${errors.short_description ? 'border-rose-500' : 'border-slate-700'}`} 121 /> 122 <div className="flex justify-between items-center mt-1"> 123 {errors.short_description ? <p className="text-rose-400 text-xs">{errors.short_description}</p> : <span />} 124 <span className="text-slate-600 text-xs">{form.short_description.length}/280</span> 125 </div> 126 </div> 127 128 {/* Content */} 129 <div> 130 <label className="block text-sm font-medium text-slate-300 mb-1.5">Story Content *</label> 131 <textarea 132 value={form.content} 133 onChange={e => setField('content', e.target.value)} 134 placeholder="Begin your story here. This is the introduction that readers will see..." 135 rows={8} 136 className={`w-full px-4 py-3 bg-slate-800 border rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 resize-y font-serif ${errors.content ? 'border-rose-500' : 'border-slate-700'}`} 137 /> 138 {errors.content && <p className="text-rose-400 text-xs mt-1">{errors.content}</p>} 139 </div> 140 141 {/* Genres */} 142 <div> 143 <label className="block text-sm font-medium text-slate-300 mb-2">Genres * <span className="text-slate-500 font-normal">(select 1-3)</span></label> 144 <div className="flex flex-wrap gap-2 mb-2"> 145 {ALL_GENRES.map(g => ( 146 <button 147 key={g} 148 type="button" 149 onClick={() => toggleGenre(g)} 150 className={`px-3 py-1.5 rounded-full text-sm border transition-all ${ 151 form.genres.includes(g) 152 ? 'bg-indigo-500/30 text-indigo-300 border-indigo-500/50' 153 : 'bg-slate-800 text-slate-400 border-slate-700 hover:border-slate-500' 154 }`} 155 > 156 {form.genres.includes(g) && <span className="mr-1">✓</span>} 153 157 {g} 154 <button onClick={() => toggleGenre(g)}><X size={10} /></button> 155 </span> 158 </button> 156 159 ))} 157 160 </div> 158 )} 159 {errors.genres && <p className="text-rose-400 text-xs mt-1">{errors.genres}</p>} 161 {form.genres.length > 0 && ( 162 <div className="flex flex-wrap gap-1 mt-1"> 163 {form.genres.map(g => ( 164 <span key={g} className="flex items-center gap-1 text-xs px-2 py-0.5 bg-indigo-500/20 text-indigo-300 rounded-full"> 165 {g} 166 <button onClick={() => toggleGenre(g)}><X size={10} /></button> 167 </span> 168 ))} 169 </div> 170 )} 171 {errors.genres && <p className="text-rose-400 text-xs mt-1">{errors.genres}</p>} 172 </div> 173 174 {/* Mature content */} 175 <div className="flex items-center gap-3"> 176 <button 177 type="button" 178 onClick={() => setField('mature_content', !form.mature_content)} 179 className={`w-11 h-6 rounded-full transition-colors ${form.mature_content ? 'bg-rose-500' : 'bg-slate-600'} relative flex-shrink-0`} 180 > 181 <div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${form.mature_content ? 'translate-x-6' : 'translate-x-1'}`} /> 182 </button> 183 <div> 184 <p className="text-white text-sm font-medium">Mature Content (18+)</p> 185 <p className="text-slate-500 text-xs">Enable for stories with adult themes</p> 186 </div> 187 </div> 188 189 {/* Actions */} 190 <div className="flex gap-3 pt-4 border-t border-slate-700"> 191 <Button variant="secondary" className="flex-1" onClick={() => handleSubmit('draft')} loading={loading}> 192 Save as Draft 193 </Button> 194 <Button className="flex-1" onClick={() => handleSubmit('published')} loading={loading}> 195 <Feather size={16} /> 196 Publish Story 197 </Button> 198 </div> 160 199 </div> 161 200 162 {/* Mature content */} 163 <div className="flex items-center gap-3"> 164 <button 165 type="button" 166 onClick={() => setField('mature_content', !form.mature_content)} 167 className={`w-11 h-6 rounded-full transition-colors ${form.mature_content ? 'bg-rose-500' : 'bg-slate-600'} relative flex-shrink-0`} 168 > 169 <div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${form.mature_content ? 'translate-x-6' : 'translate-x-1'}`} /> 170 </button> 171 <div> 172 <p className="text-white text-sm font-medium">Mature Content (18+)</p> 173 <p className="text-slate-500 text-xs">Enable for stories with adult themes</p> 174 </div> 175 </div> 176 177 {/* Actions */} 178 <div className="flex gap-3 pt-4 border-t border-slate-700"> 179 <Button variant="secondary" className="flex-1" onClick={() => handleSubmit('draft')} loading={loading}> 180 Save as Draft 181 </Button> 182 <Button className="flex-1" onClick={() => handleSubmit('published')} loading={loading}> 183 <Feather size={16} /> 184 Publish Story 185 </Button> 201 {/* AI Suggestions Sidebar */} 202 <div className="lg:col-span-1"> 203 <StoryCreationAIPanel 204 ref={aiPanelRef} 205 title={form.title} 206 description={form.short_description} 207 content={form.content} 208 genres={form.genres} 209 /> 186 210 </div> 187 211 </div> -
chapterx-frontend/src/pages/writer/EditStoryPage.tsx
rb62cefc racf690c 63 63 addToast('Story updated!') 64 64 setSaving(false) 65 navigate(`/story/${story.story_id}`) 65 66 } 66 67 -
chapterx-frontend/src/store/authStore.ts
rb62cefc racf690c 27 27 token: null, 28 28 showMatureContent: true, 29 allUsers: [ ...mockUsers],29 allUsers: [], 30 30 31 31 login: async (emailOrUsername, password) => { … … 36 36 : get().allUsers.find(u => u.username === emailOrUsername)?.email || emailOrUsername 37 37 const res = await axios.post(`${API_BASE}/auth/login`, { email, password }, { timeout: 3000 }) 38 const { token, userId, username } = res.data 39 const user = get().allUsers.find(u => u.user_id === userId) || 40 get().allUsers.find(u => u.username === username) || { 41 user_id: userId, 42 username, 43 email, 44 name: username, 45 surname: '', 46 role: 'regular' as const, 47 created_at: new Date().toISOString(), 48 follower_count: 0, 49 following_count: 0, 50 } 38 const { token, userId, username, name, surname, role } = res.data 39 const user: User = { 40 user_id: userId, 41 username, 42 email, 43 name: name ?? username, 44 surname: surname ?? '', 45 role: role ?? 'regular', 46 created_at: new Date().toISOString(), 47 follower_count: 0, 48 following_count: 0, 49 } 51 50 set({ currentUser: user, token }) 52 51 return 53 } catch { 54 // Fall through to mock login 52 } catch (err: any) { 53 // Only fall through to mock if the backend is unreachable (network/timeout) 54 // If the backend responded with an error (4xx/5xx), surface it to the user 55 if (err?.response) { 56 const message = err.response.data?.message || err.response.data || 'Invalid email or password.' 57 throw new Error(typeof message === 'string' ? message : 'Invalid email or password.') 58 } 59 // Network error / timeout — fall through to mock login 55 60 } 56 61 … … 66 71 67 72 register: async (data, role) => { 68 // Try backend first69 73 try { 70 74 await axios.post(`${API_BASE}/auth/register`, data, { timeout: 3000 }) 71 } catch { 72 // Fall through to mock register 75 // Register doesn't return a token — auto-login to get one 76 const loginRes = await axios.post(`${API_BASE}/auth/login`, { email: data.email, password: data.password }, { timeout: 3000 }) 77 const { token, userId, username } = loginRes.data 78 const newUser: User = { 79 user_id: userId, 80 username, 81 email: data.email, 82 name: data.name, 83 surname: data.surname, 84 role, 85 created_at: new Date().toISOString(), 86 follower_count: 0, 87 following_count: 0, 88 } 89 set(state => ({ allUsers: [...state.allUsers, newUser], currentUser: newUser, token })) 90 return 91 } catch (err: any) { 92 if (err?.response) { 93 const message = err.response.data?.message || err.response.data || 'Registration failed.' 94 throw new Error(typeof message === 'string' ? message : 'Registration failed.') 95 } 96 // Network error / timeout — fall through to mock register 97 console.warn('Backend unreachable during register, using mock:', err?.message) 73 98 } 74 99 -
chapterx-frontend/src/store/storyStore.ts
rb62cefc racf690c 23 23 } from '../data/mockData' 24 24 25 const API = 'https://localhost:7125/api' 26 27 function getAuthHeaders() { 28 try { 29 const token = JSON.parse(localStorage.getItem('chapterx-auth') || '{}')?.state?.token 30 return token ? { Authorization: `Bearer ${token}` } : {} 31 } catch { 32 return {} 33 } 34 } 35 25 36 interface LikeRecord { 26 37 userId: number … … 38 49 likedStories: LikeRecord[] 39 50 51 // Fetch from backend 52 fetchStories: () => Promise<void> 53 fetchChapters: () => Promise<void> 54 fetchReadingLists: () => Promise<void> 55 40 56 // Story actions 41 addStory: (story: Story) => void42 updateStory: (id: number, partial: Partial<Story>) => void43 deleteStory: (id: number) => void57 addStory: (story: Story) => Promise<number> 58 updateStory: (id: number, partial: Partial<Story>) => Promise<void> 59 deleteStory: (id: number) => Promise<void> 44 60 updateStoryStatus: (id: number, status: StoryStatus) => void 45 61 46 62 // Chapter actions 47 addChapter: (chapter: Chapter) => void48 updateChapter: (id: number, partial: Partial<Chapter>) => void49 deleteChapter: (id: number) => void63 addChapter: (chapter: Chapter) => Promise<void> 64 updateChapter: (id: number, partial: Partial<Chapter>) => Promise<void> 65 deleteChapter: (id: number) => Promise<void> 50 66 incrementViewCount: (chapterId: number) => void 51 67 … … 74 90 75 91 // Reading list actions 76 createReadingList: (list: ReadingList) => void77 addStoryToList: (listId: number, item: ReadingListItem) => void78 removeStoryFromList: (listId: number, storyId: number) => void79 deleteReadingList: (listId: number) => void92 createReadingList: (list: ReadingList) => Promise<number> 93 addStoryToList: (listId: number, item: ReadingListItem) => Promise<void> 94 removeStoryFromList: (listId: number, storyId: number) => Promise<void> 95 deleteReadingList: (listId: number) => Promise<void> 80 96 } 81 97 … … 90 106 likedStories: [], 91 107 92 addStory: (story) => 93 set(state => ({ stories: [...state.stories, story] })), 94 95 updateStory: (id, partial) => 108 fetchStories: async () => { 109 try { 110 const res = await axios.get(`${API}/stories`) 111 const data: any[] = res.data?.stories ?? res.data ?? [] 112 const stories: Story[] = data.map((s: any) => ({ 113 story_id: s.id, 114 user_id: s.userId, 115 title: s.shortDescription, 116 short_description: s.shortDescription, 117 content: s.content, 118 mature_content: s.matureContent, 119 status: 'published' as StoryStatus, 120 author_username: '', 121 created_at: s.createdAt, 122 updated_at: s.updatedAt, 123 total_likes: 0, 124 total_comments: 0, 125 total_chapters: 0, 126 total_views: 0, 127 genres: [], 128 })) 129 if (stories.length > 0) set({ stories }) 130 } catch { 131 // keep mock data on failure 132 } 133 }, 134 135 fetchChapters: async () => { 136 try { 137 const res = await axios.get(`${API}/chapters`) 138 const data: any[] = res.data?.chapters ?? res.data ?? [] 139 const chapters: Chapter[] = data.map((c: any) => ({ 140 chapter_id: c.id, 141 story_id: c.storyId, 142 title: c.title ?? c.name, 143 content: c.content, 144 chapter_number: c.number, 145 word_count: c.wordCount ?? 0, 146 view_count: c.viewCount ?? 0, 147 is_published: true, 148 created_at: c.createdAt, 149 updated_at: c.updatedAt, 150 })) 151 if (chapters.length > 0) set({ chapters }) 152 } catch { 153 // keep mock data on failure 154 } 155 }, 156 157 addStory: async (story) => { 158 set(state => ({ stories: [...state.stories, story] })) 159 const res = await axios.post(`${API}/stories`, { 160 matureContent: story.mature_content, 161 shortDescription: story.title || story.short_description, 162 image: null, 163 content: story.content, 164 userId: story.user_id, 165 }, { headers: getAuthHeaders() }) 166 const backendId = res.data?.id ?? res.data 167 if (backendId && backendId !== story.story_id) { 168 set(state => ({ 169 stories: state.stories.map(s => 170 s.story_id === story.story_id ? { ...s, story_id: backendId } : s 171 ), 172 chapters: state.chapters.map(c => 173 c.story_id === story.story_id ? { ...c, story_id: backendId } : c 174 ), 175 })) 176 return backendId 177 } 178 return story.story_id 179 }, 180 181 updateStory: async (id, partial) => { 96 182 set(state => ({ 97 183 stories: state.stories.map(s => (s.story_id === id ? { ...s, ...partial } : s)), 98 })), 99 100 deleteStory: (id) => 184 })) 185 try { 186 const story = get().stories.find(s => s.story_id === id) 187 if (!story) return 188 await axios.put(`${API}/stories/${id}`, { 189 id, 190 matureContent: partial.mature_content ?? story.mature_content, 191 shortDescription: partial.title ?? partial.short_description ?? story.title ?? story.short_description, 192 image: null, 193 content: partial.content ?? story.content, 194 }, { headers: getAuthHeaders() }) 195 } catch { 196 // keep optimistic update on failure 197 } 198 }, 199 200 deleteStory: async (id) => { 101 201 set(state => ({ 102 202 stories: state.stories.filter(s => s.story_id !== id), … … 104 204 comments: state.comments.filter(c => c.story_id !== id), 105 205 collaborations: state.collaborations.filter(c => c.story_id !== id), 106 })), 206 })) 207 try { 208 await axios.delete(`${API}/stories/${id}`, { headers: getAuthHeaders() }) 209 } catch { 210 // keep optimistic delete on failure 211 } 212 }, 107 213 108 214 updateStoryStatus: (id, status) => … … 113 219 })), 114 220 115 addChapter: (chapter) =>221 addChapter: async (chapter) => { 116 222 set(state => ({ 117 223 chapters: [...state.chapters, chapter], … … 121 227 : s 122 228 ), 123 })), 124 125 updateChapter: (id, partial) => 229 })) 230 const res = await axios.post(`${API}/chapters`, { 231 number: chapter.chapter_number, 232 name: chapter.title, 233 title: chapter.title, 234 content: chapter.content, 235 storyId: chapter.story_id, 236 }, { headers: getAuthHeaders() }) 237 const backendId = res.data?.id ?? res.data 238 if (backendId && backendId !== chapter.chapter_id) { 239 set(state => ({ 240 chapters: state.chapters.map(c => 241 c.chapter_id === chapter.chapter_id ? { ...c, chapter_id: backendId } : c 242 ), 243 })) 244 } 245 }, 246 247 updateChapter: async (id, partial) => { 126 248 set(state => ({ 127 249 chapters: state.chapters.map(c => 128 250 c.chapter_id === id ? { ...c, ...partial, updated_at: new Date().toISOString() } : c 129 251 ), 130 })), 131 132 deleteChapter: (id) => 252 })) 253 try { 254 const chapter = get().chapters.find(c => c.chapter_id === id) 255 if (!chapter) return 256 await axios.put(`${API}/chapters/${id}`, { 257 id, 258 number: partial.chapter_number ?? chapter.chapter_number, 259 name: partial.title ?? chapter.title, 260 title: partial.title ?? chapter.title, 261 content: partial.content ?? chapter.content, 262 wordCount: partial.word_count ?? chapter.word_count, 263 }, { headers: getAuthHeaders() }) 264 } catch { 265 // keep optimistic update on failure 266 } 267 }, 268 269 deleteChapter: async (id) => { 133 270 set(state => { 134 271 const chapter = state.chapters.find(c => c.chapter_id === id) … … 143 280 : state.stories, 144 281 } 145 }), 282 }) 283 try { 284 await axios.delete(`${API}/chapters/${id}`, { headers: getAuthHeaders() }) 285 } catch { 286 // keep optimistic delete on failure 287 } 288 }, 146 289 147 290 incrementViewCount: (chapterId) => … … 218 361 fetchSuggestions: async () => { 219 362 try { 220 const res = await axios.get( 'https://localhost:7125/api/aisuggestions')363 const res = await axios.get(`${API}/aisuggestions`) 221 364 const data = res.data.aiSuggestions ?? res.data 222 365 const mapped: AISuggestion[] = data.map((s: any) => ({ … … 248 391 })) 249 392 try { 250 await axios.put(` https://localhost:7125/api/aisuggestions/${id}`, {393 await axios.put(`${API}/aisuggestions/${id}`, { 251 394 id, 252 395 originalText: s.original_text, … … 268 411 })) 269 412 try { 270 await axios.put(` https://localhost:7125/api/aisuggestions/${id}`, {413 await axios.put(`${API}/aisuggestions/${id}`, { 271 414 id, 272 415 originalText: s.original_text, … … 284 427 set(state => ({ aiSuggestions: [...state.aiSuggestions, { ...suggestion, suggestion_id: tempId }] })) 285 428 try { 286 const res = await axios.post(' https://localhost:7125/api/aisuggestions', {429 const res = await axios.post('${API}/aisuggestions', { 287 430 originalText: suggestion.original_text, 288 431 suggestedText: suggestion.suggested_text, … … 306 449 set(state => ({ genres: state.genres.filter(g => g.genre_id !== id) })), 307 450 308 createReadingList: (list) => 309 set(state => ({ readingLists: [...state.readingLists, list] })), 310 311 addStoryToList: (listId, item) => 451 fetchReadingLists: async () => { 452 try { 453 const [listsRes, storiesRes] = await Promise.all([ 454 axios.get(`${API}/readinglists`), 455 axios.get(`${API}/stories`), 456 ]) 457 const data: any[] = listsRes.data?.readingLists ?? listsRes.data ?? [] 458 const storiesData: any[] = storiesRes.data?.stories ?? storiesRes.data ?? [] 459 const lists: ReadingList[] = data.map((l: any) => ({ 460 list_id: l.id, 461 user_id: l.userId, 462 username: '', 463 name: l.name, 464 description: l.content ?? '', 465 is_public: l.isPublic, 466 created_at: l.createdAt, 467 stories: (l.readingListItems ?? []).map((i: any) => { 468 const story = storiesData.find((s: any) => s.id === i.storyId) 469 return { 470 item_id: i.id ?? 0, 471 list_id: l.id, 472 story_id: i.storyId, 473 story_title: story?.title ?? story?.shortDescription ?? `Story #${i.storyId}`, 474 author_username: story?.authorUsername ?? '', 475 added_at: i.addedAt ?? new Date().toISOString(), 476 genres: story?.genres ?? [], 477 } 478 }), 479 })) 480 if (lists.length > 0) set({ readingLists: lists }) 481 } catch { 482 // keep mock data on failure 483 } 484 }, 485 486 createReadingList: async (list) => { 487 set(state => ({ readingLists: [...state.readingLists, list] })) 488 const res = await axios.post(`${API}/readinglists`, { 489 name: list.name, 490 content: list.description ?? null, 491 isPublic: list.is_public, 492 userId: list.user_id, 493 }, { headers: getAuthHeaders() }) 494 const backendId = res.data?.id ?? res.data 495 if (backendId && backendId !== list.list_id) { 496 set(state => ({ 497 readingLists: state.readingLists.map(l => 498 l.list_id === list.list_id ? { ...l, list_id: backendId } : l 499 ), 500 })) 501 return backendId 502 } 503 return list.list_id 504 }, 505 506 addStoryToList: async (listId, item) => { 312 507 set(state => ({ 313 508 readingLists: state.readingLists.map(l => 314 l.list_id === listId 315 ? { ...l, stories: [...l.stories, item] } 316 : l 317 ), 318 })), 319 320 removeStoryFromList: (listId, storyId) => 509 l.list_id === listId ? { ...l, stories: [...l.stories, item] } : l 510 ), 511 })) 512 await axios.post(`${API}/readinglistitems`, { 513 readingListId: listId, 514 storyId: item.story_id, 515 }, { headers: getAuthHeaders() }) 516 }, 517 518 removeStoryFromList: async (listId, storyId) => { 321 519 set(state => ({ 322 520 readingLists: state.readingLists.map(l => … … 325 523 : l 326 524 ), 327 })), 328 329 deleteReadingList: (listId) => 525 })) 526 try { 527 // find the item id from backend list items to delete 528 const res = await axios.get(`${API}/readinglistitems`) 529 const items: any[] = res.data?.readingListItems ?? res.data ?? [] 530 const item = items.find((i: any) => i.readingListId === listId && i.storyId === storyId) 531 if (item) await axios.delete(`${API}/readinglistitems/${item.id}`, { headers: getAuthHeaders() }) 532 } catch { 533 // optimistic update already applied 534 } 535 }, 536 537 deleteReadingList: async (listId) => { 330 538 set(state => ({ 331 539 readingLists: state.readingLists.filter(l => l.list_id !== listId), 332 })), 540 })) 541 await axios.delete(`${API}/readinglists/${listId}`, { headers: getAuthHeaders() }) 542 }, 333 543 }))
Note:
See TracChangeset
for help on using the changeset viewer.
