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