wip reader migration

This commit is contained in:
2026-04-03 11:50:57 -04:00
parent 8ec3349b7c
commit aa812c6917
13 changed files with 1489 additions and 175 deletions

1
frontend/.gitignore vendored
View File

@@ -1 +1,2 @@
node_modules
dist

View File

@@ -9,6 +9,8 @@
"ajv": "^8.18.0",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"epubjs": "^0.3.93",
"nosleep.js": "^0.12.0",
"orval": "8.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -341,6 +343,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/localforage": ["@types/localforage@0.0.34", "", { "dependencies": { "localforage": "*" } }, "sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
@@ -385,6 +389,8 @@
"@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.7.13", "", {}, "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -487,6 +493,10 @@
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
@@ -497,6 +507,8 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@@ -535,6 +547,8 @@
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"epubjs": ["epubjs@0.3.93", "", { "dependencies": { "@types/localforage": "0.0.34", "@xmldom/xmldom": "^0.7.5", "core-js": "^3.18.3", "event-emitter": "^0.3.5", "jszip": "^3.7.1", "localforage": "^1.10.0", "lodash": "^4.17.21", "marks-pane": "^1.0.9", "path-webpack": "0.0.3" } }, "sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw=="],
"es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@@ -553,6 +567,12 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="],
"es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="],
"es6-symbol": ["es6-symbol@3.1.4", "", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -575,6 +595,8 @@
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"esniff": ["esniff@2.0.1", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
@@ -587,10 +609,14 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"ext": ["ext@1.7.0", "", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
@@ -681,12 +707,16 @@
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
@@ -781,20 +811,28 @@
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
"localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
@@ -809,6 +847,8 @@
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
"marks-pane": ["marks-pane@1.0.9", "", {}, "sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
@@ -835,12 +875,16 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="],
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"nosleep.js": ["nosleep.js@0.12.0", "", {}, "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -871,6 +915,8 @@
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
@@ -883,6 +929,8 @@
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-webpack": ["path-webpack@0.0.3", "", {}, "sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -919,6 +967,8 @@
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
@@ -943,6 +993,8 @@
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
@@ -967,6 +1019,8 @@
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
@@ -985,6 +1039,8 @@
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@@ -1023,6 +1079,8 @@
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
@@ -1073,6 +1131,8 @@
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
@@ -1185,6 +1245,8 @@
"globby/unicorn-magic": ["unicorn-magic@0.4.0", "", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="],
"localforage/lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
"markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -1201,6 +1263,8 @@
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -23,8 +23,8 @@
/>
<title>AnthoLume</title>
<link rel="manifest" href="/manifest.json" />
<script type="module" crossorigin src="/assets/index-C7Wct-hD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Co--bktJ.css">
<script type="module" crossorigin src="/assets/index-BQhAeK6-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CdRalUYN.css">
</head>
<body>
<div id="root"></div>

View File

@@ -20,6 +20,8 @@
"ajv": "^8.18.0",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"epubjs": "^0.3.93",
"nosleep.js": "^0.12.0",
"orval": "8.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -14,6 +14,7 @@ import AdminImportPage from './pages/AdminImportPage';
import AdminImportResultsPage from './pages/AdminImportResultsPage';
import AdminUsersPage from './pages/AdminUsersPage';
import AdminLogsPage from './pages/AdminLogsPage';
import ReaderPage from './pages/ReaderPage';
import { ProtectedRoute } from './auth/ProtectedRoute';
export function Routes() {
@@ -118,6 +119,14 @@ export function Routes() {
}
/>
</Route>
<Route
path="/reader/:id"
element={
<ProtectedRoute>
<ReaderPage />
</ProtectedRoute>
}
/>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</ReactRoutes>

View File

@@ -0,0 +1,148 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { EBookReader, type ReaderStats, type ReaderTocItem } from '../lib/reader/EBookReader';
import type { ReaderColorScheme, ReaderFontFamily } from '../utils/localSettings';
interface UseEpubReaderOptions {
documentId: string;
initialProgress?: string;
deviceId: string;
deviceName: string;
colorScheme: ReaderColorScheme;
fontFamily: ReaderFontFamily;
fontSize: number;
}
interface UseEpubReaderResult {
viewerRef: (_node: HTMLDivElement | null) => void;
isReady: boolean;
isLoading: boolean;
error: string | null;
toc: ReaderTocItem[];
stats: ReaderStats;
nextPage: () => Promise<void>;
prevPage: () => Promise<void>;
goToHref: (href: string) => Promise<void>;
setTheme: (theme: {
colorScheme?: ReaderColorScheme;
fontFamily?: ReaderFontFamily;
fontSize?: number;
}) => Promise<void>;
}
export function useEpubReader({
documentId,
initialProgress,
deviceId,
deviceName,
colorScheme,
fontFamily,
fontSize,
}: UseEpubReaderOptions): UseEpubReaderResult {
const [viewerNode, setViewerNode] = useState<HTMLDivElement | null>(null);
const readerRef = useRef<EBookReader | null>(null);
const [isReady, setIsReady] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [toc, setToc] = useState<ReaderTocItem[]>([]);
const [stats, setStats] = useState<ReaderStats>({
chapterName: 'N/A',
sectionPage: 0,
sectionTotalPages: 0,
percentage: 0,
});
useEffect(() => {
const container = viewerNode;
if (!container) {
return;
}
setIsReady(false);
setIsLoading(true);
setError(null);
setToc([]);
setStats({
chapterName: 'N/A',
sectionPage: 0,
sectionTotalPages: 0,
percentage: 0,
});
const reader = new EBookReader({
container,
documentId,
initialProgress,
deviceId,
deviceName,
colorScheme,
fontFamily,
fontSize,
onReady: () => setIsReady(true),
onLoading: loading => setIsLoading(loading),
onError: message => setError(message),
onStats: nextStats => setStats(nextStats),
onToc: nextToc => setToc(nextToc),
});
readerRef.current = reader;
return () => {
reader.destroy();
if (readerRef.current === reader) {
readerRef.current = null;
}
};
}, [deviceId, deviceName, documentId, initialProgress, viewerNode]);
useEffect(() => {
const reader = readerRef.current;
if (!reader || !isReady) {
return;
}
void reader.applyThemeChange({
colorScheme,
fontFamily,
fontSize,
});
}, [colorScheme, fontFamily, fontSize, isReady]);
const nextPage = useCallback(async () => {
await readerRef.current?.nextPage();
}, []);
const prevPage = useCallback(async () => {
await readerRef.current?.prevPage();
}, []);
const goToHref = useCallback(async (href: string) => {
await readerRef.current?.displayHref(href);
}, []);
const setTheme = useCallback(
async (theme: {
colorScheme?: ReaderColorScheme;
fontFamily?: ReaderFontFamily;
fontSize?: number;
}) => {
await readerRef.current?.applyThemeChange(theme);
},
[]
);
return useMemo(
() => ({
viewerRef: setViewerNode,
isReady,
isLoading,
error,
toc,
stats,
nextPage,
prevPage,
goToHref,
setTheme,
}),
[error, goToHref, isLoading, isReady, nextPage, prevPage, setTheme, stats, toc]
);
}

View File

@@ -0,0 +1,879 @@
import ePub from 'epubjs';
import NoSleep from 'nosleep.js';
import type { ReaderColorScheme, ReaderFontFamily } from '../../utils/localSettings';
const THEMES: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
const THEME_FILE = '/assets/reader/themes.css';
const FONT_FILE = '/assets/reader/fonts.css';
interface TocNode {
href: string;
label?: string;
subitems?: TocNode[];
}
interface EpubContents {
document: Document;
sectionIndex?: number;
range: (cfi: string) => Range;
}
interface EpubVisibleSection {
index: number;
layout: { width: number; divisor: number };
width: () => number;
expand: () => void;
}
interface EpubLocation {
start: {
cfi: string;
href?: string;
};
end: {
cfi: string;
};
}
interface EpubNavigation {
toc?: TocNode[];
}
interface EpubSpineItem {
cfiBase: string;
index: number;
document: Document;
load: (_loader: unknown) => Promise<Document>;
cfiFromElement: (element: Element) => string;
wordCount?: number;
}
interface EpubBook {
ready: Promise<void>;
navigation?: EpubNavigation;
loaded: { navigation: Promise<EpubNavigation> };
spine: {
spineItems: EpubSpineItem[];
get: (index: number) => EpubSpineItem;
hooks: {
content: { register: (_callback: (output: Document) => void) => void };
};
};
load: (...args: unknown[]) => unknown;
renderTo: (element: HTMLElement, options: Record<string, unknown>) => EpubRendition;
getRange: (cfiRange: string) => Promise<Range>;
destroy?: () => void;
}
interface EpubRendition {
next: () => Promise<void>;
prev: () => Promise<void>;
display: (target?: string) => Promise<void>;
currentLocation: () => Promise<EpubLocation>;
getContents: () => EpubContents[];
themes: {
default: (styles: Record<string, unknown>) => void;
register: (name: string, styles: Record<string, unknown> | string) => void;
select: (name: string) => void;
};
hooks: {
content: { register: (_callback: () => void) => void };
render: { register: (_callback: (contents: EpubContents) => void) => void };
};
manager?: {
visible?: () => EpubVisibleSection[];
};
views: () => { container: { scrollLeft: number } };
destroy?: () => void;
}
interface ParsedCfiPath {
steps: unknown[];
terminal: unknown;
}
interface ParsedCfi {
base: unknown;
path: ParsedCfiPath;
}
interface EpubCfiHelper {
parse: (_value: string) => ParsedCfi;
equalStep: (_a: unknown, _b: unknown) => boolean;
segmentString: (_value: unknown) => string;
}
interface EpubWithCfiConstructor {
CFI: new () => EpubCfiHelper;
}
export interface ReaderStats {
chapterName: string;
sectionPage: number;
sectionTotalPages: number;
percentage: number;
}
export interface ReaderTocItem {
title: string;
href: string;
}
interface BookState {
pages: number;
percentage: number;
progress: string;
progressElement: Element | null;
readActivity: unknown[];
words: number;
pageStart: number;
}
interface ReaderSettings {
theme?: {
colorScheme?: ReaderColorScheme;
fontFamily?: string;
fontSize?: number;
};
}
interface EBookReaderOptions {
container: HTMLElement;
documentId: string;
initialProgress?: string;
deviceId: string;
deviceName: string;
colorScheme: ReaderColorScheme;
fontFamily: ReaderFontFamily;
fontSize: number;
onReady: () => void;
onLoading: (_loading: boolean) => void;
onError: (_message: string) => void;
onStats: (_stats: ReaderStats) => void;
onToc: (_toc: ReaderTocItem[]) => void;
}
export class EBookReader {
private container: HTMLElement;
private documentId: string;
private deviceId: string;
private deviceName: string;
private readerSettings: ReaderSettings = {};
private bookState: BookState;
private book: EpubBook;
private rendition: EpubRendition;
private noSleep: NoSleep | null = null;
private wakeTimeoutId: ReturnType<typeof setTimeout> | null = null;
private destroyed = false;
private onReady: () => void;
private onLoading: (_loading: boolean) => void;
private onError: (_message: string) => void;
private onStats: (_stats: ReaderStats) => void;
private onToc: (_toc: ReaderTocItem[]) => void;
private keyupHandler: ((event: KeyboardEvent) => void) | null = null;
constructor(options: EBookReaderOptions) {
this.container = options.container;
this.documentId = options.documentId;
this.deviceId = options.deviceId;
this.deviceName = options.deviceName;
this.onReady = options.onReady;
this.onLoading = options.onLoading;
this.onError = options.onError;
this.onStats = options.onStats;
this.onToc = options.onToc;
this.bookState = {
pages: 0,
percentage: 0,
progress: options.initialProgress ?? '',
progressElement: null,
readActivity: [],
words: 0,
pageStart: Date.now(),
};
this.loadSettings();
this.readerSettings.theme = {
colorScheme: options.colorScheme,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
};
this.onLoading(true);
this.book = ePub(`/api/v1/documents/${this.documentId}/file`, { openAs: 'epub' }) as EpubBook;
this.rendition = this.book.renderTo(this.container, {
manager: 'default',
flow: 'paginated',
width: '100%',
height: '100%',
allowScriptedContent: true,
});
this.initCSP();
this.initWakeLock();
this.initThemes();
this.initViewerListeners();
this.initDocumentListeners();
this.book.ready
.then(this.setupReader.bind(this))
.catch(error => {
if (this.destroyed) {
return;
}
this.onError(error instanceof Error ? error.message : 'Unable to initialize reader');
this.onLoading(false);
});
}
private loadSettings() {
this.readerSettings = {
theme: this.readerSettings.theme ?? {},
};
}
private initWakeLock() {
this.noSleep = new NoSleep();
document.addEventListener('wakelock', this.handleWakeLock);
}
private handleWakeLock = () => {
if (!this.noSleep) {
return;
}
if (this.wakeTimeoutId) {
clearTimeout(this.wakeTimeoutId);
}
this.wakeTimeoutId = setTimeout(() => {
void this.noSleep?.disable();
}, 1000 * 60 * 10);
void this.noSleep.enable();
};
private initThemes() {
THEMES.forEach(theme => this.rendition.themes.register(theme, THEME_FILE));
let themeLinkEl = document.querySelector('#themes') as HTMLLinkElement | null;
if (!themeLinkEl) {
themeLinkEl = document.createElement('link');
themeLinkEl.id = 'themes';
themeLinkEl.rel = 'stylesheet';
themeLinkEl.href = THEME_FILE;
document.head.append(themeLinkEl);
}
this.rendition.themes.default({
'*': {
'font-size': 'var(--editor-font-size) !important',
'font-family': 'var(--editor-font-family) !important',
},
});
this.rendition.hooks.content.register(() => {
this.setTheme();
this.rendition.getContents().forEach(content => {
const existing = content.document.getElementById('reader-fonts');
if (!existing) {
const nextLink = content.document.head.appendChild(content.document.createElement('link'));
nextLink.id = 'reader-fonts';
nextLink.rel = 'stylesheet';
nextLink.href = FONT_FILE;
}
});
});
}
private initCSP() {
const protocol = document.location.protocol;
const host = document.location.host;
const cspURL = `${protocol}//${host}`;
this.book.spine.hooks.content.register(output => {
const cspWrapper = document.createElement('div');
cspWrapper.innerHTML = `
<meta
http-equiv="Content-Security-Policy"
content="require-trusted-types-for 'script';
style-src 'self' blob: 'unsafe-inline' ${cspURL};
object-src 'none';
script-src 'none';"
>`;
const cspMeta = cspWrapper.children[0];
if (cspMeta) {
output.head.append(cspMeta);
}
});
}
private initViewerListeners() {
const nextPage = this.nextPage.bind(this);
const prevPage = this.prevPage.bind(this);
this.rendition.hooks.render.register((contents: EpubContents) => {
const renderDoc = contents.document;
const wakeLockListener = () => {
renderDoc.dispatchEvent(new CustomEvent('wakelock'));
};
renderDoc.addEventListener('click', wakeLockListener);
renderDoc.addEventListener('gesturechange', wakeLockListener);
renderDoc.addEventListener('touchstart', wakeLockListener);
renderDoc.addEventListener('click', (event: MouseEvent) => {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const barPixels = windowHeight * 0.2;
const pagePixels = windowWidth * 0.2;
const top = barPixels;
const bottom = window.innerHeight - top;
const left = pagePixels;
const right = windowWidth - left;
const leftOffset = this.rendition.views().container.scrollLeft;
const yCoord = event.clientY;
const xCoord = event.clientX - leftOffset;
if (yCoord < top || yCoord > bottom) {
return;
}
if (xCoord < left) {
void prevPage();
} else if (xCoord > right) {
void nextPage();
}
});
});
}
private initDocumentListeners() {
const nextPage = this.nextPage.bind(this);
const prevPage = this.prevPage.bind(this);
this.keyupHandler = (event: KeyboardEvent) => {
if ((event.keyCode || event.which) === 37) {
void prevPage();
}
if ((event.keyCode || event.which) === 39) {
void nextPage();
}
if ((event.keyCode || event.which) === 84) {
const currentTheme = this.readerSettings.theme?.colorScheme || 'tan';
const currentThemeIdx = THEMES.indexOf(currentTheme);
const colorScheme =
THEMES.length === currentThemeIdx + 1 ? THEMES[0] : THEMES[currentThemeIdx + 1];
if (colorScheme) {
this.setTheme({ colorScheme });
}
}
};
document.addEventListener('keyup', this.keyupHandler, false);
}
private async setupReader() {
this.bookState.words = await this.countWords();
const { cfi } = await this.getCFIFromXPath(this.bookState.progress);
await this.setPosition(cfi);
const { element } = await this.getCFIFromXPath(this.bookState.progress);
this.bookState.progressElement = element ?? null;
this.highlightPositionMarker();
const stats = await this.getBookStats();
this.onStats(stats);
this.bookState.pageStart = Date.now();
this.onToc(this.getParsedTOC());
this.onLoading(false);
this.onReady();
}
private getParsedTOC(): ReaderTocItem[] {
if (!this.book.navigation?.toc) {
return [];
}
return this.book.navigation.toc.reduce((agg: ReaderTocItem[], item) => {
const sectionTitle = item.label?.trim() ?? '';
agg.push({ title: sectionTitle || 'Untitled', href: item.href });
if (!item.subitems || item.subitems.length === 0) {
return agg;
}
const allSubSections = item.subitems.map(subitem => {
let itemTitle = subitem.label?.trim() ?? 'Untitled';
if (sectionTitle !== '') {
itemTitle = `${sectionTitle} - ${itemTitle}`;
}
return { title: itemTitle, href: subitem.href };
});
agg.push(...allSubSections);
return agg;
}, []);
}
setTheme(newTheme?: {
colorScheme?: ReaderColorScheme;
fontFamily?: string;
fontSize?: number;
}) {
this.readerSettings.theme =
typeof this.readerSettings.theme === 'object' && this.readerSettings.theme !== null
? this.readerSettings.theme
: {};
Object.assign(this.readerSettings.theme, newTheme);
const colorScheme = this.readerSettings.theme.colorScheme || 'tan';
const fontFamily = this.readerSettings.theme.fontFamily || 'serif';
const fontSize = this.readerSettings.theme.fontSize || 1;
this.rendition.themes.select(colorScheme);
const themeColorEl = document.querySelector("[name='theme-color']");
const themeStyleSheet = (document.querySelector('#themes') as HTMLLinkElement | null)?.sheet;
const themeStyleRule = themeStyleSheet
? Array.from(themeStyleSheet.cssRules).find(
item => (item as CSSStyleRule).selectorText === `.${colorScheme}`
)
: null;
if (!themeStyleRule) {
return;
}
const backgroundColor = (themeStyleRule as CSSStyleRule).style.backgroundColor;
themeColorEl?.setAttribute('content', backgroundColor);
document.body.style.backgroundColor = backgroundColor;
this.rendition.getContents().forEach(item => {
item.document.documentElement.style.setProperty('--editor-font-family', fontFamily);
item.document.documentElement.style.setProperty('--editor-font-size', `${fontSize}em`);
item.document.querySelectorAll('.highlight').forEach(element => {
Object.assign((element as HTMLElement).style, {
background: backgroundColor,
});
});
});
}
highlightPositionMarker() {
if (!this.bookState.progressElement) {
return;
}
this.rendition.getContents().forEach(item => {
item.document.querySelectorAll('.highlight').forEach(element => {
element.removeAttribute('style');
element.classList.remove('highlight');
});
});
const backgroundColor = getComputedStyle(this.bookState.progressElement.ownerDocument.body).backgroundColor;
Object.assign((this.bookState.progressElement as HTMLElement).style, {
background: backgroundColor,
filter: 'invert(0.2)',
});
this.bookState.progressElement.classList.add('highlight');
}
async nextPage() {
await this.createActivity();
await this.rendition.next();
this.bookState.pageStart = Date.now();
const stats = await this.getBookStats();
this.onStats(stats);
void this.createProgress();
}
async prevPage() {
await this.rendition.prev();
this.bookState.pageStart = Date.now();
const stats = await this.getBookStats();
this.onStats(stats);
void this.createProgress();
}
async displayHref(href: string) {
await this.rendition.display(href);
}
async setPosition(cfi?: string) {
if (!cfi) {
return;
}
await this.rendition.display(cfi);
await this.rendition.display(cfi);
await this.rendition.display(cfi);
this.highlightPositionMarker();
}
async applyThemeChange(newTheme: {
colorScheme?: ReaderColorScheme;
fontFamily?: string;
fontSize?: number;
}) {
const currentProgress = this.bookState.progress;
const { cfi } = await this.getCFIFromXPath(currentProgress);
this.setTheme(newTheme);
await this.setPosition(cfi);
const { element } = await this.getCFIFromXPath(currentProgress);
this.bookState.progressElement = element ?? null;
this.highlightPositionMarker();
}
async createActivity() {
const WPM_MAX = 2000;
const WPM_MIN = 100;
const pageStart = this.bookState.pageStart;
let elapsedTime = Date.now() - pageStart;
const pageWords = await this.getVisibleWordCount();
const currentWord = await this.getBookWordPosition();
const percentRead = pageWords / this.bookState.words;
const pageWPM = pageWords / (elapsedTime / 60000);
if (pageWPM >= WPM_MAX) {
return;
}
if (pageWPM < WPM_MIN) {
elapsedTime = (pageWords / WPM_MIN) * 60000;
}
const totalPages = Math.round(1 / percentRead);
if (totalPages === 0) {
return;
}
const currentPage = Math.round((currentWord * totalPages) / this.bookState.words);
await fetch('/api/v1/activity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: this.deviceId,
device_name: this.deviceName,
activity: [
{
document_id: this.documentId,
duration: Math.round(elapsedTime / 1000),
start_time: Math.round(pageStart / 1000),
page: currentPage,
pages: totalPages,
},
],
}),
});
}
async createProgress() {
const currentCFI = await this.rendition.currentLocation();
const { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
const currentWord = await this.getBookWordPosition();
this.bookState.progress = xpath ?? '';
this.bookState.progressElement = element ?? null;
const percentage =
this.bookState.words > 0
? Math.round((currentWord / this.bookState.words) * 100000) / 100000
: 0;
this.bookState.percentage = Math.round(percentage * 10000) / 100;
await fetch('/api/v1/progress', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
document_id: this.documentId,
device_id: this.deviceId,
device_name: this.deviceName,
percentage,
progress: this.bookState.progress,
}),
});
}
sectionProgress() {
const visibleItems = this.rendition.manager?.visible?.() ?? [];
if (visibleItems.length === 0) {
return null;
}
const visibleSection = visibleItems[0];
if (!visibleSection) {
return null;
}
const totalBlocks = visibleSection.width() / visibleSection.layout.width;
const leftOffset = this.rendition.views().container.scrollLeft;
const sectionCurrentPage = Math.round(leftOffset / visibleSection.layout.width) + 1;
return {
sectionPages: totalBlocks,
sectionCurrentPage,
};
}
async getBookStats(): Promise<ReaderStats> {
const currentProgress = this.sectionProgress();
if (!currentProgress) {
return {
sectionPage: 0,
sectionTotalPages: 0,
chapterName: 'N/A',
percentage: this.bookState.percentage,
};
}
const currentLocation = await this.rendition.currentLocation();
const currentWord = await this.getBookWordPosition();
const currentTOC = this.book.navigation?.toc?.find(item => item.href === currentLocation.start.href);
return {
sectionPage: currentProgress.sectionCurrentPage,
sectionTotalPages: currentProgress.sectionPages,
chapterName: currentTOC ? currentTOC.label?.trim() || 'N/A' : 'N/A',
percentage:
this.bookState.words > 0
? Math.round((currentWord / this.bookState.words) * 10000) / 100
: 0,
};
}
async getXPathFromCFI(cfi: string) {
const cfiBaseMatch = cfi.match(/\(([^!]+)/);
if (!cfiBaseMatch?.[1]) {
return {} as { xpath?: string; element?: Element | null };
}
const startCFI = cfiBaseMatch[1];
const docFragmentIndex =
(this.book.spine.spineItems.find(item => item.cfiBase === startCFI)?.index ?? -1) + 1;
if (docFragmentIndex <= 0) {
return {} as { xpath?: string; element?: Element | null };
}
const basePos = `/body/DocFragment[${docFragmentIndex}]/body`;
const contents = this.rendition.getContents()[0];
const currentNodeStart = contents?.range(cfi).startContainer;
if (!currentNodeStart) {
return {} as { xpath?: string; element?: Element | null };
}
let currentNode: Node | null = currentNodeStart;
const element =
currentNode.nodeType === Node.ELEMENT_NODE
? (currentNode as Element)
: currentNode.parentElement;
let allPos = '';
while (currentNode && currentNode.nodeName !== 'BODY') {
let parentElement: Element | null = currentNode.parentElement;
if (!parentElement) {
break;
}
if (currentNode.nodeType !== Node.ELEMENT_NODE) {
currentNode = parentElement;
continue;
}
while (parentElement.nodeName === 'A' && parentElement.parentElement) {
parentElement = parentElement.parentElement;
}
const currentElement = currentNode as Element;
const allDescendents = parentElement.querySelectorAll(currentElement.nodeName);
const relativeIndex = Array.from(allDescendents).indexOf(currentElement) + 1;
const nodePos = `${currentElement.nodeName.toLowerCase()}[${relativeIndex}]`;
currentNode = parentElement;
allPos = `/${nodePos}${allPos}`;
}
return { xpath: `${basePos}${allPos}`, element };
}
async getCFIFromXPath(xpath?: string) {
if (!xpath) {
return {} as { cfi?: string; element?: Element | null };
}
const fragMatch = xpath.match(/^\/body\/DocFragment\[(\d+)\]/);
if (!fragMatch?.[1]) {
return {} as { cfi?: string; element?: Element | null };
}
const spinePosition = Number.parseInt(fragMatch[1], 10) - 1;
const sectionItem = this.book.spine.get(spinePosition);
await sectionItem.load(this.book.load.bind(this.book));
const renderedContent = this.rendition
.getContents()
.find(item => item.sectionIndex == spinePosition);
const docItem = renderedContent?.document || sectionItem.document;
const namespaceURI = docItem.documentElement.namespaceURI;
let remainingXPath = xpath
.replace(fragMatch[0], '/html')
.replace(/\.(\d+)$/, '')
.replace(/\/text\(\)(\[\d+\])?$/, '');
const derivedSelectorElement = remainingXPath
.replace(/^\/html\/body/, 'body')
.split('/')
.reduce((element: ParentNode | null, item: string) => {
if (!element) {
return null;
}
const indexMatch = item.match(/(\w+)\[(\d+)\]$/);
if (!indexMatch) {
return element.querySelector(item);
}
const [, tag, rawIndex] = indexMatch;
if (!tag || !rawIndex) {
return null;
}
return element.querySelectorAll(tag)[Number.parseInt(rawIndex, 10) - 1] ?? null;
}, docItem as ParentNode | null);
if (namespaceURI) {
remainingXPath = remainingXPath.split('/').join('/ns:');
}
const docSearch = docItem.evaluate(
remainingXPath,
docItem,
prefix => {
if (prefix === 'ns') {
return namespaceURI;
}
return null;
}
);
const xpathElement = docSearch.iterateNext();
const element = xpathElement || derivedSelectorElement;
const isElementNode = Boolean(element && (element as Node).nodeType === Node.ELEMENT_NODE);
if (!isElementNode) {
return {} as { cfi?: string; element?: Element | null };
}
const resolvedElement = element as Element;
let cfi = sectionItem.cfiFromElement(resolvedElement);
if (cfi.endsWith('!/)')) {
cfi = `${cfi.slice(0, -1)}0)`;
}
return { cfi, element: resolvedElement };
}
async getVisibleWordCount() {
const visibleText = await this.getVisibleText();
return visibleText.trim().split(/\s+/).filter(Boolean).length;
}
async getBookWordPosition() {
const contents = this.rendition.getContents()[0];
if (!contents) {
return 0;
}
const spineItem = this.book.spine.get(contents.sectionIndex ?? 0);
const firstElement = spineItem.document.body.children[0];
if (!firstElement) {
return 0;
}
const firstCFI = spineItem.cfiFromElement(firstElement);
const currentLocation = await this.rendition.currentLocation();
const cfiRange = this.getCFIRange(firstCFI, currentLocation.start.cfi);
const textRange = await this.book.getRange(cfiRange);
const chapterText = textRange.toString();
const chapterWordPosition = chapterText.trim().split(/\s+/).filter(Boolean).length;
const preChapterWordPosition = this.book.spine.spineItems
.slice(0, contents.sectionIndex ?? 0)
.reduce((totalCount, item) => totalCount + (item.wordCount ?? 0), 0);
return chapterWordPosition + preChapterWordPosition;
}
async getVisibleText() {
this.rendition.manager?.visible?.()?.forEach(item => item.expand());
const currentLocation = await this.rendition.currentLocation();
const cfiRange = this.getCFIRange(currentLocation.start.cfi, currentLocation.end.cfi);
const textRange = await this.book.getRange(cfiRange);
return textRange.toString();
}
getCFIRange(a: string, b: string) {
const CFI = new (ePub as unknown as EpubWithCfiConstructor).CFI();
const start = CFI.parse(a);
const end = CFI.parse(b);
const cfi: {
range: boolean;
base: unknown;
path: ParsedCfiPath;
start: ParsedCfiPath;
end: ParsedCfiPath;
} = {
range: true,
base: start.base,
path: { steps: [], terminal: null },
start: start.path,
end: end.path,
};
const len = cfi.start.steps.length;
for (let i = 0; i < len; i += 1) {
if (CFI.equalStep(cfi.start.steps[i], cfi.end.steps[i])) {
if (i === len - 1) {
if (cfi.start.terminal === cfi.end.terminal) {
cfi.path.steps.push(cfi.start.steps[i]);
cfi.range = false;
}
} else {
cfi.path.steps.push(cfi.start.steps[i]);
}
} else {
break;
}
}
cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length);
cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length);
return `epubcfi(${CFI.segmentString(cfi.base)}!${CFI.segmentString(cfi.path)},${CFI.segmentString(cfi.start)},${CFI.segmentString(cfi.end)})`;
}
async countWords() {
const spineWC = await Promise.all(
this.book.spine.spineItems.map(async item => {
const newDoc = await item.load(this.book.load.bind(this.book));
const spineWords = ((newDoc as unknown as HTMLElement).innerText || '')
.trim()
.split(/\s+/)
.filter(Boolean).length;
item.wordCount = spineWords;
return spineWords;
})
);
return spineWC.reduce((totalCount, itemCount) => totalCount + itemCount, 0);
}
destroy() {
this.destroyed = true;
if (this.keyupHandler) {
document.removeEventListener('keyup', this.keyupHandler, false);
}
document.removeEventListener('wakelock', this.handleWakeLock);
if (this.wakeTimeoutId) {
clearTimeout(this.wakeTimeoutId);
}
void this.noSleep?.disable();
this.rendition.destroy?.();
this.book.destroy?.();
}
}

View File

@@ -125,7 +125,7 @@ export default function DocumentPage() {
{document.filepath && (
<a
href={`/reader#id=${document.id}&type=REMOTE`}
href={`/reader/${document.id}`}
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-white hover:bg-secondary-800 focus:outline-none focus:ring-4 focus:ring-secondary-300 dark:bg-secondary-600 dark:hover:bg-secondary-700"
>
Read

View File

@@ -0,0 +1,298 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useGetDocument, useGetProgress } from '../generated/anthoLumeAPIV1';
import { LoadingState } from '../components/LoadingState';
import { CloseIcon } from '../icons';
import {
getReaderColorScheme,
getReaderDevice,
getReaderFontFamily,
getReaderFontSize,
setReaderColorScheme,
setReaderFontFamily,
setReaderFontSize,
type ReaderColorScheme,
type ReaderFontFamily,
} from '../utils/localSettings';
import { useEpubReader } from '../hooks/useEpubReader';
const colorSchemes: ReaderColorScheme[] = ['light', 'tan', 'blue', 'gray', 'black'];
const fontFamilies: ReaderFontFamily[] = ['Serif', 'Open Sans', 'Arbutus Slab', 'Lato'];
export default function ReaderPage() {
const { id } = useParams<{ id: string }>();
const [isTopBarOpen, setIsTopBarOpen] = useState(false);
const [isBottomBarOpen, setIsBottomBarOpen] = useState(true);
const [colorScheme, setColorSchemeState] = useState<ReaderColorScheme>(getReaderColorScheme());
const [fontFamily, setFontFamilyState] = useState<ReaderFontFamily>(getReaderFontFamily());
const [fontSize, setFontSizeState] = useState<number>(getReaderFontSize());
const { id: defaultDeviceId, name: defaultDeviceName } = useMemo(() => getReaderDevice(), []);
const { data: documentResponse, isLoading: isDocumentLoading } = useGetDocument(id || '');
const { data: progressResponse, isLoading: isProgressLoading } = useGetProgress(id || '', {
query: {
retry: false,
},
});
const document = documentResponse?.status === 200 ? documentResponse.data.document : null;
const progress = progressResponse?.status === 200 ? progressResponse.data.progress : undefined;
const deviceId = defaultDeviceId;
const deviceName = defaultDeviceName;
const reader = useEpubReader({
documentId: id || '',
initialProgress: progress?.progress,
deviceId,
deviceName,
colorScheme,
fontFamily,
fontSize,
});
useEffect(() => {
if (document?.title) {
window.document.title = `AnthoLume - Reader - ${document.title}`;
}
}, [document?.title]);
useEffect(() => {
reader.setTheme({ colorScheme, fontFamily, fontSize });
}, [colorScheme, fontFamily, fontSize, reader.setTheme]);
if (isDocumentLoading || isProgressLoading) {
return <LoadingState className="min-h-screen bg-canvas" message="Loading reader..." />;
}
if (!id || !document || documentResponse?.status !== 200) {
return <div className="p-6 text-content-muted">Document not found</div>;
}
return (
<div className="fixed inset-0 z-50 bg-canvas text-content">
<div className="relative flex h-dvh flex-col overflow-hidden">
<div
className={`absolute inset-x-0 top-0 z-20 border-b border-border bg-surface/95 backdrop-blur transition-transform duration-200 ${
isTopBarOpen ? 'translate-y-0' : '-translate-y-full'
}`}
>
<div className="mx-auto flex max-h-[70vh] w-full max-w-6xl flex-col gap-4 overflow-auto p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-4">
<Link to={`/documents/${document.id}`} className="block shrink-0">
<img
className="h-28 w-20 rounded object-cover shadow"
src={`/api/v1/documents/${document.id}/cover`}
alt={`${document.title} cover`}
/>
</Link>
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-content-subtle">Title</p>
<p className="truncate text-lg font-semibold text-content">{document.title}</p>
<p className="mt-3 text-xs uppercase tracking-wide text-content-subtle">Author</p>
<p className="truncate text-sm text-content-muted">{document.author}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Link
to={`/documents/${document.id}`}
className="rounded border border-border px-3 py-2 text-sm text-content-muted hover:bg-surface-muted hover:text-content"
>
Back
</Link>
<button
type="button"
onClick={() => setIsTopBarOpen(false)}
className="rounded border border-border p-2 text-content-muted hover:bg-surface-muted hover:text-content"
aria-label="Close reader details"
>
<CloseIcon size={18} />
</button>
</div>
</div>
<div className="grid gap-2 pb-2 sm:grid-cols-2 lg:grid-cols-3">
{reader.toc.map(item => (
<button
key={`${item.href}-${item.title}`}
type="button"
onClick={() => {
void reader.goToHref(item.href);
setIsTopBarOpen(false);
}}
className="truncate rounded border border-border bg-surface px-3 py-2 text-left text-sm text-content-muted hover:bg-surface-muted hover:text-content"
>
{item.title}
</button>
))}
</div>
</div>
</div>
<div className="absolute left-4 top-4 z-10 flex gap-2">
<button
type="button"
onClick={() => setIsTopBarOpen(open => !open)}
className="rounded bg-surface/90 px-3 py-2 text-sm font-medium text-content shadow backdrop-blur hover:bg-surface"
>
Contents
</button>
<button
type="button"
onClick={() => setIsBottomBarOpen(open => !open)}
className="rounded bg-surface/90 px-3 py-2 text-sm font-medium text-content shadow backdrop-blur hover:bg-surface"
>
Controls
</button>
</div>
<div className="absolute inset-0 pt-[env(safe-area-inset-top)]">
{reader.isLoading && (
<LoadingState
className="absolute inset-0 z-10 min-h-full bg-canvas"
message="Opening book..."
/>
)}
{reader.error ? (
<div className="flex h-full items-center justify-center p-6 text-content-muted">
{reader.error}
</div>
) : (
<div ref={reader.viewerRef} className="size-full bg-canvas" />
)}
</div>
<div
className={`absolute inset-x-0 bottom-0 z-20 border-t border-border bg-surface/95 backdrop-blur transition-transform duration-200 ${
isBottomBarOpen ? 'translate-y-0' : 'translate-y-full'
}`}
>
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-content-muted">
<div>
<span className="text-content-subtle">Chapter:</span> {reader.stats.chapterName}
</div>
<div>
<span className="text-content-subtle">Chapter Pages:</span>{' '}
{reader.stats.sectionPage} / {reader.stats.sectionTotalPages}
</div>
<div>
<span className="text-content-subtle">Progress:</span>{' '}
{reader.stats.percentage.toFixed(2)}%
</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-surface-strong">
<div
className="h-full bg-tertiary-500 transition-all"
style={{ width: `${reader.stats.percentage}%` }}
/>
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_1fr_1fr_auto]">
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-content-subtle">Theme</p>
<div className="flex flex-wrap gap-2">
{colorSchemes.map(option => (
<button
key={option}
type="button"
onClick={() => {
setColorSchemeState(option);
setReaderColorScheme(option);
}}
className={`rounded border px-3 py-2 text-sm capitalize ${
colorScheme === option
? 'border-primary-500 bg-primary-500/10 text-content'
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
}`}
>
{option}
</button>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-content-subtle">Font</p>
<div className="flex flex-wrap gap-2">
{fontFamilies.map(option => (
<button
key={option}
type="button"
onClick={() => {
setFontFamilyState(option);
setReaderFontFamily(option);
}}
className={`rounded border px-3 py-2 text-sm ${
fontFamily === option
? 'border-primary-500 bg-primary-500/10 text-content'
: 'border-border text-content-muted hover:bg-surface-muted hover:text-content'
}`}
>
{option}
</button>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-content-subtle">
Font Size
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const nextSize = Math.max(0.8, Number((fontSize - 0.1).toFixed(2)));
setFontSizeState(nextSize);
setReaderFontSize(nextSize);
}}
className="rounded border border-border px-3 py-2 text-content-muted hover:bg-surface-muted hover:text-content"
>
-
</button>
<div className="min-w-16 text-center text-sm text-content">
{fontSize.toFixed(1)}x
</div>
<button
type="button"
onClick={() => {
const nextSize = Math.min(2.2, Number((fontSize + 0.1).toFixed(2)));
setFontSizeState(nextSize);
setReaderFontSize(nextSize);
}}
className="rounded border border-border px-3 py-2 text-content-muted hover:bg-surface-muted hover:text-content"
>
+
</button>
</div>
</div>
<div className="flex items-end gap-2">
<button
type="button"
onClick={() => void reader.prevPage()}
disabled={!reader.isReady}
className="rounded bg-secondary-700 px-4 py-2 text-sm font-medium text-white hover:bg-secondary-800 disabled:cursor-not-allowed disabled:opacity-50"
>
Previous
</button>
<button
type="button"
onClick={() => void reader.nextPage()}
disabled={!reader.isReady}
className="rounded bg-secondary-700 px-4 py-2 text-sm font-medium text-white hover:bg-secondary-800 disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

4
frontend/src/types/epubjs.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module 'epubjs' {
const ePub: (...args: unknown[]) => unknown;
export default ePub;
}

View File

@@ -1,11 +1,18 @@
export type ThemeMode = 'light' | 'dark' | 'system';
export type DocumentsViewMode = 'grid' | 'list';
export type ReaderColorScheme = 'light' | 'tan' | 'blue' | 'gray' | 'black';
export type ReaderFontFamily = 'Serif' | 'Open Sans' | 'Arbutus Slab' | 'Lato';
const LOCAL_SETTINGS_KEY = 'antholume:settings';
interface LocalSettings {
themeMode?: ThemeMode;
documentsViewMode?: DocumentsViewMode;
readerColorScheme?: ReaderColorScheme;
readerFontFamily?: ReaderFontFamily;
readerFontSize?: number;
readerDeviceId?: string;
readerDeviceName?: string;
}
function canUseLocalStorage(): boolean {
@@ -64,3 +71,77 @@ export function getDocumentsViewMode(): DocumentsViewMode {
export function setDocumentsViewMode(documentsViewMode: DocumentsViewMode): void {
updateLocalSettings({ documentsViewMode });
}
export function getReaderColorScheme(): ReaderColorScheme {
const settings = readLocalSettings();
switch (settings.readerColorScheme) {
case 'light':
case 'tan':
case 'blue':
case 'gray':
case 'black':
return settings.readerColorScheme;
default:
return 'tan';
}
}
export function setReaderColorScheme(readerColorScheme: ReaderColorScheme): void {
updateLocalSettings({ readerColorScheme });
}
export function getReaderFontFamily(): ReaderFontFamily {
const settings = readLocalSettings();
switch (settings.readerFontFamily) {
case 'Serif':
case 'Open Sans':
case 'Arbutus Slab':
case 'Lato':
return settings.readerFontFamily;
default:
return 'Serif';
}
}
export function setReaderFontFamily(readerFontFamily: ReaderFontFamily): void {
updateLocalSettings({ readerFontFamily });
}
export function getReaderFontSize(): number {
const settings = readLocalSettings();
return typeof settings.readerFontSize === 'number' && settings.readerFontSize > 0
? settings.readerFontSize
: 1;
}
export function setReaderFontSize(readerFontSize: number): void {
updateLocalSettings({ readerFontSize });
}
export function getReaderDevice(): { id: string; name: string } {
const settings = readLocalSettings();
const id =
typeof settings.readerDeviceId === 'string' && settings.readerDeviceId.length > 0
? settings.readerDeviceId
: crypto.randomUUID();
const name =
typeof settings.readerDeviceName === 'string' && settings.readerDeviceName.length > 0
? settings.readerDeviceName
: 'Web Reader';
if (id !== settings.readerDeviceId || name !== settings.readerDeviceName) {
updateLocalSettings({
readerDeviceId: id,
readerDeviceName: name,
});
}
return { id, name };
}
export function setReaderDevice(name: string, id?: string): void {
updateLocalSettings({
readerDeviceId: id ?? crypto.randomUUID(),
readerDeviceName: name,
});
}