diff --git a/.vscode/settings.json b/.vscode/settings.json index 3b747db..dc8ef81 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "jest.rootPath": "./server", "jest.jestCommandLine": "npx jest", - "jest.runMode": "on-demand" + "jest.runMode": "on-demand", + "postman.settings.dotenv-detection-notification-visibility": false } diff --git a/README.md b/README.md index 62aad77..3332092 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/fE-a_qEp) The individual and team project for this class are designed to mirror the experiences of a software engineer joining a new development team: you will be “onboarded” to our codebase, make several individual contributions, and then form a team to propose, develop and implement new features. The codebase that we’ll be developing on is a Fake Stack Overflow project (let’s call it HuskyFlow). You will get an opportunity to work with the starter code which provides basic skeleton for the app and then additional features will be proposed and implemented by you! All implementation will take place in the TypeScript programming language, using React for the user interface. ## Getting Started @@ -101,3 +102,105 @@ npm run stryker ```sh node --max-old-space-size=4096 ./node_modules/.bin/stryker run ``` + +# Community Overflow +--- + +## 🛠️ Local Setup Instructions + +### 1. Clone the Repository + +```bash +git clone https://github.com/neu-cs4530/spring25-team-project-spring25-project-group-212 +cd spring25-team-project-spring25-project-group-212 +``` + +### 2. Install Dependencies + +Run the following in the root, `client`, and `server` directories: + +```bash +npm install +``` + +### 3. Environment Variables + +#### In the `client` directory, create a `.env` file with: + +``` +REACT_APP_SERVER_URL=http://localhost:8000 +``` + +#### In the `server` directory, create a `.env` file with: + +``` +MONGODB_URI=mongodb://127.0.0.1:27017 +CLIENT_URL=http://localhost:3000 +PORT=8000 +GEMINI_API_KEY={Add your gemini api key here} +``` + +To get a Gemini API key, follow the instructions here: [https://aistudio.google.com/welcome](https://aistudio.google.com/welcome) + +--- + +## ☁️ Using Your Own MongoDB Database + +1. Create a new cluster on [MongoDB Atlas](https://www.mongodb.com/cloud/atlas). +2. In **Network Access**, allow access from anywhere. +3. Connect to your cluster; it should open in MongoDB Compass. +4. Populate the database: + +```bash +cd server +npx ts-node populate_db.ts /fake_so +``` + +--- + +## 🚀 Deploying to Render + +### Server Setup + +1. Go to the [Render Dashboard](https://dashboard.render.com/) and create a **new Web Service**. +2. Select the GitHub project. +3. Choose: + - **Language**: Node + - **Branch**: `main` + - **Root Directory**: `server` + - **Build Command**: `cd ..; npm install; npm run build --workspace=server` + - **Start Command**: `npm run start:prod` +4. Add an environment variable: + +``` +MONGODB_URI = +``` + +5. Deploy the service. + +### Client Setup + +1. Create a **new Static Site** on Render. +2. Select the GitHub project. +3. Choose: + - **Branch**: `main` + - **Root Directory**: `client` + - **Build Command**: `cd ..; npm install; npm run build --workspace=shared; npm run build --workspace=client` + - **Publish Directory**: `build` +4. Add environment variables: + +``` +REACT_APP_SERVER_URL = +CLIENT_URL = +``` + +5. Add a **"Rewrite" action**: + +``` +Source: /* +Destination: /index.html +``` + +6. Save and deploy the site. + +--- diff --git a/client/globals.d.ts b/client/globals.d.ts new file mode 100644 index 0000000..f8f8622 --- /dev/null +++ b/client/globals.d.ts @@ -0,0 +1,4 @@ +declare module '*.wav' { + const src: string; + export default src; +} diff --git a/client/package.json b/client/package.json index 4180263..81f8ab1 100644 --- a/client/package.json +++ b/client/package.json @@ -3,15 +3,31 @@ "version": "0.1.0", "private": true, "dependencies": { + "@chakra-ui/react": "^3.15.0", + "@chakra-ui/theme-tools": "^2.2.6", "@cypress/instrument-cra": "^1.4.0", "@fake-stack-overflow/shared": "^1.0.0", - "axios": "^1.7.9", + "@jitsi/react-sdk": "^1.4.4", + "@tldraw/sync": "^3.11.0", + "axios": "^1.8.4", + "chart.js": "^4.4.8", + "date-fns": "^4.1.0", + "emoji-picker-react": "^4.12.2", + "highlight.js": "^11.11.1", "mongodb": "6.12.0", + "next-themes": "^0.4.6", "react": "^18.3.1", + "react-chartjs-2": "^5.3.0", "react-dom": "^18.3.1", + "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.28.1", "react-scripts": "5.0.1", - "socket.io-client": "^4.8.1" + "react-uploader": "^3.43.0", + "rehype-highlight": "^7.0.2", + "socket.io-client": "^4.8.1", + "tldraw": "^3.10.3", + "uploader": "^3.48.3" }, "scripts": { "start": "react-scripts -r @cypress/instrument-cra start", diff --git a/client/public/favicon.ico b/client/public/favicon.ico index a11777c..fe314e2 100644 Binary files a/client/public/favicon.ico and b/client/public/favicon.ico differ diff --git a/client/public/index.html b/client/public/index.html index d79d7a7..e157bc4 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -21,7 +21,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - React App + Community Overflow diff --git a/client/src/assets/bells.wav b/client/src/assets/bells.wav new file mode 100644 index 0000000..8902bd5 Binary files /dev/null and b/client/src/assets/bells.wav differ diff --git a/client/src/components/auth/login/index.tsx b/client/src/components/auth/login/index.tsx index f64aac4..c0b3913 100644 --- a/client/src/components/auth/login/index.tsx +++ b/client/src/components/auth/login/index.tsx @@ -20,7 +20,7 @@ const Login = () => { return (
-

Welcome to FakeStackOverflow!

+

Welcome to Community Overflow!

Please login to continue.

Please enter your username.

diff --git a/client/src/components/auth/signup/index.tsx b/client/src/components/auth/signup/index.tsx index 3f7b036..ae6e490 100644 --- a/client/src/components/auth/signup/index.tsx +++ b/client/src/components/auth/signup/index.tsx @@ -21,7 +21,7 @@ const Signup = () => { return (
-

Sign up for FakeStackOverflow!

+

Sign up for Community Overflow!

Please enter your username.

{ } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } diff --git a/client/src/components/header/index.css b/client/src/components/header/index.css index fbacacb..c24c6f1 100644 --- a/client/src/components/header/index.css +++ b/client/src/components/header/index.css @@ -39,4 +39,4 @@ .view-profile-button:active { background-color: #003f7f; -} \ No newline at end of file +} diff --git a/client/src/components/header/index.tsx b/client/src/components/header/index.tsx index 0e9c4d6..aad79b0 100644 --- a/client/src/components/header/index.tsx +++ b/client/src/components/header/index.tsx @@ -1,5 +1,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; +import { Flex, Box, Text, Spacer, Button } from '@chakra-ui/react'; +import { FiLogOut, FiUser } from 'react-icons/fi'; import useHeader from '../../hooks/useHeader'; import './index.css'; import useUserContext from '../../hooks/useUserContext'; @@ -10,30 +12,35 @@ import useUserContext from '../../hooks/useUserContext'; * when they press Enter. */ const Header = () => { - const { val, handleInputChange, handleKeyDown, handleSignOut } = useHeader(); + const { handleSignOut } = useHeader(); const { user: currentUser } = useUserContext(); const navigate = useNavigate(); + return ( - + + + + Community Overflow + + + + + + + + ); }; diff --git a/client/src/components/layout/index.css b/client/src/components/layout/index.css index 84eb87a..8e297d4 100644 --- a/client/src/components/layout/index.css +++ b/client/src/components/layout/index.css @@ -18,12 +18,22 @@ padding: 2% 0 2% 2%; } +.right_bottom_padding { + padding: 2% 0 1% 2%; +} + .bold_title { font-size: 24px; font-weight: 800; line-height: 40px; } +.not_quite_so_bold_title { + font-size: 18px; + font-weight: 600; + line-height: 30px; +} + .bluebtn { background: #3090e2; color: #ffffff; diff --git a/client/src/components/main/answerPage/answer/index.css b/client/src/components/main/answerPage/answer/index.css index 3762a22..7df4f14 100644 --- a/client/src/components/main/answerPage/answer/index.css +++ b/client/src/components/main/answerPage/answer/index.css @@ -1,4 +1,3 @@ -/* All the CSS related to answers Page*/ .answer { display: flex; flex-direction: row; @@ -16,3 +15,92 @@ .answer_author { color: green; } + +.markdown-box { + background-color: #f9f9f9; + padding: 1.25rem; + border: 1px solid #ddd; + border-radius: 6px; + font-family: system-ui, sans-serif; + line-height: 1.6; + overflow-x: auto; +} + +.markdown-box h1, +.markdown-box h2, +.markdown-box h3 { + margin-top: 1rem; + margin-bottom: 0.5rem; + font-weight: 600; +} +.markdown-box h1 { + font-size: 1.75rem; +} +.markdown-box h2 { + font-size: 1.5rem; +} +.markdown-box h3 { + font-size: 1.25rem; +} + +.markdown-box p { + margin: 0.75rem 0; +} + +.markdown-box ul, +.markdown-box ol { + padding-left: 1.5rem; + margin: 0.5rem 0; +} +.markdown-box li { + margin-bottom: 0.5rem; +} + +.markdown-box pre { + background-color: #2d2d2d; + color: #f8f8f2; + padding: 1rem; + border-radius: 4px; + overflow-x: auto; +} +.markdown-box code { + background-color: #eee; + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + font-size: 0.95em; +} + +.markdown-box blockquote { + border-left: 4px solid #ccc; + padding-left: 1rem; + color: #555; + margin: 1rem 0; + font-style: italic; +} + +.markdown-box a { + color: #007acc; + text-decoration: underline; +} +.markdown-box a:hover { + color: #005fa3; +} + +.markdown-box ul, +.markdown-box ol { + margin: 1rem 0; + padding-left: 1.5rem; +} + +.markdown-box ul { + list-style-type: disc; +} + +.markdown-box ol { + list-style-type: decimal; +} + +.markdown-box li { + margin-bottom: 0.3rem; +} diff --git a/client/src/components/main/answerPage/answer/index.tsx b/client/src/components/main/answerPage/answer/index.tsx index 8c094ef..de591e0 100644 --- a/client/src/components/main/answerPage/answer/index.tsx +++ b/client/src/components/main/answerPage/answer/index.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { Box, Flex } from '@chakra-ui/react'; +import ReactMarkdown from 'react-markdown'; import { handleHyperlink } from '../../../../tool'; import CommentSection from '../../commentSection'; import './index.css'; @@ -12,6 +14,7 @@ import { Comment, DatabaseComment } from '../../../../types/types'; * - meta Additional metadata related to the answer. * - comments An array of comments associated with the answer. * - handleAddComment Callback function to handle adding a new comment. + * - isMarkdown Boolean indicating if the text should be rendered as markdown. */ interface AnswerProps { text: string; @@ -19,29 +22,44 @@ interface AnswerProps { meta: string; comments: DatabaseComment[]; handleAddComment: (comment: Comment) => void; + isMarkdown?: boolean; } /** * AnswerView component that displays the content of an answer with the author's name and metadata. - * The answer text is processed to handle hyperlinks, and a comment section is included. + * The answer text is processed to handle hyperlinks or rendered as markdown based on the isMarkdown prop. * * @param text The content of the answer. * @param ansBy The username of the answer's author. * @param meta Additional metadata related to the answer. * @param comments An array of comments associated with the answer. * @param handleAddComment Function to handle adding a new comment. + * @param isMarkdown Boolean indicating if the text should be rendered as markdown. */ -const AnswerView = ({ text, ansBy, meta, comments, handleAddComment }: AnswerProps) => ( -
-
- {handleHyperlink(text)} -
-
-
{ansBy}
-
{meta}
-
+const AnswerView = ({ + text, + ansBy, + meta, + comments, + handleAddComment, + isMarkdown = false, +}: AnswerProps) => ( + + + {isMarkdown ? ( +
+ {text} +
+ ) : ( + handleHyperlink(text) + )} +
+ + {ansBy} + {meta} + -
+ ); export default AnswerView; diff --git a/client/src/components/main/answerPage/header/index.tsx b/client/src/components/main/answerPage/header/index.tsx index 9bd2489..6c6c636 100644 --- a/client/src/components/main/answerPage/header/index.tsx +++ b/client/src/components/main/answerPage/header/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import './index.css'; +import { Flex, Text } from '@chakra-ui/react'; import AskQuestionButton from '../../askQuestionButton'; /** @@ -21,11 +22,21 @@ interface AnswerHeaderProps { * @param title The title of the question or discussion thread. */ const AnswerHeader = ({ ansCount, title }: AnswerHeaderProps) => ( -
-
{ansCount} answers
-
{title}
+ + + {ansCount} answers + + + {title} + -
+ ); export default AnswerHeader; diff --git a/client/src/components/main/answerPage/index.tsx b/client/src/components/main/answerPage/index.tsx index 49539e7..5e17249 100644 --- a/client/src/components/main/answerPage/index.tsx +++ b/client/src/components/main/answerPage/index.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Box, Button } from '@chakra-ui/react'; import { getMetaData } from '../../../tool'; import AnswerView from './answer'; import AnswerHeader from './header'; @@ -23,13 +24,20 @@ const AnswerPage = () => { return ( <> - - + + + + + + handleNewComment(comment, 'question', questionID)} @@ -41,18 +49,20 @@ const AnswerPage = () => { ansBy={a.ansBy} meta={getMetaData(new Date(a.ansDateTime))} comments={a.comments} + isMarkdown={a.useMarkdown} handleAddComment={(comment: Comment) => handleNewComment(comment, 'answer', String(a._id)) } /> ))} - + ); }; diff --git a/client/src/components/main/answerPage/questionBody/index.css b/client/src/components/main/answerPage/questionBody/index.css index ef9537a..864a2ba 100644 --- a/client/src/components/main/answerPage/questionBody/index.css +++ b/client/src/components/main/answerPage/questionBody/index.css @@ -1,7 +1,6 @@ .questionBody { display: flex; flex-direction: row; - /* justify-content: space-between; */ margin-top: 5%; border-bottom: #000000 1px dashed; } @@ -19,3 +18,92 @@ flex-direction: column; margin-left: 5%; } + +.markdown-box { + background-color: #f9f9f9; + padding: 1.25rem; + border: 1px solid #ddd; + border-radius: 6px; + font-family: system-ui, sans-serif; + line-height: 1.6; + overflow-x: auto; +} + +.markdown-box h1, +.markdown-box h2, +.markdown-box h3 { + margin-top: 1rem; + margin-bottom: 0.5rem; + font-weight: 600; +} +.markdown-box h1 { + font-size: 1.75rem; +} +.markdown-box h2 { + font-size: 1.5rem; +} +.markdown-box h3 { + font-size: 1.25rem; +} + +.markdown-box p { + margin: 0.75rem 0; +} + +.markdown-box ul, +.markdown-box ol { + padding-left: 1.5rem; + margin: 0.5rem 0; +} +.markdown-box li { + margin-bottom: 0.5rem; +} + +.markdown-box pre { + background-color: #2d2d2d; + color: #f8f8f2; + padding: 1rem; + border-radius: 4px; + overflow-x: auto; +} +.markdown-box code { + background-color: #eee; + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + font-size: 0.95em; +} + +.markdown-box blockquote { + border-left: 4px solid #ccc; + padding-left: 1rem; + color: #555; + margin: 1rem 0; + font-style: italic; +} + +.markdown-box a { + color: #007acc; + text-decoration: underline; +} +.markdown-box a:hover { + color: #005fa3; +} + +.markdown-box ul, +.markdown-box ol { + margin: 1rem 0; + padding-left: 1.5rem; +} + +.markdown-box ul { + list-style-type: disc; +} + +.markdown-box ol { + list-style-type: decimal; +} + +.markdown-box li { + margin-bottom: 0.3rem; +} diff --git a/client/src/components/main/answerPage/questionBody/index.tsx b/client/src/components/main/answerPage/questionBody/index.tsx index f57b29b..0ef44c7 100644 --- a/client/src/components/main/answerPage/questionBody/index.tsx +++ b/client/src/components/main/answerPage/questionBody/index.tsx @@ -1,6 +1,11 @@ -import React from 'react'; -import './index.css'; +import React, { useEffect } from 'react'; +import ReactMarkdown from 'react-markdown'; +import rehypeHighlight from 'rehype-highlight'; + +import { Box, Text, Button, VStack, Grid, GridItem, Flex } from '@chakra-ui/react'; import { handleHyperlink } from '../../../../tool'; +import useUserContext from '../../../../hooks/useUserContext'; +import useQuestion from '../../../../hooks/useQuestion'; /** * Interface representing the props for the QuestionBody component. @@ -9,33 +14,93 @@ import { handleHyperlink } from '../../../../tool'; * - text - The content of the question, which may contain hyperlinks. * - askby - The username of the user who asked the question. * - meta - Additional metadata related to the question, such as the date and time it was asked. + * - isMarkdown - Boolean indicating if the text should be rendered as markdown. + * - qid - String representing the ObjectId of the question. + * - anonymous - If the question should be rendered anonymous. */ interface QuestionBodyProps { views: number; text: string; askby: string; meta: string; + isMarkdown?: boolean; + qid: string; + anonymous: boolean; } /** * QuestionBody component that displays the body of a question. - * It includes the number of views, the question content (with hyperlink handling), + * It includes the number of views, the question content (with hyperlink or markdown handling), * the username of the author, and additional metadata. * * @param views The number of views the question has received. * @param text The content of the question. * @param askby The username of the question's author. * @param meta Additional metadata related to the question. + * @param isMarkdown Whether to render the text as markdown. + * @param qid String representing the ObjectId of the question. + * @param anonymous If the question should be rendered anonymous. */ -const QuestionBody = ({ views, text, askby, meta }: QuestionBodyProps) => ( -
-
{views} views
-
{handleHyperlink(text)}
-
-
{askby}
-
asked {meta}
-
-
-); +const QuestionBody = ({ + views, + text, + askby, + meta, + isMarkdown = false, + qid, + anonymous, +}: QuestionBodyProps) => { + const { user: currentUser } = useUserContext(); + const { handleToggleSaveQuestion, handleSetQuestionSaved, questionSaved } = useQuestion(); + + useEffect(() => { + handleSetQuestionSaved(currentUser.username, qid); + }, [currentUser.username, qid, handleSetQuestionSaved]); + + return ( + + + + {views} views + + + + + {isMarkdown ? ( +
+ {text} +
+ ) : ( + handleHyperlink(text) + )} +
+
+ + + + {anonymous ? 'Anonymous' : askby} + + asked {meta} + + + + + + + +
+ ); +}; export default QuestionBody; diff --git a/client/src/components/main/askQuestionButton/index.tsx b/client/src/components/main/askQuestionButton/index.tsx index 2216e01..878da58 100644 --- a/client/src/components/main/askQuestionButton/index.tsx +++ b/client/src/components/main/askQuestionButton/index.tsx @@ -1,3 +1,4 @@ +import { Button } from '@chakra-ui/react'; import React from 'react'; import { useNavigate } from 'react-router-dom'; @@ -17,13 +18,9 @@ const AskQuestionButton = () => { }; return ( - + ); }; diff --git a/client/src/components/main/baseComponents/checkbox/index.css b/client/src/components/main/baseComponents/checkbox/index.css new file mode 100644 index 0000000..1f4dc5c --- /dev/null +++ b/client/src/components/main/baseComponents/checkbox/index.css @@ -0,0 +1,4 @@ +.inline-checkbox-hint { + display: flex; + margin-bottom: 8px; +} \ No newline at end of file diff --git a/client/src/components/main/baseComponents/checkbox/index.tsx b/client/src/components/main/baseComponents/checkbox/index.tsx new file mode 100644 index 0000000..fe23144 --- /dev/null +++ b/client/src/components/main/baseComponents/checkbox/index.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import '../input/index.css'; +import './index.css'; + +/** + * Interface representing the props for the Checkbox component. + * + * - title - The label to display + * - hint - An optional helper text providing additional information. + * - id - The unique identifier for the input field. + * - val - The current value of the input field. + * - setState - Callback function to update the state with the input field's value. + * - err - An error message displayed if there's an issue with the input. + */ +interface CheckboxProps { + title: string; + hint?: string; + id: string; + val: boolean; + setState: (value: boolean) => void; +} + +/** + * Checkbox component renders a customizable checkbox with optional title, hint, + * and error message. + * + * @param title - The label of the checkbox. + * @param hint - Optional text providing additional instructions. + * @param id - The unique identifier of the checkbox element. + * @param val - The current value of the checkbox. + * @param setState - The function to update the state of the checkbox value. + * @param err - Optional error message displayed when there's an issue with input. + */ +const Checkbox = ({ title, hint, id, val, setState }: CheckboxProps) => ( + <> +
{title}
+
+ { + setState(e.target.checked); + }} + /> + {hint &&
{hint}
} +
+ +); + +export default Checkbox; diff --git a/client/src/components/main/commentSection/index.tsx b/client/src/components/main/commentSection/index.tsx index 5ca5100..d341dcc 100644 --- a/client/src/components/main/commentSection/index.tsx +++ b/client/src/components/main/commentSection/index.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { Button } from '@chakra-ui/react'; import { getMetaData } from '../../../tool'; import { Comment, DatabaseComment } from '../../../types/types'; import './index.css'; @@ -78,9 +79,9 @@ const CommentSection = ({ comments, handleAddComment }: CommentSectionProps) => onChange={e => setText(e.target.value)} className='comment-textarea' /> - +
{textErr && {textErr}}
diff --git a/client/src/components/main/communitiesListPage/communitiesListCardJoin/index.tsx b/client/src/components/main/communitiesListPage/communitiesListCardJoin/index.tsx new file mode 100644 index 0000000..976c85c --- /dev/null +++ b/client/src/components/main/communitiesListPage/communitiesListCardJoin/index.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Box, Button, Text, VStack } from '@chakra-ui/react'; +import { PopulatedDatabaseCommunity } from '@fake-stack-overflow/shared'; + +const CommunitiesListCardJoin = ({ + community, + handleCommunityJoin, + handleCommunityPreview, +}: { + community: PopulatedDatabaseCommunity; + handleCommunityJoin: (communityId: string | undefined) => void; + handleCommunityPreview: (communityId: string | undefined) => void; +}) => ( + + + + Community Name: {community.name} + + + Community Rules: {community.rules} + + + + + + + +); +export default CommunitiesListCardJoin; diff --git a/client/src/components/main/communitiesListPage/communitiesListCardView/index.tsx b/client/src/components/main/communitiesListPage/communitiesListCardView/index.tsx new file mode 100644 index 0000000..8dbb5a5 --- /dev/null +++ b/client/src/components/main/communitiesListPage/communitiesListCardView/index.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Box, Button, Text, VStack } from '@chakra-ui/react'; +import { PopulatedDatabaseCommunity } from '@fake-stack-overflow/shared'; + +const CommunitiesListCardView = ({ + community, + handleCommunityJoin, +}: { + community: PopulatedDatabaseCommunity; + handleCommunityJoin: (communityId: string | undefined) => void; +}) => ( + + + + Community Name: {community.name} + + + Community Rules: {community.rules} + + + + +); + +export default CommunitiesListCardView; diff --git a/client/src/components/main/communitiesListPage/index.tsx b/client/src/components/main/communitiesListPage/index.tsx new file mode 100644 index 0000000..f3d25fc --- /dev/null +++ b/client/src/components/main/communitiesListPage/index.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Box, Button, Heading, Text, HStack, SimpleGrid } from '@chakra-ui/react'; +import useCommunitiesListPage from '../../../hooks/useCommunitiesListPage'; +import useUserContext from '../../../hooks/useUserContext'; +import CommunitiesListCardJoin from './communitiesListCardJoin'; +import CommunitiesListCardView from './communitiesListCardView'; + +const CommunitiesListPage = () => { + const { + communities, + error, + handleJoin, + handleCreateCommunity, + sortCommunitiesBy, + handlePreviewCommunity, + } = useCommunitiesListPage(); + const { user } = useUserContext(); + + const joinedCommunities = communities.filter(c => c.members.includes(user.username)); + const unjoinedCommunities = communities.filter(c => !c.members.includes(user.username)); + + return ( + + {error && ( + + {error} + + )} + + + Communities + + + + + + + + + + + + + Joined Communities + + + {joinedCommunities.length > 0 ? ( + joinedCommunities.map(c => ( + handleJoin(c._id.toString())} + /> + )) + ) : ( + No joined communities yet. + )} + + + + + + + + Other Communities + + + {unjoinedCommunities.length > 0 ? ( + unjoinedCommunities.map(c => ( + handleJoin(c._id.toString())} + handleCommunityPreview={() => handlePreviewCommunity(c._id.toString())} + /> + )) + ) : ( + No other communities available. + )} + + + + ); +}; + +export default CommunitiesListPage; diff --git a/client/src/components/main/communityPage/CommunityQuestionHeader/index.tsx b/client/src/components/main/communityPage/CommunityQuestionHeader/index.tsx new file mode 100644 index 0000000..74e5d47 --- /dev/null +++ b/client/src/components/main/communityPage/CommunityQuestionHeader/index.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Box, ButtonGroup, Flex, Text } from '@chakra-ui/react'; +import OrderButton from '../../questionPage/header/orderButton'; +import { OrderType } from '../../../../types/types'; +import { orderTypeDisplayName } from '../../../../types/constants'; +import AskQuestionInCommunityButton from '../askQuestionInCommunityButton'; + +/** + * Interface representing the props for the QuestionHeader component. + * + * titleText - The title text displayed at the top of the header. + * qcnt - The number of questions to be displayed in the header. + * setQuestionOrder - A function that sets the order of questions based on the selected message. + */ +interface QuestionHeaderProps { + titleText: string; + qcnt: number; + setQuestionOrder: (order: OrderType) => void; +} + +/** + * CommunityQuestionHeader component displays the header section for a list of questions within a community. + * It includes the title, a button to ask a new question, the number of the quesions, + * and buttons to set the order of questions. It also includes the button to ask a question within the community. + * + * @param titleText - The title text to display in the header. + * @param qcnt - The number of questions displayed in the header. + * @param setQuestionOrder - Function to set the order of questions based on input message. + */ +const CommunityQuestionHeader = ({ titleText, qcnt, setQuestionOrder }: QuestionHeaderProps) => ( + + {/* Title and Ask Question Button */} + + + {titleText} + + + + + + + {qcnt} questions + + + {Object.keys(orderTypeDisplayName).map(order => ( + + ))} + + + +); + +export default CommunityQuestionHeader; diff --git a/client/src/components/main/communityPage/askQuestionInCommunityButton/index.tsx b/client/src/components/main/communityPage/askQuestionInCommunityButton/index.tsx new file mode 100644 index 0000000..274d7fd --- /dev/null +++ b/client/src/components/main/communityPage/askQuestionInCommunityButton/index.tsx @@ -0,0 +1,27 @@ +import { Button } from '@chakra-ui/react'; +import React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +/** + * AskQuestionButton component that renders a button for navigating to the + * "New Question" page. When clicked, it redirects the user to the page + * where they can ask a new question. + */ +const AskQuestionInCommunityButton = () => { + const navigate = useNavigate(); + const { id } = useParams(); + /** + * Function to handle navigation to the "New Question in Community" page. + */ + const handleNewQuestion = () => { + navigate(`/new/questionInCommunity/${id}`); + }; + + return ( + + ); +}; + +export default AskQuestionInCommunityButton; diff --git a/client/src/components/main/communityPage/bulletinBoardPage/index.css b/client/src/components/main/communityPage/bulletinBoardPage/index.css new file mode 100644 index 0000000..a89f206 --- /dev/null +++ b/client/src/components/main/communityPage/bulletinBoardPage/index.css @@ -0,0 +1,13 @@ +/* Primary action button (Reset, Edit, etc.) */ +.login-button { + background-color: #007bff; + color: white; + } + + .login-button:hover { + background-color: #0056b3; + } + + .login-button:active { + background-color: #003f7f; + } \ No newline at end of file diff --git a/client/src/components/main/communityPage/bulletinBoardPage/index.tsx b/client/src/components/main/communityPage/bulletinBoardPage/index.tsx new file mode 100644 index 0000000..fc13d73 --- /dev/null +++ b/client/src/components/main/communityPage/bulletinBoardPage/index.tsx @@ -0,0 +1,132 @@ +import { Tldraw, useEditor } from 'tldraw'; +import { useSyncDemo } from '@tldraw/sync'; +import 'tldraw/tldraw.css'; +import './index.css'; +import { JaaSMeeting } from '@jitsi/react-sdk'; +import { useEffect, useState } from 'react'; +import { IJitsiMeetExternalApi } from '@jitsi/react-sdk/lib/types'; +import { useLocation, useParams } from 'react-router-dom'; +import { Box, Button, Text, VStack } from '@chakra-ui/react'; +import useBulletinBoardPage from '../../../../hooks/useBulletinBoardPage'; +import CommunityNavBar from '../communityNavBar'; +import useUserContext from '../../../../hooks/useUserContext'; + +const BulletinBoardPage = () => { + const { user, socket } = useUserContext(); + const [isInCall, setIsInCall] = useState(true); + const { id } = useParams(); + const { + handleBulletinBoardLoad, + handleBulletinBoardSave, + showCheckMark, + setShowCheckMark, + bulletinBoardError, + community, + } = useBulletinBoardPage(); + const store = useSyncDemo({ roomId: `${id?.toString()}` }); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const isPreview = searchParams.get('preview') === 'true'; + + const handleApiReady = (externalApi: IJitsiMeetExternalApi) => { + externalApi.addListener('videoConferenceJoined', () => { + setIsInCall(true); + }); + + externalApi.addListener('videoConferenceLeft', () => { + setIsInCall(false); + }); + }; + useEffect(() => { + if (!community) { + return; + } + socket.emit('onlineUser', community?._id.toString(), user.username); + }, [community, socket, user.username]); + + function SnapshotToolbar() { + const editor = useEditor(); + return ( +
+ + Saved ✅ + + + +
+ ); + } + return ( + <> + {bulletinBoardError !== '' ? ( + {bulletinBoardError} + ) : ( +
+ +
+
+ {!isPreview ? ( + + ) : ( + + )} +
+ {!isPreview && ( +
+ {isInCall ? ( + { + iframeRef.style.height = '100%'; + iframeRef.style.width = '100%'; + }} + onApiReady={handleApiReady} + /> + ) : ( + + + + Call ended. Click below to rejoin. + + + + + )} +
+ )} +
+
+ )} + + ); +}; + +export default BulletinBoardPage; diff --git a/client/src/components/main/communityPage/bulletinBoardPage/snapshot.json b/client/src/components/main/communityPage/bulletinBoardPage/snapshot.json new file mode 100644 index 0000000..97f79a3 --- /dev/null +++ b/client/src/components/main/communityPage/bulletinBoardPage/snapshot.json @@ -0,0 +1,1216 @@ +{ + "document": { + "store": { + "document:document": { + "gridSize": 10, + "name": "", + "meta": {}, + "id": "document:document", + "typeName": "document" + }, + "page:page": { + "meta": {}, + "id": "page:page", + "name": "Page 1", + "index": "a1", + "typeName": "page" + }, + "asset:-2122303015": { + "meta": {}, + "type": "image", + "props": { + "name": "tldrawFile", + "src": "data:image/png;base64,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", + "w": 242, + "h": 116, + "mimeType": "image/png", + "isAnimated": false + }, + "id": "asset:-2122303015", + "typeName": "asset" + }, + "shape:kxWzJDmOhhoa3eIMS_d_U": { + "x": 439.91015625000006, + "y": 408.14453125000006, + "rotation": 0, + "isLocked": false, + "opacity": 1, + "meta": {}, + "id": "shape:kxWzJDmOhhoa3eIMS_d_U", + "type": "draw", + "props": { + "segments": [ + { + "type": "free", + "points": [ + { + "x": 0, + "y": 0, + "z": 0.5 + }, + { + "x": -0.13, + "y": 0.13, + "z": 0.5 + }, + { + "x": -0.05, + "y": 0.27, + "z": 0.5 + }, + { + "x": 0.89, + "y": 0.27, + "z": 0.5 + }, + { + "x": 6.72, + "y": 0.27, + "z": 0.5 + }, + { + "x": 19.89, + "y": -2, + "z": 0.5 + }, + { + "x": 33.39, + "y": -6.93, + "z": 0.5 + }, + { + "x": 45.85, + "y": -14.57, + "z": 0.5 + }, + { + "x": 59.59, + "y": -25.83, + "z": 0.5 + }, + { + "x": 69.66, + "y": -38.32, + "z": 0.5 + }, + { + "x": 74.64, + "y": -50.34, + "z": 0.5 + }, + { + "x": 76.25, + "y": -61.21, + "z": 0.5 + }, + { + "x": 76.23, + "y": -71.55, + "z": 0.5 + }, + { + "x": 74.98, + "y": -79.2, + "z": 0.5 + }, + { + "x": 70.04, + "y": -84.48, + "z": 0.5 + }, + { + "x": 64, + "y": -88.11, + "z": 0.5 + }, + { + "x": 58.74, + "y": -89.03, + "z": 0.5 + }, + { + "x": 52.9, + "y": -88.88, + "z": 0.5 + }, + { + "x": 47.43, + "y": -86.1, + "z": 0.5 + }, + { + "x": 42.63, + "y": -80, + "z": 0.5 + }, + { + "x": 38.77, + "y": -69.36, + "z": 0.5 + }, + { + "x": 36.81, + "y": -51.13, + "z": 0.5 + }, + { + "x": 36.43, + "y": -26.77, + "z": 0.5 + }, + { + "x": 40.13, + "y": 3.13, + "z": 0.5 + }, + { + "x": 48.43, + "y": 38.37, + "z": 0.5 + }, + { + "x": 55.57, + "y": 69.78, + "z": 0.5 + }, + { + "x": 60.53, + "y": 100.75, + "z": 0.5 + }, + { + "x": 63.42, + "y": 133.8, + "z": 0.5 + }, + { + "x": 63.1, + "y": 160.92, + "z": 0.5 + }, + { + "x": 58.09, + "y": 180.57, + "z": 0.5 + }, + { + "x": 47.5, + "y": 192.21, + "z": 0.5 + }, + { + "x": 33.36, + "y": 197.42, + "z": 0.5 + }, + { + "x": 18.86, + "y": 198.58, + "z": 0.5 + }, + { + "x": 6.95, + "y": 197.67, + "z": 0.5 + }, + { + "x": -1.21, + "y": 194.8, + "z": 0.5 + }, + { + "x": -5.55, + "y": 190.55, + "z": 0.5 + }, + { + "x": -6.84, + "y": 184.29, + "z": 0.5 + }, + { + "x": -0.86, + "y": 173.8, + "z": 0.5 + }, + { + "x": 17.39, + "y": 158.11, + "z": 0.5 + }, + { + "x": 45.72, + "y": 137.57, + "z": 0.5 + }, + { + "x": 76.32, + "y": 113.69, + "z": 0.5 + }, + { + "x": 102.73, + "y": 87.41, + "z": 0.5 + }, + { + "x": 120.88, + "y": 63.27, + "z": 0.5 + }, + { + "x": 131.73, + "y": 39.67, + "z": 0.5 + }, + { + "x": 137.52, + "y": 15.89, + "z": 0.5 + }, + { + "x": 138.82, + "y": -0.14, + "z": 0.5 + }, + { + "x": 137.79, + "y": -10.26, + "z": 0.5 + }, + { + "x": 134.94, + "y": -17.39, + "z": 0.5 + }, + { + "x": 131.56, + "y": -21.15, + "z": 0.5 + }, + { + "x": 128.66, + "y": -22.95, + "z": 0.5 + }, + { + "x": 126.65, + "y": -23.48, + "z": 0.5 + }, + { + "x": 125.56, + "y": -23.35, + "z": 0.5 + }, + { + "x": 124.66, + "y": -20.86, + "z": 0.5 + }, + { + "x": 123.36, + "y": -9.53, + "z": 0.5 + }, + { + "x": 120.79, + "y": 12.29, + "z": 0.5 + }, + { + "x": 116.39, + "y": 41.36, + "z": 0.5 + }, + { + "x": 112.64, + "y": 69.82, + "z": 0.5 + }, + { + "x": 111.43, + "y": 96.55, + "z": 0.5 + }, + { + "x": 111.3, + "y": 122.02, + "z": 0.5 + }, + { + "x": 113.43, + "y": 138.52, + "z": 0.5 + }, + { + "x": 119.61, + "y": 149.46, + "z": 0.5 + }, + { + "x": 126.6, + "y": 156.13, + "z": 0.5 + }, + { + "x": 134.3, + "y": 157.33, + "z": 0.5 + }, + { + "x": 144.39, + "y": 154.1, + "z": 0.5 + }, + { + "x": 155.78, + "y": 144.19, + "z": 0.5 + }, + { + "x": 166.99, + "y": 129.98, + "z": 0.5 + }, + { + "x": 176.18, + "y": 113.52, + "z": 0.5 + }, + { + "x": 182.1, + "y": 96.91, + "z": 0.5 + }, + { + "x": 184.19, + "y": 84.67, + "z": 0.5 + }, + { + "x": 184.39, + "y": 75.84, + "z": 0.5 + }, + { + "x": 183.51, + "y": 69.13, + "z": 0.5 + }, + { + "x": 181.32, + "y": 65.31, + "z": 0.5 + }, + { + "x": 178.81, + "y": 63.84, + "z": 0.5 + }, + { + "x": 175.82, + "y": 63.95, + "z": 0.5 + }, + { + "x": 172.76, + "y": 68.43, + "z": 0.5 + }, + { + "x": 170.66, + "y": 80.02, + "z": 0.5 + }, + { + "x": 169.83, + "y": 96.5, + "z": 0.5 + }, + { + "x": 171.36, + "y": 115.16, + "z": 0.5 + }, + { + "x": 175.26, + "y": 128.82, + "z": 0.5 + }, + { + "x": 180.67, + "y": 136.26, + "z": 0.5 + }, + { + "x": 187.09, + "y": 140.58, + "z": 0.5 + }, + { + "x": 193.12, + "y": 140.88, + "z": 0.5 + }, + { + "x": 199.64, + "y": 134.48, + "z": 0.5 + }, + { + "x": 207.02, + "y": 120.32, + "z": 0.5 + }, + { + "x": 213.03, + "y": 103.6, + "z": 0.5 + }, + { + "x": 216.17, + "y": 90.35, + "z": 0.5 + }, + { + "x": 217.28, + "y": 81.68, + "z": 0.5 + }, + { + "x": 217.74, + "y": 76.23, + "z": 0.5 + }, + { + "x": 217.66, + "y": 74.24, + "z": 0.5 + }, + { + "x": 217.05, + "y": 74.53, + "z": 0.5 + }, + { + "x": 215.91, + "y": 79.41, + "z": 0.5 + }, + { + "x": 215.21, + "y": 90.56, + "z": 0.5 + }, + { + "x": 215.13, + "y": 104.09, + "z": 0.5 + }, + { + "x": 215.88, + "y": 113.92, + "z": 0.5 + }, + { + "x": 218.83, + "y": 120, + "z": 0.5 + }, + { + "x": 223.3, + "y": 124.19, + "z": 0.5 + }, + { + "x": 227.71, + "y": 125.39, + "z": 0.5 + }, + { + "x": 232.17, + "y": 123.21, + "z": 0.5 + }, + { + "x": 236.51, + "y": 115.61, + "z": 0.5 + }, + { + "x": 240.17, + "y": 103.63, + "z": 0.5 + }, + { + "x": 242.44, + "y": 92.33, + "z": 0.5 + }, + { + "x": 243.3, + "y": 83.07, + "z": 0.5 + }, + { + "x": 243.52, + "y": 76.01, + "z": 0.5 + }, + { + "x": 243.52, + "y": 72.19, + "z": 0.5 + }, + { + "x": 243.35, + "y": 70.46, + "z": 0.5 + }, + { + "x": 243.19, + "y": 70.11, + "z": 0.5 + }, + { + "x": 243.9, + "y": 71.26, + "z": 0.5 + }, + { + "x": 249, + "y": 75.84, + "z": 0.5 + }, + { + "x": 260.58, + "y": 85.07, + "z": 0.5 + }, + { + "x": 276.09, + "y": 98.02, + "z": 0.5 + }, + { + "x": 291.7, + "y": 113.78, + "z": 0.5 + }, + { + "x": 302.66, + "y": 132.02, + "z": 0.5 + }, + { + "x": 307.07, + "y": 155.19, + "z": 0.5 + }, + { + "x": 301.63, + "y": 180.46, + "z": 0.5 + }, + { + "x": 279.7, + "y": 204.16, + "z": 0.5 + }, + { + "x": 251.84, + "y": 221.13, + "z": 0.5 + }, + { + "x": 227.02, + "y": 228.63, + "z": 0.5 + }, + { + "x": 207.41, + "y": 230.88, + "z": 0.5 + }, + { + "x": 195.65, + "y": 227.65, + "z": 0.5 + }, + { + "x": 190.99, + "y": 216.82, + "z": 0.5 + }, + { + "x": 200.69, + "y": 197.52, + "z": 0.5 + }, + { + "x": 229.42, + "y": 170.67, + "z": 0.5 + }, + { + "x": 272.13, + "y": 137.71, + "z": 0.5 + }, + { + "x": 318.65, + "y": 101.51, + "z": 0.5 + }, + { + "x": 352.09, + "y": 71.88, + "z": 0.5 + }, + { + "x": 368.29, + "y": 50.72, + "z": 0.5 + }, + { + "x": 374.72, + "y": 33.76, + "z": 0.5 + }, + { + "x": 370.11, + "y": 22.56, + "z": 0.5 + }, + { + "x": 352.23, + "y": 17.24, + "z": 0.5 + }, + { + "x": 328.26, + "y": 16.93, + "z": 0.5 + }, + { + "x": 308.98, + "y": 20.41, + "z": 0.5 + }, + { + "x": 295.36, + "y": 25.56, + "z": 0.5 + }, + { + "x": 287.54, + "y": 29.66, + "z": 0.5 + }, + { + "x": 285.12, + "y": 33.56, + "z": 0.5 + } + ] + } + ], + "color": "black", + "fill": "none", + "dash": "draw", + "size": "m", + "isComplete": true, + "isClosed": false, + "isPen": false + }, + "parentId": "page:page", + "index": "a1", + "typeName": "shape" + }, + "shape:spF8FvYHC4kgqB47r37mK": { + "x": 785.953125, + "y": 500.17968750000006, + "rotation": 0, + "isLocked": false, + "opacity": 1, + "meta": {}, + "id": "shape:spF8FvYHC4kgqB47r37mK", + "type": "text", + "props": { + "color": "black", + "size": "m", + "w": 133.484375, + "text": "It worked!", + "font": "draw", + "textAlign": "middle", + "autoSize": true, + "scale": 1 + }, + "parentId": "page:page", + "index": "a2", + "typeName": "shape" + }, + "shape:KJLXYWg4MhLdEC8kVQQ75": { + "x": 818.50390625, + "y": 544.7734375, + "rotation": 0, + "isLocked": false, + "opacity": 1, + "meta": {}, + "id": "shape:KJLXYWg4MhLdEC8kVQQ75", + "type": "draw", + "props": { + "segments": [ + { + "type": "free", + "points": [ + { + "x": 0, + "y": 0, + "z": 0.5 + } + ] + } + ], + "color": "black", + "fill": "none", + "dash": "draw", + "size": "m", + "isComplete": true, + "isClosed": false, + "isPen": false + }, + "parentId": "page:page", + "index": "a3", + "typeName": "shape" + }, + "shape:GwMBhbVosBM4LgbieiieF": { + "x": 860.21484375, + "y": 544.7734375, + "rotation": 0, + "isLocked": false, + "opacity": 1, + "meta": {}, + "id": "shape:GwMBhbVosBM4LgbieiieF", + "type": "draw", + "props": { + "segments": [ + { + "type": "free", + "points": [ + { + "x": 0, + "y": 0, + "z": 0.5 + }, + { + "x": 0.3, + "y": 0, + "z": 0.5 + } + ] + } + ], + "color": "black", + "fill": "none", + "dash": "draw", + "size": "m", + "isComplete": true, + "isClosed": false, + "isPen": false + }, + "parentId": "page:page", + "index": "a4", + "typeName": "shape" + }, + "shape:nRD0YHPlKTcnKbruTutgN": { + "x": 831.80078125, + "y": 551.25390625, + "rotation": 0, + "isLocked": false, + "opacity": 1, + "meta": {}, + "id": "shape:nRD0YHPlKTcnKbruTutgN", + "type": "draw", + "props": { + "segments": [ + { + "type": "free", + "points": [ + { + "x": 0, + "y": 0, + "z": 0.5 + }, + { + "x": -0.27, + "y": 0, + "z": 0.5 + }, + { + "x": -0.53, + "y": 0.21, + "z": 0.5 + }, + { + "x": -0.64, + "y": 0.55, + "z": 0.5 + }, + { + "x": -1.63, + "y": 1.37, + "z": 0.5 + }, + { + "x": -3.02, + "y": 2.95, + "z": 0.5 + }, + { + "x": -4.45, + "y": 4.56, + "z": 0.5 + }, + { + "x": -5.67, + "y": 5.6, + "z": 0.5 + }, + { + "x": -6.29, + "y": 6.38, + "z": 0.5 + }, + { + "x": -6.71, + "y": 6.99, + "z": 0.5 + }, + { + "x": -6.83, + "y": 7.24, + "z": 0.5 + }, + { + "x": -6.66, + "y": 7.36, + "z": 0.5 + }, + { + "x": -6.21, + "y": 7.36, + "z": 0.5 + }, + { + "x": -5.64, + "y": 7.36, + "z": 0.5 + }, + { + "x": -5.07, + "y": 7.36, + "z": 0.5 + }, + { + "x": -4.51, + "y": 7.36, + "z": 0.5 + }, + { + "x": -3.95, + "y": 7.36, + "z": 0.5 + }, + { + "x": -3.42, + "y": 7.36, + "z": 0.5 + }, + { + "x": -2.86, + "y": 7.36, + "z": 0.5 + }, + { + "x": -2.35, + "y": 7.36, + "z": 0.5 + }, + { + "x": -2.07, + "y": 7.36, + "z": 0.5 + } + ] + } + ], + "color": "black", + "fill": "none", + "dash": "draw", + "size": "m", + "isComplete": true, + "isClosed": false, + "isPen": false + }, + "parentId": "page:page", + "index": "a5", + "typeName": "shape" + }, + "shape:3-lPfp0w5eiv_eBk9MINZ": { + "x": 807.5390625, + "y": 565.74609375, + "rotation": 0, + "isLocked": false, + "opacity": 1, + "meta": {}, + "id": "shape:3-lPfp0w5eiv_eBk9MINZ", + "type": "draw", + "props": { + "segments": [ + { + "type": "free", + "points": [ + { + "x": 0, + "y": 0, + "z": 0.5 + }, + { + "x": 0.11, + "y": 0, + "z": 0.5 + }, + { + "x": 1.04, + "y": 0.44, + "z": 0.5 + }, + { + "x": 2.73, + "y": 1.43, + "z": 0.5 + }, + { + "x": 6.05, + "y": 2.77, + "z": 0.5 + }, + { + "x": 10.5, + "y": 4.27, + "z": 0.5 + }, + { + "x": 15.84, + "y": 5.84, + "z": 0.5 + }, + { + "x": 21.36, + "y": 6.87, + "z": 0.5 + }, + { + "x": 26.98, + "y": 7.38, + "z": 0.5 + }, + { + "x": 33.31, + "y": 8.23, + "z": 0.5 + }, + { + "x": 40.36, + "y": 9.04, + "z": 0.5 + }, + { + "x": 46.94, + "y": 9.46, + "z": 0.5 + }, + { + "x": 52, + "y": 9.61, + "z": 0.5 + }, + { + "x": 57.39, + "y": 9.61, + "z": 0.5 + }, + { + "x": 63.11, + "y": 9.11, + "z": 0.5 + }, + { + "x": 68.55, + "y": 7.86, + "z": 0.5 + }, + { + "x": 73, + "y": 6.53, + "z": 0.5 + }, + { + "x": 76.1, + "y": 5.38, + "z": 0.5 + }, + { + "x": 77.62, + "y": 4.19, + "z": 0.5 + }, + { + "x": 78.33, + "y": 3.05, + "z": 0.5 + }, + { + "x": 78.68, + "y": 1.89, + "z": 0.5 + }, + { + "x": 78.83, + "y": 0.77, + "z": 0.5 + }, + { + "x": 78.96, + "y": -0.21, + "z": 0.5 + }, + { + "x": 79.11, + "y": -0.96, + "z": 0.5 + }, + { + "x": 79.39, + "y": -1.52, + "z": 0.5 + }, + { + "x": 79.66, + "y": -1.93, + "z": 0.5 + }, + { + "x": 79.79, + "y": -2.2, + "z": 0.5 + }, + { + "x": 79.79, + "y": -2.44, + "z": 0.5 + }, + { + "x": 79.79, + "y": -2.64, + "z": 0.5 + }, + { + "x": 79.79, + "y": -2.76, + "z": 0.5 + } + ] + } + ], + "color": "black", + "fill": "none", + "dash": "draw", + "size": "m", + "isComplete": true, + "isClosed": false, + "isPen": false + }, + "parentId": "page:page", + "index": "a6", + "typeName": "shape" + }, + "shape:h0jgec1IGyVzAk9aCJ_-_": { + "x": 750.671875, + "y": 605.2578125, + "rotation": 0, + "isLocked": false, + "opacity": 1, + "meta": {}, + "id": "shape:h0jgec1IGyVzAk9aCJ_-_", + "type": "image", + "props": { + "w": 121, + "h": 58, + "assetId": "asset:-2122303015", + "playing": true, + "url": "", + "crop": null + }, + "parentId": "page:page", + "index": "a7", + "typeName": "shape" + } + }, + "schema": { + "schemaVersion": 2, + "sequences": { + "com.tldraw.store": 4, + "com.tldraw.asset": 1, + "com.tldraw.camera": 1, + "com.tldraw.document": 2, + "com.tldraw.instance": 25, + "com.tldraw.instance_page_state": 5, + "com.tldraw.page": 1, + "com.tldraw.instance_presence": 5, + "com.tldraw.pointer": 1, + "com.tldraw.shape": 4, + "com.tldraw.asset.bookmark": 1, + "com.tldraw.asset.image": 3, + "com.tldraw.asset.video": 3, + "com.tldraw.shape.group": 0, + "com.tldraw.shape.text": 2, + "com.tldraw.shape.bookmark": 2, + "com.tldraw.shape.draw": 1, + "com.tldraw.shape.geo": 8, + "com.tldraw.shape.note": 6, + "com.tldraw.shape.line": 4, + "com.tldraw.shape.frame": 0, + "com.tldraw.shape.arrow": 4, + "com.tldraw.shape.highlight": 0, + "com.tldraw.shape.embed": 4, + "com.tldraw.shape.image": 3, + "com.tldraw.shape.video": 2, + "com.tldraw.binding.arrow": 0 + } + } + }, + "session": { + "version": 0, + "currentPageId": "page:page", + "exportBackground": true, + "isFocusMode": false, + "isDebugMode": true, + "isToolLocked": false, + "isGridMode": false, + "pageStates": [ + { + "pageId": "page:page", + "camera": { + "x": -367.0381641329634, + "y": -293.85384513339864, + "z": 1 + }, + "selectedShapeIds": [], + "focusedGroupId": null + } + ] + } +} \ No newline at end of file diff --git a/client/src/components/main/communityPage/communityChat/index.tsx b/client/src/components/main/communityPage/communityChat/index.tsx new file mode 100644 index 0000000..bdeef0c --- /dev/null +++ b/client/src/components/main/communityPage/communityChat/index.tsx @@ -0,0 +1,252 @@ +import { useState, useEffect } from 'react'; +import EmojiPicker from 'emoji-picker-react'; +import { UploadButton } from 'react-uploader'; +import { Spinner, Center, Box, Flex, Input, Button, Text, Badge } from '@chakra-ui/react'; +import { Uploader } from 'uploader'; +import { useLocation } from 'react-router-dom'; +import useCommunityMessagingPage from '../../../../hooks/useCommunityMessagingPage'; +import MessageCard from '../../messageCard'; +import useUserContext from '../../../../hooks/useUserContext'; +import { getOnlineUsersForCommunity, joinCommunity } from '../../../../services/communityService'; +import useCommunityNameAboutRules from '../../../../hooks/useCommunityNameAboutRules'; +import { renameChat } from '../../../../services/chatService'; +import '../index.css'; +import { uploadFile } from '../../../../services/messageService'; +import CommunityNavBar from '../communityNavBar'; + +const uploader = Uploader({ apiKey: 'public_223k28T4HR7pgyJRnMLX4QntHQxQ' }); +const uploaderOptions = { + multi: false, + styles: { + colors: { + primary: '#4A90E2', + }, + }, +}; + +const CommunityChat = () => { + const { + currentCommunity, + communityChat, + newMessage, + setNewMessage, + handleSendMessage, + handleTyping, + typingUsers, + useMarkdown, + setUseMarkdown, + } = useCommunityMessagingPage(); + + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const [onlineUsers, setOnlineUsers] = useState([]); + + const { community } = useCommunityNameAboutRules(); + + const { user, socket } = useUserContext(); + + const [chatName, setChatName] = useState(community?.groupChat?.name || ''); + + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const isPreview = searchParams.get('preview') === 'true'; + + useEffect(() => { + if (!currentCommunity || !user || !socket) return undefined; + + const userHasJoinedCommunity = currentCommunity.members.includes(user.username); + + if (userHasJoinedCommunity) { + socket.emit('joinCommunity', currentCommunity._id.toString(), user.username); + joinCommunity(currentCommunity._id.toString(), user.username); + } + + return () => { + if (userHasJoinedCommunity) { + socket.emit('leaveCommunity', currentCommunity._id.toString(), user.username); + } + }; + }, [currentCommunity, user, socket]); + + useEffect(() => { + if (community?.groupChat?.name) { + setChatName(community.groupChat.name); + } + }, [community]); + + useEffect(() => { + if (!socket || !currentCommunity) { + return undefined; + } + + const updateOnlineUsers = async () => { + const data = await getOnlineUsersForCommunity(currentCommunity._id.toString()); + setOnlineUsers(data.onlineUsers); + }; + + socket.on('onlineUsersUpdate', updateOnlineUsers); + return () => { + socket.off('onlineUsersUpdate', updateOnlineUsers); + }; + }, [socket, currentCommunity]); + + const handleEmojiSelect = (emojiObject: { emoji: string }) => { + setNewMessage(prevMessage => prevMessage + emojiObject.emoji); + setShowEmojiPicker(false); + }; + + const handleRenameChat = async () => { + if (!community || !community.groupChat?._id || !chatName.trim()) return; + + try { + await renameChat(community.groupChat._id, chatName); + setChatName(chatName); + } catch (error) { + throw Error('Failed to rename the chat'); + } + }; + + const handleFileUpload = async (url: string, username: string) => { + try { + const res = await uploadFile({ fileUrl: url, username }); + if (community) { + socket.emit('imageSent', community?._id.toString()); + } + setNewMessage(prev => `${prev}${res}`); + } catch (err) { + throw Error('Error uploading file message'); + } + }; + + if (!currentCommunity || !community) { + return ( +
+ +
+ ); + } + + return ( + <> + + {!isPreview ? ( + <> + + + + Online Users: + + + {onlineUsers.map((username, index) => ( + + + {username} + + ))} + + + + + + setChatName(e.target.value)} + placeholder='Enter new chat name' + /> + + + + + + Current Chat Name: + {chatName} + + + + + + + {communityChat?.messages && communityChat.messages.length > 0 ? ( + communityChat.messages.map(message => ( + + )) + ) : ( + No messages yet. + )} + + + {typingUsers.length > 0 && ( + + {typingUsers.length === 1 && `${typingUsers[0]} is typing...`} + {typingUsers.length === 2 && + `${typingUsers[0]} and ${typingUsers[1]} are typing...`} + {typingUsers.length > 2 && 'Many people are typing...'} + + )} + + + + + + { + files.forEach(file => { + handleFileUpload(file.fileUrl, user.username); + }); + }}> + {({ onClick }) => ( + + )} + + + + + + {showEmojiPicker && ( + + + + )} + + + + + ) : ( +
+ Join the community to send messages! +
+ )} + + ); +}; + +export default CommunityChat; diff --git a/client/src/components/main/communityPage/communityInvitesPage/index.tsx b/client/src/components/main/communityPage/communityInvitesPage/index.tsx new file mode 100644 index 0000000..4cd38d6 --- /dev/null +++ b/client/src/components/main/communityPage/communityInvitesPage/index.tsx @@ -0,0 +1,83 @@ +import { useState, useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { Spinner, Center, Box, Text } from '@chakra-ui/react'; +import { SafeDatabaseUser } from '@fake-stack-overflow/shared'; +import useCommunityInvitesPage from '../../../../hooks/useCommunityInvitesPage'; +import UsersListHeader from '../../usersListPage/header'; +import '../index.css'; +import CommunityNavBar from '../communityNavBar'; +import UserStack from '../../usersListPage/userStack'; +import useUserContext from '../../../../hooks/useUserContext'; + +const CommunityInvitesPage = () => { + const { userList, setUserFilter, sendUserInvite, currentCommunity } = useCommunityInvitesPage(); + const navigate = useNavigate(); + const [showNoUsersMessage, setShowNoUsersMessage] = useState(false); + const { user: currentUser, socket } = useUserContext(); + + const handleUserCardViewClickHandler = (user: SafeDatabaseUser): void => { + navigate(`/user/${user.username}`); + }; + + useEffect(() => { + const timeout = setTimeout(() => { + if (userList.length === 0) { + setShowNoUsersMessage(true); + } + }, 3000); + + return () => clearTimeout(timeout); + }, [userList]); + + useEffect(() => { + if (!currentCommunity) { + return; + } + socket.emit('onlineUser', currentCommunity?._id.toString(), currentUser.username); + }, [currentCommunity, socket, currentUser.username]); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const isPreview = searchParams.get('preview') === 'true'; + + return ( +
+ {!isPreview ? ( +
+ {userList.length === 0 ? ( +
+ {showNoUsersMessage ? ( + + No users to invite. + + ) : ( + + )} +
+ ) : ( +
+ + + + +
+ +
+
+ )} +
+ ) : ( +
+ + Join the community to send invites to your friends! +
+ )} +
+ ); +}; + +export default CommunityInvitesPage; diff --git a/client/src/components/main/communityPage/communityNavBar/index.css b/client/src/components/main/communityPage/communityNavBar/index.css new file mode 100644 index 0000000..eafcf9b --- /dev/null +++ b/client/src/components/main/communityPage/communityNavBar/index.css @@ -0,0 +1,13 @@ +.community_menu_button { + margin: 20px; + height: 40px; + width: 80%; + border-radius: 5px; + text-align: center; + line-height: 40px; + color: cornflowerblue; +} + +.community_menu_selected { + background: #cccccc; +} \ No newline at end of file diff --git a/client/src/components/main/communityPage/communityNavBar/index.tsx b/client/src/components/main/communityPage/communityNavBar/index.tsx new file mode 100644 index 0000000..e029c0b --- /dev/null +++ b/client/src/components/main/communityPage/communityNavBar/index.tsx @@ -0,0 +1,56 @@ +import { HStack } from '@chakra-ui/react'; +import { NavLink, useLocation, useParams } from 'react-router-dom'; +import './index.css'; + +const CommunityNavBar = () => { + const { id } = useParams(); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + return ( + + + `community_menu_button ${isActive ? 'community_menu_selected' : ''}` + } + end={true}> + Home + + + `community_menu_button ${isActive ? 'community_menu_selected' : ''}` + }> + Bulletin Board + + + `community_menu_button ${isActive ? 'community_menu_selected' : ''}` + }> + Invites + + + `community_menu_button ${isActive ? 'community_menu_selected' : ''}` + }> + Chat + + + `community_menu_button ${isActive ? 'community_menu_selected' : ''}` + }> + Statistics + + + ); +}; + +export default CommunityNavBar; diff --git a/client/src/components/main/communityPage/communityStatistics/index.tsx b/client/src/components/main/communityPage/communityStatistics/index.tsx new file mode 100644 index 0000000..9388164 --- /dev/null +++ b/client/src/components/main/communityPage/communityStatistics/index.tsx @@ -0,0 +1,260 @@ +import { useNavigate } from 'react-router-dom'; +import { SafeDatabaseUser } from '@fake-stack-overflow/shared'; +import { Line } from 'react-chartjs-2'; +import { Chart, CategoryScale, LinearScale, PointElement, LineElement } from 'chart.js'; +import { Badge, Box, HStack, Text, VStack } from '@chakra-ui/react'; +import { useEffect } from 'react'; +import useCommunityStatisticsPage from '../../../../hooks/useCommunityStatisticsPage'; +import CommunityNavBar from '../communityNavBar'; +import QuestionStack from '../../questionPage/questionStack'; +import UserStack from '../../usersListPage/userStack'; +import StatisticsHeader from '../../statisticsPage/header'; +import useUserContext from '../../../../hooks/useUserContext'; + +Chart.register(CategoryScale, LinearScale, PointElement, LineElement); + +const CommunityStatisticsPage = () => { + const { + topVotedQuestions, + topViewedQuestions, + topVotedQuestionVotes, + topViewedQuestionViews, + topAskingUsers, + topViewingUsers, + topVotingUsers, + topAnsweringUsers, + topViewerCount, + topVoterCount, + topAskerQuestionCount, + topAnswererAnswerCount, + questionData, + memberData, + communityStatisticsError, + currentCommunity, + } = useCommunityStatisticsPage(); + const { user: currentUser, socket } = useUserContext(); + + const navigate = useNavigate(); + + const handleUserCardViewClickHandler = (user: SafeDatabaseUser): void => { + navigate(`/user/${user.username}`); + }; + + useEffect(() => { + if (!currentCommunity) { + return; + } + socket.emit('onlineUser', currentCommunity?._id.toString(), currentUser.username); + }, [currentCommunity, socket, currentUser.username]); + + return ( + <> + + {communityStatisticsError !== '' ? ( + {communityStatisticsError} + ) : ( + <> + + + + + + + {`The most questions asked by ${topAskingUsers.length > 1 ? 'users' : 'a user'}:`} + + {!topAskingUsers.length || topAskingUsers.length === 0 ? ( + + No questions asked + + ) : ( + + {topAskerQuestionCount} Questions + + )} + + {(!topAskingUsers.length || topAskingUsers.length !== 0) && ( + + )} + + + + + + {`The most answers made by ${topAnsweringUsers.length > 1 ? 'users' : 'a user'}:`} + + {!topAnsweringUsers.length || topAnsweringUsers.length === 0 ? ( + + No answers made + + ) : ( + + {topAnswererAnswerCount} Answers + + )} + + {(!topAnsweringUsers.length || topAnsweringUsers.length !== 0) && ( + + )} + + + + + + {`The most votes cast by ${topVotingUsers.length > 1 ? 'users' : 'a user'}:`} + + {!topVotingUsers.length || topVotingUsers.length === 0 ? ( + + No votes cast + + ) : ( + + {topVoterCount} Votes + + )} + + {(!topVotingUsers.length || topVotingUsers.length !== 0) && ( + + )} + + + + + + {`The most questions viewed by ${topViewingUsers.length > 1 ? 'users' : 'a user'}:`} + + {!topViewingUsers.length || topViewingUsers.length === 0 ? ( + + No questions viewed + + ) : ( + + {topViewerCount} Views + + )} + + {(!topViewingUsers.length || topViewingUsers.length !== 0) && ( + + )} + + + + + + {`The most viewed ${topViewedQuestions.length > 1 ? 'questions' : 'question'}:`} + + {!topViewedQuestions.length || topViewedQuestions.length === 0 ? ( + + No questions viewed + + ) : ( + + {topViewedQuestionViews} Views + + )} + + {(!topViewedQuestions.length || topViewedQuestions.length !== 0) && ( + + )} + + + + + + {`The best voted ${topVotedQuestions.length > 1 ? 'questions' : 'question'}:`} + + {!topVotedQuestions.length || topVotedQuestions.length === 0 ? ( + + No questions voted + + ) : ( + + {topVotedQuestionVotes} Votes + + )} + + {(!topVotedQuestions.length || topVotedQuestions.length !== 0) && ( + + )} + + + + Community Statistics Over Time + + + {/* Question Count Over Time */} + + + Question Count Over Time + + + + + {/* Member Count Over Time */} + + + Member Count Over Time + + + + + + + + )} + + ); +}; + +export default CommunityStatisticsPage; diff --git a/client/src/components/main/communityPage/index.css b/client/src/components/main/communityPage/index.css new file mode 100644 index 0000000..a5a7809 --- /dev/null +++ b/client/src/components/main/communityPage/index.css @@ -0,0 +1,41 @@ +.login-button { + background-color: #007bff; + color: white; +} + +.login-button:hover { + background-color: #0056b3; +} + +.login-button:active { + background-color: #003f7f; +} + +.input-text { + padding: 10px; + font-size: 16px; + width: 100%; + max-width: 400px; + margin-bottom: 15px; + border: 1px solid #ddd; + border-radius: 5px; + outline: none; + box-sizing: border-box; +} + +.input-text:focus { + border-color: #007bff; + box-shadow: 0 0 5px rgba(0, 123, 255, 0.5); +} +.delete-button { + background-color: #ff4c4c; + color: white; +} + +.delete-button:hover { + background-color: #cc0000; +} + +.delete-button:active { + background-color: #990000; +} diff --git a/client/src/components/main/communityPage/index.tsx b/client/src/components/main/communityPage/index.tsx new file mode 100644 index 0000000..555bf95 --- /dev/null +++ b/client/src/components/main/communityPage/index.tsx @@ -0,0 +1,281 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { + Spinner, + Center, + Box, + VStack, + Text, + Input, + Button, + Flex, + Heading, + SimpleGrid, + GridItem, +} from '@chakra-ui/react'; +import { useTheme } from 'next-themes'; +import useCommunityQuestionPage from '../../../hooks/useCommunityQuestionPage'; +import CommunityQuestionHeader from './CommunityQuestionHeader'; +import useUserContext from '../../../hooks/useUserContext'; +import { joinCommunity } from '../../../services/communityService'; +import useCommunityNameAboutRules from '../../../hooks/useCommunityNameAboutRules'; +import CommunityNavBar from './communityNavBar'; +import QuestionStack from '../questionPage/questionStack'; +import ReducedCommunityQuestionHeader from './minimalCommunityQuestionHeader'; + +const CommunityPage = () => { + const { titleText, qlist, setQuestionOrder } = useCommunityQuestionPage(); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const isPreview = searchParams.get('preview') === 'true'; + + const { + community, + editMode, + setEditMode, + newName, + setNewName, + newAbout, + setNewAbout, + newRules, + setNewRules, + handleEditNameAboutRules, + canEditNameAboutRules, + communityExistsError, + rankingByMembers, + rankingByQuestionsAnswers, + communityMemberCount, + communityContentCount, + } = useCommunityNameAboutRules(); + + const { user, socket } = useUserContext(); + + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === 'dark'; + + const primaryBtnBg = isDark ? 'blue.300' : 'blue.500'; + const primaryBtnHoverBg = isDark ? 'blue.400' : 'blue.600'; + const primaryBtnActiveBg = isDark ? 'blue.500' : 'blue.700'; + + const deleteBtnBg = isDark ? 'red.300' : 'red.500'; + const deleteBtnHoverBg = isDark ? 'red.400' : 'red.600'; + const deleteBtnActiveBg = isDark ? 'red.500' : 'red.700'; + + useEffect(() => { + if (!community || !user || !socket) return undefined; + + const userHasJoinedCommunity = community.members.includes(user.username); + + if (!userHasJoinedCommunity && !isPreview) { + socket.emit('joinCommunity', community._id.toString(), user.username); + joinCommunity(community._id.toString(), user.username); + } + + return () => { + if (userHasJoinedCommunity) { + socket.emit('leaveCommunity', community._id.toString(), user.username); + } + }; + }, [community, user, socket, isPreview]); + + useEffect(() => { + if (!community) { + return; + } + socket.emit('onlineUser', community?._id.toString(), user.username); + }, [community, socket, user.username]); + + if (!community) { + return ( +
+ +
+ ); + } + + const userHasJoinedCommunity = community.members.includes(user.username); + + const getMedalEmoji = (rank: number | null): string => { + if (rank === 1) return '🥇'; + if (rank === 2) return '🥈'; + if (rank === 3) return '🥉'; + return ''; + }; + + const getCountBadge = (count: number): string => { + if (count >= 100) return '🏆 for 100 or more'; + if (count >= 50) return '⭐ for 50 or more'; + if (count >= 10) return '🎖 for 10 or more'; + return ''; + }; + + return ( + <> + {communityExistsError !== '' ? ( + {communityExistsError} + ) : ( + + + + {!editMode && ( + + + {community.name} + + + + + About: + + + + {community.about} + + + {rankingByMembers && ( + + {getMedalEmoji(rankingByMembers) !== '' && ( + <>{getMedalEmoji(rankingByMembers)} in member count + )} + + )} + {rankingByQuestionsAnswers && ( + + {getMedalEmoji(rankingByQuestionsAnswers) !== '' && ( + <>{getMedalEmoji(rankingByQuestionsAnswers)} in content + )} + + )} + {communityMemberCount ? ( + <> + {getCountBadge(communityMemberCount) && ( + {getCountBadge(communityMemberCount)} members + )} + + ) : ( + <> + )} + {communityContentCount ? ( + <> + {getCountBadge(communityContentCount) && ( + {getCountBadge(communityContentCount)} questions and answers + )} + + ) : ( + <> + )} + + + + Rules: + + + + {community.rules} + + + {userHasJoinedCommunity && canEditNameAboutRules && ( + + )} + + )} + + {userHasJoinedCommunity && editMode && canEditNameAboutRules && ( + + setNewName(e.target.value)} + size='md' + maxWidth='400px' + borderColor='gray.300' + _focus={{ borderColor: 'blue.500' }} + /> + setNewAbout(e.target.value)} + size='md' + maxWidth='400px' + borderColor='gray.300' + _focus={{ borderColor: 'blue.500' }} + /> + setNewRules(e.target.value)} + size='md' + maxWidth='400px' + borderColor='gray.300' + _focus={{ borderColor: 'blue.500' }} + /> + + + + + + )} + + + + {!isPreview ? ( + + ) : ( + + )} + + + + {titleText === 'Search Results' && !qlist.length && ( + + No Questions Found + + )} + + + + + )} + + ); +}; + +export default CommunityPage; diff --git a/client/src/components/main/communityPage/minimalCommunityQuestionHeader/index.tsx b/client/src/components/main/communityPage/minimalCommunityQuestionHeader/index.tsx new file mode 100644 index 0000000..a713614 --- /dev/null +++ b/client/src/components/main/communityPage/minimalCommunityQuestionHeader/index.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Box, ButtonGroup, Flex, Text } from '@chakra-ui/react'; +import OrderButton from '../../questionPage/header/orderButton'; +import { OrderType } from '../../../../types/types'; +import { orderTypeDisplayName } from '../../../../types/constants'; + +/** + * Interface representing the props for the QuestionHeader component. + * + * titleText - The title text displayed at the top of the header. + * qcnt - The number of questions to be displayed in the header. + * setQuestionOrder - A function that sets the order of questions based on the selected message. + */ +interface QuestionHeaderProps { + titleText: string; + qcnt: number; + setQuestionOrder: (order: OrderType) => void; +} + +/** + * ReducedCommunityQuestionHeader component displays the header section for a list of questions, but without the ask question button. + * + * @param titleText - The title text to display in the header. + * @param qcnt - The number of questions displayed in the header. + * @param setQuestionOrder - Function to set the order of questions based on input message. + */ +const ReducedCommunityQuestionHeader = ({ + titleText, + qcnt, + setQuestionOrder, +}: QuestionHeaderProps) => ( + + + + {titleText} + + + + + + {qcnt} questions + + + {Object.keys(orderTypeDisplayName).map(order => ( + + ))} + + + +); + +export default ReducedCommunityQuestionHeader; diff --git a/client/src/components/main/communityPage/newQuestionInCommunity/index.css b/client/src/components/main/communityPage/newQuestionInCommunity/index.css new file mode 100644 index 0000000..e3fe3e9 --- /dev/null +++ b/client/src/components/main/communityPage/newQuestionInCommunity/index.css @@ -0,0 +1,88 @@ +.markdown-box { + background-color: #f9f9f9; + padding: 1.25rem; + border: 1px solid #ddd; + border-radius: 6px; + font-family: system-ui, sans-serif; + line-height: 1.6; + overflow-x: auto; +} + +.markdown-box h1, +.markdown-box h2, +.markdown-box h3 { + margin-top: 1rem; + margin-bottom: 0.5rem; + font-weight: 600; +} +.markdown-box h1 { + font-size: 1.75rem; +} +.markdown-box h2 { + font-size: 1.5rem; +} +.markdown-box h3 { + font-size: 1.25rem; +} + +.markdown-box p { + margin: 0.75rem 0; +} + +.markdown-box ul, +.markdown-box ol { + padding-left: 1.5rem; + margin: 0.5rem 0; +} +.markdown-box li { + margin-bottom: 0.5rem; +} + +.markdown-box pre { + background-color: #2d2d2d; + color: #f8f8f2; + padding: 1rem; + border-radius: 4px; + overflow-x: auto; +} +.markdown-box code { + background-color: #eee; + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + font-size: 0.95em; +} + +.markdown-box blockquote { + border-left: 4px solid #ccc; + padding-left: 1rem; + color: #555; + margin: 1rem 0; + font-style: italic; +} + +.markdown-box a { + color: #007acc; + text-decoration: underline; +} +.markdown-box a:hover { + color: #005fa3; +} + +.markdown-box ul, +.markdown-box ol { + margin: 1rem 0; + padding-left: 1.5rem; +} + +.markdown-box ul { + list-style-type: disc; +} + +.markdown-box ol { + list-style-type: decimal; +} + +.markdown-box li { + margin-bottom: 0.3rem; +} diff --git a/client/src/components/main/communityPage/newQuestionInCommunity/index.tsx b/client/src/components/main/communityPage/newQuestionInCommunity/index.tsx new file mode 100644 index 0000000..9034499 --- /dev/null +++ b/client/src/components/main/communityPage/newQuestionInCommunity/index.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import { Button } from '@chakra-ui/react'; +import rehypeHighlight from 'rehype-highlight'; +import useNewQuestionCommunity from '../../../../hooks/useNewQuestionCommunity'; +import Form from '../../baseComponents/form'; +import Input from '../../baseComponents/input'; +import TextArea from '../../baseComponents/textarea'; +import Checkbox from '../../baseComponents/checkbox'; +import './index.css'; + +/** + * NewQuestionInCommunityPage component allows users to submit a new question with a title, + * description, tags, and username to a community. + */ +const NewQuestionInCommunityPage = () => { + const { + title, + setTitle, + text, + setText, + tagNames, + setTagNames, + anonymous, + setAnonymous, + titleErr, + textErr, + tagErr, + postQuestion, + useMarkdown, + setUseMarkdown, + } = useNewQuestionCommunity(); + + return ( + + +