diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf3..71d52b04 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -10,18 +10,21 @@ @dataclass class Bloom: id: int - sender: User + sender: str content: str sent_timestamp: datetime.datetime + is_rebloom: bool = False + original_sender: Optional[str] = None + rebloomed_by: Optional[str] = None -def add_bloom(*, sender: User, content: str) -> Bloom: +def add_bloom(*, sender: User, content: str) -> None: 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( + cur.execute( "INSERT INTO blooms (id, sender_id, content, send_timestamp) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s)", dict( bloom_id=bloom_id, @@ -41,7 +44,7 @@ def get_blooms_for_user( username: str, *, before: Optional[int] = None, limit: Optional[int] = None ) -> List[Bloom]: with db_cursor() as cur: - kwargs = { + kwargs: Dict[str, Any]= { "sender_username": username, } if before is not None: @@ -53,14 +56,20 @@ def get_blooms_for_user( limit_clause = make_limit_clause(limit, kwargs) cur.execute( - f"""SELECT - blooms.id, users.username, content, send_timestamp - FROM - blooms INNER JOIN users ON users.id = blooms.sender_id + f"""SELECT + b.id, + u1.username AS original_author, + b.content, + b.send_timestamp, + b.is_rebloom, + u2.username AS rebloomed_by_author + FROM blooms b + INNER JOIN users u1 ON u1.id = b.sender_id + LEFT JOIN users u2 ON u2.id = b.rebloomed_by_id WHERE - username = %(sender_username)s + u1.username = %(sender_username)s OR u2.username = %(sender_username)s {before_clause} - ORDER BY send_timestamp DESC + ORDER BY b.send_timestamp DESC {limit_clause} """, kwargs, @@ -68,13 +77,18 @@ def get_blooms_for_user( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row + bloom_id, orig_author, content, timestamp, is_rebloom, rebloomed_by = row + display_sender = rebloomed_by if is_rebloom else orig_author + blooms.append( Bloom( id=bloom_id, - sender=sender_username, + sender=display_sender, content=content, sent_timestamp=timestamp, + is_rebloom=bool(is_rebloom), + original_sender=orig_author if is_rebloom else None, + rebloomed_by=rebloomed_by if is_rebloom else None ) ) return blooms @@ -83,23 +97,36 @@ 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 + b.id, + u1.username AS original_author, + b.content, + b.send_timestamp, + b.is_rebloom, + u2.username AS rebloomed_by_author + FROM blooms b + INNER JOIN users u1 ON u1.id = b.sender_id + LEFT JOIN users u2 ON u2.id = b.rebloomed_by_id + WHERE b.id = %s""", (bloom_id,), ) row = cur.fetchone() if row is None: return None - bloom_id, sender_username, content, timestamp = row + bloom_id, orig_author, content, timestamp, is_rebloom, rebloomed_by = row return Bloom( id=bloom_id, - sender=sender_username, + sender=orig_author, content=content, sent_timestamp=timestamp, + is_rebloom=bool(is_rebloom), + original_sender=orig_author if is_rebloom else None, + rebloomed_by=rebloomed_by if is_rebloom else None ) def get_blooms_with_hashtag( - hashtag_without_leading_hash: str, *, limit: int = None + hashtag_without_leading_hash: str, *, limit: Optional[int] = None ) -> List[Bloom]: kwargs = { "hashtag_without_leading_hash": hashtag_without_leading_hash, @@ -108,12 +135,19 @@ def get_blooms_with_hashtag( with db_cursor() as cur: cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp - FROM - blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id + b.id, + u1.username AS original_author, + b.content, + b.send_timestamp, + b.is_rebloom, + u2.username AS rebloomed_by_author + FROM blooms b + INNER JOIN hashtags ON b.id = hashtags.bloom_id + INNER JOIN users u1 ON b.sender_id = u1.id + LEFT JOIN users u2 ON b.rebloomed_by_id = u2.id WHERE hashtag = %(hashtag_without_leading_hash)s - ORDER BY send_timestamp DESC + ORDER BY b.send_timestamp DESC {limit_clause} """, kwargs, @@ -121,17 +155,60 @@ def get_blooms_with_hashtag( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row + bloom_id, orig_author, content, timestamp, is_rebloom, rebloomed_by = row blooms.append( Bloom( id=bloom_id, - sender=sender_username, + sender=orig_author, content=content, sent_timestamp=timestamp, + is_rebloom=bool(is_rebloom), + original_sender=orig_author if is_rebloom else None, + rebloomed_by=rebloomed_by if is_rebloom else None ) ) return blooms +def add_rebloom(*, current_user: User, original_bloom_id: int) -> Optional[Bloom]: + # Fetch the original bloom + orig_bloom = get_bloom(original_bloom_id) + if orig_bloom is None: + return None + + # Generate ID + now = datetime.datetime.now(tz=datetime.UTC) + new_bloom_id = int(now.timestamp() * 1000000) + + # Save the rebloom pointer into db + with db_cursor() as cur: + cur.execute( + """ + INSERT INTO blooms + (id, sender_id, content, send_timestamp, is_rebloom, original_bloom_id, rebloomed_by_id) + VALUES + (%(new_id)s, (SELECT id FROM users WHERE username = %(orig_username)s), %(content)s, %(timestamp)s, TRUE, %(orig_id)s, %(current_user_id)s) + """, + dict( + new_id=new_bloom_id, + orig_username=orig_bloom.sender, + content=orig_bloom.content, + timestamp=now, + orig_id=original_bloom_id, + current_user_id=current_user.id + ), + ) + + # Return the new Bloom object + return Bloom( + id=new_bloom_id, + sender=current_user.username, + content=orig_bloom.content, + sent_timestamp=now, + is_rebloom=True, + original_sender=orig_bloom.sender, + rebloomed_by=current_user.username + ) + def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: if limit is not None: diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a07..01c64c30 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -1,4 +1,5 @@ from typing import Dict, Union + from data import blooms from data.follows import follow, get_followed_usernames, get_inverse_followed_usernames from data.users import ( @@ -7,30 +8,42 @@ get_user, register_user, ) - from flask import Response, jsonify, make_response, request from flask_jwt_extended import ( create_access_token, get_current_user, jwt_required, ) - from datetime import timedelta MINIMUM_PASSWORD_LENGTH = 5 def login(): + data = request.get_json(silent=True) or {} + if not data: + return make_response( + jsonify({"success": False, "message": "Missing or invalid JSON body"}), + 400, + ) + type_check_error = verify_request_fields({"username": str, "password": str}) if type_check_error is not None: return type_check_error - user = get_user(request.json["username"]) + + user = get_user(data["username"]) if user is None: - return make_response(({"success": False, "message": "Unknown user"}, 403)) - if not user.check_password(request.json["password"]): - return make_response(({"success": False, "message": "Incorrect password"}, 403)) + return make_response( + jsonify({"success": False, "message": "Unknown user"}), 403 + ) + + if not user.check_password(data["password"]): + return make_response( + jsonify({"success": False, "message": "Incorrect password"}), 403 + ) + access_token = create_access_token( - identity=request.json["username"], expires_delta=timedelta(days=1) + identity=data["username"], expires_delta=timedelta(days=1) ) return jsonify( { @@ -44,28 +57,35 @@ def register(): type_check_error = verify_request_fields({"username": str, "password": str}) if type_check_error is not None: return type_check_error - if len(request.json["password"]) < MINIMUM_PASSWORD_LENGTH: + + request_data = request.get_json() or {} + username = request_data["username"] + password = request_data["password"] + + if len(password) < MINIMUM_PASSWORD_LENGTH: return make_response( - ( + jsonify( { "success": False, "message": f"Password must be at least {MINIMUM_PASSWORD_LENGTH} characters long", - }, - 400, - ) + } + ), + 400, ) try: - register_user(request.json["username"], request.json["password"]) + register_user(username, password) except UserRegistrationError as error: return make_response( - { - "success": False, - "message": error.reason, - }, + jsonify( + { + "success": False, + "message": error.reason, + } + ), 400, ) access_token = create_access_token( - identity=request.json["username"], expires_delta=timedelta(days=1) + identity=username, expires_delta=timedelta(days=1) ) return jsonify( { @@ -102,16 +122,19 @@ def other_profile(profile_username): if profile_user is None: return make_response( jsonify( - {"success": False, "message": f"User {profile_username} not found"} + { + "success": False, + "message": f"User {profile_username} not found", + } ), 404, ) current_user = get_current_user() - followers = get_inverse_followed_usernames(profile_user) all_blooms = blooms.get_blooms_for_user(profile_username) all_blooms.reverse() + return jsonify( { "username": profile_username, @@ -135,11 +158,18 @@ def do_follow(): current_user = get_current_user() - follow_username = request.json["follow_username"] + request_data = request.get_json() or {} + follow_username = request_data["follow_username"] follow_user = get_user(follow_username) if follow_user is None: return make_response( - (f"Cannot follow {follow_username} - user does not exist", 404) + jsonify( + { + "success": False, + "message": f"Cannot follow {follow_username} - user does not exist", + } + ), + 404, ) follow(current_user, follow_user) @@ -156,10 +186,22 @@ def send_bloom(): if type_check_error is not None: return type_check_error - user = get_current_user() + request_data = request.get_json() or {} + content = request_data["content"] - blooms.add_bloom(sender=user, content=request.json["content"]) + if len(content) > 280: + return make_response( + jsonify( + { + "success": False, + "message": "Bloom content cannot exceed 280 characters", + } + ), + 400, + ) + user = get_current_user() + blooms.add_bloom(sender=user, content=content) return jsonify( { "success": True, @@ -171,10 +213,14 @@ def get_bloom(id_str): try: id_int = int(id_str) except ValueError: - return make_response((f"Invalid bloom id", 400)) + return make_response( + jsonify({"success": False, "message": "Invalid bloom id"}), 400 + ) bloom = blooms.get_bloom(id_int) 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) @@ -190,7 +236,9 @@ def home_timeline(): ] # Flatten list of blooms from followed users - followed_blooms = [bloom for blooms in nested_user_blooms for bloom in blooms] + 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) @@ -200,7 +248,9 @@ 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.sent_timestamp, reverse=True + ) ) return jsonify(sorted_blooms) @@ -217,7 +267,9 @@ def suggested_follows(limit_str): try: limit_int = int(limit_str) except ValueError: - return make_response((f"Invalid limit", 400)) + return make_response( + jsonify({"success": False, "message": "Invalid limit"}), 400 + ) current_user = get_current_user() @@ -232,16 +284,63 @@ def hashtag(hashtag): return jsonify(blooms.get_blooms_with_hashtag(hashtag)) -def verify_request_fields(names_to_types: Dict[str, type]) -> Union[Response, None]: +def verify_request_fields( + names_to_types: Dict[str, type] +) -> Union[Response, None]: + data = request.get_json(silent=True) + if data is None: + return make_response( + jsonify( + {"success": False, "message": "Missing or invalid JSON body"} + ), + 400, + ) for name, expected_type in names_to_types.items(): - if name not in request.json: - return make_response((f"Request missing field: {name}", 400)) - actual_type = type(request.json[name]) + if name not in data: + return make_response( + jsonify( + {"success": False, "message": f"Request missing field: {name}"} + ), + 400, + ) + actual_type = type(data[name]) if actual_type != expected_type: return make_response( - ( - f"Request field {name} had wrong type - expected {expected_type.__name__} but got {actual_type.__name__}", - 400, - ) + jsonify( + { + "success": False, + "message": f"Request field {name} had wrong type - expected {expected_type.__name__} but got {actual_type.__name__}", + } + ), + 400, ) return None + + +@jwt_required() +def rebloom(id_str): + try: + original_bloom_id = int(id_str) + except ValueError: + return make_response( + jsonify({"success": False, "message": "Invalid bloom id"}), + 400 + ) + + current_user = get_current_user() + + new_rebloom = blooms.add_rebloom( + current_user=current_user, + original_bloom_id=original_bloom_id + ) + + if new_rebloom is None: + return make_response( + jsonify({"success": False, "message": "Original bloom not found"}), + 404 + ) + + return jsonify({ + "success": True, + "bloom": new_rebloom + }) diff --git a/backend/main.py b/backend/main.py index 7ba155fa..24796418 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ send_bloom, suggested_follows, user_blooms, + rebloom, ) from dotenv import load_dotenv @@ -58,10 +59,11 @@ 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=rebloom) app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) - app.run(host="0.0.0.0", port="3000", debug=True) + app.run(host="0.0.0.0", port=3000, debug=True) if __name__ == "__main__": diff --git a/backend/requirements.txt b/backend/requirements.txt index e03836c5..5e11b054 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,7 +11,7 @@ idna==3.10 itsdangerous==2.2.0 Jinja2==3.1.5 MarkupSafe==3.0.2 -psycopg2==2.9.10 +psycopg2-binary==2.9.10 pycparser==2.22 PyJWT==2.10.1 python-dotenv==1.0.1 diff --git a/db/schema.sql b/db/schema.sql index 61e7580c..44393626 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -10,7 +10,10 @@ 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, + is_rebloom BOOLEAN DEFAULT FALSE, + original_bloom_id BIGINT REFERENCES blooms(id) ON DELETE SET NULL, + rebloomed_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL ); CREATE TABLE follows ( diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c3..68ac487c 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,3 +1,4 @@ +import { apiService } from "../lib/api.mjs"; /** * Create a bloom component * @param {string} template - The ID of the template to clone @@ -20,6 +21,8 @@ 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 rebloomBtn = bloomFrag.querySelector("[data-action='rebloom']"); bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); @@ -28,17 +31,49 @@ const createBloom = (template, bloom) => { bloomTimeLink.setAttribute("href", `/bloom/${bloom.id}`); bloomContent.replaceChildren( ...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html") - .body.childNodes + .body.childNodes, ); + // Handle Rebloom Header Banner + if (bloom.is_rebloom && bloom.rebloomed_by && rebloomBanner) { + rebloomBanner.textContent = `🔄 rebloomed by ${bloom.rebloomed_by}`; + } else if (rebloomBanner) { + rebloomBanner.textContent = ""; + } + + //Handle Rebloom Button + if (rebloomBtn) { + if (bloom.is_rebloom) { + // Disable button if it's already a rebloom + rebloomBtn.disabled = true; + } else { + rebloomBtn.addEventListener("click", async (e) => { + e.preventDefault(); + e.stopPropagation(); // Prevent opening the single bloom view on click + + rebloomBtn.disabled = true; + const success = await apiService.rebloom(bloom.id); + if (!success) { + rebloomBtn.disabled = false; + } else { + if (typeof onUpdate === "function") { + onUpdate(); + } else { + window.location.reload(); // Fallback if no layout renderer handler is attached + } + } + }); + } + } + return bloomFrag; }; function _formatHashtags(text) { if (!text) return text; return text.replace( - /\B#[^#]+/g, - (match) => `${match}` + /#(\w+)/g, + (match, tag) => `${match}`, ); } @@ -46,10 +81,22 @@ function _formatTimestamp(timestamp) { if (!timestamp) return ""; try { - const date = new Date(timestamp); + let formattedTimestamp = timestamp; + if ( + typeof formattedTimestamp === "string" && + !formattedTimestamp.endsWith("Z") && + !formattedTimestamp.includes("+") + ) { + formattedTimestamp += "Z"; + } + const date = new Date(formattedTimestamp); const now = new Date(); const diffSeconds = Math.floor((now - date) / 1000); + if (diffSeconds == 0) { + return "now"; + } + // Less than a minute if (diffSeconds < 60) { return `${diffSeconds}s`; @@ -84,4 +131,4 @@ function _formatTimestamp(timestamp) { } } -export {createBloom}; +export { createBloom }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b130..e07643f4 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -1,4 +1,4 @@ - + @@ -10,12 +10,7 @@

- Purple Forest + Purple Forest PurpleForest

@@ -189,8 +184,7 @@

Create your account

Who to follow

-
    -
+
    @@ -236,9 +230,13 @@

    Share a Bloom

    diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339b..4e48ff92 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 { @@ -35,7 +35,7 @@ async function _apiRequest(endpoint, options = {}) { if (!response.ok) { const errorData = await response.json().catch(() => ({})); const error = new Error( - errorData.message || `API error: ${response.status}` + errorData.message || `API error: ${response.status}`, ); error.status = response.status; @@ -54,7 +54,7 @@ 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 @@ -70,11 +70,11 @@ function _updateProfile(username, profileData) { 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 @@ -82,7 +82,7 @@ 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 +96,7 @@ async function login(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -104,12 +104,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 +118,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 +132,20 @@ 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; } @@ -156,18 +156,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 +189,7 @@ async function getBloomsByHashtag(hashtag) { return blooms; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -197,7 +197,7 @@ async function postBloom(content) { try { const data = await _apiRequest("/bloom", { method: "POST", - body: JSON.stringify({content}), + body: JSON.stringify({ content }), }); if (data.success) { @@ -207,14 +207,36 @@ async function postBloom(content) { return data; } catch (error) { - // Error already handled by _apiRequest - return {success: false}; + return { success: false }; + } +} + +// Rebloom function +async function rebloom(bloomId) { + try { + const data = await _apiRequest(`/bloom/${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"; + if (username) { + const existingProfile = state.profiles.find((p) => p.username === username); + if (existingProfile && existingProfile.follows) { + return existingProfile; + } + } try { const profileData = await _apiRequest(endpoint); @@ -225,16 +247,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 +264,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 +277,7 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -277,7 +299,7 @@ async function unfollowUser(username) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -291,6 +313,7 @@ const apiService = { getBloom, getBlooms, postBloom, + rebloom, getBloomsByHashtag, // User methods @@ -300,4 +323,4 @@ const apiService = { getWhoToFollow, }; -export {apiService}; +export { apiService }; diff --git a/front-end/lib/router.mjs b/front-end/lib/router.mjs index d520552f..62dc4633 100644 --- a/front-end/lib/router.mjs +++ b/front-end/lib/router.mjs @@ -1,9 +1,9 @@ -import {bloomView} from "../views/bloom.mjs"; -import {profileView} from "../views/profile.mjs"; -import {signupView} from "../views/signup.mjs"; -import {loginView} from "../views/login.mjs"; -import {homeView} from "../views/home.mjs"; -import {hashtagView} from "../views/hashtag.mjs"; +import { bloomView } from "../views/bloom.mjs"; +import { profileView } from "../views/profile.mjs"; +import { signupView } from "../views/signup.mjs"; +import { loginView } from "../views/login.mjs"; +import { homeView } from "../views/home.mjs"; +import { hashtagView } from "../views/hashtag.mjs"; /** * Handle route changes based on the current URL @@ -29,7 +29,7 @@ function handleRouteChange() { // Hashtag path if (hash.startsWith("/hashtag/")) { const hashtag = hash.split("/")[2]; - hashtagView(hashtag); + hashtagView(hashtag.trim()); return; } @@ -60,7 +60,11 @@ window.addEventListener("hashchange", handleRouteChange); // Intercept clicks on internal links document.addEventListener("click", (event) => { - const link = event.target.closest("a"); + const targetElement = + event.target.nodeType === Node.TEXT_NODE + ? event.target.parentElement + : event.target; + const link = targetElement.closest("a"); if (!link) return; const href = link.getAttribute("href"); @@ -71,4 +75,4 @@ document.addEventListener("click", (event) => { } }); -export {handleRouteChange, navigateTo}; +export { handleRouteChange, navigateTo }; diff --git a/front-end/views/hashtag.mjs b/front-end/views/hashtag.mjs index 7b7e9969..b792ffa8 100644 --- a/front-end/views/hashtag.mjs +++ b/front-end/views/hashtag.mjs @@ -1,4 +1,4 @@ -import {renderOne, renderEach, destroy} from "../lib/render.mjs"; +import { renderOne, renderEach, destroy } from "../lib/render.mjs"; import { state, apiService, @@ -7,23 +7,17 @@ import { getTimelineContainer, getHeadingContainer, } from "../index.mjs"; -import {createLogin, handleLogin} from "../components/login.mjs"; -import {createLogout, handleLogout} from "../components/logout.mjs"; -import {createBloom} from "../components/bloom.mjs"; -import {createHeading} from "../components/heading.mjs"; - -// Hashtag view: show all tweets containing this tag - -function hashtagView(hashtag) { - destroy(); - - apiService.getBloomsByHashtag(hashtag); +import { createLogin, handleLogin } from "../components/login.mjs"; +import { createLogout, handleLogout } from "../components/logout.mjs"; +import { createBloom } from "../components/bloom.mjs"; +import { createHeading } from "../components/heading.mjs"; +function renderHashtagUI() { renderOne( state.isLoggedIn, getLogoutContainer(), "logout-template", - createLogout + createLogout, ); document .querySelector("[data-action='logout']") @@ -32,7 +26,7 @@ function hashtagView(hashtag) { state.isLoggedIn, getLoginContainer(), "login-template", - createLogin + createLogin, ); document .querySelector("[data-action='login']") @@ -42,14 +36,29 @@ function hashtagView(hashtag) { state.currentHashtag, getHeadingContainer(), "heading-template", - createHeading + createHeading, ); renderEach( state.hashtagBlooms || [], getTimelineContainer(), "bloom-template", - createBloom + createBloom, ); } -export {hashtagView}; +// Hashtag view: show all tweets containing this tag + +function hashtagView(hashtag) { + destroy(); + const formattedTag = hashtag.startsWith("#") ? hashtag : `#${hashtag}`; + + if (state.currentHashtag === formattedTag) { + renderHashtagUI(); + return; + } + + apiService.getBloomsByHashtag(hashtag); + renderHashtagUI(); +} + +export { hashtagView }; diff --git a/front-end/views/profile.mjs b/front-end/views/profile.mjs index dd2b92af..fa9a3fd6 100644 --- a/front-end/views/profile.mjs +++ b/front-end/views/profile.mjs @@ -1,4 +1,4 @@ -import {renderEach, renderOne, destroy} from "../lib/render.mjs"; +import { renderEach, renderOne, destroy } from "../lib/render.mjs"; import { apiService, state, @@ -7,14 +7,18 @@ import { 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 { 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"; // Profile view - just this person's blooms and their profile function profileView(username) { destroy(); + if (!username && !state.isLoggedIn) { + navigateTo("/"); + return; + } const existingProfile = state.profiles.find((p) => p.username === username); @@ -27,7 +31,7 @@ function profileView(username) { state.isLoggedIn, getLogoutContainer(), "logout-template", - createLogout + createLogout, ); document .querySelector("[data-action='logout']") @@ -36,11 +40,14 @@ function profileView(username) { state.isLoggedIn, getLoginContainer(), "login-template", - createLogin + createLogin, ); - document - .querySelector("[data-action='login']") - ?.addEventListener("click", handleLogin); + const loginForm = + getLoginContainer().querySelector("form") || + document.querySelector("#login-form"); + if (loginForm) { + loginForm.addEventListener("submit", handleLogin); + } const profileData = state.profiles.find((p) => p.username === username); if (profileData) { @@ -52,15 +59,15 @@ function profileView(username) { }, getProfileContainer(), "profile-template", - createProfile + createProfile, ); renderEach( profileData.recent_blooms || [], getTimelineContainer(), "bloom-template", - createBloom + createBloom, ); } } -export {profileView}; +export { profileView };