diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..5ace819 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -14,6 +14,10 @@ class Bloom: content: str sent_timestamp: datetime.datetime + rebloomer: Optional[str] = None # new optional field + rebloom_count: int = 0 + last_rebloomed_at: Optional[datetime.datetime] = None + def add_bloom(*, sender: User, content: str) -> Bloom: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] @@ -54,11 +58,24 @@ def get_blooms_for_user( cur.execute( f"""SELECT + blooms.id, users.username, content, send_timestamp FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE username = %(sender_username)s + + blooms.id, users.username, content, send_timestamp, + COUNT(r.id) AS rebloom_count, + MAX(r.rebloomed_at) AS last_rebloomed_at + FROM blooms + INNER JOIN users ON users.id = blooms.sender_id + LEFT JOIN reblooms r ON r.original_bloom_id = blooms.id + WHERE + username = %(sender_username)s + GROUP BY + blooms.id, users.username, content, send_timestamp + {before_clause} ORDER BY send_timestamp DESC {limit_clause} @@ -68,18 +85,103 @@ 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 , rebloom_count, last_rebloomed_at= row + blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + + rebloom_count=rebloom_count, + last_rebloomed_at=last_rebloomed_at, + ) ) return blooms +# Fetch all reblooms made by a user +# returning them as Bloom objects with original content +# but marked as rebloomed by the user +def get_reblooms_for_user( + username: str, + *, + before: Optional[datetime.datetime] = None, + limit: Optional[int] = None, +) -> List[Bloom]: + with db_cursor() as cur: + kwargs = { + "username": username, + } + + if before is not None: + before_clause = "AND r.rebloomed_at < %(before_limit)s" + kwargs["before_limit"] = before + else: + before_clause = "" + + limit_clause = make_limit_clause(limit, kwargs) + + cur.execute( + f""" + SELECT + b.id, + us.username AS original_sender, + b.content, + r.rebloomed_at, + ur.username AS rebloomer_username, + rc.rebloom_count, + rc.last_rebloomed_at + FROM + reblooms r + INNER JOIN blooms b ON r.original_bloom_id = b.id + INNER JOIN users ur ON r.rebloomed_by = ur.id + INNER JOIN users us ON b.sender_id = us.id + INNER JOIN ( + SELECT + original_bloom_id, + COUNT(*) AS rebloom_count, + MAX(rebloomed_at) AS last_rebloomed_at + FROM reblooms + GROUP BY original_bloom_id + ) rc ON rc.original_bloom_id = b.id + WHERE + ur.username = %(username)s + {before_clause} + ORDER BY + r.rebloomed_at DESC + {limit_clause} + + """, + kwargs, + ) + + rows = cur.fetchall() + reblooms = [] + + for row in rows: + bloom_id, original_sender, content, timestamp, rebloomer_username , rebloom_count, last_rebloomed_at = row + reblooms.append( + Bloom( + id=bloom_id, + sender=original_sender, # the user who performed the rebloom + content=content, # content of the original bloom + sent_timestamp=timestamp, # time when the rebloom happened + rebloomer=rebloomer_username , # user who rebloomed + rebloom_count=rebloom_count, + last_rebloomed_at=last_rebloomed_at + ) + ) + + return reblooms + + + def get_bloom(bloom_id: int) -> Optional[Bloom]: with db_cursor() as cur: cur.execute( @@ -140,3 +242,17 @@ def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: else: limit_clause = "" return limit_clause + + +#Return True if the user has already rebloomed this bloom. +def has_user_rebloomed(original_bloom_id: int, user_id: int) -> bool : + with db_cursor() as cur : + cur.execute("select 1 from reblooms where original_bloom_id=%s and rebloomed_by=%s",(original_bloom_id,user_id)) + return cur.fetchone() is not None + +#Insert a rebloom record and return its id. +def add_rebloom(original_bloom_id: int, user_id: int) -> int : + with db_cursor() as cur : + cur.execute("insert into reblooms (original_bloom_id, rebloomed_by) values(%s, %s) returning id",(original_bloom_id, user_id)) + return cur.fetchone()[0] + diff --git a/backend/data/follows.py b/backend/data/follows.py index a4b6314..258c301 100644 --- a/backend/data/follows.py +++ b/backend/data/follows.py @@ -21,6 +21,13 @@ def follow(follower: User, followee: User): pass +def unfollow(follower: User, followee: User): + 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: diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..6811266 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, @@ -8,6 +8,7 @@ register_user, ) + from flask import Response, jsonify, make_response, request from flask_jwt_extended import ( create_access_token, @@ -17,6 +18,9 @@ from datetime import timedelta +from data.blooms import get_reblooms_for_user + + MINIMUM_PASSWORD_LENGTH = 5 @@ -149,6 +153,24 @@ def do_follow(): } ) +@jwt_required() +def do_unfollow(): + type_check_error = verify_request_fields({"follow_username": str}) + if type_check_error is not None: + return type_check_error + + current_user = get_current_user() + follow_username = request.json["follow_username"] + follow_user = get_user(follow_username) + if follow_user is None: + return make_response( + (f"Cannot unfollow {follow_username} - user does not exist", 404) + ) + + # Delete record from table follows + unfollow(current_user, follow_user) + + return jsonify({"success": True}) @jwt_required() def send_bloom(): @@ -167,6 +189,41 @@ def send_bloom(): ) +@jwt_required() +def send_rebloom(original_bloom_id): + user = get_current_user() + + bloom = blooms.get_bloom(original_bloom_id) + if bloom is None: + return make_response((f"Bloom not found", 404)) + + # Check if already rebloomed + if blooms.has_user_rebloomed(original_bloom_id, user.id): + return make_response(({"success": False, "message": "Already rebloomed"}, 400)) + + # Add rebloom + rebloom_id = blooms.add_rebloom(original_bloom_id, user.id) + + return jsonify({"success": True, "rebloom_id": rebloom_id}) + +from data.blooms import get_blooms_for_user +@jwt_required() +def get_reblooms_for_user_endpoint(username): + reblooms = get_reblooms_for_user(username) + return jsonify([ + { + "id": r.id, + "sender": r.sender, + "content": r.content, + "sent_timestamp": str(r.sent_timestamp), + "rebloomer": r.rebloomer, # <-- include the new field + "rebloom_count": r.rebloom_count, + "last_rebloomed_at": str(r.last_rebloomed_at) if r.last_rebloomed_at else None + } + for r in reblooms + ]) + + def get_bloom(id_str): try: id_int = int(id_str) @@ -195,6 +252,7 @@ def home_timeline(): # Get the current user's own blooms own_blooms = blooms.get_blooms_for_user(current_user.username, limit=50) + # Combine own blooms with followed blooms all_blooms = followed_blooms + own_blooms @@ -203,6 +261,24 @@ def home_timeline(): sorted(all_blooms, key=lambda bloom: bloom.sent_timestamp, reverse=True) ) + + all_blooms_dict = {bloom.id: bloom for bloom in followed_blooms + own_blooms} + + # Fetch reblooms for the followed users + all_reblooms = [] + for user in followed_users + [current_user.username]: + all_reblooms.extend(get_reblooms_for_user(user)) + + for r in all_reblooms: + if r.id in all_blooms_dict: + all_blooms_dict[r.id].rebloomer = r.rebloomer + all_blooms_dict[r.id].rebloom_count = r.rebloom_count + all_blooms_dict[r.id].last_rebloomed_at = r.last_rebloomed_at + + + # Sort by timestamp (newest first) + sorted_blooms =sorted(all_blooms_dict.values(), key=lambda b: b.sent_timestamp, reverse=True) + return jsonify(sorted_blooms) @@ -220,13 +296,13 @@ def suggested_follows(limit_str): return make_response((f"Invalid limit", 400)) current_user = get_current_user() - suggestions = [ {"username": username} for username in get_suggested_follows(current_user, limit_int) ] return jsonify(suggestions) + def hashtag(hashtag): return jsonify(blooms.get_blooms_with_hashtag(hashtag)) diff --git a/backend/main.py b/backend/main.py index 7ba155f..16c99b9 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, @@ -12,6 +13,9 @@ register, self_profile, send_bloom, + + send_rebloom, + suggested_follows, user_blooms, ) @@ -42,6 +46,9 @@ def main(): }, ) + + from endpoints import get_reblooms_for_user_endpoint + app.config["JWT_SECRET_KEY"] = os.environ["JWT_SECRET_KEY"] jwt = JWTManager(app) jwt.user_lookup_loader(lookup_user) @@ -54,6 +61,8 @@ def main(): app.add_url_rule("/profile", view_func=self_profile) app.add_url_rule("/profile/", view_func=other_profile) app.add_url_rule("/follow", methods=["POST"], view_func=do_follow) + app.add_url_rule("/unfollow", methods=["POST"], view_func=do_unfollow) + app.add_url_rule("/suggested-follows/", view_func=suggested_follows) app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom) @@ -61,6 +70,15 @@ def main(): app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) + + # Add rebloom route + app.add_url_rule("/rebloom/", methods=["POST"], view_func=send_rebloom ) + + # Add route for fetching user reblooms + app.add_url_rule("/reblooms/user/", view_func=get_reblooms_for_user_endpoint) + + + app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/db/schema.sql b/db/schema.sql index 61e7580..438fb1c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -26,3 +26,13 @@ CREATE TABLE hashtags ( bloom_id BIGINT NOT NULL REFERENCES blooms(id), UNIQUE(hashtag, bloom_id) ); + +CREATE TABLE IF NOT EXISTS reblooms ( + id BIGSERIAL PRIMARY KEY, + original_bloom_id BIGINT NOT NULL REFERENCES blooms(id), + rebloomed_by INT NOT NULL REFERENCES users(id), + rebloomed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_rebloom_per_user UNIQUE (original_bloom_id, rebloomed_by) +); + + diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index ec4f200..bbc179b 100644 --- a/front-end/components/profile.mjs +++ b/front-end/components/profile.mjs @@ -27,20 +27,31 @@ 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_self) { + followButtonEl.hidden = true; + } else { + followButtonEl.hidden = false; + followButtonEl.textContent = profileData.is_following + ? "Unfollow" + : "Follow"; + } + if (!isLoggedIn) { followButtonEl.style.display = "none"; } + followButtonEl.addEventListener("click", handleFollow); 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); @@ -59,11 +70,22 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { async function handleFollow(event) { const button = event.target; - const username = button.getAttribute("data-username"); + const username = button.dataset.username; if (!username) return; - await apiService.followUser(username); - await apiService.getWhoToFollow(); + const isUnfollowing = button.textContent === "Unfollow"; + + try { + if (isUnfollowing) { + await apiService.unfollowUser(username); + } else { + await apiService.followUser(username); + } + + } catch (error) { + console.error(error); + } } + export {createProfile, handleFollow}; diff --git a/front-end/components/timeline.mjs b/front-end/components/timeline.mjs index a5af73a..d8c246a 100644 --- a/front-end/components/timeline.mjs +++ b/front-end/components/timeline.mjs @@ -23,7 +23,12 @@ function createTimeline(template, blooms) { const bloomsFragment = document.createDocumentFragment(); // Accumulate blooms blooms.forEach((bloom) => { + bloomsFragment.appendChild(createBloom("bloom-template", bloom)); + + console.log("TIMELINE BLOOM:", bloom); + bloomsFragment.appendChild(createBloomWithRebloom("bloom-template", bloom)); + }); // Add all blooms to the content container at once @@ -34,4 +39,68 @@ function createTimeline(template, blooms) { return timelineElement; } + +export {createTimeline}; + +// Create a bloom element and add rebloom info if available +function createBloomWithRebloom(template, bloom) { + + const bloomElement = createBloom(template, bloom); + + const rebloomInfo = bloomElement.querySelector("[data-rebloom-info]"); + const rebloomerLabel = bloomElement.querySelector("[data-rebloomer]"); + const rebloomCountEl = bloomElement.querySelector("[data-rebloom-count]"); + const rebloomTimeEl = bloomElement.querySelector("[data-rebloom-time]"); + + // If bloom is rebloomed : + if (bloom.rebloomer) { + rebloomInfo.hidden = false; + + // Show rebloomer + rebloomerLabel.textContent = `Rebloomed by ${bloom.rebloomer}`; + + // Show rebloom_count + if (typeof bloom.rebloom_count === "number" && bloom.rebloom_count > 0) { + rebloomCountEl.hidden = false; + + const countLabel = bloom.rebloom_count === 1 ? "Time rebloomed" : "Times rebloomed"; + rebloomCountEl.textContent = `(${bloom.rebloom_count} ${countLabel})`; + } else { + rebloomCountEl.hidden = true; + } + + // Show last_rebloomed_at time + if (bloom.last_rebloomed_at) { + rebloomTimeEl.hidden = false; + + const date = new Date(bloom.last_rebloomed_at); + rebloomTimeEl.textContent = date.toLocaleString(); + } else { + rebloomTimeEl.hidden = true; + } + } else { + // // If bloom is not rebloomed : + rebloomInfo.hidden = true; + rebloomerLabel.textContent = ""; + rebloomCountEl.hidden = true; + rebloomTimeEl.hidden = true; + } + + // Add rebloom button if doesn't exist + if (!bloomElement.querySelector("[data-action='rebloom']")) { + const rebloomButton = document.createElement("button"); + rebloomButton.type = "button"; + rebloomButton.classList.add("bloom__rebloom-button"); + rebloomButton.setAttribute("data-action", "rebloom"); + rebloomButton.setAttribute("aria-label", "Rebloom"); + rebloomButton.textContent = "🔄"; + bloomElement.appendChild(rebloomButton); + } + + + return bloomElement; +} + + export {createTimeline}; +export {createBloomWithRebloom}; \ No newline at end of file diff --git a/front-end/index.css b/front-end/index.css index 65c7fb4..cf6d29c 100644 --- a/front-end/index.css +++ b/front-end/index.css @@ -9,7 +9,12 @@ --paper: hsla(var(--key), 45%, 96%); --ink: hsl(var(--key), 15%, 10%); --outline: hsl(var(--key), 25%, 90%); + --shadow: var(--outline) 0px 0px 0px 1px, var(--outline) 0px 1px 0px 0px, + + --shadow: + var(--outline) 0px 0px 0px 1px, var(--outline) 0px 1px 0px 0px, + var(--outline) 0px 0px 2.5px 0px, 0px 3.25px 6px var(--outline); --inset: inset 0px -3.25px 12px var(--outline); --corner: 2.5px; @@ -30,7 +35,10 @@ body { grid-template: ". header ." auto ". main ." 1fr + ". footer ." auto / minmax(var(--space), 1fr) var(--container) minmax(var(--space), 1fr); + + header { grid-area: header; display: flex; @@ -94,8 +102,18 @@ main { /* base elements */ html { box-sizing: border-box; + font: 100%/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + font: + 100%/1.5 system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif; + background-color: var(--paper); color: var(--ink); } @@ -130,7 +148,13 @@ textarea { } button { + font: 600 100% monospace, system-ui; + + font: + 600 100% monospace, + system-ui; + white-space: nowrap; color: var(--ink); background-color: transparent; @@ -138,8 +162,10 @@ button { border-radius: var(--pill); box-shadow: 2px 3px var(--brand); padding: calc(var(--space) / 3) var(--space); + transition: all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275), - border-color 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); + + border-color 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); } button.is-active, @@ -162,6 +188,48 @@ button:focus { outline: 3px dotted var(--accent); } + +/* Rebloom button styling */ +.bloom__rebloom-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + font-size: 16px; + background-color: var(--paper); + border: 1px solid var(--accent); + border-radius: 6px; + box-shadow: 1px 2px var(--accent); + cursor: pointer; + transition: all 0.2s ease-in-out; +} + + +.bloom__rebloom-button:hover, +.bloom__rebloom-button:focus { + background-color: var(--brand); + color: var(--paper); + box-shadow: 0 0 2px var(--brand); + outline: none; +} + +.bloom__rebloom-count { + font-size: 0.75em; + opacity: 0.6; + margin-inline-start: 4px; +} + +.rebloom__time { + font-size: 0.7em; + opacity: 0.5; + margin-inline-start: 6px; +} + + + + dialog { min-width: var(--container); } @@ -221,6 +289,66 @@ dialog { gap: var(--space); } + +/* BLOOM: rebloom info */ +.bloom__rebloom-info { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--brand); + font-style: italic; + margin-top: calc(var(--space) / 2); + gap: var(--space); +} +.bloom__rebloom-info span { + display: inline-block; +} + +.rebloom-label { + font-size: 0.8em; + color: var(--accent); + margin-left: var(--space); +} + +.bloom__rebloom-info { + font-size: 0.85rem; + color: var(--accent); + gap: var(--space); + align-items: center; +} + +.bloom__rebloom-label { + font-style: italic; +} + +.bloom__rebloom-count, +.bloom__rebloom-time { + font-weight: 500; + font-size: 0.8rem; + color: var(--brand); +} + +.bloom__rebloom-button { + font-size: 1.1rem; + padding: 0.2em 0.4em; + border-radius: var(--pill); + border: 1px solid var(--brand); + background-color: transparent; + cursor: pointer; + transition: all 0.3s ease; + align-self: flex-start; +} + +.bloom__rebloom-button:hover { + color: var(--accent); + transform: scale(1.1); +} + +.bloom__rebloom-button:active { + transform: scale(0.95); +} + + /* states, helpers*/ .flex { display: flex; diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..57391db 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -232,15 +232,33 @@

Share a Bloom

- + + + +