From 82f7527ab4d03dd9a62f6e29a094e8fefb85eee6 Mon Sep 17 00:00:00 2001 From: Nataliia Volkova Date: Wed, 4 Feb 2026 12:45:13 +0000 Subject: [PATCH 1/2] added unfollow logic --- backend/data/follows.py | 10 +++++++ backend/endpoints.py | 14 ++++++++- backend/main.py | 3 ++ front-end/index.html | 23 +++++++-------- front-end/lib/api.mjs | 62 ++++++++++++++++++++-------------------- front-end/lib/router.mjs | 14 ++++----- 6 files changed, 75 insertions(+), 51 deletions(-) diff --git a/backend/data/follows.py b/backend/data/follows.py index a4b6314..912c841 100644 --- a/backend/data/follows.py +++ b/backend/data/follows.py @@ -41,3 +41,13 @@ def get_inverse_followed_usernames(followee: User) -> List[str]: ) rows = cur.fetchall() return [row[0] for row in rows] + +def unfollow(follower: User, followee: User): + with db_cursor() as cur: + cur.execute( + "DELETE FROM follows WHERE follower = %(follower_id)s AND followee = %(followee_id)s", + dict( + follower_id=follower.id, + followee_id=followee.id, + ), + ) diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..3c7baf2 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,6 +149,18 @@ def do_follow(): } ) +@jwt_required() +def do_unfollow(username): + current_user = get_current_user() + + unfollowed_user = get_user(username) + if unfollowed_user is None: + return make_response((f"Cannot unfollow {username} - user does not exist", 404)) + + unfollow(current_user, unfollowed_user) + return jsonify({"success": True}) + + @jwt_required() def send_bloom(): diff --git a/backend/main.py b/backend/main.py index 7ba155f..76bbf86 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,6 +3,7 @@ from custom_json_provider import CustomJsonProvider from data.users import lookup_user from endpoints import ( + do_unfollow, do_follow, get_bloom, hashtag, @@ -54,7 +55,9 @@ def main(): app.add_url_rule("/profile", view_func=self_profile) app.add_url_rule("/profile/", view_func=other_profile) app.add_url_rule("/follow", methods=["POST"], view_func=do_follow) + app.add_url_rule("/unfollow/", methods=["POST"], view_func=do_unfollow) app.add_url_rule("/suggested-follows/", view_func=suggested_follows) + 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/index.html b/front-end/index.html index 89d6b13..ddb9dcf 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -1,21 +1,16 @@ - + Purple Forest - +

- Purple Forest + Purple Forest PurpleForest

@@ -187,10 +182,12 @@

Create your account

+
+ +

Who to follow

-
    -
+
    @@ -236,7 +233,9 @@

    Share a Bloom

    @@ -256,6 +255,6 @@

    Share a Bloom

    Please enable JavaScript in your browser.

    - + diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..1802bd6 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -1,5 +1,5 @@ -import {state} from "../index.mjs"; -import {handleErrorDialog} from "../components/error.mjs"; +import { state } from "../index.mjs"; +import { handleErrorDialog } from "../components/error.mjs"; // === ABOUT THE STATE // state gives you these two functions only @@ -20,13 +20,13 @@ async function _apiRequest(endpoint, options = {}) { const defaultOptions = { headers: { "Content-Type": "application/json", - ...(token ? {Authorization: `Bearer ${token}`} : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, mode: "cors", credentials: "include", }; - const fetchOptions = {...defaultOptions, ...options}; + const fetchOptions = { ...defaultOptions, ...options }; const url = endpoint.startsWith("http") ? endpoint : `${baseUrl}${endpoint}`; try { @@ -35,7 +35,7 @@ async function _apiRequest(endpoint, options = {}) { if (!response.ok) { const errorData = await response.json().catch(() => ({})); const error = new Error( - errorData.message || `API error: ${response.status}` + errorData.message || `API error: ${response.status}`, ); error.status = response.status; @@ -54,7 +54,7 @@ async function _apiRequest(endpoint, options = {}) { const contentType = response.headers.get("content-type"); return contentType?.includes("application/json") ? await response.json() - : {success: true}; + : { success: true }; } catch (error) { if (!error.status) { // Only handle network errors here, response errors are handled above @@ -70,11 +70,11 @@ function _updateProfile(username, profileData) { const index = profiles.findIndex((p) => p.username === username); if (index !== -1) { - profiles[index] = {...profiles[index], ...profileData}; + profiles[index] = { ...profiles[index], ...profileData }; } else { - profiles.push({username, ...profileData}); + profiles.push({ username, ...profileData }); } - state.updateState({profiles}); + state.updateState({ profiles }); } // ====== AUTH methods @@ -82,7 +82,7 @@ async function login(username, password) { try { const data = await _apiRequest("/login", { method: "POST", - body: JSON.stringify({username, password}), + body: JSON.stringify({ username, password }), }); if (data.success && data.token) { @@ -96,7 +96,7 @@ async function login(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -104,12 +104,12 @@ async function getWhoToFollow() { try { const usernamesToFollow = await _apiRequest("/suggested-follows/3"); - state.updateState({whoToFollow: usernamesToFollow}); + state.updateState({ whoToFollow: usernamesToFollow }); return usernamesToFollow; } catch (error) { // Error already handled by _apiRequest - state.updateState({usernamesToFollow: []}); + state.updateState({ usernamesToFollow: [] }); return []; } } @@ -118,7 +118,7 @@ async function signup(username, password) { try { const data = await _apiRequest("/register", { method: "POST", - body: JSON.stringify({username, password}), + body: JSON.stringify({ username, password }), }); if (data.success && data.token) { @@ -132,20 +132,20 @@ async function signup(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } function logout() { state.destroyState(); - return {success: true}; + return { success: true }; } // ===== BLOOM methods async function getBloom(bloomId) { const endpoint = `/bloom/${bloomId}`; const bloom = await _apiRequest(endpoint); - state.updateState({singleBloomToShow: bloom}); + state.updateState({ singleBloomToShow: bloom }); return bloom; } @@ -156,18 +156,18 @@ async function getBlooms(username) { const blooms = await _apiRequest(endpoint); if (username) { - _updateProfile(username, {blooms}); + _updateProfile(username, { blooms }); } else { - state.updateState({timelineBlooms: blooms}); + state.updateState({ timelineBlooms: blooms }); } return blooms; } catch (error) { // Error already handled by _apiRequest if (username) { - _updateProfile(username, {blooms: []}); + _updateProfile(username, { blooms: [] }); } else { - state.updateState({timelineBlooms: []}); + state.updateState({ timelineBlooms: [] }); } return []; } @@ -189,7 +189,7 @@ async function getBloomsByHashtag(hashtag) { return blooms; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -197,7 +197,7 @@ async function postBloom(content) { try { const data = await _apiRequest("/bloom", { method: "POST", - body: JSON.stringify({content}), + body: JSON.stringify({ content }), }); if (data.success) { @@ -208,7 +208,7 @@ async function postBloom(content) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -225,16 +225,16 @@ async function getProfile(username) { const currentUsername = profileData.username; const fullProfileData = await _apiRequest(`/profile/${currentUsername}`); _updateProfile(currentUsername, fullProfileData); - state.updateState({currentUser: currentUsername, isLoggedIn: true}); + state.updateState({ currentUser: currentUsername, isLoggedIn: true }); } return profileData; } catch (error) { // Error already handled by _apiRequest if (!username) { - state.updateState({isLoggedIn: false, currentUser: null}); + state.updateState({ isLoggedIn: false, currentUser: null }); } - return {success: false}; + return { success: false }; } } @@ -242,7 +242,7 @@ async function followUser(username) { try { const data = await _apiRequest("/follow", { method: "POST", - body: JSON.stringify({follow_username: username}), + body: JSON.stringify({ follow_username: username }), }); if (data.success) { @@ -255,7 +255,7 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -277,7 +277,7 @@ async function unfollowUser(username) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -300,4 +300,4 @@ const apiService = { getWhoToFollow, }; -export {apiService}; +export { apiService }; diff --git a/front-end/lib/router.mjs b/front-end/lib/router.mjs index d520552..820888a 100644 --- a/front-end/lib/router.mjs +++ b/front-end/lib/router.mjs @@ -1,9 +1,9 @@ -import {bloomView} from "../views/bloom.mjs"; -import {profileView} from "../views/profile.mjs"; -import {signupView} from "../views/signup.mjs"; -import {loginView} from "../views/login.mjs"; -import {homeView} from "../views/home.mjs"; -import {hashtagView} from "../views/hashtag.mjs"; +import { bloomView } from "../views/bloom.mjs"; +import { profileView } from "../views/profile.mjs"; +import { signupView } from "../views/signup.mjs"; +import { loginView } from "../views/login.mjs"; +import { homeView } from "../views/home.mjs"; +import { hashtagView } from "../views/hashtag.mjs"; /** * Handle route changes based on the current URL @@ -71,4 +71,4 @@ document.addEventListener("click", (event) => { } }); -export {handleRouteChange, navigateTo}; +export { handleRouteChange, navigateTo }; From c84e5b5018d6ff155aba431a73ef277e4e2ab5ad Mon Sep 17 00:00:00 2001 From: Nataliia Volkova Date: Wed, 4 Feb 2026 12:47:33 +0000 Subject: [PATCH 2/2] added unfollow logic --- front-end/components/profile.mjs | 38 +++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index ec4f200..4e36841 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,7 +6,7 @@ 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) @@ -15,11 +15,18 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { const usernameEl = profileElement.querySelector("[data-username]"); const bloomCountEl = profileElement.querySelector("[data-bloom-count]"); const followingCountEl = profileElement.querySelector( - "[data-following-count]" + "[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"); + const unfollowButtonEl = profileElement.querySelector( + "[data-action='unfollow']", + ); + // console.log("follow btn", followButtonEl); + // console.log("unfollow btn", unfollowButtonEl); + const whoToFollowContainer = profileElement.querySelector( + ".profile__who-to-follow", + ); // Populate with data usernameEl.querySelector("h2").textContent = profileData.username || ""; usernameEl.setAttribute("href", `/profile/${profileData.username}`); @@ -32,9 +39,17 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { if (!isLoggedIn) { followButtonEl.style.display = "none"; } + unfollowButtonEl.setAttribute("data-username", profileData.username || ""); + unfollowButtonEl.hidden = profileData.is_self || !profileData.is_following; + unfollowButtonEl.addEventListener("click", handleUnfollow); + if (!isLoggedIn) { + followButtonEl.style.display = "none"; + } if (whoToFollow.length > 0) { - const whoToFollowList = whoToFollowContainer.querySelector("[data-who-to-follow]"); + 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); @@ -63,7 +78,18 @@ async function handleFollow(event) { if (!username) return; await apiService.followUser(username); + // await apiService.getProfile(username); + await apiService.getWhoToFollow(); +} + +async function handleUnfollow(event) { + const button = event.target; + const username = button.getAttribute("data-username"); + if (!username) return; + + await apiService.unfollowUser(username); + // await apiService.getProfile(username); await apiService.getWhoToFollow(); } -export {createProfile, handleFollow}; +export { createProfile, handleFollow, handleUnfollow };