diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..93773fc 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -1,8 +1,6 @@ import datetime - from dataclasses import dataclass from typing import Any, Dict, List, Optional - from data.connection import db_cursor from data.users import User @@ -13,21 +11,28 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + reblooms: int + original_bloom_id: int -def add_bloom(*, sender: User, content: str) -> Bloom: +def add_bloom( + *, sender: User, content: str, original_bloom_id: Optional[int] = None +) -> Bloom: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] now = datetime.datetime.now(tz=datetime.UTC) bloom_id = int(now.timestamp() * 1000000) + print(original_bloom_id) 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)", + "INSERT INTO blooms (id, sender_id, content, send_timestamp, reblooms, original_bloom_id) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(reblooms)s,%(original_bloom_id)s)", dict( bloom_id=bloom_id, sender_id=sender.id, content=content, timestamp=datetime.datetime.now(datetime.UTC), + reblooms=0, + original_bloom_id=original_bloom_id, ), ) for hashtag in hashtags: @@ -54,7 +59,7 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, reblooms, original_bloom_id FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE @@ -68,13 +73,22 @@ 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, + reblooms, + original_bloom_id, + ) = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, + original_bloom_id=original_bloom_id, ) ) return blooms @@ -83,18 +97,20 @@ 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, content, send_timestamp, reblooms, 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, reblooms, original_bloom_id = row return Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, + original_bloom_id=original_bloom_id, ) @@ -108,7 +124,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, reblooms, original_bloom_id FROM blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id WHERE @@ -121,18 +137,44 @@ def get_blooms_with_hashtag( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row + ( + bloom_id, + sender_username, + content, + timestamp, + reblooms, + original_bloom_id, + ) = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, + original_bloom_id=original_bloom_id, ) ) return blooms +def update_rebloom_counter(bloom_id: int) -> None: + with db_cursor() as cur: + cur.execute( + "UPDATE blooms SET reblooms = reblooms + 1 WHERE blooms.id = %s", + (bloom_id,), + ) + + +def add_rebloom(*, sender: User, id: int) -> None: + original_bloom = get_bloom(id) + if not original_bloom: + return None + content = original_bloom.content + update_rebloom_counter(id) + add_bloom(sender=sender, content=content, original_bloom_id=id) + + def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: if limit is not None: limit_clause = "LIMIT %(limit)s" diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..125362f 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -7,7 +7,6 @@ get_user, register_user, ) - from flask import Response, jsonify, make_response, request from flask_jwt_extended import ( create_access_token, @@ -212,6 +211,36 @@ def user_blooms(profile_username): return jsonify(user_blooms) +def update_rebloom_counter(bloom_id): + try: + id_int = int(bloom_id) + except ValueError: + return make_response((f"Invalid bloom id", 400)) + blooms.update_rebloom_counter(id_int) + return jsonify( + { + "success": True, + } + ) + + +@jwt_required() +def send_rebloom(): + user = get_current_user() + bloom_id = request.json["id"] + try: + id_int = int(bloom_id) + except ValueError: + return make_response((f"Invalid bloom id", 400)) + blooms.add_rebloom(sender=user, id=id_int) + + return jsonify( + { + "success": True, + } + ) + + @jwt_required() def suggested_follows(limit_str): try: diff --git a/backend/main.py b/backend/main.py index 7ba155f..071e8dd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,8 +14,9 @@ send_bloom, suggested_follows, user_blooms, + update_rebloom_counter, + send_rebloom, ) - from dotenv import load_dotenv from flask import Flask from flask_cors import CORS @@ -60,6 +61,7 @@ def main(): app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) + app.add_url_rule("/rebloom", methods=["POST"], view_func=send_rebloom) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/backend/requirements.txt b/backend/requirements.txt index e03836c..5e11b05 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 61e7580..4b743fb 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -10,7 +10,9 @@ 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, + reblooms INT NOT NULL DEFAULT 0, + original_bloom_id BIGINT REFERENCES blooms(id) ); CREATE TABLE follows ( diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c..c702a25 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,3 +1,5 @@ +import { apiService } from "../index.mjs"; + /** * Create a bloom component * @param {string} template - The ID of the template to clone @@ -7,7 +9,9 @@ * {"id": Number, * "sender": username, * "content": "string from textarea", - * "sent_timestamp": "datetime as ISO 8601 formatted string"} + * "sent_timestamp": "datetime as ISO 8601 formatted string"}, + * "reblooms": "reblooms count", + * "original_bloom_id": "id of the rebloomed post" */ const createBloom = (template, bloom) => { @@ -20,8 +24,12 @@ 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 rebloomButtonEl = bloomFrag.querySelector( + "[data-action='share-bloom']" + ); + const rebloomCountEl = bloomFrag.querySelector("[data-rebloom-count]"); + const rebloomInfoEl = bloomFrag.querySelector("[data-rebloom-info]"); - bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); bloomUsername.textContent = bloom.sender; bloomTime.textContent = _formatTimestamp(bloom.sent_timestamp); @@ -30,6 +38,23 @@ const createBloom = (template, bloom) => { ...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html") .body.childNodes ); + // redo to "bloom.reblooms || 0" once reblooms implemented to object + rebloomCountEl.textContent = `Rebloomed ${bloom.reblooms} times`; + rebloomCountEl.hidden = bloom.reblooms == 0; + rebloomButtonEl.setAttribute("data-id", bloom.id || ""); + rebloomButtonEl.addEventListener("click", handleRebloom); + rebloomInfoEl.hidden = bloom.original_bloom_id === null; + + if (bloom.original_bloom_id !== null) { + apiService + // I had to write another fetch, because getBloom update state, which is causing recursion if I use it here + .fetchBloomData(bloom.original_bloom_id) + .then((originalBloom) => { + const timeStamp = _formatTimestamp(originalBloom.sent_timestamp); + //I used inner html to render the arrow ↪ sign + rebloomInfoEl.innerHTML = `↪ Rebloom of the ${originalBloom.sender}'s post, posted ${timeStamp} ago`; + }); + } return bloomFrag; }; @@ -84,4 +109,11 @@ function _formatTimestamp(timestamp) { } } -export {createBloom}; +async function handleRebloom(event) { + const button = event.target; + const id = button.getAttribute("data-id"); + if (!id) return; + await apiService.postRebloom(id); +} + +export { createBloom, handleRebloom }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..c0b34fb 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -1,261 +1,204 @@ - - - - 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..e97f34a 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -1,16 +1,5 @@ -import {state} from "../index.mjs"; -import {handleErrorDialog} from "../components/error.mjs"; - -// === ABOUT THE STATE -// state gives you these two functions only -// updateState({stateKey: newValues}) -// destroyState() - -// All you can do in this file, please! -// 1. You can go to the back end and make requests for data -// 2. You can put the response data into state in the right place -// 3. You can handle your errors -// Don't touch any other part of the application with this file +import { state } from "../index.mjs"; +import { handleErrorDialog } from "../components/error.mjs"; // Helper function for making API requests async function _apiRequest(endpoint, options = {}) { @@ -20,13 +9,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,13 +43,13 @@ 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 handleErrorDialog(error); } - throw error; // Re-throw so it can be caught by the calling function + throw error; } } @@ -70,11 +59,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 +71,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 +85,7 @@ async function login(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -104,12 +93,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 +107,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 +121,27 @@ 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; +} + +//this function doesn't update state as getBloom does +async function fetchBloomData(bloomId) { + const endpoint = `/bloom/${bloomId}`; + const bloom = await _apiRequest(endpoint); return bloom; } @@ -156,18 +152,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 +185,7 @@ async function getBloomsByHashtag(hashtag) { return blooms; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -197,7 +193,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,10 +204,23 @@ async function postBloom(content) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } +async function postRebloom(originalId) { + try { + const data = await _apiRequest(`/rebloom`, { + method: "POST", + body: JSON.stringify({ id: originalId }), + }); + if (data.success) { + await getBlooms(); + await getProfile(state.currentUser); + } + } catch (error) {} +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -225,16 +234,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 +251,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 +264,7 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -277,7 +286,7 @@ async function unfollowUser(username) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -286,13 +295,13 @@ const apiService = { login, signup, logout, - // Bloom methods getBloom, + fetchBloomData, getBlooms, postBloom, getBloomsByHashtag, - + postRebloom, // User methods getProfile, followUser, @@ -300,4 +309,4 @@ const apiService = { getWhoToFollow, }; -export {apiService}; +export { apiService };