diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..131bb6f 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -4,8 +4,7 @@ from typing import Any, Dict, List, Optional from data.connection import db_cursor -from data.users import User - +from data.users import User, get_user_by_id @dataclass class Bloom: @@ -13,28 +12,195 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + rebloom_count: int = 0 + original_bloom_id: Optional[int] = None + is_rebloom: bool = False + original_sender: Optional[User] = None - -def add_bloom(*, sender: User, content: str) -> Bloom: +def add_bloom( + *, + sender: User, + content: str, + is_rebloom: bool = False, + original_bloom_id: Optional[int] = None +) -> int: + """Create a new bloom and associate any hashtags.""" + 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)", - dict( - bloom_id=bloom_id, - sender_id=sender.id, + try: + cur.execute( + """ + 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, + content=content, + timestamp=now, # Pass Python datetime object to resolve SQL type error + original_bloom_id=original_bloom_id, + ), + ) + + for hashtag in hashtags: + cur.execute( + "INSERT INTO hashtags (hashtag, bloom_id) VALUES (%(hashtag)s, %(bloom_id)s)", + dict(hashtag=hashtag, bloom_id=bloom_id), + ) + + return bloom_id + + except Exception as e: + # Keep error logging for debugging purposes + print(f"Error adding bloom: {e}") + # Returning 0 or raising an error are typical ways to indicate failure + return 0 + +def add_rebloom(user_id: int, original_bloom_id: int, content: str) -> Optional[int]: + """Create a new rebloom""" + + # 💥 FIX: Fetch the complete User object using the user_id + # This prevents the "missing required positional arguments" error + sender_user = get_user_by_id(user_id) + + if sender_user is None: + print(f"Error adding rebloom: User with ID {user_id} not found.") + return None + + with db_cursor() as cur: + try: + # First create the new bloom + rebloom_bloom_id = add_bloom( + sender=sender_user, # <<< Use the fully hydrated User object content=content, - timestamp=datetime.datetime.now(datetime.UTC), - ), + is_rebloom=True, + original_bloom_id=original_bloom_id + ) + + if rebloom_bloom_id: + # Create the rebloom relationship + cur.execute( + "INSERT INTO reblooms (user_id, original_bloom_id, rebloom_bloom_id) VALUES (%s, %s, %s) RETURNING id", + (user_id, original_bloom_id, rebloom_bloom_id) + ) + result = cur.fetchone() + if result is None: + return None + rebloom_id = result[0] + + # Update rebloom count on original bloom + # This step will now execute successfully since the code no longer crashes above + cur.execute( + "UPDATE blooms SET rebloom_count = rebloom_count + 1 WHERE id = %s", + (original_bloom_id,) + ) + + return rebloom_id + return None + except Exception as e: + # The count should now increase before any exception related to DB occurs + print(f"Error adding rebloom: {e}") + return None + +def get_rebloom_by_user_and_bloom(user_id: int, original_bloom_id: int) -> Optional[Dict]: + """Check if user has already rebloomed a specific bloom""" + with db_cursor() as cur: + cur.execute( + "SELECT * FROM reblooms WHERE user_id = %s AND original_bloom_id = %s", + (user_id, original_bloom_id) ) - for hashtag in hashtags: + row = cur.fetchone() + if row: + return { + 'id': row[0], + 'user_id': row[1], + 'original_bloom_id': row[2], + 'rebloom_bloom_id': row[3], + 'created_at': row[4] + } + return None + + +def delete_rebloom(rebloom_id: int) -> bool: + """Delete a rebloom and its associated bloom""" + with db_cursor() as cur: + try: + # Get the rebloom details first + cur.execute("SELECT rebloom_bloom_id, original_bloom_id FROM reblooms WHERE id = %s", (rebloom_id,)) + rebloom_data = cur.fetchone() + + if not rebloom_data: + return False + + rebloom_bloom_id, original_bloom_id = rebloom_data + + # Delete the rebloom bloom + cur.execute("DELETE FROM blooms WHERE id = %s", (rebloom_bloom_id,)) + + # Delete the rebloom relationship + cur.execute("DELETE FROM reblooms WHERE id = %s", (rebloom_id,)) + + # Decrement rebloom count on original bloom cur.execute( - "INSERT INTO hashtags (hashtag, bloom_id) VALUES (%(hashtag)s, %(bloom_id)s)", - dict(hashtag=hashtag, bloom_id=bloom_id), + "UPDATE blooms SET rebloom_count = rebloom_count - 1 WHERE id = %s", + (original_bloom_id,) ) + + return True + except Exception as e: + print(f"Error deleting rebloom: {e}") + return False + + +def has_user_rebloomed(user_id: int, bloom_id: int) -> bool: + """Check if user has rebloomed a specific bloom""" + return get_rebloom_by_user_and_bloom(user_id, bloom_id) is not None + + +def get_rebloom_count(bloom_id: int) -> int: + """Get the number of times a bloom has been rebloomed""" + with db_cursor() as cur: + cur.execute("SELECT rebloom_count FROM blooms WHERE id = %s", (bloom_id,)) + result = cur.fetchone() + return result[0] if result else 0 + + +def get_user_reblooms(user_id: int) -> List[Dict]: + """Get all reblooms by a user""" + with db_cursor() as cur: + cur.execute(""" + SELECT r.*, b.content, b.send_timestamp, ob.sender_id as original_sender_id, + ou.username as original_username, ob.id as original_bloom_id + FROM reblooms r + JOIN blooms b ON r.rebloom_bloom_id = b.id + JOIN blooms ob ON r.original_bloom_id = ob.id + JOIN users ou ON ob.sender_id = ou.id + WHERE r.user_id = %s + ORDER BY r.created_at DESC + """, (user_id,)) + + reblooms = [] + for row in cur.fetchall(): + reblooms.append({ + 'id': row[0], + 'user_id': row[1], + 'original_bloom_id': row[2], + 'rebloom_bloom_id': row[3], + 'created_at': row[4], + 'content': row[5], + 'rebloom_timestamp': row[6], + 'original_sender_id': row[7], + 'original_username': row[8], + 'original_bloom_id': row[9] + }) + return reblooms def get_blooms_for_user( @@ -54,7 +220,7 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, rebloom_count, original_bloom_id FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE @@ -66,36 +232,57 @@ def get_blooms_for_user( kwargs, ) rows = cur.fetchall() - blooms = [] + blooms_list = [] 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, - ) + bloom_id, sender_username, content, timestamp, rebloom_count, original_bloom_id = row + bloom = Bloom( + id=bloom_id, + sender=sender_username, + content=content, + sent_timestamp=timestamp, + rebloom_count=rebloom_count, + original_bloom_id=original_bloom_id, + is_rebloom=original_bloom_id is not None ) - return blooms + + # If this is a rebloom, get original sender info + if original_bloom_id: + original_bloom = get_bloom(original_bloom_id) + if original_bloom: + bloom.original_sender = original_bloom.sender + + blooms_list.append(bloom) + return blooms_list 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, content, send_timestamp, rebloom_count, original_bloom_id FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s", (bloom_id,), ) row = cur.fetchone() if row is None: return None - bloom_id, sender_username, content, timestamp = row - return Bloom( + bloom_id, sender_username, content, timestamp, rebloom_count, original_bloom_id = row + + bloom = Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + rebloom_count=rebloom_count, + original_bloom_id=original_bloom_id, + is_rebloom=original_bloom_id is not None ) + + # If this is a rebloom, get original sender info + if original_bloom_id: + original_bloom = get_bloom(original_bloom_id) + if original_bloom: + bloom.original_sender = original_bloom.sender + + return bloom def get_blooms_with_hashtag( @@ -108,7 +295,7 @@ def get_blooms_with_hashtag( with db_cursor() as cur: cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, rebloom_count, original_bloom_id FROM blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id WHERE @@ -119,18 +306,27 @@ def get_blooms_with_hashtag( kwargs, ) rows = cur.fetchall() - blooms = [] + blooms_list = [] 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, - ) + bloom_id, sender_username, content, timestamp, rebloom_count, original_bloom_id = row + bloom = Bloom( + id=bloom_id, + sender=sender_username, + content=content, + sent_timestamp=timestamp, + rebloom_count=rebloom_count, + original_bloom_id=original_bloom_id, + is_rebloom=original_bloom_id is not None ) - return blooms + + # If this is a rebloom, get original sender info + if original_bloom_id: + original_bloom = get_bloom(original_bloom_id) + if original_bloom: + bloom.original_sender = original_bloom.sender + + blooms_list.append(bloom) + return blooms_list def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: @@ -139,4 +335,4 @@ def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: kwargs["limit"] = limit else: limit_clause = "" - return limit_clause + return limit_clause \ No newline at end of file diff --git a/backend/data/follows.py b/backend/data/follows.py index a4b6314..90b6d6a 100644 --- a/backend/data/follows.py +++ b/backend/data/follows.py @@ -20,7 +20,6 @@ def follow(follower: User, followee: User): # Already following - treat as idempotent request. pass - 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/data/users.py b/backend/data/users.py index 00746f1..17f1340 100644 --- a/backend/data/users.py +++ b/backend/data/users.py @@ -106,3 +106,27 @@ def generate_salt() -> bytes: def lookup_user(header_info, payload_info): """lookup_user is a hook for the jwt middleware to look-up authenticated users.""" return get_user(payload_info["sub"]) + +def get_user_by_id(user_id: int) -> Optional[User]: + """ + Retrieves a complete User object from the database by their ID. + This is necessary for operations like reblooming where only the ID is known. + """ + with db_cursor() as cur: + # NOTE: Adjust column names if they are different in your 'users' table + cur.execute( + "SELECT id, username, password_salt, password_scrypt FROM users WHERE id = %s", + (user_id,) + ) + row = cur.fetchone() + + if row: + user_id, username, password_salt, password_scrypt = row + # Create and return the full User object + return User( + id=user_id, + username=username, + password_salt=password_salt, + password_scrypt=password_scrypt + ) + return None diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..57c2e81 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -149,6 +149,24 @@ def do_follow(): } ) +@jwt_required() +def do_unfollow(): + type_check_error = verify_request_fields({"unfollow_username": str}) + if type_check_error is not None: + return type_check_error + + current_user = get_current_user() + + unfollow_username = request.json["unfollow_username"] + unfollow_user_obj = get_user(unfollow_username) + if unfollow_user_obj is None: + return make_response( + (f"Cannot unfollow {unfollow_username} - user does not exist", 404) + ) + + unfollow(current_user, unfollow_user_obj) + return jsonify({"success": True}) + @jwt_required() def send_bloom(): @@ -178,6 +196,89 @@ def get_bloom(id_str): return jsonify(bloom) +@jwt_required(optional=True) +def rebloom_bloom(bloom_id_str): + print(f"🔍 Rebloom endpoint called with: {bloom_id_str}") + if request.method == "OPTIONS": + return '', 200 + + try: + bloom_id = int(bloom_id_str) + except ValueError: + return jsonify({"success": False, "message": "Invalid bloom id"}), 400 + + current_user = get_current_user() + print(f"👤 Current user: {current_user.username}") + + # Get original bloom + original_bloom = blooms.get_bloom(bloom_id) + if original_bloom is None: + return jsonify({"success": False, "message": "Bloom not found"}), 404 + + print(f"📝 Original bloom: ID={original_bloom.id}, is_rebloom={original_bloom.is_rebloom}") + + + if original_bloom.is_rebloom and original_bloom.original_bloom_id: + bloom_id = original_bloom.original_bloom_id + original_bloom = blooms.get_bloom(bloom_id) + print(f"🔄 Using original bloom ID: {bloom_id}") + + # Check if user already rebloomed this bloom + existing_rebloom = blooms.get_rebloom_by_user_and_bloom(current_user.id, bloom_id) + print(f"🔍 Existing rebloom: {existing_rebloom}") + + if existing_rebloom: + # Undo rebloom: delete the rebloom from user's timeline + success = blooms.delete_rebloom(existing_rebloom['id']) + rebloom_count = blooms.get_rebloom_count(bloom_id) + print(f"🗑️ Deleted rebloom, new count: {rebloom_count}") + + return jsonify({ + "success": True, + "rebloomed": False, + "rebloom_count": rebloom_count + }) + else: + # Create a new rebloom: this adds a new bloom for the current user + # FIX: Use object attribute, not dictionary access + rebloom_id = blooms.add_rebloom( + user_id=current_user.id, + original_bloom_id=bloom_id, + content=original_bloom.content # FIX: .content not ['content'] + ) + rebloom_count = blooms.get_rebloom_count(bloom_id) + print(f"✅ Created rebloom ID: {rebloom_id}, count: {rebloom_count}") + + return jsonify({ + "success": True, + "rebloomed": True, + "rebloom_id": rebloom_id, + "rebloom_count": rebloom_count + }) + +@jwt_required() +def check_rebloom_status(bloom_id_str): + try: + bloom_id = int(bloom_id_str) + except ValueError: + return make_response((f"Invalid bloom id", 400)) + + current_user = get_current_user() + + rebloomed = blooms.has_user_rebloomed(current_user.id, bloom_id) + rebloom_count = blooms.get_rebloom_count(bloom_id) + + return jsonify({ + "rebloomed": rebloomed, + "rebloom_count": rebloom_count + }) + +@jwt_required() +def get_user_reblooms(): + current_user = get_current_user() + user_reblooms = blooms.get_user_reblooms(current_user.id) + return jsonify(user_reblooms) + @jwt_required() def home_timeline(): current_user = get_current_user() diff --git a/backend/main.py b/backend/main.py index 7ba155f..698888c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,9 @@ send_bloom, suggested_follows, user_blooms, + rebloom_bloom, + check_rebloom_status, + get_user_reblooms, ) from dotenv import load_dotenv @@ -29,18 +32,21 @@ def main(): app.json = CustomJsonProvider(app) - # Configure CORS to handle preflight requests + # --- CORS FIX APPLIED HERE --- + # The frontend runs on http://127.0.0.1:5500, so we must explicitly + # set this origin because supports_credentials=True is required for JWT. CORS( - app, - supports_credentials=True, - resources={ - r"/*": { - "origins": "*", - "allow_headers": ["Content-Type", "Authorization"], - "methods": ["GET", "POST", "OPTIONS"], - } - }, - ) + app, + supports_credentials=True, + resources={ + r"/*": { + "origins": ["http://127.0.0.1:5500", "http://localhost:5500"], + "allow_headers": ["Content-Type", "Authorization"], + "methods": ["GET", "POST", "OPTIONS", "PUT", "DELETE"], + } + }, +) + # --- END CORS FIX --- app.config["JWT_SECRET_KEY"] = os.environ["JWT_SECRET_KEY"] jwt = JWTManager(app) @@ -61,8 +67,12 @@ def main(): app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) + app.add_url_rule("/api/blooms//rebloom", methods=["POST", "OPTIONS"], view_func=rebloom_bloom) + app.add_url_rule("/api/blooms//rebloom-status", methods=["GET"], view_func=check_rebloom_status) + app.add_url_rule("/api/user/reblooms", methods=["GET"], view_func=get_user_reblooms) + app.run(host="0.0.0.0", port="3000", debug=True) if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index 61e7580..407a767 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -26,3 +26,27 @@ CREATE TABLE hashtags ( bloom_id BIGINT NOT NULL REFERENCES blooms(id), UNIQUE(hashtag, bloom_id) ); + +-- Add rebloom_count to blooms table (to track total reblooms) +ALTER TABLE blooms ADD COLUMN rebloom_count INTEGER DEFAULT 0; + +-- Add original_bloom_id to support rebloom chains +ALTER TABLE blooms ADD COLUMN original_bloom_id BIGINT REFERENCES blooms(id); + +-- Create reblooms table to track the rebloom relationship +CREATE TABLE reblooms ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + original_bloom_id BIGINT NOT NULL REFERENCES blooms(id), + rebloom_bloom_id BIGINT NOT NULL REFERENCES blooms(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, original_bloom_id) -- User can only rebloom once per original bloom +); + +-- Indexes for performance +CREATE INDEX idx_reblooms_user_id ON reblooms(user_id); +CREATE INDEX idx_reblooms_original_bloom_id ON reblooms(original_bloom_id); +CREATE INDEX idx_reblooms_rebloom_bloom_id ON reblooms(rebloom_bloom_id); +CREATE INDEX idx_blooms_original_bloom_id ON blooms(original_bloom_id); + + diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c..222b284 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,16 +1,9 @@ -/** - * Create a bloom component - * @param {string} template - The ID of the template to clone - * @param {Object} bloom - The bloom data - * @returns {DocumentFragment} - The bloom fragment of UI, for items in the Timeline - * btw a bloom object is composed thus - * {"id": Number, - * "sender": username, - * "content": "string from textarea", - * "sent_timestamp": "datetime as ISO 8601 formatted string"} +import { apiService } from "../lib/api.mjs"; +/** + * Create a bloom component with rebloom support */ -const createBloom = (template, bloom) => { +const createBloom = (template, bloom, isRebloom = false) => { if (!bloom) return; const bloomFrag = document.getElementById(template).content.cloneNode(true); const bloomParser = new DOMParser(); @@ -31,57 +24,134 @@ const createBloom = (template, bloom) => { .body.childNodes ); + // Add rebloom button + _addRebloomButton(bloomFrag, bloom); + + // If this is a rebloom, show the original sender info + if (bloom.is_rebloom && bloom.original_sender) { + _addRebloomIndicator(bloomFrag, bloom); + } + return bloomFrag; }; -function _formatHashtags(text) { - if (!text) return text; - return text.replace( - /\B#[^#]+/g, - (match) => `${match}` - ); +/** + * Add rebloom button to the bloom component + */ +function _addRebloomButton(bloomFrag, bloom) { + const bloomActions = bloomFrag.querySelector("[data-bloom-actions]"); + if (!bloomActions) return; + + const rebloomButton = document.createElement('button'); + rebloomButton.className = 'rebloom-btn'; + rebloomButton.setAttribute('data-rebloom-btn', ''); + rebloomButton.setAttribute('data-bloom-id', bloom.id); + rebloomButton.innerHTML = ` + Rebloom ${bloom.rebloom_count || 0} + `; + + rebloomButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + _handleRebloom(bloom.id, rebloomButton); + }); + + _checkRebloomStatus(bloom.id, rebloomButton); + bloomActions.appendChild(rebloomButton); } -function _formatTimestamp(timestamp) { - if (!timestamp) return ""; +/** + * Add rebloom indicator for rebloomed blooms + */ +function _addRebloomIndicator(bloomFrag, bloom) { + const bloomHeader = bloomFrag.querySelector("[data-bloom-header]"); + if (!bloomHeader) return; + + const rebloomIndicator = document.createElement('div'); + rebloomIndicator.className = 'rebloom-indicator'; + rebloomIndicator.innerHTML = ` + Rebloomed by ${bloom.sender} (Originally from + @${bloom.original_sender}) + `; + + bloomHeader.insertBefore(rebloomIndicator, bloomHeader.firstChild); +} + +/** + * Handle rebloom button click + */ +async function _handleRebloom(bloomId, button) { + const token = localStorage.getItem('token'); + if (!token) { + window.location.hash = '/login'; + return; + } + + button.disabled = true; try { - const date = new Date(timestamp); - const now = new Date(); - const diffSeconds = Math.floor((now - date) / 1000); + const data = await apiService.rebloomBloom(bloomId); - // Less than a minute - if (diffSeconds < 60) { - return `${diffSeconds}s`; - } + if (data.success) { + const countElement = button.querySelector('[data-rebloom-count]'); + if (countElement) countElement.textContent = data.rebloom_count || 0; - // Less than an hour - const diffMinutes = Math.floor(diffSeconds / 60); - if (diffMinutes < 60) { - return `${diffMinutes}m`; + button.classList.toggle('rebloomed', data.rebloomed); + } else { + throw new Error(data.message || 'Failed to rebloom'); } + } catch (err) { + console.error('Error reblooming bloom:', err); + alert('Error reblooming bloom: ' + err.message); + } finally { + button.disabled = false; + } +} - // Less than a day - const diffHours = Math.floor(diffMinutes / 60); - if (diffHours < 24) { - return `${diffHours}h`; - } +/** + * Check if user has rebloomed this bloom + */ +async function _checkRebloomStatus(bloomId, button) { + const token = localStorage.getItem('token'); + if (!token) return; + + try { + const data = await apiService.checkRebloomStatus(bloomId); - // Less than a week - const diffDays = Math.floor(diffHours / 24); - if (diffDays < 7) { - return `${diffDays}d`; + if (data.rebloomed) { + button.classList.add('rebloomed'); } - // Format as month and day for older dates - return new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - }).format(date); + const countElement = button.querySelector('[data-rebloom-count]'); + if (countElement) countElement.textContent = data.rebloom_count || 0; } catch (error) { - console.error("Failed to format timestamp:", error); - return ""; + console.error('Error checking rebloom status:', error); } } -export {createBloom}; +function _formatHashtags(text) { + if (!text) return text; + return text.replace( + /\B#[^#]+/g, + (match) => `${match}` + ); +} + +function _formatTimestamp(timestamp) { + if (!timestamp) return ""; + const date = new Date(timestamp); + const now = new Date(); + const diffSeconds = Math.floor((now - date) / 1000); + + if (diffSeconds < 60) return `${diffSeconds}s`; + const diffMinutes = Math.floor(diffSeconds / 60); + if (diffMinutes < 60) return `${diffMinutes}m`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays}d`; + + return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }).format(date); +} + +export { createBloom }; diff --git a/front-end/components/login.mjs b/front-end/components/login.mjs index 165b16a..82b6f4a 100644 --- a/front-end/components/login.mjs +++ b/front-end/components/login.mjs @@ -29,14 +29,25 @@ async function handleLogin(event) { const username = formData.get("username"); const password = formData.get("password"); - await apiService.login(username, password); + // Perform login + const data = await apiService.login(username, password); + + if (data.success && data.token) { + // ✅ Store JWT for rebloom and other API calls + localStorage.setItem('token', data.token); + } else { + alert('Login failed: ' + data.message); + } + } catch (error) { - throw error; + console.error('Login error:', error); + alert('Login error'); } finally { - // Always reset UI state regardless of success/failure + // Always reset UI state submitButton.textContent = originalText; form.inert = false; } } + export {createLogin, handleLogin}; diff --git a/front-end/index.css b/front-end/index.css index 65c7fb4..9e6cc11 100644 --- a/front-end/index.css +++ b/front-end/index.css @@ -267,3 +267,83 @@ dialog { [hidden] { display: none !important; } + +/* rebloom.css */ +.rebloom-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: none; + background: transparent; + border-radius: 16px; + cursor: pointer; + transition: all 0.2s ease; + color: #6B7280; + font-size: 14px; + font-weight: 500; +} + +.rebloom-btn:hover { + background-color: rgba(16, 185, 129, 0.1); + color: #10B981; +} + +.rebloom-btn.rebloomed { + color: #10B981; +} + +.rebloom-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.rebloom-btn svg { + transition: all 0.2s ease; + stroke-width: 2; +} + +.rebloom-btn.rebloomed svg { + stroke: #10B981; +} + +.rebloom-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background-color: #F0FDF4; + border-radius: 6px; + margin-bottom: 8px; + font-size: 13px; + color: #065F46; + border: 1px solid #BBF7D0; +} + +.rebloom-indicator svg { + color: #10B981; +} + +.rebloom-indicator .original-bloom { + margin-left: auto; + font-size: 12px; + color: #047857; +} + +.rebloom-indicator .original-bloom a { + color: #065F46; + text-decoration: underline; +} + +.rebloom-indicator .original-bloom a:hover { + color: #064E3B; +} + +/* Bloom actions container */ +.bloom__actions { + display: flex; + gap: 12px; + padding: 8px 0; + border-top: 1px solid #E5E7EB; + margin-top: 12px; +} \ No newline at end of file diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..e263cbc 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -1,261 +1,254 @@ - - - - Purple Forest - - - -
-

- Purple Forest - PurpleForest -

+ + + Purple Forest + + + +
+ +

+ Purple Forest + PurpleForest +

+
-
- -
- -
-
- -
-
-
-
-
-

This Legacy Code project is coursework from Code Your Future

-
-
- - - - + + + + + + + + diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..5d28867 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -212,6 +212,50 @@ async function postBloom(content) { } } +// ===== REBLOOM methods +async function rebloomBloom(bloomId) { + try { + console.log('Making rebloom request for bloom:', bloomId); + const data = await _apiRequest(`/api/blooms/${bloomId}/rebloom`, { + method: "POST", + body: JSON.stringify({}), // Empty body since we're using URL params + }); + + console.log('Rebloom response:', data); + + if (data.success) { + // Refresh the timeline to show the updated rebloom count + await getBlooms(); + } + + return data; + } catch (error) { + console.error('Rebloom error:', error); + // Error already handled by _apiRequest + return {success: false}; + } +} + +async function checkRebloomStatus(bloomId) { + try { + const data = await _apiRequest(`/api/blooms/${bloomId}/rebloom-status`); + return data; + } catch (error) { + // Error already handled by _apiRequest + return {rebloomed: false, rebloom_count: 0}; + } +} + +async function getUserReblooms() { + try { + const reblooms = await _apiRequest("/api/user/reblooms"); + return reblooms; + } catch (error) { + // Error already handled by _apiRequest + return []; + } +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -242,7 +286,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,29 +299,8 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; - } -} - -async function unfollowUser(username) { - try { - const data = await _apiRequest(`/unfollow/${username}`, { - method: "POST", - }); - - if (data.success) { - // Update both the unfollowed user's profile and the current user's profile - await Promise.all([ - getProfile(username), - getProfile(state.currentUser), - getBlooms(), - ]); - } - - return data; - } catch (error) { - // Error already handled by _apiRequest - return {success: false}; + console.error("Error following user:", error); + return { success: false }; } } @@ -293,10 +316,15 @@ const apiService = { postBloom, getBloomsByHashtag, + // Rebloom methods + rebloomBloom, + checkRebloomStatus, + getUserReblooms, + // User methods getProfile, followUser, - unfollowUser, + // unfollowUser, getWhoToFollow, }; diff --git a/front-end/views/bloom.mjs b/front-end/views/bloom.mjs index 181add6..4edd2f6 100644 --- a/front-end/views/bloom.mjs +++ b/front-end/views/bloom.mjs @@ -1,4 +1,4 @@ -import {renderEach, renderOne, destroy} from "../lib/render.mjs"; +import { renderEach, renderOne, destroy } from "../lib/render.mjs"; import { apiService, getLoginContainer, @@ -6,11 +6,10 @@ import { getTimelineContainer, state, } from "../index.mjs"; -import {createBloom} from "../components/bloom.mjs"; -import {createLogin, handleLogin} from "../components/login.mjs"; -import {createLogout, handleLogout} from "../components/logout.mjs"; +import { createBloom } from "../components/bloom.mjs"; +import { createLogin, handleLogin } from "../components/login.mjs"; +import { createLogout, handleLogout } from "../components/logout.mjs"; -// Bloom view - just a single bloom function bloomView(bloomId) { destroy(); @@ -30,6 +29,7 @@ function bloomView(bloomId) { document .querySelector("[data-action='logout']") ?.addEventListener("click", handleLogout); + renderOne( state.isLoggedIn, getLoginContainer(), @@ -48,4 +48,4 @@ function bloomView(bloomId) { ); } -export {bloomView}; +export { bloomView };