diff --git a/backend/data/follows.py b/backend/data/follows.py index a4b6314..28f11b1 100644 --- a/backend/data/follows.py +++ b/backend/data/follows.py @@ -19,6 +19,14 @@ def follow(follower: User, followee: User): except UniqueViolation: # Already following - treat as idempotent request. pass + +# do the opposite for unfollow +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) + ) def get_followed_usernames(follower: User) -> List[str]: diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..24d8aa1 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,25 @@ def do_follow(): } ) +@jwt_required() +def do_unfollow(username): + + current_user = get_current_user() + + unfollow_user = get_user(username) + if unfollow_user is None: + return make_response( + (f"Cannot unfollow {username} - user does not exist", 404) + ) + + unfollow(current_user, unfollow_user) + + return jsonify( + { + "success": True, + } + ) + @jwt_required() def send_bloom(): diff --git a/backend/main.py b/backend/main.py index 7ba155f..3e4211d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ from data.users import lookup_user from endpoints import ( do_follow, + do_unfollow, get_bloom, hashtag, home_timeline, @@ -54,6 +55,8 @@ 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) diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index ec4f200..9bc74c0 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,64 +6,86 @@ import {apiService} from "../index.mjs"; * @param {Object} profileData - The profile data to display * @returns {DocumentFragment} - The profile UI */ -function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { - if (!template || !profileData) return; - const profileElement = document - .getElementById(template) - .content.cloneNode(true); +function createProfile(template, { profileData, whoToFollow, isLoggedIn }) { + if (!template || !profileData) return; + const profileElement = document + .getElementById(template) + .content.cloneNode(true); - const usernameEl = profileElement.querySelector("[data-username]"); - const bloomCountEl = profileElement.querySelector("[data-bloom-count]"); - 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 - 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; - followButtonEl.setAttribute("data-username", profileData.username || ""); - followButtonEl.hidden = profileData.is_self || profileData.is_following; - followButtonEl.addEventListener("click", handleFollow); - if (!isLoggedIn) { - followButtonEl.style.display = "none"; - } + const usernameEl = profileElement.querySelector("[data-username]"); + const bloomCountEl = profileElement.querySelector("[data-bloom-count]"); + const followingCountEl = profileElement.querySelector( + "[data-following-count]" + ); + const followerCountEl = profileElement.querySelector("[data-follower-count]"); + const followButtonEl = profileElement.querySelector("[data-action='follow']"); + const unfollowButtonEl = profileElement.querySelector( + "[data-action='unfollow']" + ); + const whoToFollowContainer = profileElement.querySelector( + ".profile__who-to-follow" + ); + // Populate with 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; + followButtonEl.setAttribute("data-username", profileData.username || ""); + followButtonEl.hidden = profileData.is_self || profileData.is_following; + followButtonEl.addEventListener("click", handleFollow); + //adding unfollow functionalities + 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 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]"); - usernameLink.innerText = userToFollow.username; - usernameLink.setAttribute("href", `/profile/${userToFollow.username}`); - const followButton = wtfElement.querySelector("button"); - followButton.setAttribute("data-username", userToFollow.username); - followButton.addEventListener("click", handleFollow); - if (!isLoggedIn) { - followButton.style.display = "none"; - } + if (whoToFollow.length > 0) { + 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]"); + usernameLink.innerText = userToFollow.username; + usernameLink.setAttribute("href", `/profile/${userToFollow.username}`); + const followButton = wtfElement.querySelector("button"); - whoToFollowList.appendChild(wtfElement); - } - } else { - whoToFollowContainer.innerText = ""; - } + followButton.setAttribute("data-username", userToFollow.username); + followButton.addEventListener("click", handleFollow); + if (!isLoggedIn) { + followButton.style.display = "none"; + unfollowButtonEl.style.display = "none"; + } - return profileElement; + whoToFollowList.appendChild(wtfElement); + } + } else { + whoToFollowContainer.innerText = ""; + } + + return profileElement; } async function handleFollow(event) { - const button = event.target; - const username = button.getAttribute("data-username"); - if (!username) return; + const button = event.target; + const username = button.getAttribute("data-username"); + if (!username) return; + + await apiService.followUser(username); + await apiService.getWhoToFollow(); +} + +async function handleUnfollow(event) { + const button = event.target; + const username = button.getAttribute("data-username"); + if (!username) return; - await apiService.followUser(username); - await apiService.getWhoToFollow(); + await apiService.unfollowUser(username); + await apiService.getWhoToFollow(); } -export {createProfile, handleFollow}; +export { createProfile, handleFollow, handleUnfollow }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..ea511fb 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -1,261 +1,260 @@ - - - - Purple Forest - - - -
-

- Purple Forest - PurpleForest -

-
- -
+ + + + Purple Forest + + + +
+

+ Purple Forest + PurpleForest +

+
+ +
-
-
- -
-
-
-
-
-

This Legacy Code project is coursework from Code Your Future

-
-
- - - - + + + - - + + - + - - + + diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..7c5ab90 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 @@ -14,290 +14,290 @@ import {handleErrorDialog} from "../components/error.mjs"; // Helper function for making API requests async function _apiRequest(endpoint, options = {}) { - const token = state.token; - const baseUrl = "http://localhost:3000"; - - const defaultOptions = { - headers: { - "Content-Type": "application/json", - ...(token ? {Authorization: `Bearer ${token}`} : {}), - }, - mode: "cors", - credentials: "include", - }; - - const fetchOptions = {...defaultOptions, ...options}; - const url = endpoint.startsWith("http") ? endpoint : `${baseUrl}${endpoint}`; - - try { - const response = await fetch(url, fetchOptions); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - const error = new Error( - errorData.message || `API error: ${response.status}` - ); - error.status = response.status; - - // Handle auth errors - if (error.status === 401 || error.status === 403) { - if (!endpoint.includes("/login") && !endpoint.includes("/register")) { - state.destroyState(); - } - } - - // Pass all errors forward to a dialog on the screen - handleErrorDialog(error); - throw error; - } - - const contentType = response.headers.get("content-type"); - return contentType?.includes("application/json") - ? await response.json() - : {success: true}; - } catch (error) { - if (!error.status) { - // Only handle network errors here, response errors are handled above - handleErrorDialog(error); - } - throw error; // Re-throw so it can be caught by the calling function - } + const token = state.token; + const baseUrl = "http://localhost:3000"; + + const defaultOptions = { + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + mode: "cors", + credentials: "include", + }; + + const fetchOptions = { ...defaultOptions, ...options }; + const url = endpoint.startsWith("http") ? endpoint : `${baseUrl}${endpoint}`; + + try { + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const error = new Error( + errorData.message || `API error: ${response.status}` + ); + error.status = response.status; + + // Handle auth errors + if (error.status === 401 || error.status === 403) { + if (!endpoint.includes("/login") && !endpoint.includes("/register")) { + state.destroyState(); + } + } + + // Pass all errors forward to a dialog on the screen + handleErrorDialog(error); + throw error; + } + + const contentType = response.headers.get("content-type"); + return contentType?.includes("application/json") + ? await response.json() + : { success: true }; + } catch (error) { + if (!error.status) { + // Only handle network errors here, response errors are handled above + handleErrorDialog(error); + } + throw error; // Re-throw so it can be caught by the calling function + } } // Local helper to update a profile in the profiles array function _updateProfile(username, profileData) { - const profiles = [...state.profiles]; - const index = profiles.findIndex((p) => p.username === username); - - if (index !== -1) { - profiles[index] = {...profiles[index], ...profileData}; - } else { - profiles.push({username, ...profileData}); - } - state.updateState({profiles}); + const profiles = [...state.profiles]; + const index = profiles.findIndex((p) => p.username === username); + + if (index !== -1) { + profiles[index] = { ...profiles[index], ...profileData }; + } else { + profiles.push({ username, ...profileData }); + } + state.updateState({ profiles }); } // ====== AUTH methods async function login(username, password) { - try { - const data = await _apiRequest("/login", { - method: "POST", - body: JSON.stringify({username, password}), - }); - - if (data.success && data.token) { - state.updateState({ - token: data.token, - currentUser: username, - isLoggedIn: true, - }); - await Promise.all([getBlooms(), getProfile(username), getWhoToFollow()]); - } - - return data; - } catch (error) { - return {success: false}; - } + try { + const data = await _apiRequest("/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + + if (data.success && data.token) { + state.updateState({ + token: data.token, + currentUser: username, + isLoggedIn: true, + }); + await Promise.all([getBlooms(), getProfile(username), getWhoToFollow()]); + } + + return data; + } catch (error) { + return { success: false }; + } } async function getWhoToFollow() { - try { - const usernamesToFollow = await _apiRequest("/suggested-follows/3"); + 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: []}); - return []; - } + return usernamesToFollow; + } catch (error) { + // Error already handled by _apiRequest + state.updateState({ usernamesToFollow: [] }); + return []; + } } async function signup(username, password) { - try { - const data = await _apiRequest("/register", { - method: "POST", - body: JSON.stringify({username, password}), - }); - - if (data.success && data.token) { - state.updateState({ - token: data.token, - currentUser: username, - isLoggedIn: true, - }); - await getProfile(username); - } - - return data; - } catch (error) { - return {success: false}; - } + try { + const data = await _apiRequest("/register", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + + if (data.success && data.token) { + state.updateState({ + token: data.token, + currentUser: username, + isLoggedIn: true, + }); + await getProfile(username); + } + + return data; + } catch (error) { + return { success: false }; + } } function logout() { - state.destroyState(); - return {success: true}; + state.destroyState(); + return { success: true }; } // ===== BLOOM methods async function getBloom(bloomId) { - const endpoint = `/bloom/${bloomId}`; - const bloom = await _apiRequest(endpoint); - state.updateState({singleBloomToShow: bloom}); - return bloom; + const endpoint = `/bloom/${bloomId}`; + const bloom = await _apiRequest(endpoint); + state.updateState({ singleBloomToShow: bloom }); + return bloom; } async function getBlooms(username) { - const endpoint = username ? `/blooms/${username}` : "/home"; - - try { - const blooms = await _apiRequest(endpoint); - - if (username) { - _updateProfile(username, {blooms}); - } else { - state.updateState({timelineBlooms: blooms}); - } - - return blooms; - } catch (error) { - // Error already handled by _apiRequest - if (username) { - _updateProfile(username, {blooms: []}); - } else { - state.updateState({timelineBlooms: []}); - } - return []; - } + const endpoint = username ? `/blooms/${username}` : "/home"; + + try { + const blooms = await _apiRequest(endpoint); + + if (username) { + _updateProfile(username, { blooms }); + } else { + state.updateState({ timelineBlooms: blooms }); + } + + return blooms; + } catch (error) { + // Error already handled by _apiRequest + if (username) { + _updateProfile(username, { blooms: [] }); + } else { + state.updateState({ timelineBlooms: [] }); + } + return []; + } } /** * Fetches blooms containing a specific hashtag */ async function getBloomsByHashtag(hashtag) { - const tag = hashtag.startsWith("#") ? hashtag.substring(1) : hashtag; - const endpoint = `/hashtag/${encodeURIComponent(tag)}`; - - try { - const blooms = await _apiRequest(endpoint); - state.updateState({ - hashtagBlooms: blooms, - currentHashtag: `#${tag}`, - }); - return blooms; - } catch (error) { - // Error already handled by _apiRequest - return {success: false}; - } + const tag = hashtag.startsWith("#") ? hashtag.substring(1) : hashtag; + const endpoint = `/hashtag/${encodeURIComponent(tag)}`; + + try { + const blooms = await _apiRequest(endpoint); + state.updateState({ + hashtagBlooms: blooms, + currentHashtag: `#${tag}`, + }); + return blooms; + } catch (error) { + // Error already handled by _apiRequest + return { success: false }; + } } async function postBloom(content) { - try { - const data = await _apiRequest("/bloom", { - method: "POST", - body: JSON.stringify({content}), - }); - - if (data.success) { - await getBlooms(); - await getProfile(state.currentUser); - } - - return data; - } catch (error) { - // Error already handled by _apiRequest - return {success: false}; - } + try { + const data = await _apiRequest("/bloom", { + method: "POST", + body: JSON.stringify({ content }), + }); + + if (data.success) { + await getBlooms(); + await getProfile(state.currentUser); + } + + return data; + } catch (error) { + // Error already handled by _apiRequest + return { success: false }; + } } // ======= USER methods async function getProfile(username) { - const endpoint = username ? `/profile/${username}` : "/profile"; - - try { - const profileData = await _apiRequest(endpoint); - - if (username) { - _updateProfile(username, profileData); - } else { - const currentUsername = profileData.username; - const fullProfileData = await _apiRequest(`/profile/${currentUsername}`); - _updateProfile(currentUsername, fullProfileData); - state.updateState({currentUser: currentUsername, isLoggedIn: true}); - } - - return profileData; - } catch (error) { - // Error already handled by _apiRequest - if (!username) { - state.updateState({isLoggedIn: false, currentUser: null}); - } - return {success: false}; - } + const endpoint = username ? `/profile/${username}` : "/profile"; + + try { + const profileData = await _apiRequest(endpoint); + + if (username) { + _updateProfile(username, profileData); + } else { + const currentUsername = profileData.username; + const fullProfileData = await _apiRequest(`/profile/${currentUsername}`); + _updateProfile(currentUsername, fullProfileData); + state.updateState({ currentUser: currentUsername, isLoggedIn: true }); + } + + return profileData; + } catch (error) { + // Error already handled by _apiRequest + if (!username) { + state.updateState({ isLoggedIn: false, currentUser: null }); + } + return { success: false }; + } } async function followUser(username) { - try { - const data = await _apiRequest("/follow", { - method: "POST", - body: JSON.stringify({follow_username: username}), - }); - - if (data.success) { - await Promise.all([ - getProfile(username), - getProfile(state.currentUser), - getBlooms(), - ]); - } - - return data; - } catch (error) { - return {success: false}; - } + try { + const data = await _apiRequest("/follow", { + method: "POST", + body: JSON.stringify({ follow_username: username }), + }); + + if (data.success) { + await Promise.all([ + getProfile(username), + getProfile(state.currentUser), + getBlooms(), + ]); + } + + return data; + } catch (error) { + return { success: false }; + } } async function unfollowUser(username) { - try { - const data = await _apiRequest(`/unfollow/${username}`, { - method: "POST", - }); - - if (data.success) { - // Update both the unfollowed user's profile and the current user's profile - await Promise.all([ - getProfile(username), - getProfile(state.currentUser), - getBlooms(), - ]); - } - - return data; - } catch (error) { - // Error already handled by _apiRequest - return {success: false}; - } + try { + const data = await _apiRequest(`/unfollow/${username}`, { + method: "POST", + }); + + if (data.success) { + // Update both the unfollowed user's profile and the current user's profile + await Promise.all([ + getProfile(username), + getProfile(state.currentUser), + getBlooms(), + ]); + } + + return data; + } catch (error) { + // Error already handled by _apiRequest + return { success: false }; + } } const apiService = { - // Auth methods - login, - signup, - logout, - - // Bloom methods - getBloom, - getBlooms, - postBloom, - getBloomsByHashtag, - - // User methods - getProfile, - followUser, - unfollowUser, - getWhoToFollow, + // Auth methods + login, + signup, + logout, + + // Bloom methods + getBloom, + getBlooms, + postBloom, + getBloomsByHashtag, + + // User methods + getProfile, + followUser, + unfollowUser, + getWhoToFollow, }; -export {apiService}; +export { apiService };