From 4d5b37899f0ecc94fe804e13c88a5350e8f5be31 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 4 Apr 2024 14:00:27 +0300 Subject: [PATCH 1/3] Add like/dislike icons to commentbubbles --- frontend/src/components/comments/Comment.tsx | 59 ++++++++++++++++---- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/comments/Comment.tsx b/frontend/src/components/comments/Comment.tsx index f27b7eb..4df29de 100644 --- a/frontend/src/components/comments/Comment.tsx +++ b/frontend/src/components/comments/Comment.tsx @@ -1,5 +1,6 @@ -import { formatDate } from '@/hooks/utils' +import { formatDate } from '@/hooks/utils'; import { useState } from 'react'; +import { BiSolidLike, BiSolidDislike } from 'react-icons/bi'; // Import like and dislike icons export interface CommentProps { id: number; @@ -27,7 +28,41 @@ const Comment: React.FC = ({ profile_image }) => { const formattedCreatedAt = formatDate(created_at); - const [isModalOpen, setIsModalOpen] = useState(false); + const [likeCount, setLikeCount] = useState(likes); + const [dislikeCount, setDislikeCount] = useState(dislikes); + const [liked, setLiked] = useState(false); + const [disliked, setDisliked] = useState(false); + + const handleLike = () => { + if (!liked) { + setLikeCount(likeCount + 1); + setLiked(true); + if (disliked) { + setDisliked(false); + setDislikeCount(dislikeCount - 1); + } + } else { + setLikeCount(likeCount - 1); + setLiked(false); + } + // TODO: Implement logic for sending like to the server + }; + + const handleDislike = () => { + if (!disliked) { + setDislikeCount(dislikeCount + 1); + setDisliked(true); + if (liked) { + setLiked(false); + setLikeCount(likeCount - 1); + } + } else { + setDislikeCount(dislikeCount - 1); + setDisliked(false); + } + // TODO: Implement logic for sending dislike to the server + }; + return (
{/* Comments inside the CommentsBox.tsx collapsing box*/} @@ -43,18 +78,20 @@ const Comment: React.FC = ({
{content} -
- {image && setIsModalOpen(true)} />} + {image && } +
+ +
- {/* TODO: comment uploaded images */} - {isModalOpen && ( -
setIsModalOpen(false)}> - e.stopPropagation()} /> -
- )}
); } -export default Comment; \ No newline at end of file +export default Comment; From 2fbe3aecce07f4dd5ddc074b9ea8e54d26189a4f Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 4 Apr 2024 14:33:02 +0300 Subject: [PATCH 2/3] Refactor post like/dislike buttons --- .../src/components/comments/CreateComment.tsx | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/comments/CreateComment.tsx b/frontend/src/components/comments/CreateComment.tsx index 248d2a1..3dc6d46 100644 --- a/frontend/src/components/comments/CreateComment.tsx +++ b/frontend/src/components/comments/CreateComment.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { CommentProps } from "./Comment"; +import { FaHeart, FaHeartBroken } from "react-icons/fa"; interface CreateCommentProps { postId: number; @@ -12,6 +13,10 @@ const CreateComment: React.FC = ({ postId, setComments }) => const [selectedFile, setSelectedFile] = useState(null); const [commentContent, setCommentContent] = useState(''); + const [likeCount, setLikeCount] = useState(0); + const [dislikeCount, setDislikeCount] = useState(0); + const [liked, setLiked] = useState(false); + const [disliked, setDisliked] = useState(false); const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files.length > 0) { @@ -23,6 +28,34 @@ const CreateComment: React.FC = ({ postId, setComments }) => setCommentContent(event.target.value); } + const handleLike = () => { + if (!liked) { + setLikeCount(likeCount + 1); + setLiked(true); + if (disliked) { + setDislikeCount(dislikeCount - 1); + setDisliked(false); + } + } else { + setLikeCount(likeCount - 1); + setLiked(false); + } + } + + const handleDislike = () => { + if (!disliked) { + setDislikeCount(dislikeCount + 1); + setDisliked(true); + if (liked) { + setLikeCount(likeCount - 1); + setLiked(false); + } + } else { + setDislikeCount(dislikeCount - 1); + setDisliked(false); + } + } + const handleSubmit = async (event: React.FormEvent) => { // Create a new comment event.preventDefault(); @@ -125,12 +158,19 @@ const CreateComment: React.FC = ({ postId, setComments }) => Send message - {/* Like Button */} - + {/* Like and Dislike Buttons */} +
+ + +
); } -export default CreateComment; \ No newline at end of file +export default CreateComment; From 46db6f0745c3ebe5bc467e0cf43d5b35ba94c396 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 11 Apr 2024 05:25:34 +0300 Subject: [PATCH 3/3] Add likes/dislikes feature to posts & comments (kind of) --- backend/api/router.go | 2 + backend/pkg/db/database.db | Bin 98304 -> 98304 bytes backend/pkg/handler/voteHandler.go | 111 +++++++++++--- backend/pkg/model/structs.go | 1 + backend/pkg/repository/voteRepository.go | 38 ++++- frontend/src/components/comments/Comment.tsx | 138 +++++++++++++----- frontend/src/components/postcreation/Post.tsx | 1 + 7 files changed, 232 insertions(+), 59 deletions(-) diff --git a/backend/api/router.go b/backend/api/router.go index 7dece88..8336328 100644 --- a/backend/api/router.go +++ b/backend/api/router.go @@ -71,6 +71,8 @@ func Router(mux *mux.Router, db *sql.DB) { // Likes & dislikes for comments and posts ... the getPosts and getComments methods return the number of likes and dislikes with each post/comment mux.HandleFunc("/vote", voteHandler.VotePostOrCommentHandler).Methods("POST") + mux.HandleFunc("/vote", voteHandler.GetItemVotesHandler).Methods("GET") + //mux.HandleFunc("/user/vote", voteHandler.GetUserVoteActionHandler).Methods("GET") // Groups groupHandler := handler.NewGroupHandler(groupRepository, sessionRepository, groupMemberRepository, notificationHandler, userRepository, friendsRepository) diff --git a/backend/pkg/db/database.db b/backend/pkg/db/database.db index 2b645e9b8a75f16f07ef3d9736af3d904abdc94e..882eabf1405a02b37ed95ab7bcb971640c24a17d 100644 GIT binary patch delta 1807 zcma)+Piz!b9LMKRcXnoH=KWfr!9uM=MI&ix-kUf7(Ug{8>48L34^1@C+1=T)we8ey zh4kWL7CeLx;3rsvN%#`7uneDq0Xn==>*@X- zRrPoeFISDY&q^zcvC=K>=g-G0QK|TxCW+7Txp-kAT3jiqu)wKGbUt}kGAt-}E?g;T z)rI&fAAh~n)qOx!A8>FTLJ-g=XjuP2pViaa7upH+0Za6ws-ID1j^}$KN<|^8UVLw$ zdZ(fsBV=T5Y1YK1brf4i2{wom*j_-~U0gGngwR_jf@iM`?GBD|2U^1h~oWl3z zC>6qdX$KPcoeW9>;AGDBqcOj z7-N3dR+3}96ASLn_5;r_7QpIY zt22ZSWRj4*EF_=r{7&5grSZK(pJF*Eu5I3ViQgyd76)Ik6P89d(3rlZzpq=hXMXP2 z)hSuw6prtGN2b-i_Xg=3aXwr%E`%i`G-l&CZ!q&vqp+4kHkEGE*-xj#B$P57&+=VV zWGb?Z&sQtu{YP@~yNfH!tD~dsnd)aIOg)vNWzJ#OE{gt+9EoCkq@Snn_4H=+dHpat zp>67q&?Rj`y9(=2V51z;Y7>8dD0Fcgce(n@zhmXeVv0M>PZjK}WoO-pM3!m#o@ct) zq<)mmVKd5_o-Z_;AqK{QX$Gb{Vp=Yy6x;p~rUAy2aUB)1aANtkMTzH|gt&R)dDwOw z-?g*8n`=Xr?ZBo1B_obUESGqexy_oZvm(!nvYwRwg9*3-zrrA@pt~@S{sD0V)!4Qyqi-vSALd_uQo^~z z;%WX#9mX5o{9llsONl%$#2W_BA3p+=L+JiNn1*X`6E49YFbMnL7FQx`VGf zQjs8N$6Fj37;OUxUGNRJrwJ$vfX+#3F;OU79T5p>wjkXm6D9SnBu0K5BWWjha)@#w z5e1d~85meeD5A#hI5X;1iK52-2u&*uw>denU|bqYnr$>E$gsuf%|h622yC1^tr4Pj PY=k)YgM;=BI?DS4i> delta 1084 zcmb7?O=uHA7>0MUP1D3=zEmq}sW^xSp+C&b{$zJg*3#6XrC0Mlho2v za#yM~;6X%$iU&Puq~7%6O%S~mTlMBa@ZwGU!KvF;FH(hJ7?^qA?|HxZn$6B;v-4VS zOr2lsJ1X~w=kK2DOG8G5ckmgmzzb-?INXNJK)O@ikG!y47L|G}4rOIWQe0X;CsXPk*u(=_VO_5Q6Ztg1F1{k4|oZm z#d|Za+k0R2Wm?m#sRna&?l3-@N-9V}`zDiGDi&0WqT-j`>NIgZPt4SXPv*nfDv2&g zRa~4EwK}IFWIh7*y^SW;6rQv9|2dc4g94H+Tw;C8+<@;E6Y zBqEe)w^S1U!(up^IUL_&No>br{Ry+dVmQpocl>7Qh*;!H@%vkr67Rcr<4h@Y2#>(y z^jhXCz6>NimcA|rpg-Ln`*t&=hhhUQR8^HXtU0!`SI2Oc$zg3trYFm*0` z>IqX6gh`pOd2F*c+$2nQH124|kj9v4v3|;>kz9(Rj8C9Rp}W-3q@h^|Jm!gj=KR30 zOr5%#MGJm>!>TdJIFUjowYi}j%b8}*ip=u#7ZeMq?a7g2zRQ{I<_gTUbZ+>}5Bx&T z=ISQY`p7Yd+m2=r>9)c3+!ho~NvsrJP+$y}VHFnPAYQ;LP{QhW+?H*r;wK9j4WRsX r)#xF00&Ul)spTyC0tp&d5sFPM6NHY$C4iD`cax8>cqe;r24#N&>8l~e diff --git a/backend/pkg/handler/voteHandler.go b/backend/pkg/handler/voteHandler.go index 12640fc..3d94b5b 100644 --- a/backend/pkg/handler/voteHandler.go +++ b/backend/pkg/handler/voteHandler.go @@ -6,11 +6,13 @@ import ( "backend/util" "encoding/json" "fmt" + "log" "net/http" + "strconv" ) type VoteHandler struct { - voteRepo *repository.VoteRepository + voteRepo *repository.VoteRepository sessionRepo *repository.SessionRepository } @@ -45,22 +47,24 @@ func (h *VoteHandler) VotePostOrCommentHandler(w http.ResponseWriter, r *http.Re return } - err = h.voteRepo.VoteItem(voteData, userID); if err != nil { + err = h.voteRepo.VoteItem(voteData, userID) + if err != nil { errmsg := fmt.Sprintf("Failed to vote %s: %s", voteData.Item, err.Error()) http.Error(w, errmsg, http.StatusInternalServerError) } - - var likes, dislikes, getVoteError = h.voteRepo.GetItemVotes(voteData.Item, voteData.ItemID); if getVoteError != nil { - http.Error(w, "Failed to fetch updated votes: " + err.Error(), http.StatusInternalServerError) + + var likes, dislikes, getVoteError = h.voteRepo.GetItemVotes(voteData.Item, voteData.ItemID) + if getVoteError != nil { + http.Error(w, "Failed to fetch updated votes: "+err.Error(), http.StatusInternalServerError) return } // Create a response struct for the votes response := struct { - Likes int `json:"likes"` + Likes int `json:"likes"` Dislikes int `json:"dislikes"` }{ - Likes: likes, + Likes: likes, Dislikes: dislikes, } @@ -78,25 +82,90 @@ func (h *VoteHandler) VotePostOrCommentHandler(w http.ResponseWriter, r *http.Re } } +func (h *VoteHandler) GetItemVotesHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) + return + } + + // Extract item type and item ID from request parameters + itemType := r.URL.Query().Get("itemType") + itemIDStr := r.URL.Query().Get("itemID") + itemID, err := strconv.Atoi(itemIDStr) + if err != nil { + http.Error(w, "Invalid item ID", http.StatusBadRequest) + return + } + + // Retrieve the user ID from the session token + userID, err := h.sessionRepo.GetUserIDFromSessionToken(util.GetSessionToken(r)) + if err != nil { + http.Error(w, "User not authenticated", http.StatusUnauthorized) + return + } + + // Call the GetItemVotes function from the repository to get the total likes and dislikes + likes, dislikes, err := h.voteRepo.GetItemVotes(itemType, itemID) + if err != nil { + http.Error(w, "Failed to fetch item votes: "+err.Error(), http.StatusInternalServerError) + return + } + + // Get the user's vote action + userAction, err := h.voteRepo.GetUserVoteAction(userID, itemType, itemID) + if err != nil { + http.Error(w, "Failed to fetch user vote action", http.StatusInternalServerError) + return + } + + // Construct a response containing the retrieved vote information and user action + response := struct { + Likes int `json:"likes"` + Dislikes int `json:"dislikes"` + UserAction string `json:"userAction"` + }{ + Likes: likes, + Dislikes: dislikes, + UserAction: userAction, + } + + // Serialize the response to JSON + responseData, err := json.Marshal(response) + if err != nil { + http.Error(w, "Failed to marshal response data", http.StatusInternalServerError) + return + } + + // Set the content type header and write the response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err = w.Write(responseData) + if err != nil { + // Handle error writing response here + http.Error(w, "Failed to write response data", http.StatusInternalServerError) + return + } +} + func (h *VoteHandler) AppendVotesToPostsResponse(posts []model.Post) ([]model.PostsResponse, error) { postsResponse := make([]model.PostsResponse, len(posts)) for i, post := range posts { likes, dislikes, err := h.voteRepo.GetItemVotes("post", post.Id) if err != nil { - return nil, err + log.Fatal(err) } postsResponse[i] = model.PostsResponse{ - Id: post.Id, - UserID: post.UserID, - GroupID: post.GroupID, - Title: post.Title, - Content: post.Content, - ImageURL: post.ImageURL, - PrivacySetting: post.PrivacySetting, - CreatedAt: post.CreatedAt, - Likes: likes, - Dislikes: dislikes, - } + Id: post.Id, + UserID: post.UserID, + GroupID: post.GroupID, + Title: post.Title, + Content: post.Content, + ImageURL: post.ImageURL, + PrivacySetting: post.PrivacySetting, + CreatedAt: post.CreatedAt, + Likes: likes, + Dislikes: dislikes, + } } return postsResponse, nil } @@ -106,7 +175,7 @@ func (h *VoteHandler) AppendVotesToCommentsResponse(comments []model.Comment) ([ for i, comment := range comments { likes, dislikes, err := h.voteRepo.GetItemVotes("comment", comment.Id) if err != nil { - return nil, err + log.Fatal(err) } commentsResponse[i] = model.CommentsResponse{ Id: comment.Id, @@ -120,4 +189,4 @@ func (h *VoteHandler) AppendVotesToCommentsResponse(comments []model.Comment) ([ } } return commentsResponse, nil -} \ No newline at end of file +} diff --git a/backend/pkg/model/structs.go b/backend/pkg/model/structs.go index 98b2282..6acbc8e 100644 --- a/backend/pkg/model/structs.go +++ b/backend/pkg/model/structs.go @@ -261,4 +261,5 @@ type VoteData struct { Item string `json:"item"` // 'comment' or 'post' ItemID int `json:"item_id"` // comment or post id Action string `json:"action"` // 'like' or 'dislike' + UserId int `json:"user_id"` } diff --git a/backend/pkg/repository/voteRepository.go b/backend/pkg/repository/voteRepository.go index df3a01a..686240b 100644 --- a/backend/pkg/repository/voteRepository.go +++ b/backend/pkg/repository/voteRepository.go @@ -4,6 +4,7 @@ import ( "backend/pkg/model" "database/sql" "fmt" + "log" // Import the log package for logging ) type VoteRepository struct { @@ -38,16 +39,45 @@ func (r *VoteRepository) VoteItem(vote model.VoteData, userID int) error { return err } -func (r *VoteRepository) GetItemVotes(itemType string, itemID int) (int, int, error){ +func (r *VoteRepository) GetItemVotes(itemType string, itemID int) (int, int, error) { var likes, dislikes int query := fmt.Sprintf("SELECT COUNT(*) FROM votes WHERE %sID = ? AND type = ?", itemType) - err := r.db.QueryRow(query, itemID, "upvote").Scan(&likes) + err := r.db.QueryRow(query, itemID, "like").Scan(&likes) if err != nil { + log.Printf("Error retrieving upvotes for %s ID %d: %v\n", itemType, itemID, err) return 0, 0, err } - err = r.db.QueryRow(query, itemID, "downvote").Scan(&dislikes) + err = r.db.QueryRow(query, itemID, "dislike").Scan(&dislikes) if err != nil { + log.Printf("Error retrieving downvotes for %s ID %d: %v\n", itemType, itemID, err) return 0, 0, err } return likes, dislikes, nil -} \ No newline at end of file +} + +func (r *VoteRepository) GetUserVoteAction(userID int, itemType string, itemID int) (string, error) { + var action string + query := fmt.Sprintf("SELECT type FROM votes WHERE %sID = ? AND userID = ?", itemType) + err := r.db.QueryRow(query, itemID, userID).Scan(&action) + if err != nil { + if err == sql.ErrNoRows { + // If no vote action found for the user and item, return an empty string + return "", nil + } + log.Printf("Error retrieving user vote action for %s ID %d: %v\n", itemType, itemID, err) + return "", err + } + return action, nil +} + +/* func (r *VoteRepository) HasUserVoted(itemType string, itemID, userID int) bool { + // Check if the user has voted for the given item + var voteCount int + query := fmt.Sprintf("SELECT COUNT(*) FROM votes WHERE %sID = ? AND userID = ?", itemType) + err := r.db.QueryRow(query, itemID, userID).Scan(&voteCount) + if err != nil { + log.Printf("Error checking user vote for %s ID %d: %v\n", itemType, itemID, err) + return false // Assume no vote has been cast in case of an error + } + return voteCount > 0 // Return true if the user has voted (voteCount > 0), false otherwise +} */ diff --git a/frontend/src/components/comments/Comment.tsx b/frontend/src/components/comments/Comment.tsx index 4df29de..b6b13e4 100644 --- a/frontend/src/components/comments/Comment.tsx +++ b/frontend/src/components/comments/Comment.tsx @@ -1,6 +1,5 @@ -import { formatDate } from '@/hooks/utils'; -import { useState } from 'react'; -import { BiSolidLike, BiSolidDislike } from 'react-icons/bi'; // Import like and dislike icons +import { useEffect, useState } from 'react'; +import { BiSolidLike, BiSolidDislike } from 'react-icons/bi'; export interface CommentProps { id: number; @@ -15,61 +14,132 @@ export interface CommentProps { profile_image?: string; } -const Comment: React.FC = ({ - id, - postId, - userId, - content, - image, - created_at, - likes, - dislikes, - username, - profile_image -}) => { - const formattedCreatedAt = formatDate(created_at); + +const Comment: React.FC = ({ id, postId, userId, content, image, created_at, likes, dislikes, username, profile_image }) => { + const BE_PORT = process.env.NEXT_PUBLIC_BACKEND_PORT; + const FE_URL = process.env.NEXT_PUBLIC_FRONTEND_URL; + const formattedCreatedAt = created_at; const [likeCount, setLikeCount] = useState(likes); const [dislikeCount, setDislikeCount] = useState(dislikes); const [liked, setLiked] = useState(false); const [disliked, setDisliked] = useState(false); - const handleLike = () => { + useEffect(() => { + const fetchData = async () => { + try { + // Fetch initial counts + const response = await fetch(`${FE_URL}:${BE_PORT}/vote`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include' + }); + if (!response.ok) { + throw new Error('Failed to fetch initial counts'); + } + const { likes: initialLikes, dislikes: initialDislikes } = await response.json(); + setLikeCount(initialLikes); + setDislikeCount(initialDislikes); + + // Fetch user's vote action + const userVoteResponse = await fetch(`${FE_URL}:${BE_PORT}/vote?id=${id}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include' + }); + if (!userVoteResponse.ok) { + throw new Error('Failed to fetch user vote'); + } + const { action } = await userVoteResponse.json(); + if (action === 'like') { + setLiked(true); + } else if (action === 'dislike') { + setDisliked(true); + } + } catch (error) { + console.error('Error fetching data:', error); + } + }; + + fetchData(); + }, [id]); + + + const handleLike = async () => { if (!liked) { - setLikeCount(likeCount + 1); - setLiked(true); - if (disliked) { - setDisliked(false); - setDislikeCount(dislikeCount - 1); + try { + const response = await fetch(`${FE_URL}:${BE_PORT}/vote`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + item: 'comment', + item_id: id, + action: 'like', + }), + credentials: 'include' + }); + if (!response.ok) { + throw new Error('Failed to vote'); + } + setLikeCount(likeCount + 1); // Update like count after successful vote + setLiked(true); + if (disliked) { + setDisliked(false); + setDislikeCount(dislikeCount - 1); + } + } catch (error) { + console.error('Error voting:', error); } } else { setLikeCount(likeCount - 1); setLiked(false); } - // TODO: Implement logic for sending like to the server }; - - const handleDislike = () => { + + const handleDislike = async () => { if (!disliked) { - setDislikeCount(dislikeCount + 1); - setDisliked(true); - if (liked) { - setLiked(false); - setLikeCount(likeCount - 1); + try { + const response = await fetch(`${FE_URL}:${BE_PORT}/vote`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + item: 'comment', + item_id: id, + action: 'dislike', + }), + credentials: 'include' + }); + if (!response.ok) { + throw new Error('Failed to vote'); + } + setDislikeCount(dislikeCount + 1); // Update dislike count after successful vote + setDisliked(true); + if (liked) { + setLiked(false); + setLikeCount(likeCount - 1); + } + } catch (error) { + console.error('Error voting:', error); } } else { setDislikeCount(dislikeCount - 1); setDisliked(false); } - // TODO: Implement logic for sending dislike to the server }; + return (
- {/* Comments inside the CommentsBox.tsx collapsing box*/}
- {/* TODO: Link Profile picture to comment */} - Tailwind CSS chat bubble component + Profile
diff --git a/frontend/src/components/postcreation/Post.tsx b/frontend/src/components/postcreation/Post.tsx index fba83ae..273a12c 100644 --- a/frontend/src/components/postcreation/Post.tsx +++ b/frontend/src/components/postcreation/Post.tsx @@ -23,6 +23,7 @@ export interface PostProps { } const Post: React.FC = ({ id, userId, groupId, title, content, image_url, privacySetting, created_at, likes, dislikes, creator, creator_avatar, comments, setComments }) => { + return (
{/* Post Content */}