diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..c4789b2 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -13,9 +13,15 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + type:str= "bloom" + original_bloom_id:Optional[int] = None + rebloomed_by: Optional[User] = None + def add_bloom(*, sender: User, content: str) -> Bloom: + if len(content) > 280: + raise ValueError("Bloom content must not exceed 280 character limit.") hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] now = datetime.datetime.now(tz=datetime.UTC) @@ -37,13 +43,13 @@ def add_bloom(*, sender: User, content: str) -> Bloom: ) + def get_blooms_for_user( username: str, *, before: Optional[int] = None, limit: Optional[int] = None ) -> List[Bloom]: with db_cursor() as cur: - kwargs = { - "sender_username": username, - } + kwargs = {"sender_username": username} + if before is not None: before_clause = "AND send_timestamp < %(before_limit)s" kwargs["before_limit"] = before @@ -53,48 +59,59 @@ 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 - WHERE - username = %(sender_username)s - {before_clause} + f""" + SELECT + blooms.id, + users.username, + blooms.content, + blooms.send_timestamp, + blooms.type, + blooms.original_bloom_id + FROM blooms + INNER JOIN users ON users.id = blooms.sender_id + WHERE username = %(sender_username)s + {before_clause} ORDER BY send_timestamp DESC {limit_clause} """, kwargs, ) + rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row + bloom_id, sender_username, content, timestamp, type_, original_id = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + type=type_, + original_bloom_id=original_id, ) ) return blooms + 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, type, 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 + bloom_id, sender_username, content, timestamp, type_, original_id = row return Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + type=type_, + original_bloom_id=original_id, ) @@ -140,3 +157,32 @@ def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: else: limit_clause = "" return limit_clause + +def add_rebloom(*, sender:User, original_bloom_id: int)-> Bloom: + now = datetime.datetime.now(tz=datetime.UTC) + rebloom_id= int(now.timestamp()* 1000000) + with db_cursor() as cur: + # Insert rebloom + cur.execute( + """INSERT INTO blooms (id, sender_id, content, send_timestamp, type, original_bloom_id) + VALUES (%(rebloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, 'rebloom', %(original_bloom_id)s)""", + dict( + rebloom_id=rebloom_id, + sender_id=sender.id, + content="", # rebloom doesn’t need its own text + timestamp=now, + original_bloom_id=original_bloom_id, + ), + ) + return get_bloom(rebloom_id) + +def bloom_to_dict(bloom: Bloom) -> Dict[str, Any]: + return { + "id": bloom.id, + "sender": bloom.sender if isinstance(bloom.sender, str) else bloom.sender.username, + "content": bloom.content, + "sent_timestamp": bloom.sent_timestamp.isoformat(), + "type": bloom.type, + "original_bloom_id": bloom.original_bloom_id, + } + diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..39b9490 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -175,7 +175,7 @@ def get_bloom(id_str): bloom = blooms.get_bloom(id_int) if bloom is None: return make_response((f"Bloom not found", 404)) - return jsonify(bloom) + return jsonify(blooms.bloom_to_dict(bloom)) @jwt_required() @@ -245,3 +245,25 @@ def verify_request_fields(names_to_types: Dict[str, type]) -> Union[Response, No ) ) return None + +@jwt_required() +def rebloom(id_str): + try: + original_id = int(id_str) + except ValueError: + return make_response(("Invalid bloom id", 400)) + + user = get_current_user() + + # Check original bloom exists + original = blooms.get_bloom(original_id) + if original is None: + return make_response(("Original bloom not found", 404)) + + new_rebloom = blooms.add_rebloom(sender=user, original_bloom_id=original_id) + + return jsonify({ + "success": True, + "rebloom_id": new_rebloom.id, + "original_bloom_id": original_id + }) diff --git a/backend/main.py b/backend/main.py index 7ba155f..1bacc4b 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 @@ -59,6 +60,8 @@ 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=rebloom) + app.add_url_rule("/hashtag/", view_func=hashtag) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/front-end/components/bloom-form.mjs b/front-end/components/bloom-form.mjs index e047f9a..6a71b98 100644 --- a/front-end/components/bloom-form.mjs +++ b/front-end/components/bloom-form.mjs @@ -1,4 +1,4 @@ -import {apiService} from "../index.mjs"; +import { apiService } from "../index.mjs"; /** * Create a bloom form component @@ -26,6 +26,11 @@ async function handleBloomSubmit(event) { const originalText = submitButton.textContent; const textarea = form.querySelector("textarea"); const content = textarea.value.trim(); + const charMaxLength = 280; + if (content.length > charMaxLength) { + alert(`Bloom content must be 280 characters or less.`); + return; + } try { // Make form inert while we call the back end @@ -55,4 +60,4 @@ function handleTyping(event) { counter.textContent = `${textarea.value.length} / ${maxLength}`; } -export {createBloomForm, handleBloomSubmit, handleTyping}; +export { createBloomForm, handleBloomSubmit, handleTyping }; diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c..8a8ecc0 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 @@ -26,19 +27,76 @@ const createBloom = (template, bloom) => { bloomUsername.textContent = bloom.sender; bloomTime.textContent = _formatTimestamp(bloom.sent_timestamp); bloomTimeLink.setAttribute("href", `/bloom/${bloom.id}`); - bloomContent.replaceChildren( - ...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html") - .body.childNodes - ); + + if (bloom.type === "rebloom" && bloom.original_bloom_id) { + if (!window._bloomCache) window._bloomCache = {}; + + const cached = window._bloomCache[bloom.original_bloom_id]; + if (cached) { + bloomContent.innerHTML = ` + Reblooomed @${cached.sender}
+ ${cached.content} + `; + } else { + apiService + .getBloom(bloom.original_bloom_id) + .then((original) => { + window._bloomCache[bloom.original_bloom_id] = original; + bloomContent.innerHTML = ` + Reblooomed @${original.sender}
+ ${original.content} + `; + }) + .catch(() => { + bloomContent.textContent = " Rebloom (original not found)"; + }); + } + } else { + bloomContent.replaceChildren( + ...bloomParser.parseFromString( + _formatHashtags(bloom.content), + "text/html" + ).body.childNodes + ); + } + + // --- Add a Rebloom button --- + const rebloomButton = document.createElement("button"); + rebloomButton.textContent = " Rebloom"; + rebloomButton.classList.add("rebloom-button"); + rebloomButton.addEventListener("click", async () => { + try { + rebloomButton.disabled = true; + rebloomButton.textContent = "Reblooming..."; + const result = await apiService.rebloomBloom(bloom.id); + + if (result.success) { + rebloomButton.textContent = "Rebloomed!"; + } else { + alert("Rebloom failed"); + rebloomButton.textContent = " Rebloom"; + } + } catch (err) { + console.error(err); + alert("Failed to rebloom."); + rebloomButton.textContent = " Rebloom"; + } finally { + rebloomButton.disabled = false; + } + }); + + // Add the button to the bloom card (e.g., at the bottom) + bloomArticle.appendChild(rebloomButton); return bloomFrag; }; function _formatHashtags(text) { if (!text) return text; + // special character in hashtag convert into url friendly format return text.replace( - /\B#[^#]+/g, - (match) => `${match}` + /\B#(\w+)/g, + (match, tag) => `${match}` ); } @@ -84,4 +142,4 @@ function _formatTimestamp(timestamp) { } } -export {createBloom}; +export { createBloom }; diff --git a/front-end/index.css b/front-end/index.css index 65c7fb4..c0bf081 100644 --- a/front-end/index.css +++ b/front-end/index.css @@ -254,6 +254,35 @@ dialog { color: var(--error); } +/* REBLOOM BUTTON */ +.rebloom-button { + font: 600 90% monospace, system-ui; + background-color: var(--paper); + color: var(--brand); + border: 0.5px solid var(--brand); + border-radius: var(--pill); + box-shadow: 2px 3px var(--brand); + padding: calc(var(--space) / 4) calc(var(--space)); + cursor: pointer; + transition: all 0.3s ease; + margin-top: calc(var(--space) / 3); + align-self: start; +} + +.rebloom-button:hover, +.rebloom-button:focus { + background-color: var(--brand); + color: var(--paper); + box-shadow: 0 0 var(--accent); + border-color: var(--brand); +} + +.rebloom-button:disabled { + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; +} + @keyframes fade { from { opacity: 0%; diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..4b060ab 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 { @@ -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) { @@ -91,12 +91,13 @@ async function login(username, password) { currentUser: username, isLoggedIn: true, }); + window.location.hash = "/"; // ensures hash router loads home view await Promise.all([getBlooms(), getProfile(username), getWhoToFollow()]); } return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -104,12 +105,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 +119,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 +133,21 @@ async function signup(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } function logout() { state.destroyState(); - return {success: true}; + window.location.hash = "/"; // redirect to home page after logout + 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 +158,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 +191,7 @@ async function getBloomsByHashtag(hashtag) { return blooms; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -197,7 +199,7 @@ async function postBloom(content) { try { const data = await _apiRequest("/bloom", { method: "POST", - body: JSON.stringify({content}), + body: JSON.stringify({ content }), }); if (data.success) { @@ -208,7 +210,7 @@ async function postBloom(content) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -225,16 +227,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 +244,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 +257,7 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -277,7 +279,23 @@ async function unfollowUser(username) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; + } +} + +async function rebloomBloom(originalBloomId) { + try { + const data = await _apiRequest(`/rebloom/${originalBloomId}`, { + method: "POST", + }); + + if (data.success) { + await getProfile(state.currentUser); + } + + return data; + } catch (error) { + return { success: false }; } } @@ -292,6 +310,7 @@ const apiService = { getBlooms, postBloom, getBloomsByHashtag, + rebloomBloom, // User methods getProfile, @@ -300,4 +319,4 @@ const apiService = { getWhoToFollow, }; -export {apiService}; +export { apiService }; diff --git a/front-end/views/hashtag.mjs b/front-end/views/hashtag.mjs index 7b7e996..181b8e7 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,17 +7,19 @@ 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"; +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) { + const formattedHashtag = hashtag.startsWith("#") ? hashtag : `#${hashtag}`; destroy(); - - apiService.getBloomsByHashtag(hashtag); + if (formattedHashtag !== state.currentHashtag) { + apiService.getBloomsByHashtag(hashtag); + } renderOne( state.isLoggedIn, @@ -52,4 +54,4 @@ function hashtagView(hashtag) { ); } -export {hashtagView}; +export { hashtagView };