Added front-end ReactApp, with basic CRUD functionality for Bookmark entries

This commit is contained in:
2023-01-28 20:48:32 -06:00
parent 0413abf587
commit 1f3adf0932
61 changed files with 19643 additions and 21 deletions

81
yaba-web/src/App.js Normal file
View File

@ -0,0 +1,81 @@
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 } 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 />}/>}
/>
)}
</Routes>
</div>
);
}
export default App;

View 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,
},
};
}
};

17
yaba-web/src/api/index.js Normal file
View File

@ -0,0 +1,17 @@
import { callExternalApi } from "./apiHelper";
import { getAllBookmarks, getBookmark, createNewBookmark, updateBookmark, deleteBookmark, deleteBookmarks, hideBookmarks } from "./v1/bookmarks";
import { getAllTags } from "./v1/tags";
import { getWebsiteMetaData } from "./v1/misc";
export {
callExternalApi,
getAllBookmarks,
getBookmark,
createNewBookmark,
updateBookmark,
getWebsiteMetaData,
getAllTags,
deleteBookmark,
deleteBookmarks,
hideBookmarks,
};

View 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, isHidden = false) => {
const config = {
url: `${apiServerUrl}?isHidden=${isHidden}`,
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});
};

View 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});
}

View File

@ -0,0 +1,16 @@
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});
}

View File

@ -0,0 +1,34 @@
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}
>
{children}
</Auth0Provider>
);
};

View File

@ -0,0 +1,34 @@
import React from "react";
import { Row, Col, Form, Button } from "../external";
import DateTimeHelper from "../../utils/dateTimeHelper";
import "../../styles/component/bookmark.css";
import { useNavigate } from "react-router-dom";
export function Bookmark(props) {
const navigate = useNavigate();
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="#" style={{textDecoration: "none"}}>{props.bookmark.title}</a>
<div className="font-weight-normal">{props.bookmark.description.substring(0, 100)}</div>
<div>
{props.bookmark.tags.map((tag) => {
return <Button variant="link" key={props.bookmark.id-tag.id} style={{textDecoration: "none"}} className="p-0 me-2">#{tag.name}</Button>
})}
</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>
}

View File

@ -0,0 +1,17 @@
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'
export {
Alert,
Col,
Container,
Row,
Form,
Button,
Modal,
};

View 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>
);
}

View File

@ -0,0 +1,68 @@
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 onClick={handleLogout}>Logout</Nav.Link>
</>
)}
</Nav>
</Container>
</Navbar>
</div>
);
}

View File

@ -0,0 +1,17 @@
import { Footer } from "./footer";
import { Header } from "./header";
import { Bookmark } from "./bookmark/bookmark";
import { Auth0ProviderWithNavigate } from "./auth0ProviderWithNavigate";
import { ProtectedRoute } from "./protectedRoute";
import { SplashScreen } from "./shared/splashScreen";
import { SearchForm } from "./shared/searchForm";
export {
Footer,
Header,
Bookmark,
Auth0ProviderWithNavigate,
ProtectedRoute,
SplashScreen,
SearchForm
};

View 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 />} />;
};

View 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>
</>
);
}

View 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
yaba-web/src/index.js Normal file
View 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>
);

View File

@ -0,0 +1,3 @@
.test {
background-color: red;
}

View 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)
})
};

View 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())
);
}

View 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`;
}
};
}

View File

@ -0,0 +1,11 @@
import { isDev } from "./isDevHelper";
import { getTagGroups } from "./tagsHelper";
import { isSubset } from "./arrayHelper";
import { containsSubstring } from "./bookmarkHelper";
export {
isDev,
getTagGroups,
isSubset,
containsSubstring
}

View File

@ -0,0 +1,3 @@
export const isDev = () => {
return !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
}

View File

@ -0,0 +1,17 @@
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);
}

View 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>
);
}

View File

@ -0,0 +1,274 @@
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.split(", ") : 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.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 className="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>
)
}

View File

@ -0,0 +1,371 @@
import React, { useEffect, useReducer, useState } from "react";
import { Alert, Col, Container, Row, Form, Button, Modal } from "../components/external";
import { Bookmark } from "../components";
import { useAuth0 } from "@auth0/auth0-react";
import { deleteBookmarks, getAllBookmarks, getAllTags, hideBookmarks } from "../api";
import { SplashScreen, SearchForm } from "../components";
import { getTagGroups, isSubset, containsSubstring } from "../utils";
export function BookmarksListView(props) {
const { getAccessTokenSilently } = useAuth0();
const [searchString, setSearchString] = useState("");
const handleSearch = (e) => {
e.preventDefault();
if(!searchString) {
dispatchBookmarksState({type: "DISPLAY_ALL"});
} else {
dispatchBookmarksState({type: "SEARCH"});
}
};
const handleSearchStringChange = (e) => {
if(!e.target.value) {
dispatchBookmarksState({type: "DISPLAY_ALL"});
} else {
setSearchString(e.target.value);
}
};
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 }));
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 onTagSelected = (isTagSelected, tag) => dispatchTagsState({type: isTagSelected ? "ADD_SELECTED" : "REMOVE_SELECTED", payload: {selectedTagId: tag.id}})
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: true}));
case "UNSELECT_ALL":
return state.map(x => ({...x, isSelected: false}));
case "DISPLAY_ALL":
return state.map(x => ({...x, isDisplayed: true}));
case "SEARCH":
if(!searchString) {
dispatchBookmarksState({type: "DISPLAY_ALL"});
}
return state.map(x => ({...x, isDisplayed: containsSubstring(x, searchString)}));
default:
return state;
}
};
const [bookmarksState, dispatchBookmarksState] = useReducer(bookmarksReducer, bookmarksInitialState);
const onBookmarkSelected = (isBookmarkSelected, bookmark) => dispatchBookmarksState({type: isBookmarkSelected ? "ADD_SELECTED" : "REMOVE_SELECTED", payload: {selectedBookmarkId: bookmark.id}});
const getSelectedBookmarksCount = () => bookmarksState.filter((x) => x.isSelected).length;
const getSelectedBookmarks = () => bookmarksState.filter((x) => x.isSelected);
const getAreAllBookmarksSelected = () => bookmarksState.every(x => x.isSelected);
const getFilteredBookmarks = () => {
if (getSelectedTags().length <= 0) {
return bookmarksState.filter(x => x.isDisplayed);
} else {
return bookmarksState.filter(x => isSubset(x.tags.map(x => x.id), getSelectedTags().map(x => x.id)));
}
}
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 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}});
}
};
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}});
}
}
useEffect(() => {
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Retrieving Tags..."}});
fetchTags();
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Retrieving Bookmarks..."}});
fetchBookmarks();
dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", payload: {message: null}});
}, []);
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>
<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().map((bookmark) => {
return <Bookmark
key={bookmark.id}
bookmark={bookmark}
onBookmarkSelected={(selected) => onBookmarkSelected(selected, bookmark)}
onDeleteClicked={() => handleDeleteBookmark(bookmark.id)}
onHideClicked={() => handleHideBookmarks([bookmark.id])}
/>
})
}
</Col>
<Col xs="3">
{
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) => {
return <Button key={tag.id} variant="link" style={{textDecoration: "none"}} className="ms-0 me-2 p-0" onClick={() => onTagSelected(true, tag)}>
#{tag.name}
</Button>
})
}
</div>
})
}
</Col>
</Row>
</Container>
</div>
</React.Fragment>
);
}

View File

@ -0,0 +1,20 @@
import React from "react";
import Container from 'react-bootstrap/Container';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
export function HomeView(props) {
return(
<React.Fragment>
<div style={{flexGrow: 1}}>
<Container>
<Row>
<Col xs="9">
<h1>This is the Home View</h1>
</Col>
</Row>
</Container>
</div>
</React.Fragment>
);
}

View File

@ -0,0 +1,17 @@
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";
export {
BaseLayout,
HomeView,
RedirectView,
BookmarksListView,
BookmarkDetailView,
TestView,
NotFoundView
}

View 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>
);
}

View 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>
);
}

View 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>
);
}