WebAPP Exam 2025
This commit is contained in:
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
client/README.md
Normal file
12
client/README.md
Normal file
@ -0,0 +1,12 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
33
client/eslint.config.js
Normal file
33
client/eslint.config.js
Normal file
@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3124
client/package-lock.json
generated
Normal file
3124
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
client/package.json
Normal file
32
client/package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.6",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^19.1.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
1
client/public/vite.svg
Normal file
1
client/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
180
client/src/API.js
Normal file
180
client/src/API.js
Normal file
@ -0,0 +1,180 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001/api/';
|
||||
function getJson(httpResponsePromise) {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpResponsePromise
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
response.json()
|
||||
.then( json => resolve(json) )
|
||||
.catch( err => reject({ error: "Cannot parse server response" }))
|
||||
|
||||
} else {
|
||||
response.json()
|
||||
.then(obj =>
|
||||
reject(obj)
|
||||
)
|
||||
.catch(err => reject({ error: "Cannot parse server response" }))
|
||||
}
|
||||
})
|
||||
.catch(err =>
|
||||
reject({ error: "Cannot communicate" })
|
||||
) // connection error
|
||||
});
|
||||
}
|
||||
|
||||
const getPosts = async () => {
|
||||
// Clientside publication is called timestamp
|
||||
return getJson(
|
||||
fetch(SERVER_URL + 'posts', { credentials: 'include' })
|
||||
).then( json => {
|
||||
const posts = json.map((post) => {
|
||||
const clientPost = {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
text: post.text,
|
||||
authorid: post.authorid,
|
||||
author: post.author,
|
||||
maxcomments: post.maxcomments,
|
||||
numcomments: post.numComments,
|
||||
timestamp: post.publication,
|
||||
comments: post.comments.map( c => { c.timestamp = c.publication; return c;})
|
||||
}
|
||||
//console.log(`POST: \n TITLE:${clientPost.title}\n ID:${clientPost.id}\n AUTHORID:${clientPost.authorid}\n AUTHOR:${clientPost.author}\n ${clientPost.timestamp} \n TEXT:${clientPost.text}`);
|
||||
return clientPost;
|
||||
});
|
||||
|
||||
return posts.sort((a,b) => dayjs(b.timestamp) - dayjs(a.timestamp));
|
||||
})
|
||||
}
|
||||
function editComment(comment) {
|
||||
const payload = { text: comment.text };
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function setCommentInteresting(comment) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id + "/interest", {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function unsetCommentInteresting(comment) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id + "/interest", {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function addPost(post) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/", {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(post)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function addComment(postid, comment) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + postid + "/comments/", {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(comment)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function deletePost(post) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + post.id, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function deleteComment(comment) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
const totpVerify = async (totpCode) => {
|
||||
return getJson(fetch(SERVER_URL + 'login-totp', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({code: totpCode}),
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
const logIn = async (credentials) => {
|
||||
return getJson(fetch(SERVER_URL + 'sessions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(credentials),
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
const getUserInfo = async () => {
|
||||
return getJson(fetch(SERVER_URL + 'sessions/current', {
|
||||
credentials: 'include'
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
const logOut = async() => {
|
||||
return getJson(fetch(SERVER_URL + 'sessions/current', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const API = { getPosts, editComment, addPost, addComment, deletePost, deleteComment, setCommentInteresting, unsetCommentInteresting,
|
||||
logIn, getUserInfo, logOut, totpVerify };
|
||||
export default API;
|
||||
0
client/src/App.css
Normal file
0
client/src/App.css
Normal file
184
client/src/App.jsx
Normal file
184
client/src/App.jsx
Normal file
@ -0,0 +1,184 @@
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
|
||||
import { React, useState, useEffect } from 'react';
|
||||
import { Container } from 'react-bootstrap';
|
||||
import { Routes, Route, useLocation, Navigate, useNavigate } from 'react-router';
|
||||
|
||||
import './App.css';
|
||||
|
||||
import { GenericLayout, AddPostLayout, AddCommentLayout, EditLayout, LoginLayout, TotpLayout } from './components/Layout';
|
||||
import API from './API.js';
|
||||
|
||||
function App() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loggedInTotp, setLoggedInTotp] = useState(false);
|
||||
|
||||
const [postList, setPostList] = useState([]);
|
||||
const [message, setMessage] = useState('');
|
||||
const [dirty, setDirty] = useState(true);
|
||||
|
||||
const handleErrors = (err) => {
|
||||
let msg = '';
|
||||
if (err.error)
|
||||
msg = err.error;
|
||||
else if (err.errors) {
|
||||
if (err.errors[0].msg)
|
||||
msg = err.errors[0].msg + " : " + err.errors[0].path;
|
||||
} else if (Array.isArray(err))
|
||||
msg = err[0].msg + " : " + err[0].path;
|
||||
else if (typeof err === "string") msg = String(err);
|
||||
else msg = "Unknown Error";
|
||||
|
||||
setMessage(msg);
|
||||
|
||||
if (msg === 'Not authenticated')
|
||||
setTimeout(() => { // do logout in the app state
|
||||
setUser(null); setLoggedIn(false); setLoggedInTotp(false); setDirty(true);
|
||||
}, 2000);
|
||||
else
|
||||
setTimeout(()=>setDirty(true), 2000);
|
||||
}
|
||||
|
||||
const checkAuth = async() => {
|
||||
try {
|
||||
const user = await API.getUserInfo();
|
||||
setLoggedIn(true);
|
||||
setUser(user);
|
||||
if (user.isTotp)
|
||||
setLoggedInTotp(true);
|
||||
} catch(err) {
|
||||
}
|
||||
};
|
||||
|
||||
/* This useeffect is called each time the location is changed, so each time a Cancel button goes back to
|
||||
the home the posts are correctly fetched again. */
|
||||
const location = useLocation();
|
||||
|
||||
useEffect( () => {
|
||||
setDirty(true);
|
||||
} ,[location, location.state]);
|
||||
|
||||
useEffect(()=> {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (credentials) => {
|
||||
try {
|
||||
const user = await API.logIn(credentials);
|
||||
setUser(user);
|
||||
setLoggedIn(true);
|
||||
setDirty(true);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await API.logOut();
|
||||
setLoggedIn(false);
|
||||
setLoggedInTotp(false);
|
||||
setUser(null);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
function deletePost(post) {
|
||||
API.deletePost(post)
|
||||
.then(()=> setDirty(true))
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function deleteComment(comment) {
|
||||
API.deleteComment(comment)
|
||||
.then(()=> setDirty(true))
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function editComment(comment) {
|
||||
API.editComment(comment)
|
||||
.then(()=>{setDirty(true); navigate('/');})
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function setCommentInteresting(comment) {
|
||||
setDirty(true);
|
||||
API.setCommentInteresting(comment)
|
||||
.then(()=>{setDirty(true); navigate('/');})
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function unsetCommentInteresting(comment) {
|
||||
API.unsetCommentInteresting(comment)
|
||||
.then(()=>{setDirty(true); navigate('/');})
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function addPost(post) {
|
||||
API.addPost(post)
|
||||
.then(()=>{setDirty(true); navigate('/');})
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function addComment(postid, comment) {
|
||||
API.addComment(postid,comment)
|
||||
.then(()=>{setDirty(true); navigate('/');})
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
return (
|
||||
<Container fluid className="bg-light">
|
||||
<Routes>
|
||||
<Route path="/" element={<GenericLayout
|
||||
type={user != null ? user.type : "nouser"}
|
||||
loggedIn={loggedIn} loggedInTotp={loggedInTotp}
|
||||
message={message} setMessage={setMessage}
|
||||
user={user} logout={handleLogout}
|
||||
checkAuth={checkAuth}
|
||||
postList={postList} setPostList={setPostList}
|
||||
deletePost={deletePost} deleteComment={deleteComment} editComment={editComment}
|
||||
disableAdd={!loggedIn} disableActions={!loggedInTotp}
|
||||
handleErrors={handleErrors}
|
||||
setCommentInteresting={setCommentInteresting} unsetCommentInteresting={unsetCommentInteresting}
|
||||
dirty={dirty} setDirty={setDirty} /> }>
|
||||
|
||||
<Route path="add">
|
||||
<Route path="post" element={loggedIn?
|
||||
<AddPostLayout setDirty={setDirty} addPost={addPost} />
|
||||
: <Navigate replate to='/'/>} />
|
||||
<Route path=":postid/comment" element={<AddCommentLayout
|
||||
setDirty={setDirty}
|
||||
addComment={addComment} />} />
|
||||
</Route>
|
||||
<Route path="edit/:commentid" element={loggedIn?
|
||||
<EditLayout setDirty={setDirty}
|
||||
posts={postList}
|
||||
editComment={editComment} /> :
|
||||
<Navigate replace to='/' />} />
|
||||
</Route>
|
||||
<Route path='/login' element={ <LoginWithTotp loggedIn={loggedIn} login={handleLogin}
|
||||
checkAuth={checkAuth}
|
||||
user={user} setLoggedInTotp={setLoggedInTotp} /> } />
|
||||
</Routes>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginWithTotp(props) {
|
||||
if (props.loggedIn) {
|
||||
if (props.user && props.user.type == "administrator") {
|
||||
if (props.loggedInTotp) {
|
||||
return <Navigate replace to='/' />;
|
||||
} else {
|
||||
return <TotpLayout totpSuccessful={() => props.setLoggedInTotp(true)} />;
|
||||
}
|
||||
} else {
|
||||
return <Navigate replace to='/' />;
|
||||
}
|
||||
} else {
|
||||
return <LoginLayout checkAuth={props.checkAuth} login={props.login} />;
|
||||
}
|
||||
}
|
||||
export default App
|
||||
1
client/src/assets/react.svg
Normal file
1
client/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
139
client/src/components/Auth.jsx
Normal file
139
client/src/components/Auth.jsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Button, Alert, Col, Row } from 'react-bootstrap';
|
||||
import { useNavigate } from 'react-router';
|
||||
import API from '../API.js'
|
||||
|
||||
function TotpForm(props) {
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const doTotpVerify = () => {
|
||||
API.totpVerify(totpCode)
|
||||
.then(() => {
|
||||
setErrorMessage('');
|
||||
props.totpSuccessful();
|
||||
navigate('/');
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorMessage('Something went wrong, please try again');
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage('');
|
||||
|
||||
let valid = true;
|
||||
|
||||
if(totpCode === '' || totpCode.length !== 6)
|
||||
valid = false;
|
||||
|
||||
if (valid) {
|
||||
doTotpVerify(totpCode);
|
||||
} else {
|
||||
setErrorMessage('Invalid content in form: either empty or not 6-char long');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={4}></Col>
|
||||
<Col xs={4}>
|
||||
|
||||
<h2>Second Factor Authentication</h2>
|
||||
<h5>Please enter the code that you read on your device</h5>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
{errorMessage ? <Alert variant='danger' dismissible onClick={() => setErrorMessage('')}>{errorMessage}</Alert> : ''}
|
||||
<Form.Group controlId='totpCode'>
|
||||
<Form.Label>Code</Form.Label>
|
||||
<Form.Control type='text' required={true} value={totpCode} onChange={ev => setTotpCode(ev.target.value)} />
|
||||
</Form.Group>
|
||||
<Button className='my-2' type='submit'>Validate</Button>
|
||||
<Button className='my-2 mx-2' variant='danger' onClick={() => navigate('/')}>Cancel</Button>
|
||||
</Form>
|
||||
</Col>
|
||||
<Col xs={4}></Col>
|
||||
</Row>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
function LoginForm(props) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [istotp, setIstotp] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const credentials = { username, password };
|
||||
|
||||
if (!username) {
|
||||
setErrorMessage('Username cannot be empty');
|
||||
} else if (!password) {
|
||||
setErrorMessage('Password cannot be empty');
|
||||
} else {
|
||||
props.login(credentials)
|
||||
.catch((err) => {
|
||||
setErrorMessage(err.error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={4}></Col>
|
||||
<Col xs={4}>
|
||||
<h1 className="pb-3">Login</h1>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
{errorMessage? <Alert dismissible onClose={() => setErrorMessage('')} variant="danger">{errorMessage}</Alert> : null}
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Username</Form.Label>
|
||||
<Form.Control
|
||||
type="username"
|
||||
value={username} placeholder="Enter your username"
|
||||
onChange={(ev) => setUsername(ev.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Password</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={password} placeholder="Enter your password"
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Check type="checkbox"
|
||||
label="Use OTP"
|
||||
onChange={(ev) => setIstotp(ev.target.value)}
|
||||
/>
|
||||
<Button className="my-2" type="submit" onClick={()=> istotp ? props.checkAuth() : navigate('/')} >Login</Button>
|
||||
<Button className='my-2 mx-2' variant='danger' onClick={() => navigate('/')}>Cancel</Button>
|
||||
</Form>
|
||||
</Col>
|
||||
<Col xs={4}></Col>
|
||||
</Row>
|
||||
|
||||
)
|
||||
};
|
||||
|
||||
function LogoutButton(props) {
|
||||
return (
|
||||
<Button variant="outline-light" onClick={props.logout}>Logout</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginButton(props) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Button variant="outline-light" onClick={()=> navigate('/login')}>{props.text}</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { LoginForm, LogoutButton, LoginButton, TotpForm };
|
||||
63
client/src/components/CommentEdit.jsx
Normal file
63
client/src/components/CommentEdit.jsx
Normal file
@ -0,0 +1,63 @@
|
||||
import {useState} from 'react';
|
||||
import {Form, Button, Alert} from 'react-bootstrap';
|
||||
import { Link, useParams } from 'react-router';
|
||||
/* Edit or add a comment */
|
||||
const CommentForm = (props) => {
|
||||
|
||||
const [text, setText] = useState(props.commentToEdit ? props.commentToEdit.text : '');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const postid = useParams().postid;
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
// String.trim() method is used for removing leading and ending whitespaces from the title.
|
||||
const comment = { "text" : text.trim() };
|
||||
if(comment.text.length == 0){
|
||||
setErrorMsg('New text tength cannot be 0');
|
||||
}
|
||||
//Probably already checked by the form
|
||||
else if ( typeof comment.text !== "string" ){
|
||||
setErrorMsg("Text comment must be a string");
|
||||
}
|
||||
else {
|
||||
/* To edit we dont need postid since comment id is unique in db, instead to add a new comment to a post we need it. */
|
||||
if (props.commentToEdit) {
|
||||
comment.id = props.commentToEdit.id;
|
||||
comment.postid = props.commentToEdit.postid;
|
||||
props.editComment(comment);
|
||||
//navigate('/'); // not here, we must wait for the editing on the server to be finished
|
||||
} else {
|
||||
props.addComment(postid, comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ errorMsg ?
|
||||
<Alert variant='danger' onClose={()=>setErrorMsg('')} dismissible>{errorMsg}</Alert> :
|
||||
false }
|
||||
|
||||
{props.commentToEdit ?
|
||||
<h1>Edit comment</h1> :
|
||||
<h1> Insert new comment</h1>}
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Text</Form.Label>
|
||||
<Form.Control as="textarea" required={true} value={text} onChange={event => setText(event.target.value)}/>
|
||||
</Form.Group>
|
||||
|
||||
<Button className="mb-3" variant="primary" type="submit">Save</Button>
|
||||
|
||||
<Link to={"/"} >
|
||||
<Button className="mb-3" variant="danger">Cancel</Button>
|
||||
</Link>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export { CommentForm };
|
||||
119
client/src/components/Forum.jsx
Normal file
119
client/src/components/Forum.jsx
Normal file
@ -0,0 +1,119 @@
|
||||
import 'dayjs';
|
||||
import { Button, Card} from 'react-bootstrap';
|
||||
import { useNavigate} from 'react-router';
|
||||
function ForumTable(props) {
|
||||
const { posts } = props;
|
||||
/*Posts embedded also comments*/
|
||||
return(
|
||||
<div>
|
||||
{posts.map((post) => <Post loggedIn={props.loggedIn} loggedInTotp={props.loggedInTotp}
|
||||
user={props.user}
|
||||
postData={post} key={post.title}
|
||||
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
|
||||
deletePost={props.deletePost} deleteComment={props.deleteComment}
|
||||
editComment={props.editComment}/>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Post(props) {
|
||||
const navigate = useNavigate();
|
||||
/* If is logged OTP or Owner then is always enabled */
|
||||
/* Somehow can happen if reloading the page in other form that loggedIn remains true without a user state */
|
||||
const isAuthor = props.loggedIn && props.user ?
|
||||
(props.user.id == props.postData.authorid ? true : false)
|
||||
: false;
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<Card.Header className="bg-primary text-white">{props.postData.title}</Card.Header>
|
||||
<Card.Body>
|
||||
<Card.Text style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{props.postData.text}
|
||||
</Card.Text>
|
||||
{isAuthor || props.loggedInTotp ?
|
||||
<Button className="mx-1 mt-1" variant='danger'
|
||||
onClick={() => { props.deletePost(props.postData) }} >
|
||||
<i className='bi bi-trash'></i>
|
||||
</Button> :
|
||||
null
|
||||
}
|
||||
<Button variant="primary" className="mx-1 mt-1" disabled={props.postData.maxcomments != -1 && props.postData.numcomments == props.postData.maxcomments ? true : false}
|
||||
onClick={()=>{navigate(`/add/${props.postData.id}/comment`); window.scrollTo(0,0);}} >
|
||||
<i className='bi bi-plus'/>
|
||||
</Button>
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
Published by: {props.postData.author} on {props.postData.timestamp}<br/>
|
||||
<b className="mx-1">Comments: {props.postData.numcomments} </b>
|
||||
{props.postData.maxcomments != -1 ? <b> Max:{props.postData.maxcomments} </b> : null}
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
<br/>
|
||||
<div>{props.postData.comments.map( (comment) => <Comment loggedInTotp={props.loggedInTotp} user={props.user} loggedIn={props.loggedIn}
|
||||
commentData={comment} key={comment.id}
|
||||
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
|
||||
deleteComment={props.deleteComment} editComment={props.editComment}/>)}</div>
|
||||
<br/>
|
||||
</>
|
||||
);}
|
||||
|
||||
function Comment(props) {
|
||||
const navigate = useNavigate();
|
||||
/* Authorid is always defined (at least is null) instead author name is undefined if authorid is null
|
||||
One user can delete the comment if logged with totp or if OWNER of comment */
|
||||
function checkAuthor(user, comment){
|
||||
if(user){
|
||||
return user.id == comment.authorid;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const isAuthor = props.loggedIn ? checkAuthor(props.user,props.commentData) : false;
|
||||
const likedClass = "mx-5 my-3 bg-light border border-3 border-success rounded"
|
||||
const generalClass = "mx-5 my-3 bg-light border border-3 rounded"
|
||||
return (
|
||||
<>
|
||||
<Card className={props.commentData.isInterested ? likedClass : generalClass}>
|
||||
<Card.Header>
|
||||
<b className="mx-2 text-primary"> {props.commentData.authorid == null ? "Anonymous" : props.commentData.author }</b>
|
||||
{ props.commentData.isInterested ?
|
||||
<i className="bi bi-flag-fill"></i>
|
||||
: <br/> }
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Card.Text style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{props.commentData.text}
|
||||
</Card.Text>
|
||||
{ props.loggedIn ?
|
||||
(props.commentData.isInterested ?
|
||||
|
||||
<Button className="mx-1" onClick={ () => props.unsetCommentInteresting(props.commentData) }>
|
||||
<i className={"bi bi-hand-thumbs-up-fill"}></i>
|
||||
</Button> :
|
||||
|
||||
<Button className="mx-1" onClick={ () => props.setCommentInteresting(props.commentData) } >
|
||||
<i className={"bi bi-hand-thumbs-up"}></i>
|
||||
</Button>)
|
||||
:
|
||||
null }
|
||||
{ isAuthor || props.loggedInTotp ? <>
|
||||
<Button className="mx-1" variant='danger'
|
||||
onClick={ () => props.deleteComment(props.commentData) } >
|
||||
<i className='bi bi-trash'></i>
|
||||
</Button>
|
||||
<Button className="mx-1" variant='warning'
|
||||
onClick={() => { navigate(`/edit/${props.commentData.id}`); window.scrollTo(0,0); }} >
|
||||
<i className='bi bi-pencil'></i>
|
||||
</Button> </> : null }
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
Published on {props.commentData.timestamp} <br/>
|
||||
{props.commentData.numInterested ?
|
||||
<b>{props.commentData.numInterested } interested </b> :
|
||||
<br/> }
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export { ForumTable };
|
||||
146
client/src/components/Layout.jsx
Normal file
146
client/src/components/Layout.jsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { Row, Col, Button, Spinner, Alert } from 'react-bootstrap';
|
||||
import { Outlet, Link, useParams, Navigate, useNavigate } from 'react-router';
|
||||
|
||||
import { Navigation } from './Navigation';
|
||||
import { ForumTable } from './Forum';
|
||||
import { CommentForm } from './CommentEdit';
|
||||
import { PostForm } from './PostCreate';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LoginForm, TotpForm } from './Auth';
|
||||
import API from '../API.js';
|
||||
|
||||
|
||||
function NotFoundLayout(props) {
|
||||
return (
|
||||
<>
|
||||
<h2>This route is not valid!</h2>
|
||||
<Link to="/">
|
||||
<Button variant="primary">Go back to the main page!</Button>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginLayout(props) {
|
||||
return (
|
||||
<Row>
|
||||
<Col>
|
||||
<LoginForm checkAuth={props.checkAuth} login={props.login} />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function TotpLayout(props) {
|
||||
return (
|
||||
<Row>
|
||||
<Col>
|
||||
<TotpForm totpSuccessful={props.totpSuccessful} />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPostLayout(props) {
|
||||
return (
|
||||
<PostForm setDirty={props.setDirty} addPost={props.addPost} />
|
||||
);
|
||||
}
|
||||
|
||||
function AddCommentLayout(props) {
|
||||
return (
|
||||
<CommentForm setDirty={props.setDirty} addComment={props.addComment} />
|
||||
);
|
||||
}
|
||||
function EditLayout(props) {
|
||||
const { commentid } = useParams();
|
||||
const findComment = (posts) => {
|
||||
for (const post of posts){
|
||||
if( post.comments )
|
||||
for( const comment of post.comments ){
|
||||
if(comment.id == commentid)
|
||||
return comment;
|
||||
}
|
||||
}
|
||||
}
|
||||
const commentToEdit = findComment(props.posts)
|
||||
console.log("EDIT COMMENT: "+commentToEdit);
|
||||
return(
|
||||
<>
|
||||
{commentToEdit?
|
||||
<CommentForm setDirty={props.setDirty} editComment={props.editComment} commentToEdit={commentToEdit} />
|
||||
: <Navigate to={"/"} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TableLayout(props) {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [waiting, setWaiting] = useState(true);
|
||||
|
||||
useEffect( () => {
|
||||
if(props.dirty){
|
||||
API.getPosts()
|
||||
.then( posts => {
|
||||
props.setPostList(posts);
|
||||
props.setDirty(false);
|
||||
setWaiting(false);
|
||||
}).catch( e => { props.handleErrors(e); } );
|
||||
|
||||
props.setDirty(false);
|
||||
}
|
||||
},[props.dirty]);
|
||||
return (
|
||||
<>
|
||||
{ props.loggedIn ?
|
||||
<div className="d-flex flex-row justify-content-between">
|
||||
<Button variant="primary" className="my-2" disabled={props.disableAdd}
|
||||
onClick={()=>navigate('/add/post')}>+</Button>
|
||||
</div> :
|
||||
<Row><Col> <br/> </Col></Row> }
|
||||
{ waiting? <Spinner /> :
|
||||
<ForumTable
|
||||
loggedIn={props.loggedIn} loggedInTotp={props.loggedInTotp}
|
||||
posts={props.postList} deletePost={props.deletePost}
|
||||
deleteComment={props.deleteComment} editComment={props.editComment}
|
||||
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
|
||||
user = {props.user} checkAuth = {props.checkAuth}
|
||||
disableActions={props.disableActions} />
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericLayout(props) {
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col>
|
||||
<Navigation type={props.type} checkAuth={props.checkAuth} loggedIn={props.loggedIn} user={props.user} loggedInTotp={props.loggedInTotp} logout={props.logout} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
{props.message? <Alert className='my-1' onClose={() => props.setMessage('')} variant='danger' dismissible>
|
||||
{props.message}</Alert> : null}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Outlet />
|
||||
<TableLayout
|
||||
loggedIn={props.loggedIn} loggedInTotp={props.loggedInTotp}
|
||||
user={props.user} checkAuth={props.checkAuth}
|
||||
message={props.message} setMessage={props.setMessage}
|
||||
postList={props.postList} setPostList={props.setPostList}
|
||||
deletePost={props.deletePost} deleteComment={props.deleteComment} editComment={props.editComment}
|
||||
disableAdd={!props.loggedIn} disableActions={!props.loggedInTotp}
|
||||
handleErrors={props.handleErrors}
|
||||
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
|
||||
dirty={props.dirty} setDirty={props.setDirty} />
|
||||
</Row>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
export { GenericLayout, NotFoundLayout, TableLayout, AddPostLayout, AddCommentLayout, EditLayout, LoginLayout, TotpLayout };
|
||||
35
client/src/components/Navigation.jsx
Normal file
35
client/src/components/Navigation.jsx
Normal file
@ -0,0 +1,35 @@
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
|
||||
import { Navbar, Nav, Form } from 'react-bootstrap';
|
||||
import { Link } from 'react-router';
|
||||
import { LoginButton, LogoutButton } from './Auth';
|
||||
const spawnButton = (props) => {
|
||||
if(props.loggedIn){
|
||||
if(props.loggedInTotp)
|
||||
return <> <LogoutButton logout={props.logout}/> </>
|
||||
if(props.type == "administrator")
|
||||
return <> <LogoutButton logout={props.logout}/> <LoginButton text="OTP"/> </>
|
||||
return <> <LogoutButton logout={props.logout}/> </>
|
||||
}
|
||||
return <LoginButton text="Login"/>
|
||||
}
|
||||
const Navigation = (props) => {
|
||||
return (
|
||||
<Navbar bg="primary" expand="md" variant="dark" className="rounded navbar-padding">
|
||||
<Navbar.Brand as={Link} to="/" >
|
||||
<i className="bi bi-flask mx-2" />
|
||||
Forum Exam
|
||||
</Navbar.Brand>
|
||||
<Nav className="ms-auto">
|
||||
<Navbar.Text className="mx-2 fs-5">
|
||||
{props.user == undefined ? 'Anonymous' : (props.user.username && `Logged in ${props.loggedInTotp ? '(2FA)' : ''} as: ${props.user.username}`)}
|
||||
</Navbar.Text>
|
||||
<Form className="mx-2 mt-1">
|
||||
{spawnButton(props)}
|
||||
</Form>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { Navigation };
|
||||
63
client/src/components/PostCreate.jsx
Normal file
63
client/src/components/PostCreate.jsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Button, Alert } from 'react-bootstrap';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const PostForm = (props) => {
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [text, setText] = useState('');
|
||||
const [checked, setChecked] = useState(false)
|
||||
/* Default is no limit on comments */
|
||||
const [max, setMax] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const post = { "title": title.trim(), "text": text.trim(), max: checked == true ? max : -1};
|
||||
if (post.title.length == 0) {
|
||||
setErrorMsg('Title length cannot be 0');
|
||||
} else if (post.text.length == 0){
|
||||
setErrorMsg('Text of post length cannot be 0');
|
||||
} else if (post.max < -1) {
|
||||
setErrorMsg('Invalid value for max number of comments');
|
||||
} else {
|
||||
props.addPost(post);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{errorMsg? <Alert variant='danger' onClose={()=>setErrorMsg('')} dismissible>{errorMsg}</Alert> : false }
|
||||
<h1>Create a new post</h1>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Title</Form.Label>
|
||||
<Form.Control type="text" required={true} value={title} onChange={event => setTitle(event.target.value)}/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Text</Form.Label>
|
||||
<Form.Control as="textarea" required={true} value={text} onChange={event => setText(event.target.value)}/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3"> <Form.Check type="checkbox"
|
||||
label="Set Max Comments"
|
||||
onChange={(ev) => setChecked(ev.target.checked)}
|
||||
/>
|
||||
<Form.Label>Max Comments</Form.Label>
|
||||
<Form.Control type="number" min={0} step={1} value={max} disabled={!checked} onChange={event => setMax(parseInt(event.target.value))}/>
|
||||
</Form.Group>
|
||||
|
||||
<Button className="mb-3" variant="primary" type="submit">Save</Button>
|
||||
|
||||
<Link to={"/"} >
|
||||
<Button className="mb-3" variant="danger">Cancel</Button>
|
||||
</Link>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export { PostForm };
|
||||
0
client/src/index.css
Normal file
0
client/src/index.css
Normal file
13
client/src/main.jsx
Normal file
13
client/src/main.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
7
client/vite.config.js
Normal file
7
client/vite.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user