From bdbb87d6c039e83b0705fe809defc3cc5d1e375e Mon Sep 17 00:00:00 2001 From: eyuell21 Date: Fri, 26 Dec 2025 16:08:58 +0000 Subject: [PATCH 1/8] Implement unfollow functionality and update profile component for dynamic follow/unfollow actions --- backend/endpoints.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..391c25a 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, get_followed_usernames, get_inverse_followed_usernames, unfollow from data.users import ( UserRegistrationError, get_suggested_follows, @@ -150,6 +150,23 @@ def do_follow(): ) +@jwt_required() +def do_unfollow(unfollow_username): + current_user = get_current_user() + 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 8c91cb3869a0da72c6202f13ee0467d1745a3bc7 Mon Sep 17 00:00:00 2001 From: eyuell21 Date: Fri, 26 Dec 2025 16:09:34 +0000 Subject: [PATCH 2/8] Implement unfollow functionality to remove follow relationships between users --- backend/data/follows.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/data/follows.py b/backend/data/follows.py index a4b6314..03a5cc7 100644 --- a/backend/data/follows.py +++ b/backend/data/follows.py @@ -21,6 +21,15 @@ def follow(follower: User, followee: User): pass +def unfollow(follower: User, followee: User): + """Remove a follow relationship between two users.""" + with db_cursor() as cur: + cur.execute( + "DELETE FROM follows WHERE follower = %s AND followee = %s", + (follower.id, followee.id), + ) + + def get_followed_usernames(follower: User) -> List[str]: """get_followed_usernames returns a list of usernames followee follows.""" with db_cursor() as cur: From 0019ce4e289eed78b9a3a0f10f7ee2dcfca38467 Mon Sep 17 00:00:00 2001 From: eyuell21 Date: Fri, 26 Dec 2025 16:09:40 +0000 Subject: [PATCH 3/8] Add unfollow endpoint to allow users to remove follow relationships --- backend/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/main.py b/backend/main.py index 7ba155f..e585963 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,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) From 136d529af2fb80b3454a83b0e942ca5f08080104 Mon Sep 17 00:00:00 2001 From: eyuell21 Date: Fri, 26 Dec 2025 16:09:49 +0000 Subject: [PATCH 4/8] Refactor follow/unfollow button logic in profile component for improved user experience --- front-end/components/profile.mjs | 37 +++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index ec4f200..56fb933 100644 --- a/front-end/components/profile.mjs +++ b/front-end/components/profile.mjs @@ -27,8 +27,17 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { 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 (profileData.is_following) { + followButtonEl.textContent = "Unfollow"; + followButtonEl.addEventListener("click", handleUnfollow); + } else { + followButtonEl.textContent = "Follow"; + followButtonEl.addEventListener("click", handleFollow); + } + + if (!isLoggedIn) { followButtonEl.style.display = "none"; } @@ -43,7 +52,18 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { usernameLink.setAttribute("href", `/profile/${userToFollow.username}`); const followButton = wtfElement.querySelector("button"); followButton.setAttribute("data-username", userToFollow.username); - followButton.addEventListener("click", handleFollow); + + + 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"; } @@ -67,3 +87,14 @@ async function handleFollow(event) { } 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 dbcbdfd2d821f3603b7f2932fb4b05331bce23f6 Mon Sep 17 00:00:00 2001 From: eyuell21 Date: Fri, 26 Dec 2025 17:13:09 +0000 Subject: [PATCH 5/8] Add rebloom functionality with endpoints to update and send reblooms --- backend/data/blooms.py | 62 ++++++++++++++++++++++++++++++++++++------ backend/endpoints.py | 31 +++++++++++++++++++++ backend/main.py | 3 ++ 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..788833a 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -13,21 +13,28 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + reblooms: int + original_bloom_id: int -def add_bloom(*, sender: User, content: str) -> Bloom: +def add_bloom( + *, sender: User, content: str, original_bloom_id: Optional[int] = None +) -> Bloom: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] now = datetime.datetime.now(tz=datetime.UTC) bloom_id = int(now.timestamp() * 1000000) + print(original_bloom_id) with db_cursor() as cur: cur.execute( - "INSERT INTO blooms (id, sender_id, content, send_timestamp) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s)", + "INSERT INTO blooms (id, sender_id, content, send_timestamp, reblooms, original_bloom_id) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(reblooms)s,%(original_bloom_id)s)", dict( bloom_id=bloom_id, sender_id=sender.id, content=content, timestamp=datetime.datetime.now(datetime.UTC), + reblooms=0, + original_bloom_id=original_bloom_id, ), ) for hashtag in hashtags: @@ -54,7 +61,7 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, reblooms, original_bloom_id FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE @@ -68,13 +75,22 @@ def get_blooms_for_user( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row + ( + bloom_id, + sender_username, + content, + timestamp, + reblooms, + original_bloom_id, + ) = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, + original_bloom_id=original_bloom_id, ) ) return blooms @@ -83,18 +99,20 @@ def get_blooms_for_user( def get_bloom(bloom_id: int) -> Optional[Bloom]: with db_cursor() as cur: cur.execute( - "SELECT blooms.id, users.username, content, send_timestamp FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s", + "SELECT blooms.id, users.username, content, send_timestamp, reblooms, original_bloom_id FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s", (bloom_id,), ) row = cur.fetchone() if row is None: return None - bloom_id, sender_username, content, timestamp = row + bloom_id, sender_username, content, timestamp, reblooms, original_bloom_id = row return Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, + original_bloom_id=original_bloom_id, ) @@ -108,7 +126,7 @@ def get_blooms_with_hashtag( with db_cursor() as cur: cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, reblooms, original_bloom_id FROM blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id WHERE @@ -121,22 +139,48 @@ def get_blooms_with_hashtag( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row + ( + bloom_id, + sender_username, + content, + timestamp, + reblooms, + original_bloom_id, + ) = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, + original_bloom_id=original_bloom_id, ) ) return blooms +def update_rebloom_counter(bloom_id: int) -> None: + with db_cursor() as cur: + cur.execute( + "UPDATE blooms SET reblooms = reblooms + 1 WHERE blooms.id = %s", + (bloom_id,), + ) + + +def add_rebloom(*, sender: User, id: int) -> None: + original_bloom = get_bloom(id) + if not original_bloom: + return None + content = original_bloom.content + update_rebloom_counter(id) + add_bloom(sender=sender, content=content, original_bloom_id=id) + + def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: if limit is not None: limit_clause = "LIMIT %(limit)s" kwargs["limit"] = limit else: limit_clause = "" - return limit_clause + return limit_clause \ No newline at end of file diff --git a/backend/endpoints.py b/backend/endpoints.py index 391c25a..5a27fb8 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -245,6 +245,37 @@ def suggested_follows(limit_str): return jsonify(suggestions) +def update_rebloom_counter(bloom_id): + try: + id_int = int(bloom_id) + except ValueError: + return make_response((f"Invalid bloom id", 400)) + blooms.update_rebloom_counter(id_int) + return jsonify( + { + "success": True, + } + ) + + +@jwt_required() +def send_rebloom(): + user = get_current_user() + bloom_id = request.json["id"] + try: + id_int = int(bloom_id) + except ValueError: + return make_response((f"Invalid bloom id", 400)) + blooms.add_rebloom(sender=user, id=id_int) + + return jsonify( + { + "success": True, + } + ) + + + def hashtag(hashtag): return jsonify(blooms.get_blooms_with_hashtag(hashtag)) diff --git a/backend/main.py b/backend/main.py index e585963..3abf495 100644 --- a/backend/main.py +++ b/backend/main.py @@ -15,6 +15,8 @@ send_bloom, suggested_follows, user_blooms, + update_rebloom_counter, + send_rebloom, ) from dotenv import load_dotenv @@ -64,6 +66,7 @@ def main(): app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) + app.add_url_rule("/rebloom", methods=["POST"], view_func=send_rebloom) app.run(host="0.0.0.0", port="3000", debug=True) From 409a8ca26b42eea9c19aebbe1af6b69c769a33af Mon Sep 17 00:00:00 2001 From: eyuell21 Date: Fri, 26 Dec 2025 17:13:26 +0000 Subject: [PATCH 6/8] Add rebloom functionality with UI updates and API integration --- front-end/components/bloom.mjs | 48 ++++++++++++++--- front-end/lib/api.mjs | 97 +++++++++++++++++++++------------- 2 files changed, 100 insertions(+), 45 deletions(-) diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c..d1c0def 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,3 +1,5 @@ +import { apiService } from "../index.mjs"; + /** * Create a bloom component * @param {string} template - The ID of the template to clone @@ -7,7 +9,9 @@ * {"id": Number, * "sender": username, * "content": "string from textarea", - * "sent_timestamp": "datetime as ISO 8601 formatted string"} + * "sent_timestamp": "datetime as ISO 8601 formatted string"}, + * "reblooms": "reblooms count", + * "original_bloom_id": "id of the rebloomed post" */ const createBloom = (template, bloom) => { @@ -20,8 +24,12 @@ const createBloom = (template, bloom) => { const bloomTime = bloomFrag.querySelector("[data-time]"); const bloomTimeLink = bloomFrag.querySelector("a:has(> [data-time])"); const bloomContent = bloomFrag.querySelector("[data-content]"); + const rebloomButtonEl = bloomFrag.querySelector( + "[data-action='share-bloom']" + ); + const rebloomCountEl = bloomFrag.querySelector("[data-rebloom-count]"); + const rebloomInfoEl = bloomFrag.querySelector("[data-rebloom-info]"); - bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); bloomUsername.textContent = bloom.sender; bloomTime.textContent = _formatTimestamp(bloom.sent_timestamp); @@ -31,6 +39,23 @@ const createBloom = (template, bloom) => { .body.childNodes ); + rebloomCountEl.textContent = `Rebloomed ${bloom.reblooms} times`; + rebloomCountEl.hidden = bloom.reblooms == 0; + rebloomButtonEl.setAttribute("data-id", bloom.id || ""); + rebloomButtonEl.addEventListener("click", handleRebloom); + rebloomInfoEl.hidden = bloom.original_bloom_id === null; + + if (bloom.original_bloom_id !== null) { + apiService + + .fetchBloomData(bloom.original_bloom_id) + .then((originalBloom) => { + const timeStamp = _formatTimestamp(originalBloom.sent_timestamp); + + rebloomInfoEl.innerHTML = `↪ Rebloom of the ${originalBloom.sender}'s post, posted ${timeStamp} ago`; + }); + } + return bloomFrag; }; @@ -55,25 +80,22 @@ function _formatTimestamp(timestamp) { return `${diffSeconds}s`; } - // Less than an hour const diffMinutes = Math.floor(diffSeconds / 60); if (diffMinutes < 60) { return `${diffMinutes}m`; } - // Less than a day + const diffHours = Math.floor(diffMinutes / 60); if (diffHours < 24) { return `${diffHours}h`; } - // Less than a week const diffDays = Math.floor(diffHours / 24); if (diffDays < 7) { return `${diffDays}d`; } - // Format as month and day for older dates return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", @@ -84,4 +106,16 @@ function _formatTimestamp(timestamp) { } } -export {createBloom}; +async function handleRebloom(event) { + const button = event.target; + const id = button.getAttribute("data-id"); + if (!id) return; + + await apiService.postRebloom(id); +} + +export { createBloom, handleRebloom }; + + + + diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..d8a2eef 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 { @@ -46,7 +46,7 @@ async function _apiRequest(endpoint, options = {}) { } } - // Pass all errors forward to a dialog on the screen + handleErrorDialog(error); throw error; } @@ -54,35 +54,34 @@ 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 + handleErrorDialog(error); } - throw error; // Re-throw so it can be caught by the calling function + throw error; } } -// 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}; + profiles[index] = { ...profiles[index], ...profileData }; } else { - profiles.push({username, ...profileData}); + profiles.push({ username, ...profileData }); } - state.updateState({profiles}); + state.updateState({ profiles }); } -// ====== AUTH methods 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 +95,7 @@ async function login(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -104,12 +103,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 +117,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 +131,27 @@ 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; +} + + +async function fetchBloomData(bloomId) { + const endpoint = `/bloom/${bloomId}`; + const bloom = await _apiRequest(endpoint); return bloom; } @@ -156,18 +162,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 +195,7 @@ async function getBloomsByHashtag(hashtag) { return blooms; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -197,7 +203,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,10 +214,23 @@ async function postBloom(content) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } +async function postRebloom(originalId) { + try { + const data = await _apiRequest(`/rebloom`, { + method: "POST", + body: JSON.stringify({ id: originalId }), + }); + if (data.success) { + await getBlooms(); + await getProfile(state.currentUser); + } + } catch (error) {} +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -225,16 +244,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 +261,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 +274,7 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -277,7 +296,7 @@ async function unfollowUser(username) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -289,9 +308,11 @@ const apiService = { // Bloom methods getBloom, + fetchBloomData, getBlooms, postBloom, getBloomsByHashtag, + postRebloom, // User methods getProfile, @@ -300,4 +321,4 @@ const apiService = { getWhoToFollow, }; -export {apiService}; +export { apiService }; \ No newline at end of file From 181346d409b8c65ae769b2bf9a41c52d01ab0058 Mon Sep 17 00:00:00 2001 From: eyuell21 Date: Fri, 26 Dec 2025 17:13:33 +0000 Subject: [PATCH 7/8] Add reblooms and original bloom reference to blooms table --- db/schema.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/db/schema.sql b/db/schema.sql index 61e7580..4b743fb 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -10,7 +10,9 @@ CREATE TABLE blooms ( id BIGSERIAL NOT NULL PRIMARY KEY, sender_id INT NOT NULL REFERENCES users(id), content TEXT NOT NULL, - send_timestamp TIMESTAMP NOT NULL + send_timestamp TIMESTAMP NOT NULL, + reblooms INT NOT NULL DEFAULT 0, + original_bloom_id BIGINT REFERENCES blooms(id) ); CREATE TABLE follows ( From c7a56f086e8653e5d75aaba7d2b9c4d7d11194ec Mon Sep 17 00:00:00 2001 From: eyuell21 Date: Fri, 26 Dec 2025 17:13:43 +0000 Subject: [PATCH 8/8] Refactor HTML structure for improved readability and maintainability --- front-end/index.html | 425 +++++++++++++++++++------------------------ 1 file changed, 184 insertions(+), 241 deletions(-) diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..8134566 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -1,261 +1,204 @@ - - - - Purple Forest - - - -
-

- Purple Forest - PurpleForest -

-
- -
-
-
- -
-
-
-
-
-

This Legacy Code project is coursework from Code Your Future

-
-
- - - - - - - - + + + + + + + + - + + - - - + \ No newline at end of file