move to git.n39.euall books in the bag ...
This commit is contained in:
commit
8cc2662092
64 changed files with 134252 additions and 0 deletions
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
@ -0,0 +1,17 @@
|
|||
FROM node:20
|
||||
|
||||
RUN mkdir -p /source/middleware
|
||||
RUN mkdir -p /source/frontend
|
||||
|
||||
COPY ../middleware /source/middleware
|
||||
COPY ../frontend /source/frontend
|
||||
|
||||
WORKDIR /source
|
||||
|
||||
RUN cd frontend && npm i && npm run build
|
||||
|
||||
RUN cd middleware && npm i && npm run build && mkdir -p ./dist/frontend
|
||||
|
||||
RUN cp -r frontend/build/* middleware/dist/frontend
|
||||
|
||||
CMD [ "node", "middleware/dist/index.js" ]
|
1
VERSION
Normal file
1
VERSION
Normal file
|
@ -0,0 +1 @@
|
|||
0.0.12
|
112250
backup.sql
Normal file
112250
backup.sql
Normal file
File diff suppressed because one or more lines are too long
1
createBackup.sh
Normal file
1
createBackup.sh
Normal file
|
@ -0,0 +1 @@
|
|||
docker exec mariadb-mariadb-1 mariadb-dump --all-databases -uroot -p"RootPassword" >> backup.sql
|
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
|
@ -0,0 +1,27 @@
|
|||
version: "3.5"
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:latest
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: RootPassword
|
||||
MYSQL_USER: admin
|
||||
MYSQL_PASSWORD: AdminPassword
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- "./backup.sql:/docker-entrypoint-initdb.d/1.sql"
|
||||
librarian:
|
||||
image: 0ry5/librarian:latest
|
||||
environment:
|
||||
PORT: 3001
|
||||
ADMIN_KEY: AdminPassword
|
||||
ADMIN_DB_USER: admin
|
||||
ADMIN_DB_PW: AdminPassword
|
||||
DB_HOST: "mdb"
|
||||
DB_PORT: 3306
|
||||
DB_CONNECTION_LIMIT: 10
|
||||
ports:
|
||||
- "3001:3001"
|
||||
links:
|
||||
- "mariadb:mdb"
|
17003
frontend/package-lock.json
generated
Normal file
17003
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
54
frontend/package.json
Normal file
54
frontend/package.json
Normal file
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "librarian-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"proxy": "http://localhost:3001",
|
||||
"dependencies": {
|
||||
"@ericblade/quagga2": "^1.8.4",
|
||||
"@tanstack/react-query": "^5.52.1",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.105",
|
||||
"@types/react": "^18.3.4",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"bootstrap": "^5.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"history": "^5.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.4",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
26
frontend/public/index.html
Normal file
26
frontend/public/index.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=New+Amsterdam&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="simple web app to manage the books in the n39 lounge"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>N39 Librarian</title>
|
||||
</head>
|
||||
<body id="bootstrap-overrides">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
BIN
frontend/public/logo192.png
Normal file
BIN
frontend/public/logo192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 KiB |
BIN
frontend/public/logo512.png
Normal file
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"short_name": "N39Librarian",
|
||||
"name": "N39Librarian",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
44
frontend/src/App.css
Normal file
44
frontend/src/App.css
Normal file
|
@ -0,0 +1,44 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.new-amsterdam-regular {
|
||||
font-family: "New Amsterdam", sans-serif;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
9
frontend/src/App.test.tsx
Normal file
9
frontend/src/App.test.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
69
frontend/src/App.tsx
Normal file
69
frontend/src/App.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import React, { createContext, useState } from "react";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "./App.css";
|
||||
import { Main } from "./pages";
|
||||
import { Library } from "./pages/Library";
|
||||
import { Book } from "./pages/Book";
|
||||
import { primary } from "./colors";
|
||||
import { ActiveModalProps } from "./shared/components/modals/types";
|
||||
|
||||
export const AuthContext = createContext<{
|
||||
authenticated: boolean;
|
||||
setAuthenticated?: (authenticated: boolean) => void;
|
||||
}>({ authenticated: false });
|
||||
|
||||
export type ModalContextType = {
|
||||
activeModal?: ActiveModalProps;
|
||||
setActiveModal: (modalType?: ActiveModalProps) => void;
|
||||
};
|
||||
|
||||
export const ModalContext = createContext<ModalContextType>({
|
||||
setActiveModal: () => undefined,
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <Main />,
|
||||
},
|
||||
{
|
||||
path: "/books",
|
||||
element: <Library />,
|
||||
},
|
||||
{
|
||||
path: "/book/:uuid",
|
||||
element: <Book />,
|
||||
},
|
||||
]);
|
||||
|
||||
function App() {
|
||||
const [authenticated, setAuthenticated] = useState<boolean>(false);
|
||||
const [activeModal, setActiveModal] = useState<ActiveModalProps>();
|
||||
|
||||
return (
|
||||
<div
|
||||
className='App d-flex'
|
||||
style={{
|
||||
height: "100vh",
|
||||
width: "100vw",
|
||||
backgroundColor: primary,
|
||||
fontFamily: "New Amsterdam",
|
||||
overflow: "scroll",
|
||||
}}
|
||||
>
|
||||
<AuthContext.Provider value={{ authenticated, setAuthenticated }}>
|
||||
<ModalContext.Provider value={{ activeModal, setActiveModal }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ModalContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
5
frontend/src/colors.ts
Normal file
5
frontend/src/colors.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export const primary = "#f2f3f4";
|
||||
export const primaryRGBA = "rgba(242, 243, 244, 0.8)";
|
||||
export const secondary = "#f2f4f4";
|
||||
export const tertiary = "#f3f2f4";
|
||||
export const danger = "#f9a9ab";
|
86
frontend/src/index.css
Normal file
86
frontend/src/index.css
Normal file
|
@ -0,0 +1,86 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
#bootstrap-overrides .form-select .form-control {
|
||||
background-color: #f2f2e8 !important;
|
||||
border-color: #f2f2e8 !important;
|
||||
}
|
||||
|
||||
#bootstrap-overrides .dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#bootstrap-overrides .form-label {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
#bootstrap-overrides table {
|
||||
letter-spacing: 0.25em;
|
||||
}
|
||||
|
||||
#bootstrap-overrides .bg-primary {
|
||||
background-color: #f2f3f4 !important;
|
||||
}
|
||||
|
||||
#bootstrap-overrides .modal-body {
|
||||
background-color: #f2f3f4 !important;
|
||||
}
|
||||
|
||||
#bootstrap-overrides .danger {
|
||||
background-color: #f9a9ab !important;
|
||||
}
|
||||
|
||||
#bootstrap-overrides .page-link {
|
||||
color: black !important;
|
||||
border: 2px solid black !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
#bootstrap-overrides .active > .page-link {
|
||||
background-color: black !important;
|
||||
color: #f2f3f4 !important;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
.spin {
|
||||
-webkit-animation: spinAnim 2s linear infinite;
|
||||
-moz-animation: spinAnim 2s linear infinite;
|
||||
animation: spinAnim 2s linear infinite;
|
||||
}
|
||||
|
||||
@-moz-keyframes spinAnim {
|
||||
100% {
|
||||
-moz-transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes spinAnim {
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes spinAnim {
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
19
frontend/src/index.tsx
Normal file
19
frontend/src/index.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
BIN
frontend/src/logo.png
Normal file
BIN
frontend/src/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
1
frontend/src/logo.svg
Normal file
1
frontend/src/logo.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg stroke="currentColor" fill="currentColor" stroke-width="0" version="1.1" viewBox="0 0 18 16" height="200px" width="200px" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 2h-3c-0.275 0-0.5 0.225-0.5 0.5v11c0 0.275 0.225 0.5 0.5 0.5h3c0.275 0 0.5-0.225 0.5-0.5v-11c0-0.275-0.225-0.5-0.5-0.5zM3 5h-2v-1h2v1z"></path><path d="M8.5 2h-3c-0.275 0-0.5 0.225-0.5 0.5v11c0 0.275 0.225 0.5 0.5 0.5h3c0.275 0 0.5-0.225 0.5-0.5v-11c0-0.275-0.225-0.5-0.5-0.5zM8 5h-2v-1h2v1z"></path><path d="M11.954 2.773l-2.679 1.35c-0.246 0.124-0.345 0.426-0.222 0.671l4.5 8.93c0.124 0.246 0.426 0.345 0.671 0.222l2.679-1.35c0.246-0.124 0.345-0.426 0.222-0.671l-4.5-8.93c-0.124-0.246-0.426-0.345-0.671-0.222z"></path><path d="M14.5 13.5c0 0.276-0.224 0.5-0.5 0.5s-0.5-0.224-0.5-0.5c0-0.276 0.224-0.5 0.5-0.5s0.5 0.224 0.5 0.5z"></path></svg>
|
After Width: | Height: | Size: 820 B |
79
frontend/src/pages/Book/Book.tsx
Normal file
79
frontend/src/pages/Book/Book.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import React, { useContext } from "react";
|
||||
import { Book as BookType } from "../../types/Book";
|
||||
import { Col, Row } from "react-bootstrap";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ImSpinner2 } from "react-icons/im";
|
||||
import { Actions } from "../Library/components/Actions";
|
||||
import { ModalContext } from "../../App";
|
||||
import { ModalSelector } from "../../shared/components/modals/Modals";
|
||||
import { useAuth } from "../../shared/utils/useAuthentication";
|
||||
|
||||
export const Book = (args?: { book?: BookType }): React.JSX.Element => {
|
||||
const params = useParams<{ uuid: string }>();
|
||||
|
||||
const auth = useAuth();
|
||||
const modalContext = useContext(ModalContext);
|
||||
|
||||
const {
|
||||
data: fetchedBook,
|
||||
error,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery<BookType>({
|
||||
queryKey: ["book", params.uuid],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/books/find`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ uuid: params.uuid }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
return (await response.json())[0];
|
||||
},
|
||||
});
|
||||
|
||||
const book = args?.book ?? fetchedBook;
|
||||
|
||||
return (
|
||||
<div className='m-auto w-50 d-flex flex-column'>
|
||||
<ModalSelector {...modalContext} />
|
||||
{isLoading || isFetching ? (
|
||||
<ImSpinner2 className='spin m-auto' size={50} />
|
||||
) : !!error ? (
|
||||
<h2 className='m-auto'>{error.message}</h2>
|
||||
) : !book ? (
|
||||
<h2 className='m-auto'>404 Book not found</h2>
|
||||
) : (
|
||||
<>
|
||||
<h1>{book.title}</h1>
|
||||
<Row>
|
||||
<Col>ISBN</Col>
|
||||
<Col>{book.isbn}</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>Shelf</Col>
|
||||
<Col>{book.shelf}</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>Actions</Col>
|
||||
<Col>
|
||||
<Actions
|
||||
book={book}
|
||||
refetch={refetch}
|
||||
authenticated={auth.authenticated}
|
||||
setActiveModal={modalContext.setActiveModal}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
1
frontend/src/pages/Book/index.ts
Normal file
1
frontend/src/pages/Book/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Book } from "./Book";
|
282
frontend/src/pages/Library/Library.tsx
Normal file
282
frontend/src/pages/Library/Library.tsx
Normal file
|
@ -0,0 +1,282 @@
|
|||
import React, { useCallback, useContext, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Book } from "../../types/Book";
|
||||
import { Pagination, Table } from "react-bootstrap";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { Actions } from "./components/Actions";
|
||||
import { ImArrowDown, ImArrowUp } from "react-icons/im";
|
||||
import { AiOutlineLeft, AiOutlineRight } from "react-icons/ai";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { format } from "date-fns";
|
||||
import { useAuth } from "../../shared/utils/useAuthentication";
|
||||
import { ModalSelector } from "../../shared/components/modals/Modals";
|
||||
import { ModalContext } from "../../App";
|
||||
|
||||
export const Library = (): React.JSX.Element => {
|
||||
const location = useLocation();
|
||||
const locationState = location.state;
|
||||
|
||||
const params = useMemo(
|
||||
() => new URLSearchParams(location.search),
|
||||
[location]
|
||||
);
|
||||
|
||||
const auth = useAuth();
|
||||
const modalContext = useContext(ModalContext);
|
||||
|
||||
const { data: books, refetch } = useQuery<Book[]>({
|
||||
queryFn: async () => {
|
||||
if (
|
||||
locationState &&
|
||||
"found" in locationState &&
|
||||
!!locationState.found.length
|
||||
) {
|
||||
return locationState.found;
|
||||
}
|
||||
const response = await fetch(`/api/books/list`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
},
|
||||
queryKey: ["books"],
|
||||
});
|
||||
|
||||
const columnHelper = createColumnHelper<Book>();
|
||||
const columns: ColumnDef<Book, any>[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
columnHelper.accessor((book) => book.isbn, {
|
||||
id: "isbn",
|
||||
header: "ISBN",
|
||||
size: 50,
|
||||
cell: (props) => <div>{props.row.original.isbn}</div>,
|
||||
}),
|
||||
columnHelper.accessor((book) => book.title, {
|
||||
id: "title",
|
||||
header: "Title",
|
||||
size: 200,
|
||||
cell: (props) => <div>{props.row.original.title}</div>,
|
||||
}),
|
||||
columnHelper.accessor((book) => book.published, {
|
||||
id: "published",
|
||||
header: "Year published",
|
||||
size: 30,
|
||||
cell: (props) => <div>{props.row.original.published}</div>,
|
||||
}),
|
||||
columnHelper.accessor((book) => book.shelf, {
|
||||
id: "shelf",
|
||||
header: "Shelf",
|
||||
size: 50,
|
||||
cell: (props) => <div>{props.row.original.shelf}</div>,
|
||||
}),
|
||||
columnHelper.accessor(
|
||||
(book) => (!book.checkoutBy ? "" : book.checkoutBy),
|
||||
{
|
||||
id: "checkoutBy",
|
||||
size: 50,
|
||||
header: "Checked out by",
|
||||
cell: (props) => (
|
||||
<div>
|
||||
{!props.row.original.checkoutBy
|
||||
? ""
|
||||
: props.row.original.checkoutBy}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
),
|
||||
columnHelper.accessor((book) => book.lastCheckoutDate, {
|
||||
id: "lastCheckoutDate",
|
||||
header: "Last checkout",
|
||||
size: 50,
|
||||
cell: (props) => (
|
||||
<div>
|
||||
{!props.row.original.lastCheckoutDate
|
||||
? ""
|
||||
: format(
|
||||
Number(props.row.original.lastCheckoutDate),
|
||||
"yyyy MM dd"
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
auth.authenticated
|
||||
? columnHelper.accessor(
|
||||
(book) => (!book.contact ? "" : book.contact),
|
||||
{
|
||||
id: "contactInfo",
|
||||
header: "Contact info",
|
||||
size: 50,
|
||||
cell: (props) => (
|
||||
<div>
|
||||
{!props.row.original.contact
|
||||
? ""
|
||||
: props.row.original.contact}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
)
|
||||
: undefined,
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
size: 30,
|
||||
cell: (props) => (
|
||||
<Actions
|
||||
book={props.row.original}
|
||||
refetch={refetch}
|
||||
authenticated={auth.authenticated}
|
||||
setActiveModal={modalContext.setActiveModal}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
].filter((c) => typeof c !== "undefined") as ColumnDef<Book, any>[],
|
||||
[auth.authenticated, columnHelper, modalContext.setActiveModal, refetch]
|
||||
);
|
||||
|
||||
const shelf = params.get("shelf");
|
||||
const year = params.get("year");
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
books?.filter(
|
||||
(b) =>
|
||||
(!shelf || b.shelf?.toLowerCase() === shelf?.toLowerCase()) &&
|
||||
(!year || b.published.toString() === year)
|
||||
) ?? [],
|
||||
[books, year, shelf]
|
||||
);
|
||||
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
});
|
||||
|
||||
const currentPage = useMemo(
|
||||
() => table.getState().pagination.pageIndex,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[table.getState().pagination.pageIndex]
|
||||
);
|
||||
|
||||
const getPage = useCallback(
|
||||
(n: number) => (
|
||||
<Pagination.Item
|
||||
key={`${n}th-page`}
|
||||
active={n === currentPage}
|
||||
onClick={() => table.setPageIndex(n)}
|
||||
>
|
||||
{n + 1}
|
||||
</Pagination.Item>
|
||||
),
|
||||
[table, currentPage]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='m-auto p-2' style={{ width: "100%", maxHeight: "100vh" }}>
|
||||
<ModalSelector {...modalContext} />
|
||||
<h1>Library</h1>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
overflow: "auto",
|
||||
marginBottom: "5px",
|
||||
}}
|
||||
>
|
||||
<Table striped bordered hover>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} colSpan={header.colSpan}>
|
||||
<div
|
||||
style={{ cursor: "pointer" }}
|
||||
{...{
|
||||
onClick: header.column.getToggleSortingHandler(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{!!header.column.getIsSorted() &&
|
||||
((header.column.getIsSorted() as string) === "asc" ? (
|
||||
<ImArrowUp />
|
||||
) : (
|
||||
<ImArrowDown />
|
||||
))}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
{table.getPageCount() ? (
|
||||
<div className='d-flex w-100'>
|
||||
<Pagination className='d-flex m-auto'>
|
||||
<Pagination.Item
|
||||
key='prev'
|
||||
disabled={currentPage === 0}
|
||||
onClick={() => table.setPageIndex(currentPage - 1)}
|
||||
>
|
||||
<AiOutlineLeft />
|
||||
</Pagination.Item>
|
||||
{getPage(0)}
|
||||
{currentPage > 3 && <Pagination.Ellipsis />}
|
||||
{currentPage === 3 && getPage(currentPage - 2)}
|
||||
{currentPage > 1 && getPage(currentPage - 1)}
|
||||
{currentPage > 0 && getPage(currentPage)}
|
||||
{currentPage < table.getPageCount() - 2 && getPage(currentPage + 1)}
|
||||
{currentPage < table.getPageCount() - 3 &&
|
||||
table.getPageCount() > 4 && <Pagination.Ellipsis />}
|
||||
{currentPage < table.getPageCount() - 1 &&
|
||||
getPage(table.getPageCount() - 1)}
|
||||
<Pagination.Item
|
||||
key='next'
|
||||
onClick={() => table.setPageIndex(currentPage + 1)}
|
||||
disabled={currentPage >= table.getPageCount() - 1}
|
||||
>
|
||||
<AiOutlineRight />
|
||||
</Pagination.Item>
|
||||
</Pagination>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
151
frontend/src/pages/Library/components/Actions.tsx
Normal file
151
frontend/src/pages/Library/components/Actions.tsx
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { Badge, Dropdown } from "react-bootstrap";
|
||||
import { Book } from "../../../types/Book";
|
||||
import { ImBin, ImBook, ImBoxAdd, ImBoxRemove, ImMenu } from "react-icons/im";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { secondary } from "../../../colors";
|
||||
import { ModalContextType } from "../../../App";
|
||||
import { modalTypes } from "../../../shared/components/modals/types";
|
||||
import { useCallback } from "react";
|
||||
|
||||
const { book: bookModal, checkout, del } = modalTypes;
|
||||
|
||||
export const Actions = ({
|
||||
refetch,
|
||||
book,
|
||||
authenticated,
|
||||
setActiveModal,
|
||||
}: {
|
||||
refetch: () => void;
|
||||
book: Book;
|
||||
authenticated: boolean;
|
||||
} & Pick<ModalContextType, "setActiveModal">): React.JSX.Element => {
|
||||
const { mutate: re } = useMutation({
|
||||
mutationFn: async () => {
|
||||
await fetch(`/api/books/return`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
checkoutBy: "admin",
|
||||
contact: "admin",
|
||||
uuid: book.uuid,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
refetch();
|
||||
setActiveModal();
|
||||
}, [refetch, setActiveModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
variant='success'
|
||||
id='dropdown-basic'
|
||||
style={{ backgroundColor: secondary, border: "2px solid black" }}
|
||||
>
|
||||
<ImMenu size={25} color='black' />
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu>
|
||||
{authenticated && (
|
||||
<Dropdown.Item
|
||||
id='moveToShelf'
|
||||
className='d-flex'
|
||||
onClick={() => setActiveModal({ type: bookModal, book, onClose })}
|
||||
>
|
||||
<Badge
|
||||
pill
|
||||
className='ml-2 d-flex mr-2'
|
||||
style={{
|
||||
border: "2px solid black",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
<ImBook size={25} color='black' className='m-auto' />
|
||||
</Badge>
|
||||
<p className='m-auto' style={{ paddingLeft: "10px" }}>
|
||||
Edit Book
|
||||
</p>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{!book.checkoutBy || book.checkoutBy === "null" ? (
|
||||
<Dropdown.Item
|
||||
id='checkout'
|
||||
className='d-flex'
|
||||
onClick={() =>
|
||||
setActiveModal({
|
||||
type: checkout,
|
||||
title: book.title,
|
||||
uuid: book.uuid,
|
||||
onClose,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
pill
|
||||
className='ml-2 d-flex mr-2'
|
||||
style={{
|
||||
border: "2px solid black",
|
||||
}}
|
||||
>
|
||||
<ImBoxRemove size={25} color='black' className='m-auto' />
|
||||
</Badge>{" "}
|
||||
<p className='m-auto' style={{ paddingLeft: "10px" }}>
|
||||
Checkout
|
||||
</p>
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
<Dropdown.Item
|
||||
id='return'
|
||||
className='d-flex'
|
||||
onClick={() =>
|
||||
authenticated
|
||||
? re()
|
||||
: setActiveModal({ type: checkout, ...book, onClose })
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
pill
|
||||
className='ml-2 d-flex mr-2'
|
||||
style={{ border: "2px solid black" }}
|
||||
>
|
||||
<ImBoxAdd size={25} color='black' className='m-auto' />
|
||||
</Badge>{" "}
|
||||
<p className='m-auto' style={{ paddingLeft: "10px" }}>
|
||||
Return
|
||||
</p>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{authenticated && (
|
||||
<Dropdown.Item
|
||||
id='delete'
|
||||
className='d-flex'
|
||||
onClick={() =>
|
||||
setActiveModal({ type: del, uuid: book.uuid, onClose })
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
pill
|
||||
className='ml-2 d-flex danger mr-2'
|
||||
style={{
|
||||
border: "2px solid black",
|
||||
}}
|
||||
>
|
||||
<ImBin size={25} color='black' className='m-auto' />
|
||||
</Badge>{" "}
|
||||
<p className='m-auto' style={{ paddingLeft: "10px" }}>
|
||||
Delete
|
||||
</p>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
1
frontend/src/pages/Library/index.ts
Normal file
1
frontend/src/pages/Library/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Library } from "./Library";
|
203
frontend/src/pages/Main/Main.tsx
Normal file
203
frontend/src/pages/Main/Main.tsx
Normal file
|
@ -0,0 +1,203 @@
|
|||
import React, { useContext, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import {
|
||||
ImBook,
|
||||
ImBooks,
|
||||
ImCamera,
|
||||
ImSearch,
|
||||
ImSpinner2,
|
||||
} from "react-icons/im";
|
||||
|
||||
import { TfiKey } from "react-icons/tfi";
|
||||
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Book } from "../../types/Book";
|
||||
import { secondary } from "../../colors";
|
||||
import { useAuth } from "../../shared/utils/useAuthentication";
|
||||
import { ModalContext } from "../../App";
|
||||
import { modalTypes } from "../../shared/components/modals/types";
|
||||
import { ModalSelector } from "../../shared/components/modals/Modals";
|
||||
|
||||
const { auth, scanner, book } = modalTypes;
|
||||
|
||||
export const Main = (): React.JSX.Element => {
|
||||
const { authenticated } = useAuth();
|
||||
const { activeModal, setActiveModal } = useContext(ModalContext);
|
||||
|
||||
const [title, setTitle] = useState<string | undefined>(undefined);
|
||||
const [isbn, setIsbn] = useState<string | undefined>(undefined);
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { refetch, isFetching } = useQuery<Book | null>({
|
||||
queryKey: ["bookTitle", isbn],
|
||||
queryFn: async () => {
|
||||
if (!title && !isbn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/books/find", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, isbn }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setError(await response.text());
|
||||
return null;
|
||||
} else {
|
||||
const books = await response.json();
|
||||
|
||||
if (!books.length) {
|
||||
setError("No books found.");
|
||||
return null;
|
||||
}
|
||||
if (books.length === 1) {
|
||||
navigate(`/book/${books[0].uuid}`);
|
||||
} else {
|
||||
navigate(`/books`, {
|
||||
state: {
|
||||
found: books,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (await response.json())[0];
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='d-flex flex-column w-100'>
|
||||
<ModalSelector
|
||||
activeModal={activeModal}
|
||||
setActiveModal={setActiveModal}
|
||||
/>
|
||||
<h1 className='m-auto mb-2'>Librarian</h1>
|
||||
<Form
|
||||
className='w-100 mb-0 mt-0 mx-auto'
|
||||
style={{ maxWidth: "500px" }}
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<div className='d-flex'>
|
||||
<Form.Control
|
||||
value={title}
|
||||
disabled={isFetching}
|
||||
placeholder='Search for a title or use the camera to scan a barcode . . .'
|
||||
onChange={(ev) => setTitle(ev.target.value)}
|
||||
style={{
|
||||
borderRadius: "20px 0px 0px 20px",
|
||||
border: "2px solid black",
|
||||
borderRight: "none",
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
style={{
|
||||
borderRadius: "0px 5px 5px 0px",
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
}}
|
||||
className='mr-2'
|
||||
disabled={activeModal?.type === scanner || isFetching}
|
||||
onClick={() =>
|
||||
!!title
|
||||
? refetch()
|
||||
: setActiveModal({ type: scanner, onDetect: setIsbn })
|
||||
}
|
||||
>
|
||||
{isFetching ? (
|
||||
<ImSpinner2 className='spin' />
|
||||
) : !!title ? (
|
||||
<ImSearch />
|
||||
) : (
|
||||
<ImCamera size={20} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Form.Control.Feedback
|
||||
style={{ display: !!error ? "block" : "none" }}
|
||||
type='invalid'
|
||||
>
|
||||
{error}
|
||||
</Form.Control.Feedback>
|
||||
</Form>
|
||||
<div className='d-flex mx-auto mb-auto mt-2 w-100'>
|
||||
{authenticated ? (
|
||||
<Button
|
||||
style={{
|
||||
borderRadius: "5px",
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
marginLeft: "auto",
|
||||
marginRight: "10px",
|
||||
}}
|
||||
onClick={() =>
|
||||
setActiveModal({ type: book, onClose: setActiveModal })
|
||||
}
|
||||
>
|
||||
<ImBook /> Add new Book
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
style={{
|
||||
borderRadius: "5px",
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
marginLeft: "auto",
|
||||
marginRight: "10px",
|
||||
}}
|
||||
onClick={() => setActiveModal({ type: auth })}
|
||||
>
|
||||
<TfiKey /> Admin Login
|
||||
</Button>
|
||||
)}
|
||||
<Link
|
||||
to='/books'
|
||||
state={{ found: [] }}
|
||||
style={
|
||||
authenticated
|
||||
? { marginRight: "10px", marginLeft: "10px" }
|
||||
: { marginRight: "auto", marginLeft: "10px" }
|
||||
}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
borderRadius: "5px",
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
}}
|
||||
>
|
||||
<ImBooks /> Browse library{" "}
|
||||
</Button>
|
||||
</Link>
|
||||
{authenticated && (
|
||||
<Button
|
||||
style={{
|
||||
borderRadius: "5px",
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
marginRight: "auto",
|
||||
marginLeft: "10px",
|
||||
}}
|
||||
onClick={() => setActiveModal({ type: auth, isUpdate: true })}
|
||||
>
|
||||
<TfiKey /> Set new Admin key{" "}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
1
frontend/src/pages/Main/index.tsx
Normal file
1
frontend/src/pages/Main/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { Main } from "./Main";
|
1
frontend/src/pages/index.tsx
Normal file
1
frontend/src/pages/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./Main";
|
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
15
frontend/src/reportWebVitals.ts
Normal file
15
frontend/src/reportWebVitals.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
5
frontend/src/setupTests.ts
Normal file
5
frontend/src/setupTests.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
184
frontend/src/shared/components/modals/AuthenticationModal.tsx
Normal file
184
frontend/src/shared/components/modals/AuthenticationModal.tsx
Normal file
|
@ -0,0 +1,184 @@
|
|||
import { useCallback, useContext } from "react";
|
||||
import { Button, Col, Form, Modal, Row } from "react-bootstrap";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { TfiKey } from "react-icons/tfi";
|
||||
import { primaryRGBA, primary, secondary } from "../../../colors";
|
||||
import { ModalHeader } from "./ModalHeader";
|
||||
import { AuthContext } from "../../../App";
|
||||
import { AuthModalProps } from "./types";
|
||||
|
||||
export const AuthenticationModal = ({
|
||||
open,
|
||||
onClose,
|
||||
isUpdate,
|
||||
}: Omit<AuthModalProps, "type">) => {
|
||||
const { control, register, formState, setError, reset } = useForm<{
|
||||
password: string;
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}>({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const values = useWatch({ control });
|
||||
|
||||
const auth = useContext(AuthContext);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (
|
||||
data: Partial<{
|
||||
password: string;
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}>
|
||||
) => {
|
||||
if (!data.password && !isUpdate) {
|
||||
return setError("password", { message: "Password is required." });
|
||||
}
|
||||
if (isUpdate && !data.oldPassword) {
|
||||
return setError("oldPassword", {
|
||||
message: "Current password is required.",
|
||||
});
|
||||
}
|
||||
if (isUpdate && !data.newPassword) {
|
||||
return setError("newPassword", {
|
||||
message: "New password is required.",
|
||||
});
|
||||
}
|
||||
const res = await fetch(
|
||||
isUpdate ? "/api/updateAdminKey" : "/api/authenticate",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
setError(isUpdate ? "newPassword" : "password", {
|
||||
message: await res.text(),
|
||||
});
|
||||
reset();
|
||||
} else {
|
||||
auth.setAuthenticated?.(true);
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[isUpdate, setError, reset, auth, onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={open}
|
||||
onHide={onClose}
|
||||
style={{ backgroundColor: primaryRGBA }}
|
||||
centered
|
||||
>
|
||||
<ModalHeader
|
||||
onClose={onClose}
|
||||
title={isUpdate ? "Set new Admin key" : "Admin Login"}
|
||||
icon={<TfiKey size={50} className='ml-0 mr-auto' />}
|
||||
/>
|
||||
<Modal.Body
|
||||
style={{
|
||||
border: "2px solid black",
|
||||
borderTop: "none",
|
||||
backgroundColor: primary,
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
className='mb-2'
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
onSubmit(values);
|
||||
}}
|
||||
>
|
||||
{isUpdate && (
|
||||
<>
|
||||
<Form.Group as={Row} className='mb-2'>
|
||||
<Col sm='4'>
|
||||
<Form.Label>Current Password</Form.Label>
|
||||
</Col>
|
||||
<Col>
|
||||
<Form.Control
|
||||
{...register("oldPassword", { required: true })}
|
||||
type='password'
|
||||
placeholder='Enter current admin password ...'
|
||||
isInvalid={!!formState.errors.oldPassword}
|
||||
/>
|
||||
<Form.Control.Feedback type='invalid'>
|
||||
{!values.oldPassword
|
||||
? "Old password is required"
|
||||
: formState.errors.oldPassword?.message}
|
||||
</Form.Control.Feedback>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group as={Row} className='mb-2'>
|
||||
<Col sm='4'>
|
||||
<Form.Label>New Password</Form.Label>
|
||||
</Col>
|
||||
<Col>
|
||||
<Form.Control
|
||||
{...register("newPassword", {
|
||||
required: true,
|
||||
})}
|
||||
type='password'
|
||||
placeholder={`Enter new admin password ...`}
|
||||
isInvalid={!!formState.errors.newPassword}
|
||||
/>
|
||||
<Form.Control.Feedback type='invalid'>
|
||||
{!values.newPassword
|
||||
? "New password is required"
|
||||
: formState.errors.newPassword?.message}
|
||||
</Form.Control.Feedback>
|
||||
</Col>
|
||||
</Form.Group>{" "}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isUpdate && (
|
||||
<Form.Group as={Row} className='mb-2'>
|
||||
<Col sm='4'>
|
||||
<Form.Label>Password</Form.Label>
|
||||
</Col>
|
||||
<Col>
|
||||
<Form.Control
|
||||
{...register("password", {
|
||||
required: true,
|
||||
})}
|
||||
type='password'
|
||||
placeholder={`Enter admin password ...`}
|
||||
isInvalid={!!formState.errors.password}
|
||||
/>
|
||||
<Form.Control.Feedback type='invalid'>
|
||||
{!values.password
|
||||
? "Password is required"
|
||||
: formState.errors.password?.message}
|
||||
</Form.Control.Feedback>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
)}
|
||||
|
||||
<div className='d-flex mx-auto mb-auto mt-2 w-100'>
|
||||
<Button
|
||||
style={{
|
||||
borderRadius: "5px",
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
marginLeft: "auto",
|
||||
marginRight: "10px",
|
||||
}}
|
||||
onClick={() => onSubmit(values)}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
};
|
268
frontend/src/shared/components/modals/BookModal.tsx
Normal file
268
frontend/src/shared/components/modals/BookModal.tsx
Normal file
|
@ -0,0 +1,268 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Col, Form, Modal, Row } from "react-bootstrap";
|
||||
import { ImBook, ImCamera } from "react-icons/im";
|
||||
import { ModalHeader } from "./ModalHeader";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { Book, bookShelfs } from "../../../types/Book";
|
||||
import { useScanner } from "../../utils/useScanner";
|
||||
import { primary, primaryRGBA, secondary } from "../../../colors";
|
||||
import { BookModalProps } from "./types";
|
||||
|
||||
type BookForm = Pick<Book, "isbn" | "title" | "shelf" | "published">;
|
||||
|
||||
export const BookModal = ({
|
||||
open,
|
||||
onClose,
|
||||
book,
|
||||
}: Omit<BookModalProps, "type">): React.JSX.Element => {
|
||||
const isEdit = !!book;
|
||||
const [showScanner, setShowScanner] = useState(false);
|
||||
|
||||
const { control, register, formState, setError, setValue, reset } =
|
||||
useForm<BookForm>({
|
||||
mode: "onChange",
|
||||
defaultValues: book,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset(book);
|
||||
}, [book, reset]);
|
||||
|
||||
const { scannerError, setScannerRef } = useScanner({
|
||||
onDetected: (result) => {
|
||||
setValue("isbn", result);
|
||||
setShowScanner(false);
|
||||
},
|
||||
});
|
||||
|
||||
const values = useWatch({ control });
|
||||
|
||||
const [submitError, setSubmitError] = useState<string | undefined>();
|
||||
const onSubmit = useCallback(
|
||||
async (data: Partial<BookForm>) => {
|
||||
setSubmitError(undefined);
|
||||
let error = false;
|
||||
if (!data.isbn?.length) {
|
||||
setError("isbn", { message: "ISBN is required" });
|
||||
error = true;
|
||||
}
|
||||
if (!data.title?.length) {
|
||||
setError("title", { message: "Title is required" });
|
||||
error = true;
|
||||
}
|
||||
if (!data.shelf?.length) {
|
||||
setError("shelf", { message: "Shelf is required" });
|
||||
error = true;
|
||||
}
|
||||
if (!data.published) {
|
||||
setError("published", { message: "Year published is required" });
|
||||
error = true;
|
||||
}
|
||||
if (error) return;
|
||||
const createdBook = await fetch(
|
||||
isEdit ? "api/books/edit" : "/api/books/create",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!createdBook.ok) {
|
||||
setSubmitError(await createdBook.text());
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[isEdit, onClose, setError]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={open}
|
||||
onHide={onClose}
|
||||
style={{ backgroundColor: primaryRGBA }}
|
||||
centered
|
||||
>
|
||||
<ModalHeader
|
||||
onClose={onClose}
|
||||
title={`${!!book ? "Edit" : "Add new"} Book`}
|
||||
icon={<ImBook size={50} className="ml-0 mr-auto" />}
|
||||
/>
|
||||
<Modal.Body
|
||||
style={{
|
||||
border: "2px solid black",
|
||||
borderTop: "none",
|
||||
backgroundColor: primary,
|
||||
}}
|
||||
>
|
||||
{!showScanner && (
|
||||
<Form
|
||||
className="mb-2"
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
onSubmit(values);
|
||||
}}
|
||||
>
|
||||
<Form.Group as={Row} className="mb-2">
|
||||
<Col sm="2">
|
||||
<Form.Label>ISBN</Form.Label>
|
||||
</Col>
|
||||
<Col className="d-flex flex-column">
|
||||
<div className="d-flex">
|
||||
<Form.Control
|
||||
{...register("isbn", { required: true, maxLength: 360 })}
|
||||
isInvalid={!!formState.errors.isbn}
|
||||
style={{
|
||||
borderRadius: "5px 0px 0px 5px",
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
style={{
|
||||
borderRadius: "0px 5px 5px 0px",
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
}}
|
||||
className="mr-2 pt-0"
|
||||
disabled={showScanner}
|
||||
onClick={() => setShowScanner(true)}
|
||||
>
|
||||
<ImCamera size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<Form.Control.Feedback
|
||||
style={{ display: !formState.errors.isbn ? "none" : "block" }}
|
||||
type="invalid"
|
||||
>
|
||||
{!formState.errors.isbn
|
||||
? "ISBN is required"
|
||||
: formState.errors.isbn?.message}
|
||||
</Form.Control.Feedback>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group as={Row} className="mb-2">
|
||||
<Col sm="2">
|
||||
<Form.Label>Title</Form.Label>
|
||||
</Col>
|
||||
<Col>
|
||||
<Form.Control
|
||||
{...register("title", { required: true, maxLength: 360 })}
|
||||
isInvalid={!!formState.errors.title}
|
||||
/>
|
||||
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{!values.title
|
||||
? "Title is required"
|
||||
: formState.errors.title?.message}
|
||||
</Form.Control.Feedback>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group as={Row} className="mb-2">
|
||||
<Col sm="2">
|
||||
<Form.Label>Year published</Form.Label>
|
||||
</Col>
|
||||
<Col>
|
||||
<Form.Control
|
||||
{...register("published", {
|
||||
required: true,
|
||||
maxLength: 360,
|
||||
validate: {
|
||||
isReasonable: (value) => {
|
||||
if (value > 1900 && value < 2150) {
|
||||
return true;
|
||||
}
|
||||
return `${value} is not a reasonable year`;
|
||||
},
|
||||
isNumber: (value) => {
|
||||
if (value && value.toString().match(/^[0-9\b]+$/)) {
|
||||
return true;
|
||||
}
|
||||
return "Please enter a valid year.";
|
||||
},
|
||||
},
|
||||
})}
|
||||
isInvalid={!!formState.errors.published}
|
||||
/>
|
||||
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{!values.published
|
||||
? "Year published is required"
|
||||
: formState.errors.published?.message}
|
||||
</Form.Control.Feedback>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group as={Row} className="mb-2">
|
||||
<Col sm="2">
|
||||
<Form.Label>Shelf</Form.Label>
|
||||
</Col>
|
||||
<Col>
|
||||
<Form.Select
|
||||
{...register("shelf", { required: true })}
|
||||
isInvalid={!!formState.errors.shelf}
|
||||
>
|
||||
{Object.keys(bookShelfs).map((key) => (
|
||||
<option
|
||||
key="key"
|
||||
value={bookShelfs[key as keyof typeof bookShelfs]}
|
||||
>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{!values.shelf
|
||||
? "Shelf is required"
|
||||
: formState.errors.shelf?.message}
|
||||
</Form.Control.Feedback>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<div className="d-flex mx-auto mb-auto mt-2 w-100">
|
||||
<Button
|
||||
style={{
|
||||
borderRadius: "5px",
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
marginLeft: "auto",
|
||||
marginRight: "10px",
|
||||
}}
|
||||
onClick={() => onSubmit(values)}
|
||||
>
|
||||
{isEdit ? "Submit" : "Add Book"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
{showScanner && (
|
||||
<div
|
||||
className="w-100 overflow-hidden"
|
||||
ref={(ref) => setScannerRef(ref)}
|
||||
style={{ position: "relative", height: "25vh" }}
|
||||
>
|
||||
<canvas
|
||||
className="drawingBuffer w-100 position-absolute"
|
||||
style={{
|
||||
height: "100%",
|
||||
border: "2px solid black",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{scannerError ? (
|
||||
<p>
|
||||
ERROR INITIALIZING CAMERA ${JSON.stringify(scannerError)} -- DO YOU
|
||||
HAVE PERMISSION?
|
||||
</p>
|
||||
) : null}
|
||||
{!!submitError && (
|
||||
<Form.Control.Feedback style={{ display: "block" }} type="invalid">
|
||||
{submitError}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
};
|
196
frontend/src/shared/components/modals/CheckoutModal.tsx
Normal file
196
frontend/src/shared/components/modals/CheckoutModal.tsx
Normal file
|
@ -0,0 +1,196 @@
|
|||
import React, { useCallback, useState } from "react";
|
||||
import { Alert, Button, Col, Form, Modal, Row } from "react-bootstrap";
|
||||
import { ImBoxAdd, ImBoxRemove } from "react-icons/im";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { Book } from "../../../types/Book";
|
||||
import { primary, primaryRGBA, secondary } from "../../../colors";
|
||||
import { ModalHeader } from "./ModalHeader";
|
||||
import { CheckoutBookModalProps } from "./types";
|
||||
import { AiOutlineExclamationCircle } from "react-icons/ai";
|
||||
|
||||
type BookCheckoutForm = Pick<Book, "checkoutBy" | "contact">;
|
||||
|
||||
export const CheckoutBookModal = ({
|
||||
uuid,
|
||||
title,
|
||||
open,
|
||||
onClose,
|
||||
checkoutBy,
|
||||
contact,
|
||||
}: Omit<CheckoutBookModalProps, "type">): React.JSX.Element => {
|
||||
const isChechout = !checkoutBy && !contact;
|
||||
|
||||
const [failed, setFailed] = useState<string>();
|
||||
|
||||
const { control, register, formState, setError } = useForm<BookCheckoutForm>({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const values = useWatch({ control });
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (data: Partial<BookCheckoutForm>) => {
|
||||
if (!data.checkoutBy) {
|
||||
setError("checkoutBy", {
|
||||
message: `please enter ${
|
||||
isChechout ? "your" : "the"
|
||||
} name or nickname ${isChechout ? "" : "you prvided on checkout"}`,
|
||||
});
|
||||
}
|
||||
if (!data.contact) {
|
||||
setError("contact", {
|
||||
message: `please enter ${
|
||||
isChechout ? "some" : "the"
|
||||
} contact information ${isChechout ? "" : "you prvided on checkout"}`,
|
||||
});
|
||||
}
|
||||
const res = await fetch(
|
||||
isChechout ? "/api/books/checkout" : "/api/books/return",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...data, uuid }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
onClose();
|
||||
} else {
|
||||
setFailed(await res.text());
|
||||
}
|
||||
},
|
||||
[uuid, onClose, setError, isChechout]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={open}
|
||||
onHide={onClose}
|
||||
style={{ backgroundColor: primaryRGBA }}
|
||||
centered
|
||||
>
|
||||
<ModalHeader
|
||||
onClose={onClose}
|
||||
title={`${isChechout ? "Checkout" : "Return"} book '${title}'`}
|
||||
icon={
|
||||
isChechout ? (
|
||||
<ImBoxRemove size={50} className='ml-0 mr-auto' />
|
||||
) : (
|
||||
<ImBoxAdd size={50} className='ml-0 mr-auto' />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Modal.Body
|
||||
style={{
|
||||
border: "2px solid black",
|
||||
borderTop: "none",
|
||||
backgroundColor: primary,
|
||||
}}
|
||||
>
|
||||
<Alert className='w-80 m-auto my-4' variant='warning'>
|
||||
<AiOutlineExclamationCircle />
|
||||
Your contact information will only be visible to the admins and is
|
||||
deleted once you have returned this book.
|
||||
</Alert>
|
||||
<Form
|
||||
className='mb-2'
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
onSubmit(values);
|
||||
}}
|
||||
>
|
||||
<Form.Group as={Row} className='mb-2'>
|
||||
<Col sm='2'>
|
||||
<Form.Label>Name</Form.Label>
|
||||
</Col>
|
||||
<Col className='d-flex flex-column'>
|
||||
<Form.Control
|
||||
{...register("checkoutBy", {
|
||||
required: true,
|
||||
validate: {
|
||||
notNullString: (value) => {
|
||||
return (
|
||||
value?.toLocaleLowerCase() !== "null" ||
|
||||
"'null' is not a valid nickname"
|
||||
);
|
||||
},
|
||||
},
|
||||
})}
|
||||
isInvalid={!!formState.errors.checkoutBy}
|
||||
placeholder={isChechout ? "name or nickname" : checkoutBy ?? ""}
|
||||
style={{
|
||||
borderRadius: "5px 0px 0px 5px",
|
||||
}}
|
||||
/>
|
||||
<Form.Control.Feedback type='invalid'>
|
||||
{!values.checkoutBy
|
||||
? "name is required"
|
||||
: formState.errors.checkoutBy?.message}
|
||||
</Form.Control.Feedback>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group as={Row} className='mb-2'>
|
||||
<Col sm='2'>
|
||||
<Form.Label>Contact Info</Form.Label>
|
||||
</Col>
|
||||
<Col className='d-flex flex-column'>
|
||||
<Form.Control
|
||||
{...register("contact", {
|
||||
required: true,
|
||||
validate: {
|
||||
//function to test if contact is a valid email or phone number
|
||||
validContact: (value) => {
|
||||
if (
|
||||
value &&
|
||||
(value.match(/^[0-9]{11}$/) ||
|
||||
value.match(
|
||||
/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/
|
||||
))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return "email or phone number is required";
|
||||
},
|
||||
},
|
||||
})}
|
||||
placeholder={
|
||||
isChechout ? "email or phone number" : contact ?? ""
|
||||
}
|
||||
isInvalid={!!formState.errors.contact}
|
||||
/>
|
||||
|
||||
<Form.Control.Feedback type='invalid'>
|
||||
{!values.contact
|
||||
? "Some contact information is required"
|
||||
: formState.errors.contact?.message}
|
||||
</Form.Control.Feedback>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Control.Feedback
|
||||
style={{ display: !failed ? "none" : "flex" }}
|
||||
type='invalid'
|
||||
>
|
||||
<p className='mr-0'>{failed}</p>
|
||||
</Form.Control.Feedback>
|
||||
<div className='d-flex mx-auto mb-auto mt-2 w-100'>
|
||||
<Button
|
||||
style={{
|
||||
borderRadius: "5px",
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
marginLeft: "auto",
|
||||
marginRight: "10px",
|
||||
}}
|
||||
onClick={() => onSubmit(values)}
|
||||
>
|
||||
{`${isChechout ? "Checkout" : "Return"} book`}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
};
|
76
frontend/src/shared/components/modals/DeleteBookModal.tsx
Normal file
76
frontend/src/shared/components/modals/DeleteBookModal.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { Button, Modal } from "react-bootstrap";
|
||||
import { primary, primaryRGBA, secondary } from "../../../colors";
|
||||
import { ModalHeader } from "./ModalHeader";
|
||||
import { ImBin } from "react-icons/im";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { DeleteBookModalProps } from "./types";
|
||||
|
||||
export const DeleteBookModal = ({
|
||||
uuid,
|
||||
open,
|
||||
onClose,
|
||||
}: Omit<DeleteBookModalProps, "type">): React.JSX.Element => {
|
||||
const { mutate: rm } = useMutation({
|
||||
mutationFn: async () => {
|
||||
await fetch(`/api/books/delete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ uuid }),
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={open}
|
||||
onHide={onClose}
|
||||
style={{ backgroundColor: primaryRGBA }}
|
||||
centered
|
||||
>
|
||||
<ModalHeader
|
||||
onClose={onClose}
|
||||
title={"Move to Shelf"}
|
||||
icon={<ImBin size={50} className='ml-0 mr-auto' />}
|
||||
/>
|
||||
<Modal.Body
|
||||
style={{
|
||||
border: "2px solid black",
|
||||
borderTop: "none",
|
||||
backgroundColor: primary,
|
||||
}}
|
||||
>
|
||||
<div className='d-flex mx-auto mb-auto mt-2 w-100'>
|
||||
<Button
|
||||
style={{
|
||||
borderRadius: "5px",
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
marginLeft: "auto",
|
||||
marginRight: "10px",
|
||||
}}
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
style={{
|
||||
borderRadius: "5px",
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
marginLeft: "auto",
|
||||
marginRight: "10px",
|
||||
}}
|
||||
onClick={() => rm()}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
};
|
50
frontend/src/shared/components/modals/ModalHeader.tsx
Normal file
50
frontend/src/shared/components/modals/ModalHeader.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import React from "react";
|
||||
import { Button, Modal } from "react-bootstrap";
|
||||
import { AiOutlineClose } from "react-icons/ai";
|
||||
import { primary, secondary } from "../../../colors";
|
||||
|
||||
export const ModalHeader = ({
|
||||
title,
|
||||
icon,
|
||||
onClose,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
title: JSX.Element | string;
|
||||
icon?: JSX.Element;
|
||||
}): React.JSX.Element => {
|
||||
return (
|
||||
<Modal.Header
|
||||
style={{
|
||||
border: "2px solid black",
|
||||
borderBottom: "none",
|
||||
backgroundColor: primary,
|
||||
}}
|
||||
>
|
||||
<Modal.Title className='w-100 d-flex' style={{ height: "fit-content" }}>
|
||||
{typeof title === "string" ? (
|
||||
<>
|
||||
{icon}
|
||||
<h2 className='m-auto p-2' style={{ textAlign: "center" }}>
|
||||
{title}
|
||||
</h2>
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
<Button
|
||||
className='ml-auto mr-0'
|
||||
style={{
|
||||
backgroundColor: secondary,
|
||||
color: "black",
|
||||
border: "2px solid black",
|
||||
width: "fit-content",
|
||||
height: "fit-content",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<AiOutlineClose />
|
||||
</Button>
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
);
|
||||
};
|
54
frontend/src/shared/components/modals/Modals.tsx
Normal file
54
frontend/src/shared/components/modals/Modals.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import React from "react";
|
||||
import { AuthenticationModal } from "./AuthenticationModal";
|
||||
import { modalTypes } from "./types";
|
||||
import { BookModal } from "./BookModal";
|
||||
import { CheckoutBookModal } from "./CheckoutModal";
|
||||
import { ScannerModal } from "./ScannerModal";
|
||||
import { DeleteBookModal } from "./DeleteBookModal";
|
||||
import { ModalContextType } from "../../../App";
|
||||
|
||||
const { auth, book, checkout, scanner, del } = modalTypes;
|
||||
|
||||
export const ModalSelector = ({
|
||||
activeModal,
|
||||
setActiveModal,
|
||||
}: ModalContextType): React.JSX.Element => (
|
||||
<>
|
||||
<AuthenticationModal
|
||||
open={activeModal?.type === auth}
|
||||
onClose={setActiveModal}
|
||||
isUpdate={activeModal?.type === auth ? activeModal.isUpdate : undefined}
|
||||
/>
|
||||
<BookModal
|
||||
open={activeModal?.type === book}
|
||||
onClose={
|
||||
activeModal?.type === book ? activeModal.onClose : setActiveModal
|
||||
}
|
||||
book={activeModal?.type === book ? activeModal.book : undefined}
|
||||
/>
|
||||
<CheckoutBookModal
|
||||
open={activeModal?.type === checkout}
|
||||
onClose={
|
||||
activeModal?.type === checkout ? activeModal.onClose : setActiveModal
|
||||
}
|
||||
uuid={activeModal?.type === checkout ? activeModal.uuid : ""}
|
||||
checkoutBy={
|
||||
activeModal?.type === checkout ? activeModal.checkoutBy : undefined
|
||||
}
|
||||
title={activeModal?.type === checkout ? activeModal.title : ""}
|
||||
contact={activeModal?.type === checkout ? activeModal.contact : undefined}
|
||||
/>
|
||||
<ScannerModal
|
||||
open={activeModal?.type === scanner}
|
||||
onClose={setActiveModal}
|
||||
onDetected={
|
||||
activeModal?.type === scanner ? activeModal.onDetect : undefined
|
||||
}
|
||||
/>
|
||||
<DeleteBookModal
|
||||
open={activeModal?.type === del}
|
||||
onClose={activeModal?.type === del ? activeModal.onClose : setActiveModal}
|
||||
uuid={activeModal?.type === checkout ? activeModal.uuid : ""}
|
||||
/>
|
||||
</>
|
||||
);
|
57
frontend/src/shared/components/modals/ScannerModal.tsx
Normal file
57
frontend/src/shared/components/modals/ScannerModal.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import React from "react";
|
||||
import { Modal } from "react-bootstrap";
|
||||
import { ImCamera } from "react-icons/im";
|
||||
import { primaryRGBA } from "../../../colors";
|
||||
import { useScanner } from "../../utils/useScanner";
|
||||
import { ModalHeader } from "./ModalHeader";
|
||||
|
||||
export const ScannerModal = ({
|
||||
open,
|
||||
onClose,
|
||||
onDetected,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDetected?: (isbn: string) => void;
|
||||
}): React.JSX.Element => {
|
||||
const { scannerError, setScannerRef } = useScanner({ onDetected });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={open}
|
||||
onHide={onClose}
|
||||
style={{ backgroundColor: primaryRGBA }}
|
||||
centered
|
||||
>
|
||||
<ModalHeader
|
||||
onClose={onClose}
|
||||
title={"Scan barcode"}
|
||||
icon={<ImCamera size={50} className='ml-0 mr-auto' />}
|
||||
/>
|
||||
<Modal.Body style={{ border: "2px solid black", borderTop: "none" }}>
|
||||
<div
|
||||
className='w-100 overflow-hidden'
|
||||
ref={(ref) => setScannerRef(ref)}
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "25vh",
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
className='drawingBuffer w-100 position-absolute'
|
||||
style={{
|
||||
height: "100%",
|
||||
border: "2px solid black",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{scannerError ? (
|
||||
<p>
|
||||
ERROR INITIALIZING CAMERA ${JSON.stringify(scannerError)} -- DO YOU
|
||||
HAVE PERMISSION?
|
||||
</p>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
};
|
47
frontend/src/shared/components/modals/types.ts
Normal file
47
frontend/src/shared/components/modals/types.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { Book } from "../../../types/Book";
|
||||
|
||||
type BaseModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const modalTypes = {
|
||||
auth: "auth",
|
||||
book: "book",
|
||||
checkout: "checkout",
|
||||
scanner: "scanner",
|
||||
del: "del",
|
||||
} as const;
|
||||
|
||||
export type ModalTypes = keyof typeof modalTypes;
|
||||
|
||||
export type AuthModalProps = {
|
||||
type: "auth";
|
||||
isUpdate?: boolean;
|
||||
} & BaseModalProps;
|
||||
|
||||
export type BookModalProps = {
|
||||
type: "book";
|
||||
book?: Book;
|
||||
} & BaseModalProps;
|
||||
|
||||
export type CheckoutBookModalProps = {
|
||||
type: "checkout";
|
||||
} & BaseModalProps &
|
||||
Pick<Book, "uuid" | "title"> &
|
||||
Partial<Pick<Book, "checkoutBy" | "contact">>;
|
||||
|
||||
export type ScannnerModal = {
|
||||
type: "scanner";
|
||||
onDetect: (isbn: string) => void;
|
||||
} & BaseModalProps;
|
||||
|
||||
export type DeleteBookModalProps = { type: "del" } & BaseModalProps &
|
||||
Pick<Book, "uuid">;
|
||||
|
||||
export type ActiveModalProps =
|
||||
| Omit<AuthModalProps, "open" | "onClose">
|
||||
| Omit<BookModalProps, "open">
|
||||
| Omit<CheckoutBookModalProps, "open">
|
||||
| Omit<ScannnerModal, "open" | "onClose">
|
||||
| Omit<DeleteBookModalProps, "open">;
|
32
frontend/src/shared/utils/useAuthentication.ts
Normal file
32
frontend/src/shared/utils/useAuthentication.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { useCallback, useContext } from "react";
|
||||
import { AuthContext } from "../../App";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
type UseAuthHook = { authenticated: boolean };
|
||||
|
||||
export const useAuth = (): UseAuthHook => {
|
||||
const auth = useContext(AuthContext);
|
||||
|
||||
const authenticationCheck = useCallback(
|
||||
async () =>
|
||||
await fetch("/api/authenticate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password: "authCheck" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then((res) => {
|
||||
auth.setAuthenticated?.(res.ok);
|
||||
//react-query can't handle undefined without throwing a warning ...
|
||||
return null;
|
||||
}),
|
||||
[auth]
|
||||
);
|
||||
|
||||
useQuery({
|
||||
queryFn: authenticationCheck,
|
||||
queryKey: ["auth"],
|
||||
});
|
||||
|
||||
return auth;
|
||||
};
|
184
frontend/src/shared/utils/useScanner.ts
Normal file
184
frontend/src/shared/utils/useScanner.ts
Normal file
|
@ -0,0 +1,184 @@
|
|||
import Quagga, {
|
||||
QuaggaJSCodeReader,
|
||||
QuaggaJSResultObject,
|
||||
} from "@ericblade/quagga2";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
const constraints = {
|
||||
width: 640,
|
||||
height: 480,
|
||||
};
|
||||
|
||||
const locator = {
|
||||
patchSize: "medium",
|
||||
halfSample: true,
|
||||
willReadFrequently: true,
|
||||
};
|
||||
|
||||
const readers: QuaggaJSCodeReader[] = ["ean_reader"];
|
||||
|
||||
export const useScanner = ({
|
||||
onDetected,
|
||||
}: {
|
||||
onDetected?: (result: string) => void;
|
||||
}): {
|
||||
setScannerRef: (ref: HTMLDivElement | null) => void;
|
||||
scannerError: string | undefined;
|
||||
} => {
|
||||
const [scannerError, setScannerError] = useState<string | undefined>();
|
||||
|
||||
const [scannerRef, setScannerRef] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const getMedian = useCallback((arr: (number | undefined)[]) => {
|
||||
const newArr = [...arr].filter((x) => typeof x !== "undefined");
|
||||
if (!newArr.length) {
|
||||
return;
|
||||
}
|
||||
newArr.sort((a, b) => a! - b!);
|
||||
const half = Math.floor(newArr.length / 2);
|
||||
if (newArr.length % 2 === 1) {
|
||||
return newArr[half];
|
||||
}
|
||||
return (newArr[half - 1]! + newArr[half]!) / 2;
|
||||
}, []);
|
||||
|
||||
const getMedianOfCodeErrors = useCallback(
|
||||
(result: QuaggaJSResultObject) => {
|
||||
const errors = result.codeResult.decodedCodes.flatMap((x) => x.error);
|
||||
const medianOfErrors = getMedian(errors);
|
||||
return medianOfErrors;
|
||||
},
|
||||
[getMedian]
|
||||
);
|
||||
|
||||
const errorCheck = useCallback(
|
||||
(result: QuaggaJSResultObject) => {
|
||||
if (!onDetected) {
|
||||
return;
|
||||
}
|
||||
const err = getMedianOfCodeErrors(result);
|
||||
|
||||
if (!err) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (err < 0.1 && result.codeResult.code) {
|
||||
onDetected(result.codeResult.code);
|
||||
}
|
||||
},
|
||||
[getMedianOfCodeErrors, onDetected]
|
||||
);
|
||||
|
||||
const handleProcessed = (result: QuaggaJSResultObject) => {
|
||||
const drawingCtx = Quagga.canvas.ctx.overlay;
|
||||
const drawingCanvas = Quagga.canvas.dom.overlay;
|
||||
drawingCtx.font = "24px Arial";
|
||||
drawingCtx.fillStyle = "green";
|
||||
const width = drawingCanvas.getAttribute("width");
|
||||
const height = drawingCanvas.getAttribute("height");
|
||||
|
||||
if (result) {
|
||||
if (result.boxes && width && height) {
|
||||
drawingCtx.clearRect(0, 0, parseInt(width), parseInt(height));
|
||||
result.boxes
|
||||
.filter((box) => box !== result.box)
|
||||
.forEach((box) => {
|
||||
Quagga.ImageDebug.drawPath(box, { x: 0, y: 1 }, drawingCtx, {
|
||||
color: "purple",
|
||||
lineWidth: 2,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (result.box) {
|
||||
Quagga.ImageDebug.drawPath(result.box, { x: 0, y: 1 }, drawingCtx, {
|
||||
color: "blue",
|
||||
lineWidth: 2,
|
||||
});
|
||||
}
|
||||
if (result.codeResult && result.codeResult.code) {
|
||||
drawingCtx.font = "24px Arial";
|
||||
drawingCtx.fillText(result.codeResult.code, 10, 20);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getCameraId = useCallback(async () => {
|
||||
await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
});
|
||||
|
||||
const devices = await navigator.mediaDevices
|
||||
.enumerateDevices()
|
||||
.then((ds) => ds.filter((d) => d.kind === "videoinput"));
|
||||
|
||||
const back = devices.filter((d) => d.label === "Back Camera")[0];
|
||||
|
||||
return !back ? devices[0].deviceId : back.deviceId;
|
||||
}, []);
|
||||
|
||||
const initQuagga = useCallback(async () => {
|
||||
try {
|
||||
const cameraId = await getCameraId();
|
||||
|
||||
if (!scannerRef || !cameraId) {
|
||||
console.log("fehlt", scannerRef, cameraId);
|
||||
return;
|
||||
}
|
||||
await Quagga.init(
|
||||
{
|
||||
inputStream: {
|
||||
type: "LiveStream",
|
||||
constraints: {
|
||||
...constraints,
|
||||
...(cameraId && { deviceId: cameraId }),
|
||||
},
|
||||
target: scannerRef,
|
||||
willReadFrequently: true,
|
||||
},
|
||||
locator,
|
||||
decoder: { readers },
|
||||
locate: true,
|
||||
},
|
||||
async (err) => {
|
||||
Quagga.onProcessed(handleProcessed);
|
||||
|
||||
if (err) {
|
||||
return console.error("Error starting Quagga:", err);
|
||||
}
|
||||
if (scannerRef) {
|
||||
Quagga.start();
|
||||
}
|
||||
}
|
||||
);
|
||||
Quagga.onDetected(errorCheck);
|
||||
} catch (e: unknown) {
|
||||
setScannerError(JSON.stringify(e));
|
||||
}
|
||||
}, [errorCheck, getCameraId, scannerRef]);
|
||||
|
||||
const stopQuagga = useCallback(async () => {
|
||||
await Quagga.CameraAccess.release();
|
||||
await Quagga.stop();
|
||||
Quagga.offDetected(errorCheck);
|
||||
Quagga.offProcessed(handleProcessed);
|
||||
}, [errorCheck]);
|
||||
|
||||
useEffect(() => {
|
||||
const init = () => {
|
||||
initQuagga();
|
||||
};
|
||||
const disable = () => {
|
||||
stopQuagga();
|
||||
};
|
||||
|
||||
if (scannerRef) {
|
||||
init();
|
||||
}
|
||||
return disable();
|
||||
}, [stopQuagga, initQuagga, scannerRef]);
|
||||
|
||||
return {
|
||||
setScannerRef,
|
||||
scannerError,
|
||||
};
|
||||
};
|
20
frontend/src/types/Book.ts
Normal file
20
frontend/src/types/Book.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
export const bookShelfs = {
|
||||
available: "AVAILABLE",
|
||||
fnord1: "FNORD1",
|
||||
fnord2: "FNORD2",
|
||||
sharing: "SHARING",
|
||||
} as const;
|
||||
|
||||
export type Shelf = (typeof bookShelfs)[keyof typeof bookShelfs];
|
||||
|
||||
export type Book = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
isbn: string;
|
||||
title: string;
|
||||
published: number;
|
||||
lastCheckoutDate: number | null;
|
||||
checkoutBy: string | null;
|
||||
contact: string | null;
|
||||
shelf: Shelf | null;
|
||||
};
|
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
25
init.sql
Normal file
25
init.sql
Normal file
|
@ -0,0 +1,25 @@
|
|||
-- MariaDB script
|
||||
|
||||
-- Create the database
|
||||
CREATE DATABASE librarian;
|
||||
|
||||
-- Use the database
|
||||
USE librarian;
|
||||
|
||||
DROP TABLE IF EXISTS bookData;
|
||||
|
||||
-- Table 'BookData'
|
||||
CREATE TABLE bookData (
|
||||
id INT NOT NULL auto_increment PRIMARY KEY,
|
||||
published INT NOT NULL,
|
||||
uuid MEDIUMTEXT NOT NULL,
|
||||
isbn MEDIUMTEXT NOT NULL,
|
||||
title MEDIUMTEXT NOT NULL,
|
||||
lastCheckoutDate INT8,
|
||||
shelf MEDIUMTEXT NOT NULL,
|
||||
checkoutBy MEDIUMTEXT,
|
||||
contact MEDIUMTEXT
|
||||
);
|
||||
|
||||
GRANT ALL PRIVILEGES ON librarian.bookData TO 'admin';
|
||||
FLUSH PRIVILEGES;
|
7
middleware/.example.env
Normal file
7
middleware/.example.env
Normal file
|
@ -0,0 +1,7 @@
|
|||
PORT=3001
|
||||
ADMIN_KEY=AdminPassword
|
||||
ADMIN_DB_USER=admin
|
||||
ADMIN_DB_PW=AdminPassword
|
||||
DB_HOST="localhost"
|
||||
DB_PORT=3306
|
||||
DB_CONNECTION_LIMIT=10
|
396
middleware/index.ts
Normal file
396
middleware/index.ts
Normal file
|
@ -0,0 +1,396 @@
|
|||
import express, { Request, Response, Application } from "express";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
import { createPool } from "mariadb";
|
||||
import { Book } from "./types/Book";
|
||||
import {
|
||||
checkoutBook,
|
||||
findBook,
|
||||
getBook,
|
||||
listBooks,
|
||||
createBook,
|
||||
deleteBook,
|
||||
editBook,
|
||||
returnBook,
|
||||
} from "./queries";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import path from "path";
|
||||
import session from "express-session";
|
||||
import { censorBookData } from "./utils/censorBookData";
|
||||
import * as fs from "fs";
|
||||
import { checkForThreads } from "./utils/passesSQLInjectionCheck";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app: Application = express();
|
||||
const port = process.env.PORT || 8000;
|
||||
let adminKey = process.env.ADMIN_KEY;
|
||||
|
||||
const pool = createPool({
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306,
|
||||
user: process.env.ADMIN_DB_USER ?? "admin",
|
||||
password: process.env.ADMIN_DB_PW ?? "AdminPassword",
|
||||
database: "librarian",
|
||||
connectionLimit: process.env.DB_CONNECTION_LIMIT
|
||||
? Number(process.env.DB_CONNECTION_LIMIT)
|
||||
: 10,
|
||||
});
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.use(
|
||||
session({
|
||||
secret: adminKey ?? "superSecretKey",
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
})
|
||||
);
|
||||
|
||||
const isAuthenticated = async (
|
||||
req: Request<any, any, any, any>,
|
||||
res: Response,
|
||||
next: () => Promise<void>
|
||||
) => {
|
||||
if (!req.session.authenticated) {
|
||||
res.status(403).send("unauthorized");
|
||||
} else {
|
||||
await next();
|
||||
}
|
||||
};
|
||||
|
||||
const stringifyResponse = (obj: unknown) =>
|
||||
JSON.stringify(obj, (_, v) => (typeof v === "bigint" ? v.toString() : v));
|
||||
|
||||
app.use(express.static(path.join(__dirname, "frontend")));
|
||||
|
||||
app.get("/", function (req, res) {
|
||||
res.sendFile(path.join(__dirname, "frontend", "index.html"));
|
||||
});
|
||||
|
||||
app.get("/books", function (req, res) {
|
||||
res.sendFile(path.join(__dirname, "frontend", "index.html"));
|
||||
});
|
||||
|
||||
app.get("/books/*", function (req, res) {
|
||||
res.sendFile(path.join(__dirname, "frontend", "index.html"));
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/authenticate",
|
||||
async (
|
||||
req: Request<undefined, undefined, { password: string }>,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
if (req.body.password === adminKey || req.session.authenticated) {
|
||||
req.session.authenticated = true;
|
||||
res.status(200).send("ok");
|
||||
} else {
|
||||
res.status(403).send("unauthorized");
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).send("Internal Server Error: " + error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/updateAdminKey",
|
||||
async (
|
||||
req: Request<
|
||||
undefined,
|
||||
undefined,
|
||||
{ oldPassword: string; newPassword: string }
|
||||
>,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
if (req.body.oldPassword === adminKey) {
|
||||
if (req.body.newPassword.length < 13) {
|
||||
res
|
||||
.status(400)
|
||||
.send("Admin key must contain at least 13 charakters.");
|
||||
return;
|
||||
}
|
||||
fs.readFile("./.env", "utf8", (err, file) => {
|
||||
if (err) return console.log(err);
|
||||
const replacedPw = file.replace(
|
||||
/ADMIN_KEY=.*/g,
|
||||
`ADMIN_KEY=${req.body.newPassword}`
|
||||
);
|
||||
fs.writeFile("./.env", replacedPw, "utf8", function (err) {
|
||||
if (err) return console.log(err);
|
||||
});
|
||||
});
|
||||
adminKey = req.body.newPassword;
|
||||
res.status(200).send("ok");
|
||||
} else {
|
||||
res.status(403).send("unauthorized");
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).send("Internal Server Error: " + error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/books/create",
|
||||
async (
|
||||
req: Request<
|
||||
undefined,
|
||||
undefined,
|
||||
Pick<Book, "isbn" | "title" | "shelf" | "published">
|
||||
>,
|
||||
res: Response
|
||||
) => {
|
||||
isAuthenticated(req, res, async () => {
|
||||
const { title, isbn, shelf, published } = req.body;
|
||||
try {
|
||||
const containsThread = checkForThreads(
|
||||
[title, isbn, shelf, published],
|
||||
res
|
||||
);
|
||||
if (containsThread) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
const uuid = uuidv4();
|
||||
await conn.query(createBook({ ...req.body, uuid }));
|
||||
const createdBook = await conn.query(getBook({ uuid }));
|
||||
await conn.end();
|
||||
res.status(200).send(stringifyResponse(createdBook));
|
||||
} catch (error) {
|
||||
res.status(500).send("Internal Server Error: " + error);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/books/delete",
|
||||
async (
|
||||
req: Request<undefined, undefined, Pick<Book, "uuid">>,
|
||||
res: Response
|
||||
) => {
|
||||
await isAuthenticated(req, res, async () => {
|
||||
try {
|
||||
const containsThread = checkForThreads([req.body.uuid], res);
|
||||
if (containsThread) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
const foundBooks = await conn.query(getBook(req.body));
|
||||
|
||||
if (!foundBooks.length) {
|
||||
await conn.end();
|
||||
res.status(404).send("Book not found");
|
||||
} else {
|
||||
await conn.query(deleteBook(req.body));
|
||||
await conn.end();
|
||||
res.status(200).send("Book deleted");
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).send("Internal Server Error: " + error);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/books/edit",
|
||||
async (
|
||||
req: Request<
|
||||
undefined,
|
||||
undefined,
|
||||
Pick<Book, "uuid" | "isbn" | "title" | "published" | "shelf">
|
||||
>,
|
||||
res: Response
|
||||
) => {
|
||||
await isAuthenticated(req, res, async () => {
|
||||
const { uuid, title, isbn, shelf, published } = req.body;
|
||||
|
||||
try {
|
||||
const containsThread = checkForThreads(
|
||||
[uuid, title, isbn, shelf, published],
|
||||
res
|
||||
);
|
||||
if (containsThread) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
const foundBooks = await conn.query(getBook(req.body));
|
||||
|
||||
if (!foundBooks.length) {
|
||||
await conn.end();
|
||||
res.status(404).send("Book not found");
|
||||
} else {
|
||||
await conn.query(editBook(req.body));
|
||||
await conn.end();
|
||||
res.status(200).send("Book moved to shelf");
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).send("Internal Server Error: " + error);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/books/checkout",
|
||||
async (
|
||||
req: Request<
|
||||
undefined,
|
||||
undefined,
|
||||
Pick<Book, "uuid" | "checkoutBy" | "contact">
|
||||
>,
|
||||
res: Response<string>
|
||||
) => {
|
||||
const { uuid, checkoutBy, contact } = req.body;
|
||||
|
||||
try {
|
||||
const containsThread = checkForThreads([uuid, checkoutBy, contact], res);
|
||||
if (containsThread) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
const foundBooks = await conn.query(getBook(req.body));
|
||||
|
||||
if (!foundBooks.length) {
|
||||
await conn.end();
|
||||
res.status(404).send(foundBooks);
|
||||
} else {
|
||||
await conn.query(
|
||||
checkoutBook({ ...req.body, lastCheckoutDate: Date.now() })
|
||||
);
|
||||
await conn.end();
|
||||
res.status(200).send("Book checked out");
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).send("Internal Server Error: " + error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/books/find",
|
||||
async (
|
||||
req: Request<
|
||||
undefined,
|
||||
undefined,
|
||||
Pick<Partial<Book>, "isbn" | "title" | "uuid">
|
||||
>,
|
||||
res: Response<Book[] | string>
|
||||
) => {
|
||||
const { uuid, title, isbn } = req.body;
|
||||
|
||||
try {
|
||||
const containsThread = checkForThreads([uuid, title, isbn], res);
|
||||
if (containsThread) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isbn && !title && !uuid) {
|
||||
res.status(400).send("No isbn or title provided");
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
const foundBooks = await conn.query(findBook(req.body));
|
||||
await conn.end();
|
||||
|
||||
if (!foundBooks.length) {
|
||||
res.status(404).send("Book not found");
|
||||
return;
|
||||
}
|
||||
|
||||
res
|
||||
.status(200)
|
||||
.send(
|
||||
stringifyResponse(
|
||||
censorBookData(foundBooks, !!req.session.authenticated)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(500).send("Internal Server Error: " + error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/books/list",
|
||||
async (
|
||||
req: Request<undefined, undefined, null>,
|
||||
res: Response<Book[] | string>
|
||||
) => {
|
||||
try {
|
||||
const conn = await pool.getConnection();
|
||||
const foundBooks = await conn.query(listBooks);
|
||||
await conn.end();
|
||||
res
|
||||
.status(200)
|
||||
.send(
|
||||
stringifyResponse(
|
||||
censorBookData(foundBooks, !!req.session.authenticated)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
res.status(500).send("Internal Server Error: " + error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/books/return",
|
||||
async (
|
||||
req: Request<
|
||||
undefined,
|
||||
undefined,
|
||||
Pick<Book, "uuid" | "checkoutBy" | "contact">
|
||||
>,
|
||||
res: Response
|
||||
) => {
|
||||
const { uuid, checkoutBy, contact } = req.body;
|
||||
|
||||
try {
|
||||
const containsThread = checkForThreads([uuid, checkoutBy, contact], res);
|
||||
if (containsThread) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
const foundBooks = await conn.query<Book[]>(getBook(req.body));
|
||||
|
||||
if (!foundBooks.length) {
|
||||
await conn.end();
|
||||
res.status(404).send("Book not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!req.session.authenticated &&
|
||||
!(
|
||||
foundBooks[0].checkoutBy === req.body.checkoutBy &&
|
||||
foundBooks[0].contact === req.body.contact
|
||||
)
|
||||
) {
|
||||
res.status(403).send("unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
await conn.query(returnBook(req.body));
|
||||
await conn.end();
|
||||
res.status(200).send("Book returned");
|
||||
} catch (error) {
|
||||
res.status(500).send("Internal Server Error: " + error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.listen(port, async () => {
|
||||
console.log(`Server listening on port ${port}`);
|
||||
});
|
1837
middleware/package-lock.json
generated
Normal file
1837
middleware/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
middleware/package.json
Normal file
28
middleware/package.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "librarian-middleware",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "npx tsc",
|
||||
"start": "node dist/index.ts",
|
||||
"dev": "nodemon index.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.0",
|
||||
"mariadb": "^3.3.1",
|
||||
"nodemon": "^3.1.4",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.5.4",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
}
|
9
middleware/queries/checkoutBook.ts
Normal file
9
middleware/queries/checkoutBook.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Book } from "../types/Book";
|
||||
|
||||
export const checkoutBook = ({
|
||||
checkoutBy,
|
||||
uuid,
|
||||
contact,
|
||||
lastCheckoutDate,
|
||||
}: Pick<Book, "uuid" | "lastCheckoutDate" | "checkoutBy" | "contact">) =>
|
||||
`UPDATE bookData SET lastCheckoutDate='${lastCheckoutDate}', checkoutBy='${checkoutBy}', contact='${contact}' WHERE uuid='${uuid}';`;
|
10
middleware/queries/createBook.ts
Normal file
10
middleware/queries/createBook.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Book } from "../types/Book";
|
||||
|
||||
export const createBook = ({
|
||||
uuid,
|
||||
title,
|
||||
isbn,
|
||||
shelf,
|
||||
published,
|
||||
}: Pick<Book, "uuid" | "title" | "isbn" | "shelf" | "published">) =>
|
||||
`INSERT INTO bookData SET uuid = '${uuid}', title = '${title}', isbn = '${isbn}', shelf = '${shelf}', published = '${published}';`;
|
4
middleware/queries/deleteBook.ts
Normal file
4
middleware/queries/deleteBook.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { Book } from "../types/Book";
|
||||
|
||||
export const deleteBook = ({ uuid }: Pick<Book, "uuid">) =>
|
||||
`DELETE FROM bookData WHERE uuid='${uuid}';`;
|
10
middleware/queries/editBook.ts
Normal file
10
middleware/queries/editBook.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Book } from "../types/Book";
|
||||
|
||||
export const editBook = ({
|
||||
uuid,
|
||||
isbn,
|
||||
title,
|
||||
published,
|
||||
shelf,
|
||||
}: Pick<Book, "uuid" | "isbn" | "title" | "published" | "shelf">) =>
|
||||
`UPDATE bookData SET shelf='${shelf}', isbn='${isbn}', title='${title}', published='${published}' WHERE uuid='${uuid}';`;
|
14
middleware/queries/findBook.ts
Normal file
14
middleware/queries/findBook.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Book } from "../types/Book";
|
||||
import { getBook } from "./getBook";
|
||||
|
||||
export const findBook = ({
|
||||
uuid,
|
||||
isbn,
|
||||
title,
|
||||
}: Pick<Partial<Book>, "uuid" | "title" | "isbn">) => {
|
||||
return !!uuid
|
||||
? getBook({ uuid })
|
||||
: title
|
||||
? `SELECT * FROM bookData WHERE title LIKE '%${title.toLowerCase()}%';`
|
||||
: `SELECT * FROM bookData WHERE isbn LIKE '%${isbn}%';`;
|
||||
};
|
7
middleware/queries/getBook.ts
Normal file
7
middleware/queries/getBook.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { Book } from "../types/Book";
|
||||
|
||||
/**
|
||||
* sql query to find a book by uuid
|
||||
*/
|
||||
export const getBook = ({ uuid }: Pick<Book, "uuid">) =>
|
||||
`SELECT * FROM bookData WHERE uuid = '${uuid}';`;
|
8
middleware/queries/index.ts
Normal file
8
middleware/queries/index.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export { returnBook } from "./returnBook";
|
||||
export { getBook } from "./getBook";
|
||||
export { checkoutBook } from "./checkoutBook";
|
||||
export { listBooks } from "./listBooks";
|
||||
export { findBook } from "./findBook";
|
||||
export { createBook } from "./createBook";
|
||||
export { deleteBook } from "./deleteBook";
|
||||
export { editBook } from "./editBook";
|
1
middleware/queries/listBooks.ts
Normal file
1
middleware/queries/listBooks.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const listBooks = `SELECT * FROM bookData ORDER BY title`;
|
4
middleware/queries/returnBook.ts
Normal file
4
middleware/queries/returnBook.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { Book } from "../types/Book";
|
||||
|
||||
export const returnBook = ({ uuid }: Pick<Book, "uuid">) =>
|
||||
`UPDATE bookData SET checkoutBy = NULL, contact = NULL WHERE uuid='${uuid}';`;
|
112
middleware/tsconfig.json
Normal file
112
middleware/tsconfig.json
Normal file
|
@ -0,0 +1,112 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs" /* Specify what module code is generated. */,
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
"typeRoots": [
|
||||
"./typing-stubs",
|
||||
"./node_modules/@types"
|
||||
] /* Specify multiple folders that act like './node_modules/@types'. */,
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
11
middleware/types/Book.ts
Normal file
11
middleware/types/Book.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export type Book = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
isbn: string;
|
||||
title: string;
|
||||
published: number;
|
||||
lastCheckoutDate: number | null;
|
||||
checkoutBy: string | null;
|
||||
contact: string | null;
|
||||
shelf: "AVAILABLE" | "FNORD1" | "FNORD2" | "SHARING" | null;
|
||||
};
|
7
middleware/typing-stubs/expres-session/index.d.ts
vendored
Normal file
7
middleware/typing-stubs/expres-session/index.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
import session from "express-session";
|
||||
|
||||
declare module "express-session" {
|
||||
export interface SessionData {
|
||||
authenticated: boolean;
|
||||
}
|
||||
}
|
26
middleware/utils/censorBookData.ts
Normal file
26
middleware/utils/censorBookData.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { Book } from "../types/Book";
|
||||
|
||||
const censorExceptFirstLast = (data: string) => {
|
||||
const regex = /(?<!^).(?!$)/g;
|
||||
return data.replace(regex, "*");
|
||||
};
|
||||
|
||||
const censorContactInfo = (data: string) => {
|
||||
if (data.includes("@")) {
|
||||
const regex = /(\w{1})[\w.]+([\w.])+@([\w.]+\w)/;
|
||||
return data.replace(regex, "$1**$2@$3");
|
||||
} else {
|
||||
return censorExceptFirstLast(data);
|
||||
}
|
||||
};
|
||||
|
||||
export const censorBookData = (books: Book[], isAdmin: boolean): Book[] =>
|
||||
isAdmin
|
||||
? books
|
||||
: books.map((book) => ({
|
||||
...book,
|
||||
checkoutBy: !book.checkoutBy
|
||||
? book.checkoutBy
|
||||
: censorExceptFirstLast(book.checkoutBy),
|
||||
contact: !book.contact ? book.contact : censorContactInfo(book.contact),
|
||||
}));
|
14
middleware/utils/passesSQLInjectionCheck.ts
Normal file
14
middleware/utils/passesSQLInjectionCheck.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Response } from "express";
|
||||
|
||||
export const passesSQLInjectionCheck = (input: string): boolean =>
|
||||
!input.includes("'");
|
||||
|
||||
export const checkForThreads = (items: unknown[], res: Response) => {
|
||||
const containsThread = items
|
||||
.map((el) => !passesSQLInjectionCheck("" + el))
|
||||
.find((el) => el);
|
||||
if (containsThread) {
|
||||
res.status(400).send("Input may not not include single quotes.");
|
||||
return containsThread;
|
||||
}
|
||||
};
|
148
versioned_docker_build.sh
Executable file
148
versioned_docker_build.sh
Executable file
|
@ -0,0 +1,148 @@
|
|||
VERSION_PATH="VERSION"
|
||||
DOCKERFILE_PATH="."
|
||||
LATEST=false
|
||||
|
||||
print_usage() {
|
||||
echo -e "Usage: generateDockerImage.sh [options]
|
||||
|
||||
options:
|
||||
-n, --name* name of the docker image
|
||||
-f, --file \e[3mpath/to/DOCKERFILE\e[0m (e.g. "./src")
|
||||
-m, --major sets the major version number, will overwrite version specified in VERSION file, resets the build version to 0
|
||||
-i, --minor sets the minor version number, will overwrite version specified in VERSION file, resets the build version to 0
|
||||
-v, --version \e[3mpath/to/VERSION\e[0m file (e.g. "./src")
|
||||
-l, --latest ignores all versioning and sets docker tag to latest
|
||||
-t, --tag ignores all versioning and sets docker tag to value
|
||||
-h, --help prints this information
|
||||
|
||||
* = required"
|
||||
exit 0
|
||||
}
|
||||
|
||||
while test $# -gt 0; do
|
||||
case "$1" in
|
||||
-n|--name)
|
||||
shift
|
||||
if test $# -gt 0
|
||||
then
|
||||
export NAME=$1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
-m|--major)
|
||||
shift
|
||||
if test $# -gt 0;
|
||||
then
|
||||
export MAJOR=$1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
-i|--minor)
|
||||
shift
|
||||
if test $# -gt 0;
|
||||
then
|
||||
export MINOR=$1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
-f|--file)
|
||||
shift
|
||||
if test $# -gt 0;
|
||||
then
|
||||
DOCKERFILE_PATH=$1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
-v|--version)
|
||||
shift
|
||||
if test $# -gt 0;
|
||||
then
|
||||
VERSION_PATH=$1"/VERSION"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
-l|--latest)
|
||||
shift
|
||||
LATEST=true
|
||||
shift
|
||||
;;
|
||||
-t|--tag)
|
||||
shift
|
||||
if test $# -gt 0;
|
||||
then
|
||||
export TAG=$1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
-h|--help) print_usage ;;
|
||||
*) print_usage
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $NAME ]]
|
||||
then
|
||||
echo -e "missing image name; use -n \e[3mname\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if $LATEST
|
||||
then
|
||||
echo "build -t ${NAME}:"latest" $DOCKERFILE_PATH"
|
||||
docker build -t ${NAME}:"latest" $DOCKERFILE_PATH
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! [[ -z $TAG ]]
|
||||
then
|
||||
echo "docker build -t "${NAME}:${TAG}" $DOCKERFILE_PATH"
|
||||
docker build -t ${NAME}:${TAG} $DOCKERFILE_PATH
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f ${VERSION_PATH} ];
|
||||
then
|
||||
VERSION=$(<${VERSION_PATH})
|
||||
IFS='.' read -ra NEWVERSION <<< "$VERSION"
|
||||
NEWVERSION[2]=$(($((${NEWVERSION[2]}))+1))
|
||||
if [[ -z "$MAJOR" ]]
|
||||
then
|
||||
if [[ -z "$MINOR" ]]
|
||||
then
|
||||
echo "${NEWVERSION[0]}.${NEWVERSION[1]}.${NEWVERSION[2]}">$VERSION_PATH
|
||||
else
|
||||
echo "${NEWVERSION[0]}.${MINOR}.0">$VERSION_PATH
|
||||
fi
|
||||
else
|
||||
if [[ -z "$MINOR" ]]
|
||||
then
|
||||
echo "${MAJOR}.${NEWVERSION[1]}.0">$VERSION_PATH
|
||||
else
|
||||
echo "${MAJOR}.${MINOR}.0">$VERSION_PATH
|
||||
fi
|
||||
|
||||
fi
|
||||
else
|
||||
if [[ -z "$MAJOR" ]]
|
||||
then
|
||||
if [[ -z "$MINOR" ]]
|
||||
then
|
||||
echo "0.0.0">$VERSION_PATH
|
||||
else
|
||||
echo "0.${MINOR}.0">$VERSION_PATH
|
||||
fi
|
||||
else
|
||||
if [[ -z "$MINOR" ]]
|
||||
then
|
||||
echo "${MAJOR}.0.0">$VERSION_PATH
|
||||
else
|
||||
echo "${MAJOR}.${MINOR}.0">$VERSION_PATH
|
||||
fi
|
||||
|
||||
fi
|
||||
fi
|
||||
|
||||
VERSION=$(<${VERSION_PATH})
|
||||
echo "docker build -t ${NAME}:${VERSION} $DOCKERFILE_PATH"
|
||||
docker build -t ${NAME}:${VERSION} $DOCKERFILE_PATH
|
Loading…
Reference in a new issue