diff --git a/frontend/src/components/Auth/PasswordResetForm.tsx b/frontend/src/components/Auth/PasswordResetForm.tsx index 4beee3a..5e1f36a 100644 --- a/frontend/src/components/Auth/PasswordResetForm.tsx +++ b/frontend/src/components/Auth/PasswordResetForm.tsx @@ -48,7 +48,9 @@ const PasswordResetForm: React.FC = ({ onSubmit, token }) => { newErrors[key as keyof ResetPasswordErrors] = "Confirm your password"; } }); - + if (resetData.newPassword.length < 8) { + newErrors.newPasswordError = "Password must be at least 8 characters long"; + } if (resetData.newPassword !== resetData.confirmNewPassword) { newErrors.confirmNewPasswordError = "Passwords do not match"; } @@ -76,6 +78,8 @@ const PasswordResetForm: React.FC = ({ onSubmit, token }) => { if (!response.ok) { const data = await response.json(); throw new Error(data.message || "An error has occurred while resetting"); + } else { + confirmPasswordReset(); } } catch (error: any) { console.error("Password reset error:", error.message); @@ -84,7 +88,6 @@ const PasswordResetForm: React.FC = ({ onSubmit, token }) => { general: error.message || "An unexpected error occurred.", })); - confirmPasswordReset(); } } }; diff --git a/frontend/src/components/Layout/ListRow.tsx b/frontend/src/components/Layout/ListRow.tsx index 3c50446..8a227ef 100644 --- a/frontend/src/components/Layout/ListRow.tsx +++ b/frontend/src/components/Layout/ListRow.tsx @@ -15,6 +15,7 @@ interface ListRowProps { title: string; description: string; items: ListItemProps[]; + extraClasses?: string; onClick: (itemId: number, itemName: string) => void; } @@ -43,10 +44,10 @@ const ListItem: React.FC = ({
)}
-
+

{title}

- {type === "stream" &&

{streamer}

} -

{viewers} viewers

+ {type === "stream" &&

{streamer}

} +

{viewers} viewers

); @@ -58,12 +59,13 @@ const ListRow: React.FC = ({ description, items, onClick, + extraClasses="", }) => { return ( -
+
-

{title}

-

{description}

+

{title}

+

{description}

{items.map((item) => ( diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index c2b6dfc..3ba9e2f 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -43,6 +43,7 @@ const HomePage: React.FC = ({ variant = "default" }) => { } items={featuredStreams} onClick={handleStreamClick} + extraClasses="border border-gray-700 bg-[#FF7F50]/80" /> {/* If Personalised_HomePage, display Categories the logged-in user follows. Else, trending categories. */} @@ -60,6 +61,8 @@ const HomePage: React.FC = ({ variant = "default" }) => { } items={featuredCategories} onClick={handleCategoryClick} + extraClasses="border border-gray-700 bg-[#5AFF75]/80" + />
); diff --git a/web_server/blueprints/search_bar.py b/web_server/blueprints/search_bar.py new file mode 100644 index 0000000..1ec5272 --- /dev/null +++ b/web_server/blueprints/search_bar.py @@ -0,0 +1,99 @@ +from flask import Blueprint, jsonify +from database.database import Database + +search_bp = Blueprint("search", __name__) + +@search_bp.route("/search/", methods=["GET", "POST"]) +def search_results(query: str): + """ + Return the most similar search results + + This is the main route that displays a subsection of each search topic + """ + # Create the connection to the database + db = Database() + db.create_connection() + + # Get the most accurate search results + # 3 categories + categories = db.fetchall(""" + SELECT bm25(category_fts), rank, f.category_id, f.category_name + FROM categories AS c + INNER JOIN category_fts AS f ON c.category_id = f.category_id + WHERE category_fts MATCH ? + LIMIT 3; + """, (query,)) + + # 3 users + users = db.fetchall(""" + SELECT bm25(user_fts), rank, f.user_id, f.username, f.is_live + FROM users u + INNER JOIN user_fts f ON u.user_id = f.user_id + WHERE user_fts MATCH ? + LIMIT 3; + """, (query,)) + + # 3 streams + streams = db.fetchall(""" + SELECT bm25(stream_fts), rank, f.user_id, f.title, f.num_viewers, f.category_id + FROM streams s + INNER JOIN stream_fts f ON s.user_id = f.user_id + WHERE stream_fts MATCH ? + LIMIT 3; + """, (query,)) + + db.close_connection() + + return jsonify({"categories": categories, "users": users, "streams": streams}) + +@search_bp.route("/search/categories/", methods=["GET", "POST"]) +def search_categories(query: str): + # Create the connection to the database + db = Database() + db.create_connection() + + categories = db.fetchall(""" + SELECT bm25(category_fts), rank, f.category_id, f.category_name + FROM categories AS c + INNER JOIN category_fts AS f ON c.category_id = f.category_id + WHERE category_fts MATCH ?; + """, (query,)) + + db.close_connection() + + return jsonify({"categories": categories}) + +@search_bp.route("/search/users/", methods=["GET", "POST"]) +def search_users(query: str): + # Create the connection to the database + db = Database() + db.create_connection() + + users = db.fetchall(""" + SELECT bm25(user_fts), rank, f.user_id, f.username, f.is_live + FROM users u + INNER JOIN user_fts f ON u.user_id = f.user_id + WHERE user_fts MATCH ?; + """, (query,)) + + db.close_connection() + + return jsonify({"users": users}) + + +@search_bp.route("/search/streams/", methods=["GET", "POST"]) +def search_streams(query: str): + # Create the connection to the database + db = Database() + db.create_connection() + + streams = db.fetchall(""" + SELECT bm25(stream_fts), rank, f.user_id, f.title, f.num_viewers, f.category_id + FROM streams s + INNER JOIN stream_fts f ON s.user_id = f.user_id + WHERE stream_fts MATCH ?; + """, (query,)) + + db.close_connection() + + return jsonify({"streams": streams}) \ No newline at end of file diff --git a/web_server/blueprints/templates/chat.html b/web_server/blueprints/templates/chat.html deleted file mode 100644 index 3629a14..0000000 --- a/web_server/blueprints/templates/chat.html +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - Chat Interface - - - - - - -

Chat for Stream #{{ stream_id }}

-
- - - - diff --git a/web_server/blueprints/templates/video.html b/web_server/blueprints/templates/video.html deleted file mode 100644 index 093fc5d..0000000 --- a/web_server/blueprints/templates/video.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/web_server/blueprints/user.py b/web_server/blueprints/user.py index 775ce86..6cd41c5 100644 --- a/web_server/blueprints/user.py +++ b/web_server/blueprints/user.py @@ -56,23 +56,28 @@ def user_following(user_id: int, followed_id: int): @login_required @user_bp.route('/user/follow/') -def follow(username): +def follow_user(username): """ Follows a user """ user_id = session.get("user_id") following_id = get_user_id(username) - follow(user_id, following_id) + if follow(user_id, following_id): + return jsonify({"success": True, + "already_following": False}) + return jsonify({"success": True, + "already_following": True}) @login_required @user_bp.route('/user/unfollow/') -def user_unfollow(followed_username): +def unfollow_user(username): """ Unfollows a user """ user_id = session.get("user_id") - followed_id = get_user_id(followed_username) + followed_id = get_user_id(username) unfollow(user_id, followed_id) + return jsonify({"success": True}) @login_required @user_bp.route('/user/following') @@ -92,7 +97,10 @@ def get_login_status(): Returns whether the user is logged in or not """ username = session.get("username") - return jsonify({'status': username is not None, 'username': username}) + user_id = session.get("user_id") + return jsonify({'status': username is not None, + 'username': username, + 'user_id': user_id}) @user_bp.route('/user/forgot_password/', methods=['GET','POST']) def user_forgot_password(email): diff --git a/web_server/database/app.db b/web_server/database/app.db index df6d35b..a868362 100644 Binary files a/web_server/database/app.db and b/web_server/database/app.db differ diff --git a/web_server/database/testing_data.sql b/web_server/database/testing_data.sql index bd015ed..212b889 100644 --- a/web_server/database/testing_data.sql +++ b/web_server/database/testing_data.sql @@ -124,9 +124,7 @@ LIMIT 10; INSERT INTO follows (user_id, followed_id, since) VALUES (7, 1, '2024-08-30'), (7, 2, '2024-08-30'), -(7, 3, '2024-08-30'), -(7, 4, '2024-08-30'), -(7, 5, '2024-08-30'); +(7, 3, '2024-08-30'); INSERT INTO followed_categories (user_id, category_id) VALUES (7, 1), @@ -134,5 +132,5 @@ INSERT INTO followed_categories (user_id, category_id) VALUES (7, 3); INSERT INTO subscribes (user_id, subscribed_id, since, expires) VALUES -(7, 1, '2024-08-30', '2025-02-28'), +(7, 1, '2024-08-30', '2025-02-28 12:00:00'), (7, 2, '2024-08-30', '2025-02-15'); \ No newline at end of file diff --git a/web_server/database/text_search.sql b/web_server/database/text_search.sql new file mode 100644 index 0000000..569684d --- /dev/null +++ b/web_server/database/text_search.sql @@ -0,0 +1,114 @@ +/* Full text search queries for categories */ +DROP TABLE IF EXISTS category_fts; +CREATE VIRTUAL TABLE category_fts +USING fts5 (category_id, category_name); + +INSERT INTO category_fts (category_id, category_name) +SELECT category_id, category_name +FROM categories; + +-- Triggers that inserts new titles into category_fts +DROP TRIGGER IF EXISTS insert_category_fts; +CREATE TRIGGER insert_category_fts +AFTER INSERT ON categories +BEGIN + INSERT INTO category_fts(category_id, category_name) + VALUES (NEW.category_id, NEW.category_name); +END; + +DROP TRIGGER IF EXISTS update_category_fts; +CREATE TRIGGER update_category_fts +AFTER UPDATE ON categories +BEGIN + UPDATE category_fts + SET + category_id = NEW.category_id, + category_name = NEW.category_name + WHERE category_id = NEW.category_id; +END; + +DROP TRIGGER IF EXISTS delete_category_fts; +CREATE TRIGGER delete_category_fts +AFTER DELETE ON categories +BEGIN + DELETE FROM category_fts + WHERE category_id = OLD.category_id; +END; + +/* Full text search queries for users */ +DROP TABLE IF EXISTS user_fts; +CREATE VIRTUAL TABLE user_fts +USING fts5 (user_id, username, is_live); + +INSERT INTO user_fts (user_id, username, is_live) +SELECT user_id, username, is_live +FROM users; + +-- Triggers that inserts new titles into user_fts +DROP TRIGGER IF EXISTS insert_user_fts; +CREATE TRIGGER insert_user_fts +AFTER INSERT ON users +BEGIN + INSERT INTO user_fts(user_id, username, is_live) + VALUES (NEW.user_id, NEW.username, NEW.is_live); +END; + +DROP TRIGGER IF EXISTS update_user_fts; +CREATE TRIGGER update_user_fts +AFTER UPDATE ON users +BEGIN + UPDATE user_fts + SET + user_id = NEW.user_id, + username = NEW.username, + is_live = NEW.is_live + WHERE user_id = NEW.user_id; +END; + +DROP TRIGGER IF EXISTS delete_user_fts; +CREATE TRIGGER delete_user_fts +AFTER DELETE ON users +BEGIN + DELETE FROM user_fts + WHERE user_id = OLD.user_id; +END; + + +/* Full text search queries for users */ +DROP TABLE IF EXISTS stream_fts; +CREATE VIRTUAL TABLE stream_fts +USING fts5 (user_id, title, num_viewers, category_id); + +INSERT INTO stream_fts (user_id, title, num_viewers, category_id) +SELECT user_id, title, num_viewers, category_id +FROM streams; + +-- Triggers that inserts new titles into stream_fts +DROP TRIGGER IF EXISTS insert_stream_fts; +CREATE TRIGGER insert_stream_fts +AFTER INSERT ON streams +BEGIN + INSERT INTO stream_fts(user_id, title, num_viewers, category_id) + VALUES (NEW.user_id, NEW.title, NEW.num_viewers, NEW.category_id); +END; + +DROP TRIGGER IF EXISTS update_stream_fts; +CREATE TRIGGER update_stream_fts +AFTER UPDATE ON streams +BEGIN + UPDATE stream_fts + SET + user_id = NEW.user_id, + title = NEW.title, + num_viewers = NEW.num_viewers, + category_id = NEW.category_id + WHERE user_id = NEW.user_id; +END; + +DROP TRIGGER IF EXISTS delete_stream_fts; +CREATE TRIGGER delete_stream_fts +AFTER DELETE ON streams +BEGIN + DELETE FROM stream_fts + WHERE user_id = OLD.user_id; +END; \ No newline at end of file diff --git a/web_server/requirements.txt b/web_server/requirements.txt index 5cdc2ce..349268f 100644 --- a/web_server/requirements.txt +++ b/web_server/requirements.txt @@ -26,4 +26,5 @@ gevent>=22.10.2 gevent-websocket flask-oauthlib==0.9.6 celery==5.2.3 -redis==5.2.1 \ No newline at end of file +redis==5.2.1 +python-dateutil \ No newline at end of file diff --git a/web_server/utils/user_utils.py b/web_server/utils/user_utils.py index 00b7297..13d57f6 100644 --- a/web_server/utils/user_utils.py +++ b/web_server/utils/user_utils.py @@ -4,6 +4,7 @@ from datetime import datetime from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired from os import getenv from werkzeug.security import generate_password_hash, check_password_hash +from dateutil import parser from dotenv import load_dotenv load_dotenv() @@ -91,29 +92,28 @@ def follow(user_id: int, following_id: int): """ Follows following_id user from user_id user """ - with Database() as db: - data = db.execute(""" - SELECT * FROM follows - WHERE user_id = ? - AND followed_id = ? - """, (user_id, following_id)) - - if not data: + if not is_following(user_id, following_id): + with Database() as db: db.execute(""" INSERT INTO follows (user_id, followed_id) - VALUES(?,?) + VALUES(?,?); """, (user_id, following_id)) + return True + return False def unfollow(user_id: int, followed_id: int): """ Unfollows follow_id user from user_id user """ - with Database() as db: - db.execute(""" - DELETE FROM follows - WHERE user_id = ? - AND followed_id = ? - """, (user_id, followed_id)) + if is_following(user_id, followed_id): + with Database() as db: + db.execute(""" + DELETE FROM follows + WHERE user_id = ? + AND followed_id = ? + """, (user_id, followed_id)) + return True + return False def subscription_expiration(user_id: int, subscribed_id: int) -> int: @@ -131,7 +131,7 @@ def subscription_expiration(user_id: int, subscribed_id: int) -> int: if data: expiration_date = data["expires"] - remaining_time = (expiration_date - datetime.now()).seconds + remaining_time = (parser.parse(expiration_date) - datetime.now()).seconds return remaining_time return 0