From 6f170968145cf880bfdf09a46c0a1c9ab2b4d200 Mon Sep 17 00:00:00 2001 From: luke-manyamazi Date: Tue, 7 Jul 2026 09:50:02 +0200 Subject: [PATCH] added code for the new feature rebloom. --- backend/data/blooms.py | 168 +++++++++++++++++++++++---------- backend/endpoints.py | 81 ++++++++++++++-- backend/main.py | 16 +++- db/schema.sql | 6 +- front-end/components/bloom.mjs | 62 +++++++++++- front-end/index.css | 17 ++++ front-end/index.html | 15 +++ front-end/lib/api.mjs | 19 ++++ front-end/views/bloom.mjs | 5 +- front-end/views/hashtag.mjs | 5 +- front-end/views/home.mjs | 5 +- front-end/views/profile.mjs | 119 +++++++++++------------ 12 files changed, 391 insertions(+), 127 deletions(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf3..067123b9 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -6,6 +6,12 @@ from data.connection import db_cursor from data.users import User +from psycopg2.errors import UniqueViolation + + +class AlreadyRebloomedError(Exception): + pass + @dataclass class Bloom: @@ -13,21 +19,79 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime - - -def add_bloom(*, sender: User, content: str) -> Bloom: + original_bloom_id: Optional[int] = None + original_sender: Optional[str] = None + original_sent_timestamp: Optional[datetime.datetime] = None + rebloom_count: int = 0 + rebloomed_by_current_user: bool = False + + +# Shared by every read query below: resolves original-bloom attribution and +# rebloom stats via the self-referencing original_bloom_id column, rather than +# a separate table or a stored counter. +_SELECT_FIELDS = """ + b.id, u.username, b.content, b.send_timestamp, + b.original_bloom_id, orig_user.username, orig.send_timestamp, + ( + SELECT COUNT(*) FROM blooms rb + WHERE rb.original_bloom_id = COALESCE(b.original_bloom_id, b.id) + ), + EXISTS( + SELECT 1 FROM blooms rb2 + WHERE rb2.sender_id = %(current_user_id)s + AND rb2.original_bloom_id = COALESCE(b.original_bloom_id, b.id) + ) +""" + +_JOIN_CLAUSE = """ + blooms b + INNER JOIN users u ON u.id = b.sender_id + LEFT JOIN blooms orig ON orig.id = b.original_bloom_id + LEFT JOIN users orig_user ON orig_user.id = orig.sender_id +""" + + +def _bloom_from_row(row) -> Bloom: + ( + bloom_id, + sender_username, + content, + timestamp, + original_bloom_id, + original_sender, + original_sent_timestamp, + rebloom_count, + rebloomed_by_current_user, + ) = row + return Bloom( + id=bloom_id, + sender=sender_username, + content=content, + sent_timestamp=timestamp, + original_bloom_id=original_bloom_id, + original_sender=original_sender, + original_sent_timestamp=original_sent_timestamp, + rebloom_count=rebloom_count, + rebloomed_by_current_user=rebloomed_by_current_user, + ) + + +def add_bloom( + *, sender_id: int, content: str, original_bloom_id: Optional[int] = None +) -> int: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] now = datetime.datetime.now(tz=datetime.UTC) bloom_id = int(now.timestamp() * 1000000) 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, original_bloom_id) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(original_bloom_id)s)", dict( bloom_id=bloom_id, - sender_id=sender.id, + sender_id=sender_id, content=content, - timestamp=datetime.datetime.now(datetime.UTC), + timestamp=now, + original_bloom_id=original_bloom_id, ), ) for hashtag in hashtags: @@ -35,17 +99,36 @@ def add_bloom(*, sender: User, content: str) -> Bloom: "INSERT INTO hashtags (hashtag, bloom_id) VALUES (%(hashtag)s, %(bloom_id)s)", dict(hashtag=hashtag, bloom_id=bloom_id), ) + return bloom_id + + +def add_rebloom(*, sender_id: int, original_bloom_id: int, content: str) -> int: + try: + return add_bloom( + sender_id=sender_id, + content=content, + original_bloom_id=original_bloom_id, + ) + except UniqueViolation: + raise AlreadyRebloomedError( + f"User {sender_id} has already reblооmed bloom {original_bloom_id}" + ) def get_blooms_for_user( - username: str, *, before: Optional[int] = None, limit: Optional[int] = None + username: str, + *, + current_user_id: Optional[int] = None, + before: Optional[int] = None, + limit: Optional[int] = None, ) -> List[Bloom]: with db_cursor() as cur: kwargs = { "sender_username": username, + "current_user_id": current_user_id, } if before is not None: - before_clause = "AND send_timestamp < %(before_limit)s" + before_clause = "AND b.send_timestamp < %(before_limit)s" kwargs["before_limit"] = before else: before_clause = "" @@ -54,83 +137,68 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + {_SELECT_FIELDS} FROM - blooms INNER JOIN users ON users.id = blooms.sender_id + {_JOIN_CLAUSE} WHERE - username = %(sender_username)s + u.username = %(sender_username)s {before_clause} - ORDER BY send_timestamp DESC + ORDER BY b.send_timestamp DESC {limit_clause} """, kwargs, ) rows = cur.fetchall() - blooms = [] - for row in rows: - bloom_id, sender_username, content, timestamp = row - blooms.append( - Bloom( - id=bloom_id, - sender=sender_username, - content=content, - sent_timestamp=timestamp, - ) - ) - return blooms + return [_bloom_from_row(row) for row in rows] -def get_bloom(bloom_id: int) -> Optional[Bloom]: +def get_bloom( + bloom_id: int, *, current_user_id: Optional[int] = None +) -> 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", - (bloom_id,), + f"""SELECT + {_SELECT_FIELDS} + FROM + {_JOIN_CLAUSE} + WHERE + b.id = %(bloom_id)s + """, + {"bloom_id": bloom_id, "current_user_id": current_user_id}, ) row = cur.fetchone() if row is None: return None - bloom_id, sender_username, content, timestamp = row - return Bloom( - id=bloom_id, - sender=sender_username, - content=content, - sent_timestamp=timestamp, - ) + return _bloom_from_row(row) def get_blooms_with_hashtag( - hashtag_without_leading_hash: str, *, limit: int = None + hashtag_without_leading_hash: str, + *, + current_user_id: Optional[int] = None, + limit: int = None, ) -> List[Bloom]: kwargs = { "hashtag_without_leading_hash": hashtag_without_leading_hash, + "current_user_id": current_user_id, } limit_clause = make_limit_clause(limit, kwargs) with db_cursor() as cur: cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + {_SELECT_FIELDS} FROM - blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id + {_JOIN_CLAUSE} + INNER JOIN hashtags ON b.id = hashtags.bloom_id WHERE hashtag = %(hashtag_without_leading_hash)s - ORDER BY send_timestamp DESC + ORDER BY b.send_timestamp DESC {limit_clause} """, kwargs, ) rows = cur.fetchall() - blooms = [] - for row in rows: - bloom_id, sender_username, content, timestamp = row - blooms.append( - Bloom( - id=bloom_id, - sender=sender_username, - content=content, - sent_timestamp=timestamp, - ) - ) - return blooms + return [_bloom_from_row(row) for row in rows] def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a07..bf084c3f 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -1,5 +1,6 @@ from typing import Dict, Union from data import blooms +from data.blooms import AlreadyRebloomedError from data.follows import follow, get_followed_usernames, get_inverse_followed_usernames from data.users import ( UserRegistrationError, @@ -110,7 +111,10 @@ 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 = blooms.get_blooms_for_user( + profile_username, + current_user_id=current_user.id if current_user else None, + ) all_blooms.reverse() return jsonify( { @@ -158,7 +162,7 @@ def send_bloom(): user = get_current_user() - blooms.add_bloom(sender=user, content=request.json["content"]) + blooms.add_bloom(sender_id=user.id, content=request.json["content"]) return jsonify( { @@ -167,17 +171,61 @@ def send_bloom(): ) +@jwt_required(optional=True) def get_bloom(id_str): try: id_int = int(id_str) except ValueError: - return make_response((f"Invalid bloom id", 400)) - bloom = blooms.get_bloom(id_int) + return make_response(jsonify({"success": False, "message": "Invalid bloom id"}), 400) + current_user = get_current_user() + bloom = blooms.get_bloom( + id_int, current_user_id=current_user.id if current_user else None + ) if bloom is None: - return make_response((f"Bloom not found", 404)) + return make_response(jsonify({"success": False, "message": "Bloom not found"}), 404) return jsonify(bloom) +@jwt_required() +def do_rebloom(id_str): + try: + id_int = int(id_str) + except ValueError: + return make_response(jsonify({"success": False, "message": "Invalid bloom id"}), 400) + + current_user = get_current_user() + + target_bloom = blooms.get_bloom(id_int) + if target_bloom is None: + return make_response(jsonify({"success": False, "message": "Bloom not found"}), 404) + + # Reblooming a rebloom points at its original, so reblooms never chain. + root_bloom_id = target_bloom.original_bloom_id or target_bloom.id + + try: + blooms.add_rebloom( + sender_id=current_user.id, + original_bloom_id=root_bloom_id, + content=target_bloom.content, + ) + except AlreadyRebloomedError: + return make_response( + jsonify( + { + "success": False, + "message": "You have already reblооmed this bloom", + } + ), + 409, + ) + + return jsonify( + { + "success": True, + } + ) + + @jwt_required() def home_timeline(): current_user = get_current_user() @@ -185,7 +233,9 @@ def home_timeline(): # Get blooms from followed users followed_users = get_followed_usernames(current_user) nested_user_blooms = [ - blooms.get_blooms_for_user(followed_user, limit=50) + blooms.get_blooms_for_user( + followed_user, current_user_id=current_user.id, limit=50 + ) for followed_user in followed_users ] @@ -193,7 +243,9 @@ def home_timeline(): followed_blooms = [bloom for blooms in nested_user_blooms for bloom in blooms] # Get the current user's own blooms - own_blooms = blooms.get_blooms_for_user(current_user.username, limit=50) + own_blooms = blooms.get_blooms_for_user( + current_user.username, current_user_id=current_user.id, limit=50 + ) # Combine own blooms with followed blooms all_blooms = followed_blooms + own_blooms @@ -206,8 +258,13 @@ def home_timeline(): return jsonify(sorted_blooms) +@jwt_required(optional=True) def user_blooms(profile_username): - user_blooms = blooms.get_blooms_for_user(profile_username) + current_user = get_current_user() + user_blooms = blooms.get_blooms_for_user( + profile_username, + current_user_id=current_user.id if current_user else None, + ) user_blooms.reverse() return jsonify(user_blooms) @@ -228,8 +285,14 @@ def suggested_follows(limit_str): return jsonify(suggestions) +@jwt_required(optional=True) def hashtag(hashtag): - return jsonify(blooms.get_blooms_with_hashtag(hashtag)) + current_user = get_current_user() + return jsonify( + blooms.get_blooms_with_hashtag( + hashtag, current_user_id=current_user.id if current_user else None + ) + ) def verify_request_fields(names_to_types: Dict[str, type]) -> Union[Response, None]: diff --git a/backend/main.py b/backend/main.py index 7ba155fa..aeb11a20 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_rebloom, get_bloom, hashtag, home_timeline, @@ -17,9 +18,10 @@ ) from dotenv import load_dotenv -from flask import Flask +from flask import Flask, jsonify from flask_cors import CORS from flask_jwt_extended import JWTManager +from werkzeug.exceptions import HTTPException def main(): @@ -46,6 +48,17 @@ def main(): jwt = JWTManager(app) jwt.user_lookup_loader(lookup_user) + # Force JSON error bodies everywhere - Flask's default HTML error pages + # break frontend code that expects response.json() to succeed. + @app.errorhandler(HTTPException) + def handle_http_exception(error): + return jsonify({"success": False, "message": error.description}), error.code + + @app.errorhandler(Exception) + def handle_unexpected_exception(error): + app.logger.exception(error) + return jsonify({"success": False, "message": "Internal server error"}), 500 + app.add_url_rule("/register", methods=["POST"], view_func=register) app.add_url_rule("/login", methods=["POST"], view_func=login) @@ -58,6 +71,7 @@ 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("/bloom//rebloom", methods=["POST"], view_func=do_rebloom) app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) diff --git a/db/schema.sql b/db/schema.sql index 61e7580c..a70d3959 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -10,9 +10,13 @@ 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, + original_bloom_id BIGINT REFERENCES blooms(id) ); +CREATE UNIQUE INDEX reblooms_unique_per_user ON blooms (sender_id, original_bloom_id) + WHERE original_bloom_id IS NOT NULL; + CREATE TABLE follows ( id SERIAL PRIMARY KEY, follower INT NOT NULL REFERENCES users(id), diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c3..0a07b791 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,12 @@ * {"id": Number, * "sender": username, * "content": "string from textarea", - * "sent_timestamp": "datetime as ISO 8601 formatted string"} + * "sent_timestamp": "datetime as ISO 8601 formatted string", + * "original_bloom_id": Number or null, + * "original_sender": username or null - set when this bloom is a rebloom, + * "original_sent_timestamp": "datetime as ISO 8601 formatted string" or null, + * "rebloom_count": Number, + * "rebloomed_by_current_user": boolean} */ const createBloom = (template, bloom) => { @@ -20,20 +27,65 @@ 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 rebloomBanner = bloomFrag.querySelector("[data-rebloom-banner]"); + const rebloomer = bloomFrag.querySelector("[data-rebloomer]"); + const rebloomTime = bloomFrag.querySelector("[data-rebloom-time]"); + const rebloomButton = bloomFrag.querySelector("[data-action='rebloom']"); + const rebloomLabel = bloomFrag.querySelector("[data-rebloom-label]"); + const rebloomCount = bloomFrag.querySelector("[data-rebloom-count]"); + + const isRebloom = Boolean(bloom.original_sender); + const bylineUsername = isRebloom ? bloom.original_sender : bloom.sender; bloomArticle.setAttribute("data-bloom-id", bloom.id); - bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); - bloomUsername.textContent = bloom.sender; - bloomTime.textContent = _formatTimestamp(bloom.sent_timestamp); + bloomArticle.classList.toggle("bloom--rebloom", isRebloom); + bloomUsername.setAttribute("href", `/profile/${bylineUsername}`); + bloomUsername.textContent = bylineUsername; + bloomTime.textContent = _formatTimestamp( + bloom.original_sent_timestamp ?? bloom.sent_timestamp + ); bloomTimeLink.setAttribute("href", `/bloom/${bloom.id}`); bloomContent.replaceChildren( ...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html") .body.childNodes ); + rebloomBanner.hidden = !isRebloom; + if (isRebloom) { + rebloomer.setAttribute("href", `/profile/${bloom.sender}`); + rebloomer.textContent = bloom.sender; + rebloomTime.textContent = _formatTimestamp(bloom.sent_timestamp); + } + + rebloomButton.setAttribute("data-bloom-id", bloom.id); + rebloomButton.disabled = Boolean(bloom.rebloomed_by_current_user); + rebloomLabel.textContent = bloom.rebloomed_by_current_user + ? "Reblооmed" + : "Rebloom"; + rebloomCount.hidden = !bloom.rebloom_count; + rebloomCount.textContent = bloom.rebloom_count || ""; + return bloomFrag; }; +/** + * Handle a rebloom button click + * @param {Event} event - The click event from a bloom's rebloom button + */ +async function handleRebloom(event) { + const button = event.currentTarget; + const bloomId = button.getAttribute("data-bloom-id"); + if (!bloomId) return; + + button.disabled = true; + const data = await apiService.rebloomBloom(bloomId); + if (!data.success) { + // A successful rebloom triggers a re-render (via getBlooms/getProfile) + // which replaces this button entirely, so only re-enable on failure. + button.disabled = false; + } +} + function _formatHashtags(text) { if (!text) return text; return text.replace( @@ -84,4 +136,4 @@ function _formatTimestamp(timestamp) { } } -export {createBloom}; +export {createBloom, handleRebloom}; diff --git a/front-end/index.css b/front-end/index.css index 65c7fb4c..aeaa82ab 100644 --- a/front-end/index.css +++ b/front-end/index.css @@ -221,6 +221,23 @@ dialog { gap: var(--space); } +/* BLOOM */ +.bloom--rebloom { + border-left: 4px solid var(--brand); +} + +.bloom__rebloom-banner { + font-family: monospace; + font-size: 0.85em; + color: var(--accent); +} + +.bloom__rebloom-button:disabled { + color: var(--brand); + border-color: var(--brand); + opacity: 1; +} + /* states, helpers*/ .flex { display: flex; diff --git a/front-end/index.html b/front-end/index.html index 89d6b130..35b475aa 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -234,11 +234,26 @@

Share a Bloom

diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339b..8060670f 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -212,6 +212,24 @@ async function postBloom(content) { } } +async function rebloomBloom(bloomId) { + try { + const data = await _apiRequest(`/bloom/${bloomId}/rebloom`, { + method: "POST", + }); + + if (data.success) { + await getBlooms(); + await getProfile(state.currentUser); + } + + return data; + } catch (error) { + // Error already handled by _apiRequest + return {success: false}; + } +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -291,6 +309,7 @@ const apiService = { getBloom, getBlooms, postBloom, + rebloomBloom, getBloomsByHashtag, // User methods diff --git a/front-end/views/bloom.mjs b/front-end/views/bloom.mjs index 181add66..016a61f7 100644 --- a/front-end/views/bloom.mjs +++ b/front-end/views/bloom.mjs @@ -6,7 +6,7 @@ import { getTimelineContainer, state, } from "../index.mjs"; -import {createBloom} from "../components/bloom.mjs"; +import {createBloom, handleRebloom} from "../components/bloom.mjs"; import {createLogin, handleLogin} from "../components/login.mjs"; import {createLogout, handleLogout} from "../components/logout.mjs"; @@ -46,6 +46,9 @@ function bloomView(bloomId) { "bloom-template", createBloom ); + document + .querySelectorAll("[data-action='rebloom']") + .forEach((button) => button.addEventListener("click", handleRebloom)); } export {bloomView}; diff --git a/front-end/views/hashtag.mjs b/front-end/views/hashtag.mjs index 7b7e9969..4a1b56d1 100644 --- a/front-end/views/hashtag.mjs +++ b/front-end/views/hashtag.mjs @@ -9,7 +9,7 @@ import { } from "../index.mjs"; import {createLogin, handleLogin} from "../components/login.mjs"; import {createLogout, handleLogout} from "../components/logout.mjs"; -import {createBloom} from "../components/bloom.mjs"; +import {createBloom, handleRebloom} from "../components/bloom.mjs"; import {createHeading} from "../components/heading.mjs"; // Hashtag view: show all tweets containing this tag @@ -50,6 +50,9 @@ function hashtagView(hashtag) { "bloom-template", createBloom ); + document + .querySelectorAll("[data-action='rebloom']") + .forEach((button) => button.addEventListener("click", handleRebloom)); } export {hashtagView}; diff --git a/front-end/views/home.mjs b/front-end/views/home.mjs index 85a5cca2..d88c5539 100644 --- a/front-end/views/home.mjs +++ b/front-end/views/home.mjs @@ -15,7 +15,7 @@ import { handleBloomSubmit, handleTyping, } from "../components/bloom-form.mjs"; -import {createBloom} from "../components/bloom.mjs"; +import {createBloom, handleRebloom} from "../components/bloom.mjs"; // Home view - logged in or not function homeView() { @@ -57,6 +57,9 @@ function homeView() { .querySelector("[data-form='bloom']") ?.addEventListener("submit", handleBloomSubmit); document.querySelector("textarea")?.addEventListener("input", handleTyping); + document + .querySelectorAll("[data-action='rebloom']") + .forEach((button) => button.addEventListener("click", handleRebloom)); } else { renderOne( state.isLoggedIn, diff --git a/front-end/views/profile.mjs b/front-end/views/profile.mjs index dd2b92af..ec4f2009 100644 --- a/front-end/views/profile.mjs +++ b/front-end/views/profile.mjs @@ -1,66 +1,69 @@ -import {renderEach, renderOne, destroy} from "../lib/render.mjs"; -import { - apiService, - state, - getLogoutContainer, - getLoginContainer, - 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 {apiService} from "../index.mjs"; -// Profile view - just this person's blooms and their profile -function profileView(username) { - destroy(); +/** + * Create a profile component + * @param {string} template - The ID of the template to clone + * @param {Object} profileData - The profile data to display + * @returns {DocumentFragment} - The profile UI + */ +function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { + if (!template || !profileData) return; + const profileElement = document + .getElementById(template) + .content.cloneNode(true); - const existingProfile = state.profiles.find((p) => p.username === username); - - // Only fetch profile if we don't have it or if it's incomplete - if (!existingProfile || !existingProfile.recent_blooms) { - apiService.getProfile(username); + const usernameEl = profileElement.querySelector("[data-username]"); + const bloomCountEl = profileElement.querySelector("[data-bloom-count]"); + const followingCountEl = profileElement.querySelector( + "[data-following-count]" + ); + const followerCountEl = profileElement.querySelector("[data-follower-count]"); + const followButtonEl = profileElement.querySelector("[data-action='follow']"); + const whoToFollowContainer = profileElement.querySelector(".profile__who-to-follow"); + // Populate with data + usernameEl.querySelector("h2").textContent = profileData.username || ""; + usernameEl.setAttribute("href", `/profile/${profileData.username}`); + bloomCountEl.textContent = profileData.total_blooms || 0; + followerCountEl.textContent = profileData.followers?.length || 0; + followingCountEl.textContent = profileData.follows?.length || 0; + followButtonEl.setAttribute("data-username", profileData.username || ""); + followButtonEl.hidden = profileData.is_self || profileData.is_following; + followButtonEl.addEventListener("click", handleFollow); + if (!isLoggedIn) { + followButtonEl.style.display = "none"; } - renderOne( - state.isLoggedIn, - getLogoutContainer(), - "logout-template", - createLogout - ); - document - .querySelector("[data-action='logout']") - ?.addEventListener("click", handleLogout); - renderOne( - state.isLoggedIn, - getLoginContainer(), - "login-template", - createLogin - ); - document - .querySelector("[data-action='login']") - ?.addEventListener("click", handleLogin); + if (whoToFollow.length > 0) { + const whoToFollowList = whoToFollowContainer.querySelector("[data-who-to-follow]"); + const whoToFollowTemplate = document.querySelector("#who-to-follow-chip"); + for (const userToFollow of whoToFollow) { + const wtfElement = whoToFollowTemplate.content.cloneNode(true); + const usernameLink = wtfElement.querySelector("a[data-username]"); + usernameLink.innerText = userToFollow.username; + usernameLink.setAttribute("href", `/profile/${userToFollow.username}`); + const followButton = wtfElement.querySelector("button"); + followButton.setAttribute("data-username", userToFollow.username); + followButton.addEventListener("click", handleFollow); + if (!isLoggedIn) { + followButton.style.display = "none"; + } - const profileData = state.profiles.find((p) => p.username === username); - if (profileData) { - renderOne( - { - profileData, - whoToFollow: state.isLoggedIn ? state.whoToFollow : [], - isLoggedIn: state.isLoggedIn, - }, - getProfileContainer(), - "profile-template", - createProfile - ); - renderEach( - profileData.recent_blooms || [], - getTimelineContainer(), - "bloom-template", - createBloom - ); + whoToFollowList.appendChild(wtfElement); + } + } else { + whoToFollowContainer.innerText = ""; } + + return profileElement; +} + +async function handleFollow(event) { + const button = event.target; + const username = button.getAttribute("data-username"); + if (!username) return; + + await apiService.followUser(username); + await apiService.getWhoToFollow(); } -export {profileView}; +export {createProfile, handleFollow};