From 27dfb26dd47d223f634e3b9219eb5f44e88e7cf7 Mon Sep 17 00:00:00 2001 From: Chris Busse Date: Tue, 3 Jun 2025 13:28:39 -0400 Subject: [PATCH 01/10] refactor search module for postgres --- backend/.env.sample | 7 + backend/package.json | 4 +- .../search/controllers/search.controller.ts | 53 ++---- .../search/repository/search.repository.ts | 57 +++++++ .../modules/search/routes/search.routes.ts | 3 +- .../modules/search/services/search.service.ts | 157 ++++-------------- backend/src/modules/search/types/index.ts | 1 + .../src/modules/search/types/search.types.ts | 23 +++ backend/src/shared/config/environment.ts | 7 + .../src/shared/database/postgres.connector.ts | 18 ++ 10 files changed, 168 insertions(+), 162 deletions(-) create mode 100644 backend/src/modules/search/repository/search.repository.ts create mode 100644 backend/src/modules/search/types/index.ts create mode 100644 backend/src/modules/search/types/search.types.ts create mode 100644 backend/src/shared/database/postgres.connector.ts diff --git a/backend/.env.sample b/backend/.env.sample index 06016ce..c41b652 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -11,6 +11,13 @@ FIREBASE_MESSAGING_SENDER_ID=... FIREBASE_APP_ID=... FIREBASE_MEASUREMENT_ID=... +# Database Configuration Variables +DB_HOST=... +DB_PORT=... +DB_USER=... +DB_PASSWORD=... +DB_DATABASE=... + # OAuth GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... diff --git a/backend/package.json b/backend/package.json index 3e2815c..227fedb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,7 +22,9 @@ "@types/express": "^4.17.22", "@types/formidable": "^3.4.5", "@types/jest": "^29.5.14", + "@types/jsonwebtoken": "^9.0.7", "@types/ms": "^2.1.0", + "@types/pg": "^8.15.4", "eslint": "^9.13.0", "globals": "^15.11.0", "jest": "^29.7.0", @@ -38,7 +40,6 @@ "@aws-sdk/s3-request-presigner": "^3.816.0", "@google/generative-ai": "^0.24.1", "@langchain/core": "^0.3.57", - "@types/jsonwebtoken": "^9.0.7", "algoliasearch": "^4.24.0", "axios": "^1.9.0", "compression": "^1.8.0", @@ -52,6 +53,7 @@ "formidable": "^3.5.2", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", + "pg": "^8.16.0", "stripe": "^18.1.1", "winston": "^3.17.0", "zod": "^3.24.4" diff --git a/backend/src/modules/search/controllers/search.controller.ts b/backend/src/modules/search/controllers/search.controller.ts index 0a12b91..8623bdf 100644 --- a/backend/src/modules/search/controllers/search.controller.ts +++ b/backend/src/modules/search/controllers/search.controller.ts @@ -1,63 +1,40 @@ import { Request, Response } from "express"; import SearchService from "../services/search.service"; import { logger } from "../../../shared/utils/logger"; +import { searchType } from "../types"; export class SearchController { /** * @description - * Fetches 5 users at a time where their username matches or starts with the text - * provided to the search query. If a starting point is provided, the search query - * starts from the provided starting point. + * Searches for users and/or content that matches the provided search text. * * @param req - Express request object. * @param res - Express response object. */ - static async searchUsers(req: Request, res: Response) { + static async search(req: Request, res: Response) { const searchText = req.query.searchText as string; - const userStartingPoint = req.query.userStartingPoint as string; + const searchType = req.query.searchType as searchType; + const limit = parseInt(req.query.limit as string); + const offset = parseInt(req.query.offset as string); - logger.info(`Searching for users that match the following: ${searchText}`); + logger.info(`Searching for users and content that match the following: ${searchText}`); try { - const response = await SearchService.searchUsers( + const response = await SearchService.search( searchText, - userStartingPoint + searchType, + limit, + offset, ); res.status(200).json(response); } catch (error) { if (error instanceof Error) { - logger.error(`Error searching users: ${error.message}`); + logger.error(`Error searching for ${searchType}: ${error.message}`); } else { - logger.error(`Error searching users: ${String(error)}`); + logger.error(`Error searching for ${searchType}: ${error}`); } - res.status(500).json({ error: "Failed to search users" }); - } - } - - /** - * @description - * Fetches 5 content items at a time where their title matches or starts with the - * text provided to the search query. - * - * @param req - Express request object. - * @param res - Express response object. - */ - static async searchContents(req: Request, res: Response) { - const searchText = req.query.searchText as string; - logger.info( - `Searching for content that matches the following: ${searchText}` - ); - - try { - const response = await SearchService.searchContents(searchText); - res.status(200).json(response); - } catch (error) { - if (error instanceof Error) { - logger.error(`Error searching content: ${error.message}`); - } else { - logger.error(`Error searching content: ${String(error)}`); - } - res.status(500).json({ error: "Failed to search content" }); + res.status(500).json({error: "Failed to search users"}); } } } + diff --git a/backend/src/modules/search/repository/search.repository.ts b/backend/src/modules/search/repository/search.repository.ts new file mode 100644 index 0000000..23b9138 --- /dev/null +++ b/backend/src/modules/search/repository/search.repository.ts @@ -0,0 +1,57 @@ +import { logger } from "../../../shared/utils/logger"; +import { query } from '../../../shared/database/postgres.connector'; +import { User, Content } from '../types'; + +/** + * Searches the 'users' table for a username. + */ +export async function findUsers(searchText: string, limit: number, offset: number): Promise { + const sql = ` + SELECT + user_id, + username, + profile_image + FROM users + WHERE + username ILIKE $1 + ORDER BY username + LIMIT $2 OFFSET $3; + `; + const params = [`%${searchText}%`, limit, offset]; + + try { + const result = await query(sql, params); + return result.rows; + } catch (err) { + logger.error('Error executing user search query:', err); + throw new Error('Database query for users failed.'); + } +} + +/** + * Searches the 'content' table. + * It uses the 'summary' column for the preview and searches title and content body. + */ +export async function findContent(searchText: string, limit: number, offset: number): Promise { + const sql = ` + SELECT + content_id, + creator_id, + title, + COALESCE(summary, LEFT(content, 150)) as summary, + date_created + FROM content + WHERE title ILIKE $1 OR content ILIKE $1 + ORDER BY date_created DESC + LIMIT $2 OFFSET $3; + `; + const params = [`%${searchText}%`, limit, offset]; + + try { + const result = await query(sql, params); + return result.rows; + } catch (err) { + logger.error('Error executing content search query:', err); + throw new Error('Database query for content failed.'); + } +} \ No newline at end of file diff --git a/backend/src/modules/search/routes/search.routes.ts b/backend/src/modules/search/routes/search.routes.ts index 9d37719..83628c8 100644 --- a/backend/src/modules/search/routes/search.routes.ts +++ b/backend/src/modules/search/routes/search.routes.ts @@ -3,7 +3,6 @@ import { Router } from "express"; import { SearchController } from "../controllers/search.controller"; const searchRoutes = Router(); -searchRoutes.get("/users", SearchController.searchUsers); -searchRoutes.get("/contents", SearchController.searchContents); +searchRoutes.get("/", SearchController.search); export default searchRoutes; diff --git a/backend/src/modules/search/services/search.service.ts b/backend/src/modules/search/services/search.service.ts index 9ec9330..a239123 100644 --- a/backend/src/modules/search/services/search.service.ts +++ b/backend/src/modules/search/services/search.service.ts @@ -1,141 +1,56 @@ -import algoliasearch from "algoliasearch"; -import { db } from "../../../shared/config/firebase.config"; -import { - collection, - query, - getDocs, - where, - orderBy, - limit, - startAfter, - doc, - getDoc, -} from "firebase/firestore"; import { logger } from "../../../shared/utils/logger"; -import { User } from "../../user/models/user.model"; +import { SearchResponse, searchType, User, Content } from "../types"; +import * as searchRepository from '../repository/search.repository'; -export class SearchService { - private static algoliaClient: ReturnType | null = null; - private static readonly ALGOLIA_INDEX_NAME = process.env - .ALGOLIA_INDEX_NAME as string; - private static getAlgoliaClient() { - if (!SearchService.algoliaClient) { - const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID as string; - const ALGOLIA_ADMIN_KEY = process.env.ALGOLIA_API_KEY as string; - SearchService.algoliaClient = algoliasearch( - ALGOLIA_APP_ID, - ALGOLIA_ADMIN_KEY - ); - } - return SearchService.algoliaClient; - } +export class SearchService { /** - * searchUsers(searchText: string, startingPoint:string) + * search(searchText: string, searchType: searchType, limit: number, offset: number) * * @description - * Fetches 5 users at a time where their username matches or starts with the text provided to the search query. - * If a starting point is provided, the search query starts from the provided starting point. + * Searches for users and/or content by calling the data repository. + * It orchestrates calls for different search types. * - * @param searchText - The text to search for in the username. - * @param startingPoint - The starting point for the search query. - * @returns An array of users matching the search query. + * @param searchText - The text to search for. + * @param searchType - The type of search to perform (users, content, or all). + * @param limit - The maximum number of results to return per type. + * @param offset - The starting point of the search (used for pagination). */ - static async searchUsers( + static async search( searchText: string, - startingPoint: string | null = null - ) { - logger.info(`Searching for users that match the following: ${searchText}`); - const userRef = collection(db, "users"); - const limitNumber: number = 5; - - // Build the base query - const baseQuery = query( - userRef, - where("usernameLower", ">=", searchText.toLowerCase()), - where("usernameLower", "<=", searchText.toLowerCase() + "\uf8ff"), - orderBy("usernameLower"), - limit(limitNumber) - ); - - // If a starting point is provided, add it to the query - const finalQuery = startingPoint - ? query(baseQuery, startAfter(startingPoint)) - : baseQuery; - - // Execute the query - const results = await getDocs(finalQuery); - - const users = results.docs.map((doc) => doc.data() as User); - - // Determine the next starting point - const nextStartingPoint = - users.length >= limitNumber - ? results.docs[results.docs.length - 1]?.data().usernameLower - : null; - - logger.info(`Next starting point: ${nextStartingPoint}`); - return { users, nextStartingPoint }; - } - - /** - * searchContent(searchText: string, startingPoint:string) - * - * @description - * Fetches 5 items at a time where their titles match or start with the text provided to the search query. - * If a starting point is provided, the search query starts from the provided starting point. - * - * @param searchText - Text to search for - * @returns - Object containing the documents and next starting point - * @throws - Error if search fails, i.e if the search query fails - */ - static async searchContents(searchText: string) { - if (!searchText) { - return { documents: [], nextStartingPoint: null }; - } - - const client = SearchService.getAlgoliaClient(); - const index = client.initIndex(this.ALGOLIA_INDEX_NAME); - + searchType: searchType = "all", + limit: number = 5, + offset: number = 0, + ): Promise { try { - const { hits } = await index.search(searchText); + const promises = []; - logger.info( - `Algolia search results length: ${hits.length}, hits: ${JSON.stringify( - hits - )}` - ); + // Add promises to the array based on the searchType, + if (searchType === 'users' || searchType === 'all') { + promises.push(searchRepository.findUsers(searchText, limit, offset)); + } else { + promises.push(Promise.resolve([])); // Add empty promise to maintain structure + } - // Fetch corresponding Firebase content documents - const firebaseContents = await Promise.all( - hits.map(async (hit) => { - const docRef = doc(db, "contents", hit.objectID); - const docSnap = await getDoc(docRef); + if (searchType === 'content' || searchType === 'all') { + promises.push(searchRepository.findContent(searchText, limit, offset)); + } else { + promises.push(Promise.resolve([])); // Add empty promise to maintain structure + } - if (docSnap.exists()) { - return { - id: docSnap.id, - ...docSnap.data(), - searchRanking: hit._rankingInfo?.nbTypos ?? 0, - }; - } - return null; - }) - ); - - // Filter out any null values (in case some documents weren't found in Firebase) - const contents = firebaseContents.filter( - (content): content is NonNullable => content !== null - ); + // Execute all database queries in parallel for efficiency + const [users, content] = await Promise.all(promises) as [User[], Content[]]; return { - contents, - nextStartingPoint: null, // Placeholder for future pagination logic + users, + content, }; - } catch (err) { - logger.error(`Failed to search for contents, error: ${err}`); - throw new Error(`Failed to search for contents, error: ${err}`); + + } catch (error) { + logger.error(`Error in SearchService for ${searchType}: ${error}`); + // Re-throw the error to be caught by a global error handler or the controller + throw error; } } } diff --git a/backend/src/modules/search/types/index.ts b/backend/src/modules/search/types/index.ts new file mode 100644 index 0000000..85a1947 --- /dev/null +++ b/backend/src/modules/search/types/index.ts @@ -0,0 +1 @@ +export * from './search.types'; \ No newline at end of file diff --git a/backend/src/modules/search/types/search.types.ts b/backend/src/modules/search/types/search.types.ts new file mode 100644 index 0000000..2f6c902 --- /dev/null +++ b/backend/src/modules/search/types/search.types.ts @@ -0,0 +1,23 @@ +export type searchType = "users" | "content" | "all"; + +// User record from the database +export interface User { + user_id: string; + username: string; + profile_image?: string; +} + +// Content record from the database +export interface Content { + content_id: string; + creator_id: string; // Changed from author_id + title: string; + summary?: string; // Changed from body_preview + date_created: Date; // Changed from created_at +} + +// API response from the service +export interface SearchResponse { + users: User[]; + content: Content[]; +} \ No newline at end of file diff --git a/backend/src/shared/config/environment.ts b/backend/src/shared/config/environment.ts index 6935871..81a5c97 100644 --- a/backend/src/shared/config/environment.ts +++ b/backend/src/shared/config/environment.ts @@ -56,6 +56,13 @@ export const env = { measurementId: process.env.FIREBASE_MEASUREMENT_ID, databaseURL: process.env.FIREBASE_DATABASE_URL }, + db: { + user: process.env.DB_USER, + host: process.env.DB_HOST, + database: process.env.DB_DATABASE, + password: process.env.DB_PASSWORD, + port: process.env.DB_PORT, + }, app: { frontend: process.env.FRONTEND_URL, backend: process.env.BACKEND_URL, diff --git a/backend/src/shared/database/postgres.connector.ts b/backend/src/shared/database/postgres.connector.ts new file mode 100644 index 0000000..72f26e6 --- /dev/null +++ b/backend/src/shared/database/postgres.connector.ts @@ -0,0 +1,18 @@ +import { Pool } from 'pg'; +import { env } from '../config/environment'; // Assuming you have db config here + +// Use this to connect to the DB. The pool will maintain a pool of connections so that +// you don't have to keep creating new connections +const pool = new Pool({ + user: env.db.user, + host: env.db.host, + database: env.db.database, + password: env.db.password, + port: parseInt(env.db.port as string), +}); + +export function query(text: string, params?: any[]) : Promise { + return pool.query(text, params); +} + +export default pool; \ No newline at end of file From 4f442f20b5aa29753b427610c96af529a5c84563 Mon Sep 17 00:00:00 2001 From: Chris Busse Date: Tue, 3 Jun 2025 20:58:06 -0400 Subject: [PATCH 02/10] integrate backend into frontend --- backend/package.json | 2 +- .../search/repository/search.repository.ts | 22 +- .../src/modules/search/types/search.types.ts | 11 +- frontend/src/App.tsx | 4 + frontend/src/components/Navbar.tsx | 358 +++++++------- .../components/search/ContentSearchResult.tsx | 137 +++++- frontend/src/components/search/SearchBar.tsx | 193 ++++++++ .../components/search/SearchListResults.tsx | 209 -------- .../components/search/UserSearchResult.tsx | 26 +- frontend/src/models/SearchResult.ts | 26 + frontend/src/pages/search/SearchResults.tsx | 146 ++++++ frontend/src/services/SearchService.ts | 48 +- frontend/src/styles/search/search.scss | 445 ++++++++++++++++-- frontend/src/utils/colorUtils.ts | 35 ++ 14 files changed, 1181 insertions(+), 481 deletions(-) create mode 100644 frontend/src/components/search/SearchBar.tsx delete mode 100644 frontend/src/components/search/SearchListResults.tsx create mode 100644 frontend/src/models/SearchResult.ts create mode 100644 frontend/src/pages/search/SearchResults.tsx create mode 100644 frontend/src/utils/colorUtils.ts diff --git a/backend/package.json b/backend/package.json index 227fedb..ba587c5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -54,7 +54,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "pg": "^8.16.0", - "stripe": "^18.1.1", + "stripe": "^18.2.1", "winston": "^3.17.0", "zod": "^3.24.4" } diff --git a/backend/src/modules/search/repository/search.repository.ts b/backend/src/modules/search/repository/search.repository.ts index 23b9138..7199dfb 100644 --- a/backend/src/modules/search/repository/search.repository.ts +++ b/backend/src/modules/search/repository/search.repository.ts @@ -10,6 +10,8 @@ export async function findUsers(searchText: string, limit: number, offset: numbe SELECT user_id, username, + first_name, + last_name, profile_image FROM users WHERE @@ -35,14 +37,18 @@ export async function findUsers(searchText: string, limit: number, offset: numbe export async function findContent(searchText: string, limit: number, offset: number): Promise { const sql = ` SELECT - content_id, - creator_id, - title, - COALESCE(summary, LEFT(content, 150)) as summary, - date_created - FROM content - WHERE title ILIKE $1 OR content ILIKE $1 - ORDER BY date_created DESC + c.content_id, + u.username, + u.first_name, + u.last_name, + u.profile_image, + c.title, + COALESCE(c.summary, LEFT(c.content, 150)) as summary, + c.date_created + FROM content c + JOIN users u ON c.creator_id = u.user_id + WHERE c.title ILIKE $1 OR c.content ILIKE $1 + ORDER BY c.date_created DESC LIMIT $2 OFFSET $3; `; const params = [`%${searchText}%`, limit, offset]; diff --git a/backend/src/modules/search/types/search.types.ts b/backend/src/modules/search/types/search.types.ts index 2f6c902..23ec369 100644 --- a/backend/src/modules/search/types/search.types.ts +++ b/backend/src/modules/search/types/search.types.ts @@ -4,16 +4,21 @@ export type searchType = "users" | "content" | "all"; export interface User { user_id: string; username: string; + first_name: string; + last_name: string; profile_image?: string; } // Content record from the database export interface Content { content_id: string; - creator_id: string; // Changed from author_id + username: string; + first_name: string; + last_name: string; + profile_image?: string; title: string; - summary?: string; // Changed from body_preview - date_created: Date; // Changed from created_at + summary?: string; + date_created: Date; } // API response from the service diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5cdfb21..13a93dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import ContentView from "./pages/content/ContentView"; import ProDetails from "./pages/pro/ProDetails"; import ManageSubscription from "./pages/pro/ManageSubscription"; import SubscribePro from "./pages/pro/SubscribePro"; +import SearchResults from "./pages/search/SearchResults"; // STYLES import "./styles/authentication/authentication.scss"; @@ -98,6 +99,9 @@ export default function App() { } /> } /> + {/* SEARCH */} + } /> + {/* PRO */} } /> } /> diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 230923a..d3ca331 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,7 +1,5 @@ -import { BellIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { BellIcon } from "@heroicons/react/24/outline"; import { useEffect, useState } from "react"; -import { User } from "../models/User"; -import { Content } from "../models/Content"; import { Notification } from "../models/Notification"; import { useAuth } from "../hooks/useAuth"; import { useNavigate } from "react-router-dom"; @@ -9,8 +7,7 @@ import { useNavigate } from "react-router-dom"; import Cookies from "js-cookie"; import NotificationList from "./notification/NotificationList"; import { SubscriptionService } from "../services/SubscriptionService"; -import { SearchService } from "../services/SearchService"; -import SearchList from "./search/SearchListResults"; +import SearchBar from "./search/SearchBar"; import NotificationService from "../services/NotificationService"; export default function Navbar() { @@ -20,13 +17,6 @@ export default function Navbar() { const [isDarkMode, setIsDarkMode] = useState(false); const [showMenu, setShowMenu] = useState(false); const [showNotificationList, setShowNotificationList] = useState(false); - const [query, setQuery] = useState(""); - const [showSearchResults, setShowSearchResults] = useState(false); - - const [userSearchResults, setUserSearchResults] = useState([]); - const [contentSearchResults, setContentSearchResults] = useState( - [] - ); const [unreadCount, setUnreadCount] = useState(0); const [notifications, setNotifications] = useState([]); @@ -97,7 +87,6 @@ export default function Navbar() { setShowNotificationList(!showNotificationList); // 2 - Hide the search results and menu (Other menus) - setShowSearchResults(false); setShowMenu(false); // 3 Mark notifications as read if needed @@ -107,49 +96,24 @@ export default function Navbar() { } }; - /** - * handleSearch() -> void - * - * @description - * Uses the Next.js router to push the user to the search results page, - * with the user's input as a url query parameter. - */ - const handleSearch = async (e: { preventDefault: () => void }) => { - e.preventDefault(); - - // Validation - const trimmedQuery = query.trim(); - if (trimmedQuery === "") { - setShowSearchResults(false); - return; + const handleLogout = () => { + if (typeof window !== "undefined") { + localStorage.removeItem("title"); } + auth.logout(); + }; - // Search Queries - const userSearchResults = await SearchService.searchUsers(trimmedQuery); - const contentSearchResults = await SearchService.searchContents( - trimmedQuery - ); - - // Display Search Results - if (userSearchResults instanceof Error) { - console.error("Error fetching user search results:", userSearchResults); - } else { - setUserSearchResults(userSearchResults.users); + const markRead = async () => { + if (auth.user === null) { + return; } - if (contentSearchResults instanceof Error) { - console.error( - "Error fetching content search results:", - contentSearchResults - ); - } else { - setContentSearchResults(contentSearchResults.contents); - } + const response = await NotificationService.markAsRead(auth.user.uid); - // Update states - setShowSearchResults(true); - setShowMenu(false); - setShowNotificationList(false); + if (response instanceof Error) { + console.error("Error marking notification as read: ", response); + return; + } }; const fetchNotifications = async (): Promise => { @@ -201,36 +165,15 @@ export default function Navbar() { } }; - const handleLogout = () => { - if (typeof window !== "undefined") { - localStorage.removeItem("title"); - } - auth.logout(); - }; - - const markRead = async () => { - if (auth.user === null) { - return; - } - - const response = await NotificationService.markAsRead(auth.user.uid); - - if (response instanceof Error) { - console.error("Error marking notification as read: ", response); - return; - } - }; - // -------------------------------------- // -------------- Render ---------------- // -------------------------------------- return ( <> - {(showSearchResults || showMenu || showNotificationList) && ( + {(showMenu || showNotificationList) && (
{ - setShowSearchResults(false); setShowMenu(false); setShowNotificationList(false); }} @@ -250,29 +193,9 @@ export default function Navbar() { {/* Create New Content */} {auth.isAuthenticated ? ( <> -
- setQuery(e.target.value)} - placeholder='Search for something!' - /> - - {showSearchResults && ( -
- -
- )} -
+
+ +
+ + {/* User Info */} +
+
+ {auth.user && auth.user.profileImage ? ( + Profile Picture + ) : ( +
+

+ {auth.user?.username[0].toUpperCase() || ""} +

+
+ )} +
+
+

+ {auth.user?.firstName} {auth.user?.lastName} +

+

@{auth.user?.username}

+
+
+ + {/* Search Bar for Mobile */} +
+ +
+ + {/* Create New Content for Mobile */} + + + {/* Notifications for Mobile */} +
+
+ + {unreadCount > 0 && {unreadCount}} +
+
- {/* ALWAYS DISPLAYED */} - { - setShowMenu(false); - navigate(`/profile/${auth.user?.uid}`); - }} - > - View Profile - -
- { - setShowMenu(false); - navigate(`/profile/manage`); - }} - > - Manage Profile - -
- {!isProUser && ( - <> - { - setShowMenu(false); - navigate("/pro"); - }} + {/* Menu Items */} +
+ + +
{ + navigate("/settings"); + setShowMenu(false); + }} + > + + + + + +

Settings

+
+ +
{ + navigate("/subscription"); + setShowMenu(false); + }} + > + + + + +

+ {isProUser + ? "Manage Subscription" + : "Upgrade to Pro"} +

+
- {/* Create Content */} - +
{ + handleLogout(); + setShowMenu(false); + }} + > + + + + + + +

Logout

+
+
+ )} diff --git a/frontend/src/components/search/ContentSearchResult.tsx b/frontend/src/components/search/ContentSearchResult.tsx index bfe8c22..ac51763 100644 --- a/frontend/src/components/search/ContentSearchResult.tsx +++ b/frontend/src/components/search/ContentSearchResult.tsx @@ -1,8 +1,18 @@ import { useNavigate } from "react-router-dom"; -import { Content } from "../../models/Content"; +import { useState, useRef, useEffect } from "react"; +import { SearchContent } from "../../models/SearchResult"; +import { generateColorFromText } from "../../utils/colorUtils"; -export default function ContentSearchResult({ content }: { content: Content }) { +export default function ContentSearchResult({ content }: { content: SearchContent }) { const navigate = useNavigate(); + const [showTooltip, setShowTooltip] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageError, setImageError] = useState(false); + const tooltipTimer = useRef(null); + const contentRef = useRef(null); + + // Generate a consistent color based on the content title + const placeholderColor = generateColorFromText(content.title); /** * handleClick() -> void @@ -13,17 +23,124 @@ export default function ContentSearchResult({ content }: { content: Content }) { * @returns void */ const handleClick = () => { - navigate(`/content/${content.uid}`); + navigate(`/content/${content.content_id}`); + }; + + // Format the date to a more readable format + const formatDate = (date: Date) => { + if (!date) return ""; + const dateObj = new Date(date); + return dateObj.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }; + + // Handle mouse enter - start timer to show tooltip + const handleMouseEnter = () => { + if (tooltipTimer.current) { + clearTimeout(tooltipTimer.current); + } + + tooltipTimer.current = setTimeout(() => { + setShowTooltip(true); + }, 1000); // Show tooltip after 1 second + }; + + // Handle mouse leave - clear timer and hide tooltip + const handleMouseLeave = () => { + if (tooltipTimer.current) { + clearTimeout(tooltipTimer.current); + tooltipTimer.current = null; + } + setShowTooltip(false); }; + // Clean up timer on unmount + useEffect(() => { + return () => { + if (tooltipTimer.current) { + clearTimeout(tooltipTimer.current); + tooltipTimer.current = null; + } + }; + }, []); + + // Handle image loading and errors + const handleImageLoad = () => { + setImageLoaded(true); + setImageError(false); + }; + + const handleImageError = () => { + setImageLoaded(true); + setImageError(true); + }; + + // Get first letter of title for placeholder + function getFirstLetter() { + return content.title ? content.title[0].toUpperCase() : 'A'; + }; + + // Check if we should show the actual image + const hasValidImage = content.profile_image && !imageError; + return ( -
- {``} -

{content.title}

+
+
+ {/* Always show placeholder until image is loaded */} + {(!imageLoaded || !hasValidImage) && ( +
+ {getFirstLetter()} +
+ )} + + {/* Only render img tag if we have a valid image */} + {content.profile_image && ( + {content.title} + )} +
+ +
+

{content.title}

+ +
+
+ + {content.first_name} {content.last_name} + + @{content.username} +
+ {formatDate(content.date_created)} +
+
+ + {/* Tooltip that appears after hovering for 1 second */} + {showTooltip && content.summary && ( +
e.stopPropagation()} + > + {content.summary} +
+ )}
); } diff --git a/frontend/src/components/search/SearchBar.tsx b/frontend/src/components/search/SearchBar.tsx new file mode 100644 index 0000000..e03d30e --- /dev/null +++ b/frontend/src/components/search/SearchBar.tsx @@ -0,0 +1,193 @@ +import { useState, useEffect, useRef, KeyboardEvent } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { SearchUser, SearchContent } from '../../models/SearchResult'; +import { SearchService } from '../../services/SearchService'; +import UserSearchResult from './UserSearchResult'; +import ContentSearchResult from './ContentSearchResult'; +import '../../styles/search/search.scss'; + +export default function SearchBar() { + const navigate = useNavigate(); + const location = useLocation(); + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [showResults, setShowResults] = useState(false); + const [userResults, setUserResults] = useState([]); + const [contentResults, setContentResults] = useState([]); + const [loading, setLoading] = useState(false); + const searchBarRef = useRef(null); + const fetchInProgress = useRef(false); + const initialRender = useRef(true); + + // Handle outside clicks to close the dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchBarRef.current && !searchBarRef.current.contains(event.target as Node)) { + setShowResults(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + // Debounce the search query with a 500ms delay + useEffect(() => { + // Skip if this is the initial render + if (initialRender.current) { + initialRender.current = false; + return; + } + + const handler = setTimeout(() => { + if (query.trim().length >= 3) { + setDebouncedQuery(query); + } else { + setShowResults(false); + } + }, 500); + + return () => { + clearTimeout(handler); + }; + }, [query]); + + // Fetch search results when debounced query changes + useEffect(() => { + // Don't fetch results if we're already on the search results page + if (location.pathname === '/search') { + return; + } + + // Don't fetch on initial render + if (initialRender.current) { + return; + } + + if (debouncedQuery.trim().length >= 3) { + fetchSearchResults(); + } + }, [debouncedQuery, location.pathname]); + + // Fetch search results from the API + const fetchSearchResults = async () => { + if (fetchInProgress.current) return; + + fetchInProgress.current = true; + setLoading(true); + + try { + console.log(`SearchBar: Fetching dropdown results for "${debouncedQuery}"`); + const response = await SearchService.search(debouncedQuery, "all", 5, 0); + + if (!(response instanceof Error)) { + // Explicitly type the response to ensure type safety + const users: SearchUser[] = response.users; + const content: SearchContent[] = response.content; + + setUserResults(users); + setContentResults(content); + setShowResults(true); + } else { + console.error('Search error:', response); + } + } catch (error) { + console.error('Error fetching search results:', error); + } finally { + setLoading(false); + fetchInProgress.current = false; + } + }; + + // Handle search submission + const handleSearch = (e?: React.FormEvent) => { + if (e) e.preventDefault(); + + if (query.trim().length >= 3) { + setShowResults(false); + navigate(`/search?query=${encodeURIComponent(query.trim())}`); + } + }; + + // Handle keyboard events + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch(); + } else if (e.key === 'Escape') { + setShowResults(false); + } + }; + + // Handle input focus + const handleInputFocus = () => { + // Only show results if there's a valid query + if (query.trim().length >= 3 && userResults.length + contentResults.length > 0) { + setShowResults(true); + } + }; + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + placeholder="Search..." + aria-label="Search" + /> + +
+ + {showResults && ( +
+ {loading ? ( +
Loading...
+ ) : ( + <> +
+

Users

+ {userResults.length > 0 ? ( + userResults.map((user, index) => ( +
setShowResults(false)}> + +
+ )) + ) : ( +

No users found

+ )} +
+ +
+

Content

+ {contentResults.length > 0 ? ( + contentResults.map((item, index) => ( +
setShowResults(false)}> + +
+ )) + ) : ( +

No content found

+ )} +
+ +
+ +
+ + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/search/SearchListResults.tsx b/frontend/src/components/search/SearchListResults.tsx deleted file mode 100644 index ac9da33..0000000 --- a/frontend/src/components/search/SearchListResults.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { Suspense, useEffect, useState } from "react"; -import { User } from "../../models/User"; -import { Content } from "../../models/Content"; -import { useSearchParams } from "react-router-dom"; -import ContentSearchResult from "./ContentSearchResult"; -import UserSearchResult from "./UserSearchResult"; -import { SearchService } from "../../services/SearchService"; - -function SearchListResults({ - userSearchResults, - contentSearchResults, -}: { - userSearchResults?: User[]; - contentSearchResults?: Content[]; -} = {}) { - // --------------------------------------- - // -------------- Variables -------------- - // --------------------------------------- - - // Retrieve the search text from the url. - const [searchParams] = useSearchParams(); - const query = searchParams.get("query"); - - const [usersReturned, setUsersReturned] = useState([]); - const [userDisabled, setUserDisabled] = useState(true); - const [userStartingPoint, setUserStartingPoint] = useState( - null - ); - - const [contentReturned, setContentReturned] = useState([]); - const [contentDisabled, setContentDisabled] = useState(true); - const [contentStartingPoint, setContentStartingPoint] = useState< - string | null - >(null); - - const [fetching, setFetching] = useState(false); - - // --------------------------------------- - // -------------- Page INIT -------------- - // --------------------------------------- - - // Fetch both user data and content data on first page load. - useEffect(() => { - if (userSearchResults) { - setUsersReturned(userSearchResults); - } else if (query) { - fetchUserData(); - } - - if (contentSearchResults) { - setContentReturned(contentSearchResults); - } else if (query) { - fetchContentData(); - } - }, [userSearchResults, contentSearchResults, query]); - - /** - * fetchUserData() -> void - * - * @description - * Sends a GET request to the API endpoint for searching users. - * - * @returns void - */ - const fetchUserData = async () => { - if (!query) return; - setFetching(true); - - const userSearchResults = await SearchService.searchUsers( - query, - userStartingPoint - ); - - if (userSearchResults instanceof Error) { - console.error("Error fetching user data:", userSearchResults); - setFetching(false); - return; - } - - const users = userSearchResults.users || []; - const existingUserIds = new Set( - usersReturned.map((user: User) => user.uid) - ); - - // Filter out duplicate users - const uniqueUsers = users.filter( - (user: { uid: string }) => !existingUserIds.has(user.uid) - ); - - if (uniqueUsers && uniqueUsers.length > 0) { - setUsersReturned((prev) => [...prev, ...uniqueUsers]); - - // Update the starting point for the next fetch - setUserStartingPoint(uniqueUsers[uniqueUsers.length - 1]?.uid || null); - - // Enable or disable the "Fetch more" button based on the number of users - setUserDisabled(!userSearchResults.nextStartingPoint); - } else { - setUserDisabled(true); // Disable the button if no unique users are found - } - - setFetching(false); - }; - - /** - * fetchUserData() -> void - * - * @description - *Send a get request to the api endpoint for searching for articles. - * - * @returns void - */ - const fetchContentData = async () => { - if (!query) return; - setFetching(true); - - const searchContentResults = await SearchService.searchContents( - query, - contentStartingPoint - ); - - if (searchContentResults instanceof Error) { - console.error("Error fetching content data:", searchContentResults); - setFetching(false); - return; - } - - const contents = searchContentResults.contents || []; - const existingContentIds = new Set( - contentReturned.map((content: Content) => content.uid) - ); - - // Filter out duplicate users - const uniqueContents = contents.filter( - (content: { uid: string }) => !existingContentIds.has(content.uid) - ); - - if (uniqueContents && uniqueContents.length > 0) { - setContentReturned((prev) => [...prev, ...uniqueContents]); - - // Update the starting point for the next fetch - setContentStartingPoint( - uniqueContents[uniqueContents.length - 1]?.uid || null - ); - - // Enable or disable the "Fetch more" button based on the number of contents - setContentDisabled(!searchContentResults.nextStartingPoint); - } else { - setContentDisabled(true); // Disable the button if no unique content are found - } - - setFetching(false); - }; - - return ( - <> -
- {query &&

Search Results for: {query}

} - -

Users

- {usersReturned?.length === 0 ? ( -

No matching user found...

- ) : ( - usersReturned.map((user: User, index) => ( -
- -
- )) - )} - {fetching &&

Loading...

} - {!userDisabled && ( - - )} - -

Content

- {contentReturned?.length === 0 ? ( -

No matching content found...

- ) : ( - contentReturned.map((content: Content, index) => ( -
- -
- )) - )} - {fetching &&

Loading...

} - {!contentDisabled && ( - - )} -
- - ); -} - -const SearchList = (props: { - userSearchResults?: User[]; - contentSearchResults?: Content[]; -}) => { - return ( - Loading search results...
}> - - - ); -}; - -export default SearchList; diff --git a/frontend/src/components/search/UserSearchResult.tsx b/frontend/src/components/search/UserSearchResult.tsx index 3e5be8f..06fc277 100644 --- a/frontend/src/components/search/UserSearchResult.tsx +++ b/frontend/src/components/search/UserSearchResult.tsx @@ -1,8 +1,12 @@ import { useNavigate } from "react-router-dom"; -import { User } from "../../models/User"; +import { SearchUser } from "../../models/SearchResult"; +import { generateColorFromText } from "../../utils/colorUtils"; -export default function UserResult({ user }: { user: User }) { +export default function UserResult({ user }: { user: SearchUser }) { const navigate = useNavigate(); + + // Generate a consistent color based on the username + const placeholderColor = generateColorFromText(user.username); /** * handleClick() -> void @@ -13,22 +17,25 @@ export default function UserResult({ user }: { user: User }) { * @returns void */ const handleClick = () => { - navigate(`/profile/${user.uid}`); + navigate(`/profile/${user.user_id}`); }; return (
- {user && user.profileImage ? ( + {user && user.profile_image ? ( Profile Picture ) : ( -
+

{user?.username?.[0].toUpperCase() || "U"}

@@ -36,9 +43,10 @@ export default function UserResult({ user }: { user: User }) { )}
-

- {user.firstName} {user.lastName} -

+
+

{user.first_name} {user.last_name}

+

@{user.username}

+
); } diff --git a/frontend/src/models/SearchResult.ts b/frontend/src/models/SearchResult.ts new file mode 100644 index 0000000..eb4d37b --- /dev/null +++ b/frontend/src/models/SearchResult.ts @@ -0,0 +1,26 @@ +// Models for search results from the backend +// These match the backend search.types.ts structure + +export interface SearchUser { + user_id: string; + username: string; + first_name: string; + last_name: string; + profile_image?: string; +} + +export interface SearchContent { + content_id: string; + username: string; + first_name: string; + last_name: string; + profile_image?: string; + title: string; + summary?: string; + date_created: Date; +} + +export interface SearchResponse { + users: SearchUser[]; + content: SearchContent[]; +} diff --git a/frontend/src/pages/search/SearchResults.tsx b/frontend/src/pages/search/SearchResults.tsx new file mode 100644 index 0000000..6cb30fd --- /dev/null +++ b/frontend/src/pages/search/SearchResults.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import { useSearchParams } from "react-router-dom"; +import { SearchUser, SearchContent } from "../../models/SearchResult"; +import { SearchService } from "../../services/SearchService"; +import UserSearchResult from "../../components/search/UserSearchResult"; +import ContentSearchResult from "../../components/search/ContentSearchResult"; +import "../../styles/search/search.scss"; + +export default function SearchResults() { + // Search parameters + const [searchParams] = useSearchParams(); + const query = searchParams.get("query") || ""; + + // State for search results + const [users, setUsers] = useState([]); + const [content, setContent] = useState([]); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = useState(false); + + // Use refs to track if initial fetch has been done and prevent duplicate calls + const initialFetchDone = useRef(false); + const fetchInProgress = useRef(false); + const currentQuery = useRef(""); + + // Constants + const USER_LIMIT = 10; + const CONTENT_LIMIT = 20; + const TOTAL_LIMIT = USER_LIMIT + CONTENT_LIMIT; + + // Define fetchResults with useCallback to avoid dependency issues + const fetchResults = useCallback(async () => { + // Prevent duplicate calls and check valid query + if (!query || + query.length < 3 || + loading || + !hasMore || + fetchInProgress.current) { + return; + } + + fetchInProgress.current = true; + setLoading(true); + + try { + console.log(`Fetching results for "${query}" with offset ${offset}`); + const response = await SearchService.search(query, "all", TOTAL_LIMIT, offset); + + if (response instanceof Error) { + console.error("Error fetching search results:", response); + } else { + const newUsers: SearchUser[] = response.users || []; + const newContent: SearchContent[] = response.content || []; + + // Check if we've reached the end of results + if ((newUsers.length + newContent.length) < TOTAL_LIMIT) { + setHasMore(false); + } + + // Use type-safe spread to combine previous and new results + setUsers(prev => [...prev, ...newUsers]); + setContent(prev => [...prev, ...newContent]); + setOffset(prev => prev + newUsers.length + newContent.length); + } + } catch (error) { + console.error("Error in fetchResults:", error); + } finally { + setLoading(false); + fetchInProgress.current = false; + } + }, [query, loading, hasMore, offset, TOTAL_LIMIT]); + + // Reset state when query changes + useEffect(() => { + if (currentQuery.current !== query) { + currentQuery.current = query; + setUsers([]); + setContent([]); + setOffset(0); + setHasMore(true); + initialFetchDone.current = false; + } + }, [query]); + + // Initial data fetch - only run once per query + useEffect(() => { + if (!initialFetchDone.current && query && query.length >= 3) { + initialFetchDone.current = true; + fetchResults().catch(error => { + console.error("Failed to fetch initial results:", error); + }); + } + }, [query, fetchResults]); + + return ( +
+
+

Search Results for: {query}

+ +
+
+

Users

+
+ {users.length > 0 ? ( + users.map((user, index) => ( +
+ +
+ )) + ) : ( +

No users found matching "{query}"

+ )} +
+
+ +
+

Content

+
+ {content.length > 0 ? ( + content.map((item, index) => ( +
+ +
+ )) + ) : ( +

No content found matching "{query}"

+ )} +
+
+
+ + {hasMore && ( + + )} +
+
+ ); +} diff --git a/frontend/src/services/SearchService.ts b/frontend/src/services/SearchService.ts index e5739dd..95d185d 100644 --- a/frontend/src/services/SearchService.ts +++ b/frontend/src/services/SearchService.ts @@ -1,51 +1,37 @@ import axios from "axios"; import { apiURL } from "../scripts/api"; -import { User } from "../models/User"; -import { Content } from "../models/Content"; +import { SearchResponse } from "../models/SearchResult"; export class SearchService { /** - * searchUsers(query: string) -> Promise + * search(searchText: string, searchType: string, limit: number, offset: number) -> Promise * * @description - * Searches for users based on the provided query string. + * Searches for users and/or content based on the provided parameters. * - * @param query - The search query string. - * @returns A promise that resolves to an array of User objects or an Error. + * @param searchText - The search query string. + * @param searchType - The type of search to perform (users, content, or all). + * @param limit - The maximum number of results to return. + * @param offset - The offset for pagination. + * @returns A promise that resolves to a SearchResponse object or an Error. */ - static async searchUsers( + static async search( searchText: string, - userStartingPoint: string | null = null - ): Promise<{ users: User[]; nextStartingPoint: string } | Error> { + searchType: "users" | "content" | "all" = "all", + limit: number = 5, + offset: number = 0 + ): Promise { try { - const response = await axios.get(`${apiURL}/search/users`, { - params: { searchText, userStartingPoint }, + const response = await axios.get(`${apiURL}/search`, { + params: { searchText, searchType, limit, offset }, }); return response.data; } catch (error) { if (error instanceof Error) { - return new Error("Failed to fetch user data: " + error.message); + return new Error(`Failed to fetch search data: ${error.message}`); } - return new Error("Failed to fetch user data: Unknown error"); - } - } - - static async searchContents( - searchText: string, - contentStartingPoint: string | null = null - ): Promise<{ contents: Content[]; nextStartingPoint: string } | Error> { - try { - const response = await axios.get(`${apiURL}/search/contents`, { - params: { searchText, contentStartingPoint }, - }); - - return response.data; - } catch (error) { - if (error instanceof Error) { - return new Error("Failed to fetch content data: " + error.message); - } - return new Error("Failed to fetch content data: Unknown error"); + return new Error("Failed to fetch search data: Unknown error"); } } } diff --git a/frontend/src/styles/search/search.scss b/frontend/src/styles/search/search.scss index d74a2c2..1a619db 100644 --- a/frontend/src/styles/search/search.scss +++ b/frontend/src/styles/search/search.scss @@ -1,70 +1,445 @@ @use "../global.scss"; +// Search Bar Component +.search-bar-container { + position: relative; + width: 100%; + max-width: 600px; +} + +.search-form { + display: flex; + align-items: center; + width: 100%; +} + +.search-input { + flex: 1; + padding: 10px 15px; + border-radius: 20px; + border: 1px solid var(--input-border-color); + background-color: var(--background-color); + color: var(--text-color); + font-size: 16px; + outline: none; + transition: all 0.3s ease; + + &:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.2); + } +} + +.search-button { + background: none; + border: none; + cursor: pointer; + margin-left: -40px; + color: var(--text-color); + display: flex; + align-items: center; + justify-content: center; + + .search-icon { + width: 20px; + height: 20px; + } +} + +// Search Dropdown +.search-dropdown { + position: absolute; + top: 100%; + left: 0; + width: 350px; + max-height: 400px; + overflow-y: auto; + background-color: var(--background-color); + border: 1px solid var(--input-border-color); + border-radius: 10px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 1000; + margin-top: 5px; + padding: 10px; + + @include global.glassmorphic-background; +} + +.search-section { + margin-bottom: 15px; +} + +.search-section-title { + font-size: 16px; + font-weight: 600; + margin-top: 0; + margin-bottom: 10px; + color: var(--text-color); + padding-bottom: 5px; + border-bottom: 1px solid var(--input-border-color); +} + +.search-dropdown-item { + padding: 8px; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + @include global.glassmorphic-background; + } +} + +.no-results { + color: var(--text-color-secondary); + font-style: italic; + padding: 5px 0; +} + +.search-loading { + text-align: center; + padding: 15px; + color: var(--text-color-secondary); +} + +.search-view-all { + text-align: center; + padding: 10px 0; + border-top: 1px solid var(--input-border-color); + margin-top: 10px; +} + +.view-all-button { + background-color: var(--accent-color); + color: white; + border: none; + border-radius: 20px; + padding: 8px 16px; + font-size: 14px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: var(--accent-color-hover); + } +} + +// Search Results Page +.search-results-page { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 20px; + padding-top: 80px; +} + +.search-results-title { + font-size: 24px; + margin-bottom: 20px; + color: var(--text-color); +} + +.search-results-container { + display: flex; + flex-direction: column; + gap: 30px; +} + +.search-results-list { + display: flex; + flex-direction: column; + gap: 15px; +} + +.load-more-button { + width: 100%; + padding: 12px; + margin-top: 15px; + background-color: var(--background-color); + border: 1px solid var(--input-border-color); + border-radius: 8px; + color: var(--text-color); + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: var(--input-border-color); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +// User and Content Search Results .userSearchResults, .contentSearchResults { display: flex; flex-direction: row; + align-items: center; + padding: 10px; + border-radius: 10px; cursor: pointer; + transition: all 0.3s ease; + position: relative; &:hover { @include global.glassmorphic-background; - transition: all 0.3s ease; - border-radius: 10px; } } .userSearchResults { - margin: 10px 0; - padding: 0px; + gap: 15px; + + .user-info { + display: flex; + flex-direction: column; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + } + + .username { + margin: 0; + font-size: 14px; + color: var(--text-color-secondary); + } + } } -.searchItem { - list-style-type: none; +.contentSearchResults { + gap: 15px; + align-items: flex-start; + + .content-thumbnail-container { + flex-shrink: 0; + position: relative; + width: 120px; + height: 80px; + } + + .content-info { + display: flex; + flex-direction: column; + flex-grow: 1; + position: relative; + min-height: 80px; + + h3 { + margin: 0 0 5px 0; + font-size: 16px; + font-weight: 600; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + max-height: 42px; /* Approximately 2 lines of text */ + } + + .content-summary { + margin: 0 0 8px 0; + font-size: 14px; + color: var(--text-color); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } + + .content-meta { + display: flex; + justify-content: space-between; + align-items: flex-end; + font-size: 12px; + color: var(--text-color-secondary); + position: absolute; + bottom: 0; + width: 100%; + + .content-creator { + display: flex; + flex-wrap: wrap; + gap: 5px; + max-width: 70%; + + .creator-name { + font-weight: 500; + } + + .creator-username { + color: var(--text-color-secondary); + } + } + + .content-date { + font-size: 12px; + margin-left: 10px; + white-space: nowrap; + position: absolute; + bottom: 0; + right: 0; + } + } + } +} + +.content-thumbnail-placeholder { + width: 120px; + height: 80px; + border-radius: 8px; + background-color: var(--input-border-color); + position: absolute; + top: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + + span { + font-size: 2.5rem; + font-weight: bold; + color: rgba(255, 255, 255, 0.7); + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2); + } } .search-content-thumbnail { - width: 200px; - height: 100px; - margin: 10px 10px 10px 0; - border-radius: 10px; + width: 120px; + height: 80px; + border-radius: 8px; object-fit: cover; background-color: var(--input-border-color); + + &.visible { + opacity: 1; + transition: opacity 0.2s ease-in; + } + + &.hidden { + opacity: 0; + } } -.searchResults { +// Content Summary Tooltip +.content-summary-tooltip { + position: absolute; + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%); + width: 90%; + max-width: 300px; + padding: 10px; + background-color: var(--background-color); + border: 1px solid var(--input-border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + font-size: 14px; + color: var(--text-color); + z-index: 1010; + animation: fadeIn 0.3s ease; + @include global.glassmorphic-background; - padding: 20px 40px; - border-radius: 25px; - margin-bottom: 30px; - max-height: 70vh; - overflow-y: auto; + + // Arrow pointing down + &:after { + content: ''; + position: absolute; + bottom: -10px; + left: 50%; + transform: translateX(-50%); + border-width: 10px 10px 0; + border-style: solid; + border-color: var(--input-border-color) transparent transparent; + } } -.fetchMoreButton { - width: 100%; +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} - margin: 1rem auto; - padding: 15px; +.search-item { + list-style-type: none; + width: 100%; +} - border-radius: 10px; - border-style: solid; - border-color: var(--input-border-color); - border-width: 1px; - box-sizing: border-box; +.profile-picture-container { + width: 50px; + height: 50px; + border-radius: 50%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} - background-color: var(--background-color); - color: var(--text-color); +.no-profile-picture-container { + width: 50px; + height: 50px; + border-radius: 50%; + background-color: var(--accent-color); + display: flex; + align-items: center; + justify-content: center; +} - cursor: pointer; +.no-profile-picture { + color: white; + font-size: 20px; + margin: 0; } -.fetchMoreButton:hover { - background-color: var(--input-border-color); - color: var(--text-color); +// Media Queries +@media (min-width: 768px) { + .search-results-container { + flex-direction: row; + + .search-section { + flex: 1; + } + } + + // Make dropdown wider on larger screens + .search-dropdown { + width: 450px; + } } -.fetchMoreButton:active { - background-color: var(--input-border-color); - color: var(--text-color); - transform: scale(0.95); +// For mobile screens, make dropdown full width +@media (max-width: 767px) { + .search-dropdown { + width: 100%; + } + + .contentSearchResults { + flex-direction: column; + + .content-thumbnail-container { + width: 100%; + margin-bottom: 10px; + } + + .search-content-thumbnail { + width: 100%; + height: auto; + aspect-ratio: 16/9; + } + } + + .content-summary-tooltip { + width: 100%; + left: 0; + transform: none; + + &:after { + left: 20px; + transform: none; + } + } } diff --git a/frontend/src/utils/colorUtils.ts b/frontend/src/utils/colorUtils.ts new file mode 100644 index 0000000..e4a2521 --- /dev/null +++ b/frontend/src/utils/colorUtils.ts @@ -0,0 +1,35 @@ +/** + * Generates a consistent color based on a string input + * @param text Any string to generate a color from + * @returns A CSS-compatible color string + */ +export function generateColorFromText(text: string): string { + // Default color if text is empty + if (!text) return '#6366F1'; + + // Use the string to generate a hash code + let hash = 0; + for (let i = 0; i < text.length; i++) { + hash = text.charCodeAt(i) + ((hash << 5) - hash); + } + + // Define a palette of pleasing colors + const colorPalette = [ + '#4F46E5', // Indigo + '#7C3AED', // Purple + '#EC4899', // Pink + '#EF4444', // Red + '#F59E0B', // Amber + '#10B981', // Emerald + '#06B6D4', // Cyan + '#3B82F6', // Blue + '#8B5CF6', // Violet + '#14B8A6', // Teal + '#F97316', // Orange + '#6366F1', // Indigo Light + ]; + + // Use the hash to select a color from the palette + const index = Math.abs(hash) % colorPalette.length; + return colorPalette[index]; +} From 41b8806344588dc2cbbec2b1195723ade12e94bb Mon Sep 17 00:00:00 2001 From: Chris Busse Date: Tue, 3 Jun 2025 21:06:31 -0400 Subject: [PATCH 03/10] Update SearchResults.tsx --- frontend/src/pages/search/SearchResults.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/search/SearchResults.tsx b/frontend/src/pages/search/SearchResults.tsx index 6cb30fd..0b6492f 100644 --- a/frontend/src/pages/search/SearchResults.tsx +++ b/frontend/src/pages/search/SearchResults.tsx @@ -7,11 +7,8 @@ import ContentSearchResult from "../../components/search/ContentSearchResult"; import "../../styles/search/search.scss"; export default function SearchResults() { - // Search parameters const [searchParams] = useSearchParams(); const query = searchParams.get("query") || ""; - - // State for search results const [users, setUsers] = useState([]); const [content, setContent] = useState([]); const [offset, setOffset] = useState(0); @@ -23,12 +20,10 @@ export default function SearchResults() { const fetchInProgress = useRef(false); const currentQuery = useRef(""); - // Constants const USER_LIMIT = 10; const CONTENT_LIMIT = 20; const TOTAL_LIMIT = USER_LIMIT + CONTENT_LIMIT; - // Define fetchResults with useCallback to avoid dependency issues const fetchResults = useCallback(async () => { // Prevent duplicate calls and check valid query if (!query || @@ -56,8 +51,7 @@ export default function SearchResults() { if ((newUsers.length + newContent.length) < TOTAL_LIMIT) { setHasMore(false); } - - // Use type-safe spread to combine previous and new results + setUsers(prev => [...prev, ...newUsers]); setContent(prev => [...prev, ...newContent]); setOffset(prev => prev + newUsers.length + newContent.length); @@ -82,7 +76,7 @@ export default function SearchResults() { } }, [query]); - // Initial data fetch - only run once per query + // Initial data fetch - only run once per query!! useEffect(() => { if (!initialFetchDone.current && query && query.length >= 3) { initialFetchDone.current = true; From cb9fd721b7d2d35a33d22bf473f6acc757607bfc Mon Sep 17 00:00:00 2001 From: Chris Busse Date: Tue, 3 Jun 2025 21:39:55 -0400 Subject: [PATCH 04/10] fix dropdown bug --- frontend/src/components/search/SearchBar.tsx | 55 ++++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/search/SearchBar.tsx b/frontend/src/components/search/SearchBar.tsx index e03d30e..7055b05 100644 --- a/frontend/src/components/search/SearchBar.tsx +++ b/frontend/src/components/search/SearchBar.tsx @@ -19,6 +19,33 @@ export default function SearchBar() { const searchBarRef = useRef(null); const fetchInProgress = useRef(false); const initialRender = useRef(true); + const previousPath = useRef(location.pathname); + const debounceTimer = useRef(null); + + // Immediately hide dropdown when location changes + // This runs before any other effects + useEffect(() => { + // Hide dropdown immediately on any location change + setShowResults(false); + + // If the path has changed (not including search params) + if (location.pathname !== previousPath.current) { + // Clear the search input and results + setQuery(''); + setDebouncedQuery(''); + setUserResults([]); + setContentResults([]); + + // Clear any pending debounce timers + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + debounceTimer.current = null; + } + + // Update the previous path + previousPath.current = location.pathname; + } + }, [location]); // Handle outside clicks to close the dropdown useEffect(() => { @@ -42,18 +69,32 @@ export default function SearchBar() { return; } - const handler = setTimeout(() => { + // Clear any existing timer + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + // Don't set up a new timer if we're on the search results page + if (location.pathname === '/search') { + return; + } + + debounceTimer.current = setTimeout(() => { if (query.trim().length >= 3) { setDebouncedQuery(query); } else { setShowResults(false); } + debounceTimer.current = null; }, 500); return () => { - clearTimeout(handler); + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + debounceTimer.current = null; + } }; - }, [query]); + }, [query, location.pathname]); // Fetch search results when debounced query changes useEffect(() => { @@ -83,6 +124,12 @@ export default function SearchBar() { console.log(`SearchBar: Fetching dropdown results for "${debouncedQuery}"`); const response = await SearchService.search(debouncedQuery, "all", 5, 0); + // Check if the path has changed since we started the fetch + if (location.pathname !== previousPath.current) { + // Don't update state if we've navigated away + return; + } + if (!(response instanceof Error)) { // Explicitly type the response to ensure type safety const users: SearchUser[] = response.users; @@ -147,7 +194,7 @@ export default function SearchBar() { - {showResults && ( + {showResults && query.trim().length >= 3 && (
{loading ? (
Loading...
From 152fdce866ca0eb48d32161793f9c2a8da704c2b Mon Sep 17 00:00:00 2001 From: Chris Busse Date: Tue, 3 Jun 2025 21:46:39 -0400 Subject: [PATCH 05/10] add first and last name searching --- backend/src/modules/search/repository/search.repository.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/modules/search/repository/search.repository.ts b/backend/src/modules/search/repository/search.repository.ts index 7199dfb..45bd81e 100644 --- a/backend/src/modules/search/repository/search.repository.ts +++ b/backend/src/modules/search/repository/search.repository.ts @@ -15,8 +15,10 @@ export async function findUsers(searchText: string, limit: number, offset: numbe profile_image FROM users WHERE - username ILIKE $1 - ORDER BY username + username ILIKE $1 OR + first_name ILIKE $1 OR + last_name ILIKE $1 + ORDER BY first_name, last_name LIMIT $2 OFFSET $3; `; const params = [`%${searchText}%`, limit, offset]; From b26ee02495cb16ee841aa3c5f6c4bc4acf38e6f2 Mon Sep 17 00:00:00 2001 From: Chris Busse Date: Thu, 5 Jun 2025 10:52:03 -0400 Subject: [PATCH 06/10] roll back navbar menu --- frontend/src/components/Navbar.tsx | 241 +++++++++-------------------- 1 file changed, 77 insertions(+), 164 deletions(-) diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index d3ca331..1d2f79e 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -323,174 +323,87 @@ export default function Navbar() {
- - {/* User Info */} -
-
- {auth.user && auth.user.profileImage ? ( - Profile Picture - ) : ( -
-

- {auth.user?.username[0].toUpperCase() || ""} -

-
- )} -
-
-

- {auth.user?.firstName} {auth.user?.lastName} -

-

@{auth.user?.username}

-
-
- - {/* Search Bar for Mobile */} -
- -
- - {/* Create New Content for Mobile */} - - - {/* Notifications for Mobile */} -
-
- - {unreadCount > 0 && {unreadCount}} -
-
- {/* Menu Items */} -
-
{ - navigate(`/profile/${auth.user?.uid}`); - setShowMenu(false); - }} - > - - - - - -

Profile

-
- -
{ - navigate("/settings"); - setShowMenu(false); - }} - > - - - - - -

Settings

-
- -
{ - navigate("/subscription"); - setShowMenu(false); - }} - > - - - - -

- {isProUser - ? "Manage Subscription" - : "Upgrade to Pro"} -

-
- -
{ - handleLogout(); - setShowMenu(false); - }} - > - { + setShowMenu(false); + navigate(`/profile/${auth.user?.uid}`); + }} + > + View Profile + +
+ { + setShowMenu(false); + navigate(`/profile/manage`); + }} + > + Manage Profile + +
+ {!isProUser && ( + <> + { + setShowMenu(false); + navigate("/pro"); + }} > - - - - - -

Logout

-
-
+ + + + Upgrade to Pro + +
+ + )} + { + setShowMenu(false); + navigate("/pro/manage"); + }} + > + Manage Subscription + +
+ { + setShowMenu(false); + handleLogout(); + }} + > + Logout + + + {/* Create Content */} +
)} From 61a25fd3d24d6f0732d153a62e5bfac92131f581 Mon Sep 17 00:00:00 2001 From: Chris Busse Date: Thu, 5 Jun 2025 10:52:16 -0400 Subject: [PATCH 07/10] bump vulnerable packages --- frontend/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 4d624e4..cf0b5d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,8 +23,8 @@ "@tiptap/pm": "^2.10.2", "@tiptap/react": "^2.10.2", "@tiptap/starter-kit": "^2.10.2", - "axios": "^1.7.7", - "dompurify": "^3.2.1", + "axios": "1.8.2", + "dompurify": "3.2.4", "js-cookie": "^3.0.5", "react": "^19.0.0", "react-dom": "^19.0.0", From 1fd9d347c7e513d20461fb01116e3c3521c17406 Mon Sep 17 00:00:00 2001 From: Chris Busse Date: Thu, 5 Jun 2025 10:53:01 -0400 Subject: [PATCH 08/10] user main-content standards --- frontend/src/pages/search/SearchResults.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/search/SearchResults.tsx b/frontend/src/pages/search/SearchResults.tsx index 0b6492f..a72cb0f 100644 --- a/frontend/src/pages/search/SearchResults.tsx +++ b/frontend/src/pages/search/SearchResults.tsx @@ -87,7 +87,7 @@ export default function SearchResults() { }, [query, fetchResults]); return ( -
+

Search Results for: {query}

From e7f8f3e19bb301d92de16068db939487f5518239 Mon Sep 17 00:00:00 2001 From: Chris Busse Date: Thu, 5 Jun 2025 11:07:34 -0400 Subject: [PATCH 09/10] css updates center dropdown, better scrollbar search page margins normalized --- frontend/src/styles/search/search.scss | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/src/styles/search/search.scss b/frontend/src/styles/search/search.scss index 1a619db..89ca172 100644 --- a/frontend/src/styles/search/search.scss +++ b/frontend/src/styles/search/search.scss @@ -50,9 +50,10 @@ .search-dropdown { position: absolute; top: 100%; - left: 0; + left: 50%; + transform: translateX(-50%); width: 350px; - max-height: 400px; + max-height: 600px; overflow-y: auto; background-color: var(--background-color); border: 1px solid var(--input-border-color); @@ -63,6 +64,25 @@ padding: 10px; @include global.glassmorphic-background; + + // Custom scrollbar styling + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + + &:hover { + background: rgba(0, 0, 0, 0.3); + } + } } .search-section { @@ -127,10 +147,8 @@ // Search Results Page .search-results-page { width: 100%; - max-width: 1200px; margin: 0 auto; padding: 20px; - padding-top: 80px; } .search-results-title { From 00e7a6a915bee2c13c69c4964ae4f4a5944f54a2 Mon Sep 17 00:00:00 2001 From: Chris Busse Date: Thu, 5 Jun 2025 11:43:47 -0400 Subject: [PATCH 10/10] use custom scrollbar across in notifications too --- .../styles/notifications/notifications.scss | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/styles/notifications/notifications.scss b/frontend/src/styles/notifications/notifications.scss index 3a5cfc2..a811013 100644 --- a/frontend/src/styles/notifications/notifications.scss +++ b/frontend/src/styles/notifications/notifications.scss @@ -33,4 +33,23 @@ padding: 20px; overflow-y: scroll; border-radius: 15px; + + // Custom scrollbar styling + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + + &:hover { + background: rgba(0, 0, 0, 0.3); + } + } }