Compare commits

...

50 Commits
v0.1.0 ... main

Author SHA1 Message Date
33f2c5cf07
Deployed to vercel 2023-09-18 15:38:40 +03:00
588e09ac00 Update README.md 2023-09-17 16:44:53 +03:00
e840ac4254
Added zip fb2 file suggestion
Updated docker build instructions and args
2023-09-17 16:42:44 +03:00
c6be8cfa8c
Added screenshots to readme 2023-09-17 14:56:45 +03:00
53ebf925aa Finally fixed envs 2022-12-15 23:59:52 +03:00
b92bd7656c Removed hardcoded env 2022-12-12 09:25:56 +03:00
193a959e65 Updated container port setting 2022-10-01 11:28:24 +03:00
aa8765ddc5 Updated packages 2022-10-01 10:57:55 +03:00
03edfdeb7a Fixed catched errors type handling 2022-10-01 10:47:43 +03:00
86b25166ff converted screenshots to webp format 2022-10-01 10:46:57 +03:00
fae03cf5b6
Added manifest and favicon to precache 2021-08-09 18:37:43 +03:00
49ee864762
Updated favicon 2021-08-09 18:37:10 +03:00
182b8907ee
Merge pull request #2 from publite/pwa
Hot fixes
2021-08-09 17:50:46 +03:00
6d21ddf2e3
Switched to square favicon 2021-08-09 17:49:50 +03:00
6458148145
Changed todo heading level in readme 2021-08-09 17:47:05 +03:00
211d1a4a96
Merge branch 'pwa' of github.com:publite/frontend into pwa 2021-08-09 17:46:16 +03:00
be978cec32
Fixed version constants and prod API url 2021-08-09 17:46:02 +03:00
9fa8734c09
Merge pull request #1 from publite/pwa
Pwa
2021-08-09 16:24:57 +03:00
7447476bbc
Update README.md 2021-08-09 16:14:14 +03:00
f00fd2ae22
Fixed manifest errors 2021-08-09 15:44:18 +03:00
9979a263ed
Merge branch 'pwa' of github.com:publite/frontend into pwa 2021-08-09 15:36:27 +03:00
599342f78b
Added manifest, icons and screenshots 2021-08-09 15:35:03 +03:00
fbfdc0fdb9
Update README.md 2021-08-09 14:28:46 +03:00
0c1c8ed75c
Update README.md 2021-08-09 14:28:06 +03:00
fa38e0054b
Tried to improve error handling on book upload 2021-08-09 14:05:57 +03:00
4f558b8530
Got rid of book list context. Now book content is served by serviceworker too 2021-08-09 14:05:34 +03:00
09da9a7d60
Merge branch 'pwa' of github.com:publite/frontend into pwa 2021-08-09 10:52:04 +03:00
582e64db0d
Removed useless console.logs, switched errors to console.error 2021-08-09 10:50:43 +03:00
8705feffbc
Book list is now fetched from indexedDB 2021-08-01 20:54:59 +03:00
a1f28d2b60
Book list is now fetched from indexedDB 2021-08-01 14:09:13 +03:00
b6ede385ca
Started react app integration with service worker. Uploading book to server now also saves result to indexedDB 2021-08-01 13:47:52 +03:00
aa98de6bd5
Added book getting by hash in service worker 2021-07-31 22:02:56 +03:00
487b6b91bd
Added book list handler in service worker 2021-07-31 22:00:27 +03:00
d004e9681e
Added /upload handler in service worker 2021-07-31 21:58:45 +03:00
1b7fa0bacf
Added fetch handler in service worker 2021-07-31 21:55:05 +03:00
12ce54e95d
Added IndexedDB 2021-07-31 21:32:34 +03:00
96f7f8462b
Added service worker and site caching 2021-07-31 21:21:45 +03:00
c416354cfa
Switched from snowpack to webpack (Preparing to add serviceworker) 2021-07-31 21:18:16 +03:00
df91e5047c
Merge branch 'main' of github.com:publite/frontend 2021-07-24 21:05:58 +03:00
97b654a433
Added dokku deployment health check 2021-07-24 21:05:31 +03:00
8eb5befb1d
Update README.md 2021-07-24 22:43:21 +05:00
1d326ae69c
Added page navigation with number 2021-07-24 20:42:52 +03:00
b7f5a0eb55
Added pages persistance (ugly, but temporary) 2021-07-24 20:28:47 +03:00
e13f37923d
Merge branch 'main' of github.com:publite/frontend 2021-07-24 19:22:52 +03:00
76311cd4b6
Improved text styles 2021-07-24 18:47:40 +03:00
fd8e58367d
Update TODO 2021-07-23 14:23:46 +05:00
d39511f736
Update README.md 2021-07-18 01:52:28 +05:00
3b88688c93
even more deployment hell 2021-07-17 23:26:08 +03:00
44973de80d
Dokku deployment hell 2021-07-17 23:15:11 +03:00
87139bd358
Changed dockerfile 2021-07-17 22:40:36 +03:00
42 changed files with 635 additions and 161 deletions

View File

@ -1,3 +1,5 @@
node_modules/
package-lock.json
build/
build/
pnpm-lock.yaml
.vscode

5
.gitignore vendored
View File

@ -1,3 +1,6 @@
node_modules/
package-lock.json
build/
build/
pnpm-lock.yaml
.vscode
.vercel

View File

@ -4,13 +4,12 @@ WORKDIR /app
COPY ./package.json ./
RUN npm install
COPY ./ ./
ENV SNOWPACK_PUBLIC_API_URL=https://publitebackend.dmitriy.icu
ENV SNOWPACK_PUBLIC_BASE_URL=https://publite.dmitriy.icu
ARG PUBLIC_API_URL=https://publitebackend.dm1sh.ru
ARG PUBLIC_BASE_URL=https://publite.dm1sh.ru
RUN NODE_ENV=production npm run build
FROM node:alpine
RUN npm install serve -g --silent
WORKDIR /app
COPY --from=builder /app/build .
EXPOSE 5000
CMD ["serve", "-p", "5000", "-s", "."]
CMD serve -p ${PORT:-8080} -s .

View File

@ -8,6 +8,20 @@
Frontend for Publite service — E-Books reader
<table style="margin: auto">
<tr>
<td>
<img style="max-height: 50vh" src="public/images/screenshot3.webp"/>
</td>
<td>
<img style="max-height: 50vh" src="public/images/screenshot2.webp"/>
</td>
<td>
<img style="max-height: 50vh" src="public/images/screenshot1.webp"/>
</td>
</tr>
</table>
## Deploy
Dev environment setup:
@ -27,10 +41,10 @@ Simple docker deployment
```bash
# build docker image
docker build . -t publite_frontend
docker build . --build-arg PUBLIC_API_URL=<https://...> --build-arg PUBLIC_BASE_URL=<https://...> -t publite_frontend
# run it with docker
docker run -p <port>:5000 publite_frontend
docker run -p <port>:8080 publite_frontend
```
Dokku deployment with image from Docker Hub
@ -41,7 +55,8 @@ dokku apps:create publitefrontend
dokku git:from-image publitefrontend publite/frontend:latest
```
# TODO
## TODO
- Migrate pagination cache and book state from LocalStorage to IndexedDB
- Add menu with book view setting
- Optimize page spliting algorythm (rewrite it)
- Fix css modules bundling

View File

@ -1,20 +1,27 @@
{
"private": true,
"scripts": {
"dev": "SNOWPACK_PUBLIC_API_URL=http://localhost:8081 SNOWPACK_PUBLIC_BASE_URL=http://localhost:8080 snowpack dev",
"build": "snowpack build",
"test": "echo \"Error: no test specified\" && exit 1"
"build": "webpack --config ./webpack.config.prod.js",
"dev": "webpack serve --config ./webpack.config.dev.js --stats-error-details",
"test": "echo \"Error: no test specified\" && exit 1",
"h": "cat ./webpack.config.dev.js"
},
"devDependencies": {
"@snowpack/plugin-typescript": "^1.2.1",
"@types/node": "^16.3.3",
"@types/react": "^17.0.14",
"@svgr/webpack": "^6.3.1",
"@types/react": "^17.0.15",
"@types/react-dom": "^17.0.9",
"@types/snowpack-env": "^2.3.3",
"snowpack": "^3.8.0",
"snowpack-plugin-svgr": "^0.1.2",
"typescript": "^4.3.5"
"copy-webpack-plugin": "^9.0.1",
"css-loader": "^6.2.0",
"esbuild-loader": "^2.13.1",
"fork-ts-checker-webpack-plugin": "^6.2.13",
"style-loader": "^3.2.1",
"typescript": "^4.3.5",
"webpack": "^5.46.0",
"webpack-cli": "^4.7.2",
"webpack-dev-server": "^4.11.1"
},
"dependencies": {
"idb": "^6.1.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"wouter": "^2.7.4"

1
public/CHECKS Normal file
View File

@ -0,0 +1 @@
/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 66 KiB

BIN
public/images/icons-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/images/icons-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/images/icons-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -3,12 +3,15 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff">
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<link rel="manifest" href="manifest.json" />
<link rel="apple-touch-icon" href="/images/icons-512.png">
<title>Publite</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/dist/index.js"></script>
<script type="module" src="/index.js"></script>
</body>
</html>

47
public/manifest.json Normal file
View File

@ -0,0 +1,47 @@
{
"short_name": "Publite",
"name": "Publite: eBook reader",
"icons": [
{
"src": "images/icons-128.png",
"type": "image/png",
"sizes": "128x128",
"purpose": "any maskable"
},
{
"src": "/images/icons-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "/images/icons-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
],
"start_url": "/",
"background_color": "#ffffff",
"display": "standalone",
"scope": "/",
"theme_color": "#000000",
"description": "eBook reader supporting EPUB and FB2 files",
"screenshots": [
{
"src": "/images/screenshot1.webp",
"type": "image/webp",
"sizes": "1080x2400"
},
{
"src": "/images/screenshot2.webp",
"type": "image/webp",
"sizes": "1080x2400"
},
{
"src": "/images/screenshot3.webp",
"type": "image/webp",
"sizes": "1080x2400"
}
]
}

View File

@ -1,28 +0,0 @@
// Snowpack Configuration File
// See all supported options: https://www.snowpack.dev/reference/configuration
/** @type {import("snowpack").SnowpackUserConfig } */
module.exports = {
plugins: ["@snowpack/plugin-typescript", "snowpack-plugin-svgr"],
packageOptions: {
polyfillNode: true,
},
mount: {
public: "/",
src: "/dist",
},
optimize: {
// bundle: true,
},
routes: [
{ match: "routes", src: "robots.txt", dest: "/robots.txt" },
{ match: "routes", src: ".*", dest: "/index.html" },
],
devOptions: {
open: "none",
},
alias: {
"~": "./src",
},
exclude: ["**/node_modules/**/*", "**/*.test.*"],
};

View File

@ -1,6 +1,5 @@
import React, { useState } from "react";
import { Route, Switch } from "wouter";
import { BookListContextProvider } from "~/context";
import { Bookshelf } from "~/pages/Bookshelf";
import { BookView } from "~/pages/BookView";
@ -24,19 +23,17 @@ export const App = () => {
</div>
)}
<Navbar />
<BookListContextProvider>
<Switch>
<Route path="/upload">
<UploadForm setLoading={setLoading} loading={loading} />
</Route>
<Route path="/">
<Bookshelf setLoading={setLoading} loading={loading} />
</Route>
<Route path="/:hash">
<BookView setLoading={setLoading} loading={loading} />
</Route>
</Switch>
</BookListContextProvider>
<Switch>
<Route path="/upload">
<UploadForm setLoading={setLoading} loading={loading} />
</Route>
<Route path="/">
<Bookshelf setLoading={setLoading} loading={loading} />
</Route>
<Route path="/:hash">
<BookView setLoading={setLoading} loading={loading} />
</Route>
</Switch>
</div>
);
};

View File

@ -1,2 +1,5 @@
export const API_URL = import.meta.env.SNOWPACK_PUBLIC_API_URL;
export const BASE_URL = import.meta.env.SNOWPACK_PUBLIC_BASE_URL;
export const API_URL = process.env.PUBLIC_API_URL || "";
export const BASE_URL = process.env.PUBLIC_BASE_URL || "";
export const CACHE = "v1.1.1";
export const DB_NAME = "publite";
export const DB_VERSION = 111;

View File

@ -1,19 +0,0 @@
import React from "react";
import { useLibrary, UseLibraryReturnTuple } from "./hooks/useLibrary";
import { IBook } from "./types/book";
export const BookListContext = React.createContext<UseLibraryReturnTuple>([
{},
(book: IBook) => {},
[],
]);
export const BookListContextProvider: React.FC = ({ children }) => {
const library = useLibrary();
return (
<BookListContext.Provider value={library}>
{children}
</BookListContext.Provider>
);
};

44
src/hooks/useBookState.ts Normal file
View File

@ -0,0 +1,44 @@
import React, { useEffect, useState } from "react";
import { BookState } from "~/types/book";
import { loadBookState, saveBookState } from "~/utils/localStorage";
export const useBookState = (
pagesReady: boolean,
hash: string | undefined,
goToPage: (pageNum: number) => void,
currentPage: React.RefObject<number>
): [boolean, BookState | undefined] => {
const [state, setState] = useState<BookState>();
const [ready, setReady] = useState(false);
useEffect(() => {
if (hash)
loadBookState(
hash,
(obj) => setState(obj),
() => setState({ currentPage: 0 })
);
}, [hash]);
useEffect(() => {
if (!ready && state?.currentPage && pagesReady) {
goToPage(state.currentPage);
setReady(true);
} else if (hash && !ready && pagesReady && typeof state === "object") {
saveBookState(hash, { currentPage: 0 });
setReady(true);
}
}, [ready, state, goToPage]);
useEffect(
() => () => {
if (hash) {
if (ready && state) saveBookState(hash, state);
else saveBookState(hash, { currentPage: currentPage.current || 0 });
}
},
[]
);
return [ready, state];
};

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { IBook } from "~/types/book";
import { BookT } from "~/types/book";
import {
getBookHT,
getHashList,
@ -7,16 +7,16 @@ import {
updateHashList,
} from "~/utils/localStorage";
export type AddBookFT = (book: IBook) => void;
export type AddBookFT = (book: BookT) => void;
export type UseLibraryReturnTuple = [
Record<string, IBook> | null,
Record<string, BookT> | null,
AddBookFT,
string[]
];
export const useLibrary = (): UseLibraryReturnTuple => {
const [library, setLibrary] = useState<Record<string, IBook> | null>(null);
const [library, setLibrary] = useState<Record<string, BookT> | null>(null);
const [hashList, setHashList] = useState<string[]>([]);
const addBook: AddBookFT = (book) => {

View File

@ -1,5 +1,6 @@
import React from "react";
import ReactDOM from "react-dom";
import * as ServiceWorker from "./registerServiceWorker";
import "./index.css";
@ -12,6 +13,4 @@ ReactDOM.render(
document.getElementById("root")
);
if (import.meta.hot) {
import.meta.hot.accept();
}
ServiceWorker.register();

View File

@ -31,13 +31,19 @@
.page {
height: 100%;
font-size: 1.25rem;
font-family: serif;
}
img {
.page img {
max-height: 90%;
max-width: 100%;
}
.page p {
margin-bottom: 1rem;
}
.pageIndicator {
position: fixed;
z-index: 1;
@ -50,6 +56,10 @@ img {
justify-content: center;
}
.pageNumber {
cursor: pointer;
}
.pageSwitchArrow {
background: none;
border: none;

View File

@ -1,28 +1,52 @@
import React, { useContext, useEffect, useRef } from "react";
import React, { MouseEventHandler, useState, useEffect, useRef } from "react";
import { Redirect, useRoute } from "wouter";
import styles from "./BookView.module.css";
import { BookListContext } from "~/context";
import { usePagination } from "~/hooks/usePagination";
import { IPageProps } from "~/types/page";
import { useBookState } from "~/hooks/useBookState";
import { BookT } from "~/types/book";
import { API_URL } from "~/constants";
import { validateResponse } from "~/utils/api";
export const BookView = ({ setLoading, loading }: IPageProps) => {
useEffect(() => setLoading(true), []);
const [hasErr, setHasErr] = useState(false);
const [book, setBook] = useState<BookT>();
const [_, params] = useRoute("/:hash");
const [books] = useContext(BookListContext);
const contentRef = useRef<HTMLDivElement>(null);
const pageContainerRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const [ready, goToPage, currentPage, pagesNumber] = usePagination(
useEffect(() => {
if (params?.hash) {
(async () => {
try {
const res = await fetch(API_URL + "/book/" + params.hash);
if (!res.ok) throw new Error(res.status + " " + res.statusText);
const book: unknown = await res.json();
if (validateResponse(book)) setBook(book);
} catch (err) {
if (process.env.NODE_ENV === "development") console.error(err);
setHasErr(true);
setLoading(false);
}
})();
}
}, []);
const [pagesReady, goToPage, currentPage, pagesNumber] = usePagination(
contentRef,
pageContainerRef,
pageRef,
books && loading ? params?.hash : undefined,
params?.hash && books && loading ? books[params.hash]?.content : undefined
book ? params?.hash : undefined,
params?.hash ? book?.content : undefined
);
const currentPageRef = useRef(currentPage);
@ -31,8 +55,15 @@ export const BookView = ({ setLoading, loading }: IPageProps) => {
currentPageRef.current = currentPage;
}, [currentPage]);
const [bookStateReady, bs] = useBookState(
pagesReady,
params?.hash,
goToPage,
currentPageRef
);
useEffect(() => {
if (ready) {
if (bookStateReady) {
setLoading(false);
const handleKey = ({ key }: KeyboardEvent) => {
@ -48,40 +79,45 @@ export const BookView = ({ setLoading, loading }: IPageProps) => {
window.addEventListener("keydown", handleKey);
}
}, [ready]);
}, [bookStateReady]);
const goPrev = () => goToPage(currentPage - 1);
const goNext = () => goToPage(currentPage + 1);
const insertNumber: MouseEventHandler<HTMLSpanElement> = (e) => {
const str = prompt("Page number");
if (str) {
const n = parseInt(str);
if (!isNaN(n) && n > 0) goToPage(n - 1);
}
};
if (books) {
if (params?.hash && params.hash in books)
return (
<>
<div
className={`${styles.border} ${styles.leftBorder}`}
onClick={goPrev}
/>
<div className={styles.content} ref={contentRef} />
<div className={styles.pageContainer} ref={pageContainerRef}>
<div className={styles.page} ref={pageRef} onClick={goNext} />
</div>
<div
className={`${styles.border} ${styles.rightBorder}`}
onClick={goNext}
/>
<div className={styles.pageIndicator}>
<button className={styles.pageSwitchArrow} onClick={goPrev}>
{currentPage !== 0 && "←"}
</button>
<span>
{currentPage + 1} / {pagesNumber}
</span>
<button className={styles.pageSwitchArrow} onClick={goNext}>
{currentPage !== pagesNumber - 1 && "→"}
</button>
</div>
</>
);
return <Redirect to="/" />;
} else return <></>;
if (hasErr) return <Redirect to="/" />;
return (
<>
<div
className={`${styles.border} ${styles.leftBorder}`}
onClick={goPrev}
/>
<div className={styles.content} ref={contentRef} />
<div className={styles.pageContainer} ref={pageContainerRef}>
<div className={styles.page} ref={pageRef} onClick={goNext} />
</div>
<div
className={`${styles.border} ${styles.rightBorder}`}
onClick={goNext}
/>
<div className={styles.pageIndicator}>
<button className={styles.pageSwitchArrow} onClick={goPrev}>
{currentPage !== 0 && "←"}
</button>
<span className={styles.pageNumber} onClick={insertNumber}>
{currentPage + 1} / {pagesNumber}
</span>
<button className={styles.pageSwitchArrow} onClick={goNext}>
{currentPage !== pagesNumber - 1 && "→"}
</button>
</div>
</>
);
};

View File

@ -2,10 +2,10 @@ import React from "react";
import styles from "./BookItem.module.css";
import { IBook } from "~/types/book";
import { BookT } from "~/types/book";
import { Link } from "wouter";
interface IBookItemProps extends IBook {}
interface IBookItemProps extends BookT {}
export const BookItem = ({ author, title, cover, hash }: IBookItemProps) => {
return (

View File

@ -1,16 +1,29 @@
import React, { useContext, useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import styles from "./Bookshelf.module.css";
import { BookItem } from "./BookItem";
import { AddBook } from "./AddBook";
import { BookListContext } from "~/context";
import { IPageProps } from "~/types/page";
import { API_URL } from "~/constants";
import { BookT } from "~/types/book";
import { connected as swConnected } from "~/utils/serviceFetch";
export const Bookshelf = ({ setLoading }: IPageProps) => {
useEffect(() => setLoading(true), []);
const [books] = useContext(BookListContext);
const [books, setBooks] = useState<BookT[]>([]);
useEffect(() => {
swConnected.then(async () => {
try {
const res = await fetch(API_URL + "/list");
setBooks(await res.json());
} catch (err) {
if (process.env.NODE_ENV === "development") console.error(err);
}
});
}, []);
useEffect(() => {
if (books) setLoading(false);

View File

@ -1,18 +1,15 @@
import React, { useContext, useEffect, useState } from "react";
import React, { useState } from "react";
import { useLocation } from "wouter";
import PlusIcon from "~/assets/plus.svg";
import styles from "./UploadForm.module.css";
import { submitFile, validateResponse, validState } from "~/utils/api";
import { BookListContext } from "~/context";
import { IPageProps } from "~/types/page";
export const UploadForm = ({ setLoading }: IPageProps) => {
const [error, setError] = useState("");
const [_, setLocation] = useLocation();
const [__, saveBook] = useContext(BookListContext);
const processFile = async (file: File | undefined) => {
try {
if (validState(file)) {
@ -22,14 +19,15 @@ export const UploadForm = ({ setLoading }: IPageProps) => {
const res = await submitFile(file);
setLoading(false);
if (validateResponse(res)) {
saveBook(res);
setLocation("/");
}
if (validateResponse(res)) setLocation("/");
}
} catch (err) {
setLoading(false);
setError(err.message);
if (err instanceof Error)
setError(err.message);
else
setError(String(err))
}
};

View File

@ -0,0 +1,16 @@
export const register = () => {
if ("serviceWorker" in navigator) {
window.addEventListener("load", () =>
navigator.serviceWorker
.register("/sw.js", { scope: "" })
.then((registration) => {
if (process.env.NODE_ENV === "development")
console.log(
"Successfully registered ServiceWorker with scope:",
registration.scope
);
})
.catch((err) => console.error(err))
);
}
};

View File

@ -0,0 +1,29 @@
import { CACHE } from "../constants";
const getCache = () => caches.open(CACHE);
/**
* Caches static files for application
*/
export const precache = async () =>
(await getCache()).addAll([
"/",
"/index.js",
"/sw.js",
"/manifest.json",
"/favicon.ico",
]);
/**
* Requests file from network or gets it from cache if offline
*/
export const fromCache = async (request: Request): Promise<Response> => {
try {
const response = await fetch(request);
(await getCache()).put(request, response.clone());
return response;
} catch (err) {
const response = await (await getCache()).match(request);
return response || new Response();
}
};

31
src/serviceWorker/db.ts Normal file
View File

@ -0,0 +1,31 @@
import { openDB as idbOpenDB } from "idb";
import { BookT } from "~/types/book";
import { DB_NAME, DB_VERSION } from "../constants";
import { PubliteDB } from "./schema";
/**
* Opens IndexedDB for interactions
*/
export const openDB = () =>
idbOpenDB<PubliteDB>(DB_NAME, DB_VERSION, {
upgrade: (db, oldVersion, _, tsx) => {
if (oldVersion < 1) db.createObjectStore("Books", { keyPath: "hash" });
},
});
/**
* Saves IBook object in IndexedDB
*/
export const saveBook = async (book: BookT) =>
(await openDB()).add("Books", book);
/**
* Returns all books saved in IndexedDB
*/
export const getBooks = async () => (await openDB()).getAll("Books");
/**
* Gets book from IndexedDB by hash
*/
export const getBook = async (hash: string) =>
(await openDB()).get("Books", hash);

View File

@ -0,0 +1,59 @@
import { getBook, getBooks, saveBook } from "./db";
import { composeResponseStatus } from "./utils";
export interface PathHandler {
/** Path start for handler */
path: string;
/** Function returning Response object */
getResponse: () => Response | Promise<Response>;
}
/**
* Routes fetch request path to specified handler
*/
export const handle = (requestPath: string, table: PathHandler[]) => {
for (const { path, getResponse: response } of table)
if (requestPath.startsWith(path)) return response();
return new Response();
};
/**
* Converts book to html with publiteBackend server.
*
* First fetch handler with network request
*/
export const handleBookUpload = async (request: Request) => {
try {
const res = await fetch(request);
if (res.ok) {
const book = await res.json();
await saveBook(book);
return new Response(JSON.stringify(book));
} else throw new Error(res.status.toString() + res.statusText);
} catch (err) {
return new Response(JSON.stringify(err), composeResponseStatus(err));
}
};
/**
* Gets all books from database
*/
export const handleBooks = async () => {
const list = await getBooks();
return new Response(JSON.stringify(list));
};
/**
* Gets book from database
*/
export const handleBook = async (request: Request, hash: string) => {
const book = await getBook(hash);
if (book) return new Response(JSON.stringify(book));
return new Response(null, {
status: 404,
statusText: "No such book :(",
});
};

View File

@ -0,0 +1,39 @@
import { API_URL } from "~/constants";
import { fromCache, precache } from "./cache";
import { openDB as createDB } from "./db";
import {
handle,
handleBook,
handleBooks,
handleBookUpload,
PathHandler,
} from "./fetchHandlers";
import { getHash } from "./utils";
declare const self: ServiceWorkerGlobalScope;
self.addEventListener("install", (event) => event.waitUntil(precache()));
self.addEventListener("activate", (event) => {
self.clients.claim();
event.waitUntil(createDB());
});
self.addEventListener("fetch", (event) => {
const { request } = event;
const path = new URL(request.url).pathname;
let handlers: PathHandler[];
if (request.url.startsWith(API_URL)) {
handlers = [
{ path: "/list", getResponse: () => handleBooks() },
{ path: "/book/", getResponse: () => handleBook(request, getHash(path)) },
{ path: "/uploadfile", getResponse: () => handleBookUpload(request) },
];
} else {
handlers = [{ path: "", getResponse: () => fromCache(request) }];
}
event.respondWith(handle(path, handlers));
});

View File

@ -0,0 +1,9 @@
import { DBSchema } from "idb";
import { BookT } from "../types/book";
export interface PubliteDB extends DBSchema {
Books: {
key: string;
value: BookT;
};
}

View File

@ -0,0 +1,15 @@
/**
* Gets hash string from book path
*/
export const getHash = (path: string) => {
let hashLength = path.length - "/book/".length;
if (path.endsWith("/")) hashLength--;
return path.substr("/book/".length, hashLength);
};
export const composeResponseStatus = (err: unknown): ResponseInit => {
if (err instanceof Error && err.name === "NetowrkError")
return { status: 503, statusText: err.message };
else return { status: 500, statusText: "Something bad happened (IDK)" };
};

View File

@ -1,9 +1,13 @@
export const requiredBookProps = ["title", "author", "content"] as const;
export const optionalBookProps = ["cover", "hash"] as const;
export type IBook = {
export type BookT = {
[key in typeof requiredBookProps[number]]: string;
} &
{
[key in typeof optionalBookProps[number]]: string | undefined;
};
export type BookState = {
currentPage: number;
};

View File

@ -1,10 +1,15 @@
import { IBook, requiredBookProps } from "~/types/book";
import { BookT, requiredBookProps } from "~/types/book";
import { API_URL } from "~/constants";
export const validState = (file: File | undefined): file is File => {
if (!file) throw new Error("Book file is required. Please, attach one");
if (file.name.endsWith(".zip"))
throw new Error(
"Please, unzip file before sending to reader if it is fb2.zip"
)
if (!file.name.match(/\.(fb2|epub)/))
throw new Error(
"Wrong file type. Only FB2 and Epub files are supported. \
@ -34,18 +39,20 @@ export const submitFile = async (
return await res.json();
} catch (err) {
console.log("Network error:", err.message);
if (err instanceof Error)
console.error("Network error:", err.message);
throw err;
}
};
export const validateResponse = (content: unknown): content is IBook => {
export const validateResponse = (content: unknown): content is BookT => {
if (content && typeof content === "object")
for (const key of requiredBookProps)
if (!(key in content)) {
if (import.meta.env.NODE_ENV === "development")
console.log(`${key} is not specified in server response`);
return false;
if (process.env.NODE_ENV === "development")
console.error(`${key} is not specified in server response`);
throw new Error(`${key} is not specified in server response`);
}
return true;

View File

@ -1,4 +1,4 @@
import { IBook } from "~/types/book";
import { BookState, BookT } from "~/types/book";
import { isArrOfStr } from "~/types/utils";
import { validateResponse } from "~/utils/api";
@ -17,7 +17,7 @@ export const getHashList = () => {
};
export const getBookHT = (hashList: string[]) => {
const bookHT: Record<string, IBook> = {};
const bookHT: Record<string, BookT> = {};
hashList.forEach((hash) => {
try {
@ -31,7 +31,7 @@ export const getBookHT = (hashList: string[]) => {
return bookHT;
};
export const saveBook = (key: string, book: IBook) =>
export const saveBook = (key: string, book: BookT) =>
localStorage.setItem(key, JSON.stringify(book));
export const updateHashList = (hashList: string[]) =>
@ -78,3 +78,37 @@ export const savePages = (
width: number,
pages: number[]
) => localStorage.setItem(hashStr(hash, height, width), JSON.stringify(pages));
export const validateBookState = (obj: unknown): obj is BookState =>
Boolean(
obj &&
typeof obj === "object" &&
!Array.isArray(obj) &&
"currentPage" in obj
);
export const loadBookState = (
hash: string,
cb: (bookState: BookState) => void,
ecb: () => void
) => {
const str = localStorage.getItem(hash + "-state");
if (str) {
try {
const obj: unknown = JSON.parse(str);
if (validateBookState(obj)) {
cb(obj);
return true;
}
} catch (e) {
console.error(e);
}
}
ecb();
};
export const saveBookState = (hash: string, state: BookState) =>
localStorage.setItem(hash + "-state", JSON.stringify(state));

View File

@ -0,0 +1,6 @@
export const connected = new Promise<void>((resolve) => {
if (navigator.serviceWorker.controller) return resolve();
navigator.serviceWorker.addEventListener("controllerchange", (e) =>
resolve()
);
});

View File

@ -1,17 +1,18 @@
{
"compilerOptions": {
"target": "es6",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"lib": ["DOM"],
"lib": ["DOM", "webworker"],
"module": "ES2020",
"moduleResolution": "node",
"resolveJsonModule": true,
"paths": {
"~/*": ["./src/*"]
}
},
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "es6"
}
}

47
webpack.config.common.js Normal file
View File

@ -0,0 +1,47 @@
const path = require("path");
const CopyPlugin = require("copy-webpack-plugin");
const ForkTsCheckerPlugin = require("fork-ts-checker-webpack-plugin");
module.exports = {
entry: {
"index.js": "./src/index.tsx",
"sw.js": "./src/serviceWorker/index.ts",
},
output: {
path: path.resolve(__dirname, `./build/`),
clean: true,
filename: "[name]",
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: "esbuild-loader",
options: {
loader: "tsx",
target: "es2015",
},
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
{
test: /\.svg$/,
use: ["@svgr/webpack"],
},
],
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".json"],
alias: { "~": path.resolve(__dirname, "src/") },
},
plugins: [
new ForkTsCheckerPlugin(),
new CopyPlugin({
patterns: [{ from: "./public", to: "." }],
}),
],
};

27
webpack.config.dev.js Normal file
View File

@ -0,0 +1,27 @@
const path = require("path");
const webpack = require("webpack");
const webpackConfig = require("./webpack.config.common");
module.exports = {
...webpackConfig,
mode: "development",
watchOptions: { ignored: /node_modules/ },
devServer: {
static: path.join(__dirname, "build"),
compress: true,
port: 8080,
hot: false,
historyApiFallback: {
index: "index.html",
},
},
plugins: [
new webpack.DefinePlugin({
"process.env.NODE_ENV": JSON.stringify("development"),
"process.env.PUBLIC_API_URL": JSON.stringify("http://localhost:8081"),
"process.env.PUBLIC_BASE_URL": JSON.stringify("http://localhost:8080"),
}),
...webpackConfig.plugins,
],
};

20
webpack.config.prod.js Normal file
View File

@ -0,0 +1,20 @@
const { ESBuildMinifyPlugin } = require("esbuild-loader");
const webpack = require("webpack");
const webpackConfig = require("./webpack.config.common");
module.exports = {
...webpackConfig,
optimization: {
minimizer: [new ESBuildMinifyPlugin()],
},
mode: "production",
plugins: [
new webpack.DefinePlugin({
"process.env.NODE_ENV": JSON.stringify("production"),
"process.env.PUBLIC_API_URL": JSON.stringify(process.env.PUBLIC_API_URL),
"process.env.PUBLIC_BASE_URL": JSON.stringify(process.env.PUBLIC_BASE_URL),
}),
...webpackConfig.plugins,
],
};