Changeset 73b69b2 for chapterx-frontend/src/components
- Timestamp:
- 03/24/26 22:13:36 (3 months ago)
- Branches:
- main
- Children:
- 7fbb91c
- Parents:
- acf690c
- Location:
- chapterx-frontend/src/components
- Files:
-
- 4 edited
-
layout/Navbar.tsx (modified) (2 diffs)
-
notifications/NotificationDropdown.tsx (modified) (2 diffs)
-
story/CommentSection.tsx (modified) (6 diffs)
-
story/LikeButton.tsx (modified) (5 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 )
Note:
See TracChangeset
for help on using the changeset viewer.
