Created Woodpecker CI/CD deployment
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Created Dockerfile for packing up API and Web projects as Docker image
This commit is contained in:
99
Web/src/App.js
Normal file
99
Web/src/App.js
Normal file
@ -0,0 +1,99 @@
|
||||
import React from "react";
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { Footer, Header, ProtectedRoute } from './components';
|
||||
import { BaseLayout, HomeView, RedirectView, BookmarksListView, BookmarkDetailView, TestView, NotFoundView, TagsView } from './views';
|
||||
import { isDev } from "./utils";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App" style={{minHeight: '100vh', display: 'flex', flexDirection: 'column'}}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<BaseLayout header={ <Header />} content={ <HomeView /> } footer={ <Footer />}/>}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/redirect"
|
||||
element={<BaseLayout header={ <Header />} content={ <RedirectView />} footer={ <Footer />}/> }
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/bookmarks"
|
||||
element={
|
||||
<ProtectedRoute
|
||||
layout={BaseLayout}
|
||||
header={Header}
|
||||
footer={Footer}
|
||||
view={BookmarksListView}
|
||||
viewProps={{showHidden: false}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/bookmarks/hidden"
|
||||
element={
|
||||
<ProtectedRoute
|
||||
layout={BaseLayout}
|
||||
header={Header}
|
||||
footer={Footer}
|
||||
view={BookmarksListView}
|
||||
viewProps={{showHidden: true}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/bookmarks/new"
|
||||
element={
|
||||
<ProtectedRoute
|
||||
layout={BaseLayout}
|
||||
header={Header}
|
||||
footer={Footer}
|
||||
view={BookmarkDetailView}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/bookmarks/:id"
|
||||
element={
|
||||
<ProtectedRoute
|
||||
layout={BaseLayout}
|
||||
header={Header}
|
||||
footer={Footer}
|
||||
view={BookmarkDetailView}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/404"
|
||||
element={<BaseLayout content={<NotFoundView />} />}
|
||||
/>
|
||||
|
||||
{ isDev() && (
|
||||
<Route
|
||||
path="/test"
|
||||
element={<BaseLayout header={ <Header />} content={ <TestView /> } footer={ <Footer />}/>}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path="/tags"
|
||||
element={
|
||||
<ProtectedRoute
|
||||
layout={BaseLayout}
|
||||
header={Header}
|
||||
footer={Footer}
|
||||
view={TagsView}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
48
Web/src/api/apiHelper.js
Normal file
48
Web/src/api/apiHelper.js
Normal file
@ -0,0 +1,48 @@
|
||||
// Taken from: https://github.com/auth0-developer-hub/spa_react_javascript_hello-world_react-router-6
|
||||
import axios from "axios";
|
||||
|
||||
export const callExternalApi = async (options) => {
|
||||
try {
|
||||
const response = await axios(options.config);
|
||||
const { data } = response;
|
||||
|
||||
return {
|
||||
data,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error;
|
||||
|
||||
const { response } = axiosError;
|
||||
|
||||
let message = "http request failed";
|
||||
|
||||
if (response && response.statusText) {
|
||||
message = response.statusText;
|
||||
}
|
||||
|
||||
if (axiosError.message) {
|
||||
message = axiosError.message;
|
||||
}
|
||||
|
||||
if (response && response.data && response.data.message) {
|
||||
message = response.data.message;
|
||||
}
|
||||
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
21
Web/src/api/index.js
Normal file
21
Web/src/api/index.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { callExternalApi } from "./apiHelper";
|
||||
import { getAllBookmarks, getBookmark, createNewBookmark, updateBookmark, deleteBookmark, deleteBookmarks, hideBookmarks } from "./v1/bookmarks";
|
||||
import { getAllTags, createNewTag, updateTag, deleteTags, hideTags } from "./v1/tags";
|
||||
import { getWebsiteMetaData } from "./v1/misc";
|
||||
|
||||
export {
|
||||
callExternalApi,
|
||||
getAllBookmarks,
|
||||
getBookmark,
|
||||
createNewBookmark,
|
||||
updateBookmark,
|
||||
getWebsiteMetaData,
|
||||
deleteBookmark,
|
||||
deleteBookmarks,
|
||||
hideBookmarks,
|
||||
getAllTags,
|
||||
createNewTag,
|
||||
updateTag,
|
||||
deleteTags,
|
||||
hideTags,
|
||||
};
|
||||
118
Web/src/api/v1/bookmarks.js
Normal file
118
Web/src/api/v1/bookmarks.js
Normal file
@ -0,0 +1,118 @@
|
||||
import { callExternalApi } from "../apiHelper";
|
||||
|
||||
const apiServerUrl = `${process.env.REACT_APP_API_BASE_URL}/v1/Bookmarks`;
|
||||
|
||||
export const getAllBookmarks = async(accessToken, showHidden = false) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}?showHidden=${showHidden}`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
|
||||
export const getBookmark = async(accessToken, id) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}/${id}`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
|
||||
export const createNewBookmark = async(accessToken, newBookmark) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
data: newBookmark
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
|
||||
export const updateBookmark = async(accessToken, id, updatedBookmarkEntry) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}/${id}`,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
data: updatedBookmarkEntry
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
|
||||
export const addNewBookmarkTags = async(accessToken, id, tags) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}/${id}/Tags`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
data: {
|
||||
tags: tags
|
||||
}
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
|
||||
export const deleteBookmark = async(accessToken, id) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}/${id}`,
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
|
||||
export const deleteBookmarks = async(accessToken, ids) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}`,
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
data: {
|
||||
ids: ids
|
||||
}
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
|
||||
export const hideBookmarks = async(accessToken, ids) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}/Hide`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
data: {
|
||||
ids: ids
|
||||
}
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
19
Web/src/api/v1/misc.js
Normal file
19
Web/src/api/v1/misc.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { callExternalApi } from "../apiHelper";
|
||||
|
||||
const apiServerUrl = `${process.env.REACT_APP_API_BASE_URL}/v1/Misc`;
|
||||
|
||||
export const getWebsiteMetaData = async(accessToken, url) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}/GetWebsiteMetaData`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
params: {
|
||||
'url': url
|
||||
}
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
}
|
||||
76
Web/src/api/v1/tags.js
Normal file
76
Web/src/api/v1/tags.js
Normal file
@ -0,0 +1,76 @@
|
||||
import { callExternalApi } from "../apiHelper";
|
||||
|
||||
const apiServerUrl = `${process.env.REACT_APP_API_BASE_URL}/v1/Tags`;
|
||||
|
||||
export const getAllTags = async(accessToken) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
|
||||
export const createNewTag = async(accessToken, newTag) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
data: newTag
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
|
||||
export const updateTag = async(accessToken, id, updatedTagEntry) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}/${id}`,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
data: updatedTagEntry
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
|
||||
export const deleteTags = async(accessToken, ids) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}`,
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
data: {
|
||||
ids: ids
|
||||
}
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
|
||||
export const hideTags = async(accessToken, ids) => {
|
||||
const config = {
|
||||
url: `${apiServerUrl}/Hide`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
data: {
|
||||
ids: ids
|
||||
}
|
||||
};
|
||||
|
||||
return await callExternalApi({config});
|
||||
};
|
||||
36
Web/src/components/auth0ProviderWithNavigate.jsx
Normal file
36
Web/src/components/auth0ProviderWithNavigate.jsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { Auth0Provider } from "@auth0/auth0-react";
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const Auth0ProviderWithNavigate = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const domain = process.env.REACT_APP_AUTH0_DOMAIN;
|
||||
const clientId = process.env.REACT_APP_AUTH0_CLIENT_ID;
|
||||
const redirectUri = process.env.REACT_APP_AUTH0_CALLBACK_URL;
|
||||
const audience = process.env.REACT_APP_AUTH0_AUDIENCE;
|
||||
|
||||
const onRedirectCallback = (appState) => {
|
||||
navigate(appState?.returnTo || window.location.pathname);
|
||||
};
|
||||
|
||||
if (!(domain && clientId && redirectUri)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Auth0Provider
|
||||
domain={domain}
|
||||
clientId={clientId}
|
||||
authorizationParams={{
|
||||
redirect_uri: redirectUri,
|
||||
audience: audience
|
||||
}}
|
||||
onRedirectCallback={onRedirectCallback}
|
||||
useRefreshTokens={true}
|
||||
cacheLocation="localstorage"
|
||||
>
|
||||
{children}
|
||||
</Auth0Provider>
|
||||
);
|
||||
};
|
||||
39
Web/src/components/bookmarks/bookmark.jsx
Normal file
39
Web/src/components/bookmarks/bookmark.jsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { Row, Col, Form, Button } from "../external";
|
||||
import DateTimeHelper from "../../utils/dateTimeHelper";
|
||||
import { getHighlightedText } from "../../utils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Tag } from "../../components";
|
||||
|
||||
export function Bookmark(props) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getTruncatedDescription = () => `${props.bookmark.description.substring(0, 97)}${props.bookmark.description.length >= 97 ? "..." : ""}`;
|
||||
const getHighlightedTitle = () => getHighlightedText(props.bookmark.title, props.searchString);
|
||||
const getHighlightedDescription = () => getHighlightedText(getTruncatedDescription(), props.searchString)
|
||||
|
||||
return <div className="mb-3">
|
||||
<Row>
|
||||
<Col xs={1} className="d-flex justify-content-center align-items-center">
|
||||
<Form.Check onChange={(e) => {props.onBookmarkSelected(e.target.checked)}} checked={props.bookmark.isSelected} />
|
||||
</Col>
|
||||
<Col xs={11}>
|
||||
<a href={props.bookmark.url} target="_blank" style={{textDecoration: "none"}}>{ getHighlightedTitle() }</a>
|
||||
<div className="font-weight-normal">{ getHighlightedDescription()}</div>
|
||||
<div>
|
||||
{
|
||||
props.bookmark.tags.map((tag) => <Tag key={props.bookmark.id-tag.id} tag={tag} searchString={props.searchString}/>)
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<span>{DateTimeHelper.getFriendlyDate(props.bookmark.createdOn)} | </span>
|
||||
<span>
|
||||
<Button variant="link" className="p-0 me-2" style={{textDecoration: "none"}} onClick={() => navigate(`/bookmarks/${props.bookmark.id}`)}>Edit</Button>
|
||||
<Button variant="link" className="p-0 me-2" style={{textDecoration: "none"}} onClick={props.onHideClicked}>{props.bookmark.isHidden ? "Unhide" : "Hide"}</Button>
|
||||
<Button variant="link" className="text-danger p-0 me-2" style={{textDecoration: "none"}} onClick={props.onDeleteClicked}>Delete</Button>
|
||||
</span>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
}
|
||||
23
Web/src/components/external/index.js
vendored
Normal file
23
Web/src/components/external/index.js
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
import Alert from "react-bootstrap/Alert";
|
||||
import Col from "react-bootstrap/Col";
|
||||
import Container from "react-bootstrap/Container";
|
||||
import Row from "react-bootstrap/Row";
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import Button from "react-bootstrap/Button";
|
||||
import Modal from 'react-bootstrap/Modal'
|
||||
import { Table } from "react-bootstrap";
|
||||
import Dropdown from 'react-bootstrap/Dropdown';
|
||||
import DropdownButton from 'react-bootstrap/DropdownButton';
|
||||
|
||||
export {
|
||||
Alert,
|
||||
Col,
|
||||
Container,
|
||||
Row,
|
||||
Form,
|
||||
Button,
|
||||
Modal,
|
||||
Table,
|
||||
Dropdown,
|
||||
DropdownButton,
|
||||
};
|
||||
13
Web/src/components/footer.jsx
Normal file
13
Web/src/components/footer.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import Container from 'react-bootstrap/Container';
|
||||
|
||||
export function Footer(props) {
|
||||
return (
|
||||
<footer className="py-4 bg-light mt-auto">
|
||||
<Container fluid>
|
||||
<div className="text-center align-middle">YABA: Yet Another Bookmark App</div>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
69
Web/src/components/header.jsx
Normal file
69
Web/src/components/header.jsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import Navbar from 'react-bootstrap/Navbar';
|
||||
import NavDropdown from 'react-bootstrap/NavDropdown';
|
||||
import Container from 'react-bootstrap/Container';
|
||||
import Nav from 'react-bootstrap/Nav';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { useAuth0 } from "@auth0/auth0-react";
|
||||
|
||||
export function Header(props) {
|
||||
const { isAuthenticated, loginWithRedirect, logout } = useAuth0();
|
||||
const handleLogin = async () => {
|
||||
await loginWithRedirect({
|
||||
appState: {
|
||||
returnTo: "/bookmarks",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSignUp = async () => {
|
||||
await loginWithRedirect({
|
||||
appState: {
|
||||
returnTo: "/bookmarks",
|
||||
},
|
||||
authorizationParams: {
|
||||
screen_hint: "signup",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout({
|
||||
logoutParams: {
|
||||
returnTo: window.location.origin,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar bg="dark" variant="dark" expand="lg">
|
||||
<Container>
|
||||
<Navbar.Brand href={!isAuthenticated ? "/" : "/bookmarks"}>YABA: Yet Another Bookmark App</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||
<Navbar.Collapse id="basic-navbar-nav" />
|
||||
<Nav className="ms-auto">
|
||||
{!isAuthenticated && (
|
||||
<>
|
||||
<Nav.Link onClick={handleLogin}>Login</Nav.Link>
|
||||
<Nav.Link onClick={handleSignUp}>Register</Nav.Link>
|
||||
</>
|
||||
)}
|
||||
{ isAuthenticated && (
|
||||
<>
|
||||
<NavDropdown title="Bookmarks">
|
||||
<NavDropdown.Item href="/bookmarks">All</NavDropdown.Item>
|
||||
<NavDropdown.Item href="/bookmarks/hidden">Hidden</NavDropdown.Item>
|
||||
<NavDropdown.Divider />
|
||||
<NavDropdown.Item href="/bookmarks/new">New</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
<Nav.Link href="/tags">Tags</Nav.Link>
|
||||
<Nav.Link onClick={handleLogout}>Logout</Nav.Link>
|
||||
</>
|
||||
)}
|
||||
</Nav>
|
||||
</Container>
|
||||
</Navbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
Web/src/components/index.js
Normal file
23
Web/src/components/index.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { Footer } from "./footer";
|
||||
import { Header } from "./header";
|
||||
import { Bookmark } from "./bookmarks/bookmark";
|
||||
import { Auth0ProviderWithNavigate } from "./auth0ProviderWithNavigate";
|
||||
import { ProtectedRoute } from "./protectedRoute";
|
||||
import { SplashScreen } from "./shared/splashScreen";
|
||||
import { SearchForm } from "./shared/searchForm";
|
||||
import { UpsertTagModal } from "./tags/upsertTagModal";
|
||||
import { InterceptModal } from "./shared/interceptModal";
|
||||
import { Tag } from "./tags/tag";
|
||||
|
||||
export {
|
||||
Footer,
|
||||
Header,
|
||||
Bookmark,
|
||||
Auth0ProviderWithNavigate,
|
||||
ProtectedRoute,
|
||||
SplashScreen,
|
||||
SearchForm,
|
||||
UpsertTagModal,
|
||||
InterceptModal,
|
||||
Tag,
|
||||
};
|
||||
19
Web/src/components/protectedRoute.jsx
Normal file
19
Web/src/components/protectedRoute.jsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { withAuthenticationRequired } from "@auth0/auth0-react";
|
||||
import React from "react";
|
||||
import { SplashScreen } from "./shared/splashScreen";
|
||||
|
||||
export const ProtectedRoute = ({ layout, header, footer, view, viewProps }) => {
|
||||
const ProtectedView = withAuthenticationRequired(layout, {
|
||||
// onRedirecting: () => (
|
||||
// <div className="page-layout">
|
||||
// <SplashScreen message="You are being redirected to the authentication page" />
|
||||
// </div>
|
||||
// ),
|
||||
});
|
||||
|
||||
const Header = () => React.createElement(header);
|
||||
const Footer = () => React.createElement(footer);
|
||||
const View = () => React.createElement(view, viewProps);
|
||||
|
||||
return <ProtectedView header={<Header/>} footer={<Footer />} content={<View />} />;
|
||||
};
|
||||
32
Web/src/components/shared/interceptModal.jsx
Normal file
32
Web/src/components/shared/interceptModal.jsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { Button, Form, Modal } from "../external";
|
||||
|
||||
export function InterceptModal(props) {
|
||||
return (
|
||||
<Modal
|
||||
show={props.show}
|
||||
onHide={props.onHide}
|
||||
keyboard={false}
|
||||
backdrop={props.backdrop}
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{props.title}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>{props.message}</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant={props.secondaryAction.variant}
|
||||
onClick={props.secondaryAction.onClick}
|
||||
>
|
||||
{props.secondaryAction.text}
|
||||
</Button>
|
||||
<Button
|
||||
variant={props.primaryAction.variant}
|
||||
onClick={props.primaryAction.onClick}
|
||||
>
|
||||
{props.primaryAction.text}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
25
Web/src/components/shared/searchForm.jsx
Normal file
25
Web/src/components/shared/searchForm.jsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { Button, Form } from "../external";
|
||||
|
||||
export function SearchForm(props) {
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
onSubmit={props.onHandleSearch}
|
||||
className="d-flex">
|
||||
<Form.Control
|
||||
type="text"
|
||||
className="me-2"
|
||||
defaultValue={props.searchString}
|
||||
onChange={props.onSearchStringChange}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
{ props.searchButtonText ?? "Search" }
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
Web/src/components/shared/splashScreen.jsx
Normal file
49
Web/src/components/shared/splashScreen.jsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import Spinner from 'react-bootstrap/Spinner';
|
||||
import Row from 'react-bootstrap/Row';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
import Button from "react-bootstrap/Button";
|
||||
|
||||
export function SplashScreen(props) {
|
||||
return(
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: "0.6",
|
||||
backgroundColor: "grey"
|
||||
}}
|
||||
>
|
||||
<Row style={{
|
||||
width: "100%"
|
||||
}}>
|
||||
<Col xs="12" style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}>
|
||||
<Spinner animation="border" />
|
||||
</Col>
|
||||
<Col xs="12"style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}>
|
||||
<div>{props.message}</div>
|
||||
</Col>
|
||||
<Col xs="12"style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}>
|
||||
{props.showCloseSplashScreenButton && (
|
||||
<Button variant="link" onClick={props.onCloseSpashScreenClick}>Close this window</Button>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
Web/src/components/tags/tag.jsx
Normal file
16
Web/src/components/tags/tag.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import { Button } from "../external";
|
||||
import { getHighlightedText } from "../../utils";
|
||||
|
||||
export function Tag(props) {
|
||||
const getHighlightedTagName = () => props.searchString ? getHighlightedText(props.tag.name, props.searchString) : props.tag.name
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
style={{textDecoration: "none"}} className={`ms-0 me-2 p-0 ${props.tag.isHidden ? "text-danger" : null}`}
|
||||
onClick={props.onClick}>
|
||||
#{getHighlightedTagName()}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
73
Web/src/components/tags/upsertTagModal.jsx
Normal file
73
Web/src/components/tags/upsertTagModal.jsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { Button, Form, Modal } from "../external";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup"
|
||||
|
||||
export function UpsertTagModal(props) {
|
||||
const formik = useFormik({
|
||||
initialValues: props.tag,
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required("Name is required").lowercase()
|
||||
}),
|
||||
enableReinitialize: true,
|
||||
onSubmit: async(values) => props.onSave(values)
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={props.show}
|
||||
onHide={props.onHide}
|
||||
keyboard={false}
|
||||
backdrop="static"
|
||||
>
|
||||
<Form onSubmit={formik.handleSubmit}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{props.tag.id > 0 ? "Edit Tag" : "New Tag"}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Name:</Form.Label>
|
||||
<Form.Control
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Enter tag name"
|
||||
defaultValue={formik.values.name}
|
||||
onBlur={formik.handleChange}
|
||||
isInvalid={!!formik.errors.name}
|
||||
/>
|
||||
<Form.Control.Feedback
|
||||
type="invalid"
|
||||
className="d-flex justify-content-start"
|
||||
>
|
||||
{formik.errors.name}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="isHidden"
|
||||
type="switch"
|
||||
label="Mark as hidden"
|
||||
checked={formik.values.isHidden}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
{ props.tag.id > 0 && <Button
|
||||
variant="danger"
|
||||
onClick={props.onDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
16
Web/src/index.js
Normal file
16
Web/src/index.js
Normal file
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { Auth0ProviderWithNavigate } from "./components";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Auth0ProviderWithNavigate>
|
||||
<App />
|
||||
</Auth0ProviderWithNavigate>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
3
Web/src/styles/component/bookmark.css
Normal file
3
Web/src/styles/component/bookmark.css
Normal file
@ -0,0 +1,3 @@
|
||||
.test {
|
||||
background-color: red;
|
||||
}
|
||||
30
Web/src/tests/utils/arrayHelper.test.js
Normal file
30
Web/src/tests/utils/arrayHelper.test.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { isSubset } from "../../utils/arrayHelper";
|
||||
|
||||
describe("utils/arrayHelper", () => {
|
||||
describe("isSubset", () => {
|
||||
const testCases = [
|
||||
{
|
||||
parentArray: ["test1", "test2", "test3", "test4"],
|
||||
subsetArray: ["test1"],
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
parentArray: ["test1", "test2", "test3", "test4"],
|
||||
subsetArray: ["test5"],
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
parentArray: ["test1", "test2", "test3", "test4"],
|
||||
subsetArray: ["test2", "test4"],
|
||||
expected: true
|
||||
}
|
||||
];
|
||||
|
||||
testCases.forEach(test => {
|
||||
it(`[${test.subsetArray.join(", ")}] should be a subset of [${test.parentArray.join(", ")}] which is ${test.expected}`, () => {
|
||||
const actual = isSubset(test.parentArray, test.subsetArray);
|
||||
expect(actual).toEqual(test.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
6
Web/src/utils/arrayHelper.js
Normal file
6
Web/src/utils/arrayHelper.js
Normal file
@ -0,0 +1,6 @@
|
||||
// FROM: https://fjolt.com/article/javascript-check-if-array-is-subset
|
||||
export const isSubset = (parentArray, subsetArray) => {
|
||||
return subsetArray.every((el) => {
|
||||
return parentArray.includes(el)
|
||||
})
|
||||
};
|
||||
13
Web/src/utils/bookmarkHelper.js
Normal file
13
Web/src/utils/bookmarkHelper.js
Normal file
@ -0,0 +1,13 @@
|
||||
export const containsSubstring = (bookmark, searchString) => {
|
||||
if (bookmark === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(bookmark)
|
||||
.some(val => typeof(val) == 'object' ?
|
||||
containsSubstring(val, searchString) :
|
||||
typeof(val) == 'number' || typeof(val) == 'boolean' ?
|
||||
val.toString().toLowerCase().includes(searchString.toLowerCase()) :
|
||||
val.toLowerCase().includes(searchString.toLowerCase())
|
||||
);
|
||||
}
|
||||
38
Web/src/utils/dateTimeHelper.js
Normal file
38
Web/src/utils/dateTimeHelper.js
Normal file
@ -0,0 +1,38 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const utc = require('dayjs/plugin/utc')
|
||||
const timezone = require('dayjs/plugin/timezone') // dependent on utc plugin
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export default class DateTimeHelper {
|
||||
static ISO8601_DATETIMEFORMAT = () => {
|
||||
return 'YYYY-MM-DD HH:mm';
|
||||
};
|
||||
|
||||
static toLocalDateTime = (dateTimeString, formatString) => {
|
||||
return dayjs(dateTimeString).local().format(formatString ?? this.ISO8601_DATETIMEFORMAT());
|
||||
};
|
||||
|
||||
static toString = (dateTimeString, formatString) => {
|
||||
return dayjs(dateTimeString).format(formatString);
|
||||
};
|
||||
|
||||
static getFriendlyDate = (dateTimeString) => {
|
||||
const dateTimeStringInUtc = dayjs(dateTimeString).utc();
|
||||
const currentUtcDateTime = dayjs().utc();
|
||||
|
||||
if(currentUtcDateTime.diff(dateTimeStringInUtc, 'day') <= 7) {
|
||||
return dateTimeStringInUtc.local().format('dddd');
|
||||
} else if(currentUtcDateTime.diff(dateTimeStringInUtc, 'week') <= 4) {
|
||||
const differenceInWeeks = currentUtcDateTime.diff(dateTimeStringInUtc, 'week');
|
||||
return `${differenceInWeeks} ${ differenceInWeeks <= 1 ? 'week' : 'weeks'} ago`;
|
||||
} else if(currentUtcDateTime.diff(dateTimeStringInUtc, 'month') <= 12) {
|
||||
const differenceInMonths = currentUtcDateTime.diff(dateTimeStringInUtc, 'month');
|
||||
return `${differenceInMonths} ${ differenceInMonths <= 1 ? 'month' : 'months' } ago`;
|
||||
} else {
|
||||
const differenceInYears = currentUtcDateTime.diff(dateTimeStringInUtc, 'year');
|
||||
return `${differenceInYears} ${ differenceInYears <= 1 ? 'year' : 'years'} ago`;
|
||||
}
|
||||
};
|
||||
}
|
||||
14
Web/src/utils/index.js
Normal file
14
Web/src/utils/index.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { isDev } from "./isDevHelper";
|
||||
import { getTagGroups, flattenTagArrays } from "./tagsHelper";
|
||||
import { isSubset } from "./arrayHelper";
|
||||
import { containsSubstring } from "./bookmarkHelper";
|
||||
import { getHighlightedText } from "./stringsHelper";
|
||||
|
||||
export {
|
||||
isDev,
|
||||
getTagGroups,
|
||||
isSubset,
|
||||
containsSubstring,
|
||||
flattenTagArrays,
|
||||
getHighlightedText,
|
||||
}
|
||||
3
Web/src/utils/isDevHelper.js
Normal file
3
Web/src/utils/isDevHelper.js
Normal file
@ -0,0 +1,3 @@
|
||||
export const isDev = () => {
|
||||
return !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
|
||||
}
|
||||
10
Web/src/utils/stringsHelper.js
Normal file
10
Web/src/utils/stringsHelper.js
Normal file
@ -0,0 +1,10 @@
|
||||
// https://stackoverflow.com/questions/29652862/highlight-text-using-reactjs
|
||||
export const getHighlightedText = (text, highlight) => {
|
||||
// Split on highlight term and include term into parts, ignore case
|
||||
const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
|
||||
return <span> { parts.map((part, i) =>
|
||||
<span key={i} className={part.toLowerCase() === highlight.toLowerCase() ? "fw-bold" : ""}>
|
||||
{ part }
|
||||
</span>)
|
||||
} </span>;
|
||||
}
|
||||
25
Web/src/utils/tagsHelper.js
Normal file
25
Web/src/utils/tagsHelper.js
Normal file
@ -0,0 +1,25 @@
|
||||
export const getTagGroups = (allBookmarkTags) => {
|
||||
const allTags = allBookmarkTags.flat().reduce((accumulator, current) => {
|
||||
if(!accumulator.find((item) => item.id === current.id)) {
|
||||
accumulator.push(current);
|
||||
}
|
||||
|
||||
return accumulator
|
||||
}, []);
|
||||
|
||||
return allTags.map((x) => x['name'][0].toUpperCase()).reduce((accumulator, current) => {
|
||||
if(!accumulator.find((item) => item.name === current)) {
|
||||
accumulator.push({name: current, tags: allTags.filter((x) => x.name[0].toUpperCase() === current)});
|
||||
}
|
||||
|
||||
return accumulator
|
||||
}, []).sort((a, b) => (a.name < b.name) ? -1 : (a.name > b.name) ? 1 : 0);
|
||||
};
|
||||
|
||||
export const flattenTagArrays = (allBookmarkTags) => allBookmarkTags.flat().reduce((accumulator, current) => {
|
||||
if(!accumulator.find((item) => item.id === current.id)) {
|
||||
accumulator.push(current);
|
||||
}
|
||||
|
||||
return accumulator
|
||||
}, []);
|
||||
14
Web/src/views/baseLayout.jsx
Normal file
14
Web/src/views/baseLayout.jsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import Stack from 'react-bootstrap/Stack';
|
||||
|
||||
export function BaseLayout(props) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stack gap={3}>
|
||||
{ props.header }
|
||||
{ props.content }
|
||||
{ props.footer }
|
||||
</Stack>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
277
Web/src/views/bookmarkDetailView.jsx
Normal file
277
Web/src/views/bookmarkDetailView.jsx
Normal file
@ -0,0 +1,277 @@
|
||||
import React, { useEffect, useReducer } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup"
|
||||
import { SplashScreen } from "../components";
|
||||
import { useAuth0 } from "@auth0/auth0-react";
|
||||
import Container from 'react-bootstrap/Container';
|
||||
import Row from 'react-bootstrap/Row';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import Form from 'react-bootstrap/Form';
|
||||
import { getBookmark, createNewBookmark, updateBookmark } from "../api";
|
||||
import { Alert } from "react-bootstrap";
|
||||
|
||||
export function BookmarkDetailView() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { getAccessTokenSilently } = useAuth0();
|
||||
|
||||
const initialSplashScreenState = { show: false, message: null }
|
||||
|
||||
const splashScreenReducer = (state = initialSplashScreenState, action) => {
|
||||
switch(action.type) {
|
||||
case "SHOW_SPLASH_SCREEN":
|
||||
return Object.assign({}, state, {
|
||||
show: true,
|
||||
message: action.message
|
||||
});
|
||||
case "HIDE_SPLASH_SCREEN":
|
||||
default:
|
||||
return Object.assign({}, state, {
|
||||
show: false,
|
||||
message: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [splashScreenState, dispatchSplashScreenState] = useReducer(splashScreenReducer, initialSplashScreenState);
|
||||
|
||||
|
||||
const initialBookmarkFormStateValues = {
|
||||
id: 0,
|
||||
createdOn: "",
|
||||
lastModified: null,
|
||||
title: "",
|
||||
description: "",
|
||||
note: "",
|
||||
isHidden: false,
|
||||
url: "",
|
||||
tags: ""
|
||||
}
|
||||
|
||||
const bookmarkFormReducer = (state = initialBookmarkFormStateValues, action) => {
|
||||
switch(action.type) {
|
||||
case "FULL_UPDATE":
|
||||
return Object.assign({}, state, action.updatedData);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const [bookmarkFormState, dispatchBookmarkFormState] = useReducer(bookmarkFormReducer, initialBookmarkFormStateValues);
|
||||
|
||||
const alertMessageInitialState = { show: false, type: "primary", message: null};
|
||||
|
||||
const alertReducer = (state = alertMessageInitialState, action) => {
|
||||
switch(action.type) {
|
||||
case "INSERT_FAIL":
|
||||
return Object.assign({}, state, {
|
||||
show: true,
|
||||
type: "danger",
|
||||
message: `Error inserting new Bookmark record: ${action.message}`
|
||||
});
|
||||
case "UPDATE_FAIL":
|
||||
return Object.assign({}, state, {
|
||||
show: true,
|
||||
type: "danger",
|
||||
message: `Error updating Bookmark record: ${action.message}`
|
||||
});
|
||||
case "INSERT_SUCCESSFUL":
|
||||
return Object.assign({}, state, {
|
||||
show: true,
|
||||
type: "success",
|
||||
message: "New Bookmark entry created"
|
||||
});
|
||||
case "UPDATE_SUCCESSFUL":
|
||||
return Object.assign({}, state, {
|
||||
show: true,
|
||||
type: "success",
|
||||
message: "Bookmark updated"
|
||||
});
|
||||
default:
|
||||
return Object.assign({}, state, {
|
||||
show: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [alertMessageState, dispatchAlertMessageState] = useReducer(alertReducer, alertMessageInitialState);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: bookmarkFormState,
|
||||
validationSchema: Yup.object({
|
||||
url: Yup.string().required("Url is required")
|
||||
}),
|
||||
enableReinitialize: true,
|
||||
onSubmit: async(values) => {
|
||||
const isUpdate = values.id > 0
|
||||
values.tags = values.tags ? values.tags.toLowerCase().split(/[\s,]+/) : null;
|
||||
dispatchAlertMessageState({ type: "CLOSE_ALERT"});
|
||||
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", message: isUpdate ? "Updating Bookmark entry" : "Creating new Bookmark"});
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
const { data, error } = isUpdate ?
|
||||
await updateBookmark(accessToken, values.id, values) :
|
||||
await createNewBookmark(accessToken, values);
|
||||
|
||||
if(error) {
|
||||
dispatchAlertMessageState({ type: isUpdate ? "UPDATE_FAIL" : "INSERT_FAIL", message: error.message });
|
||||
} else {
|
||||
dispatchAlertMessageState({ type: isUpdate ? "UPDATE_SUCCESSFUL" : "INSERT_SUCCESSFUL", message: null });
|
||||
dispatchBookmarkFormState({ type: "FULL_UPDATE", updatedData : data });
|
||||
}
|
||||
|
||||
dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", message: null });
|
||||
}
|
||||
});
|
||||
|
||||
const handleOnCancelClick = () => {
|
||||
navigate("/bookmarks");
|
||||
};
|
||||
|
||||
const fetchBookmarkData = async () => {
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
const { data, error } = await getBookmark(accessToken, id);
|
||||
|
||||
if(error) {
|
||||
navigate("/404");
|
||||
} else {
|
||||
dispatchBookmarkFormState({type: "FULL_UPDATE", updatedData: {
|
||||
id: data.id ?? 0,
|
||||
createdOn: data.createdOn ?? "",
|
||||
lastModified: data.lastModified ?? "",
|
||||
title: data.title ?? "",
|
||||
description: data.description ?? "",
|
||||
note: data.note ?? "",
|
||||
isHidden: data.isHidden ?? false,
|
||||
url: data.url ?? "",
|
||||
tags: data.tags.map((x) => x.name).join(", ") ?? ""
|
||||
}});
|
||||
dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", message: null });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if(id) {
|
||||
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", message: "Fetching Bookmark data"});
|
||||
fetchBookmarkData();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{splashScreenState.show && (
|
||||
<SplashScreen
|
||||
message={splashScreenState.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{flexGrow: 1}}>
|
||||
<Container>
|
||||
{alertMessageState.show && (
|
||||
<Alert
|
||||
variant={alertMessageState.type}
|
||||
onClose={() => dispatchAlertMessageState({type: 'CLOSE'})}
|
||||
dismissible
|
||||
>
|
||||
{alertMessageState.message}
|
||||
</Alert>
|
||||
)}
|
||||
<Row>
|
||||
<Col xs="9" className="mb-3">
|
||||
{ bookmarkFormState.id > 0 ? <h1>Update Bookmark</h1> : <h1>Create new Bookmark</h1> }
|
||||
</Col>
|
||||
<Col xs="9">
|
||||
<Form onSubmit={formik.handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Url:</Form.Label>
|
||||
<Form.Control
|
||||
id="url"
|
||||
type="url"
|
||||
placeholder="Enter Bookmark Url"
|
||||
defaultValue={formik.values.url}
|
||||
onBlur={formik.handleChange}
|
||||
isInvalid={!!formik.errors.url}
|
||||
/>
|
||||
<Form.Control.Feedback
|
||||
type="invalid"
|
||||
className="d-flex justify-content-start"
|
||||
>
|
||||
{formik.errors.url}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Tags:</Form.Label>
|
||||
<Form.Control
|
||||
id="tags"
|
||||
type="text"
|
||||
placeholder="Enter Bookmark Tags"
|
||||
defaultValue={formik.values.tags}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
<Form.Text muted>
|
||||
Tags will be separated by commas or spaces
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Title:</Form.Label>
|
||||
<Form.Control
|
||||
id="title"
|
||||
type="text"
|
||||
placeholder="Enter Bookmark Title"
|
||||
defaultValue={formik.values.title}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
<Form.Text muted>
|
||||
When left blank, we'll use the URL's Title
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Description:</Form.Label>
|
||||
<Form.Control
|
||||
id="description"
|
||||
type="text"
|
||||
placeholder="Enter Bookmark Description"
|
||||
defaultValue={formik.values.description}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
When left blank, we'll use the URL's Description
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Notes:</Form.Label>
|
||||
<Form.Control
|
||||
id="note"
|
||||
as="textarea"
|
||||
rows={3}
|
||||
placeholder="Enter notes"
|
||||
defaultValue={formik.values.note}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Check
|
||||
id="isHidden"
|
||||
type="checkbox"
|
||||
label="Mark as hidden"
|
||||
defaultValue={formik.values.isHidden}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button variant="primary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="link" type="submit" onClick={handleOnCancelClick}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
349
Web/src/views/bookmarksListView.jsx
Normal file
349
Web/src/views/bookmarksListView.jsx
Normal file
@ -0,0 +1,349 @@
|
||||
import React, { useEffect, useReducer, useState } from "react";
|
||||
import { Alert, Col, Container, Row, Button, Modal } from "../components/external";
|
||||
import { Bookmark } from "../components";
|
||||
import { useAuth0 } from "@auth0/auth0-react";
|
||||
import { deleteBookmarks, getAllBookmarks, hideBookmarks } from "../api";
|
||||
import { SplashScreen, SearchForm, Tag } from "../components";
|
||||
import { getTagGroups, isSubset, containsSubstring, flattenTagArrays } from "../utils";
|
||||
|
||||
export function BookmarksListView(props) {
|
||||
const { getAccessTokenSilently } = useAuth0();
|
||||
const [searchString, setSearchString] = useState("");
|
||||
|
||||
const initialSplashScreenState = { show: false, message: null };
|
||||
|
||||
const splashScreenReducer = (state = initialSplashScreenState, action) => {
|
||||
switch(action.type) {
|
||||
case "SHOW_SPLASH_SCREEN":
|
||||
return Object.assign({}, state, {
|
||||
show: true,
|
||||
message: action.payload.message
|
||||
});
|
||||
case "HIDE_SPLASH_SCREEN":
|
||||
default:
|
||||
return Object.assign({}, state, {
|
||||
show: false,
|
||||
message: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [splashScreenState, dispatchSplashScreenState] = useReducer(splashScreenReducer, initialSplashScreenState);
|
||||
|
||||
const alertMessageInitialState = { show: false, type: "primary", message: null};
|
||||
|
||||
const alertReducer = (state = alertMessageInitialState, action) => {
|
||||
switch(action.type) {
|
||||
case "SHOW_ALERT":
|
||||
return Object.assign({}, state, {
|
||||
show: action.payload.show,
|
||||
type: action.payload.alertType,
|
||||
message: action.payload.message
|
||||
});
|
||||
case "HIDE_ALERT":
|
||||
default:
|
||||
return Object.assign({}, state, {
|
||||
show: false
|
||||
});
|
||||
}
|
||||
};
|
||||
const [alertMessageState, dispatchAlertMessageState] = useReducer(alertReducer, alertMessageInitialState);
|
||||
|
||||
const tagsInitialState = [];
|
||||
|
||||
const tagsReducer = (state = tagsInitialState, action) => {
|
||||
switch(action.type) {
|
||||
case "SET":
|
||||
return action.payload.tags.map(x => ({...x, isSelected: false, isDisplayed: true }));
|
||||
case "ADD_SELECTED":
|
||||
case "REMOVE_SELECTED":
|
||||
const modifiedTags = [...state];
|
||||
const selectedTagIndex = modifiedTags.findIndex((x) => x.id === action.payload.selectedTagId);
|
||||
modifiedTags[selectedTagIndex].isSelected = action.type === "ADD_SELECTED";
|
||||
return modifiedTags;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const [tagsState, dispatchTagsState] = useReducer(tagsReducer, tagsInitialState);
|
||||
const getSelectedTags = () => tagsState.filter((x) => x.isSelected);
|
||||
const getNotSelectedTags = () => tagsState.filter((x) => !x.isSelected);
|
||||
|
||||
const bookmarksInitialState = [];
|
||||
|
||||
const bookmarksReducer = (state = bookmarksInitialState, action) => {
|
||||
switch(action.type) {
|
||||
case "SET":
|
||||
return action.payload.bookmarks.map(x => ({...x, isSelected: false, isDisplayed: true}));
|
||||
case "ADD_SELECTED":
|
||||
case "REMOVE_SELECTED":
|
||||
const modifiedBookmarks = [...state];
|
||||
const modifiedBookmarkIndex = modifiedBookmarks.findIndex((x) => x.id === action.payload.selectedBookmarkId);
|
||||
modifiedBookmarks[modifiedBookmarkIndex].isSelected = action.type === "ADD_SELECTED";
|
||||
return modifiedBookmarks;
|
||||
case "DELETE_SELECTED":
|
||||
case "HIDE_SELECTED":
|
||||
return state.filter((x) => !action.payload.selectedBookmarkIds.includes(x.id));
|
||||
case "SELECT_ALL":
|
||||
return state.map(x => ({...x, isSelected: x.isDisplayed}));
|
||||
case "UNSELECT_ALL":
|
||||
return state.map(x => ({...x, isSelected: false}));
|
||||
case "DISPLAY_ALL":
|
||||
return state.map(x => ({...x, isDisplayed: true}));
|
||||
case "SEARCH":
|
||||
return state.map(x => ({...x, isDisplayed: (getSelectedTags().length <= 0 || isSubset(x.tags.map(x => x.id), getSelectedTags().map(x => x.id)))
|
||||
&& (!searchString || containsSubstring(x, searchString))}));
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const [bookmarksState, dispatchBookmarksState] = useReducer(bookmarksReducer, []);
|
||||
const onBookmarkSelected = (isBookmarkSelected, bookmark) => dispatchBookmarksState({type: isBookmarkSelected ? "ADD_SELECTED" : "REMOVE_SELECTED", payload: {selectedBookmarkId: bookmark.id}});
|
||||
const getDisplayedBookmarksCount = () => bookmarksState.filter(x => x.isDisplayed).length;
|
||||
const getSelectedBookmarksCount = () => bookmarksState.filter(x => x.isSelected && x.isDisplayed).length;
|
||||
const getSelectedBookmarks = () => bookmarksState.filter((x) => x.isSelected && x.isDisplayed);
|
||||
const getAreAllBookmarksSelected = () => bookmarksState.filter(x => x.isDisplayed).every(x => x.isSelected);
|
||||
const getFilteredBookmarks = () => bookmarksState.filter(x => x.isDisplayed);
|
||||
|
||||
const onDeleteSelectedBookmarks = async (ids) => {
|
||||
if(ids.length <= 0) {
|
||||
dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "warning", message: "No bookmark(s) to delete"}});
|
||||
} else {
|
||||
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Deleting bookmark(s)"}});
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
const { data, error } = await deleteBookmarks(accessToken, ids);
|
||||
|
||||
if(error) {
|
||||
dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "danger", message: `An error has occurred while deleting bookmark(s): ${error.message}`}})
|
||||
} else {
|
||||
dispatchBookmarksState({type: "DELETE_SELECTED", payload: {selectedBookmarkIds: ids}});
|
||||
}
|
||||
dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", payload: {message: null }});
|
||||
}
|
||||
};
|
||||
|
||||
const initialConfirmModalState = {show: false, title: null, message: null, selectedBookmarkId: null};
|
||||
|
||||
const confirmModalReducer = (state = initialConfirmModalState, action) => {
|
||||
switch(action.type) {
|
||||
case "SHOW_FOR_MULTIPLE_BOOKMARK_DELETE":
|
||||
return {
|
||||
...state,
|
||||
show: action.payload.show,
|
||||
title: action.payload.title,
|
||||
message: action.payload.message
|
||||
};
|
||||
case "SHOW_FOR_BOOKMARK_DELETE":
|
||||
return {
|
||||
...state,
|
||||
show: action.payload.show,
|
||||
title: action.payload.title,
|
||||
message: action.payload.message,
|
||||
selectedBookmarkId: action.payload.selectedBookmarkId
|
||||
};
|
||||
case "HIDE":
|
||||
default:
|
||||
return initialConfirmModalState;
|
||||
}
|
||||
};
|
||||
|
||||
const [confirmModalState, dispatchConfirmModalState] = useReducer(confirmModalReducer, initialConfirmModalState);
|
||||
const handleDeleteMultipleBookmarks = () => dispatchConfirmModalState({"type": "SHOW_FOR_MULTIPLE_BOOKMARK_DELETE", payload: {show: true, title: "Delete selected bookmarks", "message": "Are you sure you want to delete selected bookmarks?"}});
|
||||
const handleDeleteBookmark = (bookmarkId) => dispatchConfirmModalState({type: "SHOW_FOR_BOOKMARK_DELETE", payload: { show: true, title: "Delete bookmark", message: "Are you sure you want to delete this bookmark?", selectedBookmarkId: bookmarkId}});
|
||||
|
||||
const onHandleCloseConfirmModal = async () => {
|
||||
const bookmarkIdsToDelete = confirmModalState.selectedBookmarkId ? [confirmModalState.selectedBookmarkId] : getSelectedBookmarks().map((x) => x.id);
|
||||
dispatchConfirmModalState({type: "HIDE"});
|
||||
await onDeleteSelectedBookmarks(bookmarkIdsToDelete);
|
||||
};
|
||||
|
||||
const handleHideBookmarks = async(ids) => {
|
||||
if(ids.length <= 0) {
|
||||
dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "warning", message: "No bookmark(s) to hide"}});
|
||||
} else {
|
||||
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Hiding bookmark(s)"}});
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
const { data, error } = await hideBookmarks(accessToken, ids);
|
||||
|
||||
if(error) {
|
||||
dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "danger", message: `An error has occurred while hiding bookmark(s): ${error.message}`}})
|
||||
} else {
|
||||
dispatchBookmarksState({type: "HIDE_SELECTED", payload: {selectedBookmarkIds: ids}});
|
||||
}
|
||||
dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", payload: {message: null }});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const onTagSelected = (isTagSelected, tag) => {
|
||||
dispatchTagsState({type: isTagSelected ? "ADD_SELECTED" : "REMOVE_SELECTED", payload: {selectedTagId: tag.id}});
|
||||
dispatchBookmarksState({type: "SEARCH"});
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
setSearchString(e.target[0].value);
|
||||
dispatchBookmarksState({type: "SEARCH"});
|
||||
};
|
||||
|
||||
const handleSearchStringChange = (e) => {
|
||||
setSearchString(e.target.value);
|
||||
dispatchBookmarksState({type: "SEARCH"});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Retrieving Bookmarks..."}});
|
||||
|
||||
const fetchBookmarks = async() => {
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
const { data, error } = await getAllBookmarks(accessToken, props.showHidden);
|
||||
|
||||
if(error) {
|
||||
dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "danger", "message": `Error fetching bookmarks: ${error.message}`}});
|
||||
} else {
|
||||
dispatchBookmarksState({type: "SET", payload: {bookmarks: data}});
|
||||
dispatchTagsState({type: "SET", payload: {tags: flattenTagArrays(data.map(x => x.tags))}});
|
||||
}
|
||||
|
||||
dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", payload: {message: null}});
|
||||
}
|
||||
|
||||
fetchBookmarks();
|
||||
}, []);
|
||||
|
||||
return(
|
||||
<React.Fragment>
|
||||
{splashScreenState.show && (
|
||||
<SplashScreen
|
||||
message={splashScreenState.message}
|
||||
/>
|
||||
)}
|
||||
<div style={{flexGrow: 1}}>
|
||||
<Container>
|
||||
{alertMessageState.show && (
|
||||
<Alert
|
||||
variant={alertMessageState.type}
|
||||
onClose={() => dispatchAlertMessageState({type: "CLOSE"})}
|
||||
dismissible
|
||||
>
|
||||
{alertMessageState.message}
|
||||
</Alert>
|
||||
)}
|
||||
<Modal
|
||||
show={confirmModalState.show}
|
||||
onHide={() => {dispatchConfirmModalState({type: "HIDE"})}}
|
||||
keyboard={false}
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{confirmModalState.title}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>{confirmModalState.message}</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {dispatchConfirmModalState({type: "HIDE"})}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onHandleCloseConfirmModal}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
<Row>
|
||||
<Col xs="9">
|
||||
<Row>
|
||||
<Col xs="7">
|
||||
<span className="fs-4">{props.showHidden ? "Hidden Bookmarks" : "Bookmarks"}</span>
|
||||
</Col>
|
||||
<Col xs="5" className="d-flex justify-content-end">
|
||||
<SearchForm
|
||||
onHandleSearch={handleSearch}
|
||||
onSearchStringChange={handleSearchStringChange}
|
||||
searchString={searchString}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<hr className="mt-1" />
|
||||
</Col>
|
||||
<Col xs="3">
|
||||
<span className="fs-4">Tags</span>
|
||||
<hr className="mt-1" />
|
||||
</Col>
|
||||
</Row>
|
||||
{
|
||||
(bookmarksState.length > 0 || getSelectedBookmarksCount() > 0) && <Row>
|
||||
<Col xs="2" className="mb-3">
|
||||
{
|
||||
bookmarksState.length > 0 &&
|
||||
<Button variant="primary" onClick={() => dispatchBookmarksState({type: getAreAllBookmarksSelected() ? "UNSELECT_ALL" : "SELECT_ALL"})}>{getAreAllBookmarksSelected() ? "Unselect All" : "Select All" }</Button>
|
||||
}
|
||||
</Col>
|
||||
<Col xs="7" className="mb-3">
|
||||
{
|
||||
getSelectedBookmarksCount() > 0 &&
|
||||
<div className="d-flex justify-content-end align-items-center">
|
||||
<span className="fs-5 me-2"> {getSelectedBookmarksCount()} selected</span>
|
||||
<Button variant="primary" className="me-2" onClick={() => handleHideBookmarks(getSelectedBookmarks().map(x => x.id))}>{props.showHidden ? "Unhide" : "Hide"}</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteMultipleBookmarks}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</Col>
|
||||
<Col xs="3" className="mb-3">
|
||||
{
|
||||
getSelectedTags().length > 0 && (
|
||||
getSelectedTags().map((tag) => {
|
||||
return <Button key={tag.id} variant="primary" style={{textDecoration: "none", cursor: "pointer" }} className="badge rounded-pill text-bg-primary me-2" onClick={() => onTagSelected(false, tag)}>#{tag.name} | x</Button>
|
||||
})
|
||||
)
|
||||
}
|
||||
</Col>
|
||||
</Row>
|
||||
}
|
||||
|
||||
<Row>
|
||||
<Col xs="9">
|
||||
{ getFilteredBookmarks().length <= 0 && <div className="fs-3">No Bookmarks found</div> }
|
||||
{
|
||||
getFilteredBookmarks().map((bookmark) => {
|
||||
return <Bookmark
|
||||
key={bookmark.id}
|
||||
bookmark={bookmark}
|
||||
onBookmarkSelected={(selected) => onBookmarkSelected(selected, bookmark)}
|
||||
onDeleteClicked={() => handleDeleteBookmark(bookmark.id)}
|
||||
onHideClicked={() => handleHideBookmarks([bookmark.id])}
|
||||
searchString={searchString}
|
||||
/>
|
||||
})
|
||||
}
|
||||
</Col>
|
||||
<Col xs="3">
|
||||
{ getTagGroups(getNotSelectedTags()).length <= 0 && <div className="fs-3">No Tags found</div> }
|
||||
{
|
||||
getTagGroups(getNotSelectedTags()).map((group) => {
|
||||
return <div key={group.name} className="mb-3">
|
||||
<span key={group.name} className="text-primary fw-bold">{group.name}</span>
|
||||
<br />
|
||||
{
|
||||
group.tags.map((tag) => <Tag key={tag.id} tag={tag} onClick={() => onTagSelected(true, tag)} />)
|
||||
}
|
||||
</div>
|
||||
})
|
||||
}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
31
Web/src/views/homeView.jsx
Normal file
31
Web/src/views/homeView.jsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React, { useEffect } from "react";
|
||||
import Container from 'react-bootstrap/Container';
|
||||
import Row from 'react-bootstrap/Row';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
import { useAuth0 } from "@auth0/auth0-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function HomeView(props) {
|
||||
const { isAuthenticated, loginWithRedirect } = useAuth0();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navigateToLogin = async () => {
|
||||
await loginWithRedirect({
|
||||
appState: {
|
||||
returnTo: "/bookmarks",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if(isAuthenticated) {
|
||||
navigate("/bookmarks");
|
||||
} else {
|
||||
navigateToLogin();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return(
|
||||
<></>
|
||||
);
|
||||
}
|
||||
19
Web/src/views/index.js
Normal file
19
Web/src/views/index.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { BaseLayout } from "./baseLayout";
|
||||
import { HomeView } from "./homeView";
|
||||
import { RedirectView } from "./redirectView";
|
||||
import { BookmarksListView } from "./bookmarksListView";
|
||||
import { BookmarkDetailView } from "./bookmarkDetailView";
|
||||
import { TestView } from "./testView";
|
||||
import { NotFoundView } from "./notFoundView";
|
||||
import { TagsView } from "./tagsView";
|
||||
|
||||
export {
|
||||
BaseLayout,
|
||||
HomeView,
|
||||
RedirectView,
|
||||
BookmarksListView,
|
||||
BookmarkDetailView,
|
||||
TestView,
|
||||
NotFoundView,
|
||||
TagsView,
|
||||
};
|
||||
28
Web/src/views/notFoundView.jsx
Normal file
28
Web/src/views/notFoundView.jsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import Row from 'react-bootstrap/Row';
|
||||
import Col from 'react-bootstrap/Col';
|
||||
|
||||
export function NotFoundView(props) {
|
||||
return(
|
||||
<React.Fragment>
|
||||
<div style={{
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
<Row>
|
||||
<Col xs="12">
|
||||
<h1>404</h1>
|
||||
</Col>
|
||||
<Col xs="12">
|
||||
<h2>{props.message || "The page you are looking for does not exist or another error has occured"}</h2>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
24
Web/src/views/redirectView.jsx
Normal file
24
Web/src/views/redirectView.jsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import Container from 'react-bootstrap/Container';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Spinner from 'react-bootstrap/Spinner';
|
||||
|
||||
export function RedirectView(props) {
|
||||
const redirectLink = () => props.redirectLink;
|
||||
|
||||
return(
|
||||
<React.Fragment>
|
||||
<div style={{flexGrow: 1}}>
|
||||
<Container className="text-center">
|
||||
<Spinner animation="border" />
|
||||
<h1>{props.message || "You will be redirected in a few seconds." }</h1>
|
||||
{props.redirectLink && (
|
||||
<>
|
||||
<Link to={redirectLink}>Click here to go now</Link>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
421
Web/src/views/tagsView.jsx
Normal file
421
Web/src/views/tagsView.jsx
Normal file
@ -0,0 +1,421 @@
|
||||
import React, { useEffect, useReducer, useState } from "react";
|
||||
import { useAuth0 } from "@auth0/auth0-react";
|
||||
import { containsSubstring, getHighlightedText } from "../utils";
|
||||
import { SplashScreen, SearchForm, UpsertTagModal, InterceptModal } from "../components";
|
||||
import { Alert, Button, Col, Container, Dropdown, DropdownButton, Form, Row, Table } from "../components/external";
|
||||
import { getAllTags, createNewTag, updateTag, deleteTags, hideTags } from "../api";
|
||||
|
||||
export function TagsView(props) {
|
||||
const { getAccessTokenSilently } = useAuth0();
|
||||
|
||||
const [searchString, setSearchString] = useState("");
|
||||
const [isAllTagsSelected, setAllTagsSelected] = useState(false);
|
||||
|
||||
const initialSplashScreenState = { show: false, message: null };
|
||||
const splashScreenReducer = (state = initialSplashScreenState, action) => {
|
||||
switch(action.type) {
|
||||
case "SHOW_SPLASH_SCREEN":
|
||||
return Object.assign({}, state, {
|
||||
show: true,
|
||||
message: action.payload.message
|
||||
});
|
||||
case "HIDE_SPLASH_SCREEN":
|
||||
default:
|
||||
return Object.assign({}, state, {
|
||||
show: false,
|
||||
message: null
|
||||
});
|
||||
}
|
||||
};
|
||||
const [splashScreenState, dispatchSplashScreenState] = useReducer(splashScreenReducer, initialSplashScreenState);
|
||||
|
||||
const alertMessageInitialState = { show: false, type: "primary", message: null};
|
||||
const alertReducer = (state = alertMessageInitialState, action) => {
|
||||
switch(action.type) {
|
||||
case "SHOW_ALERT":
|
||||
return Object.assign({}, state, {
|
||||
show: action.payload.show,
|
||||
type: action.payload.alertType,
|
||||
message: action.payload.message
|
||||
});
|
||||
case "UPSERT_FAIL":
|
||||
return {
|
||||
show: true,
|
||||
type: "danger",
|
||||
message: action.payload.message
|
||||
};
|
||||
case "UPSERT_SUCCEEDED":
|
||||
return {
|
||||
show: true,
|
||||
type: "success",
|
||||
message: action.payload.message
|
||||
};
|
||||
case "NO_TAG_TO_DELETE":
|
||||
return {
|
||||
show: true,
|
||||
type: "warning",
|
||||
message: "No tag(s) to delete"
|
||||
};
|
||||
case "DELETE_FAILED":
|
||||
return {
|
||||
show: true,
|
||||
type: "danger",
|
||||
message: action.payload.message
|
||||
};
|
||||
case "DELETE_SUCCEEDED":
|
||||
return {
|
||||
show: true,
|
||||
type: "success",
|
||||
message: "Tag(s) deleted"
|
||||
}
|
||||
case "NO_TAG_TO_HIDE":
|
||||
return {
|
||||
show: true,
|
||||
type: "warning",
|
||||
message: "No tag(s) to hide/unhide"
|
||||
}
|
||||
case "HIDE_ALERT":
|
||||
default:
|
||||
return Object.assign({}, state, {
|
||||
show: false
|
||||
});
|
||||
}
|
||||
};
|
||||
const [alertMessageState, dispatchAlertMessageState] = useReducer(alertReducer, alertMessageInitialState);
|
||||
|
||||
const tagsReducer = (state = [], action) => {
|
||||
let newState = [];
|
||||
|
||||
switch(action.type) {
|
||||
case "SET":
|
||||
newState = action.payload.tags.map(x => ({...x, isSelected: false, isDisplayed: true}));
|
||||
break;
|
||||
case "ADD_SELECTED":
|
||||
case "REMOVE_SELECTED":
|
||||
newState = [...state];
|
||||
const selectedTagIndex = newState.findIndex(x => x.id === action.payload.selectedTagId);
|
||||
newState[selectedTagIndex].isSelected = action.type === "ADD_SELECTED";
|
||||
break;
|
||||
case "DELETE_SELECTED":
|
||||
newState = state.filter((x) => !action.payload.selectedTagIds.includes(x.id));
|
||||
break;
|
||||
case "SELECT_ALL":
|
||||
newState = state.filter(x => x.isDisplayed).map(x => ({...x, isSelected: true}));
|
||||
break;
|
||||
case "UNSELECT_ALL":
|
||||
newState = state.filter(x => x.isDisplayed).map(x => ({...x, isSelected: false}));
|
||||
break;
|
||||
case "DISPLAY_ALL":
|
||||
newState = state.map(x => ({...x, isDisplayed: true}));
|
||||
break;
|
||||
case "SEARCH":
|
||||
newState = state.map(x => ({...x, isDisplayed: !searchString || containsSubstring(x, searchString)}));
|
||||
break;
|
||||
case "TAG_UPDATE":
|
||||
newState = [...state];
|
||||
const updatedTagIndex = newState.findIndex(x => x.id == action.payload.tag.id);
|
||||
newState.splice(updatedTagIndex, 1, Object.assign({}, newState[updatedTagIndex], action.payload.tag));
|
||||
break;
|
||||
case "TAG_INSERT":
|
||||
newState = [{...action.payload.tag, isDisplayed: true, isSelected: false}, ...state];
|
||||
break;
|
||||
case "HIDE_SUCCEEDED":
|
||||
newState = [...state.filter(x => !x.isSelected), ...state.filter(x => x.isSelected).map(x => Object.assign({}, x, {isHidden: !x.isHidden}))];
|
||||
break;
|
||||
default:
|
||||
newState = state;
|
||||
break;
|
||||
}
|
||||
|
||||
setAllTagsSelected(newState.every(x => x.isSelected));
|
||||
return newState;
|
||||
};
|
||||
const [tagsState, dispatchTagsState] = useReducer(tagsReducer, []);
|
||||
const getTagsSelectedCount = () => tagsState.filter(x => x.isSelected).length;
|
||||
const onTagSelected = (isTagSelected, tag) => dispatchTagsState({type: isTagSelected ? "ADD_SELECTED" : "REMOVE_SELECTED", payload: {selectedTagId: tag.id}});
|
||||
const getDisplayedTags = () => tagsState.filter(tag => tag.isDisplayed);
|
||||
const getSelectedTags = () => tagsState.filter(tag => tag.isSelected);
|
||||
const isAllSelectedHidden = () => getSelectedTags().every(tag => tag.isHidden);
|
||||
const isAllSelectedNotHidden = () => getSelectedTags().every(tag => !tag.isHidden);
|
||||
|
||||
const upsertTagModalInitialState = { show: false, tag: { id: 0, name: "", isHidden: false }};
|
||||
const upsertTagModalReducer = (state = upsertTagModalInitialState, action) => {
|
||||
switch(action.type) {
|
||||
case "SHOW_EDIT":
|
||||
return {show: true, tag: action.payload.tag};
|
||||
case "SHOW_NEW":
|
||||
return {...upsertTagModalInitialState, show: true};
|
||||
case "HIDE":
|
||||
default:
|
||||
return upsertTagModalInitialState;
|
||||
}
|
||||
}
|
||||
const [upsertTagModalState, dispatchUpsertTagModal] = useReducer(upsertTagModalReducer, upsertTagModalInitialState);
|
||||
|
||||
const initialInterceptModalState = {
|
||||
show: false,
|
||||
backdrop: "static",
|
||||
title: null,
|
||||
message: null,
|
||||
secondaryAction: {
|
||||
variant: "secondary",
|
||||
text: "Cancel",
|
||||
onClick: null,
|
||||
},
|
||||
primaryAction: {
|
||||
variant: "danger",
|
||||
text: "Delete",
|
||||
onClick: null,
|
||||
},
|
||||
selectedTagIds: []
|
||||
};
|
||||
const interceptModalReducer = (state = initialInterceptModalState, action) => {
|
||||
switch(action.type) {
|
||||
case "SHOW_FOR_MULTIPLE_DELETE":
|
||||
return {
|
||||
...state,
|
||||
show: true,
|
||||
title: "Delete Tags",
|
||||
message: "Are you sure you want to delete selected tags?",
|
||||
secondaryAction: {
|
||||
variant: "secondary",
|
||||
text: "Cancel",
|
||||
onClick: null,
|
||||
},
|
||||
primaryAction: {
|
||||
variant: "danger",
|
||||
text: "Delete",
|
||||
onClick: null,
|
||||
},
|
||||
selectedTagIds: action.payload.selectedTagIds,
|
||||
};
|
||||
case "SHOW_FOR_DELETE":
|
||||
return {
|
||||
...state,
|
||||
show: true,
|
||||
title: "Delete Tag",
|
||||
message: "Are you sure you want to delete this tag?",
|
||||
secondaryAction: {
|
||||
variant: "secondary",
|
||||
text: "Cancel",
|
||||
onClick: null,
|
||||
},
|
||||
primaryAction: {
|
||||
variant: "danger",
|
||||
text: "Delete",
|
||||
onClick: null,
|
||||
},
|
||||
selectedTagIds: action.payload.selectedTagIds,
|
||||
};
|
||||
case "HIDE":
|
||||
default:
|
||||
return initialInterceptModalState;
|
||||
}
|
||||
};
|
||||
const [interceptModalState, dispatchInterceptModalState] = useReducer(interceptModalReducer, initialInterceptModalState);
|
||||
|
||||
const fetchTags = async() => {
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
const { data, error } = await getAllTags(accessToken);
|
||||
|
||||
if(error) {
|
||||
dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "danger", "message": `Error fetching tags: ${error.message}`}});
|
||||
} else {
|
||||
dispatchTagsState({type: "SET", payload: {tags: data}});
|
||||
}
|
||||
dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", payload: {message: null}});
|
||||
};
|
||||
|
||||
const handleUpsertTag = async(tagEntry) => {
|
||||
const isUpdate = tagEntry.id > 0;
|
||||
dispatchAlertMessageState({ type: "CLOSE_ALERT"});
|
||||
dispatchUpsertTagModal({type: "HIDE"});
|
||||
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: isUpdate ? "Updating Bookmark entry" : "Creating new Bookmark"}});
|
||||
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
const { data, error } = isUpdate ?
|
||||
await updateTag(accessToken, tagEntry.id, tagEntry) :
|
||||
await createNewTag(accessToken, tagEntry);
|
||||
|
||||
if(error) {
|
||||
dispatchAlertMessageState({type: "UPSERT_FAIL", payload: {message: `Error ${isUpdate ? "updating": "inserting new"} Tag record: ${error.message}`}});
|
||||
} else {
|
||||
dispatchAlertMessageState({type: "UPSERT_SUCCEEDED", payload: {message: isUpdate ? "Tag updated" : "Tag created"}});
|
||||
dispatchTagsState({ type: isUpdate? "TAG_UPDATE" : "TAG_INSERT", payload: {tag: data}});
|
||||
}
|
||||
|
||||
dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", payload: {message: null}});
|
||||
};
|
||||
|
||||
const handleDeleteTags = async () => {
|
||||
dispatchInterceptModalState({type: "HIDE"});
|
||||
dispatchUpsertTagModal({type: "HIDE"})
|
||||
const tagIdsToDelete = interceptModalState.selectedTagIds;
|
||||
|
||||
if(tagIdsToDelete.length <= 0) {
|
||||
dispatchAlertMessageState({type: "NO_TAG_TO_DELETE"})
|
||||
} else {
|
||||
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Deleting Tag(s)"}});
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
const { data, error } = await deleteTags(accessToken, tagIdsToDelete);
|
||||
|
||||
if(error) {
|
||||
dispatchAlertMessageState({type: "DELETE_FAILED", payload: {message: `Error deleting tag(s): ${error.message}`}});
|
||||
} else {
|
||||
dispatchAlertMessageState({type: "DELETE_SUCCEEDED"});
|
||||
dispatchTagsState({type: "DELETE_SELECTED", payload: {selectedTagIds: tagIdsToDelete}});
|
||||
}
|
||||
|
||||
dispatchSplashScreenState({TYPE: "HIDE_SPLASH_SCREEN", payload: {message: null}});
|
||||
}
|
||||
};
|
||||
|
||||
const handleHidingTags = async () => {
|
||||
const tagIdsToHide = getSelectedTags().map(x => x.id);
|
||||
|
||||
if(tagIdsToHide.length <= 0) {
|
||||
dispatchAlertMessageState({type: "NO_TAG_TO_HIDE"})
|
||||
} else {
|
||||
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Hiding/Unhiding Tag(s)"}});
|
||||
const accessToken = await getAccessTokenSilently();
|
||||
const { data, error } = await hideTags(accessToken, tagIdsToHide);
|
||||
|
||||
if(error) {
|
||||
dispatchAlertMessageState({type: "UPSERT_FAILED", payload: {message: `Error hiding/unhiding tag(s): ${error.message}`}});
|
||||
} else {
|
||||
dispatchAlertMessageState({type: "UPSERT_SUCCEEDED", payload: {message: `Tag(s) have been hidden/unhidden`}});
|
||||
dispatchTagsState({type: "HIDE_SUCCEEDED"});
|
||||
dispatchTagsState({type: "UNSELECT_ALL"});
|
||||
}
|
||||
|
||||
dispatchSplashScreenState({TYPE: "HIDE_SPLASH_SCREEN", payload: {message: null}});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
setSearchString(e.target[0].value);
|
||||
dispatchTagsState({type: "SEARCH"});
|
||||
};
|
||||
|
||||
const handleSearchStringChange = (e) => {
|
||||
setSearchString(e.target.value);
|
||||
dispatchTagsState({type: "SEARCH"});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Retrieving Tags..."}});
|
||||
fetchTags();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{splashScreenState.show && (
|
||||
<SplashScreen
|
||||
message={splashScreenState.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpsertTagModal
|
||||
show={upsertTagModalState.show}
|
||||
tag={upsertTagModalState.tag}
|
||||
onCancel={() => dispatchUpsertTagModal({type: "HIDE"})}
|
||||
onHide={() => dispatchUpsertTagModal({type: "HIDE"})}
|
||||
onSave={handleUpsertTag}
|
||||
onDelete={() => dispatchInterceptModalState({type: "SHOW_FOR_DELETE", payload: {selectedTagIds: [upsertTagModalState.tag.id]}})}
|
||||
/>
|
||||
<InterceptModal
|
||||
show={interceptModalState.show}
|
||||
onHide={() => dispatchInterceptModalState({type: "HIDE"})}
|
||||
backdrop={interceptModalState.backdrop}
|
||||
title={interceptModalState.title}
|
||||
message={interceptModalState.message}
|
||||
secondaryAction={{...interceptModalState.secondaryAction, onClick: () => dispatchInterceptModalState({type: "HIDE"})}}
|
||||
primaryAction={{...interceptModalState.primaryAction, onClick: () => handleDeleteTags()}}
|
||||
/>
|
||||
|
||||
<div style={{flexGrow: 1}}>
|
||||
<Container>
|
||||
{ alertMessageState.show && <Alert
|
||||
variant={alertMessageState.type}
|
||||
onClose={() => dispatchAlertMessageState({type: "CLOSE"})}
|
||||
dismissible
|
||||
>
|
||||
{alertMessageState.message}
|
||||
</Alert>
|
||||
}
|
||||
{ tagsState.length <= 0 && !splashScreenState.show && <div className="text-center fs-1">No Tags found</div> }
|
||||
{
|
||||
tagsState.length > 0 && <>
|
||||
<Row>
|
||||
<Col xs="5">
|
||||
<SearchForm
|
||||
onHandleSearch={handleSearch}
|
||||
onSearchStringChange={handleSearchStringChange}
|
||||
onBlur={handleSearchStringChange}
|
||||
searchString={searchString}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="4">
|
||||
<div className="d-flex justify-content-end align-items-center">
|
||||
<Button variant="primary" className="me-2" onClick={() => dispatchUpsertTagModal({type: "SHOW_NEW"})}>New</Button>
|
||||
{ getTagsSelectedCount() > 0 && <DropdownButton variant="secondary" title={`${getTagsSelectedCount()} selected`}>
|
||||
{ isAllSelectedHidden() && <Dropdown.Item onClick={handleHidingTags}>Mark as Not Hidden</Dropdown.Item>}
|
||||
{ isAllSelectedNotHidden() && <Dropdown.Item onClick={handleHidingTags}>Mark as Hidden</Dropdown.Item>}
|
||||
<Dropdown.Item onClick={() => dispatchInterceptModalState({type: "SHOW_FOR_MULTIPLE_DELETE", payload: {selectedTagIds: getSelectedTags().map(x => x.id)}})}>
|
||||
<span className="text-danger">Delete</span>
|
||||
</Dropdown.Item>
|
||||
</DropdownButton>
|
||||
}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs="9">
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
onChange={(e) => dispatchTagsState({type: e.target.checked ? "SELECT_ALL" : "UNSELECT_ALL"})}
|
||||
checked={isAllTagsSelected}
|
||||
/>
|
||||
</th>
|
||||
<th>Tag</th>
|
||||
<th>Is Hidden?</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
getDisplayedTags().map(tag => {
|
||||
return <tr key={tag.id}>
|
||||
<td>
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
onChange={(e) => onTagSelected(e.target.checked, tag)}
|
||||
checked={tag.isSelected}
|
||||
/>
|
||||
</td>
|
||||
<td>{getHighlightedText(tag.name, searchString)}</td>
|
||||
<td>
|
||||
<span className={tag.isHidden ? "text-danger" : "text-dark"}>{ tag.isHidden ? "Yes" : "No" }</span>
|
||||
</td>
|
||||
<td>
|
||||
<Button variant="link" className="py-0" onClick={() => dispatchUpsertTagModal({type: "SHOW_EDIT", payload: {tag: tag}})}>Edit</Button>
|
||||
</td>
|
||||
</tr>
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
}
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
};
|
||||
33
Web/src/views/testView.jsx
Normal file
33
Web/src/views/testView.jsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React, { useState } from "react";
|
||||
import { SplashScreen } from "../components";
|
||||
import { Container, Col, Row } from "../components/external";
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import DateTimeHelper from "../utils/dateTimeHelper";
|
||||
import { getTagGroups } from "../utils";
|
||||
|
||||
export function TestView() {
|
||||
const [showSplashScreen, setShowSplashScreen] = useState(false);
|
||||
const onCloseSplashScreenClick = () => setShowSplashScreen(false);
|
||||
const onShowSplashScreenClick = () => setShowSplashScreen(true);
|
||||
|
||||
return(
|
||||
<React.Fragment>
|
||||
{showSplashScreen && (
|
||||
<SplashScreen
|
||||
onCloseSpashScreenClick={onCloseSplashScreenClick}
|
||||
showCloseSplashScreenButton={true}
|
||||
message="Lorem ipsum dolor sit amet, consectetur adipiscing elit"
|
||||
/>
|
||||
)}
|
||||
<div style={{flexGrow: 1}}>
|
||||
<Container>
|
||||
{/* <Row>
|
||||
<Col xs="3">
|
||||
<Button variant="danger" onClick={onShowSplashScreenClick}>Show splash screen</Button>
|
||||
</Col>
|
||||
</Row> */}
|
||||
</Container>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user