diff --git a/README.md b/README.md
index 9f1a8c98..66d6e944 100644
--- a/README.md
+++ b/README.md
@@ -9,11 +9,55 @@ An interplanetary town square that maintains connections between cultures and id
## Authors
-- Kamal Dolikay ([@kamalkdolikay](https://github.com/kamalkdolikay))
-- Ole Lammers ([@zyrakia](https://www.github.com/Zyrakia))
-- Tianyou Xie ([@Tianyou-Xie](https://github.com/Tianyou-Xie))
-- Samarjit Bhogal ([@SamarjitBhogal](https://github.com/SamarjitBhogal))
-- Marcus Lages ([@MarcusLages](https://github.com/MarcusLages))
+- Kamal Dolikay
+ - GitHub: [@kamalkdolikay](https://github.com/kamalkdolikay)
+ - Email: [kamaldolikay@gmail.com](mailto:kamaldolikay@gmail.com)
+- Ole Lammers
+ - GitHub: [@zyrakia](https://www.github.com/Zyrakia)
+ - Email: [ole.lammers@pm.me](mailto:ole.lammers@pm.me)
+- Tianyou Xie
+ - GitHub: [@Tianyou-Xie](https://github.com/Tianyou-Xie)
+ - Email: [tianyouxie001@gmail.com](mailto:tianyouxie001@gmail.com)
+- Samarjit Bhogal
+ - GitHub: [@SamarjitBhogal](https://github.com/SamarjitBhogal)
+ - Email: [samarjit.v.bhogal@gmail.com](mailto:samarjit.v.bhogal@gmail.com)
+- Marcus Lages
+ - GitHub: [@MarcusLages](https://github.com/MarcusLages)
+ - Email: [marcusvlages@gmail.com](mailto:marcusvlages@gmail.com)
+
+## Features
+
+- Create Account and Login (Email/Password or Google)
+- Delete Account and Logout
+- Edit Account
+- Edit Profile
+- View Available Planets
+ - Map Mode
+ - List Mode
+- Create Posts on Various Planets
+- View Posts
+ - Galactic Feed
+ - Planetary Feed
+ - User Feed
+- Interact with Posts
+ - Comment
+ - Like / Unlike
+ - Save / Unsave
+ - Share
+- Change Avatar
+- Search for Posts
+- Search for Users
+- Interact with Users
+ - Follow / Unfollow
+ - Message (real-time)
+- View Interactions
+ - Saved Posts
+ - Liked Posts
+ - Following
+ - Followers
+- View Privacy Policy
+- View Terms of Use
+- View FAQs
## Technologies
@@ -32,6 +76,13 @@ The project is built using [Typescript](https://www.typescriptlang.org/), and sp
- [React If](https://https://www.npmjs.com/package/react-if/)
- [React Toastify](https://fkhadra.github.io/react-toastify/introduction/)
- [Socket IO](https://socket.io/) (Client)
+- [Popper](https://popper.js.org/docs/v2/)
+- [Axios](https://axios-http.com/docs/intro)
+- [Axios Cache](https://axios-cache-interceptor.js.org/)
+- [Is Mobile](https://www.npmjs.com/package/is-mobile)
+- [Konva](https://konvajs.org/index.html)
+- [React Helmet](https://www.npmjs.com/package/react-helmet)
+- [Use Image](https://www.npmjs.com/package/use-image)
**Server (built with [esno](https://www.npmjs.com/package/esno)):**
@@ -41,15 +92,17 @@ The project is built using [Typescript](https://www.typescriptlang.org/), and sp
- [Express File Routing](https://www.npmjs.com/package/express-file-routing)
- [Joi](https://joi.dev/)
- [bcrypt](https://github.com/kelektiv/node.bcrypt.js)
-- [http-status-codes](https://www.npmjs.com/package/http-status-codes)
-- [nodemailer](https://www.nodemailer.com/)
+- [HTTP Status Codes](https://www.npmjs.com/package/http-status-codes)
+- [Nodemailer](https://www.nodemailer.com/)
- [JWT](https://jwt.io/)
- [CORS](https://www.npmjs.com/package/cors)
- [dotenv](https://www.dotenv.org/)
- [Google Auth Library](https://cloud.google.com/nodejs/docs/reference/google-auth-library/latest)
- [Google APIs](https://www.npmjs.com/package/googleapis)
-- [Http Status Codes](https://www.npmjs.com/package/http-status-codes)
- [Socket IO](https://socket.io/) (Server)
+- [Cloudinary](https://cloudinary.com/)
+- [OpenAI](https://openai.com/)
+- [UUID](https://www.npmjs.com/package/uuid)
**Development Utilities:**
@@ -59,8 +112,17 @@ The project is built using [Typescript](https://www.typescriptlang.org/), and sp
## Code Attributions
-- Regex escape utility: [(`./server/utils/regex.ts:10`)](https://github.com/Tianyou-Xie/2800_202410_BBY07/blob/dev/server/src/utils/regex.ts#L10)
+- Regex Escape Utility: [(`./server/src/utils/regex.ts:10`)](https://github.com/Tianyou-Xie/2800_202410_BBY07/blob/dev/server/src/utils/regex.ts#L10)
> https://github.com/component/escape-regexp/blob/master/index.js
+- Helmet SEO Component Structure: [(`./client/src/components/seo/seo.tsx`)](https://github.com/Tianyou-Xie/2800_202410_BBY07/blob/dev/client/src/components/seo/seo.tsx#L23-L37)
+ > https://www.freecodecamp.org/news/react-helmet-examples
+- Hotbar Structure and Animation Inspiration: [(`./client/src/components/Hotbar/Hotbar.tsx`)](https://github.com/Tianyou-Xie/2800_202410_BBY07/blob/dev/client/src/components/Hotbar/Hotbar.tsx)
+ > https://youtu.be/sgV111KxbwI?si=HnOS5OnfLswXk_8E
+- Customized React Toast: [(`./client/src/index.css`)](https://github.com/Tianyou-Xie/2800_202410_BBY07/blob/dev/client/src/index.css)
+ > - https://stackoverflow.com/questions/60849448/how-can-i-change-the-styles-of-the-react-toastify-popup-message
+ > - https://fkhadra.github.io/react-toastify/introduction/
+- Planet Map Orbit Calculations [(`./client/src/pages/planet-map/planet-visual.tsx`)](https://github.com/Tianyou-Xie/2800_202410_BBY07/blob/dev/client/src/pages/planet-map/planet-visual.tsx#L70-L78)
+ > https://jsfiddle.net/ColinCee/a2yu0af6/
## Environment Variables
@@ -76,30 +138,365 @@ Both the server and client utilize a `.env` file.
**Client Variables:**
-| Key | Usage |
-| ---- | ------------------------------ |
-| PORT | Port used for the frontend app |
-| VITE_LOCALHOST | Host used for listening to Server Socket Events |
+| Key | Usage |
+| ---------------- | --------------------------------------------------------------------------------------- |
+| VITE_PORT | The port that Vite serves the client on, when running locally |
+| VITE_SERVER_PORT | The port that the server is running on, when running locally |
+| VITE_NODE_ENV | The environment the client is running in (development_local / development / production) |
**Server Variables:**
-| Key | Usage |
-| ---------- | --------------------------------------------- |
-| PORT | Port used for the express server |
-| MONGO_URL | The MongoDB connection string |
-| JWT_TTL | The JWT token expiry time, in seconds |
-| JWT_SECRET | The secret used to sign and verify JWT tokens |
+| Key | Usage |
+| ----------------------- | --------------------------------------------------------------------------------------- |
+| PORT | The port that Express starts the server on, when running locally |
+| CLIENT_PORT | The port that the client is being served on, when running locally |
+| NODE_ENV | The environment the server is running in (development_local / development / production) |
+| MONGO_URL | The MongoDB connection string used to connect to the database |
+| JWT_SECRET | The secret used to sign and verify JWT tokens |
+| JWT_TTL | The JWT token expiry time, in seconds |
+| GOOGLE_OAUTH_ID | The Google OAuth API Client ID used to authenticate with Google |
+| GOOGLE_OAUTH_SECRET | The Google OAuth API Client Secret used to authenticate with Google |
+| EMAIL_HOST | The hostname of the email transporter used to send emails from the server |
+| EMAIL_PORT | The port of the email transporter used to send emails from the server |
+| EMAIL_USER | The username of the email transporter used to send emails from the server |
+| EMAIL_PASS | The password of the email transporter used to send emails from the server |
+| CLOUDINARY_CLOUD_NAME | The Cloudinary cloud name used to upload images |
+| CLOUDINARY_CLOUD_KEY | The Cloudinary cloud API key used to upload images |
+| CLOUDINARY_CLOUD_SECRET | The Cloudinary cloud API secret used to upload images |
+| OPENAI_API_KEY | The OpenAI API key used to generate images |
## Running Locally & Deployment
-During development, both the client and server use the command `npm run dev` to launch the development server with file watching.
+**Running the Server:**
+
+1. Ensure you have all server environment variables set
+2. Enter the server directory (`cd server`)
+3. Ensure all dependencies are installed (`npm i`)
+4. Launch the server in regular (`npm start`) or watch mode (`npm run dev`)
+
+The server does not compile, it runs as a Node app. In order to deploy it, you must deploy the `server` folder and run the `npm start` command inside the deployed folder.
+
+**Running the Client:**
+
+1. Ensure you have all client environment variables set
+2. Enter the client directory (`cd client`)
+3. Ensure all dependencies are installed (`npm i`)
+4. Launch the client in preview (`npm start`) or watch mode (`npm run dev`)
+
+The client compiles to a `dist` folder (`npm run build`). This folder can be deployed as a static site. Because this project uses client side navigation, you must ensure the service you are using to deploy the client has a rewrite rule to rewrite all requests to the `/` route. If request URLs are not rewritten by the deployment service, the client side routing will not be able to display the correct page when loading the page from a URL that does not point to the `/` route.
-The client compiles to a `dist` folder with the command `npm run build`. This folder can be deployed as is. The command `npm start` will first build and then preview the build with vite.
+## AI Acknowledgement
-The server does not compile, esno is used in watch mode during development (`npm run dev`), and in static mode during deployment. The whole project must be deployed and started with `npm start`.
+There are a few instances where AI was used in this project:
+
+- Default Avatars - when a user signs up, an avatar is automatically generated for them using AI
+- Policy Page - an inital draft was created with the help of ChatGPT
+- Terms of Use Page - an inital draft was created with the help of ChatGPT
+- FAQs Page - ChatGPT was use to get an idea of common questions that people have about social media platforms
+- Planet Images - the images on the homepage for the different planets were generated with AI and then edited manually to enhance the look and feel
## Project Links
+- [Hosted Site](https://skynetwork.app)
+- [Hosted Development Site](https://dev.skynetwork.app)
- [GitHub](https://github.com/1800-BBY8/1800_202410_BBY8)
- [Trello](https://trello.com/b/ENhDCODq/2800202410bby07)
- [FigJam](https://www.figma.com/file/lM0sT0hbMY3v0cW2zLn5hC/2800-202410-BBY07?type=whiteboard&node-id=0-1&t=fR12pb3gUrK1EDNj-0)
+
+## Files and Folders (as of commit [9720d71](https://github.com/Tianyou-Xie/2800_202410_BBY07/commit/9720d7117787446e357ea41a0d10af68b33e64a5))
+
+```
+.
+├── .gitignore
+├── .prettierrc
+├── .vscode
+│ ├── extensions.json
+│ └── settings.json
+├── README.md
+├── Skynet Logo.png
+├── client
+│ ├── index.html
+│ ├── package-lock.json
+│ ├── package.json
+│ ├── public
+│ │ ├── among-us.jpg
+│ │ ├── among-us2.jpg
+│ │ ├── favicon.ico
+│ │ └── logo.webp
+│ ├── src
+│ │ ├── app.tsx
+│ │ ├── assets
+│ │ │ ├── fonts
+│ │ │ │ ├── BITSUMIS.TTF
+│ │ │ │ ├── BabaPro-Bold.ttf
+│ │ │ │ ├── FjallaOne-Regular.ttf
+│ │ │ │ └── TT-Octosquares-Trial-Regular.ttf
+│ │ │ ├── images
+│ │ │ │ ├── amongus-black.webp
+│ │ │ │ ├── amongus-blue.webp
+│ │ │ │ ├── amongus-green.webp
+│ │ │ │ ├── amongus-pink.webp
+│ │ │ │ ├── amongus-red.webp
+│ │ │ │ ├── amongus-white.webp
+│ │ │ │ ├── amongus-yellow.webp
+│ │ │ │ ├── icons
+│ │ │ │ │ ├── android-chrome-192x192.png
+│ │ │ │ │ ├── android-chrome-512x512.png
+│ │ │ │ │ ├── apple-touch-icon.png
+│ │ │ │ │ ├── favicon-16x16.png
+│ │ │ │ │ ├── favicon-32x32.png
+│ │ │ │ │ ├── favicon.ico
+│ │ │ │ │ └── site.webmanifest
+│ │ │ │ └── skynet-logo.png
+│ │ │ └── videos
+│ │ │ ├── home.gif
+│ │ │ ├── message.gif
+│ │ │ └── post.gif
+│ │ ├── components
+│ │ │ ├── google-auth-btn
+│ │ │ │ └── google-auth-btn.tsx
+│ │ │ ├── header
+│ │ │ │ ├── header.module.css
+│ │ │ │ └── header.tsx
+│ │ │ ├── hotbar
+│ │ │ │ ├── hotbar-animation.css
+│ │ │ │ ├── hotbar.module.css
+│ │ │ │ └── hotbar.tsx
+│ │ │ ├── loader
+│ │ │ │ ├── loader.module.css
+│ │ │ │ ├── loader.tsx
+│ │ │ │ ├── small-loader.module.css
+│ │ │ │ └── small-loader.tsx
+│ │ │ ├── modal-confirmation
+│ │ │ │ ├── modal-confirmation.module.css
+│ │ │ │ └── modal-confirmation.tsx
+│ │ │ ├── page
+│ │ │ │ ├── page.module.css
+│ │ │ │ └── page.tsx
+│ │ │ ├── paginated-post-feed
+│ │ │ │ └── paginated-post-feed.tsx
+│ │ │ ├── paginated-user-list
+│ │ │ │ ├── paginated-user-list.module.css
+│ │ │ │ └── paginated-user-list.tsx
+│ │ │ ├── post
+│ │ │ │ ├── post-header
+│ │ │ │ │ ├── post-header.module.css
+│ │ │ │ │ └── post-header.tsx
+│ │ │ │ ├── post.module.css
+│ │ │ │ └── post.tsx
+│ │ │ ├── profile
+│ │ │ │ ├── profile.module.css
+│ │ │ │ └── profile.tsx
+│ │ │ ├── ques-accordion
+│ │ │ │ ├── ques-accordion.module.css
+│ │ │ │ └── ques-accordion.tsx
+│ │ │ ├── scrambler
+│ │ │ │ └── scrambler.tsx
+│ │ │ ├── seo
+│ │ │ │ └── seo.tsx
+│ │ │ └── uibox
+│ │ │ ├── uibox.module.css
+│ │ │ └── uibox.tsx
+│ │ ├── environment.ts
+│ │ ├── index.css
+│ │ ├── index.tsx
+│ │ ├── lib
+│ │ │ ├── auth.ts
+│ │ │ ├── axios.ts
+│ │ │ ├── callPosts.ts
+│ │ │ ├── create-slug.ts
+│ │ │ ├── is-user.ts
+│ │ │ └── with-ref.ts
+│ │ ├── pages
+│ │ │ ├── about
+│ │ │ │ ├── about-page.module.css
+│ │ │ │ ├── about-page.tsx
+│ │ │ │ └── options
+│ │ │ │ ├── about-page.tsx
+│ │ │ │ ├── policy-page.tsx
+│ │ │ │ └── terms-page.tsx
+│ │ │ ├── create-post
+│ │ │ │ ├── create-post.module.css
+│ │ │ │ └── create-post.tsx
+│ │ │ ├── edit-profile-page
+│ │ │ │ ├── edit-profile-page.module.css
+│ │ │ │ └── edit-profile-page.tsx
+│ │ │ ├── faqs
+│ │ │ │ ├── faqs-page.tsx
+│ │ │ │ └── faqs.module.css
+│ │ │ ├── follower
+│ │ │ │ ├── follower.module.css
+│ │ │ │ └── follower.tsx
+│ │ │ ├── following
+│ │ │ │ ├── following.module.css
+│ │ │ │ └── following.tsx
+│ │ │ ├── forgot-password
+│ │ │ │ ├── forgot-password.module.css
+│ │ │ │ └── forgot-password.tsx
+│ │ │ ├── general-feed
+│ │ │ │ ├── general-feed.module.css
+│ │ │ │ └── general-feed.tsx
+│ │ │ ├── home
+│ │ │ │ ├── planet-list-entry.tsx
+│ │ │ │ ├── planet-list.module.css
+│ │ │ │ └── planet-list.tsx
+│ │ │ ├── landing-page
+│ │ │ │ ├── landing-page.module.css
+│ │ │ │ └── landing-page.tsx
+│ │ │ ├── login
+│ │ │ │ ├── login-component.tsx
+│ │ │ │ ├── login-html.tsx
+│ │ │ │ └── login.module.css
+│ │ │ ├── messages-all
+│ │ │ │ ├── messages.module.css
+│ │ │ │ └── messages.tsx
+│ │ │ ├── messages
+│ │ │ │ ├── messages-component.tsx
+│ │ │ │ ├── messages-html.tsx
+│ │ │ │ └── messages.module.css
+│ │ │ ├── page404
+│ │ │ │ ├── page404.module.css
+│ │ │ │ └── page404.tsx
+│ │ │ ├── planet-feed
+│ │ │ │ ├── planet-feed.module.css
+│ │ │ │ └── planet-feed.tsx
+│ │ │ ├── planet-map
+│ │ │ │ ├── center-visual.tsx
+│ │ │ │ ├── decorative-star.tsx
+│ │ │ │ ├── planet-info-card.tsx
+│ │ │ │ ├── planet-map.tsx
+│ │ │ │ ├── planet-visual.tsx
+│ │ │ │ ├── space-traveller.tsx
+│ │ │ │ └── star-background.tsx
+│ │ │ ├── planets
+│ │ │ │ ├── planets-component.tsx
+│ │ │ │ ├── planets-html.tsx
+│ │ │ │ └── planets.module.css
+│ │ │ ├── post-details
+│ │ │ │ ├── post-details.module.css
+│ │ │ │ └── post-details.tsx
+│ │ │ ├── profile-page
+│ │ │ │ ├── profile-page.module.css
+│ │ │ │ └── profile-page.tsx
+│ │ │ ├── reset-password
+│ │ │ │ ├── reset-password.module.css
+│ │ │ │ └── reset-password.tsx
+│ │ │ ├── search-page
+│ │ │ │ ├── search-page.module.css
+│ │ │ │ └── search-page.tsx
+│ │ │ ├── signup
+│ │ │ │ ├── signup-component.tsx
+│ │ │ │ ├── signup-html.tsx
+│ │ │ │ └── signup.module.css
+│ │ │ ├── support-page
+│ │ │ │ ├── support-page.module.css
+│ │ │ │ └── support-page.tsx
+│ │ │ ├── user-page
+│ │ │ │ ├── user-page.module.css
+│ │ │ │ └── user-page.tsx
+│ │ │ └── user-settings
+│ │ │ ├── options
+│ │ │ │ ├── change-email-modal.tsx
+│ │ │ │ ├── change-password-modal.tsx
+│ │ │ │ ├── change-username-modal.tsx
+│ │ │ │ ├── commented.tsx
+│ │ │ │ ├── delete-account-modal.tsx
+│ │ │ │ ├── liked.tsx
+│ │ │ │ ├── manage-account-page.tsx
+│ │ │ │ ├── saved.tsx
+│ │ │ │ └── your-info-modal.tsx
+│ │ │ ├── user-settings-page.module.css
+│ │ │ └── user-settings-page.tsx
+│ │ └── vite-env.d.ts
+│ ├── tsconfig.json
+│ └── vite.config.ts
+└── server
+ ├── index.ts
+ ├── package-lock.json
+ ├── package.json
+ ├── src
+ │ ├── @types
+ │ │ ├── express.d.ts
+ │ │ └── model.d.ts
+ │ ├── environment.ts
+ │ ├── lib
+ │ │ └── auth
+ │ │ ├── adapters
+ │ │ │ ├── basic-jwt.ts
+ │ │ │ └── google-oauth.ts
+ │ │ ├── auth-adapter.ts
+ │ │ └── auth-worker.ts
+ │ ├── middlewares
+ │ │ ├── auth-protected.ts
+ │ │ └── log.ts
+ │ ├── models
+ │ │ ├── conversation.ts
+ │ │ ├── deletedUser.ts
+ │ │ ├── follow-relationship.ts
+ │ │ ├── like-interaction.ts
+ │ │ ├── location.ts
+ │ │ ├── media.ts
+ │ │ ├── message.ts
+ │ │ ├── planet.ts
+ │ │ ├── post.ts
+ │ │ ├── question.ts
+ │ │ ├── token.ts
+ │ │ └── user.ts
+ │ ├── routes
+ │ │ ├── faqs
+ │ │ │ └── index.ts
+ │ │ ├── feed
+ │ │ │ ├── [planetOrUserId].ts
+ │ │ │ └── index.ts
+ │ │ ├── index.ts
+ │ │ ├── planet
+ │ │ │ ├── [nameOrId].ts
+ │ │ │ └── index.ts
+ │ │ ├── post
+ │ │ │ ├── [id]
+ │ │ │ │ ├── comment.ts
+ │ │ │ │ ├── index.ts
+ │ │ │ │ ├── like.ts
+ │ │ │ │ └── save.ts
+ │ │ │ ├── index.ts
+ │ │ │ └── search
+ │ │ │ └── [search].ts
+ │ │ └── user
+ │ │ ├── [id].ts
+ │ │ ├── [id]
+ │ │ │ └── follow.ts
+ │ │ ├── changeBio.ts
+ │ │ ├── changeEmail.ts
+ │ │ ├── changeUsername.ts
+ │ │ ├── changeavatar.ts
+ │ │ ├── changepassword.ts
+ │ │ ├── chat.ts
+ │ │ ├── commented.ts
+ │ │ ├── conversations.ts
+ │ │ ├── deleteaccount
+ │ │ │ └── delete.ts
+ │ │ ├── follower.ts
+ │ │ ├── following.ts
+ │ │ ├── forgetpassword.ts
+ │ │ ├── getchats.ts
+ │ │ ├── index.ts
+ │ │ ├── liked.ts
+ │ │ ├── login.ts
+ │ │ ├── oauth
+ │ │ │ └── google.ts
+ │ │ ├── resetpassword
+ │ │ │ └── [token].ts
+ │ │ ├── saved.ts
+ │ │ ├── search
+ │ │ │ └── [search].ts
+ │ │ └── signup.ts
+ │ └── utils
+ │ ├── bcrypt.ts
+ │ ├── email.ts
+ │ ├── express.ts
+ │ ├── image.ts
+ │ ├── jwt.ts
+ │ └── regex.ts
+ └── tsconfig.json
+
+```
diff --git a/client/package-lock.json b/client/package-lock.json
index 038cc1b5..e6527d16 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -8,6 +8,7 @@
"dependencies": {
"@popperjs/core": "^2.11.8",
"axios": "^1.6.8",
+ "axios-cache-interceptor": "^1.5.3",
"bootstrap": "^5.3.3",
"dotenv": "^16.4.5",
"is-mobile": "^4.0.0",
@@ -15,6 +16,7 @@
"react": "^18.3.1",
"react-bootstrap": "^2.10.2",
"react-dom": "^18.3.1",
+ "react-helmet": "^6.1.0",
"react-icons": "^5.2.1",
"react-if": "^4.1.5",
"react-konva": "^18.2.10",
@@ -28,8 +30,8 @@
"@types/node": "^20.12.12",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
+ "@types/react-helmet": "^6.1.11",
"@vitejs/plugin-react-swc": "^3.6.0",
- "sass": "^1.77.1",
"typescript": "^5.4.5"
}
},
@@ -906,6 +908,15 @@
"@types/react": "*"
}
},
+ "node_modules/@types/react-helmet": {
+ "version": "6.1.11",
+ "resolved": "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.11.tgz",
+ "integrity": "sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==",
+ "dev": true,
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/react-reconciler": {
"version": "0.28.8",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.8.tgz",
@@ -943,7 +954,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
@@ -967,11 +979,31 @@
"proxy-from-env": "^1.1.0"
}
},
+ "node_modules/axios-cache-interceptor": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.5.3.tgz",
+ "integrity": "sha512-kPgGId9XW7tR+VF7hgSkqF4f6FrV4ecCyKxjkD9v1hNJ4sXSAskocr7SMKaVHVvrbzVeruwB6yL6Y9/lY1ApKg==",
+ "dependencies": {
+ "cache-parser": "1.2.5",
+ "fast-defer": "1.1.8",
+ "object-code": "1.3.3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/arthurfiorette/axios-cache-interceptor?sponsor=1"
+ },
+ "peerDependencies": {
+ "axios": "^1"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"engines": {
"node": ">=8"
},
@@ -1001,7 +1033,8 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"fill-range": "^7.0.1"
},
@@ -1009,11 +1042,17 @@
"node": ">=8"
}
},
+ "node_modules/cache-parser": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.5.tgz",
+ "integrity": "sha512-Md/4VhAHByQ9frQ15WD6LrMNiVw9AEl/J7vWIXw+sxT6fSOpbtt6LHTp76vy8+bOESPBO94117Hm2bIjlI7XjA=="
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@@ -1171,11 +1210,17 @@
"@esbuild/win32-x64": "0.20.2"
}
},
+ "node_modules/fast-defer": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.8.tgz",
+ "integrity": "sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q=="
+ },
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
@@ -1232,7 +1277,8 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"is-glob": "^4.0.1"
},
@@ -1244,7 +1290,8 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz",
"integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==",
- "devOptional": true
+ "optional": true,
+ "peer": true
},
"node_modules/invariant": {
"version": "2.2.4",
@@ -1258,7 +1305,8 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
@@ -1270,7 +1318,8 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1279,7 +1328,8 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
@@ -1296,7 +1346,8 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"engines": {
"node": ">=0.12.0"
}
@@ -1397,7 +1448,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1410,6 +1462,11 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-code": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/object-code/-/object-code-1.3.3.tgz",
+ "integrity": "sha512-/Ds4Xd5xzrtUOJ+xJQ57iAy0BZsZltOHssnDgcZ8DOhgh41q1YJCnTPnWdWSLkNGNnxYzhYChjc5dgC9mEERCA=="
+ },
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -1419,7 +1476,8 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"engines": {
"node": ">=8.6"
},
@@ -1533,6 +1591,25 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-fast-compare": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
+ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
+ },
+ "node_modules/react-helmet": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
+ "integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
+ "dependencies": {
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.7.2",
+ "react-fast-compare": "^3.1.1",
+ "react-side-effect": "^2.1.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.3.0"
+ }
+ },
"node_modules/react-icons": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz",
@@ -1610,6 +1687,14 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-side-effect": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
+ "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==",
+ "peerDependencies": {
+ "react": "^16.3.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-toastify": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.5.tgz",
@@ -1641,7 +1726,8 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"picomatch": "^2.2.1"
},
@@ -1700,7 +1786,8 @@
"version": "1.77.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.77.1.tgz",
"integrity": "sha512-OMEyfirt9XEfyvocduUIOlUSkWOXS/LAt6oblR/ISXCTukyavjex+zQNm51pPCOiFKY1QpWvEH1EeCkgyV3I6w==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@@ -1759,7 +1846,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "devOptional": true,
+ "optional": true,
+ "peer": true,
"dependencies": {
"is-number": "^7.0.0"
},
diff --git a/client/package.json b/client/package.json
index eb6466fe..3c5a2615 100644
--- a/client/package.json
+++ b/client/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@popperjs/core": "^2.11.8",
"axios": "^1.6.8",
+ "axios-cache-interceptor": "^1.5.3",
"bootstrap": "^5.3.3",
"dotenv": "^16.4.5",
"is-mobile": "^4.0.0",
@@ -18,6 +19,7 @@
"react": "^18.3.1",
"react-bootstrap": "^2.10.2",
"react-dom": "^18.3.1",
+ "react-helmet": "^6.1.0",
"react-icons": "^5.2.1",
"react-if": "^4.1.5",
"react-konva": "^18.2.10",
@@ -31,8 +33,8 @@
"@types/node": "^20.12.12",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
+ "@types/react-helmet": "^6.1.11",
"@vitejs/plugin-react-swc": "^3.6.0",
- "sass": "^1.77.1",
"typescript": "^5.4.5"
}
}
diff --git a/client/public/among-us.jpg b/client/public/among-us.jpg
new file mode 100644
index 00000000..46bf73f2
Binary files /dev/null and b/client/public/among-us.jpg differ
diff --git a/client/public/among-us2.jpg b/client/public/among-us2.jpg
new file mode 100644
index 00000000..9cf7d5bc
Binary files /dev/null and b/client/public/among-us2.jpg differ
diff --git a/client/public/logo.webp b/client/public/logo.webp
new file mode 100644
index 00000000..b58a8538
Binary files /dev/null and b/client/public/logo.webp differ
diff --git a/client/src/app.tsx b/client/src/app.tsx
index 60f5a279..738c7e23 100644
--- a/client/src/app.tsx
+++ b/client/src/app.tsx
@@ -1,36 +1,63 @@
+/* Import for the main stylesheet */
+import './index.css';
+
+/* Imports from React */
+import { useEffect, useState } from 'react';
+import { Else, If, Then } from 'react-if';
import { ToastContainer } from 'react-toastify';
+
+/* Import from wouter */
import { Switch, Route, useLocation } from 'wouter';
-import About from './pages/about/about';
-import Changepassword from './pages/changepassword/changepassword';
-import Forgetpassword from './pages/forgetpassword/forgetpassword';
-import GeneralFeed from './pages/general-feed/general-feed';
+
+/* Utility imports for client side authentication */
+import { Auth, UserAuthContext } from './lib/auth';
+
+/* Import for client hostname */
+import { getClientHost } from './environment';
+
+/* Imports for all pages of the website */
+import LandingPage from './pages/landing-page/landing-page';
+import { PlanetMap } from './pages/planet-map/planet-map';
+import Home from './pages/home/planet-list';
import Login from './pages/login/login-component';
-import MyFeed from './pages/my-feed/my-feed';
import Signup from './pages/signup/signup-component';
-import Test from './pages/test-page/test-page';
-import UserSettings from './pages/user-settings/user-settings';
-import Resetpassword from './pages/resetpassword/resetpassword';
-import ManageAccount from './pages/user-settings/options/manage-account';
+import About from './pages/about/about-page';
+import Forgetpassword from './pages/forgot-password/forgot-password';
+import GeneralFeed from './pages/general-feed/general-feed';
+import UserSettings from './pages/user-settings/user-settings-page';
+import Resetpassword from './pages/reset-password/reset-password';
+import ManageAccount from './pages/user-settings/options/manage-account-page';
import Messages from './pages/messages/messages-component';
-import Policy from './pages/about/options/policy';
-import Terms from './pages/about/options/terms';
-import { useEffect, useState } from 'react';
-import { Auth, UserAuthContext } from './lib/auth';
-import Cursors from './components/cursor/cursor';
-
-import { Else, If, Then } from 'react-if';
-import { Loader } from './components/loader/loader';
-import PostPage from './pages/post-page/post-page';
+import Policy from './pages/about/options/policy-page';
+import Terms from './pages/about/options/terms-page';
+import FAQs from './pages/faqs/faqs-page';
+import LikedPage from './pages/user-settings/options/liked';
+import SavedPage from './pages/user-settings/options/saved';
+import CommentedPostPage from './pages/user-settings/options/commented';
+import FollowingPage from './pages/following/following';
+import FollowerPage from './pages/follower/follower';
+import PostPage from './pages/create-post/create-post';
import UserPage from './pages/user-page/user-page';
import ProfilePage from './pages/profile-page/profile-page';
-import { PlanetMap } from './pages/planet-map/planet-map';
-
-import './index.css';
-import Home from './pages/home/home';
-import PostDetailPage from './pages/post/post';
+import PostDetailPage from './pages/post-details/post-details';
import Planets from './pages/planets/planets-component';
import PlanetFeed from './pages/planet-feed/planet-feed';
+import MessagesAll from './pages/messages-all/messages';
+import SearchPage from './pages/search-page/search-page';
+import Page404 from './pages/page404/page404';
+import EditProfilePage from './pages/edit-profile-page/edit-profile-page';
+import SupportPage from './pages/support-page/support-page';
+import AboutSkynetPage from './pages/about/options/about-page';
+
+/* Imports for custom componets made */
+import SEO from './components/seo/seo';
+import { Loader } from './components/loader/loader';
+/**
+ * Contructs, manages, and returns the entire client side.
+ *
+ * @returns the client side application as a JSX.Element.
+ */
export const App = () => {
const [loading, setLoading] = useState(true);
const [authenticatedUser, setAuthenticatedUser] = useState();
@@ -46,6 +73,9 @@ export const App = () => {
});
}, [loc]);
+ /**
+ * The common routes throughout this website.
+ */
const commonRoutes = (
<>
@@ -53,19 +83,25 @@ export const App = () => {
+
+
+
{(params) => }
-
{(params) => }
- 404 Not Found
>
);
return (
<>
+
+
-
@@ -76,29 +112,37 @@ export const App = () => {
-
{commonRoutes}
+
-
-
+ {() => }
+ {() => }
-
-
+
+
+
+
+
+
+
+
{commonRoutes}
+
+ 404 Not Found
diff --git a/client/src/assets/images/icons8-cursor-38.png b/client/src/assets/images/icons8-cursor-38.png
deleted file mode 100644
index 7715a690..00000000
Binary files a/client/src/assets/images/icons8-cursor-38.png and /dev/null differ
diff --git a/client/src/assets/images/SkynetLogo.png b/client/src/assets/images/skynet-logo.png
similarity index 100%
rename from client/src/assets/images/SkynetLogo.png
rename to client/src/assets/images/skynet-logo.png
diff --git a/client/src/assets/videos/home.gif b/client/src/assets/videos/home.gif
new file mode 100644
index 00000000..91c023a3
Binary files /dev/null and b/client/src/assets/videos/home.gif differ
diff --git a/client/src/assets/videos/message.gif b/client/src/assets/videos/message.gif
new file mode 100644
index 00000000..b280d72c
Binary files /dev/null and b/client/src/assets/videos/message.gif differ
diff --git a/client/src/assets/videos/post.gif b/client/src/assets/videos/post.gif
new file mode 100644
index 00000000..6ef9fb12
Binary files /dev/null and b/client/src/assets/videos/post.gif differ
diff --git a/client/src/components/Comment/Comment.module.css b/client/src/components/Comment/Comment.module.css
deleted file mode 100644
index fe6a9945..00000000
--- a/client/src/components/Comment/Comment.module.css
+++ /dev/null
@@ -1,102 +0,0 @@
-.link {
- color: inherit;
- text-decoration: none;
-}
-
-.commentContainer {
- font-family: TTOctosquares;
- max-width: 750px;
- width: 100%;
- margin-top: 20px;
- display: flex;
- flex-direction: column;
-}
-
-.commentsList {
- flex-grow: 1;
-}
-
-.commentForm {
- display: flex;
- flex-direction: column;
- margin-top: 20px;
-}
-
-.commentTextArea {
- width: 100%;
- min-height: 50px;
- padding: 10px;
- font-size: 0.9rem;
- /* Slightly smaller than the post text */
- resize: vertical;
- border: 1px solid #ccc;
- border-radius: 5px;
- margin-bottom: 10px;
- font-family: TTOctosquares;
-}
-
-.commentButton {
- width: fit-content;
- align-self: flex-end;
- padding: 5px 10px;
- font-size: 1rem;
- background-color: #007bff;
- color: #fff;
- border: none;
- border-radius: 5px;
- cursor: pointer;
- font-family: TTOctosquares;
-}
-
-.postContainer {
- border-radius: 5px;
- padding: 10px;
- margin-bottom: 10px;
- font-size: 0.8rem;
- /* Slightly smaller than the post text */
- font-family: TTOctosquares;
-}
-
-.userContainer {
- padding: 2px 8px;
- margin: 2px 0px;
- width: max-content;
- text-align: left;
- font-size: 0.8rem;
- /* Slightly smaller than the post text */
-}
-
-.paraContainer {
- padding: 10px;
- padding-bottom: 5px;
- margin: 2px 0px;
- width: 100%;
- text-align: left;
- word-break: break-word;
- font-size: 0.9rem;
- /* Slightly smaller than the post text */
-}
-
-.postDate {
- font-size: 0.6em;
- color: var(--soft-shadow);
-}
-
-.iconsContainer {
- display: flex;
- font-size: 1.2rem;
- /* Slightly smaller than the post icons */
- gap: 0.75rem;
-}
-
-.iconsContainer button {
- background-color: transparent;
- padding: 5px;
- width: auto;
- box-shadow: none;
- border: none;
-}
-
-.share {
- margin-right: auto;
-}
\ No newline at end of file
diff --git a/client/src/components/Comment/Comment.tsx b/client/src/components/Comment/Comment.tsx
deleted file mode 100644
index d6c224bb..00000000
--- a/client/src/components/Comment/Comment.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { api } from '../../lib/axios';
-import styles from './Comment.module.css';
-import UIBox from '../UIBox/UIBox';
-import { FaRegHeart, FaHeart, FaRegBookmark, FaBookmark, FaRocketchat } from 'react-icons/fa';
-import { RiShareBoxLine } from 'react-icons/ri';
-import { Link } from 'wouter';
-
-interface CommentProps {
- postId: string;
-}
-
-interface Comment {
- _id: string;
- authorId: string;
- content: string;
- createdAt: string;
- userName: string;
- repost: number;
- like: number;
- comment: number;
- isSaved?: boolean;
- isLiked?: boolean;
-}
-
-interface UserProp {
- username: string;
- userURL: string;
- imageURL?: string;
-}
-
-const User = (props: UserProp): JSX.Element => {
- return (
-
- {props.username}
-
- }
- />
- );
-};
-
-const Comment: React.FC = ({ postId }) => {
- const [comment, setComment] = useState('');
- const [comments, setComments] = useState([]);
- const [page, setPage] = useState(1);
- const [hasMore, setHasMore] = useState(true);
- const [loading, setLoading] = useState(false);
- const fetchComments = async (page: number) => {
- setLoading(true);
- try {
- const response = await api.get(`/post/${postId}/comment`, {
- params: { limit: 10, page },
- });
- if (response.data.success) {
- const commentsWithStatus = await Promise.all(
- response.data.value.map(async (comment: Comment) => {
- const [savedRes, likedRes] = await Promise.all([
- api.get(`/post/${comment._id}/save`),
- api.get(`/post/${comment._id}/like`),
- ]);
- return {
- ...comment,
- isSaved: savedRes.data.success ? savedRes.data.value : false,
- isLiked: likedRes.data.value,
- };
- })
- );
- setComments((prevComments) => [...prevComments, ...commentsWithStatus]);
- setHasMore(commentsWithStatus.length === 10); // Assuming 10 is the limit
- } else {
- console.error(response.data.statusMessage);
- }
- } catch (error) {
- console.error('Error fetching comments:', error);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- fetchComments(page);
- }, [page, postId]);
-
- const handleCommentChange = (event: React.ChangeEvent) => {
- setComment(event.target.value);
- };
-
- const handleCommentSubmit = async (event: React.FormEvent) => {
- event.preventDefault();
- if (comment.trim()) {
- try {
- const response = await api.post(`/post/${postId}/comment`, { content: comment });
- if (response.data.success) {
- const newComment = response.data.value;
- const [savedRes, likedRes] = await Promise.all([
- api.get(`/post/${newComment._id}/save`),
- api.get(`/post/${newComment._id}/like`),
- ]);
- newComment.isSaved = savedRes.data.success ? savedRes.data.value : false;
- newComment.isLiked = likedRes.data.value;
- const updatedComments = [newComment, ...comments].sort(
- (a: Comment, b: Comment) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
- );
- setComments(updatedComments);
- setComment('');
- } else {
- console.error(response.data.statusMessage);
- }
- } catch (error) {
- console.error('Error posting comment:', error);
- }
- }
- };
-
- const handleBookmark = async (commentId: string, isSaved: boolean) => {
- try {
- if (isSaved) {
- await api.delete(`/post/${commentId}/save`);
- } else {
- await api.post(`/post/${commentId}/save`);
- }
- setComments((prevComments) =>
- prevComments.map((comment) =>
- comment._id === commentId ? { ...comment, isSaved: !isSaved } : comment
- )
- );
- } catch (error) {
- console.error('Error updating save status:', error);
- }
- };
-
- const handleLike = async (commentId: string, isLiked: boolean) => {
- try {
- if (isLiked) {
- await api.delete(`/post/${commentId}/like`);
- setComments((prevComments) =>
- prevComments.map((comment) =>
- comment._id === commentId ? { ...comment, like: comment.like - 1, isLiked: false } : comment
- )
- );
- } else {
- await api.post(`/post/${commentId}/like`);
- setComments((prevComments) =>
- prevComments.map((comment) =>
- comment._id === commentId ? { ...comment, like: comment.like + 1, isLiked: true } : comment
- )
- );
- }
- } catch (error) {
- console.error('Error updating like status:', error);
- }
- };
-
- function formatDate(dateString: string): string {
- const date = new Date(dateString);
- const year = date.getFullYear();
- const month = date.getMonth() + 1; // Months are zero-indexed
- const day = date.getDate();
- const hours = date.getHours();
- const minutes = date.getMinutes();
- const seconds = date.getSeconds();
- return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
- }
-
- const loadMoreComments = () => {
- if (hasMore && !loading) {
- setPage((prevPage) => prevPage + 1);
- }
- };
-
- return (
-
-
-
- {comments.map((comment) => (
-
-
-
-
- {comment.content}
- {comment.createdAt ? (
- {formatDate(comment.createdAt)}
- ) : undefined}
-
-
-
{comment.repost}
-
-
-
-
handleBookmark(comment._id, comment.isSaved ?? false)}
- >
- {comment.isSaved ? : }
-
-
-
-
-
{comment.comment}
-
handleLike(comment._id, comment.isLiked ?? false)} className={styles.like}>
- {comment.isLiked ? : }
-
-
{comment.like}
-
- >
- }
- />
-
- ))}
- {hasMore && (
-
- Load More Comments
-
- )}
-
-
- );
-};
-
-export default Comment;
-// Test git ingnore file
\ No newline at end of file
diff --git a/client/src/components/Hotbar/Hotbar.module.css b/client/src/components/Hotbar/Hotbar.module.css
deleted file mode 100644
index 56efe4c4..00000000
--- a/client/src/components/Hotbar/Hotbar.module.css
+++ /dev/null
@@ -1,191 +0,0 @@
-.nav-container p {
- margin: 0;
- padding: 0;
-}
-
-.nav-container li a {
- margin-right: 100px;
- min-width: 90px;
- padding: 1px;
- text-decoration: none;
- text-align: center;
- flex-basis: 100%;
- color: #2196F3;
- font-family: Fjalla One;
- font-weight: bold;
- letter-spacing: 1px;
-}
-
-.nav-container {
- position: fixed;
- bottom: 0%;
- left: 0%;
- width: 100vw;
- margin-bottom: 105px;
-}
-
-.nav {
- position: relative;
- width: 100vw;
-}
-
-.circle {
- position: absolute;
- border: none;
- background-color: #242C38;
- padding: 60px;
- border-radius: 50%;
- z-index: 1;
-}
-
-.circle-right {
- right: 0%;
- transform: translate(50%, 10%);
-}
-
-.circle-left {
- left: 0%;
- transform: translate(-50%, 10%);
-}
-
-.bar {
- border: none;
- position: absolute;
- background-color: #242C38;
- padding: 60px;
- width: 100vw;
- top: 50px;
-}
-
-.nav-icon {
- position: absolute;
- z-index: 2;
- font-size: 2em;
- color: #2196F3;
-}
-
-.icon-right {
- right: 0%;
-}
-
-.icon-left {
- left: 0%;
-}
-
-.menu-container {
- position: absolute;
- background-color: #2196F3;
- z-index: 5;
- padding: 10px;
- border-radius: 50%;
- right: 50%;
- transform: translate(50%);
-}
-
-.menu-backdrop {
- position: absolute;
- background-color: transparent;
- z-index: 3;
- width: 110px;
- border-radius: 50%;
- padding: 50px;
- right: 50%;
- top: 0;
- transform: translate(50%);
-}
-
-.nav-container li a p {
- font-size: 11pt;
-}
-
-.menu-sub-icons {
- font-size: 3em;
-}
-
-.menu-icon, .close-icon{
- font-size: 4em;
-}
-
-.nav-container ul.submenu {
- position: absolute;
- right: 50%;
- top: 0%;
- width: 150px;
- transform: translateX(-50%);
-}
-
-.nav-container input:checked ~ ul.submenu > li {
- transform: rotate(calc((180deg / 5) * var(--item))) translateX(0px);
- opacity: 1;
-}
-
-.nav-container ul.submenu > li {
- --item: 1;
- position: absolute;
- opacity: 0;
- transform: rotate(calc((180deg / 5) * var(--item))) translateX(120px);
- transform-origin: right center;
- display: flex;
- align-self: center;
- transition: transform .7s, opacity .2s;
-}
-
-.nav-container ul.submenu li:nth-child(1) {
- --item: 1;
-}
-
-.nav-container ul.submenu li:nth-child(2) {
- --item: 2;
-}
-
-.nav-container ul.submenu li:nth-child(3) {
- --item: 3;
-}
-
-.nav-container ul.submenu li:nth-child(4) {
- --item: 4;
-}
-
-.nav-container ul.submenu li:nth-child(1) a {
- transform: rotate(-36deg);
-}
-
-.nav-container ul.submenu li:nth-child(2) a {
- transform: rotate(-72deg);
-}
-
-.nav-container ul.submenu li:nth-child(3) a {
- transform: rotate(-108deg);
-}
-
-.nav-container ul.submenu li:nth-child(4) a {
- transform: rotate(-144deg);
-}
-
-.back-drop-div {
- pointer-events: none;
-
- position: absolute;
- right: 50%;
- bottom: 0%;
- width: 100vw;
- height: 100vh;
- transform: translateY(50%) translateX(50%);
- background-color: rgba(255, 255, 255, 0.4);
-
- backdrop-filter: blur(10px);
- mask: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 70%);
-
- opacity: 0;
- transition: transform .7s, opacity .3s;
-}
-
-.nav-container input:checked ~ .back-drop-div {
- opacity: 1;
-}
-
-.menu-label {
- position: absolute;
- right: 50%;
- transform: translateX(50%);
-}
\ No newline at end of file
diff --git a/client/src/components/Post/Post.module.css b/client/src/components/Post/Post.module.css
deleted file mode 100644
index fbc4abc9..00000000
--- a/client/src/components/Post/Post.module.css
+++ /dev/null
@@ -1,51 +0,0 @@
-.link {
- color: inherit;
- text-decoration: none;
-}
-
-.post-container {
- font-family: TTOctosquares;
- max-width: 750px;
- width: 100%;
-}
-
-.user-container {
- padding: 2px 8px;
- margin: 2px 0px;
- width: max-content;
- text-align: left;
- font-size: 1rem;
-}
-
-.para-container {
- padding: 10px;
- padding-bottom: 5px;
- margin: 2px 0px;
- width: 100%;
- text-align: left;
- word-break: break-word;
- font-size: 1.1rem;
-}
-
-.post-date {
- font-size: 0.6em;
- color: var(--soft-shadow);
-}
-
-.icons-container {
- display: flex;
- font-size: 1.75rem;
- gap: 0.75rem;
-}
-
-.icons-container button {
- background-color: transparent;
- padding: 5px;
- width: auto;
- box-shadow: none;
- border: none;
-}
-
-.share {
- margin-right: auto;
-}
diff --git a/client/src/components/Post/Post.tsx b/client/src/components/Post/Post.tsx
deleted file mode 100644
index 389c5262..00000000
--- a/client/src/components/Post/Post.tsx
+++ /dev/null
@@ -1,195 +0,0 @@
-import { useEffect, useState } from 'react';
-
-import styles from './Post.module.css';
-
-import { Container } from 'react-bootstrap';
-import UIBox from '../UIBox/UIBox';
-
-// Icons
-import { FaRegHeart } from 'react-icons/fa'; // //Empty heart
-import { FaHeart } from 'react-icons/fa'; // // Filled heart
-import { RiShareBoxLine } from 'react-icons/ri'; //
-import { FaRegBookmark } from 'react-icons/fa'; // //Empty bookmark
-import { FaBookmark } from 'react-icons/fa'; // //Filled bookmark
-import { FaRocketchat } from 'react-icons/fa'; //
-import { Link } from 'wouter';
-import { api } from '../../lib/axios';
-import { toast } from 'react-toastify';
-
-interface PostProp {
- username: string;
- authorId: string;
- content: string;
- postId: string;
- likeCount?: number;
- commentCount?: number;
- repostCount?: number;
- createdAt?: Date;
- location?: {
- planetId: string;
- latitude: number;
- longitude: number;
- _id: string;
- };
- // media?: Array;
-}
-
-interface UserProp {
- username: string;
- userId: string;
- imageURL?: string;
-}
-
-/**
- * Component representing the user part of the post (image and username).
- *
- * @param props.username - Username of the author of the post (got from the Post's props).
- * @param props.userId - Links to the user's profile page.
- * @param props.imageURL - (TODO) (Optional) Will represent the user's profile pictures as an URL or image file.
- */
-const User = (props: UserProp): JSX.Element => {
- return (
- <>
-
- {props.username}
-
- }
- />
- >
- );
-};
-
-/**
- * Post component representing the thumbnail post of an user.
- *
- * @param props.username string - Username of the author of the post.
- * @param props.authorId string - ID of the author of the post.
- * @param props.content string - Text of the post.
- * @param props.postId string - Id of the post in the database.
- * @param props.likeCount number - Number of likes of the post.
- * @param props.commentCount number - Number of comments of the post.
- * @param props.repostCount number - Number of reposts of the post.
- * @param props.createdAt Date - (optional) Date in which the post was created.
- * @param props.Location LocationOject - Location in which the post was created. (planetId: string, latitude: number, longitude: number, _id: string)
- * @param props.username string - Username of the author of the post
- * @param props.text string - Text of the post
- * @param props.postId string - URL of the complete version of the post with comments and more information
- * @param props.createdAt Date - (optional) Date in which the post was created
- */
-const Post = (props: PostProp): JSX.Element => {
- function onShare() {}
-
- function onComment() {}
-
- const [saved, setSaved] = useState(false);
-
- useEffect(() => {
- const fetchSaveStatus = async () => {
- try {
- const response = await api.get(`/post/${props.postId}/save`);
- if (response.data.success) {
- setSaved(response.data.value);
- }
- } catch (error) {
- console.error('Error fetching save status:', error);
- }
- };
-
- fetchSaveStatus();
- }, []);
-
- const onBookmark = async () => {
- try {
- if (saved) {
- await api.delete(`/post/${props.postId}/save`);
- setSaved(false);
- } else {
- const response = await api.post(`/post/${props.postId}/save`);
- if (response.data.success) {
- setSaved(true);
- }
- }
- } catch (error) {
- console.error('Error updating save status:', error);
- }
- };
-
- const [liked, setLiked] = useState(false);
- const [likeCount, setLikeCount] = useState(props.likeCount ?? 0);
- useEffect(() => {
- const fetchLikeStatus = async () => {
- try {
- const response = await api.get(`/post/${props.postId}/like`);
- setLiked(response.data.value);
- console.log(response.data);
- } catch (error) {
- console.error('Error fetching like status:', error);
- }
- };
-
- fetchLikeStatus();
- }, []);
-
- const onLike = async () => {
- try {
- if (liked) {
- await api.delete(`/post/${props.postId}/like`);
- setLikeCount(likeCount - 1);
- setLiked(false);
- } else {
- const response = await api.post(`/post/${props.postId}/like`);
- if (response.data.success) {
- setLikeCount(likeCount + 1);
- setLiked(true);
- }
- }
- } catch (error) {
- console.error('Error updating like status:', error);
- }
- };
-
- return (
-
-
-
-
- {props.content}
- {props.createdAt ? (
- {props.createdAt.toLocaleString()}
- ) : undefined}
-
-
-
{props.repostCount}
-
-
-
-
- {saved ? : }
-
-
-
-
-
{props.commentCount}
-
-
- {liked ? : }
-
-
{likeCount}
-
- >
- }
- />
-
- );
-};
-
-export default Post;
diff --git a/client/src/components/Profile/Profile.tsx b/client/src/components/Profile/Profile.tsx
deleted file mode 100644
index 544a9817..00000000
--- a/client/src/components/Profile/Profile.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import styles from './Profile.module.css';
-
-import UIBox from '../UIBox/UIBox';
-import { Link } from 'wouter';
-
-interface ProfileProp {
- userId: string;
- username: string;
- description?: string;
- follower: number;
- following: number;
- postCount: number;
- outsideUser?: boolean;
- className?: string;
-}
-
-/**
- * Post component representing the thumbnail post of an user.
- *
- * @param props.username string - Username of the author of the post
- * @param props.text string - Text of the post
- * @param props.postURL string - URL of the complete version of the post with comments and more information
- * @param props.createdAt Date - (optional) Date in which the post was created
- */
-const Profile = (props: ProfileProp): JSX.Element => {
- return (
- <>
-
-
- @{props.username}
- {props.description ? {props.description}
: <>>}
- {props.follower} followers
- {props.following} following
- {props.postCount} posts
- >
- }
- className={styles.profileInfoBox}
- curved
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-};
-
-export default Profile;
diff --git a/client/src/components/cursor/cursor.module.css b/client/src/components/cursor/cursor.module.css
deleted file mode 100644
index 971eb63a..00000000
--- a/client/src/components/cursor/cursor.module.css
+++ /dev/null
@@ -1,59 +0,0 @@
- #cursor {
- width: 40px;
- height: 40px;
- border: 1px solid green;
- border-radius: 50%;
- position: absolute;
- transition-duration: 200ms;
- transition-timing-function: ease-out;
- animation: cursorAnim 0.5s infinite alternate;
- }
-
- #cursor::after {
- content: "";
- width: 40px;
- height: 40px;
- border: 8px solid green;
- border-radius: 50%;
- position: absolute;
- opacity: 0.5;
- left: -8px;
- top: -8px;
- animation: cursorAnim2 0.5s infinite alternate;
- }
-
- .expand {
- animation: cursorAnim3 0.5s forwards;
- border: 1px solid greenyellow;
- }
-
- @keyframes cursorAnim {
- from {
- transform: scale(1);
- }
- to {
- transform: scale(0.6);
- }
- }
-
- @keyframes cursorAnim2 {
- from {
- transform: scale(1);
- }
- to {
- transform: scale(0.4);
- }
- }
-
- @keyframes cursorAnim3 {
- 0% {
- transform: scale(1);
- }
- 50% {
- transform: scale(2);
- }
- 100% {
- transform: scale(1);
- /* opacity: 0; */
- }
- }
\ No newline at end of file
diff --git a/client/src/components/cursor/cursor.tsx b/client/src/components/cursor/cursor.tsx
deleted file mode 100644
index 93af2cf1..00000000
--- a/client/src/components/cursor/cursor.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import styles from './cursor.module.css';
-
-const Cursors = () => {
- const cursor = document.querySelector('.cursor');
- document.addEventListener('mousemove', (e) => {
- cursor?.setAttribute('style', 'top: ' + (e.pageY - 14) + 'px; left: ' + (e.pageX - 10) + 'px;');
- });
- document.addEventListener('click', () => {
- cursor?.classList.add('expand');
- setTimeout(() => {
- cursor?.classList.remove('expand');
- }, 500);
- });
- return (
- <>
-
- >
- );
-};
-
-export default Cursors;
diff --git a/client/src/components/google-auth-btn/google-auth-btn.tsx b/client/src/components/google-auth-btn/google-auth-btn.tsx
index 5ba382a0..5b41dd0f 100644
--- a/client/src/components/google-auth-btn/google-auth-btn.tsx
+++ b/client/src/components/google-auth-btn/google-auth-btn.tsx
@@ -35,9 +35,9 @@ export const GoogleAuthButton = (props: Props) => {
onClick()}
disabled={disabled}
- className={`${props.className} w-100 d-flex gap-2 align-items-center justify-content-center`}>
-
- {props.text ?? 'Authenticate with Google'}
+ className={`${props.className} w-100 d-flex gap-2 p-2 align-items-center justify-content-center`}>
+
+ {props.text ?? 'Start With Google'}
);
};
diff --git a/client/src/components/Header/Header.module.css b/client/src/components/header/header.module.css
similarity index 82%
rename from client/src/components/Header/Header.module.css
rename to client/src/components/header/header.module.css
index 768d7349..fde5d936 100644
--- a/client/src/components/Header/Header.module.css
+++ b/client/src/components/header/header.module.css
@@ -18,9 +18,14 @@
.logo-header {
width: 100%;
+ z-index: 10;
+ background-color: #242C38;
+ color: aliceblue;
+ position: absolute;
font-family: Bitsumishi;
padding-top: 10px;
padding-left: 10px;
+ padding-bottom: 5px;
}
@media screen and (max-width: 420px) {
diff --git a/client/src/components/Header/Header.tsx b/client/src/components/header/header.tsx
similarity index 54%
rename from client/src/components/Header/Header.tsx
rename to client/src/components/header/header.tsx
index 48fc67d3..bd959de9 100644
--- a/client/src/components/Header/Header.tsx
+++ b/client/src/components/header/header.tsx
@@ -1,11 +1,24 @@
-import styles from './Header.module.css';
+/* Stylesheet imports */
+import styles from './header.module.css';
+
+/* Imports from wouter */
+import { useRoute } from 'wouter';
+
+/* Imports from React */
+import { useContext } from 'react';
+
+/* Icon imports from react-icons */
import { IoArrowBackCircleOutline } from 'react-icons/io5';
+/* Imports from react-bootstrap */
import Container from 'react-bootstrap/Container';
import Navbar from 'react-bootstrap/Navbar';
+/* Import for the authorized user context */
+import { UserAuthContext } from '../../lib/auth';
+
/**
- * The props types for the Header.
+ * The properties and types for the Header.
*/
interface Props {
pageName?: string;
@@ -22,19 +35,33 @@ interface Props {
* @returns Returns the customized header for the page as a JSX.Element.
*/
const Header = (props: Props) => {
+ const [isHomeActive] = useRoute('/home');
+ const [isRootActive] = useRoute('/');
+ const user = useContext(UserAuthContext);
+
return (
<>
- {props.enableLogoHeader ? (
-
-
sky.net
-
+ {props.enableLogoHeader && user ? (
+ isHomeActive || isRootActive ? (
+
+
sky.net
+
+ ) : (
+
+
sky.net
+
+ )
) : (
- {props.pageName}
+
+ {props.pageName}
+
)}
@@ -42,6 +69,9 @@ const Header = (props: Props) => {
);
};
+/**
+ * Exports the header for external use.
+ */
export default Header;
/**
diff --git a/client/src/components/Hotbar/hotbar-animation.css b/client/src/components/hotbar/hotbar-animation.css
similarity index 100%
rename from client/src/components/Hotbar/hotbar-animation.css
rename to client/src/components/hotbar/hotbar-animation.css
diff --git a/client/src/components/hotbar/hotbar.module.css b/client/src/components/hotbar/hotbar.module.css
new file mode 100644
index 00000000..9023adf1
--- /dev/null
+++ b/client/src/components/hotbar/hotbar.module.css
@@ -0,0 +1,207 @@
+.nav-container p {
+ margin: 0;
+ padding: 0;
+}
+
+.nav-container li a {
+ margin-right: 100px;
+ min-width: 90px;
+ padding: 1px;
+ text-decoration: none;
+ text-align: center;
+ flex-basis: 100%;
+ color: #2196f3;
+ font-family: Fjalla One;
+ font-weight: bold;
+ letter-spacing: 1px;
+}
+
+.nav-container {
+ position: fixed;
+ bottom: 0%;
+ left: 0%;
+ width: 100vw;
+ margin-bottom: 105px;
+ z-index: 10;
+}
+
+.nav {
+ position: relative;
+ width: 100vw;
+}
+
+.circle {
+ position: absolute;
+ border: none;
+ background-color: #242c38;
+ padding: 60px;
+ border-radius: 50%;
+ z-index: 1;
+}
+
+.circle-right {
+ right: 0%;
+ transform: translate(50%, 10%);
+}
+
+.circle-left {
+ left: 0%;
+ transform: translate(-50%, 10%);
+}
+
+.bar {
+ border: none;
+ position: absolute;
+ background-color: #242c38;
+ padding: 60px;
+ width: 100vw;
+ top: 50px;
+}
+
+.nav-icon {
+ position: absolute;
+ z-index: 2;
+ font-size: 2em;
+ color: #2196f3;
+}
+
+.icon-right {
+ right: 0%;
+}
+
+.icon-left {
+ left: 0%;
+}
+
+.menu-container {
+ position: absolute;
+ background-color: #2196f3;
+ z-index: 5;
+ padding: 10px;
+ border-radius: 50%;
+ right: 50%;
+ transform: translate(50%);
+
+ border: 2px solid black;
+ box-shadow: 0px -1px 0px #000;
+}
+
+.menu-backdrop {
+ position: absolute;
+ background-color: transparent;
+ z-index: 3;
+ width: 110px;
+ border-radius: 50%;
+ padding: 50px;
+ right: 50%;
+ top: 0;
+ transform: translate(50%);
+}
+
+.nav-container li a p {
+ font-size: 11pt;
+}
+
+.menu-sub-icons {
+ font-size: 3em;
+ -webkit-filter: drop-shadow(3px 3px 2px var(--metal-grey));
+ filter: drop-shadow(3px 3px 2px var(--metal-grey));
+}
+
+.menu-icon,
+.close-icon {
+ font-size: 4em;
+}
+
+.nav-container ul.submenu {
+ position: absolute;
+ right: 50%;
+ top: 0%;
+ width: 150px;
+ transform: translateX(-50%);
+}
+
+.nav-container input:checked ~ ul.submenu > li {
+ transform: rotate(calc((180deg / 5) * var(--item))) translateX(0px);
+ opacity: 1;
+}
+
+.nav-container input:not(:checked) ~ ul.submenu > li {
+ pointer-events: none;
+}
+
+.nav-container ul.submenu > li {
+ --item: 1;
+ position: absolute;
+ opacity: 0;
+ transform: rotate(calc((180deg / 5) * var(--item))) translateX(120px);
+ transform-origin: right center;
+ display: flex;
+ align-self: center;
+ transition: transform 0.7s, opacity 0.2s;
+}
+
+.nav-container ul.submenu > li p {
+ text-shadow: -1px -1px 0 var(--metal-grey), 1px -1px 0 var(--metal-grey), -1px 1px 0 var(--metal-grey),
+ 1px 1px 0 var(--metal-grey);
+}
+
+.nav-container ul.submenu li:nth-child(1) {
+ --item: 1;
+}
+
+.nav-container ul.submenu li:nth-child(2) {
+ --item: 2;
+}
+
+.nav-container ul.submenu li:nth-child(3) {
+ --item: 3;
+}
+
+.nav-container ul.submenu li:nth-child(4) {
+ --item: 4;
+}
+
+.nav-container ul.submenu li:nth-child(1) a {
+ transform: rotate(-36deg);
+}
+
+.nav-container ul.submenu li:nth-child(2) a {
+ transform: rotate(-72deg);
+}
+
+.nav-container ul.submenu li:nth-child(3) a {
+ transform: rotate(-108deg);
+}
+
+.nav-container ul.submenu li:nth-child(4) a {
+ transform: rotate(-144deg);
+}
+
+.back-drop-div {
+ pointer-events: none;
+
+ position: absolute;
+ right: 50%;
+ bottom: 0%;
+ width: 100vw;
+ height: 100vh;
+ transform: translateY(50%) translateX(50%);
+ background-color: rgba(255, 255, 255, 0.4);
+
+ backdrop-filter: blur(10px);
+ mask: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 80%);
+
+ opacity: 0;
+ transition: transform 0.7s, opacity 0.3s;
+}
+
+.nav-container input:checked ~ .back-drop-div {
+ opacity: 1;
+}
+
+.menu-label {
+ position: absolute;
+ right: 50%;
+ transform: translateX(50%);
+}
diff --git a/client/src/components/Hotbar/Hotbar.tsx b/client/src/components/hotbar/hotbar.tsx
similarity index 76%
rename from client/src/components/Hotbar/Hotbar.tsx
rename to client/src/components/hotbar/hotbar.tsx
index b23006ad..0a346d2e 100644
--- a/client/src/components/Hotbar/Hotbar.tsx
+++ b/client/src/components/hotbar/hotbar.tsx
@@ -1,21 +1,31 @@
-import { useState } from 'react';
-
-import styles from './Hotbar.module.css';
+/* Stylesheet imports */
+import styles from './hotbar.module.css';
import './hotbar-animation.css';
+/* Imports from react */
+import { useState } from 'react';
+
+/* Icon imports from react-icons */
import { GoHomeFill } from 'react-icons/go';
import { IoMdPerson } from 'react-icons/io';
import { IoMenuSharp } from 'react-icons/io5';
import { IoCloseCircleOutline } from 'react-icons/io5';
import { IoIosAddCircle } from 'react-icons/io';
-import { MdFeed } from 'react-icons/md';
-// import { AiFillMessage } from 'react-icons/ai';
-import { PiGearFill } from 'react-icons/pi';
+import { MdSearch } from 'react-icons/md';
+import { AiFillMessage } from 'react-icons/ai';
import { MdPeople } from 'react-icons/md';
+/**
+ * Contructs the hotbar for the website.
+ *
+ * @returns The Hotbar for this website as a JSX.Element
+ */
const Hotbar = () => {
const [isChecked, setCheck] = useState(false);
+ /**
+ * Responsible for toggling between the hamburger menu icon and the close icon.
+ */
function toggleMenu() {
let menuIcon = document.getElementById('menu-icon');
let closeIcon = document.getElementById('close-icon');
@@ -43,18 +53,18 @@ const Hotbar = () => {
-
+
-
+
@@ -67,10 +77,10 @@ const Hotbar = () => {
-
+
-
-
SETTINGS
+
+
MESSAGES
@@ -93,4 +103,7 @@ const Hotbar = () => {
);
};
+/**
+ * Exports the hotbar for external use.
+ */
export default Hotbar;
diff --git a/client/src/components/loader/small-loader.module.css b/client/src/components/loader/small-loader.module.css
index 0e3ad9e2..f18913ca 100644
--- a/client/src/components/loader/small-loader.module.css
+++ b/client/src/components/loader/small-loader.module.css
@@ -1,15 +1,18 @@
.loader {
+ color: black;
position: relative;
font-family: Bitsumishi;
}
.loader::before {
+ color: inherit;
content: '[';
transform: translateX(-0.75rem);
animation: back-and-forth-left 1s infinite;
}
.loader::after {
+ color: inherit;
content: ']';
transform: translateX(0.75rem);
animation: back-and-forth-right 1s infinite;
diff --git a/client/src/components/loader/small-loader.tsx b/client/src/components/loader/small-loader.tsx
index 83d13760..ab59964f 100644
--- a/client/src/components/loader/small-loader.tsx
+++ b/client/src/components/loader/small-loader.tsx
@@ -1,15 +1,21 @@
+import { CSSProperties } from 'react';
import { Scrambler } from '../scrambler/scrambler';
import styles from './small-loader.module.css';
-export const SmallLoader = () => {
+interface Props {
+ style?: CSSProperties;
+}
+
+export const SmallLoader = (props: Props) => {
const text = 'Loading';
return (
+ className={`${styles.loader} w-100 h-100 d-flex align-items-center justify-content-center position-relative`}
+ style={props.style}>
{text}
-
+
);
diff --git a/client/src/components/ModalConfirmation/ModalConfirmation.module.css b/client/src/components/modal-confirmation/modal-confirmation.module.css
similarity index 78%
rename from client/src/components/ModalConfirmation/ModalConfirmation.module.css
rename to client/src/components/modal-confirmation/modal-confirmation.module.css
index 3d18d8bb..3d63202f 100644
--- a/client/src/components/ModalConfirmation/ModalConfirmation.module.css
+++ b/client/src/components/modal-confirmation/modal-confirmation.module.css
@@ -15,6 +15,11 @@
font-family: BabaPro;
}
+.modal button:active {
+ box-shadow: 4px 4px var(--signup-button-active) 5 !important;
+ transform: translateY(5px) !important;
+}
+
.header-font {
font-family: Bitsumishi;
font-size: 2em;
diff --git a/client/src/components/ModalConfirmation/ModalConfirmation.tsx b/client/src/components/modal-confirmation/modal-confirmation.tsx
similarity index 94%
rename from client/src/components/ModalConfirmation/ModalConfirmation.tsx
rename to client/src/components/modal-confirmation/modal-confirmation.tsx
index f964193a..21a65a5c 100644
--- a/client/src/components/ModalConfirmation/ModalConfirmation.tsx
+++ b/client/src/components/modal-confirmation/modal-confirmation.tsx
@@ -1,5 +1,7 @@
-import styles from './ModalConfirmation.module.css';
+/* Stylesheet imports */
+import styles from './modal-confirmation.module.css';
+/* Imports from react-bootstrap */
import Modal from 'react-bootstrap/Modal';
import Button from 'react-bootstrap/Button';
@@ -67,4 +69,7 @@ const ModalConfirmation = (props: Props) => {
);
};
+/**
+ * Exports the modal for external use.
+ */
export default ModalConfirmation;
diff --git a/client/src/components/Page/Page.module.css b/client/src/components/page/page.module.css
similarity index 97%
rename from client/src/components/Page/Page.module.css
rename to client/src/components/page/page.module.css
index e26d791f..ef227378 100644
--- a/client/src/components/Page/Page.module.css
+++ b/client/src/components/page/page.module.css
@@ -20,7 +20,7 @@
}
.header {
- z-index: 1;
+ z-index: 10;
grid-area: header;
position: fixed;
width: 100%;
diff --git a/client/src/components/Page/Page.tsx b/client/src/components/page/page.tsx
similarity index 55%
rename from client/src/components/Page/Page.tsx
rename to client/src/components/page/page.tsx
index 57dd0056..5448640e 100644
--- a/client/src/components/Page/Page.tsx
+++ b/client/src/components/page/page.tsx
@@ -1,13 +1,20 @@
-import styles from './Page.module.css';
+import styles from './page.module.css';
-import Header from '../Header/Header';
-import Hotbar from '..//Hotbar/Hotbar';
+import Header from '../header/header';
+import Hotbar from '../hotbar/hotbar';
+/**
+ * Interface that represents the arguments passed down to the Page component.
+ *
+ * @params Covered on the component documentation.
+ */
interface PageProp {
+ pageName?: string;
content: JSX.Element | JSX.Element[];
logoHeader?: boolean;
- pageName?: string;
noHeader?: boolean;
+ noNavbar?: boolean;
+ noBootstrap?: boolean;
}
/**
@@ -15,9 +22,11 @@ interface PageProp {
*
* @param props.pageName string - (Optional) Name of the page
* @param props.content JSX.Element | JSX.Element[] - Content that will be added in the middle
- * @param props.noHeader boolean - (Optional) Takes off the header
* @param props.logoHeader boolean - (Optional) Gives a header with only website name if true. If false give regular header
- * @returns JSX.Element
+ * @param props.noHeader boolean - (Optional) Takes off the header
+ * @param props.noNavbar boolean - (Optional) Takes off the navbar
+ * @param props.noBootstrap boolean - (Optional) Takes off the bootstrap content container format
+ * @returns JSX.Element - Page component as a JSX.Element
*/
const Page = (props: PageProp) => {
const noHeader = props.noHeader ? styles.noHeader : '';
@@ -33,13 +42,22 @@ const Page = (props: PageProp) => {
)}
-
+ {props.noNavbar ? (
+ <>>
+ ) : (
+
+
+
+ )}
);
};
+/**
+ * Exports the Page component for external use.
+ */
export default Page;
diff --git a/client/src/components/paginated-post-feed/paginated-post-feed.tsx b/client/src/components/paginated-post-feed/paginated-post-feed.tsx
new file mode 100644
index 00000000..7c4846f8
--- /dev/null
+++ b/client/src/components/paginated-post-feed/paginated-post-feed.tsx
@@ -0,0 +1,96 @@
+import { useEffect, useRef, useState } from 'react';
+import { SmallLoader } from '../loader/small-loader';
+import { Else, If, Then } from 'react-if';
+import Post from '../post/post';
+
+/**
+ * Interface that represents the arguments passed down to the PaginatedPostFeed component.
+ *
+ * @params Covered on the component documentation
+ */
+interface Props {
+ feedKey: string;
+ fetchPage: (page: number) => Promise;
+}
+
+/**
+ * A feed of posts that automatically fetches the next page while a
+ * user is scrolling.
+ *
+ * @param props.feedKey A key that aligns to the uniqueness of this feed, to handle scroll restoration.
+ * @param props.fetchPage The function used to fetch new posts. If this returns an empty array, it means that the end has been reached.
+ * @returns JSX.Element - PaginatedPostFeed component as a JSX.Element
+ */
+export const PaginatedPostFeed = (props: Props) => {
+ const [page, setPage] = useState(1);
+ const [endReached, setEndReached] = useState(false);
+
+ const postsListRef = useRef(null);
+ const [loading, setLoading] = useState(false);
+ const [posts, setPosts] = useState([]);
+
+ /**
+ * Function to check if user has scrolled to the bottom of the page and
+ * needs to load the next posts from the database.
+ *
+ * @returns void
+ */
+ const checkScroll = () => {
+ const postsList = postsListRef.current;
+ if (!postsList) return;
+
+ const rect = postsList.getBoundingClientRect();
+ const distFromBottom = Math.max(0, rect.bottom - innerHeight);
+ if (distFromBottom <= rect.height / 4) setPage((page) => page + 1);
+ };
+
+ /**
+ * Use effect used to start a timer to check if user is loading or has
+ * scrolled to the end of the page every 1 second.
+ *
+ * @returns () => void - A function to stop the timer.
+ */
+ useEffect(() => {
+ if (endReached || loading) return;
+
+ const interval = setInterval(checkScroll, 1000);
+ return () => clearInterval(interval);
+ }, [endReached, loading]);
+
+ /**
+ * Use effect used to fetch and load new posts at the end of
+ * the page.
+ */
+ useEffect(() => {
+ setLoading(true);
+ props
+ .fetchPage(page)
+ .then((newPosts) => {
+ setEndReached(() => newPosts.length === 0);
+ setPosts([...posts, ...newPosts]);
+ })
+ .catch(() => setEndReached(true))
+ .finally(() => setLoading(false));
+ }, [page]);
+
+ return (
+
+
+ {posts
+ .filter((v) => !!v?._id)
+ .map((v, i) => {
+ return
;
+ })}
+
+
+
+ There is nothing else to see, so do not look.
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/components/paginated-user-list/paginated-user-list.module.css b/client/src/components/paginated-user-list/paginated-user-list.module.css
new file mode 100644
index 00000000..3c90815b
--- /dev/null
+++ b/client/src/components/paginated-user-list/paginated-user-list.module.css
@@ -0,0 +1,14 @@
+.user-box {
+ font-family: TTOctosquares;
+ padding: 10px 6px;
+ width: 100%;
+ max-width: 750px;
+}
+
+.link {
+ text-decoration: none;
+ color: inherit;
+ display: flex;
+ justify-content: space-between;
+ margin: 0px;
+}
\ No newline at end of file
diff --git a/client/src/components/paginated-user-list/paginated-user-list.tsx b/client/src/components/paginated-user-list/paginated-user-list.tsx
new file mode 100644
index 00000000..6aaff118
--- /dev/null
+++ b/client/src/components/paginated-user-list/paginated-user-list.tsx
@@ -0,0 +1,133 @@
+import styles from './paginated-user-list.module.css';
+import { useEffect, useRef, useState } from 'react';
+import { SmallLoader } from '../loader/small-loader';
+import { Else, If, Then } from 'react-if';
+import UIBox from '../uibox/uibox';
+import { Link } from 'wouter';
+
+/**
+ * Interface that represents the arguments passed down to the PaginatedPostFeed component.
+ *
+ * @params Covered on the component documentation
+ */
+interface Props {
+ feedKey: string;
+ fetchPage: (page: number) => Promise;
+}
+
+/**
+ * A feed of user list that automatically fetches the next page while a
+ * user is scrolling down.
+ *
+ * @param props.feedKey A key that aligns to the uniqueness of this user list, to handle scroll restoration.
+ * @param props.fetchPage The function used to fetch new users to the user list. If this returns an empty array, it means that the end has been reached.
+ * @returns JSX.Element - PaginatedUserList component as a JSX.Element
+ */
+export const PaginatedUserList = (props: Props) => {
+ const [page, setPage] = useState(0);
+ const [endReached, setEndReached] = useState(false);
+
+ const usersListRef = useRef(null);
+ const [loading, setLoading] = useState(false);
+ const [users, setUsers] = useState([]);
+
+ /**
+ * Fetches a specific page of users based on the increment passed as
+ * argument to know which page to load.
+ *
+ * @param increment number - Increment of page relative to the present page.
+ * @returns void
+ */
+ const fetchIncrement = async (increment: number) => {
+ if (loading || endReached) return;
+
+ const nextPage = (page ?? 0) + increment;
+ if (nextPage < 1) return;
+
+ try {
+ const newUsers = await props.fetchPage(nextPage);
+ if (newUsers.length === 0) return setEndReached(true);
+
+ setPage(nextPage);
+ setUsers(increment < 0 ? [...newUsers, ...users] : [...users, ...newUsers]);
+ } catch (err) {
+ return setEndReached(true);
+ }
+ };
+
+ /**
+ * Function to check if user has scrolled to the bottom of the page and
+ * needs to load the next posts from the database. If so, increases the
+ * scrollable size of the window and calls to fetch the next posts.
+ *
+ * @returns void
+ */
+ const checkScroll = () => {
+ if (loading || endReached) return;
+
+ const usersList = usersListRef.current;
+ if (!usersList) return;
+
+ const scrollBottom = window.scrollY + window.innerHeight;
+
+ let increment;
+ if (scrollBottom > usersList.scrollHeight - window.innerHeight) increment = 1;
+
+ if (!increment) return;
+
+ setLoading(true);
+ fetchIncrement(increment).then(() => setLoading(false));
+ };
+
+ /**
+ * Use effect used to check scroll and fetch posts once the page is
+ * loaded for the first time.
+ */
+ useEffect(checkScroll, []);
+
+ /**
+ * Use effect used to start a timer to check if user has
+ * scrolled to the end of the page every 1 second.
+ *
+ * @returns () => void - A function to stop the timer.
+ */
+ useEffect(() => {
+ const interval = setInterval(checkScroll, 1000);
+ return () => clearInterval(interval);
+ });
+
+ return (
+
+
+ {users
+ .filter((v) => !!v)
+ .map((v, i) => {
+ return (
+
+ {'@' + v.userName}
+ {v.followerCount + ' followers'}
+
+ }
+ dark
+ curved
+ clickable
+ />
+ );
+ })}
+
+
+
+ There is nothing else to see, so do not look.
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/components/post/post-header/post-header.module.css b/client/src/components/post/post-header/post-header.module.css
new file mode 100644
index 00000000..425a68ef
--- /dev/null
+++ b/client/src/components/post/post-header/post-header.module.css
@@ -0,0 +1,39 @@
+.info-list *:not(:last-child)::after {
+ content: ',';
+}
+
+.postHeaderContainer {
+ position: relative;
+}
+
+.deleteButton {
+ position: absolute;
+ top: 8px;
+ /* Adjusted spacing for better alignment */
+ right: 8px;
+ /* Adjusted spacing for better alignment */
+ background: none;
+ border: none;
+ color: rgb(0, 0, 0);
+ font-size: 1.0rem;
+ /* Reduced font size */
+ cursor: pointer;
+ padding: 4px;
+ /* Added padding for better click area */
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.buttons {
+ background-color: #242c38;
+ border: 2px solid #2196f3;
+ box-shadow: 4px 5px 0px #355a79;
+ padding: 4px;
+ /* Adjusted padding to make the button smaller */
+ font-size: 0.8rem;
+ /* Reduced font size inside the button */
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
\ No newline at end of file
diff --git a/client/src/components/post/post-header/post-header.tsx b/client/src/components/post/post-header/post-header.tsx
new file mode 100644
index 00000000..917607cb
--- /dev/null
+++ b/client/src/components/post/post-header/post-header.tsx
@@ -0,0 +1,176 @@
+/* Import from React */
+import { useEffect, useState, useContext } from 'react';
+import { If, Then } from 'react-if';
+import { FaTrashAlt } from 'react-icons/fa';
+
+/* Import from wouter */
+import { Link } from 'wouter';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../../lib/axios';
+
+/* Stylesheet imports */
+import style from './post-header.module.css';
+
+/* Import from other components created */
+import { UserAuthContext } from '../../../lib/auth';
+import ModalConfirmation from '../../modal-confirmation/modal-confirmation';
+
+/* Import from UI components */
+import UIBox from '../../uibox/uibox';
+
+/* Define the Props interface */
+interface Props {
+ userName: string;
+ format: 'short' | 'expanded';
+ authorId: string;
+ avatarUrl?: string;
+ createdAt?: Date;
+ deleted?: boolean;
+ location?: {
+ planetId: string;
+ latitude: number;
+ longitude: number;
+ _id: string;
+ };
+ _id: string; // Add postId prop to identify the post
+}
+
+/**
+ * Post header component representing the header of the Post component.
+ *
+ * @param props._id string - Id of the post in the database.
+ * @param props.authorId string - Id of the author of the post.
+ * @param props.userName string - UserName of author of the post.
+ * @param props.createdAt Date - (optional) Date in which the post was created.
+ * @param props.format short | long - If true, displays extra information and actions about the post
+ * @param props.avatarUrl string - Url for displaying the avatar picture
+ * @param props.deleted boolean - If true, displays the post as a deleted post.
+ * @param props.Location LocationOject - Location in which the post was created. (planetId: string, latitude: number, longitude: number, _id: string)
+ * @returns JSX.Element - Post header as a JSX.Element
+ */
+export const PostHeader = (props: Props) => {
+ const user = useContext(UserAuthContext);
+ const currentUserId = user?._id; // Assuming user object has _id property
+
+ const timeOptions: Intl.DateTimeFormatOptions =
+ props.format === 'expanded' ? { hour: 'numeric', minute: 'numeric' } : {};
+
+ const dateFormatter = Intl.DateTimeFormat(navigator.language, {
+ month: 'long',
+ day: 'numeric',
+ ...timeOptions,
+ });
+
+ const [date, setDate] = useState();
+
+ /**
+ * Used to properly format the date the post was created from the database
+ * once the createdAt attribute changes.
+ */
+ useEffect(() => {
+ if (!props.createdAt) setDate(undefined);
+ else setDate(new Date(props.createdAt));
+ }, [props.createdAt]);
+
+ const [locationName, setLocationName] = useState();
+
+ /**
+ * Used to properly fetch and store the planet the post was created from
+ * the database once the location attribute changes.
+ */
+ useEffect(() => {
+ if (!props.location) return setLocationName(undefined);
+ api.get(`/planet/${props.location.planetId}`).then(({ data }) => setLocationName(data.value.name));
+ }, [props.location?._id]);
+
+ const [showModal, setShowModal] = useState(false);
+
+ /**
+ * Handles the delete post request to the frontend.
+ *
+ * @return void
+ */
+ const handleConfirmDelete = async () => {
+ try {
+ await api.delete(`/post/${props._id}`);
+ setShowModal(false);
+ window.location.reload();
+ } catch (error) {
+ console.error('Failed to delete post:', error);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
@{props.userName}
+
+
+
+
+
+ {dateFormatter.format(date)}
+
+
+
+
+
+ {locationName}
+
+
+
+
+
+
+ setShowModal(true)}>
+
+
+
+ }
+ curved
+ clickable
+ dark
+ />
+
+
+
+
+
+
+ setShowModal(false)}
+ title='Delete Post'
+ body={Are you sure you want to delete this post?
}
+ disableFooter={false}
+ onContinue={handleConfirmDelete}
+ />
+ >
+ );
+};
diff --git a/client/src/components/post/post.module.css b/client/src/components/post/post.module.css
new file mode 100644
index 00000000..113eb70c
--- /dev/null
+++ b/client/src/components/post/post.module.css
@@ -0,0 +1,18 @@
+.link {
+ color: inherit;
+ text-decoration: none;
+}
+
+.post-container {
+ font-family: TTOctosquares;
+ width: 100%;
+ max-width: 750px;
+}
+
+.user-container {
+ padding: 2px 8px;
+ margin: 2px 0px;
+ width: max-content;
+ text-align: left;
+ font-size: 1rem;
+}
diff --git a/client/src/components/post/post.tsx b/client/src/components/post/post.tsx
new file mode 100644
index 00000000..cd8cd8c0
--- /dev/null
+++ b/client/src/components/post/post.tsx
@@ -0,0 +1,307 @@
+/* Stylesheet imports */
+import styles from './post.module.css';
+
+/* Import from React */
+import { useEffect, useState } from 'react';
+import { If, Then } from 'react-if';
+
+/* Import from wouter */
+import { useLocation } from 'wouter';
+
+/* Import from UI components */
+import UIBox from '../uibox/uibox';
+
+/* Import from React Icons */
+import { FaRegHeart } from 'react-icons/fa';
+import { FaHeart } from 'react-icons/fa';
+import { RiShareBoxLine } from 'react-icons/ri';
+import { FaRegBookmark } from 'react-icons/fa';
+import { FaBookmark } from 'react-icons/fa';
+import { FaRocketchat } from 'react-icons/fa';
+import { GoCrossReference } from 'react-icons/go';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../lib/axios';
+
+/* Import from other components created */
+import { PostHeader } from './post-header/post-header';
+
+/**
+ * Interface that represents the arguments passed down to the Post component.
+ *
+ * @params Covered on the component documentation.
+ */
+interface PostProp {
+ _id: string;
+ authorId: string;
+ content: string;
+ userName: string;
+ likeCount?: number;
+ commentCount?: number;
+ createdAt?: Date;
+ format?: 'short' | 'expanded';
+ parentPost?: string;
+ avatarUrl?: string;
+ deleted?: boolean;
+ location?: {
+ planetId: string;
+ latitude: number;
+ longitude: number;
+ _id: string;
+ };
+ // media?: Array;
+}
+
+/**
+ * Post component representing the thumbnail post of an user.
+ *
+ * @param props._id string - Id of the post in the database.
+ * @param props.authorId string - Id of the author of the post.
+ * @param props.content string - Text of the post.
+ * @param props.userName string - UserName of author of the post.
+ * @param props.likeCount number - Number of likes of the post.
+ * @param props.commentCount number - Number of comments of the post.
+ * @param props.createdAt Date - (optional) Date in which the post was created.
+ * @param props.format short | long - If true, displays extra information and actions about the post
+ * @param props.isRoot boolean - If true, displays the post as a root post instead of as a comment post.
+ * @param props.avatarUrl string - Url for displaying the avatar picture of the author of the post
+ * @param props.deleted boolean - If true, displays the post as a deleted post.
+ * @param props.Location LocationOject - Location in which the post was created. (planetId: string, latitude: number, longitude: number, _id: string)
+ * @return JSX.Element - Post component as a JSX.Element
+ */
+const Post = (props: PostProp): JSX.Element => {
+ const [routerPath, navigate] = useLocation();
+
+ const [isActionActive, setIsActionActive] = useState(false);
+
+ const [saved, setSaved] = useState(false);
+ const [parentPost, setParentPost] = useState();
+
+ const getCacheKey = (property: string) => `${props._id}:${property}`;
+ const savedCacheKey = getCacheKey('saved');
+ const likedCacheKey = getCacheKey('liked');
+
+ const detailsUrl = `/post/${props._id}`;
+
+ const [format, setFormat] = useState<'short' | 'expanded'>(props.format ?? 'short');
+ useEffect(() => setFormat(props.format ?? 'short'), [props.format]);
+
+ /**
+ * Use effect used to fetch the save status of that post (if a post
+ * was already saved or not).
+ */
+ useEffect(() => {
+ const fetchSaveStatus = async () => {
+ try {
+ const response = await api.get(`/post/${props._id}/save`, { id: savedCacheKey });
+ if (response.data.success) setSaved(response.data.value);
+ } catch (error) {
+ console.error('Error fetching save status:', error);
+ }
+ };
+
+ fetchSaveStatus();
+ }, []);
+
+ /**
+ * Use effect used to fetch the status of that post as comment post or
+ * parent/root post and if it is a comment, fetch the parent post.
+ */
+ useEffect(() => {
+ if (props.parentPost === undefined) return setParentPost(undefined);
+
+ const getParentPost = async () => {
+ try {
+ const res = await api.get(`/post/${props.parentPost}`).then((res) => res.data);
+ if (!res.value) return;
+ setParentPost(res.value);
+ } catch {}
+ };
+
+ getParentPost();
+ }, [props.parentPost]);
+
+ /**
+ * Used to bookmark or unbookmark a post and send that request
+ * to the backend and database.
+ *
+ * @return void
+ */
+ const onBookmark = async () => {
+ if (isActionActive) return;
+ setIsActionActive(true);
+
+ const url = `/post/${props._id}/save`;
+
+ try {
+ if (saved) {
+ await api.delete(url, { cache: { update: { [savedCacheKey]: 'delete' } } });
+ setSaved(false);
+ } else {
+ await api.post(url, undefined, { cache: { update: { [savedCacheKey]: 'delete' } } });
+ setSaved(true);
+ }
+ } catch (error) {
+ console.error('Error updating save status:', error);
+ } finally {
+ setIsActionActive(false);
+ }
+ };
+
+ const [liked, setLiked] = useState(false);
+ const [likeCount, setLikeCount] = useState(props.likeCount ?? 0);
+
+ /**
+ * Use effect used to fetch the like status of that post (if a post
+ * was already liked or not).
+ */
+ useEffect(() => {
+ const fetchLikeStatus = async () => {
+ try {
+ const response = await api.get(`/post/${props._id}/like`, { id: likedCacheKey });
+ setLiked(response.data.value);
+ } catch (error) {
+ console.error('Error fetching like status:', error);
+ }
+ };
+
+ fetchLikeStatus();
+ }, []);
+
+ /**
+ * Used to like or dislike a post and send that request
+ * to the backend and database.
+ *
+ * @return void
+ */
+ const onLike = async () => {
+ if (isActionActive) return;
+ setIsActionActive(true);
+
+ const url = `/post/${props._id}/like`;
+
+ try {
+ if (liked) {
+ await api.delete(url, { cache: { update: { [likedCacheKey]: 'delete' } } });
+ setLikeCount(likeCount - 1);
+ setLiked(false);
+ } else {
+ await api.post(url, undefined, { cache: { update: { [likedCacheKey]: 'delete' } } });
+ setLikeCount(likeCount + 1);
+ setLiked(true);
+ }
+ } catch (error) {
+ console.error('Error updating like status:', error);
+ } finally {
+ setIsActionActive(false);
+ }
+ };
+
+ /**
+ * Used to open a window on the browser to allow the user to share or
+ * copy the url of the post.
+ *
+ * @return void
+ */
+ function onShare() {
+ const shareUrl = location.origin + detailsUrl;
+ navigator.share({ url: shareUrl, title: `Post by ${props.userName}`, text: `Post by ${props.userName}` });
+ }
+
+ /**
+ * Used to redirect the user to the post detail's page of that user.
+ *
+ * @return void
+ */
+ const viewDetails = () => {
+ if (routerPath === detailsUrl) return;
+ navigate(detailsUrl);
+ };
+
+ return (
+
+
+
+
+
+
+ navigate(`/post/${parentPost?._id}`)}>
+
+
+ {parentPost?.deleted ? (
+ Reply of a deleted post
+ ) : (
+ <>
+ Reply of "
+ {(parentPost?.content.length ?? 0) < 5
+ ? parentPost?.content
+ : parentPost?.content.slice(0, 5) + '...'}
+ " by {parentPost?.userName}
+ >
+ )}
+
+
+
+
+
+
+
+
+ {props.deleted ? (
+ Post was deleted by author.
+ ) : (
+ props.content
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {saved ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {props.commentCount}
+
+
+
+
+
+ {liked ? : }
+
+ {likeCount}
+
+
+
+
+
+ >
+ }
+ />
+
+ );
+};
+
+export default Post;
diff --git a/client/src/components/Profile/Profile.module.css b/client/src/components/profile/profile.module.css
similarity index 70%
rename from client/src/components/Profile/Profile.module.css
rename to client/src/components/profile/profile.module.css
index ecf6f80b..82c7590a 100644
--- a/client/src/components/Profile/Profile.module.css
+++ b/client/src/components/profile/profile.module.css
@@ -1,15 +1,19 @@
+.profile-container {
+ max-width: 800px;
+}
+
.profile-box {
width: 100%;
}
.profile-info-box {
font-family: TTOctosquares;
- width: 100%;
+ /* width: 100%; */
color: #2196f3;
background-color: #242c38;
border-color: #2196f3;
box-shadow: 0px 0px 0px #2196f3;
- padding: 8px;
+ /* padding: 8px; */
}
.username {
@@ -33,7 +37,7 @@ button {
.bottomButtons {
display: flex;
width: 100%;
- justify-content: right;
+ gap: 20px;
}
.buttons {
@@ -47,3 +51,13 @@ button {
height: 3px;
width: 100vw;
}
+
+.border-right {
+ border-right: 1px solid #dee2e6;
+}
+
+@media (max-width: 700px) {
+ .border-right {
+ border-right: 0px solid #dee2e6;
+ }
+}
\ No newline at end of file
diff --git a/client/src/components/profile/profile.tsx b/client/src/components/profile/profile.tsx
new file mode 100644
index 00000000..e410bce5
--- /dev/null
+++ b/client/src/components/profile/profile.tsx
@@ -0,0 +1,394 @@
+import { useContext, useEffect, useMemo, useRef, useState } from 'react';
+import { FaCog, FaEdit } from 'react-icons/fa';
+import { FaLocationDot, FaRegMessage } from 'react-icons/fa6';
+import { SlUserFollow, SlUserUnfollow } from 'react-icons/sl';
+import { Else, If, Then } from 'react-if';
+import { Link } from 'wouter';
+
+import { api } from '../../lib/axios';
+import UIBox from '../uibox/uibox';
+import styles from './profile.module.css';
+import { toast } from 'react-toastify';
+import { SmallLoader } from '../loader/small-loader';
+import { UserAuthContext } from '../../lib/auth';
+
+/**
+ * Interface that represents the arguments passed down to the Profile component.
+ *
+ * @params Covered on the component documentation.
+ */
+interface ProfileProp {
+ _id: string;
+ userName: string;
+ bio?: string;
+ followerCount: number;
+ followingCount: number;
+ postCount: number;
+ location?: { planetId: string; latitude: number; longitude: number };
+ createdAt?: Date;
+ avatarUrl?: string;
+ className?: string;
+ deleted?: boolean;
+}
+
+const joinedDateFmt = new Intl.DateTimeFormat(navigator.language, { month: 'long', day: 'numeric', year: 'numeric' });
+
+/**
+ * Builds a Profile component that contains all the user information according
+ * to the inputs.
+ *
+ * @param props._id string - Id of the user
+ * @param props.userName string - Username of the user
+ * @param props.bio string - (Optional) Small description given by the user.
+ * @param props.followerCount number - Number of followers of the user
+ * @param props.followingCount number - Number of accounts the user follows
+ * @param props.postCount number - Number of posts created by the user
+ * @param props.Location LocationOject - Location in which the user is from/was created at (planetId: string, latitude: number, longitude: number, _id: string)
+ * @param props.createdAt Date - (optional) Date in which the user was created.
+ * @param props.avatarUrl string - Url for displaying the avatar picture of the user
+ * @param props.className string - String for styling.
+ * @return JSX.Element - Profile component as a JSX.Element
+ */
+const Profile = (props: ProfileProp): JSX.Element => {
+ const user = useContext(UserAuthContext);
+
+ const [isOutsideUser, setIsOutsideUser] = useState(false);
+ useEffect(() => setIsOutsideUser(props._id !== user._id), [user]);
+
+ const [isActionActive, setIsActionActive] = useState(false);
+ const [followed, setFollowed] = useState(false);
+ const [followerCount, setFollowerCount] = useState(props.followerCount);
+ const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl);
+ const [locationName, setLocationName] = useState('');
+
+ /**
+ * Use effect used to fetch the follow status of that user (if the user
+ * was already followed or not) once the id of the user is created/changes.
+ */
+ useEffect(() => {
+ const fetchSaveStatus = async () => {
+ if (!props._id) {
+ return;
+ }
+
+ try {
+ const response = await api.get(`/user/${props._id}/follow`);
+ if (response.data.success) {
+ setFollowerCount(props.followerCount);
+ setFollowed(response.data.value);
+ }
+ } catch (error) {
+ console.error('Error fetching follow status:', error);
+ }
+ };
+
+ fetchSaveStatus();
+ }, [props._id]);
+
+ /**
+ * Use effect used to fetch the avatar/profile picture of that user.
+ */
+ useEffect(() => {
+ setAvatarUrl(props.avatarUrl);
+ }, [props.avatarUrl]);
+
+ /**
+ * Use effect used to fetch the location in which that user created his profile.
+ */
+ useEffect(() => {
+ const id = props.location?.planetId;
+ if (!id) return setLocationName('');
+
+ api.get(`/planet/${id}`)
+ .then(({ data: res }) => setLocationName(res.value.name))
+ .catch();
+ }, [props.location]);
+
+ /**
+ * Used to follow or unfollow a user and send that request
+ * to the backend and database.
+ *
+ * @return void
+ */
+ const onFollow = async () => {
+ if (isActionActive) return;
+ setIsActionActive(true);
+
+ try {
+ if (followed) {
+ await api.delete(`/user/${props._id}/follow`);
+ setFollowed(false);
+ setFollowerCount((prevCount) => Math.max(0, prevCount - 1));
+ } else {
+ const response = await api.post(`/user/${props._id}/follow`);
+ if (response.data.success) {
+ setFollowed(true);
+ setFollowerCount((prevCount) => prevCount + 1);
+ }
+ }
+ } catch (error) {
+ console.error('Error updating follow status:', error);
+ } finally {
+ setIsActionActive(false);
+ }
+ };
+
+ const avatarFileTypes = ['image/png', 'image/jpeg', 'image/webp'];
+ const changeAvatarInput = useRef(null);
+
+ /**
+ * Used to start the upload process of an avatar change and upload of
+ * the new image.
+ *
+ * @return void
+ */
+ const initiateAvatarChange = () => {
+ const input = changeAvatarInput.current;
+ if (!input) return;
+
+ input.click();
+ };
+
+ const [isUploadingFile, setUploadingFile] = useState(false);
+
+ /**
+ * Used to change the avatar/rofile picture of an user based on an image
+ * that the user uploads.
+ * This function also takes care of changing the avatar on the database.
+ *
+ * @return void
+ */
+ const changeAvatar = (imageFile: File) => {
+ if (!avatarFileTypes.includes(imageFile.type) || imageFile.size > 3e6) {
+ toast.error('Invalid file selected!');
+ return;
+ }
+
+ if (isUploadingFile) return;
+ setUploadingFile(true);
+
+ const reader = new FileReader();
+ reader.addEventListener('load', async () => {
+ try {
+ const dataUrl = reader.result;
+ if (!dataUrl || typeof dataUrl !== 'string') return;
+
+ const { data: res } = await api.patch('/user/changeavatar', { avatarDataUrl: dataUrl });
+ if (res.success === false) throw 'Error';
+
+ setAvatarUrl(res.value);
+ toast.success('Updated avatar! Changes may take a few minutes.');
+ } catch {
+ toast.error('Failed to update avatar! Try again later.');
+ } finally {
+ setUploadingFile(false);
+ }
+ });
+
+ reader.readAsDataURL(imageFile);
+ };
+
+ /**
+ * Used to display the avatar image in the profile information section
+ * of the profile page.
+ *
+ * @return void
+ */
+ const avatarImgElement = useMemo(() => {
+ if (!avatarUrl) return <>>;
+ return (
+
+ );
+ }, [avatarUrl]);
+
+ return (
+ <>
+
+
+ {/*
*/}
+
+
+
+
+
+
+ {
+ const files = e.target.files;
+ const img = files && files[0];
+ if (!img) return;
+ changeAvatar(img);
+ }}
+ hidden
+ />
+
+
+ {avatarImgElement}
+
+
+
+
+
+ Change Avatar
+
+ }
+ />
+
+
+
+ {avatarImgElement}
+
+
+
+ {props.deleted ? (
+
Deleted User
+ ) : (
+ @{props.userName}
+ )}
+
+
+
+ {props.bio}
+
+
+
+
+
+
+
+
+ Joined from
+
+
+ {locationName}
+
+
+
+ on{' '}
+ {props.createdAt
+ ? joinedDateFmt.format(new Date(props.createdAt))
+ : ''}
+
+
+
+
+
+
+
+
+
+
+ {followerCount} Follower{followerCount !== 1 ? 's' : ''}
+
+
+
{props.followingCount} Following
+
+
+ {props.postCount} Post{props.postCount !== 1 ? 's' : ''}
+
+
+ >
+ }
+ className={styles.profileInfoBox}
+ curved
+ />
+
+
+
+ {isOutsideUser ? (
+
+
+
+
+
+ Follow
+
+
+
+
+
+
+ Unfollow
+
+
+
+ }
+ curved
+ clickable={!isActionActive}
+ dark
+ />
+
+ ) : (
+
+
+
+
+ Edit Profile
+
+ }
+ curved
+ clickable
+ dark
+ />
+
+
+ )}
+
+
+
+
+
+
+ Message
+
+
+
+
+ Settings
+
+
+
+ }
+ curved
+ clickable
+ dark
+ />
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default Profile;
diff --git a/client/src/components/ques-accordion/ques-accordion.module.css b/client/src/components/ques-accordion/ques-accordion.module.css
new file mode 100644
index 00000000..c352ba5d
--- /dev/null
+++ b/client/src/components/ques-accordion/ques-accordion.module.css
@@ -0,0 +1,22 @@
+.accordion-item, .accordion-item button {
+ background-color: var(--signup-background);
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+}
+
+.accordion-item button[aria-expanded = true] {
+ background-color: var(--signup-background);
+ color: #000;
+}
+
+.accordion-item button:focus {
+ box-shadow: none;
+}
+
+.accordion-item h2 {
+ font-family: BabaPro;
+}
+
+.accordion-body {
+ font-family: TTOctosquares;
+}
\ No newline at end of file
diff --git a/client/src/components/ques-accordion/ques-accordion.tsx b/client/src/components/ques-accordion/ques-accordion.tsx
new file mode 100644
index 00000000..d18fa0db
--- /dev/null
+++ b/client/src/components/ques-accordion/ques-accordion.tsx
@@ -0,0 +1,39 @@
+/* Stylesheet imports */
+import styles from './ques-accordion.module.css';
+
+/* Imports from react-bootstrap */
+import { Accordion } from 'react-bootstrap';
+
+/**
+ * The props types for the QuestionAccordion.
+ */
+interface Props {
+ question: string;
+ answer: string;
+ eventKey: string;
+}
+
+/**
+ * Returns the customized Accordion for a question in the FAQ.
+ *
+ * @param props the props for this accordion, as seen outlined in the interface.
+ * @param props.question the question that will go in the header of the accordion.
+ * @param props.answer the answer that will go in the body of the accordion.
+ * @param props.eventKey the event key that is unique to this accordion so it is not to be confused with others.
+ * @requires eventKey to be a string. Example: "0", "1", "2", "3" etc..
+ */
+const QuestionAccordion = (props: Props) => {
+ return (
+ <>
+
+ {props.question}
+ {props.answer}
+
+ >
+ );
+};
+
+/**
+ * Exports the accordion for external use.
+ */
+export default QuestionAccordion;
diff --git a/client/src/components/scrambler/scrambler.tsx b/client/src/components/scrambler/scrambler.tsx
index fb7c77d8..6ee0f4cc 100644
--- a/client/src/components/scrambler/scrambler.tsx
+++ b/client/src/components/scrambler/scrambler.tsx
@@ -1,9 +1,10 @@
-import { useEffect, useState } from 'react';
+import { CSSProperties, useEffect, useState } from 'react';
interface Props {
text: string;
scrambleSpeed?: number;
scrambleDelay?: number;
+ style?: CSSProperties;
}
const scrambleChars = ['@', '#', '$', '%', '-', '&', '*', '_'];
@@ -29,5 +30,5 @@ export const Scrambler = (props: Props) => {
return () => clearInterval(interval);
}, [props.scrambleSpeed, props.scrambleDelay, props.text]);
- return {text} ;
+ return {text} ;
};
diff --git a/client/src/components/seo/seo.tsx b/client/src/components/seo/seo.tsx
new file mode 100644
index 00000000..cfbda924
--- /dev/null
+++ b/client/src/components/seo/seo.tsx
@@ -0,0 +1,41 @@
+import { Helmet } from 'react-helmet';
+
+interface Props {
+ /** The page title. */
+ title: string;
+ /** The page description */
+ description: string;
+
+ /** Open Graph properties (for Facebook and other sites) */
+ og?: {
+ type: string;
+ image: string;
+ imageAlt: string;
+ };
+}
+
+/**
+ * An SEO component to render a Helmet instance and change the SEO
+ * information when this component is rendered.
+ *
+ * Adapted from https://www.freecodecamp.org/news/react-helmet-examples/
+ */
+export default function SEO(props: Props) {
+ return (
+
+ {props.title}
+
+
+
+
+
+
+
+
+ {/*
+
+
+ */}
+
+ );
+}
diff --git a/client/src/components/UIBox/UIBox.module.css b/client/src/components/uibox/uibox.module.css
similarity index 100%
rename from client/src/components/UIBox/UIBox.module.css
rename to client/src/components/uibox/uibox.module.css
diff --git a/client/src/components/UIBox/UIBox.tsx b/client/src/components/uibox/uibox.tsx
similarity index 79%
rename from client/src/components/UIBox/UIBox.tsx
rename to client/src/components/uibox/uibox.tsx
index a09feacc..fb2eaac2 100644
--- a/client/src/components/UIBox/UIBox.tsx
+++ b/client/src/components/uibox/uibox.tsx
@@ -1,6 +1,10 @@
-import styles from './UIBox.module.css';
-import { Container } from 'react-bootstrap';
+import styles from './uibox.module.css';
+/**
+ * Interface that represents the arguments passed down to the UIBox component.
+ *
+ * @params Covered on the component documentation
+ */
interface UIBoxProp {
content: JSX.Element | string;
curved?: boolean;
@@ -30,7 +34,7 @@ const UIBox = (props: UIBoxProp): JSX.Element => {
return (
<>
- {props.content}
+ {props.content}
>
);
};
diff --git a/client/src/index.css b/client/src/index.css
index ba523db2..fda16674 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -1,18 +1,34 @@
+/**
+ * Global CSS variables defining color schemes and shadow options.
+ */
:root {
- --signup-background: #f8f4e5;
- --signup-input-focus: #ebe2c2;
- --signup-button-active: #ebe2c2;
+ --signup-background: #f8f4e5; /* Background color for signup page */
+ --signup-input-focus: #ebe2c2; /* Background color for focused input fields in signup page */
+ --signup-button-active: #ebe2c2; /* Active color for buttons in signup page */
- --login-background: #f8f4e5;
- --login-input-focus: #ebe2c2;
- --login-button-active: #ebe2c2;
+ --login-background: #f8f4e5; /* Background color for login page */
+ --login-input-focus: #ebe2c2; /* Background color for focused input fields in login page */
+ --login-button-active: #ebe2c2; /* Active color for buttons in login page */
- --planets-background: black;
- --planets-sun-background: yellow;
+ --planets-background: black; /* Background color for planets section */
+ --planets-sun-background: yellow; /* Background color for sun in planets section */
- --soft-shadow: #b8b199;
+ --soft-shadow: #b8b199; /* Color for soft shadow effect */
+
+ --metal-grey: #242c38; /* Color for metal grey elements */
+ --bright-blue: #2196f3; /* Color for bright blue elements */
+}
+
+/**
+ * Styles for scrollbar width.
+ */
+:root {
+ scrollbar-width: thin;
}
+/**
+ * Reset default margin and padding for all elements.
+ */
*,
*::before,
*::after {
@@ -21,22 +37,45 @@
box-sizing: border-box;
}
+/**
+ * Set HTML dimensions to 100%.
+ */
html {
width: 100%;
height: 100%;
}
+/**
+ * Set body dimensions to 100% and hide overflow on x-axis.
+ * Apply background color.
+ */
body {
width: 100%;
height: 100%;
overflow-x: hidden;
- /*This margin bottom is needed to make hotbar not interfere with other componenet*/
- /*If this margin bottom is doing something else bad please talk to sam. Thank you!*/
margin-bottom: 20px;
background-color: aliceblue;
- cursor: url("./assets/images/icons8-cursor-38.png"), auto;
}
+/* Styles for the react toasts */
+.Toastify__toast--success,
+.Toastify__toast--error,
+.Toastify__toast--info {
+ border-radius: 0;
+ background-color: var(--signup-background);
+ color: var(--metal-grey);
+ box-shadow: 4px 5px 0px var(--soft-shadow);
+ font-size: 14pt;
+}
+
+.Toastify button {
+ margin-top: 9px;
+ margin-right: 2px;
+}
+
+/**
+ * Define font faces for custom fonts.
+ */
@font-face {
font-family: Fjalla One;
src: url(./assets/fonts/FjallaOne-Regular.ttf);
diff --git a/client/src/index.tsx b/client/src/index.tsx
index 294db163..50a1c7ab 100644
--- a/client/src/index.tsx
+++ b/client/src/index.tsx
@@ -7,4 +7,8 @@ import './index.css';
import { App } from './app';
+/**
+ * Uses the ReactDOM render to render the React web app in the root
+ * div element in the file index.html.
+ */
ReactDOM.createRoot(document.getElementById('root')!).render( );
diff --git a/client/src/lib/axios.ts b/client/src/lib/axios.ts
index 089dcd37..82edf362 100644
--- a/client/src/lib/axios.ts
+++ b/client/src/lib/axios.ts
@@ -1,26 +1,40 @@
-import axios, { AxiosError, HttpStatusCode } from 'axios';
+import axios from 'axios';
import { getServerHost } from '../environment';
+import { buildWebStorage, setupCache } from 'axios-cache-interceptor';
+
+/**
+ * The server hostname, used to prefix
+ * all API requests.
+ */
+const host = getServerHost();
+
+/**
+ * The path patterns that will be cached by the API cache.
+ * Cache TTL is 5 minutes by default.
+ */
+let cachedPaths: string[] = ['^/planet.*$', '^/post/.*/(like|save|parent)$'];
+
+// TODO invalidate when saving or liking
/**
* API Axios instance to access server API routes.
*
- * This automatically handles the authorization header
- * and URL host.
+ * This automatically handles the authorization header,
+ * URL host prefixing and caching.
*/
-export const api = axios.create({
- baseURL: getServerHost(),
-});
+export const api = setupCache(axios.create({ baseURL: host }), {
+ storage: buildWebStorage(localStorage, 'skynet-cache:'),
+ cachePredicate: {
+ responseMatch: (res) => {
+ const req = res.request as XMLHttpRequest;
+ if (!req) return false;
-api.interceptors.response.use(
- (res) => {
- if (res.status === HttpStatusCode.Unauthorized) location.assign('/login');
- return res;
- },
- (err) => {
- if (!(err instanceof AxiosError)) return Promise.reject(err);
- if (err.response?.status === HttpStatusCode.Unauthorized && location.pathname !== '/login')
- // location.assign('/login');
- console.log(err);
- return Promise.reject(err);
+ const path = req.responseURL.replace(host, '');
+ for (const pattern of cachedPaths) {
+ if (path.match(pattern)) return true;
+ }
+
+ return false;
+ },
},
-);
+});
diff --git a/client/src/lib/is-user.ts b/client/src/lib/is-user.ts
new file mode 100644
index 00000000..0bb9cf1b
--- /dev/null
+++ b/client/src/lib/is-user.ts
@@ -0,0 +1,13 @@
+import { api } from './axios';
+
+/**
+ * Function to check if a specific ID is the current user's id.
+ *
+ * @param userId string - User id you are checking.
+ * @returns boolean - True if the id you are checking is current user's id.
+ */
+export async function isUser(userId: string) {
+ const res = await api.get('/user/');
+ const id = res.data.value._id;
+ return userId == id;
+}
diff --git a/client/src/lib/isUser.ts b/client/src/lib/isUser.ts
deleted file mode 100644
index a3aded40..00000000
--- a/client/src/lib/isUser.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { api } from "./axios";
-
-/**
- * FUnction to check if a specific ID is the current user's id.
- *
- * @param userId string - User id you are checking.
- * @returns boolean - True if the id you are checking is current user's id.
- */
-export async function isUser(userId: string) {
- const res = await api.get('/user/');
- const id = res.data.value._id;
- return userId == id;
-}
\ No newline at end of file
diff --git a/client/src/pages/about/about-page.module.css b/client/src/pages/about/about-page.module.css
new file mode 100644
index 00000000..83e16823
--- /dev/null
+++ b/client/src/pages/about/about-page.module.css
@@ -0,0 +1,54 @@
+.about-body {
+ width: 100%;
+ max-width: 750px;
+ font-family: BabaPro;
+ background-color: var(--signup-background);
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+}
+
+.about-body * {
+ background-color: var(--signup-background);
+}
+
+.about-body h2 {
+ font-size: 16pt;
+}
+
+.group-item-body {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.group-item {
+ border-color: gray;
+}
+
+.offical-pages-body {
+ font-family: Fjalla One;
+ width: 80%;
+}
+
+.heading-style {
+ color: white;
+ background-color: #242C38;
+ padding: 6px;
+}
+
+.second-heading {
+ display: block;
+ border-bottom: 5px solid #242C38;
+}
+
+.final-phrase {
+ display: block;
+ border-bottom: 5px solid #242C38;
+}
+
+@media only screen and (max-width: 767px) {
+ .offical-pages-body {
+ width: 100%;
+ }
+}
\ No newline at end of file
diff --git a/client/src/pages/about/about.tsx b/client/src/pages/about/about-page.tsx
similarity index 58%
rename from client/src/pages/about/about.tsx
rename to client/src/pages/about/about-page.tsx
index 3c112a7e..eaf65b8c 100644
--- a/client/src/pages/about/about.tsx
+++ b/client/src/pages/about/about-page.tsx
@@ -1,11 +1,21 @@
-import styles from './about.module.css';
+/* Stylesheet imports */
+import styles from './about-page.module.css';
+/* Icon imports from react-icons */
import { MdOutlineArrowForwardIos } from 'react-icons/md';
+
+/* Imports from react-bootstrap */
import ListGroup from 'react-bootstrap/ListGroup';
import Nav from 'react-bootstrap/Nav';
-import Page from '../../components/Page/Page';
-import { DiVim } from 'react-icons/di';
+/* Imports from other components created */
+import Page from '../../components/page/page';
+
+/**
+ * Constructs and returns the About us page for this website.
+ *
+ * @returns the About us page as a JSX.Element.
+ */
const About = () => {
return (
<>
@@ -27,6 +37,12 @@ const About = () => {
+
+
+ About Skynet
+
+
+
}
@@ -35,4 +51,7 @@ const About = () => {
);
};
+/**
+ * Exports the About us page for external use.
+ */
export default About;
diff --git a/client/src/pages/about/about.module.css b/client/src/pages/about/about.module.css
deleted file mode 100644
index 61f1cbf8..00000000
--- a/client/src/pages/about/about.module.css
+++ /dev/null
@@ -1,30 +0,0 @@
-.about-body {
- width: 100%;
- font-family: BabaPro;
- background-color: var(--signup-background);
- border: 2px solid black;
- box-shadow: 4px 5px 0px #000;
-}
-
-.about-body * {
- background-color: var(--signup-background);
-}
-
-.about-body h2 {
- font-size: 16pt;
-}
-
-.group-item-body {
- width: 100%;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.group-item {
- border-color: gray;
-}
-
-.offical-pages-body {
- font-family: Fjalla One;
-}
\ No newline at end of file
diff --git a/client/src/pages/about/options/about-page.tsx b/client/src/pages/about/options/about-page.tsx
new file mode 100644
index 00000000..64522c05
--- /dev/null
+++ b/client/src/pages/about/options/about-page.tsx
@@ -0,0 +1,147 @@
+/* Stylesheet imports */
+import styles from '../about-page.module.css';
+
+/* Imports from other components created */
+import Page from '../../../components/page/page';
+
+/**
+ * Constructs and returns the About Skynet page.
+ * Contains information about the app and the project Sky.net and the team behind the app.
+ *
+ * @returns the About Skynet page as a JSX.Element.
+ */
+const AboutSkynetPage = () => {
+ return (
+ <>
+
+ Meet our app: Sky.Net
+
+ Sky.net is an interplanetary communication network used to connect and
+ share events and ideologies from all humanity accross the galaxy and help making the
+ distance between communities feel smaller.
+
+
+
+ No matter the distance. No matter the time. Always connected.
+
+
+ Stay connected with the whole galaxy
+
+ Browse through planetary feeds and amazing communities of people. Be part of a planet or
+ share and connect with people in other planets by posting, reposting and commenting posts.
+
+
+ OR get connected in a galactical feed that shows the latest and most
+ important trends and posts of the galaxy in one place.
+
+
+ Share and connect with people who want to know you
+
+ Share your ideas through posts to the planetary feed you want.
+
+ Like, save and comment the posts you like the most. Follow and message all your friends and
+ famiily to stay connected with them all the time.
+
+ Build your profile and be unique in a galaxy of people
+
+ Personalize your bio description and have a user profile page just for you.
+
+ Edit your profile picture or have a personalized AI generated avatar picture just for you.
+
+
+ Meet the project
+
+ Sky.Net was created by team SkyOps - Skynetwork Operations (BBY-007) as a
+ project for the PROJ2800 course in BCIT-Term 1. Our idea was to create a hometown in which,
+ in the future, all humanity could reunite and share their ideas even lightyears away.
+
+ Our team put a huge amount of effort and we appreaciate you as our users for trying out and
+ being part of this project.
+
+
+ For more technical details, you can access our public repository on GitHub:
+ Sky.net repository
+
+
+ Meet our team SkyOps (BBY-007)
+
+ We were all directors, producers, designers and developers in this project. Meet our team of
+ web development professionals.
+
+ Kamal Dolikay
+
+ Ole Lammers
+
+ Tianyou Xie
+
+ Samarjit Bhogal
+
+ Marcus Lages
+
+
+
+ Stay in touch for the next steps of Sky.net in this galaxy of possibilities.
+
+
+ }
+ />
+ >
+ );
+};
+
+/**
+ * Exports the About Skynet page for external use.
+ */
+export default AboutSkynetPage;
diff --git a/client/src/pages/about/options/policy.tsx b/client/src/pages/about/options/policy-page.tsx
similarity index 83%
rename from client/src/pages/about/options/policy.tsx
rename to client/src/pages/about/options/policy-page.tsx
index 3d6532a8..716f9592 100644
--- a/client/src/pages/about/options/policy.tsx
+++ b/client/src/pages/about/options/policy-page.tsx
@@ -1,7 +1,14 @@
-import styles from '../about.module.css';
+/* Stylesheet imports */
+import styles from '../about-page.module.css';
-import Page from '../../../components/Page/Page';
+/* Imports from other components created */
+import Page from '../../../components/page/page';
+/**
+ * Constructs and returns the Policy page.
+ *
+ * @returns the Policy page as a JSX.Element.
+ */
const Policy = () => {
return (
<>
@@ -10,16 +17,16 @@ const Policy = () => {
pageName='Privacy Policy'
content={
-
Privacy Policy for Sky.Net
+
Privacy Policy for Sky.Net
At Sky.Net (“the App”), we value your privacy and are committed to protecting your personal
information. This Privacy Policy outlines how we collect, use, disclose, and safeguard your
information when you use our App. By using Sky.Net, you agree to the collection and use of
information in accordance with this policy.
-
1. Information We Collect
+
1. Information We Collect
We collect several types of information to provide and improve our service to you:
-
1.1 Personal Information
+
1.1 Personal Information
When you create an account or use Sky.Net, we may collect personally identifiable
information, such as:
@@ -31,7 +38,7 @@ const Policy = () => {
Date of birth
Location
-
1.2 Usage Data
+
1.2 Usage Data
We collect information on how the App is accessed and used, including:
Your device’s Internet Protocol (IP) address
@@ -42,13 +49,13 @@ const Policy = () => {
Unique device identifiers
Other diagnostic data
-
1.3 Cookies and Tracking Technologies
+
1.3 Cookies and Tracking Technologies
We use cookies and similar tracking technologies to track activity on our App and hold
certain information. Cookies are files with a small amount of data which may include an
anonymous unique identifier.
-
2. How We Use Your Information
+
2. How We Use Your Information
Sky.Net uses the collected data for various purposes:
To provide and maintain the App
@@ -61,7 +68,7 @@ const Policy = () => {
To monitor the usage of our App
To detect, prevent, and address technical issues
-
3. Sharing Your Information
+
3. Sharing Your Information
We do not sell, trade, or otherwise transfer to outside parties your personally identifiable
information, except in the following situations:
@@ -86,21 +93,21 @@ const Policy = () => {
asset sale, your information may be transferred.
-
4. Security of Your Information
+
4. Security of Your Information
The security of your information is important to us. We implement a variety of security
measures to maintain the safety of your personal information. However, no method of
transmission over the Internet or method of electronic storage is 100% secure, so we cannot
guarantee its absolute security.
-
5. Data Retention
+
5. Data Retention
We will retain your personal information only for as long as is necessary for the purposes
set out in this Privacy Policy. We will retain and use your information to the extent
necessary to comply with our legal obligations, resolve disputes, and enforce our
agreements.
-
6. Your Data Protection Rights
+
6. Your Data Protection Rights
Depending on your jurisdiction, you may have the following rights regarding your personal
information:
@@ -137,7 +144,7 @@ const Policy = () => {
If you make a request, we have one month to respond to you. If you would like to exercise
any of these rights, please contact us at our provided contact information.
-
7. Changes to This Privacy Policy
+
7. Changes to This Privacy Policy
We may update our Privacy Policy from time to time. We will notify you of any changes by
posting the new Privacy Policy on this page. You are advised to review this Privacy Policy
@@ -157,4 +164,7 @@ const Policy = () => {
);
};
+/**
+ * Exports the Policy page for external use.
+ */
export default Policy;
diff --git a/client/src/pages/about/options/terms.tsx b/client/src/pages/about/options/terms-page.tsx
similarity index 79%
rename from client/src/pages/about/options/terms.tsx
rename to client/src/pages/about/options/terms-page.tsx
index 331e5258..0efeebf1 100644
--- a/client/src/pages/about/options/terms.tsx
+++ b/client/src/pages/about/options/terms-page.tsx
@@ -1,7 +1,14 @@
-import styles from '../about.module.css';
+/* Stylesheet imports */
+import styles from '../about-page.module.css';
-import Page from '../../../components/Page/Page';
+/* Imports from other components created */
+import Page from '../../../components/page/page';
+/**
+ * Constructs and returns the Terms of Use page.
+ *
+ * @returns the Terms of Use page as a JSX.Element.
+ */
const Terms = () => {
return (
<>
@@ -10,30 +17,30 @@ const Terms = () => {
pageName='Terms of Use'
content={
-
Terms of Use for Sky.Net
+
Terms of Use for Sky.Net
Welcome to Sky.Net (“the App”), a social media platform designed to facilitate
interplanetary communication. By accessing or using the App, you agree to comply with and be
bound by the following Terms of Use. Please read these terms carefully. If you do not agree
with these terms, you should not use the App.
-
Acceptance of Terms
+
Acceptance of Terms
By creating an account or using Sky.Net, you agree to these Terms of Use and any additional
terms applicable to specific features of the App.
-
Eligibility
+
Eligibility
You must be at least 13 years old, that is your age relative to your home planet, to use
Sky.Net. By using the App, you represent and warrant that you meet this age requirement.
-
User Accounts
+
User Accounts
You are responsible for maintaining the confidentiality of your account information and for
all activities that occur under your account. You agree to notify us immediately of any
unauthorized use of your account.
-
User Conduct
+
User Conduct
You agree not to use Sky.Net to:
@@ -47,7 +54,7 @@ const Terms = () => {
Violate any local, state, national, or interplanetary law or regulation.
Engage in any activity that could interfere with or disrupt the App.
-
Content Ownership and License
+
Content Ownership and License
Your Content : You retain ownership of all content you post, upload, or
@@ -60,37 +67,37 @@ const Terms = () => {
content, is owned by or licensed to us and is protected by intellectual property laws.
-
Privacy
+
Privacy
Your privacy is important to us. Please review our
[Privacy Policy] to understand how we collect, use, and share
information about you.
-
Interplanetary Data Transfer
+
Interplanetary Data Transfer
Given the nature of interplanetary communication, data transfer times and reliability may
vary. You acknowledge and accept any potential delays or disruptions in data transmission.
-
Prohibited Uses
+
Prohibited Uses
You agree not to use Sky.Net for any commercial purposes without our prior written consent.
You also agree not to use the App to collect or harvest any personally identifiable
information, including account names, from Sky.Net.
-
Termination
+
Termination
We reserve the right to terminate or suspend your account and access to Sky.Net at our sole
discretion, without notice and without liability, for any reason, including if we believe
you have violated these Terms of Use.
-
Disclaimer of Warranties
+
Disclaimer of Warranties
Sky.Net is provided on an "as is" and "as available" basis. We make no
warranties, express or implied, regarding the App's operation or your use of the App,
including but not limited to warranties of merchantability, fitness for a particular
purpose, or non-infringement.
-
Limitation of Liability
+
Limitation of Liability
To the maximum extent permitted by law, we shall not be liable for any indirect, incidental,
special, consequential, or punitive damages, or any loss of profits or revenues, whether
@@ -100,12 +107,12 @@ const Terms = () => {
interruption or cessation of transmission to or from Sky.Net; or (d) any bugs, viruses,
trojan horses, or the like that may be transmitted to or through the App by any third party.
-
Governing Law
+
Governing Law
These Terms of Use are governed by and construed in accordance with the laws of SkyOps,
without regard to its conflict of law principles.
-
Changes to the Terms of Use
+
Changes to the Terms of Use
We may modify these Terms of Use at any time. We will notify you of any changes by posting
the new Terms of Use on Sky.Net. Your continued use of the App after any such changes
@@ -124,4 +131,7 @@ const Terms = () => {
);
};
+/**
+ * Exports the Terms of Use page for external use.
+ */
export default Terms;
diff --git a/client/src/pages/changepassword/changepassword.module.css b/client/src/pages/changepassword/changepassword.module.css
deleted file mode 100644
index 220389b0..00000000
--- a/client/src/pages/changepassword/changepassword.module.css
+++ /dev/null
@@ -1,74 +0,0 @@
-.changepassword-container {
- display: grid;
- align-items: center;
- justify-items: center;
- height: 80vh;
- }
-
- .changepassword-form {
- background: var(--signup-background);
- padding: 48px 59px;
- border: 2px solid black;
- box-shadow: 4px 5px 0px #000;
- }
-
- .changepassword-upperdiv {
- background: var(--signup-background);
- padding: 4px 0px;
- border: 2px solid black;
- box-shadow: 4px 3px 0px #000;
- }
-
- .changepassword-bottomdiv {
- background: var(--signup-background);
- padding: 4px 0px;
- border: 2px solid black;
- box-shadow: 4px 4px 0px #000;
- }
-
- .input {
- display: block;
- width: 100%;
- line-height: 28pt;
- margin-bottom: 20pt;
- box-shadow: 4px 5px;
- }
-
- .input:focus {
- background: var(--signup-input-focus);
- }
-
- .button {
- padding: 2px;
- width: 50%;
- box-shadow: 4px 5px;
- }
-
- .button:active {
- box-shadow: 4px 4px var(--signup-button-active)5;
- transform: translateY(5px);
- }
-
- .select {
- width: 100%;
- margin-bottom: 20pt;
- box-shadow: 4px 5px;
- padding: 6px;
- }
-
- .img {
- max-width: 250px !important;
- }
-
- .h1 {
- font-family: Bitsumishi;
- }
-
- .h5 {
- font-family: BabaPro;
- }
-
- .message {
- font-family: BabaPro;
- color: red;
- }
\ No newline at end of file
diff --git a/client/src/pages/changepassword/changepassword.tsx b/client/src/pages/changepassword/changepassword.tsx
deleted file mode 100644
index 80affabe..00000000
--- a/client/src/pages/changepassword/changepassword.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import styles from './changepassword.module.css';
-import logoUrl from '../../assets/images/SkynetLogo.png';
-import React, { useState } from 'react';
-import { api } from '../../lib/axios';
-
-const Changepassword = () => {
- const [password, setPassword] = useState('');
- const [newpassword, setNewPassword] = useState('');
- const [confirmpassword, setConfirmPassword] = useState('');
- const [message, setMessage] = useState('');
-
- const handleSubmit = async (event: React.FormEvent) => {
- event.preventDefault();
- try {
- const response = await api.patch('/user/changepassword', {
- password,
- newpassword,
- confirmpassword,
- });
- setMessage(response.data.message);
- } catch (error: any) {
- setMessage(error.response.data.message);
- }
- };
-
- return (
-
-
-
-
CHANGE PASSWORD
- {/*
FORGET YOUR PASSWORD? WE ARE HERE TO HELP! */}
-
-
-
-
-
{message}
-
-
- );
-};
-
-export default Changepassword;
diff --git a/client/src/pages/post-page/post-page.module.css b/client/src/pages/create-post/create-post.module.css
similarity index 78%
rename from client/src/pages/post-page/post-page.module.css
rename to client/src/pages/create-post/create-post.module.css
index 3c219f01..80367c95 100644
--- a/client/src/pages/post-page/post-page.module.css
+++ b/client/src/pages/create-post/create-post.module.css
@@ -1,6 +1,7 @@
.general {
font-family: TTOctosquares;
- width: 100%;
+ width: 75%;
+ max-width: 1400px;
}
.username {
@@ -33,20 +34,32 @@
padding: 6px;
padding-right: 0px;
justify-content: right;
+ gap: 20px;
}
.select {
color: #fff;
background-color: #000;
border-radius: 10px;
- margin-right: 10px;
border: #000;
box-shadow: 4px 5px 0px var(--soft-shadow);
text-align: center;
}
+.submit-div {
+ margin-right: 2px;
+ padding: 2px 7px;
+}
+
.submit {
- border: none;
- background-color: transparent;
+ color: white;
+ width: 100%;
padding: 0px;
+ background-color: transparent;
}
+
+@media only screen and (max-width: 767px) {
+ .general {
+ width: 100%;
+ }
+}
\ No newline at end of file
diff --git a/client/src/pages/create-post/create-post.tsx b/client/src/pages/create-post/create-post.tsx
new file mode 100644
index 00000000..a3d02534
--- /dev/null
+++ b/client/src/pages/create-post/create-post.tsx
@@ -0,0 +1,207 @@
+/* Stylesheet imports */
+import styles from './create-post.module.css';
+
+/* Import from React */
+import { useContext, useEffect, useState } from 'react';
+import { Else, If, Then } from 'react-if';
+import { toast } from 'react-toastify';
+import { FaEdit } from 'react-icons/fa';
+
+/* Import from wouter */
+import { useLocation } from 'wouter';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../lib/axios';
+import { AxiosError } from 'axios';
+import { UserAuthContext } from '../../lib/auth';
+
+/* Import from other components created */
+import { SmallLoader } from '../../components/loader/small-loader';
+import Page from '../../components/page/page';
+import UIBox from '../../components/uibox/uibox';
+
+/**
+ * PostPage component represents a page in which the user can create a post.
+ *
+ * @return JSX.Element - PostPage as a JSX.Element
+ */
+const PostPage = function () {
+ const user = useContext(UserAuthContext);
+
+ /**
+ * Interface used as model schema for the Planet data that is received from the database.
+ *
+ * @interface Planet
+ * @param _id string - Id of the planet in the database
+ * @param name string - Name of the planet in the database
+ */
+ interface Planet {
+ _id: string;
+ name: string;
+ }
+
+ const [planets, setPlanets] = useState>([]);
+ const [_, navigate] = useLocation();
+
+ const [selectedPlanet, setSelectedPlanet] = useState(user?.location.planetId);
+ const [postContent, setPostContent] = useState('');
+
+ /**
+ * Use effect used to get all the planets from the database and store them in a
+ * state in react once the page renders for the first time.
+ */
+ useEffect(() => {
+ (async function fetchPlanets() {
+ try {
+ const { data: res } = await api.get('/planet');
+ const data = res.value;
+ setPlanets(data);
+ } catch (err) {
+ console.log(err);
+ }
+ })();
+ }, []);
+
+ const [loading, setLoading] = useState(false);
+
+ /**
+ * Uses all the necessary data to submit and create the post by
+ * sending the request to the backend route.
+ *
+ * @return void
+ */
+ async function submitPost() {
+ if (loading) return;
+ setLoading(true);
+
+ /**
+ * Gets the user geolocation coordinates and stores them in the geoLoc constant.
+ *
+ * @returns Promise
+ */
+ const geoLoc = await new Promise((res) => {
+ navigator.geolocation.getCurrentPosition(
+ (p) => res(p),
+ () => res(undefined),
+ );
+ });
+
+ /**
+ * Object that is used to build the post request to send the data to the backend.
+ *
+ * @param content string - Content of the post.
+ * @param location LocationObj - Used to locate where the post was created from and contains {latitude: number, longitude: number, planetId: string}
+ */
+ const postRequest = {
+ content: postContent,
+ location: {
+ latitude: geoLoc ? geoLoc.coords.latitude : 0,
+ longitude: geoLoc ? geoLoc.coords.longitude : 0,
+ planetId: selectedPlanet,
+ },
+ };
+
+ try {
+ const res = await api.post('/post', postRequest);
+ const postId = res.data.value._id;
+ if (!postId) throw 'Post was not able to be created.';
+ toast.success('Post was created!');
+ navigate(`/post/${postId}`);
+ } catch (err: any) {
+ toast.error(err instanceof AxiosError ? `${err.response?.data.error}` : err, {
+ position: 'top-right',
+ hideProgressBar: false,
+ closeOnClick: true,
+ pauseOnHover: true,
+ draggable: true,
+ progress: undefined,
+ theme: 'colored',
+ });
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+ <>
+
+ {
+ e.preventDefault();
+ submitPost();
+ }}>
+
+ setPostContent(e.target.value)}
+ placeholder='Share your ideas...'
+ required
+ />
+ }
+ curved
+ />
+
+ setSelectedPlanet(v.target.value.trim())}
+ required>
+ {planets.map((planet: any, index: number) => {
+ return (
+
+ {planet._id === user.location.planetId ? '🏠' : ``} {planet.name}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ Create Post
+
+
+
+ }
+ curved
+ dark
+ clickable
+ />
+
+
+ >
+ }
+ />
+ >
+ );
+};
+
+export default PostPage;
diff --git a/client/src/pages/edit-profile-page/edit-profile-page.module.css b/client/src/pages/edit-profile-page/edit-profile-page.module.css
new file mode 100644
index 00000000..ce1fbda2
--- /dev/null
+++ b/client/src/pages/edit-profile-page/edit-profile-page.module.css
@@ -0,0 +1,59 @@
+.text-feild {
+ font-family: TTOctosquares;
+ background-color: var(--signup-background);
+ border: 2px solid black;
+}
+
+.feild-label {
+ font-family: Bitsumishi;
+ font-size: 15pt;
+}
+
+.undo-btn {
+ font-family: BabaPro;
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+ border-radius: 0;
+ color: aliceblue;
+}
+
+.submit-btn {
+ font-family: BabaPro;
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+ border-radius: 0;
+ background-color: #2196f3;
+ color: aliceblue;
+}
+
+.submit-btn:disabled {
+ background-color: #274157;
+ box-shadow: 0px 0px 0px #000;
+}
+
+.avatar-btn {
+ color: aliceblue;
+ background-color: #242c38;
+ border: 2px solid #2196f3;
+ box-shadow: 4px 5px 0px #355a79;
+ min-width: 130px;
+}
+
+.undo-btn:active,
+.submit-btn:active,
+.avatar-btn:active {
+ box-shadow: 4px 4px var(--signup-button-active) 5 !important;
+ transform: translateY(5px) !important;
+}
+
+@media screen and (min-width: 500px) {
+ .ui-box {
+ width: 75% !important;
+ }
+}
+
+@media screen and (min-width: 800px) {
+ .ui-box {
+ width: 50% !important;
+ }
+}
diff --git a/client/src/pages/edit-profile-page/edit-profile-page.tsx b/client/src/pages/edit-profile-page/edit-profile-page.tsx
new file mode 100644
index 00000000..5707858e
--- /dev/null
+++ b/client/src/pages/edit-profile-page/edit-profile-page.tsx
@@ -0,0 +1,284 @@
+/* Stylesheet import */
+import styles from './edit-profile-page.module.css';
+
+/* Imports from React */
+import { useContext, useEffect, useRef, useState } from 'react';
+import { Else, If, Then } from 'react-if';
+import { toast } from 'react-toastify';
+
+/* Import custom components that were made */
+import Page from '../../components/page/page';
+import UIBox from '../../components/uibox/uibox';
+import { SmallLoader } from '../../components/loader/small-loader';
+
+/* Frontend utility imports */
+import { UserAuthContext } from '../../lib/auth';
+import { api } from '../../lib/axios';
+
+const EditProfilePage = () => {
+ // fetches the info of the user logged in
+ const user = useContext(UserAuthContext);
+
+ // Indicates if the image is uploading to the database
+ // Responsible for showing the small loader
+ const [isUploadingFile, setUploadingFile] = useState(false);
+
+ // Reference to DOM elements
+ const avatarInput = useRef(null);
+ const undoBtn = useRef(null);
+ const submitBtn = useRef(null);
+
+ // stores initial info from database
+ const [initBio, setInitBio] = useState(user.bio ? user.bio : '');
+ const [initUsername, setInitUsername] = useState(user.userName);
+ const [initAvatarURl, setInitAvatarURl] = useState(user.avatarUrl);
+
+ // stores current changes
+ const [userAvatarUrl, setAvatarUrl] = useState(initAvatarURl);
+ const [userName, setUserName] = useState(initUsername);
+ const [userBio, setBio] = useState(initBio);
+
+ // Stores the previous changes
+ const [prevBio, setPrevBio] = useState(initBio);
+ const [prevUsername, setPrevUsername] = useState(initUsername);
+
+ /**
+ * Listens to changes in the userName, userBio, and userAvatarUrl and calls handleBtns.
+ */
+ useEffect(() => {
+ handleBtns();
+ }, [userName, userBio, userAvatarUrl]);
+
+ /**
+ * Manages the functionality of the hidden file input element externally
+ */
+ const triggerAvatarInput = () => {
+ if (!avatarInput.current) return;
+ avatarInput.current.click();
+ };
+
+ const avatarFileTypes = ['image/png', 'image/jpeg', 'image/webp'];
+
+ /**
+ * Handles the patch request to upload the new avatar image URL. Throws an error if unsuccesful or invalid file id provided.
+ *
+ * Original process was coded by Zyrakia from slient/src/pages/profile/profile.tsx
+ * This function was just extracted for a particular use Most credit goes to Zyrakia.
+ *
+ * @param file the file submited by the user
+ * @author Zyrakia & SamarjitBhogal
+ */
+ const uploadAvatar = async (file: File) => {
+ console.log('here');
+ if (!avatarFileTypes.includes(file.type) || file.size > 3e6) {
+ toast.error('Invalid file selected!');
+ return;
+ }
+ setUploadingFile(true);
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.addEventListener('load', async () => {
+ const dataUrl = reader.result;
+ if (!dataUrl || typeof dataUrl !== 'string') return;
+ try {
+ const { data: res } = await api.patch('/user/changeavatar', { avatarDataUrl: dataUrl });
+ if (res.success === false) throw 'Error';
+ setAvatarUrl(res.value);
+ const undo = undoBtn.current;
+ undo?.toggleAttribute('hidden', false);
+
+ toast.success('Updated avatar! Changes may take a few minutes.');
+ } catch (error: any) {
+ toast.error('Failed to update avatar! Try again later.');
+ } finally {
+ setUploadingFile(false);
+ }
+ });
+ };
+
+ /**
+ * Handles the toggle of the display of save and undo buttons of the page.
+ */
+ const handleBtns = () => {
+ const undo = undoBtn.current;
+ const submit = submitBtn.current;
+ if (submit?.hasAttribute('disabled')) {
+ submit?.removeAttribute('disabled');
+ }
+ if (userName == initUsername && userBio == initBio && userAvatarUrl == initAvatarURl) {
+ undo?.toggleAttribute('hidden', true);
+ submit?.setAttribute('disabled', 'true');
+ }
+ };
+
+ /**
+ * Handles the user changes to save when the save button is clicked.
+ *
+ * @returns a react toast based on an error or success prompt.
+ */
+ const submitChanges = async () => {
+ try {
+ if (userBio !== prevBio) {
+ await api.patch('/user/changeBio', {
+ newBio: userBio,
+ });
+ setPrevBio(userBio);
+ } else if (userName !== prevUsername) {
+ await api.patch('/user/changeUsername', {
+ newUsername: userName,
+ });
+ setPrevUsername(userName);
+ } else {
+ return toast.error('The information entered has already been saved.');
+ }
+
+ toast.success('Your info has been updated!');
+ } catch (error: any) {
+ toast.error(error.response.data.error);
+ }
+
+ const undo = undoBtn.current;
+ undo?.toggleAttribute('hidden', false);
+ };
+
+ /**
+ * Handles undoing the recent changes when the undo button is clicked.
+ */
+ const undoChanges = async () => {
+ try {
+ if (userBio !== initBio) {
+ await api.patch('/user/changeBio', {
+ newBio: initBio,
+ });
+ setPrevBio(initBio);
+ } else if (userName !== initUsername) {
+ await api.patch('/user/changeUsername', {
+ newUsername: initUsername,
+ });
+ setPrevUsername(initUsername);
+ } else if (userAvatarUrl !== initAvatarURl) {
+ try {
+ const { data: res } = await api.patch('/user/changeavatar', { avatarDataUrl: initAvatarURl });
+ if (res.success === false) throw 'Error';
+ } catch (error: any) {
+ toast.error('Failed to undo avatar! Try again later.');
+ }
+ }
+ } catch (error: any) {
+ toast.error(error.response.data.error);
+ }
+
+ setAvatarUrl(initAvatarURl);
+ setBio(initBio);
+ setUserName(initUsername);
+ undoBtn.current?.toggleAttribute('hidden', true);
+
+ toast.success('Changes were undone.');
+ };
+
+ /**
+ * The avatar img as a JSX.Element.
+ */
+ const avatarImgElement = (
+
+ );
+
+ return (
+ <>
+
+
+
{
+ const files = event.target.files;
+ if (!files) return;
+ uploadAvatar(files[0]);
+ }}
+ type='file'
+ hidden
+ />
+
+ {avatarImgElement}
+
+
+
+
+
+
+
+ Change Avatar
+
+
+
+
+
+ Username
+
+ setUserName(event.target.value)}
+ />
+ }
+ />
+
+ }
+ />
+
+
+ Bio
+
+ setBio(event.target.value)}
+ />
+ }
+ />
+
+ }
+ />
+
+
+
+ Save
+
+
+ Undo
+
+
+ >
+ }
+ />
+ >
+ );
+};
+
+/**
+ * Export for this edit profile page.
+ */
+export default EditProfilePage;
diff --git a/client/src/pages/faqs/faqs-page.tsx b/client/src/pages/faqs/faqs-page.tsx
new file mode 100644
index 00000000..703f501d
--- /dev/null
+++ b/client/src/pages/faqs/faqs-page.tsx
@@ -0,0 +1,175 @@
+/* Stylesheet imports */
+import styles from './faqs.module.css';
+
+/* Imports from React */
+import { useEffect, useRef, useState } from 'react';
+import { toast } from 'react-toastify';
+
+/* Imports from react-bootstrap */
+import Accordion from 'react-bootstrap/Accordion';
+
+/* Imports from other components created */
+import QuestionAccordion from '../../components/ques-accordion/ques-accordion';
+import Page from '../../components/page/page';
+import UIBox from '../../components/uibox/uibox';
+
+/* Import for Axios */
+import { api } from '../../lib/axios';
+
+const emptyMessage =
+ "Your search didn't match to any result. Try rewording your search." +
+ " It may also be possible that we don't have what you are looking for yet";
+
+/**
+ * Constructs, manages, and returns the FAQs page.
+ *
+ * @returns the FAQs page as a JSX.Element.
+ */
+const FAQs = () => {
+ const [searchQuery, setSearch] = useState('');
+ const [quesToDisplay, setQuestions] = useState(Array());
+ const [emptyMsg, setEmptyMsg] = useState(false);
+
+ const clearBtn = useRef(null);
+
+ /* Calls displayInitialQues() when the page loads */
+ useEffect(() => {
+ displayInitialQues();
+ }, []);
+
+ /**
+ * Displays the FAQs from the database.
+ */
+ const displayInitialQues = async () => {
+ const response = await api.get('/faqs');
+ const ques = response.data.value;
+ let questions: Array = renderQuestions(ques);
+ setQuestions(questions);
+ };
+
+ /**
+ * Sends a post request to recieve all FAQs that match the query body provided.
+ *
+ * @param event the form event from onSubmit.
+ */
+ const findQuestions = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (searchQuery) clearBtn.current!.toggleAttribute('hidden');
+ try {
+ const response = await api.post(`/faqs`, {
+ query: searchQuery,
+ });
+ checkEmptyResult(response.data.value);
+ let questions: Array = renderQuestions(response.data.value);
+ setQuestions(questions);
+ } catch (error: any) {
+ toast.error(error.response.data.error);
+ }
+ };
+
+ /**
+ * Check to see if the given result is empty.
+ *
+ * @param result the result to check.
+ */
+ const checkEmptyResult = (result: any) => {
+ if (result.length == 0) {
+ setEmptyMsg(true);
+ } else {
+ setEmptyMsg(false);
+ }
+ };
+
+ /**
+ * Renders the given questions as QuestionAccordions into an array and returns the array.
+ *
+ * @param ques The questions to render.
+ * @returns an array of the QuestionAccordions as a Array.
+ */
+ const renderQuestions = (ques: any): Array => {
+ setQuestions([]);
+ let num = 0;
+ let newQues: Array = [];
+ ques.map((ques: any) => {
+ newQues.push(
+ ,
+ );
+ num++;
+ });
+ return newQues;
+ };
+
+ /**
+ * Resets the FAQ page by clearing the search bar, removing the clear button, and dispalying the intital questions again.
+ */
+ const resetPage = () => {
+ setSearch('');
+ clearBtn.current!.toggleAttribute('hidden');
+ setEmptyMsg(false);
+ displayInitialQues();
+ };
+
+ return (
+ <>
+
+ What can we help you with?
+
+ setSearch(event.target.value)}
+ type='text'
+ />
+ }
+ />
+
+
+
+ Search
+
+ resetPage()}
+ hidden>
+ Clear
+
+
+
+ {emptyMsg ? (
+
+
{emptyMessage}
+
+ ) : (
+ <>
+
+ {quesToDisplay}
+
+ >
+ )}
+
+ }
+ />
+ >
+ );
+};
+
+/**
+ * Exports the FAQs page for external use.
+ */
+export default FAQs;
diff --git a/client/src/pages/faqs/faqs.module.css b/client/src/pages/faqs/faqs.module.css
new file mode 100644
index 00000000..88cd18c4
--- /dev/null
+++ b/client/src/pages/faqs/faqs.module.css
@@ -0,0 +1,63 @@
+.accordion-item,
+.accordion-item button {
+ background-color: var(--signup-background);
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+}
+
+.faq-header {
+ font-family: Bitsumishi;
+}
+
+.accordion-item button {
+ z-index: -1;
+}
+
+.accordion-item button[aria-expanded='true'] {
+ background-color: var(--signup-background);
+ color: #000;
+}
+
+.accordion-item button:focus {
+ box-shadow: none;
+}
+
+.accordion-item h2,
+.accordion-item h3,
+.accordion-item h4 {
+ font-family: BabaPro;
+}
+
+.accordion-body {
+ font-family: TTOctosquares;
+}
+
+.clear-btn {
+ font-family: BabaPro;
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+ border-radius: 0;
+ color: aliceblue;
+}
+
+.search-btn {
+ font-family: BabaPro;
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+ border-radius: 0;
+ background-color: #2196f3;
+ color: aliceblue;
+}
+
+.search-btn:active,
+.clear-btn:active {
+ box-shadow: 4px 4px var(--signup-button-active) 5 !important;
+ transform: translateY(5px) !important;
+}
+
+.empty-msg-card {
+ background-color: var(--signup-background);
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+ border-radius: 0;
+}
diff --git a/client/src/pages/follower/follower.module.css b/client/src/pages/follower/follower.module.css
new file mode 100644
index 00000000..526cdcb6
--- /dev/null
+++ b/client/src/pages/follower/follower.module.css
@@ -0,0 +1,45 @@
+.userList {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 20px;
+}
+
+.userLink {
+ text-decoration: none;
+ /* Remove underline from link */
+}
+
+.userItem {
+ display: flex;
+ align-items: center;
+ background-color: #242c38;
+ padding: 10px;
+ border: 2px solid #2196f3;
+ border-radius: 8px;
+ box-shadow: 4px 5px 0px #355a79;
+ color: inherit;
+ /* Ensure text color remains the same */
+}
+
+.avatar {
+ width: 50px;
+ /* Control the size of the avatar */
+ height: 50px;
+ border-radius: 50%;
+ margin-right: 20px;
+}
+
+.userInfo {
+ color: #ffffff;
+}
+
+.userName {
+ font-size: 1.2rem;
+ font-weight: bold;
+ margin: 0;
+}
+
+.userInfo p {
+ margin: 5px 0;
+}
\ No newline at end of file
diff --git a/client/src/pages/follower/follower.tsx b/client/src/pages/follower/follower.tsx
new file mode 100644
index 00000000..b3f0d2c9
--- /dev/null
+++ b/client/src/pages/follower/follower.tsx
@@ -0,0 +1,78 @@
+/* Stylesheet imports */
+import styles from './follower.module.css';
+
+/* Import from React */
+import { Link } from 'wouter';
+
+/* Import from React */
+import { useEffect, useState } from 'react';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../lib/axios';
+
+/* Import from other components created */
+import Page from '../../components/page/page';
+
+/* Define the PostResponse interface */
+interface PostResponse {
+ statusCode: number;
+ statusMessage: string;
+ value: UserProp[];
+ success: boolean;
+}
+
+/* Define the UserProp interface */
+interface UserProp {
+ _id: string;
+ userName: string;
+ followerCount: number;
+ followingCount: number;
+ postCount: number;
+ avatarUrl?: string;
+}
+
+/**
+ * Constructs, manages, and returns the FollowerPage component.
+ *
+ * @return The FollowerPage component as a JSX.Element
+ */
+const FollowerPage = () => {
+ const [followerUsers, setFollowerUsers] = useState([]);
+
+ useEffect(() => {
+ const fetchFollower = async () => {
+ try {
+ const response = await api.get(`/user/follower`);
+ if (response.data.success) setFollowerUsers(response.data.value);
+ } catch {}
+ };
+
+ fetchFollower();
+ }, []);
+
+ return (
+
+ {followerUsers.map((user) => (
+
+
+
+
+
{user.userName}
+
Followers: {user.followerCount}
+
Following: {user.followingCount}
+
Posts: {user.postCount}
+
+
+
+ ))}
+
+ }
+ />
+ );
+};
+
+export default FollowerPage;
diff --git a/client/src/pages/following/following.module.css b/client/src/pages/following/following.module.css
new file mode 100644
index 00000000..cb9b3f57
--- /dev/null
+++ b/client/src/pages/following/following.module.css
@@ -0,0 +1,44 @@
+.userList {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 20px;
+}
+
+.userLink {
+ text-decoration: none;
+ /* Remove underline from link */
+}
+
+.userItem {
+ display: flex;
+ align-items: center;
+ background-color: #242c38;
+ padding: 10px;
+ border: 2px solid #2196f3;
+ border-radius: 8px;
+ box-shadow: 4px 5px 0px #355a79;
+ color: inherit;
+ /* Ensure text color remains the same */
+}
+
+.avatar {
+ width: 50px;
+ /* Control the size of the avatar */
+ height: 50px;
+ border-radius: 50%;
+ margin-right: 20px;
+}
+
+.userInfo {
+ color: #ffffff;
+}
+
+.userName {
+ font-weight: bold;
+ margin: 0;
+}
+
+.userInfo p {
+ margin: 5px 0;
+}
\ No newline at end of file
diff --git a/client/src/pages/following/following.tsx b/client/src/pages/following/following.tsx
new file mode 100644
index 00000000..9b8393c6
--- /dev/null
+++ b/client/src/pages/following/following.tsx
@@ -0,0 +1,78 @@
+/* Stylesheet imports */
+import styles from './following.module.css';
+
+/* Import from React */
+import { useEffect, useState } from 'react';
+
+/* Import from wouter */
+import { Link } from 'wouter';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../lib/axios';
+
+/* Imports from other components created */
+import Page from '../../components/page/page';
+
+/* Define the PostResponse interface */
+interface PostResponse {
+ statusCode: number;
+ statusMessage: string;
+ value: UserProp[];
+ success: boolean;
+}
+
+/* Define the UserProp interface */
+interface UserProp {
+ _id: string;
+ userName: string;
+ followerCount: number;
+ followingCount: number;
+ postCount: number;
+ avatarUrl?: string;
+}
+
+/**
+ * Constructs, manages, and returns the FollowingPage component.
+ *
+ * @return The FollowingPage component as a JSX.Element
+ */
+const FollowingPage = () => {
+ const [followingUsers, setFollowingUsers] = useState([]);
+
+ useEffect(() => {
+ const fetchFollowing = async () => {
+ try {
+ const response = await api.get(`/user/following`);
+ if (response.data.success) setFollowingUsers(response.data.value);
+ } catch {}
+ };
+
+ fetchFollowing();
+ }, []);
+
+ return (
+
+ {followingUsers.map((user) => (
+
+
+
+
+ {user.userName}
+ Followers: {user.followerCount}
+ Following: {user.followingCount}
+ Posts: {user.postCount}
+
+
+
+ ))}
+
+ }
+ />
+ );
+};
+
+export default FollowingPage;
diff --git a/client/src/pages/forgetpassword/forgetpassword.module.css b/client/src/pages/forgetpassword/forgetpassword.module.css
deleted file mode 100644
index 1a2bb163..00000000
--- a/client/src/pages/forgetpassword/forgetpassword.module.css
+++ /dev/null
@@ -1,74 +0,0 @@
-.forgetpassword-container {
- display: grid;
- align-items: center;
- justify-items: center;
- height: 80vh;
- }
-
- .forgetpassword-form {
- background: var(--signup-background);
- padding: 48px 59px;
- border: 2px solid black;
- box-shadow: 4px 5px 0px #000;
- }
-
- .forgetpassword-upperdiv {
- background: var(--signup-background);
- padding: 4px 0px;
- border: 2px solid black;
- box-shadow: 4px 3px 0px #000;
- }
-
- .forgetpassword-bottomdiv {
- background: var(--signup-background);
- padding: 4px 0px;
- border: 2px solid black;
- box-shadow: 4px 4px 0px #000;
- }
-
- .input {
- display: block;
- width: 100%;
- line-height: 28pt;
- margin-bottom: 20pt;
- box-shadow: 4px 5px;
- }
-
- .input:focus {
- background: var(--signup-input-focus);
- }
-
- .button {
- padding: 2px;
- width: 50%;
- box-shadow: 4px 5px;
- }
-
- .button:active {
- box-shadow: 4px 4px var(--signup-button-active)5;
- transform: translateY(5px);
- }
-
- .select {
- width: 100%;
- margin-bottom: 20pt;
- box-shadow: 4px 5px;
- padding: 6px;
- }
-
- .img {
- max-width: 250px !important;
- }
-
- .h1 {
- font-family: Bitsumishi;
- }
-
- .h5 {
- font-family: BabaPro;
- }
-
- .message {
- font-family: BabaPro;
- color: red;
- }
\ No newline at end of file
diff --git a/client/src/pages/forgetpassword/forgetpassword.tsx b/client/src/pages/forgetpassword/forgetpassword.tsx
deleted file mode 100644
index 58010c43..00000000
--- a/client/src/pages/forgetpassword/forgetpassword.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import styles from './forgetpassword.module.css';
-import logoUrl from '../../assets/images/SkynetLogo.png';
-import React, { useState } from 'react';
-import { api } from '../../lib/axios';
-
-const Forgetpassword = () => {
- const [email, setEmail] = useState('');
- const [message, setMessage] = useState('');
-
- const handleSubmit = async (event: React.FormEvent) => {
- event.preventDefault();
- try {
- const response = await api.post('/user/forgetpassword', { email });
- setMessage(response.data.message);
- } catch (error: any) {
- setMessage(error.response.data.message);
- }
- };
-
- return (
-
-
-
-
SKY.NET
-
FORGET YOUR PASSWORD? WE ARE HERE TO HELP!
-
-
-
- setEmail(event.target.value)}
- required
- />
-
-
-
- SUBMIT
-
-
-
-
-
-
{message}
-
-
- );
-};
-
-export default Forgetpassword;
diff --git a/client/src/pages/forgot-password/forgot-password.module.css b/client/src/pages/forgot-password/forgot-password.module.css
new file mode 100644
index 00000000..6800c119
--- /dev/null
+++ b/client/src/pages/forgot-password/forgot-password.module.css
@@ -0,0 +1,77 @@
+.forgetpassword-container {
+ font-family: Babapro;
+ display: grid;
+ align-items: center;
+ justify-items: center;
+ height: 75vh;
+}
+
+.forgetpassword-form {
+ background: var(--signup-background);
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+}
+
+.forgetpassword-upperdiv {
+ background: var(--signup-background);
+ padding: 4px 0px;
+ border: 2px solid black;
+ box-shadow: 4px 3px 0px #000;
+}
+
+.forgetpassword-bottomdiv {
+ background: var(--signup-background);
+ padding: 4px 0px;
+ border: 2px solid black;
+ box-shadow: 4px 4px 0px #000;
+}
+
+.input {
+ font-family: Fjalla One;
+ display: block;
+ width: 75%;
+ line-height: 28pt;
+ margin-bottom: 20pt;
+ box-shadow: 4px 5px;
+}
+
+.input:focus {
+ background: var(--signup-input-focus);
+}
+
+.button {
+ background-color: var(--metal-grey);
+ color: aliceblue;
+ width: 50%;
+ border: 2px solid black;
+ box-shadow: 4px 4px 0px #000;
+}
+
+.button:active {
+ box-shadow: 4px 4px var(--signup-button-active) 5;
+ transform: translateY(5px);
+}
+
+.select {
+ width: 100%;
+ margin-bottom: 20pt;
+ box-shadow: 4px 5px;
+ padding: 6px;
+}
+
+.img {
+ max-width: 200px !important;
+}
+
+.h1 {
+ font-family: Bitsumishi;
+}
+
+.h5 {
+ font-family: BabaPro;
+}
+
+.message {
+ font-family: BabaPro;
+ color: red;
+}
diff --git a/client/src/pages/forgot-password/forgot-password.tsx b/client/src/pages/forgot-password/forgot-password.tsx
new file mode 100644
index 00000000..9ca74bbb
--- /dev/null
+++ b/client/src/pages/forgot-password/forgot-password.tsx
@@ -0,0 +1,96 @@
+/* Stylesheet imports */
+import styles from './forgot-password.module.css';
+
+import logoUrl from '../../assets/images/skynet-logo.png';
+
+/* Import from React */
+import React, { useState } from 'react';
+import { Else, If, Then } from 'react-if';
+import { toast } from 'react-toastify';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../lib/axios';
+
+/* Imports for AxiosError and HttpStatusCode */
+import { AxiosError, HttpStatusCode } from 'axios';
+
+/* Imports from other components created */
+import Page from '../../components/page/page';
+import { SmallLoader } from '../../components/loader/small-loader';
+
+/**
+ * Constructs, manages, and returns the Forgetpassword component.
+ *
+ * @return The Forgetpassword component as a JSX.Element
+ */
+const Forgetpassword = () => {
+ const [email, setEmail] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ if (loading) return;
+ setLoading(true);
+
+ try {
+ await api.post('/user/forgetpassword', { email });
+ } catch (error: any) {
+ if (error instanceof AxiosError && error.response?.status === HttpStatusCode.InternalServerError)
+ return toast.error('There was an error trying to reset you password, please try again later.');
+ } finally {
+ setLoading(false);
+ }
+
+ toast.info('If an account exists, a password reset email was sent.');
+ setEmail('');
+ };
+
+ return (
+
+
+
+
SKY.NET
+
FORGET YOUR PASSWORD? WE ARE HERE TO HELP!
+
+
+
Enter your email for a reset link:
+
+ setEmail(event.target.value)}
+ required
+ />
+
+
+
+ Submit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+ );
+};
+
+export default Forgetpassword;
diff --git a/client/src/pages/general-feed/general-feed.tsx b/client/src/pages/general-feed/general-feed.tsx
index 2c632bb9..5e67c4c7 100644
--- a/client/src/pages/general-feed/general-feed.tsx
+++ b/client/src/pages/general-feed/general-feed.tsx
@@ -1,54 +1,29 @@
-import { useEffect, useState } from 'react';
import { api } from '../../lib/axios';
-import Page from '../../components/Page/Page';
-import Post from '../../components/Post/Post';
+import Page from '../../components/page/page';
+import { PaginatedPostFeed } from '../../components/paginated-post-feed/paginated-post-feed';
-// PLEASE, CHECK THE PROMISE USE
-// I HAD A PROBLEM WITH USING AWAIT/ASYNC INSIDE ARRAYS
+/**
+ * GeneralFeed component representing the feed in which all the posts are aggregated
+ * from other planets and filtered/sortered.
+ *
+ * @return JSX.Element - General Feed page as an JSX.Element
+ */
const GeneralFeed = () => {
- const [displayedPosts, setDisplayedPosts] = useState(Array());
-
- useEffect(() => {
- const displayPosts = async function () {
- let postArray = await fetchPost();
- if (postArray == undefined) {
- postArray = [<>Nothing yet...>];
+ return (
+ api.get(`/feed?page=${page}`).then((res) => res.data.value)}
+ />
}
- setDisplayedPosts(postArray);
- };
- displayPosts();
- }, []);
-
- async function fetchPost() {
- try {
- const postRes = await api.get('/feed');
- const postArray = postRes.data.value;
- let postElements: Promise = Promise.all(
- postArray.map(async (post: any) => {
- const authorRes = await api.get('/user/' + post.authorId);
- const authorData = authorRes.data.value;
- return (
-
- );
- }),
- );
- return postElements;
- } catch (err) {
- console.log(err);
- }
- }
-
- return ;
+ />
+ );
};
+/**
+ * Exports the GeneralFeed component for external use.
+ */
export default GeneralFeed;
diff --git a/client/src/pages/home/planet-list-entry.tsx b/client/src/pages/home/planet-list-entry.tsx
new file mode 100644
index 00000000..88888d7f
--- /dev/null
+++ b/client/src/pages/home/planet-list-entry.tsx
@@ -0,0 +1,33 @@
+import styles from './planet-list.module.css';
+import { Link } from 'wouter';
+
+/**
+ * Interface that represents the arguments passed down to the Planet component.
+ *
+ * @params Covered on the component documentation.
+ */
+interface PlanetProps {
+ planet: string;
+ url: string;
+}
+
+/**
+ * Planet component representing a planet item from the planet list in
+ * the home page
+ *
+ * @param props.planet
+ * @param props.url
+ * @return JSX.Element - Planet item as a JSX.Element
+ */
+const Planet = (props: PlanetProps) => {
+ return (
+
+ {props.planet}
+
+ );
+};
+
+/**
+ * Exports the Planet component for external use.
+ */
+export default Planet;
diff --git a/client/src/pages/home/home.module.css b/client/src/pages/home/planet-list.module.css
similarity index 100%
rename from client/src/pages/home/home.module.css
rename to client/src/pages/home/planet-list.module.css
diff --git a/client/src/pages/home/home.tsx b/client/src/pages/home/planet-list.tsx
similarity index 73%
rename from client/src/pages/home/home.tsx
rename to client/src/pages/home/planet-list.tsx
index 8f3b7111..fab19f49 100644
--- a/client/src/pages/home/home.tsx
+++ b/client/src/pages/home/planet-list.tsx
@@ -1,26 +1,13 @@
-import styles from './home.module.css';
+import styles from './planet-list.module.css';
-import Page from '../../components/Page/Page';
-import { Link } from 'wouter';
-import React, { useContext, useEffect, useState } from 'react';
+import Page from '../../components/page/page';
+import { useContext, useEffect, useState } from 'react';
import { Else, If, Then } from 'react-if';
import { SmallLoader } from '../../components/loader/small-loader';
import { createSlug } from '../../lib/create-slug';
import { api } from '../../lib/axios';
import { UserAuthContext } from '../../lib/auth';
-
-interface PlanetProps {
- planet: string;
- url: string;
-}
-
-const Planets = (props: PlanetProps) => {
- return (
-
- {props.planet}
-
- );
-};
+import Planet from './planet-list-entry';
const Home = () => {
const user = useContext(UserAuthContext);
@@ -28,6 +15,9 @@ const Home = () => {
const [planetsData, setPlanetsData] = useState([]);
const [homePlanet, setHomePlanet] = useState('');
+ /**
+ * Use effect used to update the planets list once the page renders for the first time.
+ */
useEffect(() => {
api.get('/planet').then((res) => {
const { value } = res.data;
@@ -35,6 +25,10 @@ const Home = () => {
});
}, []);
+ /**
+ * Use effect used to set the home planet of the user once the data of the current
+ * user is received.
+ */
useEffect(() => {
setHomePlanet(user.location.planetId);
}, [user]);
@@ -43,7 +37,7 @@ const Home = () => {
Choose your planet
-
+
@@ -56,10 +50,8 @@ const Home = () => {
const slug = createSlug(name);
if (typeof slug !== 'string') return <>>;
- console.log(planet);
-
return (
-
diff --git a/client/src/pages/landing-page/landing-page.module.css b/client/src/pages/landing-page/landing-page.module.css
new file mode 100644
index 00000000..9628b162
--- /dev/null
+++ b/client/src/pages/landing-page/landing-page.module.css
@@ -0,0 +1,229 @@
+body:has(.landing-display) {
+ background-color: var(--metal-grey);
+}
+
+.section2 {
+ margin-top: 60px;
+}
+
+.landing-display {
+ width: 100vw;
+ height: 100vh;
+ position: relative;
+ background-color: var(--metal-grey) !important;
+}
+
+.logo {
+ width: 50px;
+}
+
+.nav, .nav *:not(.landing-btn) {
+ background-color: var(--metal-grey) !important;
+ color: aliceblue;
+}
+
+.nav a {
+ font-family: Bitsumishi;
+ font-size: 2em;
+}
+
+.nav {
+ position: absolute;
+ width: 100%;
+ top: 0;
+ z-index: 10;
+}
+
+.nav:first-child {
+ position: fixed;
+}
+
+.landing-header {
+ position: absolute;
+ z-index: 8;
+ right: 50%;
+ margin-top: 200px;
+ transform: translate(50%);
+
+ color: aliceblue;
+}
+
+.landing-header h1 {
+ font-family: Bitsumishi;
+ font-size: 30pt;
+}
+
+.landing-header h3 {
+ font-family: Bitsumishi;
+ font-size: 25pt;
+}
+
+.landing-btn {
+ font-family: BabaPro;
+ border-radius: 0;
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+}
+
+.landing-btn:active {
+ box-shadow: 4px 4px var(--signup-button-active) 5 !important;
+ transform: translateY(5px) !important;
+}
+
+
+.login-btn {
+ background-color: var(--signup-background);
+ color: var(--metal-grey);
+}
+
+.signup-btn {
+ background-color: var(--bright-blue);
+ color: var(--metal-grey);
+}
+
+.backdrop {
+ position: absolute;
+ z-index: 5;
+ width: 100vw;
+ height: 100vh;
+
+ backdrop-filter: blur(1.2px);
+}
+
+.map-container {
+ width: 100%;
+ height: 100vh;
+ position: relative;
+}
+
+.map-container p, .feature-desc p, .feature-desc-flip p, .join-msg h5 {
+ font-family: TTOctosquares;
+ margin: 0;
+ padding: 0;
+}
+
+.feature-desc p, .feature-desc-flip p {
+ font-size: 14pt;
+}
+
+.planet-map {
+ width: 100%;
+ height: min-content;
+ z-index: 0;
+}
+
+.arrow-icon1 {
+ position: absolute;
+ z-index: 5;
+ color: aliceblue;
+ font-size: 4em;
+ right: 50%;
+ transform: translate(50%);
+ margin-top: 500px;
+
+ animation: down alternate 2s infinite;
+}
+
+.arrow-icon2 {
+ background-color: var(--signup-background);
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+
+ display: flex;
+ animation: fade-in 300ms forwards;
+
+ position: fixed;
+ z-index: 5;
+ font-size: 4em;
+ bottom: 5%;
+ right: 5%;
+ width: 70px;
+ height: 70px;
+ border-radius: 50%;
+}
+
+.feature-header {
+ font-family: Bitsumishi;
+}
+
+.feature-desc {
+ background-color: var(--signup-background);
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+}
+
+.feature-desc-flip {
+ background-color: var(--signup-background);
+ border: 2px solid black;
+ box-shadow: -4px 5px 0px #000;
+}
+
+.feature-img {
+ border: 1px solid aliceblue;
+}
+
+.join-msg {
+ font-family: Bitsumishi;
+ background-color: var(--signup-background);
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+}
+
+.join-msg h5 {
+ font-size: 10pt;
+}
+
+@keyframes down {
+ 0% {
+ margin-top: 500px;
+ }
+
+ 100% {
+ margin-top: 515px;
+ }
+}
+
+@keyframes fade-in {
+ 0% {
+ opacity: 0%;
+ }
+
+ 100% {
+ opacity: 100%;
+ }
+}
+
+@media screen and (max-width: 410px) {
+ .logo {
+ width: 40px;
+ }
+
+ .nav a {
+ font-size: 17pt;
+ }
+}
+
+@media screen and (max-width: 350px) {
+ .landing-header h1 {
+ font-family: Bitsumishi;
+ font-size: 20pt;
+ }
+
+ .logo {
+ width: 30px;
+ }
+
+ .nav a {
+ font-size: 16pt;
+ }
+
+ .landing-btn {
+ font-size: 9pt;
+ }
+
+ .nav-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+}
\ No newline at end of file
diff --git a/client/src/pages/landing-page/landing-page.tsx b/client/src/pages/landing-page/landing-page.tsx
new file mode 100644
index 00000000..a087c7ad
--- /dev/null
+++ b/client/src/pages/landing-page/landing-page.tsx
@@ -0,0 +1,224 @@
+/* Stylesheet imports */
+import styles from './landing-page.module.css';
+
+/* Import from wouter */
+import { useLocation } from 'wouter';
+
+/* Imports from React */
+import { useRef } from 'react';
+
+/* Imports from react-bootstrap */
+import Container from 'react-bootstrap/Container';
+import Navbar from 'react-bootstrap/Navbar';
+
+/* Icon imports from react-icons */
+import { IoMdArrowDown } from 'react-icons/io';
+import { IoArrowUpSharp } from 'react-icons/io5';
+
+/* Imports from other components created */
+import { PlanetMap } from '../planet-map/planet-map';
+import { Scrambler } from '../../components/scrambler/scrambler';
+
+/* Imports from this website's assets */
+import logoUrl from '../../assets/images/skynet-logo.png';
+import homeURL from '../../assets/videos/home.gif';
+import postURL from '../../assets/videos/post.gif';
+import messageURL from '../../assets/videos/message.gif';
+
+/**
+ * Constructs, manages, and returns the Landing page.
+ *
+ * @returns returns the landing page as a JSX.Element.
+ */
+const LandingPage = () => {
+ const [_, setLocation] = useLocation();
+ const upArrow = useRef(null);
+
+ /* An event listener for the scroll event of the window */
+ document.addEventListener('scroll', () => {
+ updateArrow(window.scrollY);
+ });
+
+ /**
+ * Adjusts the current scroll view to the given element id.
+ *
+ * @param id the id of the element to scroll into view.
+ */
+ const moveDisplay = (id: string) => {
+ document.getElementById(id)?.scrollIntoView();
+ };
+
+ /**
+ * Redirects to the given page.
+ *
+ * @param path the path to redirect to.
+ */
+ const redirect = (path: string) => {
+ setLocation(path);
+ };
+
+ /**
+ * Updates the visibility of the arrow up element of this landing page based upon the scroll-y position of the window.
+ *
+ * @param position the current position of the page view in the y-direction.
+ */
+ const updateArrow = (position: number) => {
+ if (position > 0) {
+ upArrow.current?.toggleAttribute('hidden', false);
+ } else {
+ upArrow.current?.toggleAttribute('hidden', true);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ redirect('/login')}>
+ Log in
+
+ redirect('/signup')}>
+ Sign up
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Welcome to sky.net
+
No matter the distance. No matter the time. We all can stay connected.
+
+
moveDisplay('section2')}>
+
+
+
+
+
+
+
+
+
+
+
+
Interactable Planets and Galaxies
+
+ Dive into SKY.NET and see social media redesigned for our modern space age. With a
+ homepage structured around your galaxy, you can feel even closer to your friends and
+ family amongst the stars.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Need to talk to someone planets or even light years away?
+
+
+
+ SKY.NET offers messaging services that let you send interplanetary messages
+ freely. Letting you keep up with others in real-time no matter how far they are.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
sky.net Connects All
+
+ With SKY.NET feeds you can see what is going on in your planet, other planets, and
+ even the whole galaxy. Always stay up to date with popular opinions and discussions.
+
+
+
+
+
+
+
+
+
+
+
+
Join sky.net now
+ The future of connection is here.
+ redirect('/signup')}>
+ Signup
+
+
+
+
+
+
moveDisplay('section1')}
+ ref={upArrow}
+ hidden>
+
+
+
+
+ >
+ );
+};
+
+/**
+ * Exports the Landing page for external use.
+ */
+export default LandingPage;
diff --git a/client/src/pages/login/login-component.tsx b/client/src/pages/login/login-component.tsx
index 05898de8..e30ef5dc 100644
--- a/client/src/pages/login/login-component.tsx
+++ b/client/src/pages/login/login-component.tsx
@@ -5,6 +5,12 @@ import { toast } from 'react-toastify';
import { Auth } from '../../lib/auth';
import LoginHtml from './login-html';
+/**
+ * Login component handles user login functionality.
+ *
+ * @component
+ * @returns {JSX.Element} The rendered Login component.
+ */
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -12,8 +18,14 @@ const Login = () => {
const [_, setLocation] = useLocation();
const [loading, setLoading] = useState(true);
+
+ // Retrieves search query parameters from the URL
const query = useSearch();
+ /**
+ * Effect hook to handle external authentication using OAuth code.
+ * Fetches token from the backend and saves it on successful authentication.
+ */
useEffect(() => {
const params = new URLSearchParams(query);
const externalAuthCode = params.get('code');
@@ -27,6 +39,12 @@ const Login = () => {
});
}, []);
+ /**
+ * Handles form submission for user login.
+ * Sends login request to the backend and handles the response.
+ *
+ * @param {Event} e - The form submission event.
+ */
const submitForm = async (e: any) => {
e.preventDefault();
@@ -42,13 +60,12 @@ const Login = () => {
const token = res.value;
Auth.saveToken(token);
setLocation('/home');
- toast.success('login successfully');
+ toast.success('Logged in successfully.');
} catch (error: any) {
let err = error.response.data.success;
- console.log(err);
- toast.error('🦄 Wrong Credentials', {
+ toast.error('No account exists with that email and password.', {
position: 'top-right',
- autoClose: 55000,
+ autoClose: 5500,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
@@ -59,6 +76,7 @@ const Login = () => {
}
};
+ // Renders the LoginHtml component with necessary props
return (
{
- const [_, navigate] = useLocation();
+ const [showPassword, setShowPassword] = useState(false);
return (
-
+
SKY.NET
Stay Connected Across The Galaxy
-
-
+
+
setEmail(e.target.value)}
/>
- setPassword(e.target.value)}
- />
-
- Login
+
+ setPassword(e.target.value)}
+ />
+
+ setShowPassword(!showPassword)}>
+ {showPassword ? : }
+
+
+
+
+
+
+
+
+ LOG IN
+
-
+
-
-
+
+
-
- New User? Sign Up Instead
- navigate('/signup')}
- className={`${styles.loginButtonLogin} ${styles.button}`}>
- Sign Up
-
-
-
-
+
+
+ Not Registered? Sign up Instead
+
-
- Forgot your password?
-
+
+ Forgot your password?
+
+
diff --git a/client/src/pages/login/login.module.css b/client/src/pages/login/login.module.css
index a0e9d03b..acd524ec 100644
--- a/client/src/pages/login/login.module.css
+++ b/client/src/pages/login/login.module.css
@@ -1,16 +1,24 @@
+/**
+ * Styles for the login container.
+ */
.login-container {
display: grid;
align-items: center;
justify-items: center;
}
+/**
+ * Styles for the login form.
+ */
.login-form {
background: var(--login-background);
- padding: 29px 59px;
border: 2px solid black;
box-shadow: 4px 5px 0px #000;
}
+/**
+ * Styles for the upper div of the login container.
+ */
.login-upperdiv {
background: var(--login-background);
padding: 4px 0px;
@@ -18,6 +26,9 @@
box-shadow: 4px 3px 0px #000;
}
+/**
+ * Styles for the bottom div of the login container.
+ */
.login-bottomdiv {
background: var(--login-background);
padding: 4px 0px;
@@ -31,48 +42,76 @@
color: white !important;
}
+/**
+ * Styles for the login button.
+ */
.login-button-login:active {
- box-shadow: 4px 4px var(--login-button-active) 5 !important;
+ box-shadow: 4px 4px var(--login-button-active) 5 !important;
transform: translateY(5px) !important;
}
+.input {
+ font-family: Fjalla One;
+}
+
+.button {
+ font-family: BabaPro;
+}
+
.input,
.button {
font-size: 14pt;
- font-family: 'BabaPro';
background: var(--login-background);
border: 2px solid black;
outline: none;
padding-left: 5px;
}
+/**
+ * Common styles for input elements.
+ */
.input {
display: block;
width: 100%;
line-height: 28pt;
- margin-bottom: 20pt;
box-shadow: 4px 5px;
+ padding: 0 5px;
}
+/**
+ * Common styles for button elements.
+ */
.button {
padding: 2px;
width: 50%;
box-shadow: 4px 5px;
}
+/**
+ * Styles for the active state of buttons.
+ */
.button:active {
box-shadow: 4px 4px var(--login-button-active) 5;
transform: translateY(5px);
}
+/**
+ * Styles for image elements.
+ */
.img {
max-width: 250px !important;
}
+/**
+ * Styles for focused input elements.
+ */
.input:focus {
background: var(--login-input-focus);
}
+/**
+ * Styles for heading elements.
+ */
.h1 {
font-family: Bitsumishi;
}
@@ -81,6 +120,9 @@
font-family: BabaPro;
}
+/**
+ * Styles for span elements.
+ */
.span {
- font-family: TTOctosquares;
+ font-family: TTOctosquares;
}
diff --git a/client/src/pages/messages-all/messages.module.css b/client/src/pages/messages-all/messages.module.css
new file mode 100644
index 00000000..da1eefd4
--- /dev/null
+++ b/client/src/pages/messages-all/messages.module.css
@@ -0,0 +1,24 @@
+/**
+ * Defines styles for list group items.
+ */
+.list-group-item {
+ margin-top: 10px;
+ background: #e3f2fd;
+ transition: all 0.3s ease-in-out;
+ font-family: Bitsumishi;
+}
+
+/**
+ * Hover styles for list group items.
+ */
+.list-group-item:hover {
+ transform: scaleX(1.1);
+ background: #e3f2fd;
+}
+
+/**
+ * Defines styles for user links.
+ */
+.userLink {
+ text-decoration: none;
+}
\ No newline at end of file
diff --git a/client/src/pages/messages-all/messages.tsx b/client/src/pages/messages-all/messages.tsx
new file mode 100644
index 00000000..d5fd0cf6
--- /dev/null
+++ b/client/src/pages/messages-all/messages.tsx
@@ -0,0 +1,72 @@
+import { useEffect, useState } from 'react';
+import { Link } from 'wouter';
+import { api } from '../../lib/axios';
+import Page from '../../components/page/page';
+import styles from './messages.module.css';
+
+/**
+ * MessagesAll component displays all users with whom the current user has conversations.
+ *
+ * @component
+ * @returns {JSX.Element} The rendered MessagesAll component.
+ */
+const MessagesAll = () => {
+ const [users, setUsers] = useState([]);
+
+ /**
+ * Fetches users with conversations from the backend API.
+ * @function fetchUsers
+ */
+ useEffect(() => {
+ const fetchUsers = async () => {
+ const { data: res } = await api.post('/user/conversations');
+ try {
+ if (res.success) {
+ console.log(res.data);
+ setUsers(res.data);
+ }
+ } catch (error) {
+ console.log(error);
+ }
+ };
+
+ fetchUsers();
+ }, []);
+
+ // Renders the component
+ return (
+
+
+ {users.map((user: any, index) => (
+
+
+
+
+
+
+ {user.name}
+
+
+
+ 2024-05-21
+
+
+ ))}
+
+
+ }
+ />
+ );
+};
+
+export default MessagesAll;
diff --git a/client/src/pages/messages/messages-component.tsx b/client/src/pages/messages/messages-component.tsx
index b7329da1..610c72b9 100644
--- a/client/src/pages/messages/messages-component.tsx
+++ b/client/src/pages/messages/messages-component.tsx
@@ -1,48 +1,55 @@
+import io from 'socket.io-client';
+import { useParams } from 'wouter';
import MessagesHtml from './messages-html';
import { useEffect, useState, useContext } from 'react';
import { api } from '../../lib/axios';
-import { useParams } from 'wouter';
-import io from 'socket.io-client';
import { getServerHost } from '../../environment';
-// import { UserAuthContext } from '../../lib/auth';
+import { UserAuthContext } from '../../lib/auth';
+/**
+ * Messages component manages the chat functionality.
+ *
+ * @component
+ * @returns {JSX.Element} The rendered Messages component.
+ */
const Messages = () => {
- let { id } = useParams();
+ let { id } = useParams(); // Retrieves the user ID from the URL parameters
const [message, setMessage] = useState('');
const [messages, setMessages] = useState
([]);
const [isChat, setIsChat] = useState(false);
- // const [socket, setSocket] = useState(null);
const [convoID, setConvoID] = useState('');
- const socket = io(getServerHost());
- // const user = useContext(UserAuthContext);
+ const socket = io(getServerHost()); // Socket.io instance for real-time communication
const [username, setUsername] = useState('');
+ const [avatar, setAvatar] = useState('');
+ const user = useContext(UserAuthContext);
+ /**
+ * Fetches the chat messages and friend details.
+ *
+ * @async
+ * @function fetchMessages
+ * @returns {Function} The cleanup function to close the socket connection.
+ */
useEffect((): any => {
const fetchMessages = async () => {
- let headers = {
- headers: {
- Authorization: 'Bearer ' + localStorage.getItem('jwt'),
- },
- };
- const { data: res } = await api.post('/user/getchats', { receiverId: id }, headers);
+ const { data: res } = await api.post('/user/getchats', { receiverId: id });
try {
if (res.success) {
setIsChat(true);
setMessages(res.message);
- setConvoID(res.message[0].conversationId);
- // setSocket(newSocket);
- socket.emit('sendID', res.message[0].conversationId);
+ setConvoID(res.message[0].messages[0].conversationId);
+ socket.emit('sendID', res.message[0].messages[0].conversationId);
}
} catch (error) {
console.log(error);
}
const friend = await api.get(`/user/${id}`);
- console.log(friend.data);
try {
if (friend.data.success) {
setUsername(friend.data.value.userName);
+ setAvatar(friend.data.value.avatarUrl);
}
} catch (error) {
console.log(error);
@@ -50,21 +57,36 @@ const Messages = () => {
};
fetchMessages();
+ // Cleanup function to close the socket connection
return () => socket.close();
}, []);
+ /**
+ * Listens for new messages from the socket.
+ *
+ * @function listenForMessages
+ */
useEffect(() => {
let arr: any;
if (socket) {
- socket.on('receiveMessage', (data: any) => {
- arr = data;
- });
- socket.on('displayMessage', () => {
- setMessages((prevMessages) => [...prevMessages, arr]);
+ /* socket.on('receiveMessage', (data: any) => {
+ arr = data;
+ }); */
+ socket.on('displayMessage', (data) => {
+ console.log("arr", arr)
+ // setMessages((prevMessages) => [...prevMessages, arr]);
+ setMessages(data);
});
}
}, [socket]);
+ /**
+ * Submits the chat message.
+ *
+ * @async
+ * @function submitForm
+ * @param {Object} e - The event object from the form submission.
+ */
const submitForm = async (e: any) => {
e.preventDefault();
const newMessage = {
@@ -74,7 +96,7 @@ const Messages = () => {
try {
const { data: res } = await api.post('/user/chat', newMessage);
- socket.emit('sendMessage', convoID);
+ // socket.emit('sendMessage', res.value.conversationId);
setMessage('');
} catch (error) {
console.log(error);
@@ -89,6 +111,7 @@ const Messages = () => {
id={id}
isChat={isChat}
username={username}
+ avatar={avatar}
/>
);
};
diff --git a/client/src/pages/messages/messages-html.tsx b/client/src/pages/messages/messages-html.tsx
index 0e34fde7..f81d891e 100644
--- a/client/src/pages/messages/messages-html.tsx
+++ b/client/src/pages/messages/messages-html.tsx
@@ -1,6 +1,6 @@
import { FaVideo } from 'react-icons/fa';
import { IoCall } from 'react-icons/io5';
-import { IoChevronBackCircleSharp } from 'react-icons/io5';
+import { IoArrowBackCircle } from 'react-icons/io5';
import { IoSend } from 'react-icons/io5';
import { MdOutlineEmojiEmotions } from 'react-icons/md';
import { RxAvatar } from 'react-icons/rx';
@@ -8,61 +8,136 @@ import { Else, If, Then } from 'react-if';
import styles from './messages.module.css';
-const MessagesHtml = ({ message, messages, setMessage, submitForm, id, isChat, username }: any) => {
+/**
+ * MessagesHtml component handles the presentation layer for messages.
+ *
+ * @component
+ * @param {Object} props - The properties object.
+ * @param {string} props.message - The current message being typed.
+ * @param {Array} props.messages - The list of messages.
+ * @param {Function} props.setMessage - The function to update the message.
+ * @param {Function} props.submitForm - The function to handle form submission.
+ * @param {string} props.id - The ID of the conversation.
+ * @param {boolean} props.isChat - Flag indicating whether it's an active chat.
+ * @param {string} props.username - The username of the chat partner.
+ * @param {string} props.avatar - The avatar URL of the chat partner.
+ * @returns {JSX.Element} The rendered MessagesHtml component.
+ */
+const MessagesHtml = ({
+ message,
+ messages,
+ setMessage,
+ submitForm,
+ id,
+ isChat,
+ username,
+ avatar,
+}:
+any) => {
+
+ /**
+ * Handles the return to the previous page.
+ *
+ * @function handlePageReturn
+ */
function handlePageReturn() {
history.back();
}
return (
<>
-
+
-
-
+
+
{isChat ? (
- messages.map((message: any, index: number) => {
- return (
-
-
-
-
-
-
- {message.content}
-
-
-
-
-
-
-
-
- {message.content}
-
-
-
-
-
+
+ {messages.map((messageGroup: any) => (
+
+
+ {Intl.DateTimeFormat('en-US', {
+ timeZone: 'America/Vancouver',
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ }).format(new Date(messageGroup._id + 'T00:00:00'))}
+
+ {messageGroup.messages.map((message: any, index: any) => (
+
+
+
+
+
+ {message.content}
+
+
+ {new Date(message.createdAt).toLocaleTimeString(
+ 'en-US',
+ )}
+
+
+
+
+
+
+ {message.content}
+
+
+ {new Date(message.createdAt).toLocaleTimeString(
+ 'en-US',
+ )}
+
+
+
+
+
+ ))}
- );
- })
+ ))}
+
) : (
+ /* messages.map((message: any, index: number) => {
+ return (
+
+
+
+
+
{message.content}
+
+ {new Date(message.createdAt).toLocaleTimeString('en-US')}
+
+
+
+
+
+
{message.content}
+
+ {new Date(message.createdAt).toLocaleTimeString('en-US')}
+
+
+
+
+
+ );
+ }) */
@@ -70,34 +145,28 @@ const MessagesHtml = ({ message, messages, setMessage, submitForm, id, isChat, u
-
-
-
-
-
-
+
+
+ {/*
+
+
*/}
+
setMessage(e.target.value)}
/>
-
- {/*
- Send
- */}
>
diff --git a/client/src/pages/messages/messages.module.css b/client/src/pages/messages/messages.module.css
index c2f57dea..6f9eb87d 100644
--- a/client/src/pages/messages/messages.module.css
+++ b/client/src/pages/messages/messages.module.css
@@ -1,11 +1,112 @@
-.container {
- /* background: #232c38; */
-}
-
+/**
+ * Applies the Bitsumishi font family to the h3 elements.
+ */
.h3 {
font-family: Bitsumishi;
}
+/**
+ * Applies the TTOctosquares font family to the paragraphs.
+ */
.p {
- font-family: TTOctosquares;
+ font-family: TTOctosquares;
+}
+
+/**
+ * Applies the Fjalla One font family to input elements.
+ */
+.input {
+ font-family: Fjalla One;
+}
+
+/**
+ * Sets custom column widths for specific screen sizes.
+ */
+@media (min-width: 600px) {
+ .custom-col {
+ width: 4.333333%;
+ }
+
+ .custom-col11 {
+ width: 94.333333%;
+ }
+}
+
+/**
+ * Adjusts padding for custom columns on smaller screens.
+ */
+@media (max-width: 700px) {
+ .custom-col {
+ padding-left: 0;
+ }
+}
+
+/**
+ * Defines styles for message containers.
+ */
+.message {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 20px;
+}
+
+/**
+ * Styles for messages sent by the sender.
+ */
+.sender {
+ align-items: flex-end;
}
+
+/**
+ * Styles for messages received by the receiver.
+ */
+.receiver {
+ align-items: flex-start;
+}
+
+/**
+ * Defines styles for message content.
+ */
+.message .content {
+ padding: 11px;
+ border-radius: 10px;
+}
+
+/**
+ * Styles for message content received by the receiver.
+ */
+.receiver .content {
+ background-color: #cadcfc;
+ border-bottom-left-radius: 0;
+}
+
+/**
+ * Styles for message content sent by the sender.
+ */
+.sender .content {
+ background-color: #d9f0ff;
+ border-bottom-right-radius: 0;
+}
+
+/**
+ * Defines styles for message timestamps.
+ */
+.timestamp {
+ font-size: 10px;
+ color: #999;
+ margin-top: 5px;
+}
+
+/**
+ * Aligns timestamps to the right within message containers.
+ */
+.message .timestamp {
+ text-align: right;
+}
+
+/**
+ * Defines styles for the fixed-bottom container.
+ */
+.fixed-bottom {
+ background: #f0f8ff;
+}
\ No newline at end of file
diff --git a/client/src/pages/my-feed/my-feed.module.css b/client/src/pages/my-feed/my-feed.module.css
deleted file mode 100644
index e69de29b..00000000
diff --git a/client/src/pages/my-feed/my-feed.tsx b/client/src/pages/my-feed/my-feed.tsx
deleted file mode 100644
index d6a69a73..00000000
--- a/client/src/pages/my-feed/my-feed.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import styles from './my-feed.module.css';
-import Page from '../../components/Page/Page';
-import Post from '../../components/Post/Post';
-
-const MyFeed = () => {
- const displayedPosts: JSX.Element[] = [];
-
- const dummyPost = (
-
- );
-
- for (let i = 1; i < 10; i++) {
- displayedPosts.push(dummyPost);
- }
-
- return
;
-};
-
-export default MyFeed;
diff --git a/client/src/pages/page404/page404.module.css b/client/src/pages/page404/page404.module.css
new file mode 100644
index 00000000..1905710b
--- /dev/null
+++ b/client/src/pages/page404/page404.module.css
@@ -0,0 +1,160 @@
+.page {
+ height: 100%;
+ overflow: hidden;
+}
+
+.header {
+ background-color: white;
+}
+
+.image {
+ background-color: black;
+ width: 100%;
+ border-bottom: 6px solid gray;
+}
+
+.panel {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ background-image: url('/among-us.jpg');
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ width: 100%;
+ height: 100%
+}
+
+.text404 {
+ text-align: center;
+}
+
+.text404 h1 {
+ font-family: Babapro;
+ font-size: 20em;
+ text-shadow:
+ 7px 7px #2196F3,
+ 7px -7px #2196F3,
+ -7px 7px #2196F3,
+ -7px -7px #2196F3;
+}
+
+.text404 h3,
+h5 {
+ font-family: TTOctosquares;
+ font-weight: 600;
+ text-shadow:
+ 1px 1px white,
+ 1px -1px white,
+ -1px 1px white,
+ -1px -2px white;
+}
+
+.button-group {
+ display: flex;
+ justify-content: center;
+ gap: 45px;
+ padding-top: 12px;
+ padding-bottom: 12px;
+}
+
+.buttons {
+ font-family: TTOctosquares;
+ padding: 4px 7px;
+ height: 100%;
+ align-items: center;
+}
+
+.linkText {
+ color: inherit;
+ text-decoration: none;
+}
+
+.rest {
+ font-size: 0.8em;
+ color: darkgrey;
+ padding: 6px;
+ background-color: black;
+ width: 100vw;
+ height: 100vh;
+ overflow: visible;
+}
+
+@media only screen and (min-width: 1050px) and (max-width: 1300px) {
+ .text404 h1 {
+ font-size: 15em;
+ text-shadow:
+ 5px 5px #2196F3,
+ 5px -5px #2196F3,
+ -5px 5px #2196F3,
+ -5px -5px #2196F3;
+ }
+}
+
+@media only screen and (min-width: 825px) and (max-width: 1049px) {
+ .text404 h1 {
+ font-size: 12em;
+ text-shadow:
+ 3px 3px #2196F3,
+ 3px -3px #2196F3,
+ -3px 3px #2196F3,
+ -3px -3px #2196F3;
+ }
+
+ .text404 h3 {
+ font-size: 1.7em;
+ }
+
+ .text404 h5 {
+ font-size: 1em;
+ }
+}
+
+@media only screen and (min-width: 600px) and (max-width: 824px) {
+ .text404 h1 {
+ font-size: 10em;
+ text-shadow:
+ 3px 3px #2196F3,
+ 3px -3px #2196F3,
+ -3px 3px #2196F3,
+ -3px -3px #2196F3;
+ }
+
+ .text404 h3 {
+ font-size: 1.5em;
+ }
+
+ .text404 h5 {
+ font-size: 0.9em;
+ }
+
+ .button-group {
+ padding-top: 8px;
+ padding-bottom: 8px;
+ }
+}
+
+@media only screen and (max-width: 599px) {
+ .text404 h1 {
+ font-size: 8em;
+ text-shadow:
+ 2px 2px #2196F3,
+ 2px -2px #2196F3,
+ -2px 2px #2196F3,
+ -2px -2px #2196F3;
+ }
+
+ .text404 h3 {
+ font-size: 1.2em;
+ }
+
+ .text404 h5 {
+ font-size: 0.7em;
+ }
+
+ .button-group {
+ padding-top: 8px;
+ padding-bottom: 8px;
+ }
+}
\ No newline at end of file
diff --git a/client/src/pages/page404/page404.tsx b/client/src/pages/page404/page404.tsx
new file mode 100644
index 00000000..f983b7d2
--- /dev/null
+++ b/client/src/pages/page404/page404.tsx
@@ -0,0 +1,40 @@
+import { Link } from 'wouter';
+import Header from '../../components/header/header';
+import UIBox from '../../components/uibox/uibox';
+import styles from './page404.module.css';
+import { Container, Image } from 'react-bootstrap';
+
+/**
+ * Page404 component representing the page a user should land if a page is not found.
+ *
+ * @return JSX.Element - Page404 as a JSX.Element
+ */
+const Page404 = () => {
+ return (
+
+
+
+
+
+
+
404
+ Suspect page not found
+ The page you were looking for doesn't exist or was moved to the next galaxy.
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+/**
+ * Exports the Page404 component for external use.
+ */
+export default Page404;
diff --git a/client/src/pages/planet-feed/planet-feed.tsx b/client/src/pages/planet-feed/planet-feed.tsx
index d264eec4..9260a54b 100644
--- a/client/src/pages/planet-feed/planet-feed.tsx
+++ b/client/src/pages/planet-feed/planet-feed.tsx
@@ -1,57 +1,30 @@
-import { useEffect, useState } from 'react';
import { api } from '../../lib/axios';
-import Page from '../../components/Page/Page';
-import Post from '../../components/Post/Post';
+import Page from '../../components/page/page';
import { useParams } from 'wouter';
+import { PaginatedPostFeed } from '../../components/paginated-post-feed/paginated-post-feed';
-// PLEASE, CHECK THE PROMISE USE
-// I HAD A PROBLEM WITH USING AWAIT/ASYNC INSIDE ARRAYS
+/**
+ * PlanetFeed component representing a specific planet feed containing posts only from that
+ * planet.
+ *
+ * @return JSX.Element - Planet Feed page as an JSX.Element
+ */
const PlanetFeed = () => {
- const [displayedPosts, setDisplayedPosts] = useState(Array
());
- let { id } = useParams() ?? '';
- let { planetName } = useParams() ?? '';
+ let { id = '' } = useParams();
+ let { planetName = 'unknown' } = useParams();
- useEffect(() => {
- const displayPosts = async function () {
- let postArray = await fetchPost();
- if (postArray == undefined) {
- postArray = [<>Nothing yet...>];
+ return (
+ api.get(`/feed/${id}?page=${page}`).then((res) => res.data.value)}
+ />
}
- setDisplayedPosts(postArray);
- };
- displayPosts();
- }, []);
-
- async function fetchPost() {
- try {
- const postRes = await api.get(`/feed/${id}`);
- const postArray = postRes.data.value;
- let postElements: Promise = Promise.all(
- postArray.map(async (post: any) => {
- const authorRes = await api.get('/user/' + post.authorId);
- const authorData = authorRes.data.value;
- return (
-
- );
- }),
- );
- return postElements;
- } catch (err) {
- console.log(err);
- }
- }
-
- return ;
+ />
+ );
};
export default PlanetFeed;
diff --git a/client/src/pages/planet-map/center-visual.tsx b/client/src/pages/planet-map/center-visual.tsx
index 02ddbc0b..3f57630e 100644
--- a/client/src/pages/planet-map/center-visual.tsx
+++ b/client/src/pages/planet-map/center-visual.tsx
@@ -4,7 +4,7 @@ import { Image } from 'react-konva';
import useImage from 'use-image';
import { useLocation } from 'wouter';
-import logoUrl from '../../assets/images/SkynetLogo.png';
+import logoUrl from '../../assets/images/skynet-logo.png';
import { withRef } from '../../lib/with-ref';
export const CenterVisual = () => {
diff --git a/client/src/pages/planet-map/planet-map.tsx b/client/src/pages/planet-map/planet-map.tsx
index 5f2648b4..7dbe0fca 100644
--- a/client/src/pages/planet-map/planet-map.tsx
+++ b/client/src/pages/planet-map/planet-map.tsx
@@ -13,8 +13,14 @@ import { PlanetVisual } from './planet-visual';
import { SpaceTraveller } from './space-traveller';
import { StarBackground } from './star-background';
import { useLocation } from 'wouter';
+import { If, Then } from 'react-if';
+import Header from '../../components/header/header';
-export const PlanetMap = () => {
+interface Props {
+ interactable: boolean;
+}
+
+export const PlanetMap = (props: Props) => {
const stageRef = useRef(null);
const [_, navigate] = useLocation();
@@ -187,26 +193,31 @@ export const PlanetMap = () => {
return (
<>
-
-
-
- X: {-relativePanPosition.x.toFixed(0)}, Y: {relativePanPosition.y.toFixed(0)}, x
- {zoom.toLocaleString()}
-
-
resetPan()}>
-
-
-
-
navigate('/home-list')}>
-
-
-
-
+
+
+
+
+
+
+ X: {-relativePanPosition.x.toFixed(0)}, Y: {relativePanPosition.y.toFixed(0)}, x
+ {zoom.toLocaleString()}
+
+
resetPan()}>
+
+
+
+
navigate('/home-list')}>
+
+
+
+
+
+
{
width={innerWidth}
height={innerHeight}
offset={{ x: -innerWidth / 2, y: -innerHeight / 2 }}
- style={{ position: 'absolute', background: 'transparent', zIndex: 1 }}
- draggable>
+ style={{
+ position: 'absolute',
+ background: 'transparent',
+ zIndex: 1,
+ pointerEvents: props.interactable ? 'all' : 'none',
+ }}
+ draggable={props.interactable}
+ listening={props.interactable}>
{planetData.map((v) => {
- return ;
+ return (
+
+ );
})}
@@ -240,7 +265,11 @@ export const PlanetMap = () => {
-
+
>
diff --git a/client/src/pages/planet-map/planet-visual.tsx b/client/src/pages/planet-map/planet-visual.tsx
index e7d98833..9766cfe5 100644
--- a/client/src/pages/planet-map/planet-visual.tsx
+++ b/client/src/pages/planet-map/planet-visual.tsx
@@ -14,9 +14,10 @@ interface Props {
planet: unknown;
planetId: string;
home: boolean;
+ interactable: boolean;
}
-export const PlanetVisual = ({ planet, home, planetId }: Props) => {
+export const PlanetVisual = ({ planet, home, planetId, interactable }: Props) => {
if (!planet || typeof planet !== 'object' || !('visual' in planet)) return;
const name = 'name' in planet && typeof planet.name === 'string' ? planet.name : 'Unknown Planet';
@@ -37,7 +38,7 @@ export const PlanetVisual = ({ planet, home, planetId }: Props) => {
const [planetImg, planetImgStatus] = useImage(imageUrl);
- const [active, setActive] = useState(isMobile);
+ const [active, setActive] = useState(interactable ? isMobile : false);
const [cardType, setCardType] = useState<'preview' | 'expanded'>(isMobile ? 'preview' : 'expanded');
const [x, setX] = useState(orbitRadius);
@@ -143,6 +144,7 @@ export const PlanetVisual = ({ planet, home, planetId }: Props) => {
setCardType('expanded');
} else setActive(!active);
}}
+ listening={interactable}
onClick={goToFeed}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
diff --git a/client/src/pages/post-details/post-details.module.css b/client/src/pages/post-details/post-details.module.css
new file mode 100644
index 00000000..fc62e34e
--- /dev/null
+++ b/client/src/pages/post-details/post-details.module.css
@@ -0,0 +1,20 @@
+.commentTextArea {
+ width: 100%;
+ min-height: 50px;
+ padding: 10px;
+ resize: vertical;
+ border: 1px solid #ccc;
+ border-radius: 5px;
+ margin-bottom: 10px;
+ font-family: TTOctosquares;
+}
+
+.commentButton {
+ padding: 5px 10px;
+ font-size: 1rem;
+ background-color: #007bff;
+ color: #fff;
+ border: none;
+ border-radius: 5px;
+ font-family: TTOctosquares;
+}
diff --git a/client/src/pages/post-details/post-details.tsx b/client/src/pages/post-details/post-details.tsx
new file mode 100644
index 00000000..a18300fc
--- /dev/null
+++ b/client/src/pages/post-details/post-details.tsx
@@ -0,0 +1,198 @@
+/* Stylesheet imports */
+import styles from './post-details.module.css';
+
+/* Import from React */
+import React, { useEffect, useState } from 'react';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../lib/axios';
+
+/* Import from other components created */
+import Post from '../../components/post/post';
+import Page from '../../components/page/page';
+import { PaginatedPostFeed } from '../../components/paginated-post-feed/paginated-post-feed';
+import { Loader } from '../../components/loader/loader';
+import SEO from '../../components/seo/seo';
+
+/**
+ * Post interface used as a model schema for the Post data that is displayed.
+ *
+ * @param _id string - Id of the post in the database.
+ * @param authorId string - Id of the author of the post.
+ * @param content string - Text of the post.
+ * @param likeCount number - Number of likes of the post.
+ * @param commentCount number - Number of comments of the post.
+ * @param Location LocationOject - Location in which the post was created. (planetId: string, latitude: number, longitude: number, _id: string)
+ * @param media any[] - (TODO) Contains media embedded into the post (image, video, audio, etc.)
+ * @param createdAt Date - (optional) Date in which the post was created.
+ * @param deleted boolean - If true, displays the post as a deleted post.
+ * @param userName string - UserName of author of the post.
+ * @param avatarUrl string - Url for displaying the avatar picture of the author of the post.
+ * @param parentPost string - If post is a comment, contains the _id of the parent post.
+ * @interface Post
+ */
+interface Post {
+ _id: string;
+ authorId: string;
+ content: string;
+ likeCount: number;
+ commentCount: number;
+ location: {
+ planetId: string;
+ latitude: number;
+ longitude: number;
+ _id: string;
+ };
+ media: any[];
+ createdAt: Date;
+ deleted: boolean;
+ userName: string;
+ avatarUrl: string;
+}
+
+/**
+ * PostResponse interface used as a model schema for the response that comes from the
+ * database containing the Post information as it's value.
+ *
+ * @interface PostResponse
+ * @param statusCode number - Status code of the request (e.g. 404).
+ * @param statusMessage string - Status message of the request (e.g. 'Page not found')
+ * @param value Post - Post data sent through the request.
+ * @param success boolean - True if the request was received, processed and then sent successfully.
+ */
+interface PostResponse {
+ statusCode: number;
+ statusMessage: string;
+ value: Post;
+ success: boolean;
+}
+
+/**
+ * Interface that represents the arguments passed down to the PostDetailPage component.
+ *
+ * @params Covered on the component documentation.
+ */
+interface Props {
+ id: string;
+}
+
+/**
+ * Constructs, manages, and returns the PostDetailPage component.
+ *
+ * @param id The id of the post
+ * @return The PostDetailPage component as a JSX.Element
+ */
+const PostDetailPage: React.FC = ({ id }) => {
+ const [postDetails, setPostDetails] = useState();
+ const [comment, setComment] = useState('');
+ const [isCommenting, setIsCommenting] = useState(false);
+ const [postedComments, setPostedComments] = useState([]);
+
+ /**
+ *
+ */
+ useEffect(() => {
+ const fetchPost = async () => {
+ try {
+ const response = await api.get(`/post/${id}`);
+ if (response.data.success) setPostDetails(response.data.value);
+ } catch {}
+ };
+
+ fetchPost();
+ }, [id]);
+
+ if (!postDetails) return ;
+
+ /**
+ *
+ */
+ const handleCommentChange = (event: React.ChangeEvent) => {
+ setComment(event.target.value);
+ };
+
+ /**
+ *
+ */
+ const handleCommentSubmit = async (event: React.FormEvent) => {
+ if (!postDetails) return;
+
+ event.preventDefault();
+ const content = comment.trim();
+ if (!content) return;
+
+ if (isCommenting) return;
+ setIsCommenting(true);
+
+ try {
+ const response = await api.post(`/post/${postDetails._id}/comment`, { content });
+ if (!response.data.success) return;
+
+ const newComment = response.data.value;
+ setPostedComments([newComment, ...postedComments]);
+
+ setComment('');
+ } catch (error) {
+ console.error('Error posting comment:', error);
+ } finally {
+ setIsCommenting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Comment
+
+
+
+
+
+ {postedComments.map((v, i) => {
+ return
;
+ })}
+
+
+
+ api
+ .get(`/post/${postDetails!._id}/comment?page=${page}`)
+ .then((v) => v.data.value.map((v: any) => ({ ...v, parentPost: undefined })))
+ }
+ />
+
+ >
+ }
+ />
+ );
+};
+
+export default PostDetailPage;
diff --git a/client/src/pages/post-page/post-page.tsx b/client/src/pages/post-page/post-page.tsx
deleted file mode 100644
index 28321bd6..00000000
--- a/client/src/pages/post-page/post-page.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import styles from './post-page.module.css';
-import Page from '../../components/Page/Page';
-import UIBox from '../../components/UIBox/UIBox';
-import { useContext, useEffect, useState } from 'react';
-import { api } from '../../lib/axios';
-import { toast } from 'react-toastify';
-import { useLocation } from 'wouter';
-import { UserAuthContext } from '../../lib/auth';
-
-interface PostProps {}
-
-const PostPage = function (props: PostProps) {
- const user = useContext(UserAuthContext);
-
- interface Planet {
- _id: string;
- name: string;
- }
-
- const [planets, setPlanets] = useState>([]);
- const [_, setLocation] = useLocation();
-
- useEffect(() => {
- (async function fetchPlanets() {
- try {
- const { data: res } = await api.get('/planet');
- const data = res.value;
- setPlanets(data);
- } catch (err) {
- console.log(err);
- }
- })();
- }, []);
-
- async function submitPost() {
- const geoLoc = await new Promise((res) => {
- navigator.geolocation.getCurrentPosition(
- (p) => res(p),
- () => res(undefined),
- );
- });
-
- const postContent = document.getElementsByTagName('textarea');
- const content = postContent[0].value;
- const planet = document.getElementsByTagName('select');
- const planetID = planet[0].value;
-
- const postRequest = {
- content: content,
- location: {
- latitude: geoLoc ? geoLoc.coords.latitude : 0,
- longitude: geoLoc ? geoLoc.coords.longitude : 0,
- planetId: planetID,
- },
- };
-
- console.log(postRequest);
-
- try {
- const res = await api.post('/post', postRequest);
- setLocation('/profile');
- toast.success(res.data.message);
- } catch (err: any) {
- toast.error(`${err.response.data.error}`, {
- position: 'top-right',
- hideProgressBar: false,
- closeOnClick: true,
- pauseOnHover: true,
- draggable: true,
- progress: undefined,
- theme: 'colored',
- });
- }
- }
-
- return (
- <>
-
-
-
-
- }
- curved
- />
-
-
- {planets.map((planet: any, index: number) => {
- return (
-
- {planet.name}
-
- );
- })}
-
-
-
-
-
-
- >
- }
- />
- >
- );
-};
-
-export default PostPage;
diff --git a/client/src/pages/post/post.module.css b/client/src/pages/post/post.module.css
deleted file mode 100644
index e69de29b..00000000
diff --git a/client/src/pages/post/post.tsx b/client/src/pages/post/post.tsx
deleted file mode 100644
index c6f6f373..00000000
--- a/client/src/pages/post/post.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import styles from './post.module.css';
-
-import React, { useEffect, useState } from 'react';
-import { api } from '../../lib/axios';
-import Post from '../../components/Post/Post';
-import Page from '../../components/Page/Page';
-import Comment from '../../components/Comment/Comment';
-
-interface Post {
- _id: string;
- authorId: string;
- content: string;
- likeCount: number;
- commentCount: number;
- repostCount: number;
- location: {
- planetId: string;
- latitude: number;
- longitude: number;
- _id: string;
- };
- media: any[];
- createdAt: Date;
- deleted: boolean;
- userName: string;
-}
-
-interface PostResponse {
- statusCode: number;
- statusMessage: string;
- value: Post;
- success: boolean;
-}
-
-interface Props {
- id: string;
-}
-
-const PostDetailPage: React.FC = ({ id }) => {
- const [post, setPost] = useState(null);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- const fetchPost = async () => {
- try {
- const response = await api.get(`/post/${id}`);
- if (response.data.success) {
- setPost(response.data.value);
- } else {
- setError(response.data.statusMessage);
- }
- } catch (err) {
- setError('Error fetching the post.');
- }
- };
-
- fetchPost();
- }, [id]);
-
- if (error) {
- return Error: {error}
;
- }
-
- if (!post) {
- return Loading...
;
- }
-
- const postDetail = (
-
- );
-
- return (
-
- {postDetail}
-
- >
- }
- />
- );
-};
-
-export default PostDetailPage;
diff --git a/client/src/pages/profile-page/profile-page.tsx b/client/src/pages/profile-page/profile-page.tsx
index 41e1b47f..e5aa84d3 100644
--- a/client/src/pages/profile-page/profile-page.tsx
+++ b/client/src/pages/profile-page/profile-page.tsx
@@ -1,87 +1,37 @@
-import styles from './profile-page.module.css';
-import Page from '../../components/Page/Page';
-import Post from '../../components/Post/Post';
-import Profile from '../../components/Profile/Profile';
+import Page from '../../components/page/page';
+import Profile from '../../components/profile/profile';
import { api } from '../../lib/axios';
-import { useContext, useEffect, useState } from 'react';
+import { useContext } from 'react';
import { UserAuthContext } from '../../lib/auth';
+import { PaginatedPostFeed } from '../../components/paginated-post-feed/paginated-post-feed';
-interface Post {
- authorId: string;
- commentCount: number;
- content: string;
- createdAt: Date;
- deleted: false;
- likeCount: number;
- location: {
- planetId: string;
- latitude: number;
- longitude: number;
- _id: string;
- };
- media: [];
- repostCount: number;
- __v: number;
- _id: string;
-}
-
+/**
+ * ProfilePage component representing the own user's profile page, containing profile
+ * information and personal feed (all posts made by the user) and being different by
+ * being and editable profile page.
+ *
+ * @return JSX.Element -Profile Page as a JSX.Element
+ */
const ProfilePage = () => {
const user = useContext(UserAuthContext);
- const [displayedPosts, setDisplayedPosts] = useState(Array());
-
- useEffect(() => {
- const displayPosts = async function () {
- setDisplayedPosts(await getPosts());
- };
- displayPosts();
- }, [user]);
-
- async function getPosts() {
- try {
- if (!user) return;
- const res = await api.get('/feed/' + user._id);
- const postArray = res.data.value;
- let postElements = postArray.map((post: Post) => {
- return (
-
- );
- });
- if (postArray.length == 0) {
- postElements = [<>Nothing yet...>];
- }
- return postElements;
- } catch (err) {
- console.log(err);
- }
- }
return (
-
+ api.get(`/feed/${user._id}?page=${page}`).then((res) => res.data.value)}
/>
- {displayedPosts}
>
}
/>
);
};
+/**
+ * Exports the ProfilePage component for external use.
+ */
export default ProfilePage;
diff --git a/client/src/pages/reset-password/reset-password.module.css b/client/src/pages/reset-password/reset-password.module.css
new file mode 100644
index 00000000..d90ce29f
--- /dev/null
+++ b/client/src/pages/reset-password/reset-password.module.css
@@ -0,0 +1,74 @@
+.resetpassword-container {
+ display: grid;
+ align-items: center;
+ justify-items: center;
+ height: 80vh;
+}
+
+.resetpassword-form {
+ background: var(--signup-background);
+ padding: 48px 59px;
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+}
+
+.resetpassword-upperdiv {
+ background: var(--signup-background);
+ padding: 4px 0px;
+ border: 2px solid black;
+ box-shadow: 4px 3px 0px #000;
+}
+
+.resetpassword-bottomdiv {
+ background: var(--signup-background);
+ padding: 4px 0px;
+ border: 2px solid black;
+ box-shadow: 4px 4px 0px #000;
+}
+
+.input {
+ display: block;
+ width: 100%;
+ line-height: 28pt;
+ margin-bottom: 20pt;
+ box-shadow: 4px 5px;
+}
+
+.input:focus {
+ background: var(--signup-input-focus);
+}
+
+.button {
+ padding: 2px;
+ width: 82.5%;
+ box-shadow: 4px 5px;
+}
+
+.button:active {
+ box-shadow: 4px 4px var(--signup-button-active)5;
+ transform: translateY(5px);
+}
+
+.select {
+ width: 100%;
+ margin-bottom: 20pt;
+ box-shadow: 4px 5px;
+ padding: 6px;
+}
+
+.img {
+ max-width: 200px !important;
+}
+
+.h1 {
+ font-family: Bitsumishi;
+}
+
+.h5 {
+ font-family: BabaPro;
+}
+
+.message {
+ font-family: BabaPro;
+ color: red;
+}
\ No newline at end of file
diff --git a/client/src/pages/reset-password/reset-password.tsx b/client/src/pages/reset-password/reset-password.tsx
new file mode 100644
index 00000000..69df8717
--- /dev/null
+++ b/client/src/pages/reset-password/reset-password.tsx
@@ -0,0 +1,105 @@
+/* Stylesheet imports */
+import styles from './reset-password.module.css';
+
+/* Import from react and toastify */
+import React, { useState } from 'react';
+import { toast } from 'react-toastify';
+
+/* Import from wouter */
+import { useLocation } from 'wouter';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../lib/axios';
+
+/* Imports from other components created */
+import Page from '../../components/page/page';
+
+/* Import from local files */
+import logoUrl from '../../assets/images/skynet-logo.png';
+
+/* Define the Props interface */
+interface Props {
+ token: string;
+}
+
+/**
+ * Constructs, manages, and returns the Resetpassword component.
+ *
+ * @param token The token used to reset the password
+ * @return The Resetpassword component as a JSX.Element
+ */
+const Resetpassword: React.FC = ({ token }) => {
+ const [password, setPassword] = useState('');
+ const [confirmpassword, setConfirmPassword] = useState('');
+
+ const [_, navigate] = useLocation();
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ if (loading) return;
+ setLoading(true);
+
+ try {
+ await api.patch(`/user/resetpassword/${token}`, {
+ password,
+ confirmpassword,
+ });
+
+ navigate('/login');
+ toast.info('Your password has been reset!');
+ } catch (error: any) {
+ toast.error(error.response.data.error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
RESET PASSWORD
+
+
+
+
+
+ }
+ />
+ );
+};
+
+export default Resetpassword;
diff --git a/client/src/pages/resetpassword/resetpassword.module.css b/client/src/pages/resetpassword/resetpassword.module.css
deleted file mode 100644
index 47f74e5f..00000000
--- a/client/src/pages/resetpassword/resetpassword.module.css
+++ /dev/null
@@ -1,74 +0,0 @@
-.resetpassword-container {
- display: grid;
- align-items: center;
- justify-items: center;
- height: 80vh;
- }
-
- .resetpassword-form {
- background: var(--signup-background);
- padding: 48px 59px;
- border: 2px solid black;
- box-shadow: 4px 5px 0px #000;
- }
-
- .resetpassword-upperdiv {
- background: var(--signup-background);
- padding: 4px 0px;
- border: 2px solid black;
- box-shadow: 4px 3px 0px #000;
- }
-
- .resetpassword-bottomdiv {
- background: var(--signup-background);
- padding: 4px 0px;
- border: 2px solid black;
- box-shadow: 4px 4px 0px #000;
- }
-
- .input {
- display: block;
- width: 100%;
- line-height: 28pt;
- margin-bottom: 20pt;
- box-shadow: 4px 5px;
- }
-
- .input:focus {
- background: var(--signup-input-focus);
- }
-
- .button {
- padding: 2px;
- width: 50%;
- box-shadow: 4px 5px;
- }
-
- .button:active {
- box-shadow: 4px 4px var(--signup-button-active)5;
- transform: translateY(5px);
- }
-
- .select {
- width: 100%;
- margin-bottom: 20pt;
- box-shadow: 4px 5px;
- padding: 6px;
- }
-
- .img {
- max-width: 250px !important;
- }
-
- .h1 {
- font-family: Bitsumishi;
- }
-
- .h5 {
- font-family: BabaPro;
- }
-
- .message {
- font-family: BabaPro;
- color: red;
- }
\ No newline at end of file
diff --git a/client/src/pages/resetpassword/resetpassword.tsx b/client/src/pages/resetpassword/resetpassword.tsx
deleted file mode 100644
index e100955a..00000000
--- a/client/src/pages/resetpassword/resetpassword.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import styles from './resetpassword.module.css';
-import logoUrl from '../../assets/images/SkynetLogo.png';
-import React, {useState} from 'react';
-import { api } from '../../lib/axios';
-
-interface Props {
- token: string;
-}
-
-const Resetpassword: React.FC
= ({ token }) => {
- const [password, setPassword] = useState('');
- const [confirmpassword, setConfirmPassword] = useState('');
- const [message, setMessage] = useState('');
- const [resetSuccess, setResetSuccess] = useState(false);
-
- const handleSubmit = async (event: React.FormEvent) => {
- event.preventDefault();
- try {
- const response = await api.patch(`/user/resetpassword/${token}`, {
- password,
- confirmpassword
- });
- setMessage(response.data.message);
- if (response.data.message === 'Password has been reset successfully.') {
- setResetSuccess(true);
- }
- } catch (error: any) {
- setMessage(error.response.data.message);
- }
- };
-
- return (
-
-
-
-
RESET YOUR PASSWORD
-
-
-
-
-
-
-
- );
-};
-
-export default Resetpassword;
diff --git a/client/src/pages/search-page/search-page.module.css b/client/src/pages/search-page/search-page.module.css
new file mode 100644
index 00000000..92ddc6dd
--- /dev/null
+++ b/client/src/pages/search-page/search-page.module.css
@@ -0,0 +1,46 @@
+.general {
+ font-family: TTOctosquares;
+ padding: 0px;
+ margin: 0px;
+ width: 100%;
+ max-width: 1000px;
+ height: fit-content;
+ align-items: center;
+}
+
+.search {
+ padding: 10px;
+ margin: 2px 0px;
+ text-align: left;
+ word-break: break-word;
+ font-size: 1.1rem;
+}
+
+.search-box {
+ border: none;
+ font-family: TTOctosquares;
+ width: 100%;
+ background-color: inherit;
+ resize: none;
+}
+
+.buttons {
+ padding-top: 6px;
+}
+
+.submit {
+ border: none;
+ border-bottom: 2px solid gray;
+ padding: 5px;
+ background-color: transparent;
+ width: 50%;
+}
+
+.selected {
+ color: white;
+ background-color: gray;
+}
+
+.submit-box {
+ padding: 2px 7px;
+}
\ No newline at end of file
diff --git a/client/src/pages/search-page/search-page.tsx b/client/src/pages/search-page/search-page.tsx
new file mode 100644
index 00000000..ff4e7514
--- /dev/null
+++ b/client/src/pages/search-page/search-page.tsx
@@ -0,0 +1,101 @@
+import styles from './search-page.module.css';
+import { useEffect, useState } from 'react';
+import { api } from '../../lib/axios';
+import Page from '../../components/page/page';
+import UIBox from '../../components/uibox/uibox';
+import { Container } from 'react-bootstrap';
+import { PaginatedPostFeed } from '../../components/paginated-post-feed/paginated-post-feed';
+import { PaginatedUserList } from '../../components/paginated-user-list/paginated-user-list';
+
+/**
+ * Creates a Search Page in which the user can search for posts or users by name/keywords.
+ *
+ * @return JSX.Element - SearchPage as a JSX.Element
+ */
+const SearchPage = function () {
+ const [search, setSearch] = useState('');
+ const [feedComponent, setFeedComponent] = useState();
+ // If true, searches for posts
+ const [searchPost, setSearchPost] = useState(true);
+
+ /**
+ * Use effect used to search a post everytime the user presses a button between user or
+ * post or everytime the user types something they want to search for.
+ */
+ useEffect(() => {
+ setFeedComponent(<>>);
+ if (search != '') {
+ setTimeout(() => {
+ setFeedComponent(
+ searchPost ? (
+
+ api.get(`/post/search/${search}?page=${page}`).then((res) => res.data.value)
+ }
+ />
+ ) : (
+
+ api.get(`/user/search/${search}?page=${page}`).then((res) => res.data.value)
+ }
+ />
+ ),
+ );
+ }, 0);
+ }
+ }, [search, searchPost]);
+
+ /**
+ * Stores what is typed by the user in the search bar in a React state.
+ *
+ * @returns void
+ */
+ function storeSearch() {
+ const search = document.getElementsByTagName('input');
+ const content = search[0].value;
+ setSearch(content);
+ }
+
+ return (
+
+
+
+ }
+ curved
+ />
+
+ setSearchPost(true)}>
+ Posts
+
+ setSearchPost(false)}>
+ Users
+
+
+
+ {feedComponent}
+ >
+ }
+ />
+ );
+};
+
+export default SearchPage;
diff --git a/client/src/pages/signup/signup-component.tsx b/client/src/pages/signup/signup-component.tsx
index c6596ae3..d41b05b3 100644
--- a/client/src/pages/signup/signup-component.tsx
+++ b/client/src/pages/signup/signup-component.tsx
@@ -5,11 +5,18 @@ import { api } from '../../lib/axios';
import { Auth } from '../../lib/auth';
import SignupHtml from './signup-html';
+/**
+ * Signup component handles user registration.
+ *
+ * @component
+ */
const Signup = () => {
interface Planet {
- _id: string;
- name: string;
+ _id: string; //The ID of the planet.
+ name: string; //The name of the planet.
}
+
+ const [loading, setLoading] = useState(false);
const [planets, setPlanets] = useState>([]);
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
@@ -17,11 +24,16 @@ const Signup = () => {
const [location, setLocation] = useState('');
const [_, navigate] = useLocation();
+ /**
+ * Fetches the list of planets from the API.
+ *
+ * @function fetchPlanets
+ * @returns {Promise} - A promise that resolves when the planets are fetched and state is updated.
+ */
useEffect(() => {
const fetchPlanets = async () => {
const { data: res } = await api.get('/planet');
try {
- // console.log(res);
setPlanets(res.value);
setLocation(res.value[0]._id);
} catch (error) {
@@ -32,7 +44,15 @@ const Signup = () => {
fetchPlanets();
}, []);
+ /**
+ * Handles form submission for user signup.
+ *
+ * @function submitForm
+ * @param {Object} e - The event object from the form submission.
+ * @returns {Promise} - A promise that resolves when the form is submitted and response is handled.
+ */
const submitForm = async (e: any) => {
+ setLoading(true);
e.preventDefault();
const geoLoc = await new Promise((res) => {
@@ -58,21 +78,12 @@ const Signup = () => {
const token = res.value;
Auth.saveToken(token);
navigate('/');
- toast.success('User created successfully');
+ toast.success('Account created successfully.');
} catch (err: any) {
- toast.error(`🦄 ${err.response.data.error}`, {
- position: 'top-right',
- hideProgressBar: false,
- closeOnClick: true,
- pauseOnHover: true,
- draggable: true,
- progress: undefined,
- theme: 'colored',
- });
+ toast.error(err.response.data.error);
+ } finally {
+ setLoading(false);
}
-
- // if (!res.success) throw res.error;
- // else alert('new user created');
};
return (
@@ -88,6 +99,7 @@ const Signup = () => {
setPassword={setPassword}
setLocation={setLocation}
submitForm={submitForm}
+ loading={loading}
/>
);
};
diff --git a/client/src/pages/signup/signup-html.tsx b/client/src/pages/signup/signup-html.tsx
index 15c9d516..80b8a0d5 100644
--- a/client/src/pages/signup/signup-html.tsx
+++ b/client/src/pages/signup/signup-html.tsx
@@ -1,34 +1,56 @@
-import { useLocation } from 'wouter';
+import { Link } from 'wouter';
import styles from './signup.module.css';
-import logoUrl from '../../assets/images/SkynetLogo.png';
+import logoUrl from '../../assets/images/skynet-logo.png';
import { GoogleAuthButton } from '../../components/google-auth-btn/google-auth-btn';
+import { Else, If, Then } from 'react-if';
+import { SmallLoader } from '../../components/loader/small-loader';
+import { useState } from 'react';
+import { FaEyeSlash, FaEye } from 'react-icons/fa';
+
+/**
+ * SignupHtml component handles the presentation layer for user signup.
+ *
+ * @component
+ * @param {Object} props - The properties object.
+ * @param {Array} props.planets - The list of planets.
+ * @param {string} props.username - The username entered by the user.
+ * @param {string} props.email - The email entered by the user.
+ * @param {string} props.password - The password entered by the user.
+ * @param {Function} props.setUsername - The function to update the username.
+ * @param {Function} props.setEmail - The function to update the email.
+ * @param {Function} props.setPassword - The function to update the password.
+ * @param {Function} props.setLocation - The function to update the location.
+ * @param {Function} props.submitForm - The function to handle form submission.
+ * @param {boolean} props.loading - The loading state to indicate form submission.
+ * @returns {JSX.Element} The rendered signup HTML component.
+ */
const SignupHtml = ({
planets,
username,
email,
password,
- location,
- setPlanets,
setUsername,
setEmail,
setPassword,
setLocation,
submitForm,
+ loading,
}: any) => {
- const [_, navigate] = useLocation();
+ const [showPassword, setShowPassword] = useState(false);
+
return (
-
+
SKY.NET
STAY CONNECTED ACROSS THE GALAXY
-
-
+
+
setEmail(e.target.value)}
/>
- setPassword(e.target.value)}
- />
+
+
+ setPassword(e.target.value)}
+ />
+
+ setShowPassword(!showPassword)}>
+ {showPassword ? : }
+
+
+
);
})}
- {/* Xenos Prime */}
- SIGN UP
+
+
+
+
+
+ SIGN UP
+
+
-
+
-
-
Already a User. Login Below
-
navigate('/login')}
- className={`${styles.signupButtonLogin} ${styles.button}`}>
- LOG IN
-
+
+
+ Already Registered? Login Instead
+
+
diff --git a/client/src/pages/signup/signup.module.css b/client/src/pages/signup/signup.module.css
index 45e5ec86..52e77845 100644
--- a/client/src/pages/signup/signup.module.css
+++ b/client/src/pages/signup/signup.module.css
@@ -1,16 +1,19 @@
+/* Styles for the container of the signup component */
.signup-container {
+ padding-bottom: 10px;
display: grid;
align-items: center;
justify-items: center;
}
+/* Styles for the signup form */
.signup-form {
background: var(--signup-background);
- padding: 29px 59px;
border: 2px solid black;
box-shadow: 4px 5px 0px #000;
}
+/* Styles for the upper div of the signup form */
.signup-upperdiv {
background: var(--signup-background);
padding: 4px 0px;
@@ -18,6 +21,7 @@
box-shadow: 4px 3px 0px #000;
}
+/* Styles for the bottom div of the signup form */
.signup-bottomdiv {
background: var(--signup-background);
padding: 4px 0px;
@@ -25,47 +29,61 @@
box-shadow: 4px 4px 0px #000;
}
+/* Styles for the login button within the signup form */
.signup-button-login {
box-shadow: 4px 5px gray !important;
background: black !important;
color: white !important;
}
+/* Styles for the active state of the login button within the signup form */
.signup-button-login:active {
- box-shadow: 4px 4px var(--signup-button-active) 5 !important;
+ box-shadow: 4px 4px var(--signup-button-active) 5 !important;
transform: translateY(5px) !important;
}
-input,
-.button,
-.select {
+/* Styles for input fields within the signup form */
+.signup-form input {
+ font-size: 20px;
+ font-family: Fjalla One;
+ padding-left: 5px;
+ border: 2px solid black;
+ outline: none;
+}
+
+/* Styles for buttons and select dropdowns within the signup form */
+.signup-form .button,
+.signup-form .select {
font-size: 14pt;
- font-family: 'BabaPro';
+ font-family: BabaPro;
background: var(--signup-background);
border: 2px solid black;
outline: none;
padding-left: 5px;
}
+/* Common styles for input elements */
.input {
display: block;
width: 100%;
line-height: 28pt;
- margin-bottom: 20pt;
box-shadow: 4px 5px;
}
+/* Common styles for buttons */
.button {
padding: 2px;
width: 50%;
box-shadow: 4px 5px;
}
+/* Styles for the active state of buttons */
.button:active {
box-shadow: 4px 4px var(--signup-button-active) 5;
transform: translateY(5px);
}
+/* Styles for select dropdowns */
.select {
width: 100%;
margin-bottom: 20pt;
@@ -73,22 +91,26 @@ input,
padding: 6px;
}
+/* Styles for images */
.img {
max-width: 250px !important;
}
+/* Styles for focused input fields */
.input:focus {
background: var(--signup-input-focus);
}
+/* Styles for heading elements */
.h1 {
font-family: Bitsumishi;
}
.h5 {
- font-family: BabaPro;
+ font-family: TTOctosquares;
}
+/* Styles for span elements */
.span {
- font-family: TTOctosquares;
+ font-family: TTOctosquares;
}
diff --git a/client/src/pages/support-page/support-page.module.css b/client/src/pages/support-page/support-page.module.css
new file mode 100644
index 00000000..0664a5d2
--- /dev/null
+++ b/client/src/pages/support-page/support-page.module.css
@@ -0,0 +1,8 @@
+.panel {
+ font-family: Fjalla One;
+ width: 75%;
+}
+
+.panel h1 {
+ font-family: Babapro;
+}
\ No newline at end of file
diff --git a/client/src/pages/support-page/support-page.tsx b/client/src/pages/support-page/support-page.tsx
new file mode 100644
index 00000000..b50fe5e1
--- /dev/null
+++ b/client/src/pages/support-page/support-page.tsx
@@ -0,0 +1,57 @@
+/* Stylesheet imports */
+import styles from './support-page.module.css';
+
+/* Imports from other components created */
+import Page from '../../components/page/page';
+import { Container } from 'react-bootstrap';
+
+/**
+ * Constructs and returns the Support page for this website in which users
+ * can contact someone in case of any errors while using the website.
+ *
+ * @returns the Support page as a JSX.Element.
+ */
+const SupportPage = () => {
+ return (
+ <>
+
+ Sky.net support
+ We are sad to see you had a problem with Sky.net. 😥
+
+
+ Please check our FAQ (Frequently Asked Questions) page first as your
+ request or problem may have already been answered by one of our professionals before.
+
+
+ If you could not find your problem on FAQ or you have any other problems, technical issues
+ or questions, feel free to contact us using our support contact:
+
+
+
+
+
+
+
+ *Phone call support only in Canada.
+
+
+ }
+ />
+ >
+ );
+};
+
+/**
+ * Exports the Support page for external use.
+ */
+export default SupportPage;
diff --git a/client/src/pages/test-page/test-page.module.css b/client/src/pages/test-page/test-page.module.css
deleted file mode 100644
index d5d291b2..00000000
--- a/client/src/pages/test-page/test-page.module.css
+++ /dev/null
@@ -1,4 +0,0 @@
-.page-container {
- max-width: 100vw;
- padding: 0%;
-}
diff --git a/client/src/pages/test-page/test-page.tsx b/client/src/pages/test-page/test-page.tsx
deleted file mode 100644
index 982a691a..00000000
--- a/client/src/pages/test-page/test-page.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import styles from './test-page.module.css';
-import Container from 'react-bootstrap/Container';
-
-import Header from '../../components/Header/Header';
-import Hotbar from '../../components/Hotbar/Hotbar';
-import Post from '../../components/Post/Post';
-
-// PAGE FOR TESTING COMPONENTS
-// Feel free to edit it
-const Test = () => {
- return (
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default Test;
diff --git a/client/src/pages/user-page/user-page.tsx b/client/src/pages/user-page/user-page.tsx
index 1c1bb809..6e78949b 100644
--- a/client/src/pages/user-page/user-page.tsx
+++ b/client/src/pages/user-page/user-page.tsx
@@ -1,57 +1,45 @@
-import styles from './user-page.module.css';
-import Page from '../../components/Page/Page';
-import Post from '../../components/Post/Post';
-import Profile from '../../components/Profile/Profile';
-import { useLocation, useParams } from 'wouter';
+/* Imports from React */
import { useEffect, useState } from 'react';
-import { api } from '../../lib/axios';
-import { isUser } from '../../lib/isUser';
-interface Post {
- authorId: string;
- commentCount: number;
- content: string;
- createdAt: Date;
- deleted: false;
- likeCount: number;
- location: {
- planetId: string;
- latitude: number;
- longitude: number;
- _id: string;
- };
- media: [];
- repostCount: number;
- __v: number;
- _id: string;
-}
+/* Import from wouter */
+import { useLocation, useParams } from 'wouter';
+
+/* Imports from other components created */
+import Page from '../../components/page/page';
+import Profile from '../../components/profile/profile';
+import { PaginatedPostFeed } from '../../components/paginated-post-feed/paginated-post-feed';
+import SEO from '../../components/seo/seo';
+/* Imports for frontend api call and authentication verification */
+import { isUser } from '../../lib/is-user';
+import { api } from '../../lib/axios';
+
+/**
+ * Constructs, manages, and returns the UserPage component, which
+ * represents the profile page of another specific user.
+ *
+ * @return The UserPage component as a JSX.Element
+ */
const UserPage = () => {
- const [username, setUsername] = useState('');
- const [follower, setFollower] = useState(0);
- const [following, setFollowing] = useState(0);
- const [postCount, setPostCount] = useState(0);
- const [displayedPosts, setDisplayedPosts] = useState(Array());
- const [userID, setUserID] = useState('');
+ const [userData, setUserData] = useState({ userName: '' });
const [_, navigate] = useLocation();
- let { id } = useParams() ?? '';
+ let { id = '' } = useParams();
+ /**
+ * Use effect used to check if it's the own user's profile page,
+ * and if so, redirects him to editable version of the profile page.
+ * If not, retrieves the data from a user and store it in a
+ * React state.
+ */
useEffect(() => {
const getUserData = async function () {
if (id !== undefined && (await isUser(id))) {
- navigate('/profile');
- return;
+ return navigate('/profile', { replace: true });
} else {
try {
if (id == '') return;
const res = await api.get('/user/' + id);
- const data = res.data.value;
- setUserID(data._id);
- setUsername(data.userName);
- setFollower(data.followerCount);
- setFollowing(data.followingCount);
- setPostCount(data.postCount);
- // setDisplayedPosts(await getPosts());
+ setUserData(res.data.value);
} catch (err) {
console.log(err);
}
@@ -61,57 +49,23 @@ const UserPage = () => {
getUserData();
}, []);
- useEffect(() => {
- const displayPosts = async function () {
- setDisplayedPosts(await getPosts());
- };
- displayPosts();
- }, [userID]);
-
- async function getPosts() {
- try {
- if (userID == '') return;
- const res = await api.get('/feed/' + userID);
- const postArray = res.data.value;
- console.log(postArray);
- let postElements = postArray.map((post: Post) => {
- return (
-
- );
- });
- if (postArray.length == 0) {
- postElements = [<>Nothing yet...>];
- }
- return postElements;
- } catch (err) {
- console.log(err);
- }
- }
-
return (
-
+
+
+
+ api.get(`/feed/${id}?page=${page}`).then((res) => res.data.value)}
/>
- {displayedPosts}
>
}
/>
diff --git a/client/src/pages/user-settings/options/change-email.tsx b/client/src/pages/user-settings/options/change-email-modal.tsx
similarity index 51%
rename from client/src/pages/user-settings/options/change-email.tsx
rename to client/src/pages/user-settings/options/change-email-modal.tsx
index 3b406aff..f9f2474f 100644
--- a/client/src/pages/user-settings/options/change-email.tsx
+++ b/client/src/pages/user-settings/options/change-email-modal.tsx
@@ -1,8 +1,16 @@
-import styles from '../user-settings.module.css';
+/* Stylesheet imports */
+import styles from '../user-settings-page.module.css';
-import ModalConfirmation from '../../../components/ModalConfirmation/ModalConfirmation';
+/* Imports from react-bootstrap */
import Button from 'react-bootstrap/Button';
+/* Imports from other components created */
+import ModalConfirmation from '../../../components/modal-confirmation/modal-confirmation';
+import UIBox from '../../../components/uibox/uibox';
+
+/**
+ * The properties and types for the ChangeEmailModal.
+ */
interface Props {
emailBody1: {
showEmailBody1: boolean;
@@ -21,9 +29,18 @@ interface Props {
};
}
+/**
+ * Contructs, manages, and returns the ChangeEmailModal component.
+ *
+ * @param props the props for this ChangeEmailModal, as seen outlined in the interface
+ * @returns The ChangeEmailModal component as a JSX.Element
+ */
const ChangeEmailModal = (props: Props) => {
+ /**
+ * Clears the current input feilds.
+ */
const clearFields = () => {
- props.emailBody2.setCurrEmail('');
+ props.emailBody2.setCurrEmail('');
props.emailBody2.setEmailInput('');
props.emailBody2.setConfEmailInput('');
};
@@ -66,33 +83,51 @@ const ChangeEmailModal = (props: Props) => {
<>
What would you like your new email to be?
- props.emailBody2.setCurrEmail(event.target.value)}
- required
+ props.emailBody2.setCurrEmail(event.target.value)}
+ required
+ />
+ }
/>
- props.emailBody2.setEmailInput(event.target.value)}
- required
+
+ props.emailBody2.setEmailInput(event.target.value)}
+ required
+ />
+ }
/>
- props.emailBody2.setConfEmailInput(event.target.value)}
- required
+
+ props.emailBody2.setConfEmailInput(event.target.value)}
+ required
+ />
+ }
/>
+
Change Email
@@ -116,4 +151,7 @@ const ChangeEmailModal = (props: Props) => {
);
};
+/**
+ * Exports the ChangeEmailModal for external use.
+ */
export default ChangeEmailModal;
diff --git a/client/src/pages/user-settings/options/change-password-modal.tsx b/client/src/pages/user-settings/options/change-password-modal.tsx
index eee3be1c..cc75e59e 100644
--- a/client/src/pages/user-settings/options/change-password-modal.tsx
+++ b/client/src/pages/user-settings/options/change-password-modal.tsx
@@ -1,11 +1,15 @@
-import { useState } from 'react';
-
-import styles from '../user-settings.module.css';
-
-import ModalConfirmation from '../../../components/ModalConfirmation/ModalConfirmation';
+/* Imports from react-bootstrap */
import Button from 'react-bootstrap/Button';
-import logoUrl from '../../../assets/images/SkynetLogo.png';
+/* Imports from other components created */
+import ModalConfirmation from '../../../components/modal-confirmation/modal-confirmation';
+import UIBox from '../../../components/uibox/uibox';
+import { useState } from 'react';
+import { FaEyeSlash, FaEye } from 'react-icons/fa';
+
+/**
+ * The properties and types for the ChangePasswordModal.
+ */
interface Props {
passBody1: {
showPassBody1: boolean;
@@ -24,7 +28,19 @@ interface Props {
};
}
+/**
+ * Contructs, manages, and returns the ChangePasswordModal component.
+ *
+ * @param props the props for this ChangePasswordModal, as seen outlined in the interface
+ * @returns The ChangePasswordModal component as a JSX.Element
+ */
const ChangePasswordModal = (props: Props) => {
+ const [showOriginalPassword, setShowOriginalPassword] = useState(false);
+ const [showNewPassword, setShowNewPassword] = useState(false);
+
+ /**
+ * Clears the current input feilds.
+ */
const clearFields = () => {
props.passBody2.setPassword('');
props.passBody2.setNewPassword('');
@@ -77,48 +93,87 @@ const ChangePasswordModal = (props: Props) => {
disableFooter={true}
header={
-
Change Password
}
body={
<>
-
-
- props.passBody2.setPassword(event.target.value)}
- required
- />
-
- props.passBody2.setNewPassword(event.target.value)}
- required
- />
-
- props.passBody2.setConfPassword(event.target.value)}
- required
- />
-
-
+
+
+
+ props.passBody2.setPassword(event.target.value)}
+ required
+ />
+ }
+ />
+
+ setShowOriginalPassword(!showOriginalPassword)}>
+ {showOriginalPassword ? : }
+
+
+
+
+ props.passBody2.setNewPassword(event.target.value)}
+ required
+ />
+ }
+ />
+
+ setShowNewPassword(!showNewPassword)}>
+ {showNewPassword ? : }
+
+
+
+
+
+ props.passBody2.setConfPassword(event.target.value)
+ }
+ required
+ />
+ }
+ />
+
+ setShowNewPassword(!showNewPassword)}>
+ {showNewPassword ? : }
+
+
+
+
- Change Password
+ Confirm
@@ -142,4 +197,7 @@ const ChangePasswordModal = (props: Props) => {
);
};
+/**
+ * Exports the ChangePasswordModal for external use.
+ */
export default ChangePasswordModal;
diff --git a/client/src/pages/user-settings/options/change-username.tsx b/client/src/pages/user-settings/options/change-username-modal.tsx
similarity index 64%
rename from client/src/pages/user-settings/options/change-username.tsx
rename to client/src/pages/user-settings/options/change-username-modal.tsx
index 87672f5b..fcf9832f 100644
--- a/client/src/pages/user-settings/options/change-username.tsx
+++ b/client/src/pages/user-settings/options/change-username-modal.tsx
@@ -1,8 +1,16 @@
-import styles from '../user-settings.module.css';
+/* Stylesheet imports */
+import styles from '../user-settings-page.module.css';
-import ModalConfirmation from '../../../components/ModalConfirmation/ModalConfirmation';
+/* Imports from other components created */
+import ModalConfirmation from '../../../components/modal-confirmation/modal-confirmation';
+import UIBox from '../../../components/uibox/uibox';
+
+/* Imports from react-bootstrap */
import Button from 'react-bootstrap/Button';
+/**
+ * The properties and types for the ChangeNameModal.
+ */
interface Props {
usernameBody1: {
showNameBody1: boolean;
@@ -17,7 +25,16 @@ interface Props {
};
}
+/**
+ * Contructs, manages, and returns the ChangeNameModal component.
+ *
+ * @param props the props for this ChangeNameModal, as seen outlined in the interface
+ * @returns The ChangeNameModal component as a JSX.Element
+ */
const ChangeNameModal = (props: Props) => {
+ /**
+ * Clears the current input feilds.
+ */
const clearFields = () => {
props.usernameBody2.setNameInput('');
};
@@ -60,14 +77,19 @@ const ChangeNameModal = (props: Props) => {
<>
What would you like your new username to be?
- props.usernameBody2.setNameInput(event.target.value)}
- required
+ props.usernameBody2.setNameInput(event.target.value)}
+ required
+ />
+ }
/>
@@ -92,4 +114,7 @@ const ChangeNameModal = (props: Props) => {
);
};
+/**
+ * Exports the ChangeNameModal for external use.
+ */
export default ChangeNameModal;
diff --git a/client/src/pages/user-settings/options/commented.tsx b/client/src/pages/user-settings/options/commented.tsx
new file mode 100644
index 00000000..7f7500f6
--- /dev/null
+++ b/client/src/pages/user-settings/options/commented.tsx
@@ -0,0 +1,39 @@
+/* Import from React */
+import { useContext } from 'react';
+
+/* Import for the authorized user context */
+import { UserAuthContext } from '../../../lib/auth';
+
+/* Imports from other components created */
+import Page from '../../../components/page/page';
+import { PaginatedPostFeed } from '../../../components/paginated-post-feed/paginated-post-feed';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../../lib/axios';
+
+/**
+ * Constructs, manages, and returns the CommentedPostPage component.
+ *
+ * @return The CommentedPostPage component as a JSX.Element
+ */
+const CommentedPostPage = () => {
+ const user = useContext(UserAuthContext);
+
+ return (
+
+
+ api.get(`/user/commented?page=${page}`).then((res) => res.data.value.commentedPosts)
+ }
+ />
+ >
+ }
+ />
+ );
+};
+
+export default CommentedPostPage;
diff --git a/client/src/pages/user-settings/options/delete-account-modal.tsx b/client/src/pages/user-settings/options/delete-account-modal.tsx
index 694fa0ac..46595a2c 100644
--- a/client/src/pages/user-settings/options/delete-account-modal.tsx
+++ b/client/src/pages/user-settings/options/delete-account-modal.tsx
@@ -1,8 +1,19 @@
-import styles from '../user-settings.module.css';
+/* Stylesheet imports */
+import styles from '../user-settings-page.module.css';
-import ModalConfirmation from '../../../components/ModalConfirmation/ModalConfirmation';
+/* Imports from other components created */
+import ModalConfirmation from '../../../components/modal-confirmation/modal-confirmation';
+
+/* Imports from react-bootstrap */
import Button from 'react-bootstrap/Button';
+import UIBox from '../../../components/uibox/uibox';
+
+/* Import from react toastify */
+import { toast } from 'react-toastify';
+/**
+ * The properties and types for the DeleteAccountModal.
+ */
interface Props {
deleteBody1: {
showDeleteBody1: boolean;
@@ -15,9 +26,22 @@ interface Props {
confInput: string;
setConfInput: any;
};
+ deleteBody3: {
+ showDeleteBody3: boolean;
+ setShowDelete3: any;
+ };
}
+/**
+ * Contructs, manages, and returns the DeleteAccountModal component.
+ *
+ * @param props the props for this DeleteAccountModal, as seen outlined in the interface
+ * @returns The DeleteAccountModal component as a JSX.Element
+ */
const DeleteAccountModal = (props: Props) => {
+ /**
+ * Clears the current input feilds.
+ */
const clearFields = () => {
props.deleteBody2.setConfInput('');
};
@@ -71,31 +95,83 @@ const DeleteAccountModal = (props: Props) => {
I-WANT-TO-DELETE-THIS-ACCOUNT
-
- props.deleteBody2.setConfInput(event.target.value)}
- required
- />
-
-
- DELETE ACCOUNT
-
-
{
+ props.deleteBody2.setConfInput(event.target.value)}
+ required
+ />
+ }
+ />
+
+
+ {
+ props.deleteBody2.setShowDelete2(false);
+ clearFields();
+ }}>
+ Cancel
+
+ {
+ if (props.deleteBody2.confInput === 'I-WANT-TO-DELETE-THIS-ACCOUNT') {
props.deleteBody2.setShowDelete2(false);
- clearFields();
- }}>
- Cancel
-
-
-
+ props.deleteBody3.setShowDelete3(true);
+ } else {
+ toast.error('The phrase given does not match.');
+ }
+ }}>
+ DELETE ACCOUNT
+
+
+ >
+ }
+ disableFooter={true}
+ />
+
+ props.deleteBody3.setShowDelete3(false)}
+ body={
+ <>
+
+ By clicking "DELETE ACCOUNT" below you account will be{' '}
+ permenatly deleted after 30 days .
+
+
+ If you wish to undo this contact: support@skynetwork.app
+
+
+ If you do not wish to proceed click "CANCEL" .
+
+
+ {
+ props.deleteBody3.setShowDelete3(false);
+ clearFields();
+ }}>
+ Cancel
+
+
+ DELETE ACCOUNT
+
+
>
}
disableFooter={true}
@@ -104,4 +180,7 @@ const DeleteAccountModal = (props: Props) => {
);
};
+/**
+ * Exports the DeleteAccountModal for external use.
+ */
export default DeleteAccountModal;
diff --git a/client/src/pages/user-settings/options/liked.tsx b/client/src/pages/user-settings/options/liked.tsx
new file mode 100644
index 00000000..1d2245be
--- /dev/null
+++ b/client/src/pages/user-settings/options/liked.tsx
@@ -0,0 +1,61 @@
+/* Imported from React */
+import { useContext } from 'react';
+
+/* Import for the authorized user context */
+import { UserAuthContext } from '../../../lib/auth';
+
+/* Imports from other components created */
+import Page from '../../../components/page/page';
+import { PaginatedPostFeed } from '../../../components/paginated-post-feed/paginated-post-feed';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../../lib/axios';
+
+// Define the Post interface
+interface PostType {
+ _id: string;
+ authorId: string;
+ content: string;
+ likeCount: number;
+ commentCount: number;
+ repostCount: number;
+ location: {
+ planetId: string;
+ latitude: number;
+ longitude: number;
+ _id: string;
+ };
+ media: any[];
+ createdAt: Date;
+ deleted: boolean;
+ parentPost?: string;
+ userName: string;
+ avatar: string;
+}
+
+/**
+ * Constructs, manages, and returns the LikedPage component.
+ *
+ * @return The LikedPage component as a JSX.Element
+ */
+const LikedPage = () => {
+ const user = useContext(UserAuthContext);
+
+ return (
+
+
+ api.get(`/user/liked?page=${page}`).then((res) => res.data.value.likedPosts)
+ }
+ />
+ >
+ }
+ />
+ );
+};
+
+export default LikedPage;
diff --git a/client/src/pages/user-settings/options/manage-account.tsx b/client/src/pages/user-settings/options/manage-account-page.tsx
similarity index 74%
rename from client/src/pages/user-settings/options/manage-account.tsx
rename to client/src/pages/user-settings/options/manage-account-page.tsx
index 82c8b86a..b97cdc00 100644
--- a/client/src/pages/user-settings/options/manage-account.tsx
+++ b/client/src/pages/user-settings/options/manage-account-page.tsx
@@ -1,17 +1,29 @@
-import { useState } from 'react';
-import { toast } from 'react-toastify';
-import { api } from '../../../lib/axios';
+/* Stylesheet imports */
+import styles from '../user-settings-page.module.css';
+
+/* Import from wouter */
import { useLocation } from 'wouter';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../../lib/axios';
import { Auth } from '../../../lib/auth';
-import styles from '../user-settings.module.css';
+/* Imports from React */
+import { useState } from 'react';
+import { toast } from 'react-toastify';
+/* Imports from react-bootstrap */
import ListGroup from 'react-bootstrap/ListGroup';
-import ChangePasswordModal from '../options/change-password-modal';
-import DeleteAccountModal from '../options/delete-account-modal';
-import ChangeNameModal from './change-username';
-import ChangeEmailModal from './change-email';
-import Page from '../../../components/Page/Page';
+
+/* Icon imports from react-icons */
+import { MdOutlineArrowForwardIos } from 'react-icons/md';
+
+/* Imports from other components created */
+import ChangePasswordModal from './change-password-modal';
+import DeleteAccountModal from './delete-account-modal';
+import ChangeNameModal from './change-username-modal';
+import ChangeEmailModal from './change-email-modal';
+import Page from '../../../components/page/page';
const ManageAccount = () => {
const [_, setLocation] = useLocation();
@@ -23,6 +35,7 @@ const ManageAccount = () => {
// variables responsible for showing delete account modals
const [showDeleteBody1, setShowDelete1] = useState(false);
const [showDeleteBody2, setShowDelete2] = useState(false);
+ const [showDeleteBody3, setShowDelete3] = useState(false);
// variables responsible for showing change username modals
const [showNameBody1, setNameBody1] = useState(false);
@@ -48,6 +61,11 @@ const ManageAccount = () => {
const [emailInput, setEmailInput] = useState('');
const [confEmailInput, setConfEmailInput] = useState('');
+ /**
+ * Handles the patch request to change the user's password with the help of axios.
+ *
+ * @param event the form event from onSubmit.
+ */
const changePassword = async (event: React.FormEvent) => {
event.preventDefault();
try {
@@ -63,8 +81,12 @@ const ManageAccount = () => {
}
};
- const deleteAccount = async (event: React.FormEvent) => {
- event.preventDefault();
+ /**
+ * Handles the delete request to delete the user's account with the help of axios.
+ *
+ * @param event the form event from onSubmit.
+ */
+ const deleteAccount = async () => {
try {
const response = await api.post('/user/deleteaccount/delete', {
confirmationInput: confInput,
@@ -74,9 +96,16 @@ const ManageAccount = () => {
logout();
} catch (error: any) {
toast.error(error.response.data.error);
+ setShowDelete3(false);
+ setShowDelete2(true);
}
};
+ /**
+ * Handles the patch request to change the user's username with the help of axios.
+ *
+ * @param event the form event from onSubmit.
+ */
const changeUsername = async (event: React.FormEvent) => {
event.preventDefault();
try {
@@ -90,6 +119,11 @@ const ManageAccount = () => {
}
};
+ /**
+ * Handles the patch request to change the user's email with the help of axios.
+ *
+ * @param event the form event from onSubmit.
+ */
const changeEmail = async (event: React.FormEvent) => {
event.preventDefault();
try {
@@ -105,6 +139,9 @@ const ManageAccount = () => {
}
};
+ /**
+ * Reseta all inputs and visibility statuses of the modals.
+ */
const wrapUpModal = () => {
setPassword('');
setNewPassword('');
@@ -120,6 +157,9 @@ const ManageAccount = () => {
setEmailBody2(false);
};
+ /**
+ * Logs the user out.
+ */
const logout = () => [Auth.loseToken(), setLocation('/login')];
// defining values for the Change Password Modal
@@ -151,6 +191,10 @@ const ManageAccount = () => {
confInput: confInput,
setConfInput: setConfInput,
};
+ const deleteBody3 = {
+ showDeleteBody3: showDeleteBody3,
+ setShowDelete3: setShowDelete3,
+ };
// defining values for the change username Modal
const usernameBody1 = {
@@ -188,29 +232,42 @@ const ManageAccount = () => {
logoHeader={false}
pageName='Manage Account'
content={
-
+
Danger Zone
setNameBody1(true)}>
- Change Username
+
+
setShowPass1(true)}>
- Change Password
+ onClick={() => setEmailBody1(true)}>
+
setEmailBody1(true)}>
- Change Email
+ onClick={() => setShowPass1(true)}>
+
setShowDelete1(true)}>
- DELETE ACCOUNT
+
@@ -219,11 +276,14 @@ const ManageAccount = () => {
/>
-
+
>
);
};
+/**
+ * Exports the user manage account page for external use.
+ */
export default ManageAccount;
diff --git a/client/src/pages/user-settings/options/saved.tsx b/client/src/pages/user-settings/options/saved.tsx
new file mode 100644
index 00000000..19ed949c
--- /dev/null
+++ b/client/src/pages/user-settings/options/saved.tsx
@@ -0,0 +1,60 @@
+/* Imported from React */
+import { useContext, useEffect, useState } from 'react';
+
+/* Import for the authorized user context */
+import { UserAuthContext } from '../../../lib/auth';
+
+/* Imports from other components created */
+import Page from '../../../components/page/page';
+import { PaginatedPostFeed } from '../../../components/paginated-post-feed/paginated-post-feed';
+
+/* Imports for frontend api call and authentication verification */
+import { api } from '../../../lib/axios';
+
+// Define the Post interface
+interface PostType {
+ _id: string;
+ authorId: string;
+ content: string;
+ likeCount: number;
+ commentCount: number;
+ repostCount: number;
+ location: {
+ planetId: string;
+ latitude: number;
+ longitude: number;
+ _id: string;
+ };
+ media: any[];
+ createdAt: Date;
+ deleted: boolean;
+ parentPost: string;
+ userName: string;
+ avatar: string;
+}
+/**
+ * Constructs, manages, and returns the SavedPage component.
+ *
+ * @return The SavedPage component as a JSX.Element
+ */
+const SavedPage = () => {
+ const user = useContext(UserAuthContext);
+
+ return (
+
+
+ api.get(`/user/saved?page=${page}`).then((res) => res.data.value.savedPosts)
+ }
+ />
+ >
+ }
+ />
+ );
+};
+
+export default SavedPage;
diff --git a/client/src/pages/user-settings/options/your-info.tsx b/client/src/pages/user-settings/options/your-info-modal.tsx
similarity index 52%
rename from client/src/pages/user-settings/options/your-info.tsx
rename to client/src/pages/user-settings/options/your-info-modal.tsx
index 8deb016c..3f9ebe47 100644
--- a/client/src/pages/user-settings/options/your-info.tsx
+++ b/client/src/pages/user-settings/options/your-info-modal.tsx
@@ -1,10 +1,19 @@
+/* Imports from React */
import { useContext } from 'react';
+import { Else, If, Then } from 'react-if';
-import ModalConfirmation from '../../../components/ModalConfirmation/ModalConfirmation';
+/* Imports from react-bootstrap */
import Button from 'react-bootstrap/Button';
+
+/* Import for the authorized user context */
import { UserAuthContext } from '../../../lib/auth';
-import { Else, If, Then } from 'react-if';
+/* Imports from other components created */
+import ModalConfirmation from '../../../components/modal-confirmation/modal-confirmation';
+
+/**
+ * The properties and types for the YourInfoModal.
+ */
interface Props {
infoBody: {
showInfoBody: boolean;
@@ -12,8 +21,19 @@ interface Props {
};
}
+/**
+ * Contructs, manages, and returns the YourInfoModal component.
+ *
+ * @param props the props for this YourInfoModal, as seen outlined in the interface
+ * @returns The YourInfoModal component as a JSX.Element
+ */
const YourInfoModal = (props: Props) => {
const user = useContext(UserAuthContext);
+ const joinedDateFmt = new Intl.DateTimeFormat(navigator.language, {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ });
return (
<>
@@ -26,7 +46,11 @@ const YourInfoModal = (props: Props) => {
Username: {user.userName}
Email: {user.email ?? 'Authenticated with Provider'}
- Bio: {user.bio ?? 'No Bio Provided'}
+ Bio: {user.bio ? user.bio : 'No Bio Provided'}
+ Followers: {user.followerCount}
+ Following: {user.followingCount}
+ User Type: {!user.admin ? 'Basic user' : 'Administrator'}
+ Account Created: {joinedDateFmt.format(new Date(user.createdAt))}
An error occured.
@@ -37,7 +61,6 @@ const YourInfoModal = (props: Props) => {
footer={
{
props.infoBody.setInfoBody(false);
@@ -51,4 +74,7 @@ const YourInfoModal = (props: Props) => {
);
};
+/**
+ * Exports the YourInfoModal for external use.
+ */
export default YourInfoModal;
diff --git a/client/src/pages/user-settings/user-settings-page.module.css b/client/src/pages/user-settings/user-settings-page.module.css
new file mode 100644
index 00000000..f5458526
--- /dev/null
+++ b/client/src/pages/user-settings/user-settings-page.module.css
@@ -0,0 +1,90 @@
+.setting-body {
+ width: 100%;
+ font-size: x-large;
+ font-family: BabaPro;
+}
+
+.setting-body p {
+ margin: 0;
+}
+
+.setting-title {
+ font-size: 30pt;
+ font-family: Bitsumishi;
+}
+
+.warning-color {
+ color: red !important;
+}
+
+.danger-zone {
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+}
+
+.danger-zone,
+.danger-zone * {
+ background-color: #242c38;
+ color: red !important;
+}
+
+.group-item-holder {
+ margin-bottom: 30px;
+ margin-left: 5px;
+ margin-right: 5px;
+ font-size: 16pt;
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+}
+
+.group-item-holder,
+.group-item-holder * {
+ color: black;
+ background-color: var(--signup-background);
+}
+
+.group-item {
+ border-color: gray;
+}
+
+.group-item-body {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.logout-btn {
+ border: 2px solid black;
+ box-shadow: 4px 5px 0px #000;
+}
+
+.logout-btn h1 {
+ margin: 0;
+}
+
+.logout-btn,
+.logout-btn > * {
+ background-color: #242c38;
+ color: red;
+}
+
+.logout-btn:active {
+ box-shadow: 4px 4px var(--signup-button-active) 5 !important;
+ transform: translateY(5px) !important;
+}
+
+.conf-input {
+ font-family: Fjalla One;
+}
+
+.red {
+ font-weight: bold;
+ color: red;
+}
+
+@media screen and (min-width: 800px) {
+ .setting-body {
+ width: 50%;
+ }
+}
diff --git a/client/src/pages/user-settings/user-settings.tsx b/client/src/pages/user-settings/user-settings-page.tsx
similarity index 78%
rename from client/src/pages/user-settings/user-settings.tsx
rename to client/src/pages/user-settings/user-settings-page.tsx
index 544bda89..c9a707d3 100644
--- a/client/src/pages/user-settings/user-settings.tsx
+++ b/client/src/pages/user-settings/user-settings-page.tsx
@@ -1,15 +1,31 @@
-import { useState } from 'react';
+/* Stylesheet imports */
+import styles from './user-settings-page.module.css';
+
+/* Import from wouter */
import { useLocation } from 'wouter';
-import styles from './user-settings.module.css';
+/* Import for JWT token authorization */
+import { Auth } from '../../lib/auth';
+
+/* Imports from React */
+import { useState } from 'react';
+/* Imports from react-bootstrap */
import ListGroup from 'react-bootstrap/ListGroup';
import Nav from 'react-bootstrap/Nav';
+
+/* Icon imports from react-icons */
import { MdOutlineArrowForwardIos } from 'react-icons/md';
-import Page from '../../components/Page/Page';
-import YourInfoModal from './options/your-info';
-import { Auth } from '../../lib/auth';
+/* Imports from other components created */
+import Page from '../../components/page/page';
+import YourInfoModal from './options/your-info-modal';
+
+/**
+ * Constructs, manages, and returns the user settings page.
+ *
+ * @returns the user settings page as a JSX.Element
+ */
const UserSettings = () => {
const [_, setLocation] = useLocation();
@@ -21,7 +37,6 @@ const UserSettings = () => {
// calls loseToken() and logs the user out
function logout() {
- console.log('here');
Auth.loseToken();
setLocation('/login');
}
@@ -43,13 +58,13 @@ const UserSettings = () => {
Account
-
+
Followers
-
+
Following
@@ -76,20 +91,20 @@ const UserSettings = () => {
History
-
+
Saved
-
+
Liked
-
- Commented Posts
+
+ Commented
@@ -105,13 +120,13 @@ const UserSettings = () => {
-
+
FAQs
-
+
Support
@@ -134,4 +149,7 @@ const UserSettings = () => {
);
};
+/**
+ * Exports the user settings page for external use.
+ */
export default UserSettings;
diff --git a/client/src/pages/user-settings/user-settings.module.css b/client/src/pages/user-settings/user-settings.module.css
deleted file mode 100644
index d37f6d5f..00000000
--- a/client/src/pages/user-settings/user-settings.module.css
+++ /dev/null
@@ -1,75 +0,0 @@
-.setting-body {
- width: 100%;
- font-size: x-large;
- font-family: BabaPro;
-}
-
-.setting-body p {
- margin: 0;
-}
-
-.setting-title {
- font-size: 30pt;
- font-family: Bitsumishi;
-}
-
-.warning-color {
- color: red !important;
-}
-
-.danger-zone {
- border: 2px solid black;
- box-shadow: 4px 5px 0px #000;
-}
-
-.danger-zone, .danger-zone * {
- background-color: #242C38;
- color: red !important;
-}
-
-.group-item-holder {
- margin-bottom: 30px;
- margin-left: 5px;
- margin-right: 5px;
- font-size: 16pt;
- border: 2px solid black;
- box-shadow: 4px 5px 0px #000;
-}
-
-.group-item-holder, .group-item-holder * {
- color: black;
- background-color: var(--signup-background);
-}
-
-.group-item {
- border-color: gray;
-}
-
-.group-item-body {
- width: 100%;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.clickable {
- cursor: pointer;
-}
-
-.logout-btn {
- border: 2px solid black;
- box-shadow: 4px 5px 0px #000;
-}
-
-.logout-btn h1 {
- margin: 0;
-}
-
-.logout-btn, .logout-btn > * {
- background-color: #242C38;
- color: red;
-}
-
-.conf-input {
- font-family: Fjalla One;
-}
\ No newline at end of file
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 35263a69..2a69497e 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -11,6 +11,7 @@
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"isolatedModules": true,
- "resolveJsonModule": true
+ "resolveJsonModule": true,
+ "forceConsistentCasingInFileNames": true
}
}
diff --git a/client/vite.config.ts b/client/vite.config.ts
index 343eee04..f5637437 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -1,3 +1,9 @@
+/**
+ * Configuration file for Vite build tool.
+ * Defines plugins, CSS options, and server configuration.
+ * Loads environment variables from .env file using dotenv.
+ * @returns {import('vite').UserConfig}
+ */
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import dotenv from 'dotenv';
@@ -5,8 +11,23 @@ import dotenv from 'dotenv';
dotenv.config();
export default defineConfig({
+
+ /**
+ * Plugins used by Vite during the build process.
+ * Configures the react plugin for handling React JSX.
+ */
plugins: [react()],
+
+ /**
+ * CSS configuration options.
+ * Enables CSS modules and sets localsConvention to 'camelCaseOnly'.
+ */
css: { modules: { localsConvention: 'camelCaseOnly' } },
+
+ /**
+ * Server configuration options.
+ * Sets the port from environment variable VITE_PORT.
+ */
server: {
port: parseInt(process.env.VITE_PORT!),
},
diff --git a/server/index.ts b/server/index.ts
index 1390faad..cbed472f 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -20,27 +20,25 @@ const PROJECT_ROOT = path.join(__dirname, 'src');
});
io.on('connection', (socket) => {
- // console.log('New client connected');
-
socket.on('disconnect', () => {
// console.log('Client disconnected');
});
socket.on('sendID', (convoID: string) => {
+ console.log("sendID", convoID)
socket.join(convoID);
});
- socket.on('sendMessage', (convoID: string) => {
- socket.to(convoID).emit('displayMessage');
- });
+ /* socket.on('sendMessage', (convoID: string) => {
+ socket.to(convoID).emit('displayMessage');
+ }); */
});
app.set('socketio', io);
-
app.use(cors());
- app.use(express.urlencoded({ extended: true }));
- app.use(express.json());
+ app.use(express.urlencoded({ extended: true, limit: '5mb', parameterLimit: 100 }));
+ app.use(express.json({ limit: '5mb' }));
app.use(requestLogger);
const mongoUrl = process.env.MONGO_URL!;
diff --git a/server/package-lock.json b/server/package-lock.json
index 3a759d5c..fb7d02bb 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -7,6 +7,7 @@
"name": "skynet-server",
"dependencies": {
"bcrypt": "^5.1.1",
+ "cloudinary": "^2.2.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"esno": "^4.7.0",
@@ -20,8 +21,10 @@
"mongoose": "^8.3.3",
"morgan": "^1.10.0",
"nodemailer": "^6.9.13",
+ "openai": "^4.47.1",
"picocolors": "^1.0.1",
- "socket.io": "^4.7.5"
+ "socket.io": "^4.7.5",
+ "uuid": "^9.0.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
@@ -31,6 +34,7 @@
"@types/morgan": "^1.9.9",
"@types/node": "^20.12.8",
"@types/nodemailer": "^6.4.15",
+ "@types/uuid": "^9.0.8",
"typescript": "^5.4.5"
}
},
@@ -545,6 +549,15 @@
"undici-types": "~5.26.4"
}
},
+ "node_modules/@types/node-fetch": {
+ "version": "2.6.11",
+ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz",
+ "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==",
+ "dependencies": {
+ "@types/node": "*",
+ "form-data": "^4.0.0"
+ }
+ },
"node_modules/@types/nodemailer": {
"version": "6.4.15",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz",
@@ -587,6 +600,12 @@
"@types/send": "*"
}
},
+ "node_modules/@types/uuid": {
+ "version": "9.0.8",
+ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
+ "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==",
+ "dev": true
+ },
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
@@ -605,6 +624,17 @@
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
},
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -649,6 +679,17 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
+ "node_modules/agentkeepalive": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz",
+ "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==",
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -680,6 +721,11 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -828,6 +874,18 @@
"node": ">=10"
}
},
+ "node_modules/cloudinary": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.2.0.tgz",
+ "integrity": "sha512-akbLTZcNegGSkl07Frnt9fyiK9KZ2zPS+a+j7uLrjNYxVhDpDdIBz9G6snPCYqgk+WLVMRPfXTObalLr5L6g0Q==",
+ "dependencies": {
+ "lodash": "^4.17.21",
+ "q": "^1.5.1"
+ },
+ "engines": {
+ "node": ">=9"
+ }
+ },
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -836,6 +894,17 @@
"color-support": "bin.js"
}
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -914,6 +983,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -1118,6 +1195,14 @@
"node": ">= 0.6"
}
},
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/express": {
"version": "4.19.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
@@ -1189,6 +1274,44 @@
"node": ">= 0.8"
}
},
+ "node_modules/form-data": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+ "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/form-data-encoder": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+ "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="
+ },
+ "node_modules/formdata-node": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+ "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+ "dependencies": {
+ "node-domexception": "1.0.0",
+ "web-streams-polyfill": "4.0.0-beta.3"
+ },
+ "engines": {
+ "node": ">= 12.20"
+ }
+ },
+ "node_modules/formdata-node/node_modules/web-streams-polyfill": {
+ "version": "4.0.0-beta.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+ "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1629,6 +1752,14 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -1755,6 +1886,11 @@
"node": ">=12.0.0"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+ },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -2085,6 +2221,24 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
},
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -2200,6 +2354,32 @@
"wrappy": "1"
}
},
+ "node_modules/openai": {
+ "version": "4.47.1",
+ "resolved": "https://registry.npmjs.org/openai/-/openai-4.47.1.tgz",
+ "integrity": "sha512-WWSxhC/69ZhYWxH/OBsLEirIjUcfpQ5+ihkXKp06hmeYXgBBIUCa9IptMzYx6NdkiOCsSGYCnTIsxaic3AjRCQ==",
+ "dependencies": {
+ "@types/node": "^18.11.18",
+ "@types/node-fetch": "^2.6.4",
+ "abort-controller": "^3.0.0",
+ "agentkeepalive": "^4.2.1",
+ "form-data-encoder": "1.7.2",
+ "formdata-node": "^4.3.2",
+ "node-fetch": "^2.6.7",
+ "web-streams-polyfill": "^3.2.1"
+ },
+ "bin": {
+ "openai": "bin/cli"
+ }
+ },
+ "node_modules/openai/node_modules/@types/node": {
+ "version": "18.19.33",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.33.tgz",
+ "integrity": "sha512-NR9+KrpSajr2qBVp/Yt5TU/rp+b5Mayi3+OlMlcg2cVCfRmcG5PWZ7S4+MG9PZ5gWBoc9Pd0BKSRViuBCRPu0A==",
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2246,6 +2426,15 @@
"node": ">=6"
}
},
+ "node_modules/q": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
+ "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==",
+ "engines": {
+ "node": ">=0.6.0",
+ "teleport": ">=0.2.0"
+ }
+ },
"node_modules/qs": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
@@ -2725,6 +2914,14 @@
"node": ">= 0.8"
}
},
+ "node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
diff --git a/server/package.json b/server/package.json
index e350f3c1..82b81f6b 100644
--- a/server/package.json
+++ b/server/package.json
@@ -8,6 +8,7 @@
},
"dependencies": {
"bcrypt": "^5.1.1",
+ "cloudinary": "^2.2.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"esno": "^4.7.0",
@@ -21,8 +22,10 @@
"mongoose": "^8.3.3",
"morgan": "^1.10.0",
"nodemailer": "^6.9.13",
+ "openai": "^4.47.1",
"picocolors": "^1.0.1",
- "socket.io": "^4.7.5"
+ "socket.io": "^4.7.5",
+ "uuid": "^9.0.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
@@ -32,6 +35,7 @@
"@types/morgan": "^1.9.9",
"@types/node": "^20.12.8",
"@types/nodemailer": "^6.4.15",
+ "@types/uuid": "^9.0.8",
"typescript": "^5.4.5"
}
}
diff --git a/server/src/models/comment-relationship.ts b/server/src/models/comment-relationship.ts
deleted file mode 100644
index 0dd6168d..00000000
--- a/server/src/models/comment-relationship.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { model, Schema, Types } from 'mongoose';
-
-export interface ICommentRelationship {
- parentPost: Types.ObjectId;
- childPost: Types.ObjectId;
-}
-
-const schema = new Schema(
- {
- parentPost: { type: 'ObjectID', ref: 'Post', required: true, index: true },
- childPost: { type: 'ObjectID', ref: 'Post', required: true, index: true },
- },
- { timestamps: { createdAt: true, updatedAt: false } },
-);
-
-export const CommentRelationship = model('CommentRelationship', schema);
diff --git a/server/src/models/conversation.ts b/server/src/models/conversation.ts
index 8d9f4a02..75c1bfd3 100644
--- a/server/src/models/conversation.ts
+++ b/server/src/models/conversation.ts
@@ -19,4 +19,10 @@ const schema = new Schema(
{ timestamps: { createdAt: true, updatedAt: false } },
);
-export const ConversationModel = model('Conversation', schema);
\ No newline at end of file
+/**
+ * The model representing the "conversations" collection in MongoDB.
+ *
+ * This holds all initiated chat conversations between users, but not
+ * the messages themselves.
+ */
+export const ConversationModel = model('Conversation', schema);
diff --git a/server/src/models/deletedUser.ts b/server/src/models/deletedUser.ts
index a4567dd7..3011b4f8 100644
--- a/server/src/models/deletedUser.ts
+++ b/server/src/models/deletedUser.ts
@@ -2,6 +2,12 @@ import { model, ObjectId, Schema, Types } from 'mongoose';
import { ILocation, LocationSchema } from './location';
import { AuthProvider, SSOSchema } from './user';
+
+/**
+ * The number seconds until the deleted user expires.
+ */
+const expireTime = 2592000;
+
interface IDeletedUser {
originID: ObjectId;
email?: string;
@@ -16,6 +22,7 @@ interface IDeletedUser {
followingCount: number;
postCount: number;
admin: boolean;
+ deleted: boolean;
savedPosts: Array;
createdAt: Date;
}
@@ -36,9 +43,17 @@ const schema = new Schema(
postCount: { type: 'number', required: true, default: 0 },
savedPosts: { type: ['ObjectId'], required: true, default: [] },
admin: { type: 'boolean', required: true, default: false },
- // createdAt: {type: Date, default: new Date(), expires: 2592000}
- createdAt: {type: Date, default: new Date()}
- }
+ deleted: { type: 'boolean', required: true },
+ },
+ { timestamps: true },
);
+schema.index({createdAt: 1}, {expireAfterSeconds: expireTime});
+
+/**
+ * The model representing the "deletedusers" collection in MongoDB.
+ *
+ * This holds records of all deleted users that were anonomized on
+ * the main "users" collection.
+ */
export const DeletedUserModel = model('DeletedUser', schema);
diff --git a/server/src/models/follow-relationship.ts b/server/src/models/follow-relationship.ts
index bc472efe..46caf2c9 100644
--- a/server/src/models/follow-relationship.ts
+++ b/server/src/models/follow-relationship.ts
@@ -13,4 +13,9 @@ const schema = new Schema(
{ timestamps: { createdAt: true, updatedAt: false } },
);
+/**
+ * The model representing the "followrelationships" collection in MongoDB.
+ *
+ * This holds follows between users.
+ */
export const FollowRelationship = model('FollowRelationship', schema);
diff --git a/server/src/models/like-interaction.ts b/server/src/models/like-interaction.ts
index 1a782b49..3478962e 100644
--- a/server/src/models/like-interaction.ts
+++ b/server/src/models/like-interaction.ts
@@ -13,4 +13,9 @@ const schema = new Schema(
{ timestamps: { createdAt: true, updatedAt: false } },
);
+/**
+ * The model representing the "likeinteractions" collection in MongoDB.
+ *
+ * This holds likes on posts made by users.
+ */
export const LikeInteraction = model('LikeInteraction', schema);
diff --git a/server/src/models/message.ts b/server/src/models/message.ts
index 7c981b78..32e8fd16 100644
--- a/server/src/models/message.ts
+++ b/server/src/models/message.ts
@@ -3,7 +3,7 @@ import { IMedia, MediaSchema } from './media';
import { ILocation, LocationSchema } from './location';
export interface IMessage {
- conversationId: Types.ObjectId,
+ conversationId: Types.ObjectId;
senderId: Types.ObjectId;
createdAt: Date;
media: Array;
@@ -13,7 +13,7 @@ export interface IMessage {
const schema = new Schema(
{
- conversationId: { type: 'ObjectId', ref: 'Conversation', required: true },
+ conversationId: { type: 'ObjectId', ref: 'Conversation', required: true },
senderId: { type: 'ObjectId', ref: 'User', required: true },
content: { type: 'string', required: true },
// location: { type: LocationSchema, required: true },
@@ -26,4 +26,9 @@ const schema = new Schema(
{ timestamps: { createdAt: true, updatedAt: false } },
);
-export const MessageModel = model('Message', schema);
\ No newline at end of file
+/**
+ * The model representing the "messages" collection in MongoDB.
+ *
+ * This stores all chat messages in conversations between users.
+ */
+export const MessageModel = model('Message', schema);
diff --git a/server/src/models/planet.ts b/server/src/models/planet.ts
index 08f67c90..435f3ec5 100644
--- a/server/src/models/planet.ts
+++ b/server/src/models/planet.ts
@@ -25,4 +25,9 @@ const schema = new Schema({
visual: { type: visualSchema, required: true },
});
+/**
+ * The model representing the "planets" collection in MongoDB.
+ *
+ * This stores all planets and their visualization information.
+ */
export const PlanetModel = model('Planet', schema);
diff --git a/server/src/models/post.ts b/server/src/models/post.ts
index b236a313..3e2b59f2 100644
--- a/server/src/models/post.ts
+++ b/server/src/models/post.ts
@@ -7,11 +7,11 @@ export interface IPost {
content: string;
likeCount: number;
commentCount: number;
- repostCount: number;
createdAt: Date;
location: ILocation;
media: Array;
deleted: boolean;
+ parentPost?: Types.ObjectId;
}
const schema = new Schema(
@@ -20,9 +20,9 @@ const schema = new Schema(
content: { type: 'string', required: true },
likeCount: { type: 'number', required: true, default: 0 },
commentCount: { type: 'number', required: true, default: 0 },
- repostCount: { type: 'number', required: true, default: 0 },
location: { type: LocationSchema, required: true },
deleted: { type: 'boolean', required: true, default: false },
+ parentPost: { type: 'ObjectID', ref: 'Post', index: true },
media: {
required: true,
default: [],
@@ -32,4 +32,9 @@ const schema = new Schema(
{ timestamps: { createdAt: true, updatedAt: false } },
);
+/**
+ * The model representing the "posts" collection in MongoDB.
+ *
+ * This stores all comments and posts.
+ */
export const PostModel = model('Post', schema);
diff --git a/server/src/models/question.ts b/server/src/models/question.ts
new file mode 100644
index 00000000..1f82a5bc
--- /dev/null
+++ b/server/src/models/question.ts
@@ -0,0 +1,27 @@
+import { model, Schema } from 'mongoose';
+
+/**
+ * The interface for this model.
+ */
+interface IQuestion {
+ question: string;
+ answer: string;
+ keywords: string[];
+}
+
+/**
+ * The schema for this Model.
+ */
+const schema = new Schema(
+ {
+ question: { type: 'string', required: true },
+ answer: { type: 'string', required: true },
+ keywords: { type: [], required: true },
+ },
+ { timestamps: { createdAt: true, updatedAt: false } },
+);
+
+/**
+ * The model representing the "questions" collection in MongoDB.
+ */
+export const QuestionModel = model('Question', schema);
diff --git a/server/src/models/repost-relationship.ts b/server/src/models/repost-relationship.ts
deleted file mode 100644
index 84799b6c..00000000
--- a/server/src/models/repost-relationship.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { model, Schema, Types } from 'mongoose';
-
-export interface IRepostRelationship {
- targetPost: Types.ObjectId;
- repostPost: Types.ObjectId;
-}
-
-const schema = new Schema(
- {
- targetPost: { type: 'ObjectID', ref: 'Post', required: true },
- repostPost: { type: 'ObjectID', ref: 'Post', required: true, index: true },
- },
- { timestamps: { createdAt: true, updatedAt: false } },
-);
-
-export const RepostRelationship = model('RepostRelationship', schema);
diff --git a/server/src/models/token.ts b/server/src/models/token.ts
index 2ae5ef14..2b7d2fbd 100644
--- a/server/src/models/token.ts
+++ b/server/src/models/token.ts
@@ -2,7 +2,7 @@ import { model, Schema, Types } from 'mongoose';
export interface Token {
userId: Types.ObjectId;
- email: string;
+ email: string;
passwordResetToken?: string;
passwordResetExpires?: Date;
createdAt: Date;
@@ -10,7 +10,7 @@ export interface Token {
const schema = new Schema(
{
- userId: { type: 'ObjectID', ref: 'User', required: true },
+ userId: { type: 'ObjectID', ref: 'User', required: true },
email: { type: 'string', required: true },
passwordResetToken: { type: 'string' },
passwordResetExpires: { type: 'date' },
@@ -18,4 +18,9 @@ const schema = new Schema(
{ timestamps: { createdAt: true, updatedAt: false } },
);
+/**
+ * The model representing the "tokens" collection in MongoDB.
+ *
+ * This is associated with password reset tokens.
+ */
export const TokenModel = model('Token', schema);
diff --git a/server/src/models/user.ts b/server/src/models/user.ts
index 119cc6c6..23902aae 100644
--- a/server/src/models/user.ts
+++ b/server/src/models/user.ts
@@ -23,6 +23,7 @@ export interface IUser {
followingCount: number;
postCount: number;
admin: boolean;
+ deleted: boolean;
savedPosts: Array;
createdAt: Date;
}
@@ -47,8 +48,12 @@ const schema = new Schema(
postCount: { type: 'number', required: true, default: 0 },
savedPosts: { type: ['ObjectId'], required: true, default: [] },
admin: { type: 'boolean', required: true, default: false },
+ deleted: { type: 'boolean', required: true, default: false },
},
{ timestamps: { createdAt: true, updatedAt: false } },
);
+/**
+ * The model representing the "users" collection in MongoDB.
+ */
export const UserModel = model('User', schema);
diff --git a/server/src/routes/faqs/index.ts b/server/src/routes/faqs/index.ts
new file mode 100644
index 00000000..863c38f2
--- /dev/null
+++ b/server/src/routes/faqs/index.ts
@@ -0,0 +1,72 @@
+import { Handler } from 'express';
+import { authProtected } from '../../middlewares/auth-protected';
+import { QuestionModel } from '../../models/question';
+import { Resolve } from '../../utils/express';
+import Joi from 'joi';
+import { Document } from 'mongoose';
+
+interface QueryBody {
+ query: string;
+}
+
+const minSearchLen = 1;
+const maxSearchLen = 30;
+
+/**
+ * GET @ /faqs
+ *
+ * This handles the retrieval of FAQs from the database.
+ *
+ * This function processes incoming GET requests to the /faqs endpoint. It fetches a list of
+ * frequently asked questions from the database and returns them to the client.
+ */
+export const get: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const questions = await QuestionModel.find();
+ Resolve(res).okWith(questions, 'All questions sent.');
+ },
+];
+
+
+/**
+ * POST @ /faqs
+ *
+ * This recieves a query in the body of the request.
+ * The query must adhere to the schema witten.
+ * This query is then used to find and filter relevent faqs from the database.
+ * An array of relevent questions that were found are sent with the resolve.
+ * An empty array will be sent if none were found.
+ */
+export const post: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const querySchema = Joi.object({
+ query: Joi.string().trim().min(minSearchLen).max(maxSearchLen).messages({
+ 'string.base': 'The given search must be a string.',
+ 'string.empty': 'You must enter a search.',
+ 'string.min': 'You must enter a search.',
+ 'string.max': 'Search too long.',
+ 'any.required': 'A search request is required to search.',
+ }),
+ });
+
+ const validationResult = querySchema.validate(req.body);
+ if (validationResult.error) return Resolve(res).badRequest(validationResult.error.message);
+
+ const value = validationResult.value;
+ const queryArr: string[] = value.query.split(/\W+/);
+ const questions = await QuestionModel.find();
+ let quesToSend: Document[] = [];
+
+ for (let i = 0; i < questions.length; i++) {
+ queryArr.forEach((q) => {
+ if (questions[i].keywords.includes(q.toLowerCase()) && !quesToSend.includes(questions[i])) quesToSend.push(questions[i]);
+ });
+ }
+
+ if (quesToSend.length === 0) return Resolve(res).okWith(quesToSend, 'Questions were not found.');
+
+ return Resolve(res).okWith(quesToSend, 'Questions were found.');
+ },
+];
diff --git a/server/src/routes/feed/[planetOrUserId].ts b/server/src/routes/feed/[planetOrUserId].ts
index fda6657e..572638dd 100644
--- a/server/src/routes/feed/[planetOrUserId].ts
+++ b/server/src/routes/feed/[planetOrUserId].ts
@@ -1,28 +1,55 @@
import { Handler } from 'express';
-import { PostModel } from '../../models/post';
+import { IPost, PostModel } from '../../models/post';
import { Resolve } from '../../utils/express';
import { PlanetModel } from '../../models/planet';
import mongoose from 'mongoose';
import { UserModel } from '../../models/user';
+import { authProtected } from '../../middlewares/auth-protected';
-export const get: Handler = async (req, res) => {
- const planetOrUserId = req.params.planetOrUserId;
- if (!mongoose.isValidObjectId(planetOrUserId)) return Resolve(res).badRequest('Invalid planet ID specified.');
+/**
+ * GET @ /feed/:planetOrUserId
+ *
+ * This retrieves the feed posts for a given user
+ * or planet.
+ */
+export const get: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const planetOrUserId = req.params.planetOrUserId;
+ if (!mongoose.isValidObjectId(planetOrUserId)) return Resolve(res).badRequest('Invalid planet ID specified.');
- let search;
- const existingPlanet = await PlanetModel.exists({ _id: planetOrUserId }).lean();
- if (existingPlanet) search = { 'location.planetId': existingPlanet._id };
- else {
- const existingUser = await UserModel.exists({ _id: planetOrUserId }).lean();
- if (existingUser) search = { authorId: existingUser._id };
- else return Resolve(res).notFound('No planet or user by the given ID exists.');
- }
+ let search: mongoose.FilterQuery;
+ const existingPlanet = await PlanetModel.exists({ _id: planetOrUserId }).lean();
+ if (existingPlanet) search = { 'location.planetId': existingPlanet._id, 'parentPost': { $exists: false } };
+ else {
+ const existingUser = await UserModel.exists({ _id: planetOrUserId }).lean();
+ if (existingUser) search = { authorId: existingUser._id };
+ else return Resolve(res).notFound('No planet or user by the given ID exists.');
+ }
- const rawPage = parseInt(typeof req.query.page === 'string' ? req.query.page : '');
- const page = Math.max(1, typeof rawPage !== 'number' || isNaN(rawPage) ? 1 : rawPage);
- const limit = 20;
- const skip = (page - 1) * limit;
+ const rawPage = parseInt(typeof req.query.page === 'string' ? req.query.page : '');
+ const page = Math.max(1, typeof rawPage !== 'number' || isNaN(rawPage) ? 1 : rawPage);
+ const limit = 20;
+ const skip = (page - 1) * limit;
- const latestPosts = await PostModel.find(search).sort({ createdAt: -1 }).skip(skip).limit(limit).lean();
- Resolve(res).okWith(latestPosts);
-};
+ const latestPosts = await PostModel.aggregate([
+ { $match: { ...search, deleted: false } },
+ { $sort: { createdAt: -1 } },
+ { $skip: skip },
+ { $limit: limit },
+ {
+ $lookup: {
+ from: 'users',
+ localField: 'authorId',
+ foreignField: '_id',
+ as: 'author',
+ },
+ },
+ { $unwind: { path: '$author' } },
+ { $addFields: { userName: '$author.userName', avatarUrl: '$author.avatarUrl' } },
+ { $project: { author: 0 } },
+ ]);
+
+ Resolve(res).okWith(latestPosts);
+ },
+];
diff --git a/server/src/routes/feed/index.ts b/server/src/routes/feed/index.ts
index 6aa5ff6a..4a373084 100644
--- a/server/src/routes/feed/index.ts
+++ b/server/src/routes/feed/index.ts
@@ -1,13 +1,43 @@
import { Handler } from 'express';
import { PostModel } from '../../models/post';
import { Resolve } from '../../utils/express';
+import { authProtected } from '../../middlewares/auth-protected';
-export const get: Handler = async (req, res) => {
- const rawPage = parseInt(typeof req.query.page === 'string' ? req.query.page : '');
- const page = Math.max(1, typeof rawPage !== 'number' || isNaN(rawPage) ? 1 : rawPage);
- const limit = 20;
- const skip = (page - 1) * limit;
+/**
+ * GET @ /feed
+ *
+ * This retrieves the feed posts for all combined
+ * planets. This utilizes different sorting than the
+ * other feeds, because it prioritizes popularity.
+ */
+export const get: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const userId = req.user!._id;
- const latestPosts = await PostModel.find().sort({ likes: -1, createdAt: -1 }).skip(skip).limit(limit).lean();
- Resolve(res).okWith(latestPosts);
-};
+ const rawPage = parseInt(typeof req.query.page === 'string' ? req.query.page : '');
+ const page = Math.max(1, typeof rawPage !== 'number' || isNaN(rawPage) ? 1 : rawPage);
+ const limit = 20;
+ const skip = (page - 1) * limit;
+
+ const latestPosts = await PostModel.aggregate([
+ { $match: { parentPost: { $exists: false }, deleted: false } },
+ { $sort: { likeCount: -1, commentCount: -1, createdAt: -1 } },
+ { $skip: skip },
+ { $limit: limit },
+ {
+ $lookup: {
+ from: 'users',
+ localField: 'authorId',
+ foreignField: '_id',
+ as: 'author',
+ },
+ },
+ { $unwind: { path: '$author' } },
+ { $addFields: { userName: '$author.userName', avatarUrl: '$author.avatarUrl' } },
+ { $project: { author: 0 } },
+ ]);
+
+ Resolve(res).okWith(latestPosts);
+ },
+];
diff --git a/server/src/routes/planet/[nameOrId].ts b/server/src/routes/planet/[nameOrId].ts
index 62ea8954..10240cd8 100644
--- a/server/src/routes/planet/[nameOrId].ts
+++ b/server/src/routes/planet/[nameOrId].ts
@@ -4,6 +4,12 @@ import { escapeRegex } from '../../utils/regex';
import mongoose from 'mongoose';
import { Resolve } from '../../utils/express';
+/**
+ * GET @ /planet/:nameOrId
+ *
+ * This retrieves information for the given planet name
+ * or ID.
+ */
export const get: Handler = async (req, res) => {
const nameOrId = req.params.nameOrId;
diff --git a/server/src/routes/planet/index.ts b/server/src/routes/planet/index.ts
index 1ed147ef..d457b157 100644
--- a/server/src/routes/planet/index.ts
+++ b/server/src/routes/planet/index.ts
@@ -2,6 +2,11 @@ import { Handler } from 'express';
import { PlanetModel } from '../../models/planet';
import { Resolve } from '../../utils/express';
+/**
+ * GET @ /planet
+ *
+ * This returns all the planets currently in the database.
+ */
export const get: Handler = async (_, res) => {
const planets = await PlanetModel.find({}).lean().exec();
Resolve(res).okWith(planets);
diff --git a/server/src/routes/post/[id]/comment.ts b/server/src/routes/post/[id]/comment.ts
index e28724ec..92ab9afd 100644
--- a/server/src/routes/post/[id]/comment.ts
+++ b/server/src/routes/post/[id]/comment.ts
@@ -7,8 +7,6 @@ import { RawDocument } from '../../../@types/model';
import { ILocation, RawLocationSchema } from '../../../models/location';
import { IMedia, RawMediaSchema } from '../../../models/media';
import Joi from 'joi';
-import { CommentRelationship } from '../../../models/comment-relationship';
-import { UserModel } from '../../../models/user';
interface PostBody {
content: string;
@@ -16,50 +14,48 @@ interface PostBody {
media?: RawDocument;
}
+/**
+ * GET @ /post/:id/comment
+ *
+ * This retrieves the comments for the given
+ * post ID.
+ */
export const get: Handler = async (req, res) => {
const parentPostId = req.params.id;
- const rawLimit = req.query.limit;
- let limit = typeof rawLimit === 'string' ? parseInt(rawLimit) : NaN;
- if (isNaN(limit)) limit = 10;
- limit = Math.max(0, Math.min(limit, 100));
-
-
- const rawPage = req.query.page;
- let page = typeof rawPage === 'string' ? parseInt(rawPage) : NaN;
- if (isNaN(page)) page = 1;
- page = Math.max(1, page);
+ const rawPage = parseInt(typeof req.query.page === 'string' ? req.query.page : '');
+ const page = Math.max(1, typeof rawPage !== 'number' || isNaN(rawPage) ? 1 : rawPage);
+ const limit = 20;
+ const skip = (page - 1) * limit;
if (!mongoose.isValidObjectId(parentPostId)) return Resolve(res).badRequest('Invalid post ID provided.');
- const relationships = await CommentRelationship.find({ parentPost: parentPostId })
- .sort({ createdAt: -1 })
- .skip((page - 1) * limit)
- .limit(limit);
-
- const comments = await Promise.all(
- relationships.map(async (v) => {
- const comment = await PostModel.findById(v.childPost).lean();
- if (comment) {
- const user = await UserModel.findById(comment.authorId).lean();
- const post = await PostModel.findById(comment._id).lean();
- if (user && post) {
- return {
- ...comment,
- userName: user.userName,
- repost: post.repostCount,
- like: post.likeCount,
- comment: post.commentCount,
- };
- }
- }
- return comment;
- }),
- );
+ const comments = await PostModel.aggregate([
+ { $match: { parentPost: new mongoose.Types.ObjectId(parentPostId), deleted: false } },
+ { $sort: { createdAt: -1 } },
+ { $skip: skip },
+ { $limit: limit },
+ {
+ $lookup: {
+ from: 'users',
+ localField: 'authorId',
+ foreignField: '_id',
+ as: 'author',
+ },
+ },
+ { $unwind: { path: '$author' } },
+ { $addFields: { userName: '$author.userName', avatarUrl: '$author.avatarUrl' } },
+ { $project: { author: 0 } },
+ ]);
Resolve(res).okWith(comments);
};
+/**
+ * POST @ /post/:id/comment
+ *
+ * This creates a new comment for the given post ID.
+ */
export const post: Handler[] = [
authProtected,
async (req, res) => {
@@ -87,21 +83,16 @@ export const post: Handler[] = [
const session = await mongoose.startSession();
try {
- const commentRelationship = await session.withTransaction(async () => {
+ const createdComment = await session.withTransaction(async () => {
const comment = new PostModel({
authorId: currentUser._id,
content: body.content,
media: body.media,
location: body.location ?? currentUser.location,
- });
-
- const relationship = new CommentRelationship({
parentPost: parentPost._id,
- childPost: comment._id,
});
await comment.save({ session });
- await relationship.save({ session });
currentUser.postCount++;
await currentUser.save({ session });
@@ -109,20 +100,13 @@ export const post: Handler[] = [
parentPost.commentCount++;
await parentPost.save({ session });
- return relationship;
+ return comment;
});
- // Fetch the newly created comment with user information
- const newComment = await PostModel.findById(commentRelationship.childPost).lean();
- if (!newComment) throw new Error('Failed to fetch the newly created comment.');
- const user = await UserModel.findById(newComment.authorId).lean();
- if (!user) throw new Error('Failed to fetch the user information of the comment author.');
const responseComment = {
- ...newComment,
- userName: user.userName,
- repost: newComment.repostCount,
- like: newComment.likeCount,
- comment: newComment.commentCount,
+ ...createdComment.toObject(),
+ userName: currentUser.userName,
+ avatarUrl: currentUser.avatarUrl,
};
Resolve(res).created(responseComment, 'Comment created successfully.');
diff --git a/server/src/routes/post/[id]/index.ts b/server/src/routes/post/[id]/index.ts
index e55724da..2661cba1 100644
--- a/server/src/routes/post/[id]/index.ts
+++ b/server/src/routes/post/[id]/index.ts
@@ -3,30 +3,44 @@ import mongoose from 'mongoose';
import { authProtected } from '../../../middlewares/auth-protected';
import { PostModel } from '../../../models/post';
import { Resolve } from '../../../utils/express';
-import { CommentRelationship } from '../../../models/comment-relationship';
import { LikeInteraction } from '../../../models/like-interaction';
import { UserModel } from '../../../models/user';
+/**
+ * GET @ /post/:id
+ *
+ * This returns the post associated with the
+ * given ID.
+ */
export const get: Handler = async (req, res) => {
const id = req.params.id;
if (!mongoose.isValidObjectId(id)) return Resolve(res).badRequest('Invalid post ID provided.');
- const post = await PostModel.findById(id);
+ const post = await PostModel.findById(id).lean();
if (!post) return Resolve(res).notFound('Invalid post ID provided.');
- if (post.deleted) return Resolve(res).gone('Post has been deleted.');
-
- const user = await UserModel.findById(post.authorId).select('userName');
+ const user = await UserModel.findById(post.authorId).select('userName avatarUrl').lean();
if (!user) return Resolve(res).notFound('User not found.');
const postWithUser = {
- ...post.toObject(),
- userName: user.userName
+ ...post,
+ userName: user.userName,
+ avatarUrl: user.avatarUrl,
};
return Resolve(res).okWith(postWithUser);
};
+/**
+ * DELETE @ /post/:id
+ *
+ * This deletes the post associated with the given ID.
+ * This does not fully delete the post, but it does
+ * remove all like interactions and clears the content.
+ *
+ * We cannot simply drop the document from the database because
+ * it could still be accessed by relationship from a comment.
+ */
export const del: Handler[] = [
authProtected,
async (req, res) => {
@@ -37,7 +51,12 @@ export const del: Handler[] = [
if (!post) return Resolve(res).notFound('Invalid post ID provided.');
const currentUser = req.user!;
- if (!post.authorId.equals(currentUser._id)) return Resolve(res).forbidden('You cannot delete this post.');
+
+ const postAuthor = await UserModel.findById(post.authorId);
+ if (!postAuthor) return Resolve(res).error('The given post has no creator!');
+
+ if (!postAuthor._id.equals(currentUser._id) && !currentUser.admin)
+ return Resolve(res).forbidden('You cannot delete this post.');
if (post.deleted) return Resolve(res).gone('Post is already deleted.');
@@ -45,19 +64,17 @@ export const del: Handler[] = [
try {
const deletedPost = await session.withTransaction(async () => {
- await post.updateOne({ deleted: true, content: '', likeCount: 0 }, { session });
- await currentUser.updateOne({ $inc: { postCount: -1 } }, { session });
-
- const commentOfRelationship = await CommentRelationship.findOne({ childPost: post._id });
- if (commentOfRelationship) {
- await commentOfRelationship.deleteOne({ session });
- await PostModel.updateOne(
- { _id: commentOfRelationship.parentPost },
- { $inc: { commentCount: -1 } },
- { session },
- );
+ const parentPostId = post.parentPost;
+ if (parentPostId) {
+ await PostModel.updateOne({ _id: parentPostId }, { $inc: { commentCount: -1 } }, { session });
}
+ await post.updateOne(
+ { deleted: true, content: '', likeCount: 0, $unset: { parentPost: 1 } },
+ { session },
+ );
+
+ await postAuthor.updateOne({ $inc: { postCount: -1 } }, { session });
await LikeInteraction.deleteMany({ postId: post._id }, { session });
return post;
diff --git a/server/src/routes/post/[id]/like.ts b/server/src/routes/post/[id]/like.ts
index f76c9cd4..2663b5f6 100644
--- a/server/src/routes/post/[id]/like.ts
+++ b/server/src/routes/post/[id]/like.ts
@@ -5,6 +5,12 @@ import mongoose from 'mongoose';
import { Resolve } from '../../../utils/express';
import { LikeInteraction } from '../../../models/like-interaction';
+/**
+ * GET @ /post/:id/like
+ *
+ * This returns whether the user making the request
+ * has the post associated with the given ID liked.
+ */
export const get: Handler[] = [
authProtected,
async (req, res) => {
@@ -17,6 +23,12 @@ export const get: Handler[] = [
},
];
+/**
+ * POST @ /post/:id/like
+ *
+ * This likes the post associated with the given ID as
+ * the user making the request.
+ */
export const post: Handler[] = [
authProtected,
async (req, res) => {
@@ -58,6 +70,12 @@ export const post: Handler[] = [
},
];
+/**
+ * DELETE @ /post/:id/like
+ *
+ * This unlikes the post associated with the given
+ * ID as the user making the request.
+ */
export const del: Handler[] = [
authProtected,
async (req, res) => {
diff --git a/server/src/routes/post/[id]/save.ts b/server/src/routes/post/[id]/save.ts
index fb6a17f6..f2dbc44a 100644
--- a/server/src/routes/post/[id]/save.ts
+++ b/server/src/routes/post/[id]/save.ts
@@ -4,6 +4,13 @@ import mongoose from 'mongoose';
import { Resolve } from '../../../utils/express';
import { PostModel } from '../../../models/post';
+/**
+ * GET @ /post/:id/save
+ *
+ * This returns whether the user making the
+ * request has the post associated with the given ID
+ * saved.
+ */
export const get: Handler[] = [
authProtected,
async (req, res) => {
@@ -15,6 +22,13 @@ export const get: Handler[] = [
},
];
+/**
+ * POST @ /post/:id/save
+ *
+ * This saves the post associated with the
+ * given ID to the saved posts of the user making the
+ * request.
+ */
export const post: Handler[] = [
authProtected,
async (req, res) => {
@@ -36,6 +50,13 @@ export const post: Handler[] = [
},
];
+/**
+ * DELETE @ /post/:id/save
+ *
+ * This unsaves the post associated with the
+ * given ID for the user making the
+ * request.
+ */
export const del: Handler[] = [
authProtected,
async (req, res) => {
diff --git a/server/src/routes/post/index.ts b/server/src/routes/post/index.ts
index c29b1308..3d94fa81 100644
--- a/server/src/routes/post/index.ts
+++ b/server/src/routes/post/index.ts
@@ -14,6 +14,12 @@ interface PostBody {
media?: RawDocument;
}
+/**
+ * POST @ /post
+ *
+ * This creates a new post as the user
+ * making the request.
+ */
export const post: Handler[] = [
authProtected,
async (req, res) => {
diff --git a/server/src/routes/post/search/[search].ts b/server/src/routes/post/search/[search].ts
new file mode 100644
index 00000000..d4c44c4c
--- /dev/null
+++ b/server/src/routes/post/search/[search].ts
@@ -0,0 +1,52 @@
+import { Handler } from 'express';
+import { Resolve } from '../../../utils/express';
+import { IPost, PostModel } from '../../../models/post';
+import { authProtected } from '../../../middlewares/auth-protected';
+import mongoose from 'mongoose';
+import { escapeRegex } from '../../../utils/regex';
+
+/**
+ * GET @ /post/search/:search?page
+ *
+ * This searches in the database for a post with or that contains the specified string
+ * passed as an url parameter and uses the query page for pagination.
+ */
+export const get: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const search = req.params.search.trim();
+
+ const rawPage = parseInt(typeof req.query.page === 'string' ? req.query.page : '');
+ const page = Math.max(1, typeof rawPage !== 'number' || isNaN(rawPage) ? 1 : rawPage);
+ const limit = 20;
+ const skip = (page - 1) * limit;
+
+ let postSearch: mongoose.FilterQuery;
+
+ const posts = await PostModel.exists({ content: { $regex: search, $options: 'si' } }).lean();
+
+ if (!posts) return Resolve(res).notFound('No user or posts found with this search key.');
+ else {
+ postSearch = { content: { $regex: escapeRegex(search), $options: 'si' } };
+ const searchedPosts = await PostModel.aggregate([
+ { $match: { ...postSearch, deleted: { $not: { $eq: true } } } },
+ { $sort: { likeCount: -1, commentCount: -1, createdAt: -1 } },
+ { $skip: skip },
+ { $limit: limit },
+ {
+ $lookup: {
+ from: 'users',
+ localField: 'authorId',
+ foreignField: '_id',
+ as: 'author',
+ },
+ },
+ { $unwind: { path: '$author' } },
+ { $addFields: { userName: '$author.userName' } },
+ { $project: { author: 0 } },
+ ]);
+
+ Resolve(res).okWith(searchedPosts);
+ }
+ },
+];
diff --git a/server/src/routes/user/[id].ts b/server/src/routes/user/[id].ts
index 8a3cdb99..22124e6f 100644
--- a/server/src/routes/user/[id].ts
+++ b/server/src/routes/user/[id].ts
@@ -1,54 +1,21 @@
-import Joi from 'joi';
import mongoose from 'mongoose';
import { Handler } from 'express';
import { UserModel } from '../../models/user';
-import { authProtected } from '../../middlewares/auth-protected';
-import { assertRequestBody, Resolve } from '../../utils/express';
-
+import { Resolve } from '../../utils/express';
+
+/**
+ * GET @ /user/:id
+ *
+ * This returns public infromation for the user
+ * associated with the given ID>
+ */
export const get: Handler = async (req, res) => {
const id = req.params.id;
if (!mongoose.isValidObjectId(id)) return Resolve(res).badRequest('Invalid user ID provided.');
const user = await UserModel.findById(id)
.lean()
- .select('userName bio location avatarUrl followerCount followingCount postCount createdAt');
+ .select('userName bio location avatarUrl followerCount followingCount postCount createdAt deleted');
if (!user) Resolve(res).notFound('No user found by the given ID.');
else Resolve(res).okWith(user);
};
-
-interface PatchBody {
- userName?: string;
- email?: string;
- bio?: string;
-}
-
-export const patch: Handler[] = [
- authProtected,
- async (req, res) => {
- const id = req.params.id;
- if (!mongoose.isValidObjectId(id)) return Resolve(res).badRequest('Invalid user ID provided');
-
- const user = req.user!;
- if (!user._id.equals(id)) return Resolve(res).forbidden('You are not authorized to modify this user.');
-
- const body = assertRequestBody(
- req,
- res,
- Joi.object({
- userName: Joi.string().trim(),
- email: Joi.string().trim().email(),
- bio: Joi.string().trim(),
- }),
- );
-
- if (!body) return;
-
- if (body.email) {
- const existing = await UserModel.findOne({ email: body.email }).lean();
- if (existing) return Resolve(res).conflict('Email already exists.');
- }
-
- await user.updateOne(body);
- Resolve(res).ok();
- },
-];
diff --git a/server/src/routes/user/[id]/follow.ts b/server/src/routes/user/[id]/follow.ts
new file mode 100644
index 00000000..2938ddfb
--- /dev/null
+++ b/server/src/routes/user/[id]/follow.ts
@@ -0,0 +1,113 @@
+import { Handler } from 'express';
+import { authProtected } from '../../../middlewares/auth-protected';
+import mongoose from 'mongoose';
+import { Resolve } from '../../../utils/express';
+import { FollowRelationship } from '../../../models/follow-relationship';
+import { UserModel } from '../../../models/user';
+
+/**
+ * GET @ /user/:id/follow
+ *
+ * This returns whether the user making the request
+ * has the followed the user with the given ID followed.
+ */
+export const get: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const id = req.params.id;
+ if (!mongoose.isValidObjectId(id)) return Resolve(res).badRequest('Invalid user ID provided.');
+
+ const currentUserId = req.user!.id;
+ const existingInteraction = await FollowRelationship.exists({ targetUserId: id, initiateUserId: currentUserId });
+ Resolve(res).okWith(!!existingInteraction);
+ },
+];
+
+/**
+ * POST @ /post/:id/follow
+ *
+ * This follow the user associated with the given ID as
+ * the user making the request.
+ */
+export const post: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const id = req.params.id;
+ if (!mongoose.isValidObjectId(id)) return Resolve(res).badRequest('Invalid user ID provided.');
+
+ const targetUser = await UserModel.findById(id);
+ if (!targetUser) return Resolve(res).notFound('Invalid user ID provided.');
+
+ const currentUserId = req.user!._id;
+ const existingInteraction = await FollowRelationship.exists({ targetUserId: id, initiateUserId: currentUserId }).lean();
+ if (existingInteraction) return Resolve(res).badRequest('Target user is already followed.');
+
+ const session = await mongoose.startSession();
+
+ try {
+ const interaction = await session.withTransaction(async () => {
+ const interaction = new FollowRelationship({
+ targetUserId: id,
+ initiateUserId: currentUserId,
+ });
+
+ await interaction.save({ session });
+
+ targetUser.followerCount++;
+ await targetUser.save({ session });
+
+ req.user!.followingCount++;
+ await req.user!.save({ session });
+
+ return interaction;
+ });
+
+ Resolve(res).okWith(interaction);
+ } catch {
+ Resolve(res).error('Error occured while trying to follow this user.');
+ } finally {
+ await session.endSession();
+ }
+ },
+];
+
+/**
+ * DELETE @ /post/:id/follow
+ *
+ * This unfollows the user associated with the given
+ * ID as the user making the request.
+ */
+export const del: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const id = req.params.id;
+ if (!mongoose.isValidObjectId(id)) return Resolve(res).badRequest('Invalid user ID provided.');
+
+ const targetUser = await UserModel.findById(id);
+ if (!targetUser) return Resolve(res).notFound('Invalid user ID provided.');
+
+ const currentUserId = req.user!.id;
+ const interaction = await FollowRelationship.findOne({ targetUserId: id, initiateUserId: currentUserId });
+ if (!interaction) return Resolve(res).badRequest('User is not followed.');
+
+ const session = await mongoose.startSession();
+ const currentUser = req.user!
+
+ try {
+ await session.withTransaction(async () => {
+ await interaction.deleteOne({ session });
+
+ await targetUser.updateOne({ $inc: { followerCount: -1 } }, { session });
+ await currentUser.updateOne({ $inc: { followingCount: -1 } }, { session });
+
+ return interaction;
+ });
+
+ Resolve(res).okWith(interaction);
+ } catch {
+ Resolve(res).error('Error occured while trying to unfollow this user.');
+ } finally {
+ await session.endSession();
+ }
+ },
+];
diff --git a/server/src/routes/user/changeBio.ts b/server/src/routes/user/changeBio.ts
new file mode 100644
index 00000000..b3bf8005
--- /dev/null
+++ b/server/src/routes/user/changeBio.ts
@@ -0,0 +1,39 @@
+import Joi from 'joi';
+import { Handler } from 'express';
+import { Resolve } from '../../utils/express';
+import { authProtected } from '../../middlewares/auth-protected';
+
+interface bioBody {
+ newBio: string;
+}
+
+/**
+ * PATCH @ /user/changeBio
+ *
+ * This changes the bio for the user
+ * making the request.
+ */
+export const patch: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const user = req.user!;
+
+ const currBio = user.bio ? user.bio : '';
+
+ const emailSchema = Joi.object({
+ newBio: Joi.string().allow('').trim().required().invalid(currBio).messages({
+ 'string.base': 'New bio must be a string.',
+ 'any.required': 'A bio is required in order to change your bio.',
+ 'any.invalid': 'Bio entered is already your current bio.',
+ }),
+ });
+
+ const validationResult = emailSchema.validate(req.body);
+ if (validationResult.error) return Resolve(res).badRequest(validationResult.error.message);
+
+ const value = validationResult.value;
+
+ const result = await user.updateOne({ bio: value.newBio });
+ return Resolve(res).okWith(result, 'Bio changed successfully.');
+ },
+];
diff --git a/server/src/routes/user/changeEmail.ts b/server/src/routes/user/changeEmail.ts
index 06423434..79e19c95 100644
--- a/server/src/routes/user/changeEmail.ts
+++ b/server/src/routes/user/changeEmail.ts
@@ -9,6 +9,12 @@ interface EmailBody {
confirmEmail: string;
}
+/**
+ * PATCH @ /user/changeEmail
+ *
+ * This changes the email for the user that is
+ * making the request.
+ */
export const patch: Handler[] = [
authProtected,
async (req, res) => {
@@ -31,7 +37,7 @@ export const patch: Handler[] = [
'string.email': 'The confirmed email entered is not a valid email',
'any.required': 'Confirming the email is required.',
'any.only': 'The confirmed email does not match the new email.',
- })
+ }),
});
const bodyValidationResult = emailSchema.validate(req.body);
diff --git a/server/src/routes/user/changeUsername.ts b/server/src/routes/user/changeUsername.ts
index 14dfdc9a..d451983b 100644
--- a/server/src/routes/user/changeUsername.ts
+++ b/server/src/routes/user/changeUsername.ts
@@ -7,6 +7,12 @@ interface UsernameBody {
newUsername: string;
}
+/**
+ * PATCH @ /user/changeUsername
+ *
+ * This changes the username for the user
+ * making the request.
+ */
export const patch: Handler[] = [
authProtected,
async (req, res) => {
diff --git a/server/src/routes/user/changeavatar.ts b/server/src/routes/user/changeavatar.ts
new file mode 100644
index 00000000..7aed6156
--- /dev/null
+++ b/server/src/routes/user/changeavatar.ts
@@ -0,0 +1,25 @@
+import { Handler } from 'express';
+import { authProtected } from '../../middlewares/auth-protected';
+import { Resolve } from '../../utils/express';
+import { imageUpload } from '../../utils/image';
+
+/**
+ * PATCH @ /user/changeavatar
+ *
+ * This updates the avatar for the user making the request.
+ */
+export const patch: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const url = req.body.avatarDataUrl;
+ if (!url || typeof url !== 'string') return Resolve(res).badRequest('Invalid');
+
+ const uploadResult = await imageUpload(url);
+ if (!uploadResult) return Resolve(res).error('Unable to upload avatar image.');
+
+ const user = req.user!;
+ await user.updateOne({ avatarUrl: uploadResult.secure_url });
+
+ Resolve(res).okWith(uploadResult.secure_url);
+ },
+];
diff --git a/server/src/routes/user/changepassword.ts b/server/src/routes/user/changepassword.ts
index 89fa1302..7cc72af1 100644
--- a/server/src/routes/user/changepassword.ts
+++ b/server/src/routes/user/changepassword.ts
@@ -11,6 +11,12 @@ interface PostBody {
confirmpassword: string;
}
+/**
+ * PATCH @ /user/changepassword
+ *
+ * This changes the password for the user making
+ * the request, given that they know their current password.
+ */
export const patch: Handler[] = [
authProtected,
async (req, res) => {
diff --git a/server/src/routes/user/chat.ts b/server/src/routes/user/chat.ts
index 2946ec1e..eb2c9afc 100644
--- a/server/src/routes/user/chat.ts
+++ b/server/src/routes/user/chat.ts
@@ -10,57 +10,97 @@ interface PostBody {
content: string;
}
+/**
+ * POST @ /user/chat
+ *
+ * This creates a new chat message in the conversation
+ * between the user making the request and the given receiver.
+ */
export const post: Handler[] = [
- authProtected,
- async (req, res) => {
- const user = req.user!;
- const io = req.app.get('socketio');
+ authProtected,
+ async (req, res) => {
+ const user = req.user!;
+ const io = req.app.get('socketio');
- const postSchema = Joi.object({
+ const postSchema = Joi.object({
receiverId: Joi.string().trim().required(),
content: Joi.string().trim().required(),
});
- const bodyValidationResult = postSchema.validate(req.body);
- if (bodyValidationResult.error) return res.status(400).json({ error: bodyValidationResult.error.message });
+ const bodyValidationResult = postSchema.validate(req.body);
+ if (bodyValidationResult.error) return res.status(400).json({ error: bodyValidationResult.error.message });
- const value = bodyValidationResult.value;
+ const value = bodyValidationResult.value;
- const converationID = await ConversationModel.findOne({
- $or: [
- {senderId: user._id, receiverId: value.receiverId},
- {receiverId: user._id, senderId: value.receiverId},
- ]
- })
+ const converationID = await ConversationModel.findOne({
+ $or: [
+ { senderId: user._id, receiverId: value.receiverId },
+ { receiverId: user._id, senderId: value.receiverId },
+ ],
+ });
+
+ if (converationID == null) {
+
+ const conversation = new ConversationModel({
+ senderId: user._id,
+ receiverId: value.receiverId,
+ });
+ const convo = await conversation.save();
- if (converationID == null){
- const conversation = new ConversationModel({
- senderId: user._id,
- receiverId: value.receiverId
- });
+ const message = new MessageModel({
+ conversationId: convo._id,
+ senderId: user._id,
+ content: value.content,
+ });
+ const data = await message.save();
- const convo = await conversation.save();
-
- const message = new MessageModel({
- conversationId: convo._id,
- senderId: user._id,
- content: value.content
- });
+ const messages = await MessageModel.aggregate([
+ {
+ $match: { conversationId: data.conversationId }
+ },
+ {
+ $group: {
+ _id: {
+ $dateToString: { format: "%Y-%m-%d", date: "$createdAt" }
+ },
+ messages: { $push: "$$ROOT" }
+ }
+ },
+ {
+ $sort: { "_id": 1 }
+ }
+ ]);
- const data = await message.save();
- io.emit('receiveMessage', data);
- return Resolve(res).ok('Message saved successfully.');
- }
+ io.to(data.conversationId.toHexString()).emit('displayMessage', messages);
+ return Resolve(res).okWith(data,'Message saved successfully.');
+ }
- const message = new MessageModel({
+ const message = new MessageModel({
conversationId: converationID._id,
- senderId: user._id,
- content: value.content
+ senderId: user._id,
+ content: value.content,
});
-
- const data = await message.save();
-
- io.emit('receiveMessage', data);
- return Resolve(res).okWith(data, 'Message saved successfully.');
- }
-];
\ No newline at end of file
+ const data = await message.save();
+
+ const messages = await MessageModel.aggregate([
+ {
+ $match: { conversationId: converationID._id }
+ },
+ {
+ $group: {
+ _id: {
+ $dateToString: { format: "%Y-%m-%d", date: "$createdAt" }
+ },
+ messages: { $push: "$$ROOT" }
+ }
+ },
+ {
+ $sort: { "_id": 1 }
+ }
+ ]);
+
+ // io.emit('receiveMessage', messages);
+ io.to(data.conversationId.toHexString()).emit('displayMessage', messages);
+ return Resolve(res).okWith(data, 'Message saved successfully.');
+ },
+];
diff --git a/server/src/routes/user/commented.ts b/server/src/routes/user/commented.ts
new file mode 100644
index 00000000..5d43eab8
--- /dev/null
+++ b/server/src/routes/user/commented.ts
@@ -0,0 +1,56 @@
+import { Handler } from 'express';
+import { authProtected } from '../../middlewares/auth-protected';
+import { PostModel } from '../../models/post';
+import { Resolve } from '../../utils/express';
+
+/**
+ * GET @ /user/commented
+ * Fetches paginated commented posts by a user.
+ */
+export const get: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const userId = req.user!._id;
+ const rawPage = parseInt(typeof req.query.page === 'string' ? req.query.page : '');
+ const page = Math.max(1, typeof rawPage !== 'number' || isNaN(rawPage) ? 1 : rawPage);
+ const limit = 20;
+ const skip = (page - 1) * limit;
+
+ try {
+ // Find all non-root posts by the user
+ const parentPostIds = await PostModel.find({
+ authorId: userId,
+ parentPost: { $exists: true },
+ deleted: false,
+ }).distinct('parentPost').lean();
+
+ if (parentPostIds.length === 0) {
+ return Resolve(res).okWith({ commentedPosts: [] });
+ }
+
+ // Retrieve parent posts with pagination and join with user details
+ const commentedPostsDetails = await PostModel.aggregate([
+ { $match: { _id: { $in: parentPostIds } } },
+ { $sort: { createdAt: -1 } },
+ { $skip: skip },
+ { $limit: limit },
+ {
+ $lookup: {
+ from: 'users',
+ localField: 'authorId',
+ foreignField: '_id',
+ as: 'author',
+ },
+ },
+ { $unwind: { path: '$author' } },
+ { $addFields: { userName: '$author.userName', avatarUrl: '$author.avatarUrl' } },
+ { $project: { author: 0 } },
+ ]);
+
+ return Resolve(res).okWith({ commentedPosts: commentedPostsDetails });
+ } catch (error) {
+ console.error(error);
+ return Resolve(res).error('Error fetching commented posts.');
+ }
+ },
+];
diff --git a/server/src/routes/user/conversations.ts b/server/src/routes/user/conversations.ts
new file mode 100644
index 00000000..09b0382f
--- /dev/null
+++ b/server/src/routes/user/conversations.ts
@@ -0,0 +1,83 @@
+import Joi from 'joi';
+import { Handler } from 'express';
+import { authProtected } from '../../middlewares/auth-protected';
+import { MessageModel } from '../../models/message';
+import { ConversationModel } from '../../models/conversation';
+
+interface PostBody {
+ receiverId: string;
+ content: string;
+}
+
+/**
+ * POST @ /user/conversations
+ *
+ * This returns all the list of users
+ * that one user has conversations or
+ * interacted with
+ */
+export const post: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const user = req.user!;
+
+ const converationID = await ConversationModel.find({
+ $or: [
+ {senderId: user._id},
+ {receiverId: user._id},
+ ]
+ })
+
+ const converation = await ConversationModel.aggregate([
+ {
+ $match: {
+ $or: [
+ { senderId: user._id },
+ { receiverId: user._id }
+ ]
+ }
+ },
+ {
+ $project: {
+ userId: {
+ $cond: { if: { $eq: ["$senderId", user._id] }, then: "$receiverId", else: "$senderId" }
+ }
+ }
+ },
+ // useful code for future
+ /*{
+ $group: {
+ _id: null,
+ uniqueUserIds: { $addToSet: "$userId" }
+ }
+ }*/
+ {
+ $lookup: {
+ from: "users",
+ localField: "userId",
+ foreignField: "_id",
+ as: "userDetails"
+ }
+ },
+ {
+ $unwind: "$userDetails"
+ },
+ {
+ $project: {
+ userId: "$userDetails._id",
+ name: "$userDetails.userName",
+ avatar: "$userDetails.avatarUrl"
+ }
+ }
+ ])
+
+ if (converationID == null){
+ res.json({success: false})
+ return
+ }
+
+ // const message = await MessageModel.find({conversationId: converationID._id});
+
+ res.json({success: true, data: converation})
+ }
+];
\ No newline at end of file
diff --git a/server/src/routes/user/deleteaccount/delete.ts b/server/src/routes/user/deleteaccount/delete.ts
index 800f8afe..39b5e2e4 100644
--- a/server/src/routes/user/deleteaccount/delete.ts
+++ b/server/src/routes/user/deleteaccount/delete.ts
@@ -12,6 +12,15 @@ interface deleteBody {
const message = 'I-WANT-TO-DELETE-THIS-ACCOUNT';
+/**
+ * POST @ /user/deleteaccount
+ *
+ * This deletes the user that is making the request.
+ * This does not actually drop the document from the
+ * database because of legal reasons. It simply anonomizes
+ * the existing user and saves the previous version of the user
+ * in a private spot.
+ */
export const post: Handler[] = [
authProtected,
async (req, res) => {
@@ -38,12 +47,13 @@ export const post: Handler[] = [
await userRef.updateOne({
email: null,
sso: null,
- userName: 'deletedUser',
+ userName: 'Deleted User',
password: null,
location: null,
bio: null,
birthDate: null,
avatarUrl: null,
+ deleted: true
});
const deletedUser = new DeletedUserModel({
@@ -61,8 +71,9 @@ export const post: Handler[] = [
postCount: userCopy.postCount,
savedPosts: userCopy.savedPosts,
admin: userCopy.admin,
- createdAt: Date.now()
+ deleted: true,
});
+
await deletedUser.save();
Resolve(res).okWith(userCopy, 'Account deleted successfully.');
diff --git a/server/src/routes/user/follower.ts b/server/src/routes/user/follower.ts
new file mode 100644
index 00000000..bef9d5dd
--- /dev/null
+++ b/server/src/routes/user/follower.ts
@@ -0,0 +1,24 @@
+import { Handler } from 'express';
+import { authProtected } from '../../middlewares/auth-protected';
+import { FollowRelationship } from '../../models/follow-relationship';
+import { Resolve } from '../../utils/express';
+
+/**
+ * GET @ /user/follower
+ *
+ * This returns the users that the user with the given ID is following.
+ */
+
+export const get: Handler[] = [
+
+ authProtected,
+ async (req, res) => {
+ const user = req.user!;
+
+ const followerId = await FollowRelationship.find({ targetUserId: user._id })
+ .populate('initiateUserId', 'userName followerCount followingCount postCount avatarUrl')
+ .lean();
+
+ return Resolve(res).okWith(followerId.map(follow => follow.initiateUserId));
+ },
+];
\ No newline at end of file
diff --git a/server/src/routes/user/following.ts b/server/src/routes/user/following.ts
new file mode 100644
index 00000000..3106085f
--- /dev/null
+++ b/server/src/routes/user/following.ts
@@ -0,0 +1,24 @@
+import { Handler } from 'express';
+import { authProtected } from '../../middlewares/auth-protected';
+import { FollowRelationship } from '../../models/follow-relationship';
+import { Resolve } from '../../utils/express';
+
+/**
+ * GET @ /user/following
+ *
+ * This returns the users that the user with the given ID is following.
+ */
+
+export const get: Handler[] = [
+
+ authProtected,
+ async (req, res) => {
+ const user = req.user!;
+
+ const followingId = await FollowRelationship.find({ initiateUserId: user._id })
+ .populate('targetUserId', 'userName followerCount followingCount postCount avatarUrl')
+ .lean();
+
+ return Resolve(res).okWith(followingId.map(follow => follow.targetUserId));
+ },
+];
\ No newline at end of file
diff --git a/server/src/routes/user/forgetpassword.ts b/server/src/routes/user/forgetpassword.ts
index c3d2949e..b2bdc8eb 100644
--- a/server/src/routes/user/forgetpassword.ts
+++ b/server/src/routes/user/forgetpassword.ts
@@ -5,11 +5,18 @@ import { TokenModel } from '../../models/token';
import { Resolve } from '../../utils/express';
import crypto from 'crypto';
import { sendEmail } from '../../utils/email';
+import { getClientHost } from '../../environment';
interface PostBody {
email: string;
}
+/**
+ * POST @ /user/forgetpassword
+ *
+ * This initiates a new password reset token for
+ * the specified email address.
+ */
export const post: Handler = async (req, res) => {
const bodySchema = Joi.object({
email: Joi.string().trim().email().required().messages({
@@ -24,7 +31,7 @@ export const post: Handler = async (req, res) => {
const { value: body } = bodyValidationResult;
- const existingUser = await UserModel.findOne({ email: body.email });
+ const existingUser = await UserModel.findOne({ email: body.email }).lean();
if (!existingUser) return Resolve(res).badRequest('Sorry, no user with that email exists.');
const submittedEmail = existingUser.email;
@@ -43,8 +50,8 @@ export const post: Handler = async (req, res) => {
await token.save();
- const resetURL = `${req.protocol}://localhost:8000/resetpassword/${resetToken}`;
- const message = `We have received a request to reset the password for your account.
+ const resetURL = `${getClientHost()}/resetpassword/${resetToken}`;
+ const message = `We have received a request to reset the password for your account (${existingUser.userName}).
\nYou can reset your password using the following link:\n${resetURL}
\nThe link is only valid for 10 minutes.\nIf you did not make this request, simply ignore this email.`;
@@ -55,7 +62,7 @@ export const post: Handler = async (req, res) => {
text: message,
});
- return Resolve(res).badRequest('Password rest link has been sent your email.');
+ return Resolve(res).ok('Password rest link has been sent your email.');
} catch (error) {
await token.deleteOne();
console.log(error);
diff --git a/server/src/routes/user/getchats.ts b/server/src/routes/user/getchats.ts
index 15f04e09..47342a47 100644
--- a/server/src/routes/user/getchats.ts
+++ b/server/src/routes/user/getchats.ts
@@ -9,6 +9,13 @@ interface PostBody {
content: string;
}
+/**
+ * POST @ /user/getchat
+ *
+ * This returns the messages in the conversation
+ * between the user making the request and
+ * the specified receiver.
+ */
export const post: Handler[] = [
authProtected,
async (req, res) => {
@@ -26,7 +33,23 @@ export const post: Handler[] = [
return
}
- const message = await MessageModel.find({conversationId: converationID._id});
+ await MessageModel.find({conversationId: converationID._id});
+ const message = await MessageModel.aggregate([
+ {
+ $match: { conversationId: converationID._id }
+ },
+ {
+ $group: {
+ _id: {
+ $dateToString: { format: "%Y-%m-%d", date: "$createdAt" }
+ },
+ messages: { $push: "$$ROOT" }
+ }
+ },
+ {
+ $sort: { "_id": 1 }
+ }
+ ]);
res.json({success: true, message})
}
diff --git a/server/src/routes/user/index.ts b/server/src/routes/user/index.ts
index 01764ccd..89ae395a 100644
--- a/server/src/routes/user/index.ts
+++ b/server/src/routes/user/index.ts
@@ -2,6 +2,12 @@ import { Handler } from 'express';
import { authProtected } from '../../middlewares/auth-protected';
import { Resolve } from '../../utils/express';
+/**
+ * GET @ /user
+ *
+ * This returns all information associated with
+ * the user making the request.
+ */
export const get: Handler[] = [
authProtected,
(req, res) =>
diff --git a/server/src/routes/user/liked.ts b/server/src/routes/user/liked.ts
new file mode 100644
index 00000000..0b9f9b72
--- /dev/null
+++ b/server/src/routes/user/liked.ts
@@ -0,0 +1,51 @@
+import { Handler } from 'express';
+import { authProtected } from '../../middlewares/auth-protected';
+import { LikeInteraction } from '../../models/like-interaction';
+import { PostModel } from '../../models/post';
+import { Resolve } from '../../utils/express';
+
+/**
+ * GET @ /user/liked
+ * Fetches paginated liked posts by a user.
+ */
+export const get: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const userId = req.user!._id;
+ const rawPage = parseInt(typeof req.query.page === 'string' ? req.query.page : '');
+ const page = Math.max(1, typeof rawPage !== 'number' || isNaN(rawPage) ? 1 : rawPage);
+ const limit = 20;
+ const skip = (page - 1) * limit;
+
+ try {
+ const likedPostIds = await LikeInteraction.find({ userId }).distinct('postId').lean();
+
+ if (likedPostIds.length === 0) {
+ return Resolve(res).okWith({ likedPosts: [] });
+ }
+
+ const likedPostsDetails = await PostModel.aggregate([
+ { $match: { _id: { $in: likedPostIds }, deleted: false } },
+ { $sort: { createdAt: -1 } },
+ { $skip: skip },
+ { $limit: limit },
+ {
+ $lookup: {
+ from: 'users',
+ localField: 'authorId',
+ foreignField: '_id',
+ as: 'author',
+ },
+ },
+ { $unwind: { path: '$author' } },
+ { $addFields: { userName: '$author.userName', avatarUrl: '$author.avatarUrl' } },
+ { $project: { author: 0 } },
+ ]);
+
+ return Resolve(res).okWith({ likedPosts: likedPostsDetails });
+ } catch (error) {
+ console.error(error);
+ return Resolve(res).error('Error fetching liked posts.');
+ }
+ }
+];
diff --git a/server/src/routes/user/login.ts b/server/src/routes/user/login.ts
index 231731b1..247d8041 100644
--- a/server/src/routes/user/login.ts
+++ b/server/src/routes/user/login.ts
@@ -11,6 +11,12 @@ interface PostBody {
password: string;
}
+/**
+ * GET @ /user/login
+ *
+ * This returns whether a user is currently authorized, and
+ * if requested, can issue a new JWT token.
+ */
export const get: Handler[] = [
authProtected,
async (req, res) => {
@@ -22,6 +28,13 @@ export const get: Handler[] = [
},
];
+/**
+ * POST @ /user/login
+ *
+ * This authenticates a user based off of specified
+ * email and password, and issues a JWT token if
+ * all is okay.
+ */
export const post: Handler = async (req, res) => {
const body = assertRequestBody(
req,
diff --git a/server/src/routes/user/oauth/google.ts b/server/src/routes/user/oauth/google.ts
index 617c5e13..054fda8e 100644
--- a/server/src/routes/user/oauth/google.ts
+++ b/server/src/routes/user/oauth/google.ts
@@ -2,6 +2,12 @@ import { Handler } from 'express';
import { Resolve } from '../../../utils/express';
import { GoogleOAuthAdapter } from '../../../lib/auth/adapters/google-oauth';
+/**
+ * POST @ /user/oauth/google
+ *
+ * This returns a new Google authentication consent
+ * screen URL to initiate a Google authentication.
+ */
export const post: Handler = async (_, res) => {
const { authClient } = GoogleOAuthAdapter.generateClients();
diff --git a/server/src/routes/user/resetpassword/[token].ts b/server/src/routes/user/resetpassword/[token].ts
index 14428630..e30c4cad 100644
--- a/server/src/routes/user/resetpassword/[token].ts
+++ b/server/src/routes/user/resetpassword/[token].ts
@@ -7,40 +7,51 @@ import { TokenModel } from '../../../models/token';
import crypto from 'crypto';
interface PostBody {
- password: string;
- confirmpassword: string;
+ password: string;
+ confirmpassword: string;
}
+/**
+ * PATCH @ /user/resetpassword/:token
+ *
+ * Given a correct token, this changes the
+ * password for the user associated with the given
+ * token to the provided password.
+ *
+ * This is used for password reset.
+ */
export const patch: Handler = async (req, res) => {
- const token = crypto.createHash('sha256').update(req.params.token).digest('hex');
+ const token = crypto.createHash('sha256').update(req.params.token).digest('hex');
- const tokenUser = await TokenModel.findOne({ passwordResetToken: token, passwordResetExpires: { $gt: Date.now() } });
- if (!tokenUser) return Resolve(res).created(patch, 'Token is invalid or has expired!');
+ const tokenUser = await TokenModel.findOne({
+ passwordResetToken: token,
+ passwordResetExpires: { $gt: Date.now() },
+ });
+ if (!tokenUser) return Resolve(res).badRequest('Token is invalid or has expired!');
- const user = await UserModel.findById(tokenUser.userId);
- if (!user) return Resolve(res).created(patch, 'No valid user found. Please try again.');
+ const user = await UserModel.findById(tokenUser.userId);
+ if (!user) return Resolve(res).badRequest('No valid user found. Please try again.');
- const bodySchema = Joi.object({
- password: Joi.string().trim().required().messages({
- 'string.base': 'The given password must be a string.',
- 'any.required': 'Password is required.',
- }),
- confirmpassword: Joi.string().trim().required().valid(Joi.ref('password')).messages({
- 'string.base': 'The given password must be a string.',
- 'any.required': 'Confirm password is required.',
- 'string.notEqual': 'New passwords do not match.',
- }),
- });
+ const bodySchema = Joi.object({
+ password: Joi.string().trim().required().messages({
+ 'string.base': 'The given password must be a string.',
+ 'any.required': 'Password is required.',
+ }),
+ confirmpassword: Joi.string().trim().required().valid(Joi.ref('password')).messages({
+ 'string.base': 'The given password must be a string.',
+ 'any.required': 'Confirm password is required.',
+ 'string.notEqual': 'New passwords do not match.',
+ }),
+ });
- const bodyValidationResult = bodySchema.validate(req.body);
- if (bodyValidationResult.error) return Resolve(res).created(patch, 'New passwords do not match.');
+ const bodyValidationResult = bodySchema.validate(req.body);
+ if (bodyValidationResult.error) return Resolve(res).badRequest('New passwords do not match.');
- const { value: body } = bodyValidationResult;
+ const { value: body } = bodyValidationResult;
- await user.updateOne({ password: await createHash(body.password) });
+ await user.updateOne({ password: await createHash(body.password) });
- await tokenUser.deleteOne();
+ await tokenUser.deleteOne();
- return Resolve(res).created(patch, 'Password has been reset successfully.');
-
-};
\ No newline at end of file
+ return Resolve(res).created(patch, 'Password has been reset successfully.');
+};
diff --git a/server/src/routes/user/saved.ts b/server/src/routes/user/saved.ts
new file mode 100644
index 00000000..493cebe0
--- /dev/null
+++ b/server/src/routes/user/saved.ts
@@ -0,0 +1,51 @@
+import { Handler } from 'express';
+import { authProtected } from '../../middlewares/auth-protected';
+import { UserModel } from '../../models/user';
+import { PostModel } from '../../models/post';
+import { Resolve } from '../../utils/express';
+
+/**
+ * GET @ /user/saved
+ * Fetches the saved posts by a user.
+ */
+export const get: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const user = req.user!._id;
+
+ const rawPage = parseInt(typeof req.query.page === 'string' ? req.query.page : '');
+ const page = Math.max(1, typeof rawPage !== 'number' || isNaN(rawPage) ? 1 : rawPage);
+ const limit = 20;
+ const skip = (page - 1) * limit;
+
+ try {
+ const userDoc = await UserModel.findById(user).select('savedPosts').lean();
+ if (!userDoc) return Resolve(res).notFound('User not found.');
+
+ const savedPostsIds = userDoc.savedPosts || [];
+
+ const savedPostsDetails = await PostModel.aggregate([
+ { $match: { _id: { $in: savedPostsIds }, deleted: false } },
+ { $sort: { createdAt: -1 } },
+ { $skip: skip },
+ { $limit: limit },
+ {
+ $lookup: {
+ from: 'users',
+ localField: 'authorId',
+ foreignField: '_id',
+ as: 'author',
+ },
+ },
+ { $unwind: { path: '$author' } },
+ { $addFields: { userName: '$author.userName', avatarUrl: '$author.avatarUrl' } },
+ { $project: { author: 0 } },
+ ]);
+
+ return Resolve(res).okWith({ savedPosts: savedPostsDetails });
+ } catch (error) {
+ console.error(error);
+ return Resolve(res).error('Error fetching saved posts.');
+ }
+ }
+];
diff --git a/server/src/routes/user/search/[search].ts b/server/src/routes/user/search/[search].ts
new file mode 100644
index 00000000..f1d9d6a9
--- /dev/null
+++ b/server/src/routes/user/search/[search].ts
@@ -0,0 +1,51 @@
+import { Handler } from 'express';
+import { Resolve } from '../../../utils/express';
+import { IUser, UserModel } from '../../../models/user';
+import { authProtected } from '../../../middlewares/auth-protected';
+import mongoose from 'mongoose';
+import { escapeRegex } from '../../../utils/regex';
+
+/**
+ * GET @ /user/search/:search?page
+ *
+ * This searches in the database for a user with or that contains the specified string
+ * passed as an url parameter and uses the query page for pagination.
+ */
+export const get: Handler[] = [
+ authProtected,
+ async (req, res) => {
+ const search = req.params.search.trim();
+
+ const rawPage = parseInt(typeof req.query.page === 'string' ? req.query.page : '');
+ const page = Math.max(1, typeof rawPage !== 'number' || isNaN(rawPage) ? 1 : rawPage);
+ const limit = 20;
+ const skip = (page - 1) * limit;
+
+ let userSearch: mongoose.FilterQuery;
+
+ const users = await UserModel.exists({ userName: { $regex: search, $options: 'si' } }).lean();
+
+ if (!users) return Resolve(res).notFound('No user or posts found with this search key.');
+ else {
+ userSearch = { userName: { $regex: escapeRegex(search), $options: 'si' } };
+ const searchedUsers = await UserModel.aggregate([
+ { $match: { ...userSearch, deleted: { $not: { $eq: true } } } },
+ { $sort: { createdAt: -1 } },
+ { $skip: skip },
+ { $limit: limit },
+ {
+ $project: {
+ userName: 1,
+ avatarUrl: 1,
+ followerCount: 1,
+ followingCount: 1,
+ postCount: 1,
+ },
+ },
+ ]);
+
+ Resolve(res).okWith(searchedUsers);
+ }
+ },
+];
+``
\ No newline at end of file
diff --git a/server/src/routes/user/signup.ts b/server/src/routes/user/signup.ts
index 678a3df1..34a0ece6 100644
--- a/server/src/routes/user/signup.ts
+++ b/server/src/routes/user/signup.ts
@@ -1,6 +1,7 @@
import Joi from 'joi';
import mongoose from 'mongoose';
import { Handler } from 'express';
+import OpenAI from 'openai';
import { UserModel } from '../../models/user';
import { createHash } from '../../utils/bcrypt';
import { PlanetModel } from '../../models/planet';
@@ -8,6 +9,7 @@ import { assertRequestBody, Resolve } from '../../utils/express';
import { ILocation, RawLocationSchema } from '../../models/location';
import { RawDocument } from '../../@types/model';
import { JWT } from '../../utils/jwt';
+import { imageUpload } from '../../utils/image';
interface PostBody {
email: string;
@@ -17,7 +19,19 @@ interface PostBody {
}
const inflightEmails = new Set();
+const openai = new OpenAI({
+ apiKey: process.env.OPENAI_API_KEY,
+});
+const prompt =
+ 'generate a random centered avatar for a social networking app that displays post from each planets within galaxy';
+/**
+ * POST @ /user/signup
+ *
+ * This creates a new user with the specified parameters.
+ * This also issues a JWT token so the user will not have to
+ * log in right after signing up.
+ */
export const post: Handler = async (req, res) => {
const body = assertRequestBody(
req,
@@ -52,6 +66,19 @@ export const post: Handler = async (req, res) => {
location: body.location,
});
+ openai.images
+ .generate({
+ model: 'dall-e-3',
+ prompt: prompt,
+ size: '1024x1024',
+ })
+ .then((image) => {
+ imageUpload(image.data[0].url).then(async (res) => {
+ if (!res) return;
+ await user.updateOne({ avatarUrl: res.secure_url });
+ });
+ });
+
await user.save();
Resolve(res).okWith(JWT.signAs(user));
} finally {
diff --git a/server/src/utils/email.ts b/server/src/utils/email.ts
index d77bcd2e..22a90c40 100644
--- a/server/src/utils/email.ts
+++ b/server/src/utils/email.ts
@@ -1,5 +1,19 @@
import nodemailer from 'nodemailer';
+/**
+ * Sends an email using the specified options.
+ *
+ * This function uses the Nodemailer library to send an email. The email server configuration
+ * is loaded from environment variables.
+ *
+ * @async
+ * @param option - The email options.
+ * @param option.email - The recipient's email address.
+ * @param option.subject - The subject of the email.
+ * @param option.text - The plain text body of the email.
+ * @returns A promise that resolves when the email is sent.
+ * @throws If sending the email fails.
+ */
const sendEmail = async (option: { email: string, subject: string, text: string }) => {
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
@@ -7,7 +21,7 @@ const sendEmail = async (option: { email: string, subject: string, text: string
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
- },
+ },
} as nodemailer.TransportOptions);
const emailOptions = {
diff --git a/server/src/utils/image.ts b/server/src/utils/image.ts
new file mode 100644
index 00000000..8dd7b986
--- /dev/null
+++ b/server/src/utils/image.ts
@@ -0,0 +1,31 @@
+import { v2 as cloudinary } from 'cloudinary';
+import { v4 as uuidv4 } from 'uuid';
+
+/**
+ * Uploads an image to Cloudinary.
+ *
+ * This function configures the Cloudinary API using environment variables,
+ * generates a unique identifier for the image, and uploads the image to Cloudinary.
+ *
+ * @export
+ * @param image - The image file to be uploaded. This can be a path to a file, a data URI, a Buffer, etc.
+ * @return The result of the upload operation, containing information such as the URL of the uploaded image.
+ */
+export async function imageUpload(image: any) {
+ // Configuration
+ cloudinary.config({
+ cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
+ api_key: process.env.CLOUDINARY_CLOUD_KEY,
+ api_secret: process.env.CLOUDINARY_CLOUD_SECRET
+ });
+
+ let image_uuid = uuidv4();
+ // Upload an image
+ const uploadResult = await cloudinary.uploader.upload(image, {
+ public_id: image_uuid
+ }).catch((error) => {
+ console.log(error)
+ });
+
+ return uploadResult
+}
\ No newline at end of file