move to git.n39.euall books in the bag ...

This commit is contained in:
0ry5 2024-09-09 20:41:15 +02:00
commit 8cc2662092
64 changed files with 134252 additions and 0 deletions

17
Dockerfile Normal file
View 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
View file

@ -0,0 +1 @@
0.0.12

112250
backup.sql Normal file

File diff suppressed because one or more lines are too long

1
createBackup.sh Normal file
View file

@ -0,0 +1 @@
docker exec mariadb-mariadb-1 mariadb-dump --all-databases -uroot -p"RootPassword" >> backup.sql

27
docker-compose.yml Normal file
View 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

File diff suppressed because it is too large Load diff

54
frontend/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View 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"
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

44
frontend/src/App.css Normal file
View 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;
}

View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

1
frontend/src/logo.svg Normal file
View 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

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

View file

@ -0,0 +1 @@
export { Book } from "./Book";

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

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

View file

@ -0,0 +1 @@
export { Library } from "./Library";

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

View file

@ -0,0 +1 @@
export { Main } from "./Main";

View file

@ -0,0 +1 @@
export * from "./Main";

1
frontend/src/react-app-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

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

View 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';

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

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

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

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

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

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

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

View 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">;

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

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

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

28
middleware/package.json Normal file
View 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"
}
}

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

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

View file

@ -0,0 +1,4 @@
import { Book } from "../types/Book";
export const deleteBook = ({ uuid }: Pick<Book, "uuid">) =>
`DELETE FROM bookData WHERE uuid='${uuid}';`;

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

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

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

View 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";

View file

@ -0,0 +1 @@
export const listBooks = `SELECT * FROM bookData ORDER BY title`;

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

View file

@ -0,0 +1,7 @@
import session from "express-session";
declare module "express-session" {
export interface SessionData {
authenticated: boolean;
}
}

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

View 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
View 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