From a5732d22a2769fab9ca3b3194db85b549f778489 Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Thu, 25 Jun 2026 13:49:47 +0100 Subject: [PATCH 01/13] created table reblooms with on delete cascade, and added rebloom_count in class Bloom in backend/data --- backend/data/blooms.py | 2 +- db/schema.sql | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf3..0fc192c4 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -13,7 +13,7 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime - + rebloom_count: int = 0 def add_bloom(*, sender: User, content: str) -> Bloom: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] diff --git a/db/schema.sql b/db/schema.sql index 61e7580c..c6029a65 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -26,3 +26,11 @@ CREATE TABLE hashtags ( bloom_id BIGINT NOT NULL REFERENCES blooms(id), UNIQUE(hashtag, bloom_id) ); + +CREATE TABLE reblooms ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + bloom_id BIGINT NOT NULL REFERENCES blooms(id) ON DELETE CASCADE, + rebloom_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, bloom_id) +); From 27394fa1abf96bcd15d406d4bd0673790196a05a Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Thu, 25 Jun 2026 16:15:34 +0100 Subject: [PATCH 02/13] added a function add_rebloom in backend/data/blooms.py to rebloom by current user id and bloom id --- backend/data/blooms.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 0fc192c4..50068407 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -36,6 +36,17 @@ def add_bloom(*, sender: User, content: str) -> Bloom: dict(hashtag=hashtag, bloom_id=bloom_id), ) +def add_rebloom(*, user_id: int, bloom_id: int) -> bool: + with db_cursor() as cur: + try: + cur.execute( + "INSERT INTO reblooms (user_id, bloom_id) VALUES (%(user_id)s, %(bloom_id)s) ON CONFLICT (user_id, bloom_id) DO NOTHING", dict(user_id=user_id, bloom_id=bloom_id) + ) + return True + except Exception as e: + print(f"cannot rebloom bloom: {bloom_id}") + return False + def get_blooms_for_user( username: str, *, before: Optional[int] = None, limit: Optional[int] = None From f1812f22498db99dd8af4adc582aaef4d0a2fd9d Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Fri, 26 Jun 2026 09:29:33 +0100 Subject: [PATCH 03/13] added a function to get reblooms for user --- backend/data/blooms.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 50068407..6fe0be2c 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -90,6 +90,39 @@ def get_blooms_for_user( ) return blooms +def get_reblooms_for_user(username: str) -> List[Bloom]: + with db_cursor() as cur: + cur.execute( + """ + SELECT + blooms.id, users.username, blooms.content, blooms.send_timestamp + FROM + reblooms + INNER JOIN blooms ON blooms.id = reblooms.bloom_id + INNER JOIN users ON users.id = blooms.sender_id + INNER JOIN users AS rebloomer ON rebloomer.id = reblooms.user_id + WHERE + rebloomer.username = %(username)s + ORDER BY reblooms.rebloom_timestamp DESC + """, + dict(username=username) + ) + rows = cur.fetchall() + + reblooms = [] + for row in rows: + bloom_id, send_username, content, timestamp = row + reblooms.append( + Bloom( + id=bloom_id, + sender=send_username, + content=content, + sent_timestamp=timestamp, + ) + ) + return reblooms + + def get_bloom(bloom_id: int) -> Optional[Bloom]: with db_cursor() as cur: From ccd038efd2d054a1cb708a26201583d33021e830 Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Fri, 26 Jun 2026 17:29:19 +0100 Subject: [PATCH 04/13] created a function rebloom in endpoints.py, replaced get_user_blooms by user_timeline, inserted shared_blooms in function home_timeline, added routes for rebloom, updated blooms/profile_username, view_func = user_timeline in main.py --- backend/data/blooms.py | 19 ++++++++++++++++--- backend/endpoints.py | 42 ++++++++++++++++++++++++++++++++++-------- backend/main.py | 6 ++++-- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 6fe0be2c..f33529c7 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -90,10 +90,21 @@ def get_blooms_for_user( ) return blooms -def get_reblooms_for_user(username: str) -> List[Bloom]: +def get_reblooms_for_user(username: str, *, before: Optional[int] = None, limit: Optional[int] = None) -> List[Bloom]: with db_cursor() as cur: + kwargs = { + "username": username, + } + if before is not None: + before_clause = "AND send_timestamp < %(before_limit)s" + kwargs["before_limit"] = before + else: + before_clause = "" + + limit_clause = make_limit_clause(limit, kwargs) + cur.execute( - """ + f""" SELECT blooms.id, users.username, blooms.content, blooms.send_timestamp FROM @@ -103,9 +114,11 @@ def get_reblooms_for_user(username: str) -> List[Bloom]: INNER JOIN users AS rebloomer ON rebloomer.id = reblooms.user_id WHERE rebloomer.username = %(username)s + {before_clause} ORDER BY reblooms.rebloom_timestamp DESC + {limit_clause} """, - dict(username=username) + kwargs ) rows = cur.fetchall() diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a07..8831f7c2 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -110,12 +110,16 @@ def other_profile(profile_username): current_user = get_current_user() followers = get_inverse_followed_usernames(profile_user) - all_blooms = blooms.get_blooms_for_user(profile_username) - all_blooms.reverse() + own_blooms = blooms.get_blooms_for_user(profile_username) + shared_blooms = blooms.get_reblooms_for_user(profile_username) + all_blooms = own_blooms + shared_blooms + + sorted_blooms = list(sorted(all_blooms, key=lambda bloom: bloom.sent_timestamp, reverse=True)) + return jsonify( { "username": profile_username, - "recent_blooms": all_blooms[:10], + "recent_blooms": sorted_blooms[:10], "follows": get_followed_usernames(profile_user), "followers": list(followers), "is_following": current_user is not None @@ -177,6 +181,21 @@ def get_bloom(id_str): return make_response((f"Bloom not found", 404)) return jsonify(bloom) +@jwt_required() +def rebloom(bloom_id): + try: + id_int = int(bloom_id) + except ValueError: + return make_response(jsonify({"error": "Invalid bloom id"}), 400) + + current_user = get_current_user() + + success = blooms.add_rebloom(user_id=current_user.id, bloom_id=id_int) + + if success: + return make_response(jsonify({"message": "Rebloomed successfully"}), 200) + else: + return make_response(jsonify({"error": "Failed to rebloom"}), 500) @jwt_required() def home_timeline(): @@ -195,8 +214,11 @@ def home_timeline(): # Get the current user's own blooms own_blooms = blooms.get_blooms_for_user(current_user.username, limit=50) + # Get the current user's shared blooms + shared_blooms = blooms.get_reblooms_for_user(current_user.username, limit=50) + # Combine own blooms with followed blooms - all_blooms = followed_blooms + own_blooms + all_blooms = followed_blooms + own_blooms + shared_blooms # Sort by timestamp (newest first) sorted_blooms = list( @@ -205,12 +227,16 @@ def home_timeline(): return jsonify(sorted_blooms) +def user_timeline(profile_username): + own_blooms = blooms.get_blooms_for_user(profile_username) + + shared_blooms = blooms.get_reblooms_for_user(profile_username) + + combined_timeline = own_blooms + shared_blooms -def user_blooms(profile_username): - user_blooms = blooms.get_blooms_for_user(profile_username) - user_blooms.reverse() - return jsonify(user_blooms) + sorted_timeline = list(sorted(combined_timeline, key=lambda bloom: bloom.sent_timestamp, reverse=True)) + return jsonify(sorted_timeline) @jwt_required() def suggested_follows(limit_str): diff --git a/backend/main.py b/backend/main.py index 7ba155fa..bc4c80ad 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,6 +5,7 @@ from endpoints import ( do_follow, get_bloom, + rebloom, hashtag, home_timeline, login, @@ -13,7 +14,7 @@ self_profile, send_bloom, suggested_follows, - user_blooms, + user_timeline, ) from dotenv import load_dotenv @@ -58,7 +59,8 @@ def main(): app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom) app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) - app.add_url_rule("/blooms/", view_func=user_blooms) + app.add_url_rule("/blooms//rebloom", methods=["POST"], view_func=rebloom) + app.add_url_rule("/blooms/", view_func=user_timeline) app.add_url_rule("/hashtag/", view_func=hashtag) app.run(host="0.0.0.0", port="3000", debug=True) From cb9f1515e5592f331a14e423c32f27fcf8479518 Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Fri, 26 Jun 2026 22:06:01 +0100 Subject: [PATCH 05/13] created postBloom in lib/api.mjs --- front-end/lib/api.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339b..50085f69 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -212,6 +212,23 @@ async function postBloom(content) { } } +async function postRebloom(bloomId) { + try { + const data = await _apiRequest(`/blooms/${bloomId}/rebloom`, { + method: "POST" + }); + if (data.success) { + await Promise.all([ + getBlooms(), + getProfile(state.currentUser) + ]); + } + return data; + } catch (error) { + return {success: false} + } +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; From 1d12c5cd0314393ae299f304875de4e35f37868c Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Fri, 26 Jun 2026 22:23:36 +0100 Subject: [PATCH 06/13] updated bloom-template in index.html with rebloom button --- front-end/index.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/front-end/index.html b/front-end/index.html index 89d6b130..a3005ecc 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -239,6 +239,11 @@

Share a Bloom

+
+ +
From 68673595e95b1e3979d9bf553ca5f5dd6c3e3ddd Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Sat, 27 Jun 2026 00:04:43 +0100 Subject: [PATCH 07/13] updated createBloom with rebloomBtn and added eventlistener to it in frontend component bloom.mjs --- front-end/components/bloom.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c3..dcbe5174 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 @@ -20,6 +22,7 @@ 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 rebloomBtn = bloomFrag.querySelector("[data-action='rebloom']") bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); @@ -31,6 +34,15 @@ const createBloom = (template, bloom) => { .body.childNodes ); + if (rebloomBtn) { + rebloomBtn.addEventListener("click", async (e) => { + e.preventDefault(); + rebloomBtn.disabled = true; + await apiService.postRebloom(bloom.id); + rebloomBtn.disabled = false; + }) + } + return bloomFrag; }; From 41bf0ee309b9194376f8fd078865aa96a4e6ada4 Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Sat, 27 Jun 2026 16:29:37 +0100 Subject: [PATCH 08/13] added attributes on rebloomed post, and css styles --- front-end/components/bloom.mjs | 7 +++++++ front-end/index.css | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index dcbe5174..64d3a146 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,4 +1,6 @@ +import { stat } from "node:fs"; import { apiService } from "../index.mjs"; +import { state } from "../lib/state.mjs"; /** * Create a bloom component @@ -25,6 +27,11 @@ const createBloom = (template, bloom) => { const rebloomBtn = bloomFrag.querySelector("[data-action='rebloom']") bloomArticle.setAttribute("data-bloom-id", bloom.id); + + if (state.currentUser && bloom.sender !== state.currentUser) { + bloomArticle.setAttribute("data-is-rebloom", "true"); + } + bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); bloomUsername.textContent = bloom.sender; bloomTime.textContent = _formatTimestamp(bloom.sent_timestamp); diff --git a/front-end/index.css b/front-end/index.css index 65c7fb4c..547a7fa5 100644 --- a/front-end/index.css +++ b/front-end/index.css @@ -194,6 +194,11 @@ dialog { grid-area: char-count; } +[data-is-rebloom="true"] { + border-left: 4px solid #7149c6; + background-color: #f9f6ff; +} + /* LOGIN */ .login__container { margin: 1em 0 auto auto; From 5cc475e7db1d3577b9e5bd128be1ece37edc23db Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Wed, 1 Jul 2026 14:28:03 +0100 Subject: [PATCH 09/13] fixed rendering problem rebloom post for main and profile --- backend/data/blooms.py | 1 - backend/endpoints.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index f33529c7..6bd467d1 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -13,7 +13,6 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime - rebloom_count: int = 0 def add_bloom(*, sender: User, content: str) -> Bloom: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] diff --git a/backend/endpoints.py b/backend/endpoints.py index 8831f7c2..63d9798a 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -193,9 +193,9 @@ def rebloom(bloom_id): success = blooms.add_rebloom(user_id=current_user.id, bloom_id=id_int) if success: - return make_response(jsonify({"message": "Rebloomed successfully"}), 200) + return make_response(jsonify({"success": True, "message": "Rebloom successfully"}), 200) else: - return make_response(jsonify({"error": "Failed to rebloom"}), 500) + return make_response(jsonify({"success": False, "message": "Failed to rebloom"}), 500) @jwt_required() def home_timeline(): From 89b7f95a3e1c05c46bc61cf6b3897ea4dac1de3c Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Wed, 1 Jul 2026 16:41:52 +0100 Subject: [PATCH 10/13] fixed the problem of by whom rebloomed and styling of rebloom --- backend/data/blooms.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 6bd467d1..222035d7 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -14,6 +14,15 @@ class Bloom: content: str sent_timestamp: datetime.datetime +@dataclass +class RebloomView: + id: int + sender: str + content: str + sent_timestamp: datetime.datetime + rebloomer: str + activity_timestamp: datetime.datetime + def add_bloom(*, sender: User, content: str) -> Bloom: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] @@ -105,7 +114,7 @@ def get_reblooms_for_user(username: str, *, before: Optional[int] = None, limit: cur.execute( f""" SELECT - blooms.id, users.username, blooms.content, blooms.send_timestamp + blooms.id, users.username AS sender, blooms.content, blooms.send_timestamp, rebloomer.username AS rebloomer, reblooms.rebloom_timestamp AS activity_time FROM reblooms INNER JOIN blooms ON blooms.id = reblooms.bloom_id @@ -123,13 +132,15 @@ def get_reblooms_for_user(username: str, *, before: Optional[int] = None, limit: reblooms = [] for row in rows: - bloom_id, send_username, content, timestamp = row + bloom_id, send_username, content, timestamp, rebloomer_name, activity_time = row reblooms.append( - Bloom( + RebloomView( id=bloom_id, sender=send_username, content=content, sent_timestamp=timestamp, + rebloomer=rebloomer_name, + activity_timestamp=activity_time ) ) return reblooms From 40fb01804fab187dd97d62add3c05c5ba6129c27 Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Wed, 1 Jul 2026 17:22:18 +0100 Subject: [PATCH 11/13] fixed the timeline problem of rebloomed posts in main and profile page --- backend/data/blooms.py | 2 ++ backend/endpoints.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 222035d7..ac004980 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -13,6 +13,7 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + activity_timestamp: datetime.datetime @dataclass class RebloomView: @@ -94,6 +95,7 @@ def get_blooms_for_user( sender=sender_username, content=content, sent_timestamp=timestamp, + activity_timestamp=timestamp ) ) return blooms diff --git a/backend/endpoints.py b/backend/endpoints.py index 63d9798a..1de2cb2d 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -114,7 +114,7 @@ def other_profile(profile_username): shared_blooms = blooms.get_reblooms_for_user(profile_username) all_blooms = own_blooms + shared_blooms - sorted_blooms = list(sorted(all_blooms, key=lambda bloom: bloom.sent_timestamp, reverse=True)) + sorted_blooms = list(sorted(all_blooms, key=lambda bloom: bloom.activity_timestamp, reverse=True)) return jsonify( { @@ -222,7 +222,7 @@ def home_timeline(): # Sort by timestamp (newest first) sorted_blooms = list( - sorted(all_blooms, key=lambda bloom: bloom.sent_timestamp, reverse=True) + sorted(all_blooms, key=lambda bloom: bloom.activity_timestamp, reverse=True) ) return jsonify(sorted_blooms) @@ -234,7 +234,7 @@ def user_timeline(profile_username): combined_timeline = own_blooms + shared_blooms - sorted_timeline = list(sorted(combined_timeline, key=lambda bloom: bloom.sent_timestamp, reverse=True)) + sorted_timeline = list(sorted(combined_timeline, key=lambda bloom: bloom.activity_timestamp, reverse=True)) return jsonify(sorted_timeline) From 597406aa438452e851c185e39bca9ccb75534c71 Mon Sep 17 00:00:00 2001 From: TzeMingHo Date: Wed, 1 Jul 2026 17:23:29 +0100 Subject: [PATCH 12/13] updated the frontend for timeline --- front-end/components/bloom.mjs | 21 +++++++++++++++++---- front-end/index.css | 29 ++++++++++++++++++++++++++--- front-end/index.html | 3 +++ front-end/lib/api.mjs | 14 ++++++++++---- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 64d3a146..61e6025b 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,4 +1,3 @@ -import { stat } from "node:fs"; import { apiService } from "../index.mjs"; import { state } from "../lib/state.mjs"; @@ -24,12 +23,24 @@ 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 rebloomBtn = bloomFrag.querySelector("[data-action='rebloom']") + const rebloomBtn = bloomFrag.querySelector("[data-action='rebloom']"); + const rebloomHeader = bloomFrag.querySelector("[data-rebloom-header]"); + const rebloomBy = bloomFrag.querySelector("[data-rebloom-by]"); bloomArticle.setAttribute("data-bloom-id", bloom.id); - if (state.currentUser && bloom.sender !== state.currentUser) { + bloomArticle.removeAttribute("data-is-rebloom"); + if (rebloomHeader) rebloomHeader.classList.add("is-hidden"); + + if (bloom.rebloomer) { bloomArticle.setAttribute("data-is-rebloom", "true"); + + if (rebloomHeader && rebloomBy) { + rebloomHeader.classList.remove("is-hidden"); + rebloomBy.setAttribute("href", `/profile/${bloom.rebloomer}`); + rebloomBy.textContent = bloom.rebloomer; + + } } bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); @@ -45,7 +56,9 @@ const createBloom = (template, bloom) => { rebloomBtn.addEventListener("click", async (e) => { e.preventDefault(); rebloomBtn.disabled = true; - await apiService.postRebloom(bloom.id); + console.log(bloom) + const response = await apiService.postRebloom(bloom.id); + console.log("API service responded with", response); rebloomBtn.disabled = false; }) } diff --git a/front-end/index.css b/front-end/index.css index 547a7fa5..8b604b59 100644 --- a/front-end/index.css +++ b/front-end/index.css @@ -194,9 +194,32 @@ dialog { grid-area: char-count; } -[data-is-rebloom="true"] { - border-left: 4px solid #7149c6; +/* REBLOOM */ + +.bloom.box[data-is-rebloom="true"] { + border-left: 4px solid #7149c6 !important; background-color: #f9f6ff; + box-shadow: inset 0 0 8px rgba(113, 73, 198, 0.02); +} + +.bloom__rebloom-header { + font-size: 0.85rem; + font-weight: 500; + color: #657786; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 4px; +} + +.bloom__rebloom-author { + font-weight: 700; + color: #7149c6; + text-decoration: none; +} + +.bloom__rebloom-author:hover { + text-decoration: underline; } /* LOGIN */ @@ -269,6 +292,6 @@ dialog { } /* We always want attribute hidden to sync with display:none, no matter what */ -[hidden] { +.is-hidden, [hidden] { display: none !important; } diff --git a/front-end/index.html b/front-end/index.html index a3005ecc..bd4e1142 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -234,6 +234,9 @@

Share a Bloom