diff --git a/.gitignore b/.gitignore index b02a1ff7..71258c57 100644 --- a/.gitignore +++ b/.gitignore @@ -8,10 +8,12 @@ pnpm-debug.log* lerna-debug.log* node_modules +.env dist dist-ssr *.local package-lock.json +todo.md # Editor directories and files .vscode/* diff --git a/README.md b/README.md index 41ebece2..79ba318e 100644 --- a/README.md +++ b/README.md @@ -1 +1,45 @@ -# Happy Thoughts +![Made with React](https://img.shields.io/badge/React-2023-blue?logo=react) +![Node.js](https://img.shields.io/badge/Node.js-API-green?logo=node.js) +![MongoDB](https://img.shields.io/badge/MongoDB-Mongoose-brightgreen?logo=mongodb) +![MIT License](https://img.shields.io/badge/license-MIT-blue.svg) +![Status](https://img.shields.io/badge/status-In_Progress-yellow) + +# 💬 Happy Thoughts + +A social message board app where users can share short positive thoughts, ❤️ like others', and edit/delete their own posts. Built with full CRUD functionality and user authentication. + +--- + +## 🔗 **Project Access**: + +🚀 [Live Demo](https://happy-thoughts-blr.netlify.app) +📚 [View API Documentation](https://your-api.onrender.com/) + +## 🛠 Technologies Used + +- Frontend: React, Vite, Zustand (state management), MUI (Material UI) +- Backend: Node.js, Express, MongoDB, Mongoose +- Auth: Access tokens with protected routes and user-based permissions + +--- + +## 💡 Features + +- Register or log in to post a thought +- Like others' thoughts ❤️ +- See your own thoughts marked as `"✨ Yours"` +- Edit or delete your own posts +- Logout safely with feedback via Snackbar + +--- + +## 📌 Future Improvements + +- 🌈 Add Lottie animation in the header +- 🌓 Theme toggle (dark/light mode) +- 🗂️ Paginate thoughts or infinite scroll +- 🔐 Expiring access tokens or JWT + +### 🤝 Contributing + +Got ideas or want to improve the app? Fork the repo, create a branch, and open a PR! diff --git a/index.html b/index.html index d4492e94..b2c4dc67 100644 --- a/index.html +++ b/index.html @@ -2,15 +2,27 @@ - + + + + + + + + + Happy Thoughts + -
- +
+ diff --git a/package.json b/package.json index 2f66d295..abf8dfb8 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,24 @@ "preview": "vite preview" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^7.1.1", + "@mui/material": "^7.1.1", + "dotenv": "^16.5.0", + "moment": "^2.30.1", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-icons": "^5.5.0", + "styled-components": "^6.1.17", + "zustand": "^5.0.5" }, "devDependencies": { "@eslint/js": "^9.21.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^4.4.1", + "babel-plugin-styled-components": "^2.1.4", "eslint": "^9.21.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", diff --git a/public/happy.cloud.png b/public/happy.cloud.png new file mode 100644 index 00000000..b3b12915 Binary files /dev/null and b/public/happy.cloud.png differ diff --git a/public/pixel-heart.png b/public/pixel-heart.png new file mode 100644 index 00000000..e0038017 Binary files /dev/null and b/public/pixel-heart.png differ diff --git a/pull_request_template.md b/pull_request_template.md index 154c92e8..e6fd37c3 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -1 +1,5 @@ -Please include your Netlify link here. \ No newline at end of file +Please include your Netlify link here. + +Deployed site: https://happy-thoughts-blr.netlify.app/ + +Github: https://github.com/Bianka2112/js-project-happy-thoughts diff --git a/src/App.jsx b/src/App.jsx index 07f2cbdf..3d4e0aba 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,28 @@ -export const App = () => { +import { useEffect } from "react" +import Form from "../src/sections/Form" +import Header from "../src/sections/Header" +import { Loader } from "./components/Loader" +import { GlobalStyle } from "./GlobalStyles" +import { MsgBoard } from "./sections/MsgBoard" +import { useThoughtStore } from "./store/useThoughtStore" + +const App = ({ toggleTheme, mode }) => { + + const fetchThoughts = useThoughtStore((state) => state.fetchThoughts) + const loading = useThoughtStore((state) => state.loading) + + useEffect(() => { + fetchThoughts() + }, [fetchThoughts]) + return ( -

Happy Thoughts

+ <> + +
+
+ {loading ? : } + ) } + +export default App diff --git a/src/AppWrapper.jsx b/src/AppWrapper.jsx new file mode 100644 index 00000000..476120ac --- /dev/null +++ b/src/AppWrapper.jsx @@ -0,0 +1,58 @@ +import { ThemeProvider as StyledThemeProvider } from "styled-components" +import { ThemeProvider as MuiThemeProvider, CssBaseline, createTheme } from "@mui/material" +import { useState, useMemo } from "react" + +import App from './App.jsx' +import { lightTheme, darkTheme } from "./theme" + +const AppWrapper = () => { + const [mode, setMode] = useState("light") + + const styledTheme = useMemo(() => ( + mode === "dark" ? darkTheme : lightTheme + ), [mode]) + + const muiTheme = useMemo(() => + createTheme({ + palette: { + mode, + primary: { main: styledTheme.colors.primary }, + background: { default: styledTheme.colors.background }, + text: { primary: styledTheme.colors.text }, + }, + typography: { + fontFamily: styledTheme.font, + }, + components: { + MuiButtonBase: { + defaultProps: { + disableRipple: true, + }, + }, + MuiCssBaseline: { + styleOverrides: { + "*:focus": { + outline: "none", + boxShadow: "none", + }, + "button:focus-visible": { + outline: "2px solid #f95f86", + outlineOffset: "2px", + }, + }, + }, + }, + }), [styledTheme] + ) + + return ( + + + + setMode(prev => prev === "light" ? "dark" : "light")} /> + + + ) +} + +export default AppWrapper diff --git a/src/GlobalStyles.jsx b/src/GlobalStyles.jsx new file mode 100644 index 00000000..7f7042a9 --- /dev/null +++ b/src/GlobalStyles.jsx @@ -0,0 +1,39 @@ +import { createGlobalStyle } from "styled-components"; + +export const GlobalStyle = createGlobalStyle` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + body { + margin-top: 40px; + margin-bottom: 20px; + background: + linear-gradient( + to bottom, + rgba(255, 237, 230, 0.85), + rgba(255, 237, 230, 0.3) + ); + background-size: cover; + font-family: 'Quicksand', sans-serif; + font-weight: 600; + background-color: #fdfdfd; + color: #111; + } + + button { + font-family: 'Comfortaa', sans-serif; + } + + a { + text-decoration: none; + color: inherit; + } + + *:focus-visible { + outline: 2px solid ${({ theme }) => theme.colors.primary}; + outline-offset: 2px; + } +` \ No newline at end of file diff --git a/src/components/BackToTop.jsx b/src/components/BackToTop.jsx new file mode 100644 index 00000000..cf6f28f4 --- /dev/null +++ b/src/components/BackToTop.jsx @@ -0,0 +1,37 @@ +import styled from "styled-components" + +const BtnIcon = styled.button` + font-size: 2rem; + cursor: pointer; + border: 2px solid lightgray; + padding: 5px; + box-shadow: 2px 2px 5px #00000033; + border-radius: 20%; + left: 50%; + margin: 0 auto; + + + &:hover { + border: 2px solid gray; + scale: 1.1; + } + +` + +const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: "smooth" }) +} + +const BackToTop = () => { + return ( + <> + + 🔝 + + + ) +} + +export default BackToTop \ No newline at end of file diff --git a/src/components/DeleteButton.jsx b/src/components/DeleteButton.jsx new file mode 100644 index 00000000..b40034b6 --- /dev/null +++ b/src/components/DeleteButton.jsx @@ -0,0 +1,80 @@ +import { useState } from "react" +import { useThoughtStore } from "../store/useThoughtStore" +import { useAuthStore } from "../store/useAuthStore" +import { + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Snackbar, + Alert, + Tooltip, +} from "@mui/material" + +const DeleteThought = ({ id }) => { + const deleteThought = useThoughtStore((state) => state.deleteThought) + const accessToken = useAuthStore((state) => state.accessToken) + const [openConfirm, setOpenConfirm] = useState(false) + const [snackbarOpen, setSnackbarOpen] = useState(false) + + const handleDelete = async () => { + try { + await deleteThought(id) + setOpenConfirm(false) + setSnackbarOpen(true) + } catch (error) { + console.error("Failed to delete thought:", error) + } + } + + if (!accessToken) return null + + return ( + <> + + setOpenConfirm(true)} aria-label="Delete your thought"> + 🗑️ + + + + setOpenConfirm(false)}> + Confirm Deletion + + Are you sure you want to delete this thought? This action can't be undone. + + + + + + + + setSnackbarOpen(false)} + anchorOrigin={{ vertical: "top", horizontal: "center" }} + > + setSnackbarOpen(false)} + severity="success" + sx={{ width: "100%" }} + > + 💭 Thought deleted successfully. + + + + ) +} + +export default DeleteThought diff --git a/src/components/EditTh-Form.jsx b/src/components/EditTh-Form.jsx new file mode 100644 index 00000000..cae30a2e --- /dev/null +++ b/src/components/EditTh-Form.jsx @@ -0,0 +1,133 @@ +import { useState, useEffect } from "react" +import { + Modal, + Box, + Typography, + TextField, + Button, + IconButton, + Tooltip, + Snackbar, + Alert +} from "@mui/material" +import { useThoughtStore } from "../store/useThoughtStore" +import { useAuthStore } from "../store/useAuthStore" + +const modalStyle = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: 300, + fontFamily: "'Quicksand', sans-serif", + color: "#111", + bgcolor: "#fff9f7", + borderRadius: 2, + boxShadow: 24, + p: 6 +} + +const EditThoughtForm = ({ id, currentMessage }) => { + const editThought = useThoughtStore((state) => state.editThought) + const accessToken = useAuthStore((state) => state.accessToken) + + const [open, setOpen] = useState(false) + const [newMessage, setNewMessage] = useState(currentMessage) + const [errorMessage, setErrorMessage] = useState("") + const [snackbarOpen, setSnackbarOpen] = useState(false) + + useEffect(() => { + if (open) { + setNewMessage(currentMessage) + } + }, [open, currentMessage]) + + const handleClickOpen = () => { + setOpen(true) + } + + const handleClose = () => { + setOpen(false) + setErrorMessage("") + } + + const handleSave = async () => { + if (!newMessage.trim()) { + setErrorMessage("Message can't be empty.") + return + } + + try { + await editThought(id, newMessage) + setSnackbarOpen(true) + handleClose() + } catch (err) { + setErrorMessage("Update failed. Try again.") + } + } + + if (!accessToken) return null + + return ( + <> + + + ✏️ + + + + + Edit Your Thought + setNewMessage(e.target.value)} + /> + {errorMessage && ( + + {errorMessage} + + )} + + + + + + + setSnackbarOpen(false)} + anchorOrigin={{ vertical: "top", horizontal: "center" }} + > + setSnackbarOpen(false)} + severity="success" + sx={{ width: "100%" }} + > + Thought updated successfully! + + + + ) +} + +export default EditThoughtForm diff --git a/src/components/HeartsButton.jsx b/src/components/HeartsButton.jsx new file mode 100644 index 00000000..5400af0d --- /dev/null +++ b/src/components/HeartsButton.jsx @@ -0,0 +1,46 @@ +import { useState } from "react" +import { THOUGHTS_URL } from "../utils/constants" + +import * as Styled from "./Styled-Comps" + +const HeartsButton = ({ hearts, id }) => { + + const [count, setCount] = useState(hearts) + + const handleLike = async (event) => { + event.preventDefault() + + const button = event.currentTarget + button.classList.add("spin") + setTimeout(() => { + button.classList.remove("spin") + }, 600) + + try { + const response = await fetch(`${THOUGHTS_URL}/${id}/like`, { + method: "POST", + }) + + if (!response.ok) { + throw new Error("Failed to post like") + } + + const newLike = await response.json() + setCount(newLike.response.hearts) + + } catch (error) { + console.error("Error liking message:", error) + } + } + + return ( + + ♥️ + + x {count} + + ) +} + +export default HeartsButton \ No newline at end of file diff --git a/src/components/Loader.css b/src/components/Loader.css new file mode 100644 index 00000000..9aa1343a --- /dev/null +++ b/src/components/Loader.css @@ -0,0 +1,34 @@ +.dots-loader { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + height: 100px; + color: red; +} + +.dots-loader span { + width: 12px; + height: 12px; + background-color: red; + border-radius: 50%; + animation: bounce 0.6s infinite alternate; +} + +.dots-loader span:nth-child(2) { + animation-delay: 0.2s; +} +.dots-loader span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes bounce { + from { + transform: translateY(0); + opacity: 0.5; + } + to { + transform: translateY(-10px); + opacity: 1; + } +} diff --git a/src/components/Loader.jsx b/src/components/Loader.jsx new file mode 100644 index 00000000..23692ab1 --- /dev/null +++ b/src/components/Loader.jsx @@ -0,0 +1,12 @@ +import "./Loader.css" + +export const Loader = () => { + return ( +
+

Loading our Happy Feed

+ + + +
+ ) +} \ No newline at end of file diff --git a/src/components/LoginForm.jsx b/src/components/LoginForm.jsx new file mode 100644 index 00000000..aedcd308 --- /dev/null +++ b/src/components/LoginForm.jsx @@ -0,0 +1,151 @@ +import { useState } from "react" +import { Modal, Box, Typography, TextField, Button, Snackbar, Alert } from "@mui/material" +import { useAuthStore } from "../store/useAuthStore" + +const LoginForm = () => { + const [open, setOpen] = useState(false) + const loginUser = useAuthStore((state) => state.loginUser) + const accessToken = useAuthStore((state) => state.accessToken) + const [snackbarOpen, setSnackbarOpen] = useState(false) + + const handleClick = () => setOpen(true) + + const handleLoginSuccess = () => { + setSnackbarOpen(true) + setOpen(false) + } + + return ( + <> + {!accessToken && ( + + )} + + setOpen(false)} loginUser={loginUser} onLoginSuccess={handleLoginSuccess} /> + + setSnackbarOpen(false)} + anchorOrigin={{ vertical: "top", horizontal: "center" }} + > + setSnackbarOpen(false)} severity="success" sx={{ width: "100%" }}> + 🎉 You're logged in! + + + + ) +} + +export default LoginForm + +// MODAL COMPONENT +const modalStyle = { + position: "absolute", + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 300, + fontFamily: "'Quicksand', sans-serif", + color: "#111", + bgcolor: "#fff9f7", + borderRadius: 2, + boxShadow: 24, + p: 6, +} + +const LoginModal = ({ open, onClose, loginUser, onLoginSuccess }) => { + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [errorMessage, setErrorMessage] = useState("") + + const handleSubmit = async () => { + if (!username || !password) { + setErrorMessage("Please fill in both fields") + return + } + + try { + await loginUser({ username, password }) + setErrorMessage("") + setUsername("") + setPassword("") + onLoginSuccess() + } catch (err) { + setErrorMessage(err.message || "Login failed") + } + } + + return ( + + + Log In + + setUsername(e.target.value)} + /> + + setPassword(e.target.value)} + /> + + + + {errorMessage && ( + + {errorMessage} + + )} + + + ) +} diff --git a/src/components/SignupForm.jsx b/src/components/SignupForm.jsx new file mode 100644 index 00000000..fff92abd --- /dev/null +++ b/src/components/SignupForm.jsx @@ -0,0 +1,263 @@ +import { useState } from "react" +import { Modal, Box, Typography, TextField, Button, Snackbar, Alert} from "@mui/material" +import { useAuthStore } from "../store/useAuthStore" +import WelcomeUser from "./WelcomeUser" + +const SignupForm = () => { + const [open, setOpen] = useState(false) + const createUser = useAuthStore((state) => state.createUser) + const accessToken = useAuthStore((state) => state.accessToken) + const logoutUser = useAuthStore((state) => state.logoutUser) + const [snackbarOpen, setSnackbarOpen] = useState(false) + + + const handleClick = (e) => { + e.preventDefault() + setOpen(true) + } + + const handleLogout = () => { + logoutUser() + setSnackbarOpen(true) + } + + return ( + <> + {!accessToken ? ( + + ) : ( + + + + + + )} + setSnackbarOpen(false)} + anchorOrigin={{ vertical: "top", horizontal: "center" }} + > + setSnackbarOpen(false)} + severity="success" + sx={{ width: "100%" }} + > + 👋 You have been logged out successfully. + + + setOpen(false)} + createUser={createUser} + /> + + ) +} + +export default SignupForm + +// FORM MODAL +const modalStyle = { + position: "absolute", + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 300, + fontFamily: "'Quicksand', sans-serif", + color: "#111", + bgcolor: "#fff9f7", + borderRadius: 2, + boxShadow: 24, + p: 6, +} + + export const SignupModal = ({ open, onClose, createUser }) => { + const [username, setUsername] = useState("") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + + const [errorMessage, setErrorMessage] = useState("") + const [successMessage, setSuccessMessage] = useState("") + const [fieldErrors, setFieldErrors] = useState({ + username: "", + email: "", + password: "", + }) + + const handleSubmit = async () => { + setFieldErrors({ username: "", email: "", password: "" }) + const errors = {} + + if (!username.trim()) { + errors.username = "Username is required" + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errors.email = "Use valid email example@me.com" + } + + if (password.length < 4) { + errors.password = "Password must be at least 4 characters" + } + + if (Object.keys(errors).length > 0) { + setFieldErrors(errors) + return + } + + try { + await createUser({ username, email, password }) + setErrorMessage("") + setSuccessMessage("🎉 Signup successful! You can now post a thought.") + + setTimeout(() => { + setSuccessMessage("") + setUsername("") + setEmail("") + setPassword("") + onClose() + }, 2000) + + } catch (err) { + setSuccessMessage("") + const msg = err.message || "Signup failed" + + // Smart detection + if (msg.includes("username")) { + setFieldErrors((prev) => ({ ...prev, username: msg })) + } else if (msg.includes("email")) { + setFieldErrors((prev) => ({ ...prev, email: msg })) + } else if (msg.toLowerCase().includes("password")) { + setFieldErrors((prev) => ({ ...prev, password: msg })) + } else { + setFieldErrors((prev) => ({ ...prev, username: msg })) + } + } + } + + return ( + + + Register + { + setUsername(e.target.value) + setFieldErrors((prev) => ({ ...prev, username: "" })) + }} + error={!!fieldErrors.username} + helperText={fieldErrors.username} + /> + { + setEmail(e.target.value) + setFieldErrors((prev) => ({ ...prev, email: "" })) + }} + error={!!fieldErrors.email} + helperText={fieldErrors.email} + /> + { + setPassword(e.target.value) + setFieldErrors((prev) => ({ ...prev, password: "" })) + }} + error={!!fieldErrors.password} + helperText={fieldErrors.password} + /> + + {successMessage && ( + + {successMessage} + + )} + {errorMessage && ( + + {errorMessage} + + )} + + + ) +} \ No newline at end of file diff --git a/src/components/Styled-Comps.jsx b/src/components/Styled-Comps.jsx new file mode 100644 index 00000000..149c6a65 --- /dev/null +++ b/src/components/Styled-Comps.jsx @@ -0,0 +1,150 @@ +import styled from "styled-components" + +export const FormContainer = styled.form` + display: flex; + flex-direction: column; + align-items: left; + gap: 24px; + background: #fabda5b3; + padding: 1.5rem; + box-shadow: 8px 8px; + border: 2px solid black; + margin: 20px; + + @media (min-width: 426px) { + margin: 0 auto; + max-width: 550px; + } +` + +export const FormTitle = styled.h2` + font-size: 90%; + padding: 10px 0; + + @media (min-width: 426px) { + font-size: 100%; + } +` + +export const MessageInput = styled.textarea` + width: 100%; + padding: 12px 16px; + font-size: 1rem; + border: 2px solid #ccc; + border-radius: 8px; + resize: vertical; + min-height: 100px; + box-sizing: border-box; + font-family: inherit; + margin-top: 8px; +` + +export const CharCount = styled.p` + color: ${props => (props.$invalid ? "red" : "black")}; +` + +export const MessageBoard = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + padding: 1.5rem; + + @media (min-width: 426px) { + margin: 0 auto; + width: 100%; + max-width: 500px; + } +` + +export const CardContainer = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + padding: 1.5rem; + box-shadow: 8px 8px; + border: 2px solid black; + margin: 20px; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal; + width: 100%; + hyphens: auto; + background-color: white; + + @media (min-width: 426px) { + margin: 0 auto; + width: 100%; + max-width: 900px; + } +` + +export const FormButton = styled.button` + padding: 10px; + background: #ffdbcdba; + border: solid red; + border-radius: 20px; + font-weight: bold; + box-shadow: 0 3px 2px red; + width: fit-content; + + + &:not(:disabled):hover { + scale: 1.1; + border: solid white; + color: white; + box-shadow: none; + cursor: pointer; + } +` + +export const BoardDetails = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; +` + +export const LikeButtonContainer = styled.div` + display: flex; + align-items: center; + gap: 10px; +` + +export const LikeButton = styled.button` + padding: 15px; + border-radius: 30px; + border: #b0b0b062; + background: #b0b0b062; + transition: transform 0.6s ease; + perspective: 1000px; /* Needed for 3D effect */ + + &.spin { + animation: spinYColor 0.6s ease; + } + + @keyframes spinYColor { + 0% { + transform: rotateY(0deg); + color: #b0b0b062; + border-color: #b0b0b062; + background: #b0b0b062; + } + 50% { + transform: rotateY(180deg); + color: red; + border-color: red; + background: #ffdbcdba; + } + 100% { + transform: rotateY(360deg); + color: #b0b0b062; + border-color: #b0b0b062; + background: #b0b0b062; + } + } +` + +export const CountText = styled.p` + color: #a2a3a4; +` \ No newline at end of file diff --git a/src/components/TimeStamp.jsx b/src/components/TimeStamp.jsx new file mode 100644 index 00000000..c673bce7 --- /dev/null +++ b/src/components/TimeStamp.jsx @@ -0,0 +1,14 @@ +import moment from "moment" +import styled from "styled-components" + +const TimeStyle = styled.p` + color: #a2a3a4; +` + +const TimeStamp = ({ timeSubmitted }) => { + const msgTimeStamp = moment(timeSubmitted).fromNow() + + return {msgTimeStamp} +} + +export default TimeStamp \ No newline at end of file diff --git a/src/components/WelcomeUser.jsx b/src/components/WelcomeUser.jsx new file mode 100644 index 00000000..5f82c622 --- /dev/null +++ b/src/components/WelcomeUser.jsx @@ -0,0 +1,29 @@ +import { Chip, Box } from "@mui/material" +import { useAuthStore } from "../store/useAuthStore" + +const WelcomeUser = () => { + const username = useAuthStore((state) => state.username) + + if (!username) return null + + return ( + + + + ) +} + +export default WelcomeUser diff --git a/src/index.css b/src/index.css deleted file mode 100644 index f7c0aef5..00000000 --- a/src/index.css +++ /dev/null @@ -1,3 +0,0 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; -} diff --git a/src/main.jsx b/src/main.jsx index 1b8ffe9b..db2e5de8 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,12 +1,9 @@ import React from 'react' import ReactDOM from 'react-dom/client' - -import { App } from './App.jsx' - -import './index.css' +import AppWrapper from './AppWrapper.jsx' ReactDOM.createRoot(document.getElementById('root')).render( - + ) diff --git a/src/sections/Form.jsx b/src/sections/Form.jsx new file mode 100644 index 00000000..1f67516d --- /dev/null +++ b/src/sections/Form.jsx @@ -0,0 +1,88 @@ +import { useState } from "react" +import { useThoughtStore } from "../store/useThoughtStore" +import { useAuthStore } from "../store/useAuthStore" +import { THOUGHTS_URL } from "../utils/constants" + +import * as Styled from "../components/Styled-Comps" +import SignupForm from "../components/SignupForm" +import LoginForm from "../components/LoginForm" +import WelcomeUser from "../components/WelcomeUser" + +const Form = () => { + + const addThought = useThoughtStore(state => state.addThought) + const accessToken = useAuthStore((state) => state.accessToken) + const [messageText, setMessageText] = useState("") + const [error, setError] = useState("") + const msgLength = messageText.length + + const handleSubmit = async (event) => { + event.preventDefault() + setError("") + + if (msgLength < 5 || msgLength > 140) { + setError("Message must be between 5 and 140 characters.") + return + } + + try { + const response = await fetch(THOUGHTS_URL, { + method: "POST", + body: JSON.stringify({ message: messageText }), + headers: { + "Content-Type": "application/json", + Authorization: accessToken + }, + }) + + const newThought = await response.json() + + if (!response.ok) { + throw new Error(newThought.message || "Failed to post message") + } + + addThought(newThought.response) + setMessageText("") + setError("") + + } catch (error) { + setError(error.message) + } + } + + return ( + + + 140} + > + ♥️ Share a happy thought! ♥️ + + {msgLength > 0 && error && ( +

+ {error} +

+ )} + + +
+ ) +} + +export default Form \ No newline at end of file diff --git a/src/sections/Header.jsx b/src/sections/Header.jsx new file mode 100644 index 00000000..16700509 --- /dev/null +++ b/src/sections/Header.jsx @@ -0,0 +1,49 @@ +import styled, { keyframes } from "styled-components" +import { IconButton, Tooltip } from "@mui/material" +import Brightness4Icon from "@mui/icons-material/Brightness4" +import Brightness7Icon from "@mui/icons-material/Brightness7" + +const scrollingTitle = keyframes` + 0% { + transform: translateX(100%); + } + 100% { + transform: translateX(-100%); + } +` +const TitleContainer = styled.div` + margin: 0 auto; + width: 100%; + width: clamp(300px, 80%, 600px); + overflow: hidden; + white-space: nowrap; + padding: 0 1rem; +` +const Title = styled.h1` + text-align: center; + padding: 2rem 1rem; + animation: ${scrollingTitle} 10s linear infinite; +` +const ToggleContainer = styled.div` + position: absolute; + right: 10px; + top: 10px; +` + +const Header = ({ toggleTheme, mode }) => { + return ( + + + + {/* + {mode === "dark" ? : } + */} + {/* hidden until refactor style-code */} + + + 💬 Happy Thoughts 💬 + + ) +} + +export default Header \ No newline at end of file diff --git a/src/sections/MsgBoard.jsx b/src/sections/MsgBoard.jsx new file mode 100644 index 00000000..ed68062b --- /dev/null +++ b/src/sections/MsgBoard.jsx @@ -0,0 +1,42 @@ +import { useThoughtStore } from "../store/useThoughtStore" +import BackToTop from "../components/BackToTop" +import DeleteThought from "../components/DeleteButton" +import HeartsButton from "../components/HeartsButton" +import TimeStamp from "../components/TimeStamp" +import EditThoughtForm from "../components/EditTh-Form" +import { BoardDetails, CardContainer, MessageBoard } from "../components/Styled-Comps" +import { useAuthStore } from "../store/useAuthStore" +import { Typography } from "@mui/material" + +export const MsgBoard = () => { + const thoughts = useThoughtStore(state => state.thoughts) + const userId = useAuthStore((state) => state.userId) + + + return ( + + {thoughts.map((t) => { + const isOwner = t.createdBy && t.createdBy === userId + return ( + {t.message} + + + + {isOwner && ( + <> + + ✨ Yours + + + + + )} + + + ) + })} + + + ) +} + diff --git a/src/store/useAuthStore.js b/src/store/useAuthStore.js new file mode 100644 index 00000000..c51132b0 --- /dev/null +++ b/src/store/useAuthStore.js @@ -0,0 +1,67 @@ +import { create } from "zustand" +import { AUTH_URL } from "../utils/constants" + +export const useAuthStore = create((set) => ({ + username: "", + email: "", + userId: localStorage.getItem("userId") ?? null, + username: localStorage.getItem("username") ?? "", + accessToken: localStorage.getItem("accessToken") ?? null, + + setUsername: (username) => set({ username }), + setEmail: (email) => set({ email }), + + createUser: async ({ username, email, password }) => { + try { + const response = await fetch(`${AUTH_URL}/register`, { + method: "POST", + headers: { "Content-type": "application/json" }, + body: JSON.stringify({ username, email, password }) + }) + const data = await response.json() + if (!response.ok) { + throw new Error(data.message || "Registration failed") + } + + set({ accessToken: data.accessToken, username: data.username, userId: data.userId }) + localStorage.setItem("accessToken", data.accessToken) + localStorage.setItem("userId", data.userId) + localStorage.setItem("username", data.username) + + } catch (err) { + console.error("User not registered:", err) + throw err + } + }, + + loginUser: async ({ username, password }) => { + try { + const response = await fetch(`${AUTH_URL}/login`, { + method: "POST", + headers: {"Content-type": "application/json"}, + body: JSON.stringify({ username, password }) + }) + const data = await response.json() + if (!response.ok) { + throw new Error(data.message || "Login failed") + } + + set({ accessToken: data.accessToken, username: data.username, userId: data.userId }) + localStorage.setItem("accessToken", data.accessToken) + localStorage.setItem("userId", data.userId) + localStorage.setItem("username", data.username) + + } catch (err) { + console.error("User not logged in:", err) + throw err + } + }, + + logoutUser: async () => { + set({ accessToken: null, username: "", password: "", email: "" }) + localStorage.removeItem("accessToken") + localStorage.removeItem("userId") + localStorage.removeItem("username") + } +}) +) \ No newline at end of file diff --git a/src/store/useThoughtStore.js b/src/store/useThoughtStore.js new file mode 100644 index 00000000..740ae80d --- /dev/null +++ b/src/store/useThoughtStore.js @@ -0,0 +1,77 @@ +import { create } from "zustand"; +import { THOUGHTS_URL } from "../utils/constants" +import { useAuthStore } from "./useAuthStore"; + +export const useThoughtStore = create((set) => ({ + thoughts: [], + loading: false, + error: null, + + fetchThoughts: async () => { + set({ loading: true, error: null }) + try { + const response = await fetch(THOUGHTS_URL) + const data = await response.json() + set({ thoughts: data.response, loading: false }) + } catch (error) { + console.error(error) + set({ loading: false, error: error }) + } + }, + + addThought: (newThought) => set((state) => ({ + thoughts: [newThought, ...state.thoughts] + })), + + deleteThought: async (id) => { + const token = useAuthStore.getState().accessToken + + try { + const response = await fetch(`${THOUGHTS_URL}/${id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: token, + } + }) + + const data = await response.json() + if (!response.ok) { + throw new Error(data.message || "Thought not deleted") + } + set((state) => ({ + thoughts: state.thoughts.filter(t => t._id !== id) + })) + } catch(error) { + console.error("Error deleting thought:", error) + throw error + } + }, + + editThought: async (id, newMessage) => { + const token = useAuthStore.getState().accessToken + + try { + const response = await fetch(`${THOUGHTS_URL}/${id}`, { + method: "PATCH", + headers: { + "Content-type": "application/json", + Authorization: token, + }, + body: JSON.stringify({ editThought: newMessage }) + }) + const data = await response.json() + + if (!response.ok) { + throw new Error(data.message || "Thought not edited") + } + set((state) => ({ + thoughts: state.thoughts.map(t => t._id === id ? data.response : t ) + })) + } catch(error) { + console.error("Error to edit thought:", error) + throw error + } + } +}) +) \ No newline at end of file diff --git a/src/theme.js b/src/theme.js new file mode 100644 index 00000000..88ba85d3 --- /dev/null +++ b/src/theme.js @@ -0,0 +1,30 @@ + +export const baseTheme = { + font: "'Quicksand', sans-serif", + borderRadius: "12px", + transition: "all 0.25s ease-in-out", +} + +export const lightTheme = { + ...baseTheme, + mode: "light", + colors: { + background: "#fff9f7", + text: "#111", + primary: "#f95f86", + secondary: "#fabda5b3", + border: "#ddd", + }, +} + +export const darkTheme = { + ...baseTheme, + mode: "dark", + colors: { + background: "#121212", + text: "#fafafa", + primary: "#f95f86", + secondary: "#915f75", + border: "#444", + }, +} diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 00000000..9c4282d4 --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,5 @@ +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL + +export const AUTH_URL = `${API_BASE_URL}/auth` +export const USERS_URL = `${API_BASE_URL}/users` +export const THOUGHTS_URL = `${API_BASE_URL}/thoughts` diff --git a/vite.config.js b/vite.config.js index ba242447..315dcdd9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,13 @@ -import react from '@vitejs/plugin-react' +// vite.config.js import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' -// https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()] -}) + plugins: [ + react({ + babel: { + plugins: [['babel-plugin-styled-components', { displayName: true }]] + } + }) + ] +}) \ No newline at end of file