├── public └── _redirects ├── .env.example ├── src ├── components │ ├── views │ │ ├── user-detail │ │ │ ├── user-detail.module.css │ │ │ └── UserDetail.jsx │ │ ├── thing-detail │ │ │ ├── thing-detail.module.css │ │ │ └── ThingDetail.jsx │ │ ├── home │ │ │ ├── home.module.css │ │ │ └── Home.jsx │ │ ├── login │ │ │ ├── login.module.css │ │ │ └── Login.jsx │ │ ├── register │ │ │ ├── register.module.css │ │ │ └── Register.jsx │ │ ├── add-thing │ │ │ ├── add-thing.module.css │ │ │ └── AddThing.jsx │ │ ├── edit-thing │ │ │ ├── edit-thing.module.css │ │ │ └── EditThing.jsx │ │ ├── delete-thing │ │ │ ├── delete-thing.module.css │ │ │ └── DeleteThing.jsx │ │ ├── things-list │ │ │ ├── things-list.module.css │ │ │ └── ThingsList.jsx │ │ ├── users-list │ │ │ ├── users-list.module.css │ │ │ └── UsersList.jsx │ │ └── my-things │ │ │ ├── my-things.module.css │ │ │ └── MyThings.jsx │ └── ui │ │ ├── main-content │ │ ├── main-content.module.css │ │ └── MainContent.jsx │ │ ├── site-footer │ │ ├── site-footer.module.css │ │ └── SiteFooter.jsx │ │ ├── root │ │ ├── root.module.css │ │ └── Root.jsx │ │ ├── toast │ │ ├── toast.module.css │ │ └── Toast.jsx │ │ ├── breadcrumb │ │ ├── breadcrumb.module.css │ │ └── Breadcrumb.jsx │ │ ├── site-nav │ │ ├── site-nav.module.css │ │ └── SiteNav.jsx │ │ ├── site-header │ │ ├── site-header.module.css │ │ └── SiteHeader.jsx │ │ └── protected-route │ │ └── ProtectedRoute.jsx ├── slices │ ├── breadcrumbSlice.js │ ├── toastSlice.js │ ├── authSlice.js │ ├── usersSlice.js │ └── thingsSlice.js ├── main.jsx ├── store.js ├── assets │ └── css │ │ └── global.css ├── services │ ├── authService.js │ └── apiService.js └── routes.jsx ├── vite.config.js ├── index.html ├── .gitignore ├── .vscode └── settings.json ├── .eslintrc.cjs ├── package.json └── README.md /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL="http://localhost:3001/api" -------------------------------------------------------------------------------- /src/components/views/user-detail/user-detail.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #efefef; 3 | width: 100%; 4 | padding: 1rem; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/views/thing-detail/thing-detail.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #efefef; 3 | width: 100%; 4 | padding: 1rem; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/views/home/home.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #efefef; 3 | width: 100%; 4 | align-items: center; 5 | justify-content: center; 6 | display: flex; 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /src/components/ui/main-content/main-content.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | align-items: stretch; 4 | justify-content: center; 5 | width: 100%; 6 | height: 100%; 7 | overflow: auto; 8 | flex: 1; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/ui/site-footer/site-footer.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | padding: 1rem; 3 | display: flex; 4 | align-items: center; 5 | justify-content: flex-end; 6 | color: white; 7 | background: grey; 8 | font-size: 14px; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/views/login/login.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #efefef; 3 | width: 100%; 4 | padding: 1rem; 5 | } 6 | 7 | .inputContainer { 8 | display: block; 9 | } 10 | 11 | .input { 12 | padding: 10px; 13 | border: 1px solid #ccc; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/views/register/register.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #efefef; 3 | width: 100%; 4 | padding: 1rem; 5 | } 6 | 7 | .inputContainer { 8 | display: block; 9 | } 10 | 11 | .input { 12 | padding: 10px; 13 | border: 1px solid #ccc; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/ui/site-footer/SiteFooter.jsx: -------------------------------------------------------------------------------- 1 | import styles from "./site-footer.module.css"; 2 | 3 | function SiteFooter() { 4 | return ( 5 |
© {new Date().getFullYear()}
6 | ); 7 | } 8 | 9 | export default SiteFooter; 10 | -------------------------------------------------------------------------------- /src/components/views/add-thing/add-thing.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #efefef; 3 | width: 100%; 4 | padding: 1rem; 5 | } 6 | 7 | .inputContainer { 8 | display: block; 9 | } 10 | 11 | .input { 12 | padding: 10px; 13 | border: 1px solid #ccc; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/views/edit-thing/edit-thing.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #efefef; 3 | width: 100%; 4 | padding: 1rem; 5 | } 6 | 7 | .inputContainer { 8 | display: block; 9 | } 10 | 11 | .input { 12 | padding: 10px; 13 | border: 1px solid #ccc; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/views/delete-thing/delete-thing.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #efefef; 3 | width: 100%; 4 | padding: 1rem; 5 | } 6 | 7 | .inputContainer { 8 | display: block; 9 | } 10 | 11 | .input { 12 | padding: 10px; 13 | border: 1px solid #ccc; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/ui/root/root.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | flex-direction: column; 4 | width: 90%; 5 | height: 80%; 6 | max-width: 600px; 7 | box-shadow: 0 0 10px rgb(0 0 0 / 40%); 8 | background: #fff; 9 | overflow: hidden; 10 | border-radius: 20px; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/ui/main-content/MainContent.jsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom"; 2 | 3 | import styles from "./main-content.module.css"; 4 | 5 | function MainContent() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | 13 | export default MainContent; 14 | -------------------------------------------------------------------------------- /src/components/ui/toast/toast.module.css: -------------------------------------------------------------------------------- 1 | .toast { 2 | position: fixed; 3 | bottom: 20px; 4 | right: 20px; 5 | background-color: #333; 6 | color: #fff; 7 | padding: 1rem; 8 | font-size: 24px; 9 | border-radius: 5px; 10 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); 11 | z-index: 1000; 12 | opacity: 0.8; 13 | } 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | The things! 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/ui/breadcrumb/breadcrumb.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | padding: 1rem; 3 | display: flex; 4 | align-items: center; 5 | justify-content: flex-start; 6 | background: #ddd; 7 | } 8 | 9 | .crumb { 10 | display: flex; 11 | font-size: 14px; 12 | } 13 | 14 | .divider { 15 | margin-left: 5px; 16 | margin-right: 5px; 17 | } 18 | 19 | .link { 20 | color: #333; 21 | } 22 | -------------------------------------------------------------------------------- /src/slices/breadcrumbSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | const breadcrumbSlice = createSlice({ 4 | name: "breadcrumb", 5 | initialState: [], 6 | reducers: { 7 | setBreadcrumb: (state, action) => { 8 | return action.payload; 9 | }, 10 | }, 11 | }); 12 | 13 | export const { setBreadcrumb } = breadcrumbSlice.actions; 14 | export default breadcrumbSlice.reducer; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | !.vscode/settings.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # env 28 | .env -------------------------------------------------------------------------------- /src/components/ui/site-nav/site-nav.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | padding: 1rem; 3 | border: 1px solid lightgray; 4 | display: flex; 5 | align-items: center; 6 | justify-content: flex-start; 7 | background: lightgrey; 8 | } 9 | 10 | .links { 11 | display: flex; 12 | } 13 | 14 | .activeLink, 15 | .inactiveLink { 16 | font-size: 20px; 17 | margin-right: 1.5rem; 18 | } 19 | 20 | .activeLink { 21 | color: #000; 22 | } 23 | 24 | .inactiveLink { 25 | color: #666; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/ui/site-header/site-header.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | padding: 1rem; 3 | display: flex; 4 | width: 100%; 5 | align-items: center; 6 | justify-content: space-between; 7 | color: white; 8 | background: grey; 9 | } 10 | 11 | .title { 12 | margin: 5px 0 0 0; 13 | } 14 | 15 | .links { 16 | } 17 | 18 | .activeLink, 19 | .inactiveLink { 20 | padding: 8px; 21 | } 22 | 23 | .activeLink { 24 | color: #fff; 25 | } 26 | 27 | .inactiveLink { 28 | color: #ddd; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/ui/protected-route/ProtectedRoute.jsx: -------------------------------------------------------------------------------- 1 | import { Navigate, useLocation } from "react-router-dom"; 2 | import { useSelector } from "react-redux"; 3 | 4 | const ProtectedRoute = ({ element }) => { 5 | const location = useLocation(); 6 | const isLoggedIn = useSelector((state) => state.auth.isLoggedIn); 7 | 8 | if (!isLoggedIn) { 9 | return ; 10 | } 11 | 12 | return element; 13 | }; 14 | 15 | export default ProtectedRoute; 16 | -------------------------------------------------------------------------------- /src/slices/toastSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | const toastSlice = createSlice({ 4 | name: "toast", 5 | initialState: { 6 | message: null, 7 | }, 8 | reducers: { 9 | showToast: (state, action) => { 10 | state.message = action.payload; 11 | }, 12 | hideToast: (state) => { 13 | state.message = null; 14 | }, 15 | }, 16 | }); 17 | 18 | export const { showToast, hideToast } = toastSlice.actions; 19 | export default toastSlice.reducer; 20 | -------------------------------------------------------------------------------- /src/components/views/home/Home.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDispatch } from "react-redux"; 3 | import { setBreadcrumb } from "../../../slices/breadcrumbSlice"; 4 | import styles from "./home.module.css"; 5 | 6 | function Home() { 7 | const dispatch = useDispatch(); 8 | 9 | useEffect(() => { 10 | dispatch(setBreadcrumb([{ label: "Home" }])); 11 | }, [dispatch]); 12 | 13 | return ( 14 |
15 |

Welcome to the things

16 |
17 | ); 18 | } 19 | 20 | export default Home; 21 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { Provider } from "react-redux"; 4 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 5 | import store from "./store"; 6 | import routes from "./routes"; 7 | import "./assets/css/global.css"; 8 | 9 | const router = createBrowserRouter(routes); 10 | 11 | ReactDOM.createRoot(document.getElementById("root")).render( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | 3 | import authReducer from "./slices/authSlice"; 4 | import thingsReducer from "./slices/thingsSlice"; 5 | import usersReducer from "./slices/usersSlice"; 6 | import toastReducer from "./slices/toastSlice"; 7 | import breadcrumbReducer from "./slices/breadcrumbSlice"; 8 | 9 | const store = configureStore({ 10 | reducer: { 11 | auth: authReducer, 12 | things: thingsReducer, 13 | users: usersReducer, 14 | toast: toastReducer, 15 | breadcrumb: breadcrumbReducer, 16 | }, 17 | }); 18 | 19 | export default store; 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[javascript]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "[json]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "[html]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[css]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "[jsonc]": { 16 | "editor.defaultFormatter": "esbenp.prettier-vscode" 17 | }, 18 | "[javascriptreact]": { 19 | "editor.defaultFormatter": "esbenp.prettier-vscode" 20 | } 21 | } -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react/jsx-no-target-blank': 'off', 16 | 'react-refresh/only-export-components': [ 17 | 'warn', 18 | { allowConstantExport: true }, 19 | ], 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /src/components/ui/root/Root.jsx: -------------------------------------------------------------------------------- 1 | import styles from "./root.module.css"; 2 | 3 | import SiteHeader from "../site-header/SiteHeader"; 4 | import SiteNav from "../site-nav/SiteNav"; 5 | import SiteFooter from "../site-footer/SiteFooter"; 6 | import MainContent from "../main-content/MainContent"; 7 | import Toast from "../toast/Toast"; 8 | import Breadcrumb from "../breadcrumb/Breadcrumb"; 9 | 10 | function Root() { 11 | return ( 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | ); 21 | } 22 | 23 | export default Root; 24 | -------------------------------------------------------------------------------- /src/components/views/things-list/things-list.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #efefef; 3 | width: 100%; 4 | padding: 1rem; 5 | } 6 | 7 | .list { 8 | display: flex; 9 | flex-wrap: wrap; 10 | align-items: center; 11 | justify-content: flex-start; 12 | margin: 0; 13 | padding: 0; 14 | } 15 | 16 | .item { 17 | list-style: none; 18 | flex-basis: 50%; 19 | } 20 | 21 | @media (min-width: 600px) { 22 | .item { 23 | flex-basis: 33.33%; 24 | } 25 | } 26 | 27 | .link { 28 | display: block; 29 | padding: 10px; 30 | margin-right: 10px; 31 | margin-bottom: 10px; 32 | border: 1px solid #ccc; 33 | border-radius: 10px; 34 | text-align: center; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/views/users-list/users-list.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #efefef; 3 | width: 100%; 4 | padding: 1rem; 5 | } 6 | 7 | .list { 8 | display: flex; 9 | flex-wrap: wrap; 10 | align-items: center; 11 | justify-content: flex-start; 12 | margin: 0; 13 | padding: 0; 14 | } 15 | 16 | .item { 17 | list-style: none; 18 | flex-basis: 50%; 19 | } 20 | 21 | @media (min-width: 600px) { 22 | .item { 23 | flex-basis: 33.33%; 24 | } 25 | } 26 | 27 | .link { 28 | display: block; 29 | padding: 10px; 30 | margin-right: 10px; 31 | margin-bottom: 10px; 32 | border: 1px solid #ccc; 33 | border-radius: 10px; 34 | text-align: center; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/views/my-things/my-things.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #efefef; 3 | width: 100%; 4 | padding: 1rem; 5 | } 6 | 7 | .list { 8 | display: flex; 9 | flex-wrap: wrap; 10 | align-items: center; 11 | justify-content: flex-start; 12 | margin: 0; 13 | padding: 0; 14 | } 15 | 16 | .item { 17 | list-style: none; 18 | flex-basis: 50%; 19 | } 20 | 21 | @media (min-width: 600px) { 22 | .item { 23 | flex-basis: 33.33%; 24 | } 25 | } 26 | 27 | .itemContent { 28 | display: block; 29 | padding: 10px; 30 | margin-right: 10px; 31 | margin-bottom: 10px; 32 | border: 1px solid #ccc; 33 | border-radius: 10px; 34 | text-align: center; 35 | } 36 | 37 | .link { 38 | margin: 0 5px; 39 | color: #333; 40 | } 41 | -------------------------------------------------------------------------------- /src/components/ui/toast/Toast.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { hideToast } from "../../../slices/toastSlice"; 4 | import styles from "./toast.module.css"; 5 | 6 | const Toast = () => { 7 | const dispatch = useDispatch(); 8 | const toastMessage = useSelector((state) => state.toast.message); 9 | 10 | useEffect(() => { 11 | if (toastMessage) { 12 | const timer = setTimeout(() => { 13 | dispatch(hideToast()); 14 | }, 3000); 15 | return () => clearTimeout(timer); 16 | } 17 | }, [toastMessage, dispatch]); 18 | 19 | if (!toastMessage) return null; 20 | 21 | return
{toastMessage}
; 22 | }; 23 | 24 | export default Toast; 25 | -------------------------------------------------------------------------------- /src/assets/css/global.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, 6 | *::before, 7 | *::after { 8 | box-sizing: inherit; 9 | } 10 | 11 | html, 12 | body { 13 | height: 100%; 14 | } 15 | 16 | body { 17 | margin: 0; 18 | padding: 0; 19 | font-family: Helvetica, sans-serif; 20 | } 21 | 22 | #root { 23 | height: 100%; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | background: linear-gradient(45deg, #ffd07b, #aaa1c8); 28 | background-size: 400% 400%; 29 | animation: gradient 11s ease infinite; 30 | } 31 | 32 | @keyframes gradient { 33 | 0% { 34 | background-position: 0% 50%; 35 | } 36 | 35% { 37 | background-position: 100% 50%; 38 | } 39 | 65% { 40 | background-position: 0% 50%; 41 | } 42 | 100% { 43 | background-position: 0% 50%; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "project-react-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@reduxjs/toolkit": "^2.2.5", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0", 16 | "react-redux": "^9.1.2", 17 | "react-router-dom": "^6.23.1", 18 | "redux": "^5.0.1" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^18.2.66", 22 | "@types/react-dom": "^18.2.22", 23 | "@vitejs/plugin-react": "^4.2.1", 24 | "eslint": "^8.57.0", 25 | "eslint-plugin-react": "^7.34.1", 26 | "eslint-plugin-react-hooks": "^4.6.0", 27 | "eslint-plugin-react-refresh": "^0.4.6", 28 | "vite": "^5.2.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/ui/breadcrumb/Breadcrumb.jsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import { NavLink } from "react-router-dom"; 3 | import styles from "./breadcrumb.module.css"; 4 | 5 | const Breadcrumb = () => { 6 | const breadcrumb = useSelector((state) => state.breadcrumb); 7 | 8 | return ( 9 | 25 | ); 26 | }; 27 | 28 | export default Breadcrumb; 29 | -------------------------------------------------------------------------------- /src/components/ui/site-nav/SiteNav.jsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from "react-router-dom"; 2 | import { useSelector } from "react-redux"; 3 | import styles from "./site-nav.module.css"; 4 | 5 | function SiteNav() { 6 | const isLoggedIn = useSelector((state) => state.auth.isLoggedIn); 7 | 8 | const navLinks = [ 9 | { label: "Home", url: "/" }, 10 | { label: "Things", url: "/things/" }, 11 | ]; 12 | 13 | if (isLoggedIn) { 14 | navLinks.push({ label: "Users", url: "/users/" }); 15 | } 16 | 17 | return ( 18 |
19 | 32 |
33 | ); 34 | } 35 | 36 | export default SiteNav; 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Project - example front-end 2 | 3 | This is an example front-end application for the React project. 4 | 5 | It allows users to register, log in, and manage their collections of things. 6 | 7 | The app is built using React and Redux. 8 | 9 | 10 | ## Installation 11 | 12 | ### Prerequisites 13 | 14 | Ensure you have the following installed: 15 | 16 | - Node.js (version 18 or higher) 17 | - npm (version 6 or higher) 18 | 19 | ### Install dependencies 20 | 21 | ``` 22 | npm install 23 | ``` 24 | 25 | ### Environment Variables 26 | 27 | Create a `.env` file in the root of the project - duplicate the `.env.example` file and replace values with your real values: 28 | 29 | ``` 30 | VITE_API_URL="http://localhost:3001/api" 31 | ``` 32 | 33 | ## Running the app 34 | 35 | Start the app using the following command: 36 | 37 | ``` 38 | npm run dev 39 | ``` 40 | 41 | The app should now be running on [http://localhost:3000](http://localhost:3000) 42 | 43 | 44 | ## Available scripts 45 | 46 | In the project directory, you can run the following scripts: 47 | 48 | - `npm run dev`: Start the development server. 49 | - `npm run build`: Build the app for production. 50 | - `npm run serve`: Serve the production build of the app. 51 | - `npm run lint`: Run ESLint to lint the code. 52 | -------------------------------------------------------------------------------- /src/slices/authSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; 2 | import authService from "../services/authService"; 3 | 4 | export const login = createAsyncThunk( 5 | "auth/login", 6 | async ({ email, password }) => { 7 | const user = await authService.login(email, password); 8 | return user; 9 | } 10 | ); 11 | 12 | export const logout = createAsyncThunk("auth/logout", async () => { 13 | await authService.logout(); 14 | }); 15 | 16 | const initialState = { 17 | isLoggedIn: authService.isLoggedIn(), 18 | status: "idle", 19 | error: null, 20 | }; 21 | 22 | const authSlice = createSlice({ 23 | name: "auth", 24 | initialState, 25 | extraReducers: (builder) => { 26 | builder 27 | .addCase(login.pending, (state) => { 28 | state.status = "loading"; 29 | }) 30 | .addCase(login.fulfilled, (state) => { 31 | state.status = "succeeded"; 32 | state.isLoggedIn = true; 33 | }) 34 | .addCase(login.rejected, (state, action) => { 35 | state.status = "failed"; 36 | state.error = action.error.message; 37 | }) 38 | .addCase(logout.fulfilled, (state) => { 39 | state.isLoggedIn = false; 40 | state.status = "idle"; 41 | }); 42 | }, 43 | }); 44 | 45 | export default authSlice.reducer; 46 | -------------------------------------------------------------------------------- /src/components/views/users-list/UsersList.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { NavLink } from "react-router-dom"; 4 | import { fetchUsers } from "../../../slices/usersSlice"; 5 | import { setBreadcrumb } from "../../../slices/breadcrumbSlice"; 6 | import styles from "./users-list.module.css"; 7 | 8 | const UsersList = () => { 9 | const dispatch = useDispatch(); 10 | const users = useSelector((state) => state.users.items); 11 | const status = useSelector((state) => state.users.status); 12 | const error = useSelector((state) => state.users.error); 13 | 14 | useEffect(() => { 15 | dispatch(fetchUsers()); 16 | }, [dispatch]); 17 | 18 | useEffect(() => { 19 | dispatch(setBreadcrumb([{ label: "Home", url: "/" }, { label: "Users" }])); 20 | }, [dispatch]); 21 | 22 | return ( 23 |
24 | {status === "loading" &&
Loading...
} 25 | {status === "failed" &&
{error}
} 26 | 35 |
36 | ); 37 | }; 38 | 39 | export default UsersList; 40 | -------------------------------------------------------------------------------- /src/components/views/things-list/ThingsList.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { NavLink } from "react-router-dom"; 4 | import { fetchThings } from "../../../slices/thingsSlice"; 5 | import { setBreadcrumb } from "../../../slices/breadcrumbSlice"; 6 | import styles from "./things-list.module.css"; 7 | 8 | const ThingsList = () => { 9 | const dispatch = useDispatch(); 10 | const things = useSelector((state) => state.things.items); 11 | const status = useSelector((state) => state.things.status); 12 | const error = useSelector((state) => state.things.error); 13 | 14 | useEffect(() => { 15 | dispatch(fetchThings()); 16 | }, [dispatch]); 17 | 18 | useEffect(() => { 19 | dispatch(setBreadcrumb([{ label: "Home", url: "/" }, { label: "Things" }])); 20 | }, [dispatch]); 21 | 22 | return ( 23 |
24 | {status === "loading" &&
Loading...
} 25 | {status === "failed" &&
{error}
} 26 | 35 |
36 | ); 37 | }; 38 | 39 | export default ThingsList; 40 | -------------------------------------------------------------------------------- /src/components/ui/site-header/SiteHeader.jsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from "react-router-dom"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { logout } from "../../../slices/authSlice"; 4 | import styles from "./site-header.module.css"; 5 | 6 | function SiteHeader() { 7 | const dispatch = useDispatch(); 8 | const isLoggedIn = useSelector((state) => state.auth.isLoggedIn); 9 | 10 | const handleLogout = () => { 11 | dispatch(logout()); 12 | }; 13 | 14 | const navLinks = []; 15 | if (isLoggedIn) { 16 | navLinks.push( 17 | { label: "My things", url: "/my-things/" }, 18 | { label: "Add thing", url: "/my-things/add/" } 19 | ); 20 | } else { 21 | navLinks.push( 22 | { label: "Login", url: "/login/" }, 23 | { label: "Register", url: "/register/" } 24 | ); 25 | } 26 | 27 | return ( 28 |
29 |

The Things!

30 | 44 |
45 | ); 46 | } 47 | 48 | export default SiteHeader; 49 | -------------------------------------------------------------------------------- /src/services/authService.js: -------------------------------------------------------------------------------- 1 | import makeApiRequest from "./apiService"; 2 | 3 | const register = async (name, email, password, bio) => { 4 | return makeApiRequest("auth/register", { 5 | method: "POST", 6 | body: JSON.stringify({ name, email, password, bio }), 7 | }); 8 | }; 9 | 10 | const login = async (email, password) => { 11 | const response = await makeApiRequest("auth/login", { 12 | method: "POST", 13 | body: JSON.stringify({ email, password }), 14 | }); 15 | 16 | if (response.accessToken) { 17 | sessionStorage.setItem("accessToken", response.accessToken); 18 | } 19 | 20 | return { id: response.id, name: response.name }; 21 | }; 22 | 23 | const logout = async () => { 24 | await makeApiRequest("auth/logout", { 25 | method: "POST", 26 | }); 27 | sessionStorage.removeItem("accessToken"); 28 | }; 29 | 30 | const getAccessToken = () => { 31 | return sessionStorage.getItem("accessToken"); 32 | }; 33 | 34 | const refreshAccessToken = async () => { 35 | const response = await makeApiRequest("auth/refresh-token", { 36 | method: "POST", 37 | }); 38 | 39 | if (response.accessToken) { 40 | sessionStorage.setItem("accessToken", response.accessToken); 41 | } 42 | 43 | return response.accessToken; 44 | }; 45 | 46 | const isLoggedIn = () => { 47 | return !!sessionStorage.getItem("accessToken"); 48 | }; 49 | 50 | export default { 51 | register, 52 | login, 53 | logout, 54 | getAccessToken, 55 | refreshAccessToken, 56 | isLoggedIn, 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/views/delete-thing/DeleteThing.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useParams, useNavigate } from "react-router-dom"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { fetchThings, deleteThing } from "../../../slices/thingsSlice"; 5 | import { setBreadcrumb } from "../../../slices/breadcrumbSlice"; 6 | import { showToast } from "../../../slices/toastSlice"; 7 | import styles from "./delete-thing.module.css"; 8 | 9 | const DeleteThing = () => { 10 | const { id } = useParams(); 11 | const navigate = useNavigate(); 12 | const dispatch = useDispatch(); 13 | const thing = useSelector((state) => 14 | state.things.items.find((thing) => thing.id === parseInt(id)) 15 | ); 16 | 17 | useEffect(() => { 18 | dispatch( 19 | setBreadcrumb([ 20 | { label: "Home", url: "/" }, 21 | { label: "My things", url: "/my-things/" }, 22 | { label: "Delete thing" }, 23 | ]) 24 | ); 25 | }, [dispatch]); 26 | 27 | useEffect(() => { 28 | if (!thing) { 29 | dispatch(fetchThings()); 30 | } 31 | }, [thing, dispatch]); 32 | 33 | const handleSubmit = async (e) => { 34 | e.preventDefault(); 35 | await dispatch(deleteThing(thing.id)); 36 | dispatch(showToast(`Thing deleted`)); 37 | navigate("/my-things/"); 38 | }; 39 | 40 | return ( 41 |
42 |
43 |

{thing && thing.name}

44 | 45 |
46 |
47 | ); 48 | }; 49 | 50 | export default DeleteThing; 51 | -------------------------------------------------------------------------------- /src/services/apiService.js: -------------------------------------------------------------------------------- 1 | import authService from "./authService"; 2 | import store from "../store"; 3 | import { showToast } from "../slices/toastSlice"; 4 | 5 | const API_URL = import.meta.env.VITE_API_URL; 6 | 7 | const makeApiRequest = async (url, options = {}) => { 8 | options.headers = options.headers || {}; 9 | // Include credentials for cross-origin requests 10 | options.credentials = "include"; 11 | options.headers["Content-Type"] = "application/json"; 12 | 13 | let accessToken = authService.getAccessToken(); 14 | if (accessToken) { 15 | options.headers["Authorization"] = `Bearer ${accessToken}`; 16 | } 17 | 18 | try { 19 | let response = await fetch(`${API_URL}/${url}`, options); 20 | 21 | if (accessToken && (response.status === 401 || response.status === 403)) { 22 | // Attempt to refresh the access token and re-request 23 | try { 24 | accessToken = await authService.refreshAccessToken(); 25 | if (accessToken) { 26 | options.headers["Authorization"] = `Bearer ${accessToken}`; 27 | response = await fetch(`${API_URL}/${url}`, options); 28 | } else { 29 | throw new Error("Unauthorized"); 30 | } 31 | } catch (error) { 32 | await authService.logout(); 33 | throw new Error("Unauthorized"); 34 | } 35 | } 36 | 37 | if (response.status >= 400) { 38 | const data = await response.json(); 39 | throw new Error(data.error || "Fetch failed"); 40 | } 41 | 42 | return await response.json(); 43 | } catch (error) { 44 | store.dispatch(showToast(error.message)); 45 | throw error; 46 | } 47 | }; 48 | 49 | export default makeApiRequest; 50 | -------------------------------------------------------------------------------- /src/slices/usersSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; 2 | import apiService from "../services/apiService"; 3 | 4 | export const fetchUsers = createAsyncThunk("users/fetchUsers", async () => { 5 | const response = await apiService("users", { method: "GET" }); 6 | return response; 7 | }); 8 | 9 | export const fetchUser = createAsyncThunk("users/fetchUser", async (id) => { 10 | const response = await apiService(`users/${id}`, { method: "GET" }); 11 | return response; 12 | }); 13 | 14 | const usersSlice = createSlice({ 15 | name: "users", 16 | initialState: { 17 | items: [], 18 | user: null, 19 | status: "idle", 20 | userStatus: "idle", 21 | error: null, 22 | userError: null, 23 | }, 24 | extraReducers: (builder) => { 25 | builder 26 | .addCase(fetchUsers.pending, (state) => { 27 | state.status = "loading"; 28 | }) 29 | .addCase(fetchUsers.fulfilled, (state, action) => { 30 | state.status = "succeeded"; 31 | state.items = action.payload; 32 | }) 33 | .addCase(fetchUsers.rejected, (state, action) => { 34 | state.status = "failed"; 35 | state.error = action.error.message; 36 | }) 37 | .addCase(fetchUser.pending, (state) => { 38 | state.userStatus = "loading"; 39 | }) 40 | .addCase(fetchUser.fulfilled, (state, action) => { 41 | state.userStatus = "succeeded"; 42 | state.user = action.payload; 43 | }) 44 | .addCase(fetchUser.rejected, (state, action) => { 45 | state.userStatus = "failed"; 46 | state.userError = action.error.message; 47 | }); 48 | }, 49 | }); 50 | 51 | export default usersSlice.reducer; 52 | -------------------------------------------------------------------------------- /src/components/views/my-things/MyThings.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { NavLink, useLocation } from "react-router-dom"; 4 | import { fetchMyThings } from "../../../slices/thingsSlice"; 5 | import { setBreadcrumb } from "../../../slices/breadcrumbSlice"; 6 | import styles from "./my-things.module.css"; 7 | 8 | const MyThings = () => { 9 | const dispatch = useDispatch(); 10 | const location = useLocation(); 11 | const things = useSelector((state) => state.things.userThings); 12 | const status = useSelector((state) => state.things.userStatus); 13 | const error = useSelector((state) => state.things.userError); 14 | 15 | useEffect(() => { 16 | dispatch(fetchMyThings()); 17 | }, [dispatch, location]); 18 | 19 | useEffect(() => { 20 | dispatch( 21 | setBreadcrumb([{ label: "Home", url: "/" }, { label: "My things" }]) 22 | ); 23 | }, [dispatch]); 24 | 25 | return ( 26 |
27 | {status === "loading" &&
Loading...
} 28 | {status === "failed" &&
{error}
} 29 | 44 |
45 | ); 46 | }; 47 | 48 | export default MyThings; 49 | -------------------------------------------------------------------------------- /src/components/views/thing-detail/ThingDetail.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { NavLink, useParams } from "react-router-dom"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { fetchThing } from "../../../slices/thingsSlice"; 5 | import { setBreadcrumb } from "../../../slices/breadcrumbSlice"; 6 | import styles from "./thing-detail.module.css"; 7 | 8 | const ThingDetail = () => { 9 | const { id } = useParams(); 10 | const dispatch = useDispatch(); 11 | const thing = useSelector((state) => state.things.currentThing); 12 | const status = useSelector((state) => state.things.status); 13 | const error = useSelector((state) => state.things.error); 14 | const isLoggedIn = useSelector((state) => state.auth.isLoggedIn); 15 | 16 | useEffect(() => { 17 | dispatch(fetchThing(id)); 18 | }, [dispatch, id]); 19 | 20 | useEffect(() => { 21 | dispatch( 22 | setBreadcrumb([ 23 | { label: "Home", url: "/" }, 24 | { label: "Things", url: "/things/" }, 25 | { label: thing?.name || "Thing" }, 26 | ]) 27 | ); 28 | }, [dispatch, thing]); 29 | 30 | if (status === "loading") { 31 | return
Loading...
; 32 | } 33 | 34 | if (status === "failed") { 35 | return
{error}
; 36 | } 37 | 38 | if (!thing) { 39 | return
Thing not found
; 40 | } 41 | 42 | return ( 43 |
44 |

45 | Name: {thing.name} 46 |

47 |

48 | Description: {thing.description} 49 |

50 | {isLoggedIn && ( 51 |

52 | Owner: 53 | 54 | 55 | {thing.user_name} 56 | 57 | 58 |

59 | )} 60 |
61 | ); 62 | }; 63 | 64 | export default ThingDetail; 65 | -------------------------------------------------------------------------------- /src/components/views/add-thing/AddThing.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useDispatch } from "react-redux"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { setBreadcrumb } from "../../../slices/breadcrumbSlice"; 5 | import { addThing } from "../../../slices/thingsSlice"; 6 | import { showToast } from "../../../slices/toastSlice"; 7 | import styles from "./add-thing.module.css"; 8 | 9 | const AddThing = () => { 10 | const navigate = useNavigate(); 11 | const dispatch = useDispatch(); 12 | const [name, setName] = useState(""); 13 | const [description, setDescription] = useState(""); 14 | 15 | useEffect(() => { 16 | dispatch( 17 | setBreadcrumb([{ label: "Home", url: "/" }, { label: "Add thing" }]) 18 | ); 19 | }, [dispatch]); 20 | 21 | const handleSubmit = async (e) => { 22 | e.preventDefault(); 23 | dispatch(addThing({ name, description })); 24 | dispatch(showToast(`${name} added!`)); 25 | setName(""); 26 | setDescription(""); 27 | navigate("/my-things/"); 28 | }; 29 | 30 | return ( 31 |
32 |
33 | 44 |