diff --git a/FU.API/FU.API/DTOs/Chat/ChatResponseDTO.cs b/FU.API/FU.API/DTOs/Chat/ChatResponseDTO.cs index a43773ee..aeb6d2fb 100644 --- a/FU.API/FU.API/DTOs/Chat/ChatResponseDTO.cs +++ b/FU.API/FU.API/DTOs/Chat/ChatResponseDTO.cs @@ -1,5 +1,9 @@ namespace FU.API.DTOs.Chat; +/// +/// The response DTO for chat. +/// Members is a collection of usernames. +/// public class ChatResponseDTO { public int Id { get; set; } diff --git a/FU.API/FU.API/DTOs/Chat/MessageResponseDTO.cs b/FU.API/FU.API/DTOs/Chat/MessageResponseDTO.cs index d7f38b66..e096166c 100644 --- a/FU.API/FU.API/DTOs/Chat/MessageResponseDTO.cs +++ b/FU.API/FU.API/DTOs/Chat/MessageResponseDTO.cs @@ -2,6 +2,10 @@ using FU.API.Models; +/// +/// The response DTO for message. +/// Sender is the user who sent the message, and contains the username and avatar. +/// public class MessageResponseDTO { public int Id { get; set; } diff --git a/FU.API/FU.API/DTOs/Game/GameDTO.cs b/FU.API/FU.API/DTOs/Game/GameDTO.cs index a7c923f7..d78c2b75 100644 --- a/FU.API/FU.API/DTOs/Game/GameDTO.cs +++ b/FU.API/FU.API/DTOs/Game/GameDTO.cs @@ -3,6 +3,10 @@ using FU.API.Validation; using System.ComponentModel.DataAnnotations; +/// +/// The DTO for creating/retrieving games. +/// Name is the name of the game. +/// public class GameDTO { public int Id { get; set; } diff --git a/FU.API/FU.API/DTOs/Game/UpdateGameDTO.cs b/FU.API/FU.API/DTOs/Game/UpdateGameDTO.cs index e87ff8d3..ae37aa58 100644 --- a/FU.API/FU.API/DTOs/Game/UpdateGameDTO.cs +++ b/FU.API/FU.API/DTOs/Game/UpdateGameDTO.cs @@ -3,6 +3,10 @@ using FU.API.Validation; using System.ComponentModel.DataAnnotations; +/// +/// The DTO for updating games. +/// Only Name and ImageUrl can be updated. +/// public class UpdateGameDTO { [NonEmptyString] diff --git a/FU.API/FU.API/DTOs/LoginRequestDTO.cs b/FU.API/FU.API/DTOs/LoginRequestDTO.cs index 85a83ee5..16b12b76 100644 --- a/FU.API/FU.API/DTOs/LoginRequestDTO.cs +++ b/FU.API/FU.API/DTOs/LoginRequestDTO.cs @@ -1,5 +1,9 @@ namespace FU.API.DTOs; +/// +/// The request DTO for login. +/// Used for logging in. +/// public class LoginRequestDTO { public string Username { get; set; } = string.Empty; diff --git a/FU.API/FU.API/DTOs/Post/PostRequestDTO.cs b/FU.API/FU.API/DTOs/Post/PostRequestDTO.cs index fa4f6d1a..7b8262d8 100644 --- a/FU.API/FU.API/DTOs/Post/PostRequestDTO.cs +++ b/FU.API/FU.API/DTOs/Post/PostRequestDTO.cs @@ -3,6 +3,11 @@ using FU.API.Validation; using System.ComponentModel.DataAnnotations; +/// +/// The DTO for creating posts. +/// Needs a title and a gameId. +/// TagIds is a list of tag ids. +/// public class PostRequestDTO { [NonEmptyString] diff --git a/FU.API/FU.API/DTOs/Post/PostResponseDTO.cs b/FU.API/FU.API/DTOs/Post/PostResponseDTO.cs index 830c11c7..0f3f1485 100644 --- a/FU.API/FU.API/DTOs/Post/PostResponseDTO.cs +++ b/FU.API/FU.API/DTOs/Post/PostResponseDTO.cs @@ -3,6 +3,13 @@ using FU.API.DTOs.Chat; using FU.API.Models; +/// +/// The response DTO for post. +/// May contain reference to the last message in the chat. +/// Includes the creator of the post. +/// Include the game name, and list of tag names. +/// Indicates if the current user has joined the post. +/// public class PostResponseDTO { public int Id { get; set; } diff --git a/FU.API/FU.API/DTOs/ResendConfirmationDTO.cs b/FU.API/FU.API/DTOs/ResendConfirmationDTO.cs index e4b46c1f..51bc49de 100644 --- a/FU.API/FU.API/DTOs/ResendConfirmationDTO.cs +++ b/FU.API/FU.API/DTOs/ResendConfirmationDTO.cs @@ -1,5 +1,9 @@ namespace FU.API.DTOs; +/// +/// The DTO for resending a confirmation email. +/// May be trying to find the user by email or username. +/// public class ResendConfirmationDTO { public string? Email { get; set; } diff --git a/FU.API/FU.API/DTOs/Search/PostSearchRequestDTO.cs b/FU.API/FU.API/DTOs/Search/PostSearchRequestDTO.cs index 5b40113d..91a8df82 100644 --- a/FU.API/FU.API/DTOs/Search/PostSearchRequestDTO.cs +++ b/FU.API/FU.API/DTOs/Search/PostSearchRequestDTO.cs @@ -2,6 +2,11 @@ namespace FU.API.DTOs.Search; using Microsoft.AspNetCore.Mvc; +/// +/// Represents a request to search for posts. +/// All properties are optional. +/// Will be converted to a PostQuery. +/// public record PostSearchRequestDTO { [FromQuery] diff --git a/FU.API/FU.API/DTOs/Search/UserSearchRequestDTO.cs b/FU.API/FU.API/DTOs/Search/UserSearchRequestDTO.cs index 50b5c920..4358731a 100644 --- a/FU.API/FU.API/DTOs/Search/UserSearchRequestDTO.cs +++ b/FU.API/FU.API/DTOs/Search/UserSearchRequestDTO.cs @@ -2,6 +2,11 @@ namespace FU.API.DTOs.Search; using Microsoft.AspNetCore.Mvc; +/// +/// The request DTO for searching for users. +/// All properties are optional. +/// Will be converted to a UserQuery. +/// public record UserSearchRequestDTO { [FromQuery] diff --git a/FU.API/FU.API/DTOs/Tag/TagRequestDTO.cs b/FU.API/FU.API/DTOs/Tag/TagRequestDTO.cs index 3f3262f1..efde6d2d 100644 --- a/FU.API/FU.API/DTOs/Tag/TagRequestDTO.cs +++ b/FU.API/FU.API/DTOs/Tag/TagRequestDTO.cs @@ -3,6 +3,10 @@ using System.ComponentModel.DataAnnotations; using FU.API.Validation; +/// +/// The DTO for creating tags. +/// Tag names can't be longer than 20 characters. +/// public class TagRequestDTO { [NoSpaces] diff --git a/FU.API/FU.API/DTOs/Tag/TagResponseDTO.cs b/FU.API/FU.API/DTOs/Tag/TagResponseDTO.cs index 8a196de4..8667efad 100644 --- a/FU.API/FU.API/DTOs/Tag/TagResponseDTO.cs +++ b/FU.API/FU.API/DTOs/Tag/TagResponseDTO.cs @@ -1,5 +1,9 @@ namespace FU.API.DTOs.Tag; +/// +/// The response DTO for tags. +/// Includes the tag's id and name. +/// public class TagResponseDTO { public int Id { get; set; } diff --git a/FU.API/FU.API/DTOs/UpdateCredentialDTO.cs b/FU.API/FU.API/DTOs/UpdateCredentialDTO.cs index 8d7c2a99..07ba53bb 100644 --- a/FU.API/FU.API/DTOs/UpdateCredentialDTO.cs +++ b/FU.API/FU.API/DTOs/UpdateCredentialDTO.cs @@ -3,6 +3,9 @@ using FU.API.Validation; using System.ComponentModel.DataAnnotations; +/// +/// Used for updating credentials. +/// public record UpdateCredentailsDTO { [NonEmptyString] diff --git a/FU.API/FU.API/DTOs/User/UserRelationDTO.cs b/FU.API/FU.API/DTOs/User/UserRelationDTO.cs index 5641476a..77c9eccb 100644 --- a/FU.API/FU.API/DTOs/User/UserRelationDTO.cs +++ b/FU.API/FU.API/DTOs/User/UserRelationDTO.cs @@ -1,5 +1,8 @@ namespace FU.API.DTOs.User; +/// +/// The response DTO for user relations. +/// public class UserRelationDTO { public string Status { get; set; } = string.Empty; diff --git a/FU.API/FU.API/Helpers/AuthHelper.cs b/FU.API/FU.API/Helpers/AuthHelper.cs index 624178ac..e9f37de5 100644 --- a/FU.API/FU.API/Helpers/AuthHelper.cs +++ b/FU.API/FU.API/Helpers/AuthHelper.cs @@ -6,6 +6,10 @@ using System.Security.Claims; using System.Text; +/// +/// Helper class for creating authentication tokens. +/// Used when a user logs in or sending account verification emails. +/// public static class AuthHelper { public static AuthenticationInfo CreateAuthInfo(IConfiguration configuration, DateTime expires, int userId) diff --git a/FU.API/FU.API/Helpers/Mapper.cs b/FU.API/FU.API/Helpers/Mapper.cs index 5fd67165..98c09245 100644 --- a/FU.API/FU.API/Helpers/Mapper.cs +++ b/FU.API/FU.API/Helpers/Mapper.cs @@ -10,6 +10,11 @@ namespace FU.API.Helpers; using FU.API.DTOs.User; using FU.API.DTOs; +/// +/// A static class that contains extension methods for mapping between DTOs and models. +/// Used to keep the controllers clean. +/// Simple mapping, and no complex logic. +/// public static class Mapper { public static UserProfile ToProfile(this ApplicationUser appUser, Message? lastChatMessage = null) diff --git a/FU.API/FU.API/Services/PostService.cs b/FU.API/FU.API/Services/PostService.cs index 7da21c1b..0aaa871d 100644 --- a/FU.API/FU.API/Services/PostService.cs +++ b/FU.API/FU.API/Services/PostService.cs @@ -76,6 +76,8 @@ public async Task UpdatePost(Post postChanges) throw new PostException("The updated post's creator does not match the old post's creator", HttpStatusCode.UnprocessableEntity); } + AssertValidDateAndTime(postChanges); + ogPost.Game = await _dbContext.Games.FindAsync(postChanges.GameId) ?? throw new NonexistentGameException(); ogPost.Description = postChanges.Description; ogPost.MaxPlayers = postChanges.MaxPlayers; diff --git a/FU.API/README.md b/FU.API/README.md index 2ff1fdb8..4cd3d3a5 100644 --- a/FU.API/README.md +++ b/FU.API/README.md @@ -12,6 +12,76 @@ - Azure Communication Service is used to send emails - Docker can be used for containerization +## Development + +### External Requirements + +In order to run the project you first have to install: + +- [ASP.NET Core 7](https://learn.microsoft.com/en-us/aspnet/core/introduction-to-aspnet-core?view=aspnetcore-7.0) +- [PostgreSQL](https://www.postgresql.org/download/) + +### Postgres Setup + +Install and start the database by installing Docker and running the following command. + +``` +docker run --name postgres-490 -e POSTGRES_DB=fu_dev -e POSTGRES_PASSWORD=dev -e POSTGRES_USER=dev -p 5432:5432 postgres:alpine +``` + +To run the container at a later time, run: + +``` +docker container start postgres-490 +``` + +Alternatively, install and start a PostgreSQL database manually. + +### Config Setup + +Config settings are loaded from the environment variables. To automatically load the environment variable from a file, create a `.env` file in this folder. + +#### Blob storage + +An azure storage account is needed with a storage container. Public anonymous access must be enabled. The `STORAGE_CONNECTION_STRING` and `AVATAR_CONTAINER_NAME` environment variables must be set. + +``` +STORAGE_CONNECTION_STRING="XXXXXXXX" +AVATAR_CONTAINER_NAME="some-container-name" +``` + +#### Jwt Secret + +A random string of 32+ characters is required in the `JWT_SECRET` environment variable as a Jwt Secret. + +``` +JWT_SECRET="my-32-character-ultra-secure-and-ultra-long-secret" +``` + +#### Email service + +An azure communication service is needed to send emails. + +``` +EMAIL_CONNECTION_STRING="XXXXXXXX" +``` + +#### Connect to Postgres + +Set the postgres `CONNECTION_STRING` environment variable. + +``` +CONNECTION_STRING="Host=localhost; Database=fu_dev; Username=dev; Password=dev" +``` + +### Coding Style + +Follow Google's C# [style guide](https://google.github.io/styleguide/csharp-style.html) + +## Testing + +Run tests on the backend with `dotnet test`. Tests are located in `FU.API.Tests/`. They consist of service unit tests as behavioral testing is done by the SPA. + ## Understanding Controllers The controllers serve as wrappers around the Services. They take an http request, run a corresponding service calls, and return an http result. For example consider the following `GameController` class. @@ -73,72 +143,78 @@ When users navigate to a pages with a chat, they will be connected the chat grou First an avatar is uploaded to the server via `AvatarController.UploadAvatar`. Then it is validated to be an image, cropped to be square, resized, and converted to a JPEG using the `SkiaSharp` library. Then the image is uploaded to a public azure storage blob and its a url is returned to the user for previewing. If the user wants to use the image, they call `UsersController.UpdateProfile` to set it as their new avatar. After awhile, unused profile pictures are deleted by `PeriodicRemoteStorageCleanerHostedService`. -## Development +## Understanding DTOs -### External Requirements +DTOs, or Data Transfer Objects, serve as simple objects that carry data between processes, typically between a client and a server. -In order to run the project you first have to install: +Consider the following DTO as an example: -- [ASP.NET Core 7](https://learn.microsoft.com/en-us/aspnet/core/introduction-to-aspnet-core?view=aspnetcore-7.0) -- [PostgreSQL](https://www.postgresql.org/download/) - -### Postgres Setup - -Install and start the database by installing Docker and running the following command. - -``` -docker run --name postgres-490 -e POSTGRES_DB=fu_dev -e POSTGRES_PASSWORD=dev -e POSTGRES_USER=dev -p 5432:5432 postgres:alpine ``` +public record UserSearchRequestDTO +{ + [FromQuery] + public string? Keywords { get; set; } -To run the container at a later time, run: + [FromQuery] + public string? Sort { get; set; } -``` -docker container start postgres-490 -``` - -Alternatively, install and start a PostgreSQL database manually. - -### Config Setup - -Config settings are loaded from the environment variables. To automatically load the environment variable from a file, create a `.env` file in this folder. - -#### Blob storage - -An azure storage account is needed with a storage container. Public anonymous access must be enabled. The `STORAGE_CONNECTION_STRING` and `AVATAR_CONTAINER_NAME` environment variables must be set. + [FromQuery] + public int? Limit { get; set; } + [FromQuery] + public int? Page { get; set; } +} ``` -STORAGE_CONNECTION_STRING="XXXXXXXX" -AVATAR_CONTAINER_NAME="some-container-name" -``` + +In this example, UserSearchRequestDTO is a DTO used for handling user search requests. It contains properties for keywords, sorting criteria, pagination limits, and page numbers. The [FromQuery] attribute indicates that the properties should be bound from the query string when used in our ASP.NET Web API. -#### Jwt Secret - -A random string of 32+ characters is required in the `JWT_SECRET` environment variable as a Jwt Secret. +To process the user search request, a static mapper class is utilized to convert the UserSearchRequestDTO to a UserQuery. Here's an example of how it's done: ``` -JWT_SECRET="my-32-character-ultra-secure-and-ultra-long-secret" -``` - -#### Email service - -An azure communication service is needed to send emails. +public static class Mapper +{ + ... + public static UserQuery ToUserQuery(this UserSearchRequestDTO dto) + { + var query = new UserQuery() + { + Limit = dto.Limit ?? 20, + Page = dto.Page ?? 1, + SortType = UserSortType.Username, // Default value + SortDirection = SortDirection.Ascending // Default value + }; + + // Splitting keywords if provided + if (dto.Keywords is not null) + { + query.Keywords = dto.Keywords.Split(" ").ToList(); + } -``` -EMAIL_CONNECTION_STRING="XXXXXXXX" -``` + // Setting default sort if not provided + dto.Sort ??= "newest:desc"; -#### Connect to Postgres + // Parsing and setting sort type and direction + var arr = dto.Sort.ToLower().Split(":"); + query.SortType = arr[0] switch + { + "username" => UserSortType.Username, + "dob" => UserSortType.DOB, + "chatactivity" => UserSortType.ChatActivity, + _ => UserSortType.Username, + }; -Set the postgres `CONNECTION_STRING` environment variable. + if (arr.Length > 1 && arr[1].StartsWith("desc")) + { + query.SortDirection = SortDirection.Descending; + } + else + { + query.SortDirection = SortDirection.Ascending; + } + return query; + } +} ``` -CONNECTION_STRING="Host=localhost; Database=fu_dev; Username=dev; Password=dev" -``` - -### Coding Style - -Follow Google's C# [style guide](https://google.github.io/styleguide/csharp-style.html) - -## Testing -Run tests on the backend with `dotnet test`. Tests are located in `FU.API.Tests/`. They consist of service unit tests as behavioral testing is done by the SPA. +This static mapper class contains a method ToUserQuery which takes a UserSearchRequestDTO and converts it into a UserQuery object, which is then used in the search service to perform the actual search operation. diff --git a/FU.SPA/README.md b/FU.SPA/README.md index 92d66f2d..44aa4a88 100644 --- a/FU.SPA/README.md +++ b/FU.SPA/README.md @@ -1,10 +1,45 @@ # SPA Overview +## Tech Stack + +- SPA is built with React +- - Material UI is used for common components, some components are custom or modified versions of MUI components +- - Vite is used for local development hosting of the SPA and building the SPA for deployment +- npm for package management and scripting of installed packages + +### Understanding Components + +Components are rendered and displayed on the page. They integrate JavaScript code and React's JSX syntax to allow for complex functionality to be added to webpages. +Components are displayed in the Document Object Model, or DOM. Each component is a child of the root of the DOM, and components can be children of other comopnents. +This allows passing properties and information down to components, such as passing a title/username from a parent component down to a child. + +### Understanding Contexts + +Contexts are a way to pass data through the component tree/DOM without having to do it at every level. This simplifies a lot of logic for several different scenarios. +Things like a username, authentication token, login status, and more are not easily passed down through the DOM. For example, our `UserContext` looks like this: + +``` +const UserContext = createContext({ + user: null, + token: null, + login: () => {}, + logout: () => {}, + refreshUser: () => {}, +}); +``` + +This allows us to keep track of the user and call its properties at any level, as well as the user's authentication token. + +### Understanding Services + +Services are just thin wrappers used by the SPA to ease calling the API and allow for reuse of common API calls. +See the (API services)[https://github.com/SCCapstone/PalmettoProgrammers/blob/main/FU.API/README.md#understanding-services] for understanding of how they work. + ## Development Install [npm](https://www.npmjs.com/package/npm) and [Docker](https://www.docker.com/get-started/). -## Configure the API URL +### Configure the API URL Config settings are loaded from environment variables. To automatically load environment variables from a file, create a `.env` file in this folder. @@ -14,7 +49,7 @@ Set the `VITE_API_URL` environment variable by adding the following to `.env`. If there are CORS errors, change the URL to match `http://` instead of `https://` and this may resolve the issue. -## Running with docker +### Running with Docker Add the following to your `hosts` file (`/etc/hosts` on Linux and `C:\Windows\System32\drivers\etc` on Windows). diff --git a/FU.SPA/assets/create-post-view.png b/FU.SPA/assets/create-post-view.png new file mode 100644 index 00000000..821dfe95 Binary files /dev/null and b/FU.SPA/assets/create-post-view.png differ diff --git a/FU.SPA/assets/discover-posts-view.png b/FU.SPA/assets/discover-posts-view.png new file mode 100644 index 00000000..51ddb895 Binary files /dev/null and b/FU.SPA/assets/discover-posts-view.png differ diff --git a/FU.SPA/assets/friends-view.png b/FU.SPA/assets/friends-view.png new file mode 100644 index 00000000..5648e074 Binary files /dev/null and b/FU.SPA/assets/friends-view.png differ diff --git a/FU.SPA/assets/post-view.png b/FU.SPA/assets/post-view.png new file mode 100644 index 00000000..3c241774 Binary files /dev/null and b/FU.SPA/assets/post-view.png differ diff --git a/FU.SPA/package-lock.json b/FU.SPA/package-lock.json index 00298bd6..231a781f 100644 --- a/FU.SPA/package-lock.json +++ b/FU.SPA/package-lock.json @@ -22,7 +22,8 @@ "react-dom": "^18.2.0", "react-notifications-component": "^4.0.1", "react-router": "^6.18.0", - "selenium-side-runner": "^4.0.0-alpha.66" + "selenium-side-runner": "^4.0.0-alpha.66", + "validator": "^13.11.0" }, "devDependencies": { "@types/react": "^18.2.15", @@ -9540,6 +9541,14 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, + "node_modules/validator": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vite": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", diff --git a/FU.SPA/package.json b/FU.SPA/package.json index 5a2107b3..655bc792 100644 --- a/FU.SPA/package.json +++ b/FU.SPA/package.json @@ -32,7 +32,8 @@ "react-dom": "^18.2.0", "react-notifications-component": "^4.0.1", "react-router": "^6.18.0", - "selenium-side-runner": "^4.0.0-alpha.66" + "selenium-side-runner": "^4.0.0-alpha.66", + "validator": "^13.11.0" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/FU.SPA/src/App.jsx b/FU.SPA/src/App.jsx index 374f7438..280b676a 100644 --- a/FU.SPA/src/App.jsx +++ b/FU.SPA/src/App.jsx @@ -20,6 +20,10 @@ import EditPost from './components/pages/EditPost'; import { ReactNotifications } from 'react-notifications-component'; import 'react-notifications-component/dist/theme.css'; +/* Top level of application + * Provides theme, notifications, general CSS, User Context, Navbar, + * and top level routes + */ function App() { return ( diff --git a/FU.SPA/src/Theme.js b/FU.SPA/src/Theme.js index 3a794d3a..c2bcfb26 100644 --- a/FU.SPA/src/Theme.js +++ b/FU.SPA/src/Theme.js @@ -1,5 +1,6 @@ import { createTheme } from '@mui/material/styles'; +// Main color palatte for the application const COLORS = { PRIMARY_MAIN: '#e354dc', SECONDARY_MAIN: '#4290f5', @@ -7,6 +8,7 @@ const COLORS = { BACKGROUND_DEFAULT: '#23194f', }; +// Create Theme for the ThemeProvider const Theme = createTheme({ palette: { mode: 'dark', diff --git a/FU.SPA/src/components/Chat.css b/FU.SPA/src/components/Chat.css index b63acfbb..f65ee165 100644 --- a/FU.SPA/src/components/Chat.css +++ b/FU.SPA/src/components/Chat.css @@ -1,10 +1,6 @@ .chat-actions { gap: 10px; - input { - width: 1500px; - } - button { /* background-color: #23194f; */ height: 100%; diff --git a/FU.SPA/src/components/Chat.jsx b/FU.SPA/src/components/Chat.jsx index 2db1895c..4b5d111d 100644 --- a/FU.SPA/src/components/Chat.jsx +++ b/FU.SPA/src/components/Chat.jsx @@ -17,6 +17,12 @@ import ChatMessage from './ChatMessage'; import UserContext from '../context/userContext'; import config from '../config'; +/** + * The Chat component is used to render the chat interface + * + * @param {number} chatId The id of the chat + * @returns The rendered chat component + */ export default function Chat({ chatId }) { const [message, setMessage] = useState(''); const [messages, setMessages] = useState([]); @@ -27,6 +33,7 @@ export default function Chat({ chatId }) { const [isNewMessageReceived, setIsNewMessageReceived] = useState(false); const limit = 25; + // Set the chat messages and join the chat group useEffect(() => { const initializeChat = async () => { try { @@ -44,6 +51,7 @@ export default function Chat({ chatId }) { } }; + // Handles receiving messages const handleReceiveMessage = (receivedMessage) => { setMessages((prevMessages) => [...prevMessages, receivedMessage]); @@ -68,6 +76,7 @@ export default function Chat({ chatId }) { }; }, [chatId, user]); + // Load more messages when user is scrolled near the top useEffect(() => { const loadMoreMessages = async () => { try { @@ -91,6 +100,7 @@ export default function Chat({ chatId }) { } }, [offset, chatId]); + // Save the message to the database async function handleSendMessage() { try { if (message === '') { @@ -103,6 +113,7 @@ export default function Chat({ chatId }) { } } + // Handle scrolling to load more messages const handleScroll = (event) => { if (event.target.scrollTop === 0) { if (hasMoreMessages) { @@ -111,6 +122,7 @@ export default function Chat({ chatId }) { } }; + // Scroll to the bottom when messages are updated useEffect(() => { // Scroll to the bottom when messages are updated const chatContainer = document @@ -124,8 +136,6 @@ export default function Chat({ chatId }) { } }, [messages, prevScrollHeight]); - // Use MUi card for chat - return ( ))} - + setMessage(e.target.value)} diff --git a/FU.SPA/src/components/ChatLocked.jsx b/FU.SPA/src/components/ChatLocked.jsx index ebd91637..9ca34e08 100644 --- a/FU.SPA/src/components/ChatLocked.jsx +++ b/FU.SPA/src/components/ChatLocked.jsx @@ -11,6 +11,7 @@ import { useNavigate } from 'react-router-dom'; export default function ChatLocked({ chatType, reason, onResolutionClick }) { const navigate = useNavigate(); + // Render the message based on the reason const renderMessage = () => { let reasonMessage; @@ -29,6 +30,7 @@ export default function ChatLocked({ chatType, reason, onResolutionClick }) { ); }; + // Render the resolution button based on the reason const renderResolution = () => { // get the current path var path = window.location.pathname; diff --git a/FU.SPA/src/components/ChatMessage.jsx b/FU.SPA/src/components/ChatMessage.jsx index 4300cc43..5428336c 100644 --- a/FU.SPA/src/components/ChatMessage.jsx +++ b/FU.SPA/src/components/ChatMessage.jsx @@ -3,6 +3,14 @@ import { Avatar } from '@mui/material'; import { useNavigate } from 'react-router-dom'; import DateUtils from '../helpers/dateUtils'; +/** + * This component is used to render a chat message + * + * @param {object} chatMessage The chat message object + * @param {boolean} userIsSender A boolean to check if the current user is the sender + * + * @returns The rendered chat message component + */ export default function ChatMessage({ chatMessage, userIsSender }) { const user = chatMessage.sender; const navigate = useNavigate(); diff --git a/FU.SPA/src/components/ChatMessagePreview.jsx b/FU.SPA/src/components/ChatMessagePreview.jsx index 19c0b1bc..c8d37f6e 100644 --- a/FU.SPA/src/components/ChatMessagePreview.jsx +++ b/FU.SPA/src/components/ChatMessagePreview.jsx @@ -1,6 +1,13 @@ import { Typography } from '@mui/material'; import DateUtils from '../helpers/dateUtils'; +/** + * The ChatMessagePreview component is used to render a chat message preview + * + * @param {object} chatMessage The chat message object + * + * @returns The rendered chat message preview component + */ export default function ChatMessage({ chatMessage }) { return (
diff --git a/FU.SPA/src/components/CreateGroup.jsx b/FU.SPA/src/components/CreateGroup.jsx index 80f4a982..f2069205 100644 --- a/FU.SPA/src/components/CreateGroup.jsx +++ b/FU.SPA/src/components/CreateGroup.jsx @@ -1,3 +1,4 @@ +// NOTE: unused component and deprecated import { Button, TextField, @@ -11,15 +12,6 @@ import { TextareaAutosize } from '@mui/base/TextareaAutosize'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import Radio from '@mui/material/Radio'; -// TODO remove, this demo shouldn't need to reset the theme. - -//const defaultTheme = createTheme(); - -//Look at changing to const CreatePost = () => { -// CreatingPost(); -//} or something similiar. -//Design of the page.// TODO START OF PAGE CODE, WHICH I'M CHANGING TO CREATE GROUP, BEFORE MAKING IT A BUTTON FUNCTION (semi-completed) -// need to add a group image and an upload button to the left under the buttons that will be placed there, as well. export default function CreateGroup() { return ( diff --git a/FU.SPA/src/components/Navbar.jsx b/FU.SPA/src/components/Navbar.jsx index 4ab3031d..30474941 100644 --- a/FU.SPA/src/components/Navbar.jsx +++ b/FU.SPA/src/components/Navbar.jsx @@ -83,6 +83,7 @@ export default function Navbar() { }, })); + // Renders the profile picture and username on the navbar const renderProfile = () => ( <>
@@ -182,6 +183,7 @@ export default function Navbar() { setAnchorElUser(null); }; + // Displays navbar component return ( diff --git a/FU.SPA/src/components/PostCard.jsx b/FU.SPA/src/components/PostCard.jsx index 1bd8bd58..98f32a40 100644 --- a/FU.SPA/src/components/PostCard.jsx +++ b/FU.SPA/src/components/PostCard.jsx @@ -16,6 +16,7 @@ import dayjs from 'dayjs'; import { Done } from '@mui/icons-material'; import ChatMessagePreview from './ChatMessagePreview'; +// Function that displays a card with details of a given post const PostCard = ({ post, showActions, onTagClick, showJoinedStatus }) => { const navigate = useNavigate(); const user = post.creator; @@ -24,6 +25,7 @@ const PostCard = ({ post, showActions, onTagClick, showJoinedStatus }) => { showActions = true; } + // const handleTagClick = (tag) => { if (onTagClick) { onTagClick(tag); @@ -46,8 +48,10 @@ const PostCard = ({ post, showActions, onTagClick, showJoinedStatus }) => { if (post.startTime) { let startOfToday = dayjs().startOf('day'); let postStartDateTime = dayjs(post.startTime); + let postEndDateTime = dayjs(post.endTime); let startDate = dayjs(post.startTime).format('MMM D, YYYY'); + // This block handles formatting of start date display on card if (postStartDateTime < startOfToday) { // Use default } else if (postStartDateTime < startOfToday.add(1, 'day')) { @@ -60,6 +64,23 @@ const PostCard = ({ post, showActions, onTagClick, showJoinedStatus }) => { startDate = dayjs(post.startTime).format('MMM D'); } + let endDate = ''; + if (!postEndDateTime.isSame(postStartDateTime, 'day')) { + endDate = dayjs(post.endTime).format('MMM D, YYYY'); + // This block handles formatting of end date display on card + if (postEndDateTime < startOfToday) { + // Use default + } else if (postEndDateTime < startOfToday.add(1, 'day')) { + endDate = 'Today'; + } else if (postEndDateTime < startOfToday.add(2, 'day')) { + endDate = 'Tomorrow'; + } else if (postEndDateTime < startOfToday.add(6, 'day')) { + endDate = dayjs(post.endTime).format('ddd'); + } else if (postEndDateTime < startOfToday.add(1, 'year')) { + endDate = dayjs(post.endTime).format('MMM D'); + } + } + var startTime = new Date(post.startTime).toLocaleString('en-US', { timeStyle: 'short', }); @@ -67,7 +88,11 @@ const PostCard = ({ post, showActions, onTagClick, showJoinedStatus }) => { timeStyle: 'short', }); - dateTimeString = `${startDate}, ${startTime} - ${endTime}`; + dateTimeString = `${startDate}, ${startTime} - `; + if (endDate !== '') { + dateTimeString += `${endDate}, `; + } + dateTimeString += `${endTime}`; } else { dateTimeString = 'No time'; } @@ -88,6 +113,7 @@ const PostCard = ({ post, showActions, onTagClick, showJoinedStatus }) => { return color; }; + // Returns card with post details to be displayed return ( @@ -127,9 +153,11 @@ const PostCard = ({ post, showActions, onTagClick, showJoinedStatus }) => { {post.game} - - {dateTimeString} - + + + {dateTimeString} + + { const { user } = useContext(UserContext); @@ -31,6 +33,7 @@ const PostForm = ({ onSubmit, submitButtonText, initialValue }) => { const [isEnabled, setIsEnabled] = useState(false); const [gameError, setGameError] = useState(''); + const [tagError, setTagError] = useState(''); const [titleError, setTitleError] = useState(''); const [startDateError, setStartDateError] = useState(''); const [endDateError, setEndDateError] = useState(''); @@ -53,6 +56,8 @@ const PostForm = ({ onSubmit, submitButtonText, initialValue }) => { // Manage enabled state useEffect(() => { + const validTags = tags.every((tag) => tag.name.length <= 10); + const validTagCount = tags.length <= 4; setIsEnabled( game?.name?.length >= 3 && title.length >= 3 && @@ -62,9 +67,11 @@ const PostForm = ({ onSubmit, submitButtonText, initialValue }) => { startTime.isAfter(dayjs()) && startTime.isBefore(endTime) && endTime.isBefore(startTime.add(24, 'hours')) && - endTime.isAfter(startTime), + endTime.isAfter(startTime) && + validTags && + validTagCount, ); - }, [game, title, isEnabled, endTime, startTime, description.length]); + }, [game, title, endTime, startTime, description.length, tags]); // Handles game state error const handleGameChange = (e) => { @@ -79,15 +86,29 @@ const PostForm = ({ onSubmit, submitButtonText, initialValue }) => { } }; + // Handles tag state error + const handleTagChange = (e) => { + const validTags = e.length <= 4 && e.every((tag) => tag.name.length <= 10); + if (!validTags) { + setTagError( + 'Ensure tags are up to 10 characters and no more than 4 tags.', + ); + setIsEnabled(false); + } else { + setTagError(''); + setTags(e); + } + }; + // Handles title state error const handleTitleChange = (e) => { - if (e.target.value < 3) { + if (e.target.value.length < 3 && e.target.value.length > 0) { setTitleError('Title must be longer than 3 characters'); setTitle(e.target.value); } else { - setTitle(e.target.value); setTitleError(''); } + setTitle(e.target.value); }; // Handles start date state error @@ -107,6 +128,7 @@ const PostForm = ({ onSubmit, submitButtonText, initialValue }) => { } }; + // Handles description change state error const handleDescriptionChange = (e) => { if (e.length > 1500) { setDescriptionError('Description cannot exceed 1500 characters'); @@ -137,28 +159,46 @@ const PostForm = ({ onSubmit, submitButtonText, initialValue }) => { const handleSubmit = async (e) => { e.preventDefault(); - let tagIds = []; - - // Gets tags from API or creates them - for (const tag of tags) { - const newTag = await TagService.findOrCreateTagByName(tag.name); - tagIds.push(newTag.id); - } + try { + let tagIds = []; - // Gets game from API or creates it - var findGame = await GameService.findOrCreateGameByTitle(game.name); - - // Form payload - const post = { - title: title, - description: description, - startTime: startTime !== null ? startTime.toISOString() : null, - endTime: endTime !== null ? endTime.toISOString() : null, - tagIds: tagIds, - gameId: findGame.id, - }; + // Gets tags from API or creates them + for (const tag of tags) { + const newTag = await TagService.findOrCreateTagByName(tag.name); + tagIds.push(newTag.id); + } - onSubmit(post); + // Gets game from API or creates it + var findGame = await GameService.findOrCreateGameByTitle(game.name); + + // Form payload + const post = { + title: title, + description: description, + startTime: startTime !== null ? startTime.toISOString() : null, + endTime: endTime !== null ? endTime.toISOString() : null, + tagIds: tagIds, + gameId: findGame.id, + }; + + onSubmit(post); + } catch (e) { + // Error notification + Store.addNotification({ + title: 'Error has occured', + message: 'An error has occured.\n' + e, + type: 'danger', + insert: 'bottom', + container: 'bottom-right', + animationIn: ['animate__animated', 'animate__fadeIn'], + animationOut: ['animate__animated', 'animate__fadeOut'], + dismiss: { + duration: 8000, + onScreen: true, + }, + }); + console.error('Error in post form: ', e); + } }; const getPreviewTags = () => { @@ -169,6 +209,7 @@ const PostForm = ({ onSubmit, submitButtonText, initialValue }) => { return tags?.map((tag) => tag.name); }; + // Returns form component to be displayed return (
@@ -198,7 +239,7 @@ const PostForm = ({ onSubmit, submitButtonText, initialValue }) => { { }} /> - + 1500} label="Description" @@ -334,6 +379,7 @@ const GameSelector = ({ onChange, initialValue }) => { return filtered; }; + // Returns Game selector field return ( { const TagsSelector = ({ onChange, initialValues }) => { const [tagOptions, setTagOptions] = useState([]); const [value, setValue] = useState([]); + const [error, setError] = useState(false); // Load initial values useEffect(() => { @@ -390,14 +437,12 @@ const TagsSelector = ({ onChange, initialValues }) => { }, [initialValues]); const onInputChange = (event, newValues) => { - for (const newValue of newValues) { - if (newValue.id === null) { - // if not in options add to options - if (!tagOptions.some((o) => o.name === newValue.name)) { - const newOptions = tagOptions.concat([newValue]); - setTagOptions(newOptions); - } - } + if (newValues.length > 4) { + setError(true); + } else if (newValues.some((tag) => tag.name.length > 10)) { + setError(true); + } else { + setError(false); } setValue(newValues); @@ -425,6 +470,7 @@ const TagsSelector = ({ onChange, initialValues }) => { return filtered; }; + // Returns tag selector field return ( { 4 + ? `You can only add up to 4 tags.` + : `Tag cannot exceed 10 characters.` + : '' + } /> )} /> diff --git a/FU.SPA/src/components/PostUsersList.jsx b/FU.SPA/src/components/PostUsersList.jsx index 6691575d..43c4d3ae 100644 --- a/FU.SPA/src/components/PostUsersList.jsx +++ b/FU.SPA/src/components/PostUsersList.jsx @@ -6,6 +6,7 @@ import PostService from '../services/postService'; import './PostUsersList.css'; import { useNavigate } from 'react-router-dom'; +// Component that displays all users that have joined a post export default function PostUsersList({ postId }) { const [users, setUsers] = useState([]); @@ -91,6 +92,7 @@ const UserListItem = ({ user }) => { return initials; }; + // Display profile picture and names of users in a post const renderPfp = () => { return defaultPfp ? ( { ); }; + // Display status of users in a post const renderOnlineStatus = (isOnline) => { if (!isOnline) return; diff --git a/FU.SPA/src/components/ProtectedRoute.jsx b/FU.SPA/src/components/ProtectedRoute.jsx index 4bf12d33..5ce2a702 100644 --- a/FU.SPA/src/components/ProtectedRoute.jsx +++ b/FU.SPA/src/components/ProtectedRoute.jsx @@ -3,6 +3,7 @@ import { Navigate } from 'react-router-dom'; import UserContext from '../context/userContext'; import config from '../config'; +// Function that handles protecting routes based on user authorization level export const ProtectedRoute = ({ children }) => { const { user } = useContext(UserContext); const [isLoading, setIsLoading] = useState(true); @@ -12,7 +13,8 @@ export const ProtectedRoute = ({ children }) => { useEffect(() => { const delay = async () => { - // See #281: We need to wait for the user to be set before rendering the children + // See #281: We need to wait for the user to be set before rendering the + // children await new Promise((resolve) => setTimeout(resolve, config.WAIT_TIME)); setIsLoading(false); }; diff --git a/FU.SPA/src/components/TextSearch.jsx b/FU.SPA/src/components/TextSearch.jsx index b27dbd9e..a54be9f6 100644 --- a/FU.SPA/src/components/TextSearch.jsx +++ b/FU.SPA/src/components/TextSearch.jsx @@ -2,6 +2,7 @@ import { TextField, InputAdornment, IconButton } from '@mui/material'; import { useEffect, useState } from 'react'; import SearchIcon from '@mui/icons-material/Search'; +// Component search bar that handles searching of posts and users function SearchBar({ searchText, onSearchSubmit }) { const [localSearchText, setLocalSearchText] = useState(searchText); diff --git a/FU.SPA/src/components/UserCard.jsx b/FU.SPA/src/components/UserCard.jsx index beec3201..9dc2bf51 100644 --- a/FU.SPA/src/components/UserCard.jsx +++ b/FU.SPA/src/components/UserCard.jsx @@ -14,6 +14,7 @@ import { useNavigate } from 'react-router-dom'; import { People, PendingActions, CallMade } from '@mui/icons-material'; import ChatMessagePreview from './ChatMessagePreview'; +// Function that displays a card with details of a given user const UserCard = ({ user, showRelationStatus, showActions }) => { if (showRelationStatus === undefined) { showRelationStatus = false; @@ -42,6 +43,7 @@ const UserCard = ({ user, showRelationStatus, showActions }) => { (today.getTime() - dob.getTime()) / (1000 * 3600 * 24 * 365), ); + // Handles displaying relationship status with another user const renderRelationStatus = () => { if (!showRelationStatus) { return null; @@ -70,6 +72,7 @@ const UserCard = ({ user, showRelationStatus, showActions }) => { } }; + // Return card to be displayed return ( Are you sure you want to delete your account? diff --git a/FU.SPA/src/components/pages/CreatePost.jsx b/FU.SPA/src/components/pages/CreatePost.jsx index eac05bec..249d1ced 100644 --- a/FU.SPA/src/components/pages/CreatePost.jsx +++ b/FU.SPA/src/components/pages/CreatePost.jsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Store } from 'react-notifications-component'; import PostForm from '../PostForm'; +// Create post page export default function CreatePost() { const navigate = useNavigate(); diff --git a/FU.SPA/src/components/pages/Discover.jsx b/FU.SPA/src/components/pages/Discover.jsx index f5521372..b3557e4e 100644 --- a/FU.SPA/src/components/pages/Discover.jsx +++ b/FU.SPA/src/components/pages/Discover.jsx @@ -33,6 +33,7 @@ const paramKey = { userSort: 'usort', }; +// Converts a search paramater to its dayjs equivalent const paramToDayjs = (searchParams, paramKey) => { let paramValue = searchParams.get(paramKey); if (!paramValue || !dayjs(paramValue).isValid()) return undefined; @@ -45,6 +46,7 @@ export default function Discover() { Users: 'Users', }; + // STATE VARIABLES START const queryLimit = 12; const [totalResults, setTotalResults] = useState(0); const [searchParams, setSearchParams] = useSearchParams(); @@ -112,11 +114,17 @@ export default function Discover() { const [endTime, setEndTime] = useState( paramToDayjs(searchParams, paramKey.endTime), ); + // STATE VARIABLES END + // useEffect to update search params useEffect(() => { const updateSearchParams = async () => { setSearchParams( (params) => { + /* This large block sets the search paramters for a query + * Also handles error checking so that invalid params cannot + * be included + */ if ( dateRangeRadioValue === DateFilterRadioValues.between && startDate?.isValid() @@ -189,7 +197,6 @@ export default function Discover() { ); }; - //TODO pull this out to directly call const updateSearchResults = async () => { if (tabOption === tabOptions.Posts) { const query = { @@ -318,6 +325,7 @@ export default function Discover() { } }; + // Render posts or users based on tab option selected const renderTabContent = () => { if (tabOption === tabOptions.Posts) { return ( @@ -328,6 +336,7 @@ export default function Discover() { } }; + // Displays post sort selector for sort options const renderPostSortSelector = () => { return ( { return ( { return (
{ return ( @@ -18,7 +23,7 @@ const Home = () => { make friends. - + Don't limit yourself to restrictive or random in-game matchmaking. Find like-minded players and play how you want when you @@ -28,25 +33,25 @@ const Home = () => { If you can't find a match, make a new post and invite others to join! - + As a post member, discuss meetup detail, strategize a game plan, or just chat. - + Befriend others to keep in touch and meet up later. - +