From fcb7ffbb89c2b3175477686b767ba8320bea83b2 Mon Sep 17 00:00:00 2001 From: eediallo Date: Tue, 27 May 2025 09:26:58 +0100 Subject: [PATCH 01/17] Fix paths and clean up HTML structure in index.html --- front-end/index.html | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..1d78ca6 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -4,18 +4,13 @@ Purple Forest - +

- Purple Forest + Purple Forest PurpleForest

@@ -189,8 +184,7 @@

Create your account

Who to follow

-
    -
+
    @@ -236,7 +230,9 @@

    Share a Bloom

    @@ -256,6 +252,6 @@

    Share a Bloom

    Please enable JavaScript in your browser.

    - + From 98f46d7d9ec6657910f9b33d5a7ab7f46a9131d3 Mon Sep 17 00:00:00 2001 From: eediallo Date: Tue, 27 May 2025 10:01:54 +0100 Subject: [PATCH 02/17] Fix relative paths for CSS and logo in index.html --- front-end/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front-end/index.html b/front-end/index.html index 1d78ca6..d734b1b 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -4,13 +4,13 @@ Purple Forest - +

    - Purple Forest + Purple Forest PurpleForest

    From 56f33ca87be4233440f77cdfe31ec9e837138b59 Mon Sep 17 00:00:00 2001 From: eediallo Date: Sun, 1 Jun 2025 21:24:44 +0100 Subject: [PATCH 03/17] Add test for unfollowing a user --- front-end/tests/profile.spec.mjs | 49 ++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/front-end/tests/profile.spec.mjs b/front-end/tests/profile.spec.mjs index a90f69d..705b217 100644 --- a/front-end/tests/profile.spec.mjs +++ b/front-end/tests/profile.spec.mjs @@ -1,8 +1,14 @@ -import {test, expect} from "@playwright/test"; -import {TIMELINE_USERNAMES_ELEMENTS_LOCATOR, loginAsSample, loginAsJustSomeGuy, waitForLocatorToHaveMatches} from "./test-utils.mjs"; +import { test, expect } from "@playwright/test"; +import { + TIMELINE_USERNAMES_ELEMENTS_LOCATOR, + loginAsSample, + loginAsJustSomeGuy, + waitForLocatorToHaveMatches, + signUp +} from "./test-utils.mjs"; test.describe("Profile View", () => { - test("shows own profile when logged in", async ({page}) => { + test("shows own profile when logged in", async ({ page }) => { // Given a profile view // When I am logged in as sample await loginAsSample(page); @@ -16,8 +22,13 @@ test.describe("Profile View", () => { page.locator("#profile-container header a[data-username]") ).toBeVisible(); - await waitForLocatorToHaveMatches(page, TIMELINE_USERNAMES_ELEMENTS_LOCATOR); - const postUsernames = new Set(await page.locator(TIMELINE_USERNAMES_ELEMENTS_LOCATOR).allInnerTexts()); + await waitForLocatorToHaveMatches( + page, + TIMELINE_USERNAMES_ELEMENTS_LOCATOR + ); + const postUsernames = new Set( + await page.locator(TIMELINE_USERNAMES_ELEMENTS_LOCATOR).allInnerTexts() + ); expect(postUsernames).toEqual(new Set(["sample"])); // And bloom form is not attached @@ -26,7 +37,7 @@ test.describe("Profile View", () => { ).not.toBeAttached(); }); - test("shows other user's profile with follow button", async ({page}) => { + test("shows other user's profile with follow button", async ({ page }) => { // Given I am logged in as AS await loginAsJustSomeGuy(page); // When I go to sample's profile @@ -43,4 +54,30 @@ test.describe("Profile View", () => { // And bloom form is not attached await expect(page.locator("#bloom-form-container form")).not.toBeAttached(); }); + + test("allows unfollowing a user from their profile", async ({ page }) => { + await signUp(page, "sample"); + await signUp(page, "AnotherUser"); + + // Given a profile component AnotherUser + // And I am logged in as sample + await loginAsSample(page); + await page.goto("/#/profile/AnotherUser"); + // And sample is following AS + await page.click('[data-action="follow"]'); + + // When I view the profile component for AnotherUser + // Then I should see a button labeled "Unfollow" + const unfollowButton = page.locator('[data-action="unfollow"]'); + await expect(unfollowButton).toBeVisible(); + + // When I click the "Unfollow" button + await unfollowButton.click(); + + // Then I should no longer be following AnotherUser + const followerCount = page.locator("[data-follower-count]"); + await expect(followerCount).toHaveText("0"); + // And the unfollow button is not visible + await expect(unfollowButton).toBe("hidden"); + }); }); From 5e23a3ad2eab295059fa930cab915d185d258ac3 Mon Sep 17 00:00:00 2001 From: eediallo Date: Sun, 1 Jun 2025 21:25:36 +0100 Subject: [PATCH 04/17] Add unfollow button to profile actions --- front-end/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/front-end/index.html b/front-end/index.html index d734b1b..16bade8 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -181,6 +181,7 @@

    Create your account

    +

    Who to follow

    From d0367b0cc7535f23b433fe10ab4f3230fdce73c2 Mon Sep 17 00:00:00 2001 From: eediallo Date: Sun, 1 Jun 2025 21:48:19 +0100 Subject: [PATCH 05/17] feat: add handleUnfollow handler function --- front-end/components/profile.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index ec4f200..46d1e8a 100644 --- a/front-end/components/profile.mjs +++ b/front-end/components/profile.mjs @@ -66,4 +66,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}; From 2c23aaf4b051ca7be1aaedf409b0bf31239ade67 Mon Sep 17 00:00:00 2001 From: eediallo Date: Sun, 1 Jun 2025 22:18:09 +0100 Subject: [PATCH 06/17] fix: correct unfollow button label in profile actions --- front-end/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front-end/index.html b/front-end/index.html index 16bade8..7737a52 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -181,7 +181,7 @@

    Create your account

    - +

    Who to follow

    From a434f7b43e4defc46e404749179f1036d602cf2f Mon Sep 17 00:00:00 2001 From: eediallo Date: Sun, 1 Jun 2025 22:18:33 +0100 Subject: [PATCH 07/17] create unfollow button --- front-end/components/profile.mjs | 35 +++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index 46d1e8a..35fc798 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) @@ -18,34 +18,59 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { "[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']" + ); + + 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 || ""); + unfollowButtonEl.setAttribute("data-username", profileData.username || ""); + followButtonEl.hidden = profileData.is_self || profileData.is_following; + unfollowButtonEl.hidden = profileData.is_self || profileData.is_following; + followButtonEl.addEventListener("click", handleFollow); + unfollowButtonEl.addEventListener("click", handleUnfollow); + if (!isLoggedIn) { followButtonEl.style.display = "none"; + unfollowButtonEl.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); const usernameLink = wtfElement.querySelector("a[data-username]"); usernameLink.innerText = userToFollow.username; usernameLink.setAttribute("href", `/profile/${userToFollow.username}`); + const followButton = wtfElement.querySelector("button"); + const unfollowButton = wtfElement.querySelector("button"); + followButton.setAttribute("data-username", userToFollow.username); + unfollowButton.setAttribute("data-username", userToFollow.username); + followButton.addEventListener("click", handleFollow); + unfollowButton.addEventListener("click", handleUnfollow); + if (!isLoggedIn) { followButton.style.display = "none"; + unfollowButton.style.display = "none"; } whoToFollowList.appendChild(wtfElement); @@ -75,4 +100,4 @@ async function handleUnfollow(event) { await apiService.getWhoToFollow(); } -export {createProfile, handleFollow, handleUnfollow}; +export { createProfile, handleFollow, handleUnfollow }; From b4d33496d26bf8cb537bf4be0cb1550f435181da Mon Sep 17 00:00:00 2001 From: eediallo Date: Sun, 1 Jun 2025 22:26:47 +0100 Subject: [PATCH 08/17] handle follow and unfollow events --- front-end/views/profile.mjs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/front-end/views/profile.mjs b/front-end/views/profile.mjs index dd2b92a..0a784ac 100644 --- a/front-end/views/profile.mjs +++ b/front-end/views/profile.mjs @@ -1,4 +1,4 @@ -import {renderEach, renderOne, destroy} from "../lib/render.mjs"; +import { renderEach, renderOne, destroy } from "../lib/render.mjs"; import { apiService, state, @@ -7,10 +7,14 @@ import { getProfileContainer, getTimelineContainer, } from "../index.mjs"; -import {createLogin, handleLogin} from "../components/login.mjs"; -import {createLogout, handleLogout} from "../components/logout.mjs"; -import {createProfile, handleFollow} from "../components/profile.mjs"; -import {createBloom} from "../components/bloom.mjs"; +import { createLogin, handleLogin } from "../components/login.mjs"; +import { createLogout, handleLogout } from "../components/logout.mjs"; +import { + createProfile, + handleFollow, + handleUnfollow, +} from "../components/profile.mjs"; +import { createBloom } from "../components/bloom.mjs"; // Profile view - just this person's blooms and their profile function profileView(username) { @@ -54,6 +58,12 @@ function profileView(username) { "profile-template", createProfile ); + document + .querySelector("[data-action='follow']") + ?.addEventListener("click", handleFollow); + document + .querySelector("[data-action='unfollow']") + ?.addEventListener("click", handleUnfollow); renderEach( profileData.recent_blooms || [], getTimelineContainer(), @@ -63,4 +73,4 @@ function profileView(username) { } } -export {profileView}; +export { profileView }; From 2c255a36d91f82ecdaccd2723794cc8d3edbbb05 Mon Sep 17 00:00:00 2001 From: eediallo Date: Sun, 1 Jun 2025 22:57:26 +0100 Subject: [PATCH 09/17] feat: add unfollow button to profile actions --- front-end/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/front-end/index.html b/front-end/index.html index 7737a52..728ac9c 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -194,6 +194,7 @@

    Who to follow

    +
    From 63b9babb5dc642ccbdc4fd1308a3e132fe5afd90 Mon Sep 17 00:00:00 2001 From: eediallo Date: Sun, 1 Jun 2025 22:57:39 +0100 Subject: [PATCH 10/17] feat: update package.json to include http-server as a dependency and add start script --- front-end/package-lock.json | 602 +++++++++++++++++++++++++++++++++++- front-end/package.json | 8 +- 2 files changed, 607 insertions(+), 3 deletions(-) diff --git a/front-end/package-lock.json b/front-end/package-lock.json index c1a321c..d6bcd0c 100644 --- a/front-end/package-lock.json +++ b/front-end/package-lock.json @@ -8,8 +8,12 @@ "name": "front-end", "version": "1.0.0", "license": "ISC", + "dependencies": { + "http-server": "^14.1.1" + }, "devDependencies": { - "@playwright/test": "^1.40.0" + "@playwright/test": "^1.41.2", + "@types/node": "^20.11.24" } }, "node_modules/@playwright/test": { @@ -28,6 +32,208 @@ "node": ">=18" } }, + "node_modules/@types/node": { + "version": "20.17.57", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz", + "integrity": "sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -43,6 +249,228 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/playwright": { "version": "1.51.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.0.tgz", @@ -74,6 +502,178 @@ "engines": { "node": ">=18" } + }, + "node_modules/portfinder": { + "version": "1.0.37", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.37.tgz", + "integrity": "sha512-yuGIEjDAYnnOex9ddMnKZEMFE0CcGo6zbfzDklkmT1m5z734ss6JMzN9rNB3+RR7iS+F10D4/BVIaXOyh8PQKw==", + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } } } } diff --git a/front-end/package.json b/front-end/package.json index 9607ebe..d8a285f 100644 --- a/front-end/package.json +++ b/front-end/package.json @@ -5,7 +5,8 @@ "main": "index.mjs", "type": "module", "scripts": { - "test": "npx playwright test --ui" + "test": "npx playwright test --ui", + "start": "http-server" }, "devDependencies": { "@playwright/test": "^1.41.2", @@ -13,5 +14,8 @@ }, "keywords": [], "author": "", - "license": "ISC" + "license": "ISC", + "dependencies": { + "http-server": "^14.1.1" + } } From 830ad68b7ac0ae652c0928a029eaccaafd306b19 Mon Sep 17 00:00:00 2001 From: eediallo Date: Sun, 1 Jun 2025 22:57:57 +0100 Subject: [PATCH 11/17] fix: correct unfollow button visibility logic and update follow/unfollow button interactions --- front-end/components/profile.mjs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index 35fc798..6914cbf 100644 --- a/front-end/components/profile.mjs +++ b/front-end/components/profile.mjs @@ -38,7 +38,7 @@ function createProfile(template, { profileData, whoToFollow, isLoggedIn }) { unfollowButtonEl.setAttribute("data-username", profileData.username || ""); followButtonEl.hidden = profileData.is_self || profileData.is_following; - unfollowButtonEl.hidden = profileData.is_self || profileData.is_following; + unfollowButtonEl.hidden = profileData.is_self || !profileData.is_following; followButtonEl.addEventListener("click", handleFollow); unfollowButtonEl.addEventListener("click", handleUnfollow); @@ -59,12 +59,17 @@ function createProfile(template, { profileData, whoToFollow, isLoggedIn }) { usernameLink.innerText = userToFollow.username; usernameLink.setAttribute("href", `/profile/${userToFollow.username}`); - const followButton = wtfElement.querySelector("button"); - const unfollowButton = wtfElement.querySelector("button"); + const followButton = wtfElement.querySelector("[data-action='follow']"); + const unfollowButton = wtfElement.querySelector( + "[data-action='unfollow']" + ); followButton.setAttribute("data-username", userToFollow.username); unfollowButton.setAttribute("data-username", userToFollow.username); + followButton.hidden = userToFollow.is_following; + unfollowButton.hidden = !userToFollow.is_following; + followButton.addEventListener("click", handleFollow); unfollowButton.addEventListener("click", handleUnfollow); @@ -88,6 +93,14 @@ async function handleFollow(event) { if (!username) return; await apiService.followUser(username); + // Hide follow button and show unfollow button + button.hidden = true; + const unfollowButton = button.parentElement.querySelector( + "[data-action='unfollow']" + ); + if (unfollowButton) { + unfollowButton.hidden = false; + } await apiService.getWhoToFollow(); } @@ -97,6 +110,14 @@ async function handleUnfollow(event) { if (!username) return; await apiService.unfollowUser(username); + // Hide unfollow button and show follow button + button.hidden = true; + const followButton = button.parentElement.querySelector( + "[data-action='follow']" + ); + if (followButton) { + followButton.hidden = false; + } await apiService.getWhoToFollow(); } From 49516361a030214426dce4794f5277d8a9912e05 Mon Sep 17 00:00:00 2001 From: eediallo Date: Mon, 2 Jun 2025 11:49:11 +0100 Subject: [PATCH 12/17] test: enhance unfollow user functionality in profile tests --- front-end/tests/profile.spec.mjs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/front-end/tests/profile.spec.mjs b/front-end/tests/profile.spec.mjs index 705b217..95b2496 100644 --- a/front-end/tests/profile.spec.mjs +++ b/front-end/tests/profile.spec.mjs @@ -4,7 +4,7 @@ import { loginAsSample, loginAsJustSomeGuy, waitForLocatorToHaveMatches, - signUp + signUp, } from "./test-utils.mjs"; test.describe("Profile View", () => { @@ -56,28 +56,27 @@ test.describe("Profile View", () => { }); test("allows unfollowing a user from their profile", async ({ page }) => { - await signUp(page, "sample"); - await signUp(page, "AnotherUser"); - - // Given a profile component AnotherUser + // Given a profile component AS // And I am logged in as sample await loginAsSample(page); - await page.goto("/#/profile/AnotherUser"); - // And sample is following AS - await page.click('[data-action="follow"]'); + await page.goto("/#/profile/AS"); - // When I view the profile component for AnotherUser - // Then I should see a button labeled "Unfollow" - const unfollowButton = page.locator('[data-action="unfollow"]'); + // And sample is following AS, Then I should see the 'unfollow button' + const unfollowButton = page.locator( + '#profile-container .profile__actions [data-action="unfollow"][data-username="AS"]' + ); await expect(unfollowButton).toBeVisible(); // When I click the "Unfollow" button await unfollowButton.click(); - // Then I should no longer be following AnotherUser - const followerCount = page.locator("[data-follower-count]"); - await expect(followerCount).toHaveText("0"); - // And the unfollow button is not visible - await expect(unfollowButton).toBe("hidden"); + //Then I should no longer be following AS, and the 'Unfollow' button is not visible + await expect(unfollowButton).toBeHidden(); + + // And a "Follow" button should be visible + const followButton = page.locator( + '#profile-container .profile__actions [data-action="follow"][data-username="AS"]' + ); + await expect(followButton).toBeVisible(); }); }); From fb9cb6d32ee138f707800c6fb5dcba2b66231d18 Mon Sep 17 00:00:00 2001 From: eediallo Date: Mon, 2 Jun 2025 12:38:11 +0100 Subject: [PATCH 13/17] feat: implement unfollow functionality in do_unfollow endpoint --- backend/endpoints.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..12f59f2 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, @@ -150,6 +150,28 @@ def do_follow(): ) +@jwt_required() +def do_unfollow(): + type_check_error = verify_request_fields({"unfollow_username": str}) + if type_check_error is not None: + return type_check_error + + current_user = get_current_user() + + unfollow_username = request.json["unfollow_username"] + unfollow_user = get_user(unfollow_username) + if unfollow_user is None: + return make_response( + (f"Cannot unfollow {unfollow_username} - user does not exist", 404) + ) + + unfollow(current_user, unfollow_user) + return jsonify( + { + "success": True, + } + ) + @jwt_required() def send_bloom(): type_check_error = verify_request_fields({"content": str}) From a67433d5bb2ee88fad578715ba244ee3c713e51e Mon Sep 17 00:00:00 2001 From: eediallo Date: Mon, 2 Jun 2025 12:38:22 +0100 Subject: [PATCH 14/17] feat: add unfollow endpoint to main application --- backend/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/main.py b/backend/main.py index 7ba155f..a437812 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,7 @@ 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) From 9632480ef3d6db00953b5f72ad13262a1182f5a3 Mon Sep 17 00:00:00 2001 From: eediallo Date: Mon, 2 Jun 2025 12:38:34 +0100 Subject: [PATCH 15/17] feat: implement unfollow functionality in follows.py --- backend/data/follows.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/data/follows.py b/backend/data/follows.py index a4b6314..32939d2 100644 --- a/backend/data/follows.py +++ b/backend/data/follows.py @@ -20,6 +20,16 @@ def follow(follower: User, followee: User): # Already following - treat as idempotent request. pass +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]: """get_followed_usernames returns a list of usernames followee follows.""" From 5f0a92115f76e5f0cd528b271b9b658f44ea8ed6 Mon Sep 17 00:00:00 2001 From: eediallo Date: Mon, 2 Jun 2025 12:38:44 +0100 Subject: [PATCH 16/17] fix: correct unfollow button logic to properly show/hide follow button --- front-end/components/profile.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index 6914cbf..394c96b 100644 --- a/front-end/components/profile.mjs +++ b/front-end/components/profile.mjs @@ -112,11 +112,11 @@ async function handleUnfollow(event) { await apiService.unfollowUser(username); // Hide unfollow button and show follow button button.hidden = true; - const followButton = button.parentElement.querySelector( - "[data-action='follow']" + const unfollowButton = button.parentElement.querySelector( + "[data-action='unfollow']" ); - if (followButton) { - followButton.hidden = false; + if (unfollowButton) { + unfollowButton.hidden = false; } await apiService.getWhoToFollow(); } From b1d4c581dee8d10ea8787c828de8de0c58dfd376 Mon Sep 17 00:00:00 2001 From: eediallo Date: Mon, 2 Jun 2025 12:39:03 +0100 Subject: [PATCH 17/17] style: format code for consistency and readability --- front-end/lib/api.mjs | 63 ++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..8be2d4b 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 { @@ -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,14 +255,15 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } async function unfollowUser(username) { try { - const data = await _apiRequest(`/unfollow/${username}`, { + const data = await _apiRequest(`/unfollow`, { method: "POST", + body: JSON.stringify({ unfollow_username: username }), }); if (data.success) { @@ -277,7 +278,7 @@ async function unfollowUser(username) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -300,4 +301,4 @@ const apiService = { getWhoToFollow, }; -export {apiService}; +export { apiService };