diff --git a/README.md b/README.md index e69de29..af14acc 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,34 @@ + +# Mini Message Board + +A mini message board is a simple web application that allows users to leave messages on a public board. Users can post messages and view a list of all messages that have been posted. + +# Demo + +[Live Demo](https://mini-message-frontend.vercel.app/) 😎 +## Environment Variables + +To run this project, you will need to add the following environment variables to your .env file + +**Frontend** + +` VITE_SERVER` + +**Backend** + +`PORT` + +`MONGODB_CNN` + + +## Screenshots + +![App Screenshot](https://raw.githubusercontent.com/ZevaGuillo/mini-message-board/main/screenshot.png) + + +## Tech Stack + +**Client:** React, Typescript, Styled Components + +**Server:** Node, Express, MongoDB + diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..ce41a35 --- /dev/null +++ b/client/.env.example @@ -0,0 +1 @@ +VITE_SERVER=... \ No newline at end of file diff --git a/client/.gitignore b/client/.gitignore index 7a9d1bc..b7adbe3 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -11,7 +11,7 @@ node_modules dist dist-ssr *.local - +.env # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/client/index.html b/client/index.html index e0d1c84..b1b706a 100644 --- a/client/index.html +++ b/client/index.html @@ -5,6 +5,8 @@ Vite + React + TS + +
diff --git a/client/package.json b/client/package.json index ba719be..ed73246 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@types/styled-components": "^5.1.26", + "emoji-picker-react": "^4.4.7", "formik": "^2.2.9", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index d93e2ed..ea26b49 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,16 +1,87 @@ -import { useState } from 'react' -import { GlobalStyle } from './theme/GlobalStyle' -import Board from './components/Board' +import { useCallback, useEffect, useState } from "react"; +import { GlobalStyle } from "./theme/GlobalStyle"; +import Board from "./components/Board"; +import SendMessage from "./components/SendMessage"; +import { Message } from "./types/messageType"; +import { getMessage } from "./service/message"; +import Card from "./components/Card"; +import styled from "styled-components"; +import SvgPattern from "./components/SvgPattern"; +import useInfiniteScroll from "./hooks/useInfiniteScroll"; +import Loading from "./components/Loading"; function App() { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [last, setLast] = useState(false); + const { loadMoreRef, skip } = useInfiniteScroll(); + + const fetchMessages = useCallback(async () => { + try { + setLoading(true); + if (!last) { + getMessage(skip).then(data => { + setMessages(prev => [...data.reverse(), ...prev]); + if (data.length < 10 && data.length > 0) { + setLast(true); + } + }); + } + } catch (error) { + console.log(error); + alert(error); + } finally { + setLoading(false); + } + }, [skip]); + + useEffect(() => { + fetchMessages(); + }, [fetchMessages]); return (
- -

Mini Message Board

- + + + + + +

Mini Message Board

+ +
+ +
+ + +
+
- ) + ); } -export default App +const StyledConent = styled.main` + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + + h1 { + padding: 1rem 0; + } + + .main-content { + flex: 1; + overflow: auto; + } + .loading { + display: flex; + justify-content: center; + align-items: center; + } +`; + +export default App; diff --git a/client/src/components/Board.tsx b/client/src/components/Board.tsx index d429a20..d070ce2 100644 --- a/client/src/components/Board.tsx +++ b/client/src/components/Board.tsx @@ -1,30 +1,156 @@ -import { useEffect, useState } from 'react'; -import { getMessage } from "../service/message" -import { Message } from '../types/messageType'; +import { useEffect, useRef } from "react"; +import styled from "styled-components"; +import { Message } from "../types/messageType"; +import { getRandomColor } from "../utils/getColor"; +import Loading from "./Loading"; -const Board = () => { - const [messages, setMessages] = useState() +type BoardProps = { + messages: Message[] | undefined; + last: boolean; + loadMoreRef: React.RefObject; +}; - useEffect(() => { - getMessage().then((data)=>setMessages(data)) - console.log(messages); - - }, []) - +const Board = ({ messages, loadMoreRef, last }: BoardProps) => { + const messagesEndRef = useRef(null); - return ( -
-

Messages

-
- {messages?.map(message => ( -
-

{message.username}

-

{message.text}

-
- ))} + useEffect(() => { + messagesEndRef?.current?.scrollIntoView({ + block: "end", + behavior: "smooth", + }); + }); + + const showMessages = messages?.map(message => { + let color = getRandomColor(); + return ( +
+
+

{message.username}

+

+ {new Date(message.added).toLocaleDateString("default", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} +

-
- ) -} +

+ {message.text} +

+
+ ); + }); + + return ( + + {!!messages && ( +
+ {!last && ( +
+ +
+ )} + {showMessages} +
+ )} +
+ ); +}; + +const StyledBoard = styled.main` + + .loader { + min-height: 3rem; + display: flex; + align-items: center; + justify-content: center; + } + + .height{ + height: 100%; + } + + .messages-section { + height: 100%; + padding: 0.5rem 1rem; + overflow-y: hidden; + } + h3 { + padding-bottom: 0.5rem; + } + .message { + color: #363f4d; + padding: 18px 20px; + line-height: 26px; + font-size: 16px; + border-radius: 7px; + margin-bottom: 30px; + width: fit-content; + max-width: 100%; + position: relative; + white-space: initial; + border: none; + + &::after { + content: ""; + bottom: 98%; + left: 0.7rem; + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; + pointer-events: none; + border-bottom-color: inherit; + border-width: 7px; + margin-left: -7px; + } + } + .message-header { + display: flex; + p { + flex: 1; + text-align: end; + padding-left: 0.5rem; + opacity: 0.4; + } + } + .messages-animate { + -webkit-animation: slide-top 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + animation: slide-top 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + } + @-webkit-keyframes slide-top { + 0% { + -webkit-transform: translateY(100px); + transform: translateY(100px); + } + 100% { + -webkit-transform: translateY(0px); + transform: translateY(0px); + } + } + @keyframes slide-top { + 0% { + -webkit-transform: translateY(100px); + transform: translateY(100px); + } + 100% { + -webkit-transform: translateY(0px); + transform: translateY(0px); + } + } +`; -export default Board \ No newline at end of file +export default Board; diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx new file mode 100644 index 0000000..276c1b7 --- /dev/null +++ b/client/src/components/Button.tsx @@ -0,0 +1,55 @@ +import styled from "styled-components"; + +type ButtonProps = { + type: "button" | "submit" | "reset" | undefined; + disabled: boolean | undefined; +}; + +const Button = ({ type, disabled }: ButtonProps) => { + return ( + + + + + + ); +}; + +const StyledButton = styled.button` + background: none; + border: none; + padding: 20px 20px; + border-radius: 0 8px 8px 0; + background-color: #f8f8f8; + cursor: pointer; + transition: 0.5s all ease-out; + + &:hover { + background: #ffffff; + box-shadow: 10px 10px 20px #ededed, -10px -10px 20px #ffffff; + } + & svg { + fill: var(--dark); + } + + &:hover svg { + fill: #504464; + } + + &:disabled { + background: linear-gradient(145deg, #f0efef, #ffffff); + box-shadow: 6px 6px 12px #f3f3f3, -6px -6px 12px #f1eded; + } + + &:disabled svg { + cursor: wait; + fill: #cac9c9; + } +`; + +export default Button; diff --git a/client/src/components/Card.tsx b/client/src/components/Card.tsx new file mode 100644 index 0000000..1ea15a0 --- /dev/null +++ b/client/src/components/Card.tsx @@ -0,0 +1,107 @@ +import styled from "styled-components"; + +type Cardprops = { + children: JSX.Element | JSX.Element[]; +}; + +const Card = ({ children }: Cardprops) => { + return ( + +
+
+ +
+
+ +
+
+ +
+
+
+ Coded by{" "} + + {" "} + ZevaGuillo + +
+
{children}
+
+ ); +}; + +const StyledCard = styled.div` + width: 95vw; + height: 90vh; + position: relative; + margin: 0 auto; + background-color: var(--white); + border-radius: 8px; + z-index: 1; + box-shadow: rgba(50, 50, 93, 0.25) 0px 6px 12px -2px, + rgba(0, 0, 0, 0.3) 0px 3px 7px -3px; + + .tools { + position: absolute; + border-radius: 8px 8px 0 0; + background: #2f283b; + width: 100%; + display: flex; + align-items: center; + padding: 9px; + .circle { + padding: 0 4px; + } + + .box { + display: inline-block; + align-items: center; + width: 10px; + height: 10px; + padding: 1px; + border-radius: 50%; + } + + .red { + background-color: #ff605c; + } + + .yellow { + background-color: #ffbd44; + } + + .green { + background-color: #00ca4e; + } + } + .attribution { + position: absolute; + top: 9px; + left: 50%; + transform: translateX(-50%); + color: #aaa2c9; + a { + padding-left: .2rem; + text-decoration: none; + color: #dab3b4; + } + } + + .card__content { + padding: 2.5rem 9px 9px; + height: 100%; + border-radius: 8px; + } + + @media (min-width: 900px) { + width: 50vw; + } +`; + +export default Card; diff --git a/client/src/components/Emoji.tsx b/client/src/components/Emoji.tsx new file mode 100644 index 0000000..972579f --- /dev/null +++ b/client/src/components/Emoji.tsx @@ -0,0 +1,63 @@ +import { useRef, useState } from "react"; +import EmojiPicker from "emoji-picker-react"; +import styled from "styled-components"; +import { useOnClickOutside } from "../hooks/useOnClickOutside"; +import { FormikState, useFormikContext } from "formik"; +import { MessageForm } from "../types/messageType"; + +type EmojiProp = { + setEmoji: React.Dispatch> +} + +const Emoji = ({setEmoji}:EmojiProp) => { + const { values , resetForm} = useFormikContext(); + + const [isPickerVisible, setIsPickerVisible] = useState(false); + const emojiPickerRef = useRef(null); + + const handlePicker = ()=> setIsPickerVisible(!isPickerVisible); + + useOnClickOutside(emojiPickerRef, () => handlePicker()); + + + return ( + + {isPickerVisible && ( +
+ { + setEmoji(e.emoji) + handlePicker() + }} + /> +
+ )} + +
+ ); +}; + +const StyledEmoji = styled.div` + position: relative; + width: 45px; + .picker { + z-index: 11; + bottom: 1rem; + position: absolute; + } + button{ + border: none; + height: 100%; + width: 100%; + border-radius: 8px 0 0 8px; + font-size: 1.5rem; + background: #f8f8f8; + } + button:hover{ + background: #e4e3e3; + } +`; + +export default Emoji; diff --git a/client/src/components/Input.tsx b/client/src/components/Input.tsx new file mode 100644 index 0000000..b45d36b --- /dev/null +++ b/client/src/components/Input.tsx @@ -0,0 +1,86 @@ +import { Field } from "formik"; +import styled from "styled-components"; + +type InputProps = { + id: string; + name: string; + placeholder: string; + className?: string; +}; + +const Input = ({ id, name, placeholder, className="" }: InputProps) => { + return ( + + + + + ); +}; + +const StyledInput = styled.div` + --width-of-input: 100%; + --border-height: 1px; + --border-before-color: rgba(221, 221, 221, 0.39); + --border-after-color: #434b5c; + --input-hovered-color: #6062641f; + position: relative; + width: var(--width-of-input); + + &.error::after{ + content: 'Required'; + position: absolute; + right: .5rem; + top: 50%; + transform: translateY(-50%); + color: salmon; + opacity: .7; + } + + /* styling of Input */ + .input { + color: var(--dark); + font-size: 0.9rem; + background-color: transparent; + width: 100%; + box-sizing: border-box; + padding-inline: 0.5em; + padding-block: 0.7em; + border: none; + border-bottom: var(--border-height) solid var(--border-before-color); + } + .input.error{ + border-bottom: var(--border-height) solid salmon; + } + .input.error::after{ + content: 'd'; + } + /* styling of animated border */ + .input-border { + position: absolute; + background: var(--border-after-color); + width: 0%; + height: 2px; + bottom: 0; + left: 0; + transition: 0.3s; + } + /* Hover on Input */ + input:hover { + background: var(--input-hovered-color); + } + + input:focus { + outline: none; + } + /* here is code of animated border */ + input:focus ~ .input-border { + width: 100%; + } +`; + +export default Input; diff --git a/client/src/components/Loading.tsx b/client/src/components/Loading.tsx new file mode 100644 index 0000000..d6aba9c --- /dev/null +++ b/client/src/components/Loading.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import styled from "styled-components"; + + +const Loading = () => { + return ( + +
+ + + + + + + + + +
+ ); +}; + +const StyledLoading = styled.div` + .jelly { + --uib-size: 40px; + --uib-speed: 0.8s; + --uib-color: #2c2b2b; + position: relative; + height: calc(var(--uib-size) / 2); + width: var(--uib-size); + filter: url("#uib-jelly-ooze"); + animation: rotate72317 calc(var(--uib-speed) * 2) linear infinite; + } + + .jelly::before, + .jelly::after { + content: ""; + position: absolute; + top: 0%; + left: 25%; + width: 50%; + height: 100%; + background: var(--uib-color); + border-radius: 100%; + } + + .jelly::before { + animation: shift-left var(--uib-speed) ease infinite; + } + + .jelly::after { + animation: shift-right var(--uib-speed) ease infinite; + } + + .jelly-maker { + width: 0; + height: 0; + position: absolute; + } + + @keyframes rotate72317 { + 0%, + 49.999%, + 100% { + transform: none; + } + + 50%, + 99.999% { + transform: rotate(90deg); + } + } + + @keyframes shift-left { + 0%, + 100% { + transform: translateX(0%); + } + + 50% { + transform: scale(0.65) translateX(-75%); + } + } + + @keyframes shift-right { + 0%, + 100% { + transform: translateX(0%); + } + + 50% { + transform: scale(0.65) translateX(75%); + } + } +`; + +export default Loading; diff --git a/client/src/components/SendMessage.tsx b/client/src/components/SendMessage.tsx new file mode 100644 index 0000000..24cbbd1 --- /dev/null +++ b/client/src/components/SendMessage.tsx @@ -0,0 +1,104 @@ +import EmojiPicker from "emoji-picker-react"; +import { Formik, Field, Form } from "formik"; +import { FormikHelpers } from "formik/dist/types"; +import { useEffect, useRef, useState } from "react"; +import styled from "styled-components"; +import { createMessage } from "../service/message"; +import { Message, MessageForm } from "../types/messageType"; +import Button from "./Button"; +import Emoji from "./Emoji"; +import Input from "./Input"; + +type FormValues = { + username: string; + text: string; +}; + +type SendMessageProps = { + setMessages: React.Dispatch>; +}; + +const SendMessage = ({ setMessages }: SendMessageProps) => { + const initialValues: FormValues = { username: "", text: "" }; + + const handleSubmit = async ( + values: FormValues, + actions: FormikHelpers + ) => { + await createMessage(values) + .then(res => { + console.log(res); + setMessages(m => [...m!, res.messageDB]); + }) + .catch(err => console.log(err)) + .finally(()=>{ + actions.resetForm() + }) + }; + + return ( + + + initialValues={initialValues} + validate={values => { + const errors: { username?: string; text?: string } = {}; + + if (!values.username) { + errors.username = "Required"; + } + + if (!values.text) { + errors.text = "Required"; + } + + return errors; + }} + onSubmit={handleSubmit}> + {({ isSubmitting, errors, setFieldValue, values}) => { + const [emoji, setEmoji] = useState(''); + + useEffect(() => { + setFieldValue('text', values.text.concat(emoji) , false); + }, [emoji]); + + return ( +
+ +
+ + {} + +
+