Changeset 73b69b2 for chapterx-frontend
- Timestamp:
- 03/24/26 22:13:36 (3 months ago)
- Branches:
- main
- Children:
- 7fbb91c
- Parents:
- acf690c
- Location:
- chapterx-frontend/src
- Files:
-
- 10 edited
-
components/layout/Navbar.tsx (modified) (2 diffs)
-
components/notifications/NotificationDropdown.tsx (modified) (2 diffs)
-
components/story/CommentSection.tsx (modified) (6 diffs)
-
components/story/LikeButton.tsx (modified) (5 diffs)
-
pages/admin/AdminGenresPage.tsx (modified) (4 diffs)
-
pages/reading-list/CommunityListsPage.tsx (modified) (2 diffs)
-
pages/reading-list/ReadingListPage.tsx (modified) (5 diffs)
-
pages/story/StoryDetailPage.tsx (modified) (5 diffs)
-
store/notificationStore.ts (modified) (1 diff)
-
store/storyStore.ts (modified) (9 diffs)
Legend:
- Unmodified
- Added
- Removed
-
chapterx-frontend/src/components/layout/Navbar.tsx
racf690c r73b69b2 14 14 const location = useLocation() 15 15 const { currentUser, logout } = useAuthStore() 16 const { notifications } = useNotificationStore()16 const { notifications, fetchUserNotifications } = useNotificationStore() 17 17 const unread = notifications.filter(n => !n.is_read).length 18 19 useEffect(() => { 20 if (currentUser) fetchUserNotifications(currentUser.user_id) 21 }, [currentUser?.user_id]) 18 22 19 23 const [mobileOpen, setMobileOpen] = useState(false) … … 100 104 <div className="relative" ref={notifRef}> 101 105 <button 102 onClick={() => { setNotifOpen(!notifOpen); setUserMenuOpen(false) }}106 onClick={() => { const opening = !notifOpen; setNotifOpen(opening); setUserMenuOpen(false); if (opening && currentUser) fetchUserNotifications(currentUser.user_id) }} 103 107 className="relative p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors" 104 108 > -
chapterx-frontend/src/components/notifications/NotificationDropdown.tsx
racf690c r73b69b2 14 14 case 'system': return <Settings {...props} className="text-slate-400" /> 15 15 default: return <Bell {...props} className="text-slate-400" /> 16 } 17 } 18 19 const typeTitle = (type: NotificationType) => { 20 switch (type) { 21 case 'like': return 'New Like' 22 case 'comment': return 'New Comment' 23 case 'collaboration': return 'Collaboration' 24 case 'follow': return 'New Follower' 25 case 'system': return 'System' 26 default: return 'Notification' 16 27 } 17 28 } … … 69 80 </div> 70 81 <div className="flex-1 min-w-0"> 71 <p className="text-xs font-medium text-white">{ n.title}</p>82 <p className="text-xs font-medium text-white">{typeTitle(n.type)}</p> 72 83 <p className="text-xs text-slate-400 line-clamp-2">{n.message}</p> 73 84 <p className="text-xs text-slate-600 mt-0.5">{timeAgo(n.created_at)}</p> -
chapterx-frontend/src/components/story/CommentSection.tsx
racf690c r73b69b2 1 import React, { use State } from 'react'1 import React, { useEffect, useState } from 'react' 2 2 import { MessageCircle, Trash2, Send } from 'lucide-react' 3 import axios from 'axios' 3 4 import { useAuthStore } from '../../store/authStore' 4 import { useStoryStore } from '../../store/storyStore'5 5 import { useNotificationStore } from '../../store/notificationStore' 6 6 import { useUIStore } from '../../store/uiStore' 7 import { Comment } from '../../types'8 7 import { Avatar } from '../ui/Avatar' 9 8 import { Button } from '../ui/Button' 10 9 11 interface CommentSectionProps { 12 storyId: number 13 authorUserId: number 10 const API = 'https://localhost:7125/api' 11 12 function getAuthHeaders() { 13 try { 14 const token = JSON.parse(localStorage.getItem('chapterx-auth') || '{}')?.state?.token 15 return token ? { Authorization: `Bearer ${token}` } : {} 16 } catch { return {} } 14 17 } 15 18 … … 24 27 } 25 28 26 export const CommentSection: React.FC<CommentSectionProps> = ({ storyId, authorUserId }) => { 29 interface BackendComment { 30 id: number 31 content: string 32 userId: number 33 storyId: number 34 username: string 35 createdAt: string 36 } 37 38 interface CommentSectionProps { 39 storyId: number 40 authorUserId: number 41 onCountChange?: (count: number) => void 42 } 43 44 export const CommentSection: React.FC<CommentSectionProps> = ({ storyId, authorUserId, onCountChange }) => { 27 45 const { currentUser } = useAuthStore() 28 const { comments, addComment, deleteComment } = useStoryStore()29 46 const { addNotification } = useNotificationStore() 30 47 const { addToast } = useUIStore() 48 const [comments, setComments] = useState<BackendComment[]>([]) 31 49 const [text, setText] = useState('') 32 50 const [submitting, setSubmitting] = useState(false) 33 51 34 const storyComments = comments 35 .filter(c => c.story_id === storyId) 36 .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) 52 useEffect(() => { 53 axios.get(`${API}/comments/story/${storyId}`) 54 .then(res => { 55 const data = res.data ?? [] 56 setComments(data) 57 onCountChange?.(data.length) 58 }) 59 .catch(() => {}) 60 }, [storyId]) 37 61 38 62 const handleSubmit = async () => { 39 63 if (!text.trim() || !currentUser) return 40 64 setSubmitting(true) 41 await new Promise(r => setTimeout(r, 300)) 42 const comment: Comment = { 43 comment_id: Date.now(), 44 story_id: storyId, 45 user_id: currentUser.user_id, 46 username: currentUser.username, 47 content: text.trim(), 48 created_at: new Date().toISOString(), 65 try { 66 const res = await axios.post(`${API}/comments`, { 67 content: text.trim(), 68 userId: currentUser.user_id, 69 storyId, 70 }, { headers: getAuthHeaders() }) 71 const newComment: BackendComment = { 72 id: res.data?.id ?? Date.now(), 73 content: text.trim(), 74 userId: currentUser.user_id, 75 storyId, 76 username: currentUser.username, 77 createdAt: new Date().toISOString(), 78 } 79 setComments(prev => { const next = [newComment, ...prev]; onCountChange?.(next.length); return next }) 80 if (currentUser.user_id !== authorUserId) { 81 await addNotification({ 82 recipientUserId: authorUserId, 83 type: 'comment', 84 content: `${currentUser.username} commented: "${text.trim().slice(0, 60)}${text.length > 60 ? '...' : ''}"`, 85 link: `/story/${storyId}`, 86 }) 87 } 88 setText('') 89 addToast('Comment posted!') 90 } catch { 91 addToast('Failed to post comment.', 'error') 49 92 } 50 addComment(comment) 51 if (currentUser.user_id !== authorUserId) { 52 addNotification({ 53 user_id: authorUserId, 54 type: 'comment', 55 title: 'New Comment', 56 message: `${currentUser.username} commented: "${text.trim().slice(0, 60)}..."`, 57 link: `/story/${storyId}`, 58 }) 93 setSubmitting(false) 94 } 95 96 const handleDelete = async (commentId: number) => { 97 try { 98 await axios.delete(`${API}/comments/${commentId}`, { headers: getAuthHeaders() }) 99 setComments(prev => { const next = prev.filter(c => c.id !== commentId); onCountChange?.(next.length); return next }) 100 addToast('Comment deleted', 'info') 101 } catch { 102 addToast('Failed to delete comment.', 'error') 59 103 } 60 setText('')61 setSubmitting(false)62 addToast('Comment posted!')63 104 } 64 105 … … 67 108 <div className="flex items-center gap-2 mb-4"> 68 109 <MessageCircle size={18} className="text-indigo-400" /> 69 <h3 className="text-white font-semibold">Comments ({ storyComments.length})</h3>110 <h3 className="text-white font-semibold">Comments ({comments.length})</h3> 70 111 </div> 71 112 72 {/* Input */}73 113 {currentUser ? ( 74 114 <div className="flex gap-3"> … … 83 123 /> 84 124 <div className="flex justify-end mt-2"> 85 <Button 86 size="sm" 87 onClick={handleSubmit} 88 loading={submitting} 89 disabled={!text.trim()} 90 > 125 <Button size="sm" onClick={handleSubmit} loading={submitting} disabled={!text.trim()}> 91 126 <Send size={14} /> 92 127 Post Comment … … 103 138 )} 104 139 105 {/* Comment list */}106 140 <div className="space-y-3"> 107 { storyComments.length === 0 ? (141 {comments.length === 0 ? ( 108 142 <div className="text-center py-8 text-slate-500 text-sm"> 109 143 No comments yet. Be the first to share your thoughts! 110 144 </div> 111 145 ) : ( 112 storyComments.map(comment => ( 113 <CommentCard 114 key={comment.comment_id} 115 comment={comment} 116 canDelete={currentUser?.user_id === comment.user_id || currentUser?.role === 'admin'} 117 onDelete={() => { deleteComment(comment.comment_id); addToast('Comment deleted', 'info') }} 118 /> 146 comments.map(comment => ( 147 <div key={comment.id} className="flex gap-3 p-4 bg-slate-800 rounded-xl border border-slate-700"> 148 <Avatar name={comment.username} size="sm" /> 149 <div className="flex-1 min-w-0"> 150 <div className="flex items-center justify-between"> 151 <span className="text-sm font-medium text-white">{comment.username}</span> 152 <div className="flex items-center gap-2"> 153 <span className="text-xs text-slate-500">{timeAgo(comment.createdAt)}</span> 154 {(currentUser?.user_id === comment.userId || currentUser?.role === 'admin') && ( 155 <button onClick={() => handleDelete(comment.id)} className="text-slate-500 hover:text-rose-400 transition-colors"> 156 <Trash2 size={13} /> 157 </button> 158 )} 159 </div> 160 </div> 161 <p className="text-slate-300 text-sm mt-1 leading-relaxed">{comment.content}</p> 162 </div> 163 </div> 119 164 )) 120 165 )} … … 123 168 ) 124 169 } 125 126 const CommentCard: React.FC<{127 comment: Comment128 canDelete: boolean129 onDelete: () => void130 }> = ({ comment, canDelete, onDelete }) => (131 <div className="flex gap-3 p-4 bg-slate-800 rounded-xl border border-slate-700">132 <Avatar name={comment.username} size="sm" />133 <div className="flex-1 min-w-0">134 <div className="flex items-center justify-between">135 <span className="text-sm font-medium text-white">{comment.username}</span>136 <div className="flex items-center gap-2">137 <span className="text-xs text-slate-500">{timeAgo(comment.created_at)}</span>138 {canDelete && (139 <button onClick={onDelete} className="text-slate-500 hover:text-rose-400 transition-colors">140 <Trash2 size={13} />141 </button>142 )}143 </div>144 </div>145 <p className="text-slate-300 text-sm mt-1 leading-relaxed">{comment.content}</p>146 </div>147 </div>148 ) -
chapterx-frontend/src/components/story/LikeButton.tsx
racf690c r73b69b2 1 import React from 'react'1 import React, { useEffect, useState } from 'react' 2 2 import { Heart } from 'lucide-react' 3 3 import { useNavigate } from 'react-router-dom' 4 import axios from 'axios' 4 5 import { useAuthStore } from '../../store/authStore' 5 import { useStoryStore } from '../../store/storyStore'6 6 import { useNotificationStore } from '../../store/notificationStore' 7 7 import { useUIStore } from '../../store/uiStore' 8 9 const API = 'https://localhost:7125/api' 10 11 function getAuthHeaders() { 12 try { 13 const token = JSON.parse(localStorage.getItem('chapterx-auth') || '{}')?.state?.token 14 return token ? { Authorization: `Bearer ${token}` } : {} 15 } catch { return {} } 16 } 8 17 9 18 interface LikeButtonProps { … … 11 20 authorUserId: number 12 21 totalLikes: number 22 onCountChange?: (count: number) => void 13 23 } 14 24 15 export const LikeButton: React.FC<LikeButtonProps> = ({ storyId, authorUserId, totalLikes }) => {25 export const LikeButton: React.FC<LikeButtonProps> = ({ storyId, authorUserId, totalLikes, onCountChange }) => { 16 26 const navigate = useNavigate() 17 27 const { currentUser } = useAuthStore() 18 const { toggleLike, isLiked } = useStoryStore()19 28 const { addNotification } = useNotificationStore() 20 29 const { addToast } = useUIStore() 30 const [liked, setLiked] = useState(false) 31 const [count, setCount] = useState(totalLikes) 32 const [loading, setLoading] = useState(false) 21 33 22 const liked = currentUser ? isLiked(currentUser.user_id, storyId) : false 34 useEffect(() => { 35 axios.get(`${API}/likes/story/${storyId}`) 36 .then(res => { 37 const c = res.data?.count ?? totalLikes 38 setCount(c) 39 onCountChange?.(c) 40 if (currentUser) { 41 setLiked((res.data?.userIds ?? []).includes(currentUser.user_id)) 42 } 43 }) 44 .catch(() => {}) 45 }, [storyId, currentUser?.user_id]) 23 46 24 const handleClick = () => {47 const handleClick = async () => { 25 48 if (!currentUser) { 26 49 addToast('Please sign in to like stories.', 'info') … … 28 51 return 29 52 } 30 toggleLike(currentUser.user_id, storyId) 31 if (!liked && currentUser.user_id !== authorUserId) { 32 addNotification({ 33 user_id: authorUserId, 34 type: 'like', 35 title: 'New Like', 36 message: `${currentUser.username} liked your story.`, 37 link: `/story/${storyId}`, 38 }) 53 if (loading) return 54 setLoading(true) 55 try { 56 if (liked) { 57 await axios.delete(`${API}/likes/user/${currentUser.user_id}/story/${storyId}`, { headers: getAuthHeaders() }) 58 setLiked(false) 59 setCount(c => { const n = c - 1; onCountChange?.(n); return n }) 60 addToast('Removed from likes', 'info') 61 } else { 62 await axios.post(`${API}/likes`, { userId: currentUser.user_id, storyId }, { headers: getAuthHeaders() }) 63 setLiked(true) 64 setCount(c => { const n = c + 1; onCountChange?.(n); return n }) 65 if (currentUser.user_id !== authorUserId) { 66 await addNotification({ 67 recipientUserId: authorUserId, 68 type: 'like', 69 content: `${currentUser.username} liked your story.`, 70 link: `/story/${storyId}`, 71 }) 72 } 73 addToast('Added to likes!', 'success') 74 } 75 } catch { 76 addToast('Something went wrong.', 'error') 39 77 } 40 addToast(liked ? 'Removed from likes' : 'Added to likes!', liked ? 'info' : 'success')78 setLoading(false) 41 79 } 42 80 … … 44 82 <button 45 83 onClick={handleClick} 84 disabled={loading} 46 85 className={`flex items-center gap-2 px-4 py-2 rounded-xl border transition-all duration-200 ${ 47 86 liked … … 51 90 > 52 91 <Heart size={16} className={liked ? 'fill-rose-400' : ''} /> 53 <span className="text-sm font-medium">{ totalLikes.toLocaleString()}</span>92 <span className="text-sm font-medium">{count.toLocaleString()}</span> 54 93 </button> 55 94 ) -
chapterx-frontend/src/pages/admin/AdminGenresPage.tsx
racf690c r73b69b2 1 import React, { useState } from 'react'1 import React, { useState, useEffect } from 'react' 2 2 import { useNavigate } from 'react-router-dom' 3 3 import { ArrowLeft, Plus, Trash2, Tag } from 'lucide-react' … … 10 10 export const AdminGenresPage: React.FC = () => { 11 11 const navigate = useNavigate() 12 const { genres, addGenre, deleteGenre } = useStoryStore()12 const { genres, fetchGenres, addGenre, deleteGenre } = useStoryStore() 13 13 const { addToast } = useUIStore() 14 14 const [addOpen, setAddOpen] = useState(false) … … 16 16 const [deleteTarget, setDeleteTarget] = useState<Genre | null>(null) 17 17 18 const handleAdd = () => { 18 useEffect(() => { fetchGenres() }, []) 19 20 const handleAdd = async () => { 19 21 if (!newName.trim()) return 20 22 if (genres.some(g => g.name.toLowerCase() === newName.trim().toLowerCase())) { … … 22 24 return 23 25 } 24 addGenre({ genre_id: Date.now(), name: newName.trim(), story_count: 0 }) 25 addToast(`Genre "${newName.trim()}" added!`) 26 setNewName('') 27 setAddOpen(false) 26 try { 27 await addGenre(newName.trim()) 28 addToast(`Genre "${newName.trim()}" added!`) 29 setNewName('') 30 setAddOpen(false) 31 } catch { 32 addToast('Failed to add genre', 'error') 33 } 28 34 } 29 35 30 const handleDelete = (genre: Genre) => { 31 if (genre.story_count > 0) { 32 addToast('Cannot delete genre with associated stories', 'error') 33 return 36 const handleDelete = async (genre: Genre) => { 37 try { 38 await deleteGenre(genre.genre_id) 39 addToast(`Genre "${genre.name}" deleted`, 'info') 40 } catch { 41 addToast('Failed to delete genre', 'error') 34 42 } 35 deleteGenre(genre.genre_id)36 addToast(`Genre "${genre.name}" deleted`, 'info')37 43 setDeleteTarget(null) 38 44 } -
chapterx-frontend/src/pages/reading-list/CommunityListsPage.tsx
racf690c r73b69b2 1 import React, { useState } from 'react'1 import React, { useState, useEffect } from 'react' 2 2 import { useNavigate } from 'react-router-dom' 3 3 import { Globe, BookOpen, User } from 'lucide-react' … … 10 10 export const CommunityListsPage: React.FC = () => { 11 11 const navigate = useNavigate() 12 const { readingLists } = useStoryStore()12 const { readingLists, fetchReadingLists } = useStoryStore() 13 13 const [selectedList, setSelectedList] = useState<ReadingList | null>(null) 14 15 useEffect(() => { fetchReadingLists() }, []) 14 16 15 17 const publicLists = readingLists.filter(l => l.is_public) -
chapterx-frontend/src/pages/reading-list/ReadingListPage.tsx
racf690c r73b69b2 12 12 const navigate = useNavigate() 13 13 const { currentUser } = useAuthStore() 14 const { readingLists, fetchReadingLists, createReadingList, deleteReadingList, removeStoryFromList } = useStoryStore() 14 const { readingLists, fetchUserReadingLists, createReadingList, deleteReadingList, removeStoryFromList } = useStoryStore() 15 const [fetching, setFetching] = useState(false) 16 const [confirmRemove, setConfirmRemove] = useState<{ listId: number; storyId: number; title: string } | null>(null) 15 17 16 18 useEffect(() => { 17 fetchReadingLists() 18 }, []) 19 if (!currentUser) return 20 setFetching(true) 21 fetchUserReadingLists(currentUser.user_id).finally(() => setFetching(false)) 22 }, [currentUser?.user_id]) 19 23 const { addToast } = useUIStore() 20 24 const [createOpen, setCreateOpen] = useState(false) … … 33 37 } 34 38 35 const myLists = readingLists.filter(l => l.user_id === currentUser .user_id)39 const myLists = readingLists.filter(l => l.user_id === currentUser!.user_id) 36 40 37 41 const handleCreate = async () => { … … 73 77 </div> 74 78 75 {myLists.length === 0 ? ( 79 {fetching ? ( 80 <div className="flex flex-col items-center py-20 text-slate-500"> 81 <BookOpen size={48} className="mb-4 opacity-40 animate-pulse" /> 82 <p className="text-sm">Loading your lists...</p> 83 </div> 84 ) : myLists.length === 0 ? ( 76 85 <div className="flex flex-col items-center py-20 text-slate-500"> 77 86 <BookOpen size={48} className="mb-4 opacity-40" /> … … 124 133 <div className="space-y-2"> 125 134 {list.stories.slice(0, 4).map(item => ( 126 <div key={item.item_id} className="flex items-center gap- 3 p-2 rounded-lg hover:bg-slate-700/50 group">127 <div className="flex-1 min-w-0 ">135 <div key={item.item_id} className="flex items-center gap-2 p-2 rounded-lg hover:bg-slate-700/50"> 136 <div className="flex-1 min-w-0 overflow-hidden"> 128 137 <button 129 138 onClick={() => navigate(`/story/${item.story_id}`)} 130 className="text-white text-sm font-medium hover:text-indigo-300 transition-colors truncate block text-left "139 className="text-white text-sm font-medium hover:text-indigo-300 transition-colors truncate block text-left w-full" 131 140 > 132 141 {item.story_title} 133 142 </button> 134 143 <div className="flex items-center gap-2 mt-0.5"> 135 <span className="text-slate-500 text-xs ">by {item.author_username}</span>144 <span className="text-slate-500 text-xs truncate">by {item.author_username}</span> 136 145 {item.genres.slice(0, 1).map(g => <GenreBadge key={g} genre={g} />)} 137 146 </div> 138 147 </div> 139 148 <button 140 onClick={() => removeStoryFromList(list.list_id, item.story_id)} 141 className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-rose-400 transition-all" 149 onClick={() => setConfirmRemove({ listId: list.list_id, storyId: item.story_id, title: item.story_title })} 150 className="flex-shrink-0 text-slate-500 hover:text-rose-400 hover:bg-rose-400/10 transition-all p-1.5 rounded-lg" 151 title="Remove from list" 142 152 > 143 < Xsize={14} />153 <Trash2 size={14} /> 144 154 </button> 145 155 </div> … … 165 175 )} 166 176 177 {/* Confirm remove modal */} 178 <Modal isOpen={!!confirmRemove} onClose={() => setConfirmRemove(null)} title="Remove Story"> 179 <div className="space-y-4"> 180 <p className="text-slate-300 text-sm"> 181 Are you sure you want to remove <span className="text-white font-medium">"{confirmRemove?.title}"</span> from this list? 182 </p> 183 <div className="flex gap-3"> 184 <Button variant="secondary" className="flex-1" onClick={() => setConfirmRemove(null)}>Cancel</Button> 185 <Button variant="danger" className="flex-1" onClick={async () => { 186 if (!confirmRemove) return 187 await removeStoryFromList(confirmRemove.listId, confirmRemove.storyId) 188 addToast('Story removed from list', 'info') 189 setConfirmRemove(null) 190 }}>Remove</Button> 191 </div> 192 </div> 193 </Modal> 194 167 195 {/* Create modal */} 168 196 <Modal isOpen={createOpen} onClose={() => setCreateOpen(false)} title="Create Reading List"> -
chapterx-frontend/src/pages/story/StoryDetailPage.tsx
racf690c r73b69b2 23 23 const [listModalOpen, setListModalOpen] = useState(false) 24 24 const [newListName, setNewListName] = useState('') 25 const [liveLikes, setLiveLikes] = useState<number | null>(null) 26 const [liveComments, setLiveComments] = useState<number | null>(null) 25 27 26 28 const story = stories.find(s => s.story_id === Number(id)) … … 153 155 {/* Action bar */} 154 156 <div className="flex items-center gap-3 flex-wrap"> 155 <LikeButton storyId={story.story_id} authorUserId={story.user_id} totalLikes={story.total_likes} />157 <LikeButton storyId={story.story_id} authorUserId={story.user_id} totalLikes={story.total_likes} onCountChange={setLiveLikes} /> 156 158 {currentUser && ( 157 159 <Button variant="secondary" size="sm" onClick={() => setListModalOpen(true)}> … … 183 185 {/* Comments */} 184 186 <div className="border-t border-slate-700 pt-8"> 185 <CommentSection storyId={story.story_id} authorUserId={story.user_id} />187 <CommentSection storyId={story.story_id} authorUserId={story.user_id} onCountChange={setLiveComments} /> 186 188 </div> 187 189 </div> … … 203 205 <div className="flex justify-between"> 204 206 <span className="text-slate-400">Likes</span> 205 <span className="text-white">{ story.total_likes.toLocaleString()}</span>207 <span className="text-white">{(liveLikes ?? story.total_likes).toLocaleString()}</span> 206 208 </div> 207 209 <div className="flex justify-between"> … … 211 213 <div className="flex justify-between"> 212 214 <span className="text-slate-400">Comments</span> 213 <span className="text-white">{ story.total_comments}</span>215 <span className="text-white">{liveComments ?? story.total_comments}</span> 214 216 </div> 215 217 <div className="flex justify-between"> -
chapterx-frontend/src/store/notificationStore.ts
racf690c r73b69b2 1 1 import { create } from 'zustand' 2 import axios from 'axios' 2 3 import { Notification } from '../types' 3 import { mockNotifications } from '../data/mockData' 4 5 const API = 'https://localhost:7125/api' 6 7 function getAuthHeaders() { 8 try { 9 const token = JSON.parse(localStorage.getItem('chapterx-auth') || '{}')?.state?.token 10 return token ? { Authorization: `Bearer ${token}` } : {} 11 } catch { 12 return {} 13 } 14 } 4 15 5 16 interface NotificationStore { 6 17 notifications: Notification[] 7 addNotification: (n: Omit<Notification, 'notification_id' | 'created_at' | 'is_read'>) => void 8 markAllRead: () => void 9 markRead: (id: number) => void 18 fetchUserNotifications: (userId: number) => Promise<void> 19 addNotification: (n: { recipientUserId: number; type: string; content: string; link?: string }) => Promise<void> 20 markAllRead: () => Promise<void> 21 markRead: (id: number) => Promise<void> 10 22 getUnreadCount: () => number 11 23 } 12 24 13 25 export const useNotificationStore = create<NotificationStore>((set, get) => ({ 14 notifications: [ ...mockNotifications],26 notifications: [], 15 27 16 addNotification: (n) => 17 set(state => ({ 18 notifications: [ 19 { 20 ...n, 21 notification_id: Date.now(), 22 created_at: new Date().toISOString(), 23 is_read: false, 24 }, 25 ...state.notifications, 26 ], 27 })), 28 fetchUserNotifications: async (userId) => { 29 try { 30 const res = await axios.get(`${API}/notifications/user/${userId}`, { headers: getAuthHeaders() }) 31 const data: any[] = res.data ?? [] 32 const notifications: Notification[] = data.map(n => ({ 33 notification_id: n.id, 34 user_id: userId, 35 type: n.type ?? 'info', 36 title: n.type ?? 'Notification', 37 message: n.content, 38 link: n.link, 39 is_read: n.isRead, 40 created_at: n.createdAt, 41 })) 42 set({ notifications }) 43 } catch { 44 // keep existing 45 } 46 }, 28 47 29 markAllRead: () => 30 set(state => ({ 31 notifications: state.notifications.map(n => ({ ...n, is_read: true })), 32 })), 48 addNotification: async ({ recipientUserId, type, content, link }) => { 49 try { 50 await axios.post(`${API}/notifications`, { 51 content, 52 recipientUserId, 53 type, 54 link, 55 }, { headers: getAuthHeaders() }) 56 } catch { 57 // silent — notification is for recipient, not current user 58 } 59 }, 33 60 34 markRead: (id) => 61 markAllRead: async () => { 62 const unread = get().notifications.filter(n => !n.is_read) 63 set(state => ({ notifications: state.notifications.map(n => ({ ...n, is_read: true })) })) 64 try { 65 await Promise.all(unread.map(n => 66 axios.put(`${API}/notifications/${n.notification_id}/read`, {}, { headers: getAuthHeaders() }) 67 )) 68 } catch { 69 // keep optimistic 70 } 71 }, 72 73 markRead: async (id) => { 35 74 set(state => ({ 36 75 notifications: state.notifications.map(n => 37 76 n.notification_id === id ? { ...n, is_read: true } : n 38 77 ), 39 })), 78 })) 79 try { 80 await axios.put(`${API}/notifications/${id}/read`, {}, { headers: getAuthHeaders() }) 81 } catch { 82 // keep optimistic 83 } 84 }, 40 85 41 86 getUnreadCount: () => get().notifications.filter(n => !n.is_read).length, -
chapterx-frontend/src/store/storyStore.ts
racf690c r73b69b2 25 25 const API = 'https://localhost:7125/api' 26 26 27 function mapReadingList(l: any): ReadingList { 28 return { 29 list_id: l.id, 30 user_id: l.userId, 31 username: l.username ?? '', 32 name: l.name, 33 description: l.content ?? '', 34 is_public: l.isPublic, 35 created_at: l.createdAt, 36 stories: (l.readingListItems ?? []).map((i: any) => ({ 37 item_id: i.listId ?? 0, 38 list_id: l.id, 39 story_id: i.storyId, 40 story_title: i.storyTitle ?? `Story #${i.storyId}`, 41 author_username: i.authorUsername ?? '', 42 added_at: i.addedAt ?? new Date().toISOString(), 43 genres: i.genres ?? [], 44 })), 45 } 46 } 47 27 48 function getAuthHeaders() { 28 49 try { … … 53 74 fetchChapters: () => Promise<void> 54 75 fetchReadingLists: () => Promise<void> 76 fetchUserReadingLists: (userId: number) => Promise<void> 77 fetchGenres: () => Promise<void> 55 78 56 79 // Story actions … … 86 109 87 110 // Genre actions 88 addGenre: ( genre: Genre) => void89 deleteGenre: (id: number) => void111 addGenre: (name: string) => Promise<void> 112 deleteGenre: (id: number) => Promise<void> 90 113 91 114 // Reading list actions … … 103 126 aiSuggestions: [...mockAISuggestions], 104 127 genres: [...mockGenres], 105 readingLists: [ ...mockReadingLists],128 readingLists: [], 106 129 likedStories: [], 107 130 … … 118 141 mature_content: s.matureContent, 119 142 status: 'published' as StoryStatus, 120 author_username: '',143 author_username: s.writer?.user?.username ?? '', 121 144 created_at: s.createdAt, 122 145 updated_at: s.updatedAt, … … 125 148 total_chapters: 0, 126 149 total_views: 0, 127 genres: [],150 genres: (s.hasGenres ?? []).map((hg: any) => hg.genre?.name ?? hg.name).filter(Boolean), 128 151 })) 129 152 if (stories.length > 0) set({ stories }) … … 159 182 const res = await axios.post(`${API}/stories`, { 160 183 matureContent: story.mature_content, 161 shortDescription: story. title || story.short_description,184 shortDescription: story.short_description || story.title, 162 185 image: null, 163 186 content: story.content, 164 187 userId: story.user_id, 188 genres: story.genres ?? [], 165 189 }, { headers: getAuthHeaders() }) 166 190 const backendId = res.data?.id ?? res.data … … 443 467 }, 444 468 445 addGenre: (genre) => 446 set(state => ({ genres: [...state.genres, genre] })), 447 448 deleteGenre: (id) => 449 set(state => ({ genres: state.genres.filter(g => g.genre_id !== id) })), 469 fetchGenres: async () => { 470 try { 471 const res = await axios.get(`${API}/genres`) 472 const data: any[] = res.data?.genres ?? res.data ?? [] 473 const genres: Genre[] = data.map((g: any) => ({ genre_id: g.id, name: g.name })) 474 if (genres.length > 0) set({ genres }) 475 } catch { 476 // keep mock data on failure 477 } 478 }, 479 480 addGenre: async (name) => { 481 const res = await axios.post(`${API}/genres`, { name }, { headers: getAuthHeaders() }) 482 const id = res.data?.id ?? res.data 483 set(state => ({ genres: [...state.genres, { genre_id: id, name }] })) 484 }, 485 486 deleteGenre: async (id) => { 487 set(state => ({ genres: state.genres.filter(g => g.genre_id !== id) })) 488 try { 489 await axios.delete(`${API}/genres/${id}`, { headers: getAuthHeaders() }) 490 } catch { 491 // optimistic delete already applied 492 } 493 }, 450 494 451 495 fetchReadingLists: async () => { 452 496 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 497 const res = await axios.get(`${API}/readinglists`) 498 const data: any[] = res.data ?? [] 499 const lists: ReadingList[] = data.map(mapReadingList) 500 set({ readingLists: lists }) 501 } catch { 502 // keep existing data on failure 503 } 504 }, 505 506 fetchUserReadingLists: async (userId) => { 507 try { 508 const res = await axios.get(`${API}/readinglists/user/${userId}`, { headers: getAuthHeaders() }) 509 const data: any[] = res.data ?? [] 510 const lists: ReadingList[] = data.map(mapReadingList) 511 set(state => ({ 512 readingLists: [ 513 ...state.readingLists.filter(l => l.user_id !== userId), 514 ...lists, 515 ] 516 })) 517 } catch (err) { 518 console.error('fetchUserReadingLists failed:', err) 483 519 } 484 520 }, … … 525 561 })) 526 562 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() }) 563 await axios.delete(`${API}/readinglistitems/${listId}/story/${storyId}`, { headers: getAuthHeaders() }) 532 564 } catch { 533 565 // optimistic update already applied
Note:
See TracChangeset
for help on using the changeset viewer.
