diff --git a/backend/data/follows.py b/backend/data/follows.py index a4b6314..cbc26e8 100644 --- a/backend/data/follows.py +++ b/backend/data/follows.py @@ -1,7 +1,7 @@ from typing import List from data.connection import db_cursor -from data.users import User +from data.users import User, get_user from psycopg2.errors import UniqueViolation @@ -20,6 +20,24 @@ def follow(follower: User, followee: User): # Already following - treat as idempotent request. pass +def unfollow(*, follower, follow_username: str): + follow_user = get_user(follow_username) + if follow_user is None: + return + + with db_cursor() as cur: + cur.execute( + """ + DELETE FROM follows + WHERE follower = %(follower_id)s + AND followee = %(followee_id)s + """, + { + "follower_id": follower.id, + "followee_id": follow_user.id, + }, + ) + def get_followed_usernames(follower: User) -> List[str]: """get_followed_usernames returns a list of usernames followee follows.""" diff --git a/backend/data/users.py b/backend/data/users.py index 00746f1..f379da8 100644 --- a/backend/data/users.py +++ b/backend/data/users.py @@ -47,6 +47,18 @@ def get_user(username: str) -> Optional[User]: ) +def unfollow(*, follower: User, follow_username: str): + with db_cursor() as cur: + cur.execute( + """ + DELETE FROM follows + WHERE follower = %(follower)s + AND followee = (SELECT id FROM users WHERE username = %(username)s) + """, + {"follower": follower.id, "username": follow_username}, + ) + + def get_suggested_follows(following_user: User, limit: int) -> List[str]: with db_cursor() as cur: cur.execute( diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..996ebfc 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -1,6 +1,6 @@ from typing import Dict, Union from data import blooms -from data.follows import follow, get_followed_usernames, get_inverse_followed_usernames +from data.follows import follow, unfollow, get_followed_usernames, get_inverse_followed_usernames from data.users import ( UserRegistrationError, get_suggested_follows, @@ -149,7 +149,14 @@ def do_follow(): } ) +@jwt_required() +def do_unfollow(username): + current_user = get_current_user() + + unfollow(follower=current_user, follow_username=username) + return jsonify({"success": True}) + @jwt_required() def send_bloom(): type_check_error = verify_request_fields({"content": str}) diff --git a/backend/main.py b/backend/main.py index 7ba155f..71303cb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ send_bloom, suggested_follows, user_blooms, + do_unfollow, ) from dotenv import load_dotenv @@ -55,6 +56,7 @@ def main(): app.add_url_rule("/profile/", view_func=other_profile) app.add_url_rule("/follow", methods=["POST"], view_func=do_follow) app.add_url_rule("/suggested-follows/", view_func=suggested_follows) + app.add_url_rule("/unfollow/", "unfollow", do_unfollow, methods=["POST"]) app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom) app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index ec4f200..f150720 100644 --- a/front-end/components/profile.mjs +++ b/front-end/components/profile.mjs @@ -1,4 +1,4 @@ -import {apiService} from "../index.mjs"; +import { apiService } from "../index.mjs"; /** * Create a profile component @@ -6,8 +6,9 @@ import {apiService} from "../index.mjs"; * @param {Object} profileData - The profile data to display * @returns {DocumentFragment} - The profile UI */ -function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { +function createProfile(template, { profileData, whoToFollow, isLoggedIn }) { if (!template || !profileData) return; + const profileElement = document .getElementById(template) .content.cloneNode(true); @@ -17,33 +18,76 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { const followingCountEl = profileElement.querySelector( "[data-following-count]" ); - const followerCountEl = profileElement.querySelector("[data-follower-count]"); - const followButtonEl = profileElement.querySelector("[data-action='follow']"); - const whoToFollowContainer = profileElement.querySelector(".profile__who-to-follow"); - // Populate with data + const followerCountEl = profileElement.querySelector( + "[data-follower-count]" + ); + const followButtonEl = profileElement.querySelector( + "[data-action='follow']" + ); + const whoToFollowContainer = profileElement.querySelector( + ".profile__who-to-follow" + ); + + // ===== PROFILE DATA ===== usernameEl.querySelector("h2").textContent = profileData.username || ""; usernameEl.setAttribute("href", `/profile/${profileData.username}`); bloomCountEl.textContent = profileData.total_blooms || 0; followerCountEl.textContent = profileData.followers?.length || 0; followingCountEl.textContent = profileData.follows?.length || 0; + + // ===== FOLLOW BUTTON ===== followButtonEl.setAttribute("data-username", profileData.username || ""); - followButtonEl.hidden = profileData.is_self || profileData.is_following; - followButtonEl.addEventListener("click", handleFollow); - if (!isLoggedIn) { + + if (!isLoggedIn || profileData.is_self) { followButtonEl.style.display = "none"; + } else { + followButtonEl.style.display = "inline-block"; + + if (profileData.is_following) { + followButtonEl.textContent = "Unfollow"; + followButtonEl.addEventListener("click", handleUnfollow); + } else { + followButtonEl.textContent = "Follow"; + followButtonEl.addEventListener("click", handleFollow); + } } + // ===== WHO TO FOLLOW ===== if (whoToFollow.length > 0) { - const whoToFollowList = whoToFollowContainer.querySelector("[data-who-to-follow]"); - const whoToFollowTemplate = document.querySelector("#who-to-follow-chip"); + const whoToFollowList = whoToFollowContainer.querySelector( + "[data-who-to-follow]" + ); + const whoToFollowTemplate = document.querySelector( + "#who-to-follow-chip" + ); + for (const userToFollow of whoToFollow) { - const wtfElement = whoToFollowTemplate.content.cloneNode(true); - const usernameLink = wtfElement.querySelector("a[data-username]"); + const wtfElement = + whoToFollowTemplate.content.cloneNode(true); + + const usernameLink = + wtfElement.querySelector("a[data-username]"); usernameLink.innerText = userToFollow.username; - usernameLink.setAttribute("href", `/profile/${userToFollow.username}`); + usernameLink.setAttribute( + "href", + `/profile/${userToFollow.username}` + ); + const followButton = wtfElement.querySelector("button"); - followButton.setAttribute("data-username", userToFollow.username); - followButton.addEventListener("click", handleFollow); + followButton.setAttribute( + "data-username", + userToFollow.username + ); + + // Correct follow/unfollow logic + if (userToFollow.is_following) { + followButton.textContent = "Unfollow"; + followButton.addEventListener("click", handleUnfollow); + } else { + followButton.textContent = "Follow"; + followButton.addEventListener("click", handleFollow); + } + if (!isLoggedIn) { followButton.style.display = "none"; } @@ -57,6 +101,8 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { return profileElement; } +// ===== HANDLERS ===== + async function handleFollow(event) { const button = event.target; const username = button.getAttribute("data-username"); @@ -66,4 +112,13 @@ async function handleFollow(event) { await apiService.getWhoToFollow(); } -export {createProfile, handleFollow}; +async function handleUnfollow(event) { + const button = event.target; + const username = button.getAttribute("data-username"); + if (!username) return; + + await apiService.unfollowUser(username); + await apiService.getWhoToFollow(); +} + +export { createProfile, handleFollow, handleUnfollow }; \ No newline at end of file