diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..da30dc9 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -13,6 +13,9 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + rebloom_from: Optional[str] = None + rebloomed_by: Optional[any] = None + rebloom_count: int = 0 def add_bloom(*, sender: User, content: str) -> Bloom: @@ -36,6 +39,62 @@ def add_bloom(*, sender: User, content: str) -> Bloom: dict(hashtag=hashtag, bloom_id=bloom_id), ) +def add_rebloom(*, rebloomer: User, original_bloom_id: int) -> Optional[Bloom]: + # 1. Fetch original bloom from DB + + with db_cursor() as cur: + cur.execute( + """ + SELECT blooms.id, users.id, users.username, content, send_timestamp + FROM blooms + INNER JOIN users ON users.id = blooms.sender_id + WHERE blooms.id =%s + """, + (original_bloom_id,) + ) + row = cur.fetchone() + + if row is None: + return None + + original_id, original_sender_id, original_sender_username, original_content, original_timestamp = row + + # 2. New bloom id & timestamp + now = datetime.datetime.now(tz=datetime.UTC) + new_bloom_id = int(now.timestamp() * 1000000) + + # 3. Insert new bloom as a "repost" of the original + with db_cursor() as cur: + cur.execute( + """ + INSERT INTO blooms (id, sender_id, content, send_timestamp, rebloom_from, rebloom_by) + VALUES (%(id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(rebloom_from)s, %(rebloom_by)s) + """, + dict( + id=new_bloom_id, + sender_id=rebloomer.id, + content=original_content, + timestamp=now, + rebloom_from=original_bloom_id, + rebloom_by=rebloomer.id, + ), + ) + + # 4. Increment rebloom count on the original + cur.execute( + "UPDATE blooms SET rebloom_count = rebloom_count + 1 WHERE id = %(original)s", + dict(original=original_bloom_id), + ) + + # 5. Return the new repost bloom + return Bloom( + id=new_bloom_id, + sender=rebloomer, + content=original_content, + sent_timestamp=now, + rebloomed_by=rebloomer, + rebloom_count=0, + ) def get_blooms_for_user( username: str, *, before: Optional[int] = None, limit: Optional[int] = None @@ -54,11 +113,16 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, blooms.content, blooms.send_timestamp, + blooms.rebloom_from, rebloomer.username, original_sender.username, blooms.rebloom_count FROM - blooms INNER JOIN users ON users.id = blooms.sender_id + blooms + INNER JOIN users ON users.id = blooms.sender_id + LEFT JOIN users AS rebloomer ON rebloomer.id = blooms.rebloom_by + LEFT JOIN blooms AS original_bloom ON original_bloom.id = blooms.rebloom_from + LEFT JOIN users AS original_sender ON original_sender.id = original_bloom.sender_id WHERE - username = %(sender_username)s + users.username = %(sender_username)s {before_clause} ORDER BY send_timestamp DESC {limit_clause} @@ -68,13 +132,16 @@ 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_from_id, rebloomed_by_username, original_sender_username, rebloom_count = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + rebloom_from=original_sender_username, + rebloomed_by=rebloomed_by_username, + rebloom_count= rebloom_count or 0 ) ) return blooms @@ -83,18 +150,30 @@ 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, blooms.content, blooms.send_timestamp, original_sender.username, blooms.rebloom_count + FROM + blooms + INNER JOIN users ON users.id = blooms.sender_id + LEFT JOIN users AS rebloomer ON rebloomer.id = blooms.rebloom_by + LEFT JOIN blooms AS original_bloom on original_bloom.id = blooms.rebloom_from + LEFT JOIN users AS original_sender on original_sender.id = original_bloom.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, rebloom_from_id, rebloomed_by_username, original_sender_username, rebloom_count = row return Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + rebloom_from=original_sender_username, + rebloomed_by=rebloomed_by_username, + rebloom_count= rebloom_count or 0, ) diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..cbd03ce 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -167,6 +167,26 @@ def send_bloom(): ) +@jwt_required() +def send_rebloom(): + type_check_error = verify_request_fields({"bloom_id": int}) + if type_check_error is not None: + return type_check_error + + current_user = get_current_user() + bloom_id = request.json["bloom_id"] + + new_bloom = blooms.add_rebloom( + rebloomer=current_user, + original_bloom_id=bloom_id + ) + if new_bloom is None: + return make_response(({"success": False, "message": "Original bloom not found"}, 400)) + + return jsonify({ + "success": True, + }) + def get_bloom(id_str): try: id_int = int(id_str) diff --git a/backend/main.py b/backend/main.py index 7ba155f..16a3c2f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ send_bloom, suggested_follows, user_blooms, + send_rebloom, ) from dotenv import load_dotenv @@ -59,6 +60,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("/blooms/", view_func=user_blooms) + app.add_url_rule("/rebloom", methods=["POST"], view_func= send_rebloom) app.add_url_rule("/hashtag/", view_func=hashtag) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/db/schema.sql b/db/schema.sql index 61e7580..9af0b14 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -13,6 +13,11 @@ CREATE TABLE blooms ( send_timestamp TIMESTAMP NOT NULL ); +ALTER TABLE blooms +ADD COLUMN rebloom_from BIGINT REFERENCES blooms(id), +ADD COLUMN rebloom_by INT REFERENCES users(id), +ADD COLUMN rebloom_count INT DEFAULT 0; + 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 0b4166c..ecbb013 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,3 +1,4 @@ +import {apiService} from "../index.mjs" /** * Create a bloom component * @param {string} template - The ID of the template to clone @@ -21,6 +22,10 @@ const createBloom = (template, bloom) => { const bloomTimeLink = bloomFrag.querySelector("a:has(> [data-time])"); const bloomContent = bloomFrag.querySelector("[data-content]"); + const rebloomEl = bloomFrag.querySelector("[data-rebloom]") + const rebloomCount = bloomFrag.querySelector("[data-rebloom-count]"); + const rebloomBtn = bloomFrag.querySelector("[data-rebloom-button]"); + bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); bloomUsername.textContent = bloom.sender; @@ -31,6 +36,34 @@ const createBloom = (template, bloom) => { .body.childNodes ); + rebloomBtn.addEventListener("click", async (e) => handleRebloomClick(e, bloom)); + // Show "originally bloomed by" + if (bloom.rebloomed_by) { + rebloomEl.replaceChildren(); + + const originalBloomerLink = document.createElement("a"); + originalBloomerLink.href = `/profile/${bloom.rebloom_from}`; + originalBloomerLink.textContent = bloom.rebloom_from; + + originalBloomerLink.style.fontWeight = "bold"; + + rebloomEl.append( "Originally bloomed by ", originalBloomerLink ); + } else { + rebloomEl.hidden = true; + } + + // Count how many times it has been rebloomed + const count = Number(bloom.rebloom_count || 0); + + if (rebloomCount) { + if (count > 0) { + rebloomCount.hidden = false; + rebloomCount.textContent = `Rebloomed ${String(count)} times`; + } else { + rebloomCount.hidden = true; + } + } + return bloomFrag; }; @@ -84,4 +117,26 @@ function _formatTimestamp(timestamp) { } } +async function handleRebloomClick(event, bloom) { + event.preventDefault(); + const button = event.currentTarget; + const originalText = button.textContent; + + try { + // Make button inert while calling backend + button.inert = true; + button.textContent = "Reblooming..."; + + await apiService.postRebloom(bloom); + + } catch (error) { + console.error("Rebloom failed:", error); + } finally { + // Restore UI state + button.textContent = originalText; + button.inert = false; + } +} + + export {createBloom}; diff --git a/front-end/index.css b/front-end/index.css index 65c7fb4..ed0ef14 100644 --- a/front-end/index.css +++ b/front-end/index.css @@ -267,3 +267,17 @@ dialog { [hidden] { display: none !important; } + +/* rebloom info line */ +[data-rebloom] { + font-size: 0.8rem; + color: var(--accent); +} + +/* rebloom count */ +.rebloom-count { + font-size: 0.8rem; + color: var(--accent); + margin-left: auto; + margin-right: 2em; +} diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..d2119d9 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -236,9 +236,12 @@

Share a Bloom

diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..15b9561 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -212,6 +212,23 @@ async function postBloom(content) { } } +async function postRebloom(bloom){ + try{ + const data = await _apiRequest("/rebloom", { + method: "POST", + body: JSON.stringify({ bloom_id: bloom.id }) + }); + if(data.success){ + await getBlooms(); + await getProfile(state.currentUser); + } + return data; + } catch(error){ + console.error("Rebloom failed:", error); + return {success: false}; + } +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -292,6 +309,7 @@ const apiService = { getBlooms, postBloom, getBloomsByHashtag, + postRebloom, // User methods getProfile,