[add] wip service worker & offline reader
All checks were successful
continuous-integration/drone/push Build is passing
|
Before Width: | Height: | Size: 511 KiB After Width: | Height: | Size: 511 KiB |
|
Before Width: | Height: | Size: 699 KiB After Width: | Height: | Size: 699 KiB |
|
Before Width: | Height: | Size: 462 KiB After Width: | Height: | Size: 462 KiB |
|
Before Width: | Height: | Size: 457 KiB After Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
50
assets/js/idb-keyval.js
Normal file
@@ -0,0 +1,50 @@
|
||||
function _slicedToArray(t,n){return _arrayWithHoles(t)||_iterableToArrayLimit(t,n)||_unsupportedIterableToArray(t,n)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(t,n){if(t){if("string"==typeof t)return _arrayLikeToArray(t,n);var r=Object.prototype.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?_arrayLikeToArray(t,n):void 0}}function _arrayLikeToArray(t,n){(null==n||n>t.length)&&(n=t.length);for(var r=0,e=new Array(n);r<n;r++)e[r]=t[r];return e}function _iterableToArrayLimit(t,n){var r=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=r){var e,o,u=[],i=!0,a=!1;try{for(r=r.call(t);!(i=(e=r.next()).done)&&(u.push(e.value),!n||u.length!==n);i=!0);}catch(t){a=!0,o=t}finally{try{i||null==r.return||r.return()}finally{if(a)throw o}}return u}}function _arrayWithHoles(t){if(Array.isArray(t))return t}function _typeof(t){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},_typeof(t)}!function(t,n){"object"===("undefined"==typeof exports?"undefined":_typeof(exports))&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).idbKeyval={})}(this,(function(t){"use strict";function n(t){return new Promise((function(n,r){t.oncomplete=t.onsuccess=function(){return n(t.result)},t.onabort=t.onerror=function(){return r(t.error)}}))}function r(t,r){var e=indexedDB.open(t);e.onupgradeneeded=function(){return e.result.createObjectStore(r)};var o=n(e);return function(t,n){return o.then((function(e){return n(e.transaction(r,t).objectStore(r))}))}}var e;function o(){return e||(e=r("keyval-store","keyval")),e}function u(t,r){return t.openCursor().onsuccess=function(){this.result&&(r(this.result),this.result.continue())},n(t.transaction)}t.clear=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readwrite",(function(t){return t.clear(),n(t.transaction)}))},t.createStore=r,t.del=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return r.delete(t),n(r.transaction)}))},t.delMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return t.forEach((function(t){return r.delete(t)})),n(r.transaction)}))},t.entries=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(r){if(r.getAll&&r.getAllKeys)return Promise.all([n(r.getAllKeys()),n(r.getAll())]).then((function(t){var n=_slicedToArray(t,2),r=n[0],e=n[1];return r.map((function(t,n){return[t,e[n]]}))}));var e=[];return t("readonly",(function(t){return u(t,(function(t){return e.push([t.key,t.value])})).then((function(){return e}))}))}))},t.get=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readonly",(function(r){return n(r.get(t))}))},t.getMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readonly",(function(r){return Promise.all(t.map((function(t){return n(r.get(t))})))}))},t.keys=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(t){if(t.getAllKeys)return n(t.getAllKeys());var r=[];return u(t,(function(t){return r.push(t.key)})).then((function(){return r}))}))},t.promisifyRequest=n,t.set=function(t,r){var e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:o();return e("readwrite",(function(e){return e.put(r,t),n(e.transaction)}))},t.setMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return t.forEach((function(t){return r.put(t[1],t[0])})),n(r.transaction)}))},t.update=function(t,r){var e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:o();return e("readwrite",(function(e){return new Promise((function(o,u){e.get(t).onsuccess=function(){try{e.put(r(this.result),t),o(n(e.transaction))}catch(t){u(t)}}}))}))},t.values=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(t){if(t.getAll)return n(t.getAll());var r=[];return u(t,(function(t){return r.push(t.value)})).then((function(){return r}))}))},Object.defineProperty(t,"__esModule",{value:!0})}));
|
||||
|
||||
/**
|
||||
* Custom IDB Convenience Functions Wrapper
|
||||
**/
|
||||
const IDB = (function () {
|
||||
let { get, del, entries, update, keys } = window.idbKeyval;
|
||||
|
||||
return {
|
||||
async set(key, newValue) {
|
||||
let changeObj = {};
|
||||
await update(key, (oldValue) => {
|
||||
if (oldValue != null) changeObj.oldValue = oldValue;
|
||||
changeObj.newValue = newValue;
|
||||
return newValue;
|
||||
});
|
||||
return changeObj;
|
||||
},
|
||||
|
||||
get(key, defaultValue) {
|
||||
return get(key).then((resp) => {
|
||||
return defaultValue && resp == null ? defaultValue : resp;
|
||||
});
|
||||
},
|
||||
|
||||
del(key) {
|
||||
return del(key);
|
||||
},
|
||||
|
||||
find(keyRegExp, includeValues = false) {
|
||||
if (!(keyRegExp instanceof RegExp)) throw new Error("Invalid RegExp");
|
||||
|
||||
if (!includeValues)
|
||||
return keys().then((allKeys) =>
|
||||
allKeys.filter((key) => keyRegExp.test(key))
|
||||
);
|
||||
|
||||
return entries().then((allItems) => {
|
||||
const matchingKeys = allItems.filter((keyVal) =>
|
||||
keyRegExp.test(keyVal[0])
|
||||
);
|
||||
return matchingKeys.reduce((obj, keyVal) => {
|
||||
const [key, val] = keyVal;
|
||||
obj[key] = val;
|
||||
return obj;
|
||||
}, {});
|
||||
});
|
||||
},
|
||||
};
|
||||
})();
|
||||
155
assets/offline/index.html
Normal file
@@ -0,0 +1,155 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
content="#F3F4F6"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
content="#1F2937"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
|
||||
<title>Book Manager - Offline</title>
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="stylesheet" href="/assets/style.css" />
|
||||
|
||||
<script src="/assets/js/idb-keyval.js"></script>
|
||||
<script src="/assets/offline/index.js"></script>
|
||||
|
||||
<style>
|
||||
/* ----------------------------- */
|
||||
/* -------- PWA Styling -------- */
|
||||
/* ----------------------------- */
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
html {
|
||||
height: calc(100% + env(safe-area-inset-bottom));
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
|
||||
env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
main {
|
||||
height: calc(100dvh - 4rem - env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
#container {
|
||||
padding-bottom: calc(5em + env(safe-area-inset-bottom) * 2);
|
||||
}
|
||||
|
||||
/* No Scrollbar - IE, Edge, Firefox */
|
||||
* {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* No Scrollbar - WebKit */
|
||||
*::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.css-button:checked + div {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.css-button + div {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.css-button:checked + div + label {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between w-full h-16">
|
||||
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-48">
|
||||
Offline Documents
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<main class="relative overflow-hidden">
|
||||
<div
|
||||
id="container"
|
||||
class="h-[100dvh] px-4 overflow-auto md:px-6 lg:mx-48"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"></div>
|
||||
|
||||
<div
|
||||
class="fixed bottom-6 right-6 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="add-file-button"
|
||||
class="hidden css-button"
|
||||
/>
|
||||
<div
|
||||
class="rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".epub"
|
||||
id="document_file"
|
||||
name="document_file"
|
||||
/>
|
||||
<button
|
||||
class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Add File
|
||||
</button>
|
||||
</div>
|
||||
<label for="add-file-button">
|
||||
<div
|
||||
class="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<label
|
||||
class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
|
||||
for="add-file-button"
|
||||
>
|
||||
<svg
|
||||
width="34"
|
||||
height="34"
|
||||
class="text-gray-200 dark:text-gray-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 15.75C12.4142 15.75 12.75 15.4142 12.75 15V4.02744L14.4306 5.98809C14.7001 6.30259 15.1736 6.33901 15.4881 6.06944C15.8026 5.79988 15.839 5.3264 15.5694 5.01191L12.5694 1.51191C12.427 1.34567 12.2189 1.25 12 1.25C11.7811 1.25 11.573 1.34567 11.4306 1.51191L8.43056 5.01191C8.16099 5.3264 8.19741 5.79988 8.51191 6.06944C8.8264 6.33901 9.29988 6.30259 9.56944 5.98809L11.25 4.02744L11.25 15C11.25 15.4142 11.5858 15.75 12 15.75Z"
|
||||
/>
|
||||
<path
|
||||
d="M16 9C15.2978 9 14.9467 9 14.6945 9.16851C14.5853 9.24148 14.4915 9.33525 14.4186 9.44446C14.25 9.69667 14.25 10.0478 14.25 10.75L14.25 15C14.25 16.2426 13.2427 17.25 12 17.25C10.7574 17.25 9.75004 16.2426 9.75004 15L9.75004 10.75C9.75004 10.0478 9.75004 9.69664 9.58149 9.4444C9.50854 9.33523 9.41481 9.2415 9.30564 9.16855C9.05341 9 8.70227 9 8 9C5.17157 9 3.75736 9 2.87868 9.87868C2 10.7574 2 12.1714 2 14.9998V15.9998C2 18.8282 2 20.2424 2.87868 21.1211C3.75736 21.9998 5.17157 21.9998 8 21.9998H16C18.8284 21.9998 20.2426 21.9998 21.1213 21.1211C22 20.2424 22 18.8282 22 15.9998V14.9998C22 12.1714 22 10.7574 21.1213 9.87868C20.2426 9 18.8284 9 16 9Z"
|
||||
/>
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
195
assets/offline/index.js
Normal file
@@ -0,0 +1,195 @@
|
||||
const BASE_ITEM = `
|
||||
<div class="w-full relative">
|
||||
<div class="flex gap-4 w-full h-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
||||
<div class="min-w-fit my-auto h-48 relative">
|
||||
<a href="#">
|
||||
<img class="rounded object-cover h-full" src="/assets/images/no-cover.jpg"></img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
||||
<div class="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p class="text-gray-400">Title</p>
|
||||
<p class="font-medium">
|
||||
N/A
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p class="text-gray-400">Author</p>
|
||||
<p class="font-medium">
|
||||
N/A
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p class="text-gray-400">Progress</p>
|
||||
<p class="font-medium">
|
||||
0%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400">
|
||||
<a href="#">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM12 6.25C12.4142 6.25 12.75 6.58579 12.75 7V12.1893L14.4697 10.4697C14.7626 10.1768 15.2374 10.1768 15.5303 10.4697C15.8232 10.7626 15.8232 11.2374 15.5303 11.5303L12.5303 14.5303C12.3897 14.671 12.1989 14.75 12 14.75C11.8011 14.75 11.6103 14.671 11.4697 14.5303L8.46967 11.5303C8.17678 11.2374 8.17678 10.7626 8.46967 10.4697C8.76256 10.1768 9.23744 10.1768 9.53033 10.4697L11.25 12.1893V7C11.25 6.58579 11.5858 6.25 12 6.25ZM8 16.25C7.58579 16.25 7.25 16.5858 7.25 17C7.25 17.4142 7.58579 17.75 8 17.75H16C16.4142 17.75 16.75 17.4142 16.75 17C16.75 16.5858 16.4142 16.25 16 16.25H8Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const GET_SW_CACHE = "GET_SW_CACHE";
|
||||
const DEL_SW_CACHE = "DEL_SW_CACHE";
|
||||
|
||||
async function initOffline() {
|
||||
if (document.location.pathname !== "/offline")
|
||||
window.history.replaceState(null, null, "/offline");
|
||||
|
||||
await SW.install().catch((e) => {
|
||||
console.log("Service Worker Install Error:", e);
|
||||
});
|
||||
console.log("Registered");
|
||||
getCachedDocuments().then(populateDOM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask service worker for list of cached documents.
|
||||
**/
|
||||
async function getCachedDocuments() {
|
||||
let cachedDocuments = await IDB.find(/^DOCUMENT-/, true);
|
||||
let cachedSWData = await SW.send({ type: GET_SW_CACHE });
|
||||
return cachedSWData;
|
||||
}
|
||||
|
||||
async function sendSWMessage(data) {
|
||||
let swReg = await navigator.serviceWorker.ready;
|
||||
if (!swReg.active) return;
|
||||
|
||||
function randomID() {
|
||||
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
|
||||
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))))
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
let id = randomID();
|
||||
navigator.serviceWorker.addEventListener(id, (event) => {
|
||||
console.log("Response:", event);
|
||||
navigator.serviceWorker.removeEventListener(id);
|
||||
});
|
||||
|
||||
swReg.active.postMessage({ id, data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate DOM with cached documents.
|
||||
**/
|
||||
function populateDOM(data) {
|
||||
let allDocuments = document.querySelector("#container div");
|
||||
|
||||
data.forEach((item) => {
|
||||
// Create Main Element
|
||||
let baseEl = document.createElement("div");
|
||||
baseEl.innerHTML = BASE_ITEM;
|
||||
baseEl = baseEl.firstElementChild;
|
||||
|
||||
// Get Elements
|
||||
let coverEl = baseEl.querySelector("a img");
|
||||
let [titleEl, authorEl, percentageEl] = baseEl.querySelectorAll("p + p");
|
||||
let downloadEl = baseEl.querySelector("svg").parentElement;
|
||||
|
||||
// Set Variables
|
||||
downloadEl.setAttribute("href", "/documents/" + item.id + "/file");
|
||||
coverEl.setAttribute("src", "/documents/" + item.id + "/cover");
|
||||
coverEl.parentElement.setAttribute("href", "/reader?id=" + item.id);
|
||||
titleEl.textContent = item.title;
|
||||
authorEl.textContent = item.author;
|
||||
percentageEl.textContent = item.percentage + "%";
|
||||
|
||||
allDocuments.append(baseEl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow adding file to offline reader. Add to IndexedDB,
|
||||
* and later upload? Add style indicating external file?
|
||||
**/
|
||||
function handleFileAdd() {}
|
||||
|
||||
const SW = (function () {
|
||||
// Helper Function
|
||||
function randomID() {
|
||||
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
|
||||
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))))
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
// Variables
|
||||
let swInstance = null;
|
||||
let outstandingMessages = {};
|
||||
|
||||
navigator.serviceWorker.addEventListener("message", ({ data }) => {
|
||||
let { id } = data;
|
||||
data = data.data;
|
||||
|
||||
console.log("SW Message:", id, data);
|
||||
if (!outstandingMessages[id])
|
||||
return console.warn("Invalid Outstanding Message:", { id, data });
|
||||
|
||||
outstandingMessages[id](data);
|
||||
delete outstandingMessages[id];
|
||||
});
|
||||
|
||||
return {
|
||||
async install() {
|
||||
// Register Service Worker
|
||||
swInstance = await navigator.serviceWorker.register("/sw.js");
|
||||
console.log(swInstance);
|
||||
swInstance.onupdatefound = (data) => console.log("Update Found:", data);
|
||||
|
||||
// Wait for Registration / Update
|
||||
let serviceWorker =
|
||||
swInstance.installing || swInstance.waiting || swInstance.active;
|
||||
if (serviceWorker.state == "activated") return;
|
||||
await new Promise((resolve) => {
|
||||
serviceWorker.onstatechange = (data) => {
|
||||
console.log("State Change:", serviceWorker.state);
|
||||
if (serviceWorker.state == "activated") resolve();
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
send(data) {
|
||||
if (!swInstance?.active) return Promise.reject("Inactive Service Worker");
|
||||
let id = randomID();
|
||||
|
||||
let msgPromise = new Promise((resolve) => {
|
||||
outstandingMessages[id] = resolve;
|
||||
});
|
||||
|
||||
swInstance.active.postMessage({ id, data });
|
||||
return msgPromise;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// Initialize
|
||||
window.addEventListener("DOMContentLoaded", initOffline);
|
||||
259
assets/reader/index.html
Normal file
@@ -0,0 +1,259 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
id="viewport"
|
||||
name="viewport"
|
||||
content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta name="theme-color" content="#D2B48C" />
|
||||
|
||||
<title>Book Manager - Reader</title>
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="stylesheet" href="/assets/style.css" />
|
||||
|
||||
<!-- Libraries -->
|
||||
<script src="/assets/js/platform.js"></script>
|
||||
<script src="/assets/js/jszip.min.js"></script>
|
||||
<script src="/assets/js/epub.min.js"></script>
|
||||
<script src="/assets/js/no-sleep.js"></script>
|
||||
<script src="/assets/js/idb-keyval.js"></script>
|
||||
|
||||
<!-- Reader -->
|
||||
<script src="/assets/reader/index.js"></script>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: calc(100% + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
#viewer {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
/* For Webkit-based browsers (Chrome, Safari and Opera) */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* For IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
#bottom-bar {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
#top-bar {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
#top-bar:not(.top-0) {
|
||||
top: calc((8em + env(safe-area-inset-top)) * -1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 dark:bg-gray-800">
|
||||
<main class="relative overflow-hidden h-[100dvh]">
|
||||
<div
|
||||
id="top-bar"
|
||||
class="transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 w-full px-2"
|
||||
>
|
||||
<div class="w-full h-32 flex items-center justify-around relative">
|
||||
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4">
|
||||
<a href="#">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M20.5355 3.46447C19.0711 2 16.714 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355C22 19.0711 22 16.714 22 12C22 7.28595 22 4.92893 20.5355 3.46447ZM14.0303 8.46967C14.3232 8.76256 14.3232 9.23744 14.0303 9.53033L11.5607 12L14.0303 14.4697C14.3232 14.7626 14.3232 15.2374 14.0303 15.5303C13.7374 15.8232 13.2626 15.8232 12.9697 15.5303L9.96967 12.5303C9.82902 12.3897 9.75 12.1989 9.75 12C9.75 11.8011 9.82902 11.6103 9.96967 11.4697L12.9697 8.46967C13.2626 8.17678 13.7374 8.17678 14.0303 8.46967Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100 close-top-bar"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22ZM8.96965 8.96967C9.26254 8.67678 9.73742 8.67678 10.0303 8.96967L12 10.9394L13.9696 8.96969C14.2625 8.6768 14.7374 8.6768 15.0303 8.96969C15.3232 9.26258 15.3232 9.73746 15.0303 10.0303L13.0606 12L15.0303 13.9697C15.3232 14.2625 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2625 15.3232 13.9696 15.0303L12 13.0607L10.0303 15.0303C9.73744 15.3232 9.26256 15.3232 8.96967 15.0303C8.67678 14.7374 8.67678 14.2626 8.96967 13.9697L10.9393 12L8.96965 10.0303C8.67676 9.73744 8.67676 9.26256 8.96965 8.96967Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-10 h-full p-4 pl-14 rounded">
|
||||
<div class="h-full my-auto relative">
|
||||
<a href="#">
|
||||
<img
|
||||
class="rounded object-cover h-full"
|
||||
src="/assets/images/no-cover.jpg"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex gap-7 justify-around dark:text-white text-sm">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p class="text-gray-400">Title</p>
|
||||
<p
|
||||
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
||||
>
|
||||
"N/A"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-flex shrink-0 items-center">
|
||||
<div>
|
||||
<p class="text-gray-400">Author</p>
|
||||
<p
|
||||
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
||||
>
|
||||
"N/A"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="bottom-bar"
|
||||
class="-bottom-28 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 items-center flex w-full overflow-y-scroll snap-x snap-mandatory no-scrollbar"
|
||||
>
|
||||
<div
|
||||
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap gap-2 justify-around w-full dark:text-white pb-2"
|
||||
>
|
||||
<div class="flex justify-center gap-2 w-full md:w-fit">
|
||||
<p class="text-gray-400 text-xs">Chapter:</p>
|
||||
<p id="chapter-name-status" class="text-xs">N/A</p>
|
||||
</div>
|
||||
<div class="inline-flex gap-2">
|
||||
<p class="text-gray-400 text-xs">Chapter Pages:</p>
|
||||
<p id="chapter-status" class="text-xs">N/A</p>
|
||||
</div>
|
||||
<div class="inline-flex gap-2">
|
||||
<p class="text-gray-400 text-xs">Progress:</p>
|
||||
<p id="progress-status" class="text-xs">N/A</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-[90%] h-2 rounded border border-gray-500">
|
||||
<div
|
||||
id="progress-bar-status"
|
||||
class="w-0 bg-green-200 h-full rounded-l"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
|
||||
>
|
||||
<p class="text-gray-400">Theme</p>
|
||||
<div class="flex justify-around w-full gap-4 p-2 text-sm">
|
||||
<div
|
||||
class="color-scheme cursor-pointer rounded border border-white bg-[#fff] text-[#000] grow text-center"
|
||||
>
|
||||
light
|
||||
</div>
|
||||
<div
|
||||
class="color-scheme cursor-pointer rounded border border-white bg-[#d2b48c] text-[#333] grow text-center"
|
||||
>
|
||||
tan
|
||||
</div>
|
||||
<div
|
||||
class="color-scheme cursor-pointer rounded border border-white bg-[#1f2937] text-[#fff] grow text-center"
|
||||
>
|
||||
blue
|
||||
</div>
|
||||
<div
|
||||
class="color-scheme cursor-pointer rounded border border-white bg-[#232323] text-[#fff] grow text-center"
|
||||
>
|
||||
gray
|
||||
</div>
|
||||
<div
|
||||
class="color-scheme cursor-pointer rounded border border-white bg-[#000] text-[#ccc] grow text-center"
|
||||
>
|
||||
black
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
|
||||
>
|
||||
<p class="text-gray-400">Font</p>
|
||||
<div class="flex justify-around w-full gap-4 p-2 text-sm">
|
||||
<div
|
||||
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
|
||||
>
|
||||
Serif
|
||||
</div>
|
||||
<div
|
||||
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
|
||||
>
|
||||
Open Sans
|
||||
</div>
|
||||
<div
|
||||
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
|
||||
>
|
||||
Arbutus Slab
|
||||
</div>
|
||||
<div
|
||||
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
|
||||
>
|
||||
Lato
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
|
||||
>
|
||||
<p class="text-gray-400">Font Size</p>
|
||||
<div class="flex justify-around w-full gap-4 p-2 text-sm">
|
||||
<div
|
||||
class="font-size cursor-pointer rounded border border-white grow text-center dark:text-white"
|
||||
>
|
||||
-
|
||||
</div>
|
||||
<div
|
||||
class="font-size cursor-pointer rounded border border-white grow text-center dark:text-white"
|
||||
>
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="viewer" class="w-full h-full"></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,51 @@
|
||||
const THEMES = ["light", "tan", "blue", "gray", "black"];
|
||||
const THEME_FILE = "/assets/reader/readerThemes.css";
|
||||
|
||||
async function initReader() {
|
||||
let documentData;
|
||||
let filePath;
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const documentID = urlParams.get("id");
|
||||
const localID = urlParams.get("local");
|
||||
|
||||
if (documentID) {
|
||||
// Get Server / Cached Document
|
||||
let progressResp = await fetch("/documents/" + documentID + "/progress");
|
||||
documentData = await progressResp.json();
|
||||
filePath = "/documents/" + documentID + "/file";
|
||||
} else if (localID) {
|
||||
// Get Local Document
|
||||
// TODO:
|
||||
// - IDB FileID
|
||||
// - IDB Metadata
|
||||
} else {
|
||||
throw new Error("Invalid");
|
||||
}
|
||||
|
||||
populateMetadata(documentData);
|
||||
window.currentReader = new EBookReader(filePath, documentData);
|
||||
}
|
||||
|
||||
function populateMetadata(data) {
|
||||
let documentLocation = data.id.startsWith("local-")
|
||||
? "/offline"
|
||||
: "/documents/" + data.id;
|
||||
|
||||
let documentCoverLocation = data.id.startsWith("local-")
|
||||
? "/assets/images/no-cover.jpg"
|
||||
: "/documents/" + data.id + "/cover";
|
||||
|
||||
let [backEl, coverEl] = document.querySelectorAll("a");
|
||||
backEl.setAttribute("href", documentLocation);
|
||||
coverEl.setAttribute("href", documentLocation);
|
||||
coverEl.firstElementChild.setAttribute("src", documentCoverLocation);
|
||||
|
||||
let [titleEl, authorEl] = document.querySelectorAll("#top-bar p + p");
|
||||
titleEl.innerText = data.title;
|
||||
authorEl.innerText = data.author;
|
||||
}
|
||||
|
||||
class EBookReader {
|
||||
bookState = {
|
||||
currentWord: 0,
|
||||
@@ -13,6 +58,9 @@ class EBookReader {
|
||||
};
|
||||
|
||||
constructor(file, bookState) {
|
||||
// Save IDB Cache
|
||||
IDB.set("DOCUMENT-" + bookState.id, bookState);
|
||||
|
||||
// Set Variables
|
||||
Object.assign(this.bookState, bookState);
|
||||
|
||||
@@ -61,7 +109,7 @@ class EBookReader {
|
||||
|
||||
// Get Stats
|
||||
let stats = this.getBookStats();
|
||||
this.updateBookStats(stats);
|
||||
this.updateBookStatElements(stats);
|
||||
}.bind(this);
|
||||
|
||||
// Register Content Hook
|
||||
@@ -381,25 +429,41 @@ class EBookReader {
|
||||
// ------------------------------------------------ //
|
||||
// --------------- Bottom & Top Bar --------------- //
|
||||
// ------------------------------------------------ //
|
||||
let emSize = parseFloat(getComputedStyle(renderDoc.body).fontSize);
|
||||
renderDoc.addEventListener("click", function (event) {
|
||||
let barPixels = emSize * 5;
|
||||
renderDoc.addEventListener(
|
||||
"click",
|
||||
function (event) {
|
||||
// Get Window Dimensions
|
||||
let windowWidth = window.innerWidth;
|
||||
let windowHeight = window.innerHeight;
|
||||
|
||||
let top = barPixels;
|
||||
let bottom = window.innerHeight - top;
|
||||
// Calculate X & Y Hot Zones
|
||||
let barPixels = windowHeight * 0.2;
|
||||
let pagePixels = windowWidth * 0.2;
|
||||
|
||||
let left = barPixels / 2;
|
||||
let right = window.innerWidth - left;
|
||||
// Calculate Top & Bottom Thresholds
|
||||
let top = barPixels;
|
||||
let bottom = window.innerHeight - top;
|
||||
|
||||
if (event.clientY < top) handleSwipeDown();
|
||||
else if (event.clientY > bottom) handleSwipeUp();
|
||||
else if (event.screenX < left) prevPage();
|
||||
else if (event.screenX > right) nextPage();
|
||||
else {
|
||||
bottomBar.classList.remove("bottom-0");
|
||||
topBar.classList.remove("top-0");
|
||||
}
|
||||
});
|
||||
// Calculate Left & Right Thresholds
|
||||
let left = pagePixels;
|
||||
let right = windowWidth - left;
|
||||
|
||||
// Calculate Relative Coords
|
||||
let leftOffset = this.views().container.scrollLeft;
|
||||
let yCoord = event.clientY;
|
||||
let xCoord = event.clientX - leftOffset;
|
||||
|
||||
// Handle Event
|
||||
if (yCoord < top) handleSwipeDown();
|
||||
else if (yCoord > bottom) handleSwipeUp();
|
||||
else if (xCoord < left) prevPage();
|
||||
else if (xCoord > right) nextPage();
|
||||
else {
|
||||
bottomBar.classList.remove("bottom-0");
|
||||
topBar.classList.remove("top-0");
|
||||
}
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
renderDoc.addEventListener(
|
||||
"wheel",
|
||||
@@ -565,26 +629,8 @@ class EBookReader {
|
||||
* Progresses to the next page & monitors reading activity
|
||||
**/
|
||||
async nextPage() {
|
||||
// Flush Activity
|
||||
this.flushActivity();
|
||||
|
||||
// Get Elapsed Time
|
||||
let elapsedTime = Date.now() - this.bookState.pageStart;
|
||||
|
||||
// Update Current Word
|
||||
let pageWords = await this.getVisibleWordCount();
|
||||
let startingWord = this.bookState.currentWord;
|
||||
let percentRead = pageWords / this.bookState.words;
|
||||
this.bookState.currentWord += pageWords;
|
||||
|
||||
// Add Read Event
|
||||
this.bookState.readActivity.push({
|
||||
percentRead,
|
||||
startingWord,
|
||||
pageWords,
|
||||
elapsedTime,
|
||||
startTime: this.bookState.pageStart,
|
||||
});
|
||||
// Create Activity
|
||||
await this.createActivity();
|
||||
|
||||
// Render Next Page
|
||||
await this.rendition.next();
|
||||
@@ -594,24 +640,16 @@ class EBookReader {
|
||||
|
||||
// Update Stats
|
||||
let stats = this.getBookStats();
|
||||
this.updateBookStats(stats);
|
||||
this.updateBookStatElements(stats);
|
||||
|
||||
// Update & Flush Progress
|
||||
let currentCFI = await this.rendition.currentLocation();
|
||||
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
||||
this.bookState.progress = xpath;
|
||||
this.bookState.progressElement = element;
|
||||
|
||||
this.flushProgress();
|
||||
// Create Progress
|
||||
this.createProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Progresses to the previous page & monitors reading activity
|
||||
**/
|
||||
async prevPage() {
|
||||
// Flush Activity
|
||||
this.flushActivity();
|
||||
|
||||
// Render Previous Page
|
||||
await this.rendition.prev();
|
||||
|
||||
@@ -624,14 +662,10 @@ class EBookReader {
|
||||
|
||||
// Update Stats
|
||||
let stats = this.getBookStats();
|
||||
this.updateBookStats(stats);
|
||||
this.updateBookStatElements(stats);
|
||||
|
||||
// Update & Flush Progress
|
||||
let currentCFI = await this.rendition.currentLocation();
|
||||
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
||||
this.bookState.progress = xpath;
|
||||
this.bookState.progressElement = element;
|
||||
this.flushProgress();
|
||||
// Create Progress
|
||||
this.createProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -652,88 +686,95 @@ class EBookReader {
|
||||
this.highlightPositionMarker();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and flush activity
|
||||
**/
|
||||
async flushActivity() {
|
||||
// Process & Reset Activity
|
||||
let allActivity = this.bookState.readActivity;
|
||||
this.bookState.readActivity = [];
|
||||
|
||||
async createActivity() {
|
||||
// WPM MAX & MIN
|
||||
const WPM_MAX = 2000;
|
||||
const WPM_MIN = 100;
|
||||
|
||||
let normalizedActivity = allActivity
|
||||
// Exclude Fast WPM
|
||||
.filter((item) => item.pageWords / (item.elapsedTime / 60000) < WPM_MAX)
|
||||
.map((item) => {
|
||||
let pageWPM = item.pageWords / (item.elapsedTime / 60000);
|
||||
// Get Elapsed Time
|
||||
let pageStart = this.bookState.pageStart;
|
||||
let elapsedTime = Date.now() - pageStart;
|
||||
|
||||
// Min WPM
|
||||
if (pageWPM < WPM_MIN) {
|
||||
// TODO - Exclude Event?
|
||||
item.elapsedTime = (item.pageWords / WPM_MIN) * 60000;
|
||||
}
|
||||
// Update Current Word
|
||||
let pageWords = await this.getVisibleWordCount();
|
||||
let startingWord = this.bookState.currentWord;
|
||||
let percentRead = pageWords / this.bookState.words;
|
||||
this.bookState.currentWord += pageWords;
|
||||
|
||||
item.pages = Math.round(1 / item.percentRead);
|
||||
let pageWPM = pageWords / (elapsedTime / 60000);
|
||||
|
||||
item.page = Math.round(
|
||||
(item.startingWord * item.pages) / this.bookState.words
|
||||
);
|
||||
// Exclude Ridiculous WPM
|
||||
// if (pageWPM >= WPM_MAX) return;
|
||||
|
||||
// Estimate Accuracy Loss (Debugging)
|
||||
// let wordLoss = Math.abs(
|
||||
// item.pageWords - this.bookState.words / item.pages
|
||||
// );
|
||||
// console.log("Word Loss:", wordLoss);
|
||||
// Ensure WPM Minimum
|
||||
if (pageWPM < WPM_MIN) elapsedTime = (pageWords / WPM_MIN) * 60000;
|
||||
|
||||
return {
|
||||
document: this.bookState.id,
|
||||
duration: Math.round(item.elapsedTime / 1000),
|
||||
start_time: Math.round(item.startTime / 1000),
|
||||
page: item.page,
|
||||
pages: item.pages,
|
||||
};
|
||||
});
|
||||
let totalPages = Math.round(1 / percentRead);
|
||||
|
||||
if (normalizedActivity.length == 0) return;
|
||||
|
||||
console.log("Flushing Activity...");
|
||||
let currentPage = Math.round(
|
||||
(startingWord * totalPages) / this.bookState.words
|
||||
);
|
||||
|
||||
// Create Activity Event
|
||||
let activityEvent = {
|
||||
device_id: this.readerSettings.deviceID,
|
||||
device: this.readerSettings.deviceName,
|
||||
activity: normalizedActivity,
|
||||
activity: [
|
||||
{
|
||||
document: this.bookState.id,
|
||||
duration: Math.round(elapsedTime / 1000),
|
||||
start_time: Math.round(pageStart / 1000),
|
||||
page: currentPage,
|
||||
pages: totalPages,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Flush Activity
|
||||
fetch("/api/ko/activity", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(activityEvent),
|
||||
})
|
||||
.then(async (r) =>
|
||||
console.log("Flushed Activity:", {
|
||||
response: r,
|
||||
json: await r.json(),
|
||||
data: activityEvent,
|
||||
})
|
||||
)
|
||||
.catch((e) =>
|
||||
console.error("Activity Flush Failed:", {
|
||||
error: e,
|
||||
data: activityEvent,
|
||||
})
|
||||
);
|
||||
// Flush -> Offline Cache IDB
|
||||
this.flushActivity(activityEvent).catch(async (e) => {
|
||||
console.error("Activity Flush Failed:", {
|
||||
error: e,
|
||||
data: activityEvent,
|
||||
});
|
||||
|
||||
// Get & Update Activity
|
||||
let existingActivity = await IDB.get("ACTIVITY", { activity: [] });
|
||||
existingActivity.device_id = activityEvent.device_id;
|
||||
existingActivity.device = activityEvent.device;
|
||||
existingActivity.activity.push(...activityEvent.activity);
|
||||
|
||||
// Update IDB
|
||||
await IDB.set("ACTIVITY", existingActivity);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush progress to the API. Called when the page changes.
|
||||
* Normalize and flush activity
|
||||
**/
|
||||
async flushProgress() {
|
||||
console.log("Flushing Progress...");
|
||||
flushActivity(activityEvent) {
|
||||
console.log("Flushing Activity...");
|
||||
|
||||
// Create Progress Event
|
||||
// Flush Activity
|
||||
return fetch("/api/ko/activity", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(activityEvent),
|
||||
}).then(async (r) =>
|
||||
console.log("Flushed Activity:", {
|
||||
response: r,
|
||||
json: await r.json(),
|
||||
data: activityEvent,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async createProgress() {
|
||||
// Update Pointers
|
||||
let currentCFI = await this.rendition.currentLocation();
|
||||
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
||||
this.bookState.progress = xpath;
|
||||
this.bookState.progressElement = element;
|
||||
|
||||
// Create Event
|
||||
let progressEvent = {
|
||||
document: this.bookState.id,
|
||||
device_id: this.readerSettings.deviceID,
|
||||
@@ -745,24 +786,39 @@ class EBookReader {
|
||||
progress: this.bookState.progress,
|
||||
};
|
||||
|
||||
// Flush -> Offline Cache IDB
|
||||
this.flushProgress(progressEvent).catch(async (e) => {
|
||||
console.error("Progress Flush Failed:", {
|
||||
error: e,
|
||||
data: progressEvent,
|
||||
});
|
||||
|
||||
// Get & Update Activity
|
||||
let existingProgress = await IDB.get("PROGRESS", []);
|
||||
existingProgress.push(progressEvent);
|
||||
|
||||
// Update IDB
|
||||
await IDB.set("PROGRESS", existingProgress);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush progress to the API. Called when the page changes.
|
||||
**/
|
||||
flushProgress(progressEvent) {
|
||||
console.log("Flushing Progress...");
|
||||
|
||||
// Flush Progress
|
||||
fetch("/api/ko/syncs/progress", {
|
||||
return fetch("/api/ko/syncs/progress", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(progressEvent),
|
||||
})
|
||||
.then(async (r) =>
|
||||
console.log("Flushed Progress:", {
|
||||
response: r,
|
||||
json: await r.json(),
|
||||
data: progressEvent,
|
||||
})
|
||||
)
|
||||
.catch((e) =>
|
||||
console.error("Progress Flush Failed:", {
|
||||
error: e,
|
||||
data: progressEvent,
|
||||
})
|
||||
);
|
||||
}).then(async (r) =>
|
||||
console.log("Flushed Progress:", {
|
||||
response: r,
|
||||
json: await r.json(),
|
||||
data: progressEvent,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -812,7 +868,7 @@ class EBookReader {
|
||||
/**
|
||||
* Update elements with stats
|
||||
**/
|
||||
updateBookStats(data) {
|
||||
updateBookStatElements(data) {
|
||||
if (!data) return;
|
||||
|
||||
let chapterStatus = document.querySelector("#chapter-status");
|
||||
@@ -1076,3 +1132,5 @@ class EBookReader {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initReader);
|
||||
|
||||
@@ -2046,6 +2046,11 @@ video {
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:mx-48 {
|
||||
margin-left: 12rem;
|
||||
margin-right: 12rem;
|
||||
}
|
||||
|
||||
.lg\:ml-44 {
|
||||
margin-left: 11rem;
|
||||
}
|
||||
|
||||
161
assets/sw.js
Normal file
@@ -0,0 +1,161 @@
|
||||
// Local Consts
|
||||
const SW_VERSION = 1;
|
||||
const SW_CACHE_NAME = "OFFLINE_V1";
|
||||
|
||||
// Message Consts
|
||||
const PURGE_SW_CACHE = "PURGE_SW_CACHE";
|
||||
const DEL_SW_CACHE = "DEL_SW_CACHE";
|
||||
const GET_SW_CACHE = "GET_SW_CACHE";
|
||||
const GET_SW_VERSION = "GET_SW_VERSION";
|
||||
|
||||
// Assets
|
||||
const ASSETS_DOCUMENT = /^\/documents\/[a-zA-Z0-9]{32}\/(cover|file|progress)$/;
|
||||
const ASSETS_OFFLINE = [
|
||||
// Offline Resources
|
||||
"/offline",
|
||||
"/assets/offline/index.js",
|
||||
"/assets/reader/index.js",
|
||||
"/assets/images/no-cover.jpg",
|
||||
|
||||
// App Style
|
||||
"/manifest.json",
|
||||
"/assets/style.css",
|
||||
|
||||
// Reader & Offline Libraries
|
||||
"/assets/js/platform.js",
|
||||
"/assets/js/jszip.min.js",
|
||||
"/assets/js/epub.min.js",
|
||||
"/assets/js/no-sleep.js",
|
||||
"/assets/js/idb-keyval.js",
|
||||
];
|
||||
|
||||
function wantCache(request) {
|
||||
let urlPath = new URL(request.url).pathname;
|
||||
if (ASSETS_OFFLINE.includes(urlPath)) return true;
|
||||
if (urlPath.match(ASSETS_DOCUMENT)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuke Cache
|
||||
**/
|
||||
function purgeCache() {
|
||||
return caches.keys().then(function (names) {
|
||||
for (let name of names) caches.delete(name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Cache
|
||||
**/
|
||||
async function updateCache(request) {
|
||||
let cache = await caches.open(SW_CACHE_NAME);
|
||||
|
||||
console.log("UPDATING CACHE:", request.url);
|
||||
|
||||
return fetch(request).then((response) => {
|
||||
const resClone = response.clone();
|
||||
if (response.status < 400) cache.put(request, resClone);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-Cache Resources on Install
|
||||
**/
|
||||
function cacheOfflineResources() {
|
||||
return caches.open(SW_CACHE_NAME).then(function (cache) {
|
||||
return cache.addAll(ASSETS_OFFLINE);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Install & Update Listener -> Cache Offline Resources
|
||||
**/
|
||||
self.addEventListener("install", function (event) {
|
||||
console.log("INSTALL:", event);
|
||||
event.waitUntil(cacheOfflineResources());
|
||||
});
|
||||
|
||||
/**
|
||||
* Message Listener -> Communication Channel Page <-> SW
|
||||
**/
|
||||
self.addEventListener("message", (event) => {
|
||||
console.log("MESSAGE:", event);
|
||||
let { id, data } = event.data;
|
||||
|
||||
if (data.type === GET_SW_VERSION) {
|
||||
event.source.postMessage({ id, data: SW_VERSION });
|
||||
} else if (data.type === PURGE_SW_CACHE) {
|
||||
purgeCache()
|
||||
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
|
||||
.catch(() => event.source.postMessage({ id, data: "FAILURE" }));
|
||||
} else if (data.type === GET_SW_CACHE) {
|
||||
caches.open(SW_CACHE_NAME).then(async (cache) => {
|
||||
let allKeys = await cache.keys();
|
||||
|
||||
let docResources = allKeys
|
||||
.map((item) => new URL(item.url).pathname)
|
||||
.filter((item) => item.startsWith("/documents/"));
|
||||
|
||||
let documentIDs = Array.from(
|
||||
new Set(docResources.map((item) => item.split("/")[2]))
|
||||
);
|
||||
|
||||
let cachedDocuments = await Promise.all(
|
||||
documentIDs
|
||||
.filter(
|
||||
(id) =>
|
||||
docResources.includes("/documents/" + id + "/file") &&
|
||||
docResources.includes("/documents/" + id + "/progress")
|
||||
)
|
||||
.map(async (id) => {
|
||||
let resp = await cache.match("/documents/" + id + "/progress");
|
||||
return resp.json();
|
||||
})
|
||||
);
|
||||
|
||||
event.source.postMessage({ id, data: cachedDocuments });
|
||||
});
|
||||
|
||||
// TODO
|
||||
} else if (data.type === DEL_SW_CACHE) {
|
||||
// TODO
|
||||
} else {
|
||||
event.source.postMessage({ id, data: { pong: 1 } });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch Listener -> Cache
|
||||
* - Covers
|
||||
* - Files
|
||||
* - Assets (Styles, JS Libraries)
|
||||
*
|
||||
* NOTE: We do not cache regular app resources. We will fallback to the
|
||||
* offline reader.
|
||||
**/
|
||||
self.addEventListener("fetch", (event) => {
|
||||
event.respondWith(
|
||||
(async function () {
|
||||
// Bypass Lazy Caching
|
||||
if (event.request.url.endsWith("/progress")) {
|
||||
return updateCache(event.request).catch((e) =>
|
||||
caches.match(event.request)
|
||||
);
|
||||
}
|
||||
|
||||
// Get Potential Cache
|
||||
let cachedResponse = await caches.match(event.request);
|
||||
|
||||
// Update Cache Asynchronously (If Wanted)
|
||||
let newResponse = (
|
||||
wantCache(event.request)
|
||||
? updateCache(event.request)
|
||||
: fetch(event.request)
|
||||
).catch((e) => caches.match("/offline"));
|
||||
|
||||
return cachedResponse || newResponse;
|
||||
})()
|
||||
);
|
||||
});
|
||||