From 6f88fbdc946028c1fe271cde921289e7a2a722a1 Mon Sep 17 00:00:00 2001 From: TimMikeladze Date: Sat, 21 Feb 2026 04:42:04 +0700 Subject: [PATCH 1/3] feat: add reflowable EPUB reader using epubjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the rasterize-to-canvas EPUB import with a proper reflowable reader. New EPUBs are imported by extracting metadata + cover only (near-instant), with the raw file stored in IndexedDB for epubjs to render as live HTML. - Add epubjs dependency and epubCfi fields to Comic/Bookmark/Note types - Add epubFiles IndexedDB store (DB v7→v8) with save/get/updateProgress - Add parseEpubMetadata for fast metadata-only EPUB import - Build EpubViewer component with paginated/scrolling modes, dark mode theming, keyboard/click-zone/swipe navigation, and resize handling - Wire EpubViewer into reader page with CFI-based position restoration - Adapt PageIndicator (chapter + percentage) and ReaderMenu for EPUB - Previously imported rasterized EPUBs continue working (no migration) Co-Authored-By: Claude Opus 4.6 --- bun.lock | 35 ++ package.json | 1 + src/app/reader/[id]/page.tsx | 93 ++++- .../library/batch-upload-manager.tsx | 71 +++- src/components/reader/epub-viewer.tsx | 375 ++++++++++++++++++ src/components/reader/page-indicator.tsx | 28 +- src/components/reader/reader-menu.tsx | 68 +++- src/hooks/use-app-actions.ts | 40 +- src/lib/comic-parser.ts | 122 ++++++ src/lib/storage.ts | 81 +++- src/lib/types.ts | 3 + 11 files changed, 883 insertions(+), 34 deletions(-) create mode 100644 src/components/reader/epub-viewer.tsx diff --git a/bun.lock b/bun.lock index 62eb4bf..a66ffd6 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "epubjs": "^0.3.93", "flags": "^4.0.3", "input-otp": "^1.4.2", "jszip": "3.10.1", @@ -512,6 +513,8 @@ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/localforage": ["@types/localforage@0.0.34", "", { "dependencies": { "localforage": "*" } }, "sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA=="], + "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], "@types/parse-path": ["@types/parse-path@7.1.0", "", { "dependencies": { "parse-path": "*" } }, "sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q=="], @@ -524,6 +527,8 @@ "@vercel/analytics": ["@vercel/analytics@1.6.1", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.7.13", "", {}, "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -592,12 +597,16 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], + "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=="], "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=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], @@ -660,14 +669,24 @@ "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], + "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-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="], + "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.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + "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=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], @@ -676,12 +695,16 @@ "eta": ["eta@4.5.0", "", {}, "sha512-qifAYjuW5AM1eEEIsFnOwB+TGqu6ynU3OKj9WbUTOtUBHFPZqL03XUW34kbp3zm19Ald+U8dEyRXaVsUck+Y1g=="], + "event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "ext": ["ext@1.7.0", "", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="], + "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -792,6 +815,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="], + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], "lodash.capitalize": ["lodash.capitalize@4.2.1", "", {}, "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw=="], @@ -816,6 +841,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marks-pane": ["marks-pane@1.0.9", "", {}, "sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], @@ -844,6 +871,8 @@ "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="], + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-readable-to-web-readable-stream": ["node-readable-to-web-readable-stream@0.4.2", "", {}, "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ=="], @@ -880,6 +909,8 @@ "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], + "path-webpack": ["path-webpack@0.0.3", "", {}, "sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pdfjs-dist": ["pdfjs-dist@5.4.624", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.88", "node-readable-to-web-readable-stream": "^0.4.2" } }, "sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg=="], @@ -1016,6 +1047,8 @@ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="], + "type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -1112,6 +1145,8 @@ "execa/onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], + "localforage/lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], diff --git a/package.json b/package.json index 9810228..b77c9fe 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "epubjs": "^0.3.93", "flags": "^4.0.3", "input-otp": "^1.4.2", "jszip": "3.10.1", diff --git a/src/app/reader/[id]/page.tsx b/src/app/reader/[id]/page.tsx index 243077b..0abe301 100644 --- a/src/app/reader/[id]/page.tsx +++ b/src/app/reader/[id]/page.tsx @@ -7,6 +7,7 @@ import { use, useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { AttachFileDialog } from "@/components/library/attach-file-dialog"; import { ComicViewer } from "@/components/reader/comic-viewer"; +import { EpubViewer } from "@/components/reader/epub-viewer"; import { NextIssueOverlay } from "@/components/reader/next-issue-overlay"; import { PageIndicator } from "@/components/reader/page-indicator"; import { PdfViewer } from "@/components/reader/pdf-viewer"; @@ -23,12 +24,14 @@ import { getAllComics, getBookmarks, getComic, + getEpubFile, getFileFromHandle, getPagesForComic, getRemotePages, loadPagesFromHandle, saveBookmark, saveNote, + updateEpubProgress, updateReadingProgress, } from "@/lib/storage"; import type { Bookmark, Comic, RemotePage } from "@/lib/types"; @@ -57,6 +60,14 @@ export default function ReaderPage({ // Native PDF viewing state const [pdfFile, setPdfFile] = useState(null); const [useNativePdf, setUseNativePdf] = useState(false); + // EPUB reflowable viewer state + const [epubFile, setEpubFile] = useState(null); + const [useEpubViewer, setUseEpubViewer] = useState(false); + const [epubLocation, setEpubLocation] = useState( + undefined, + ); + const [epubChapter, setEpubChapter] = useState(undefined); + const [readingFraction, setReadingFraction] = useState(0); const containerRef = useRef(null); const router = useRouter(); @@ -301,6 +312,36 @@ export default function ReaderPage({ } try { + // EPUB reflowable viewer: load the raw EPUB file for epubjs + if (comic.format === "epub") { + let blob: Blob | null = null; + + // Try file handle first (desktop, avoids IndexedDB storage) + if (comic.fileHandle) { + try { + const f = await getFileFromHandle(comic.fileHandle); + if (f) blob = f; + } catch { + // fall through to IndexedDB + } + } + + // Fall back to stored epub file + if (!blob) { + blob = await getEpubFile(comic.id); + } + + if (blob) { + setEpubFile(blob); + setUseEpubViewer(true); + setEpubLocation(comic.epubCfi || undefined); + setIsLoading(false); + return; + } + + // No EPUB file found — fall through to legacy rasterized pages + } + let pages: Blob[] | null = null; // For PDFs with a file handle, try native PDF viewing first @@ -379,6 +420,38 @@ export default function ReaderPage({ [comic], ); + // EPUB location change handler with debounced progress save + const epubProgressTimerRef = useRef | null>( + null, + ); + const handleEpubLocationChange = useCallback( + (cfi: string, fraction: number, chapter?: string) => { + setReadingFraction(fraction); + setEpubChapter(chapter); + + // Debounce progress save + if (epubProgressTimerRef.current) { + clearTimeout(epubProgressTimerRef.current); + } + epubProgressTimerRef.current = setTimeout(() => { + if (comic) { + const approxPage = Math.floor(fraction * (comic.totalPages || 1)); + updateEpubProgress(comic.id, cfi, approxPage); + } + }, 1000); + }, + [comic], + ); + + // Cleanup epub progress timer + useEffect(() => { + return () => { + if (epubProgressTimerRef.current) { + clearTimeout(epubProgressTimerRef.current); + } + }; + }, []); + // biome-ignore lint/correctness/useExhaustiveDependencies: loadBookmarks is stable const handleBookmark = useCallback(async () => { if (!comic) return; @@ -473,9 +546,11 @@ export default function ReaderPage({ const isRemote = comic.sourceType === "remote"; const hasContent = isRemote ? remotePageData.length > 0 || usingCachedPages - : useNativePdf - ? pdfFile !== null - : comic.hasFile && comic.totalPages; + : useEpubViewer + ? epubFile !== null + : useNativePdf + ? pdfFile !== null + : comic.hasFile && comic.totalPages; if (!hasContent) { return ( @@ -553,7 +628,14 @@ export default function ReaderPage({ />
- {useNativePdf && pdfFile ? ( + {useEpubViewer && epubFile ? ( + + ) : useNativePdf && pdfFile ? ( + prev.map((f, i) => (i === index ? { ...f, progress: 50 } : f)), + ); + + let coverImage = ""; + if (epubMeta.coverBlob) { + coverImage = await generateThumbnail(epubMeta.coverBlob); + } + + setFileStatuses((prev) => + prev.map((f, i) => (i === index ? { ...f, progress: 75 } : f)), + ); + + const parsed = parseComicTitle(epubMeta.title); + const comic: Comic = { + id: comicId, + title: epubMeta.title, + coverImage, + totalPages: epubMeta.chapterCount, + currentPage: 0, + fileName: epubMeta.fileName, + fileSize: epubMeta.fileSize, + hasFile: true, + format: "epub", + author: epubMeta.author, + publisher: epubMeta.publisher, + fileHandle: handle, + sourceType: "local", + series: parsed.seriesName, + issue: parsed.issueNumber?.toString(), + addedAt: new Date(), + }; + + await saveComic(comic); + await saveEpubFile(comicId, file); + + setFileStatuses((prev) => + prev.map((f, i) => + i === index + ? { ...f, status: "success" as const, progress: 100 } + : f, + ), + ); + + return true; + } + + // Non-EPUB: existing rasterize flow const { pages, metadata } = await parseComicFile(file); setFileStatuses((prev) => @@ -145,7 +211,6 @@ export function BatchUploadManager({ prev.map((f, i) => (i === index ? { ...f, progress: 75 } : f)), ); - const comicId = crypto.randomUUID(); const parsed = parseComicTitle(metadata.title); const comic: Comic = { diff --git a/src/components/reader/epub-viewer.tsx b/src/components/reader/epub-viewer.tsx new file mode 100644 index 0000000..92037c9 --- /dev/null +++ b/src/components/reader/epub-viewer.tsx @@ -0,0 +1,375 @@ +"use client"; + +import type Book from "epubjs/types/book"; +import type Rendition from "epubjs/types/rendition"; +import type { Location } from "epubjs/types/rendition"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useReading } from "@/lib/reading-context"; + +// Lazy-load epubjs +let ePubModule: ((input: ArrayBuffer) => Book) | null = null; + +async function getEpub(): Promise<(input: ArrayBuffer) => Book> { + if (!ePubModule) { + const mod = await import("epubjs"); + ePubModule = mod.default as unknown as (input: ArrayBuffer) => Book; + } + return ePubModule; +} + +interface EpubViewerProps { + file: Blob; + initialCfi?: string; + onLocationChange: (cfi: string, fraction: number, chapter?: string) => void; + onReady?: (totalChapters: number) => void; +} + +export function EpubViewer({ + file, + initialCfi, + onLocationChange, + onReady, +}: EpubViewerProps) { + const { settings } = useReading(); + const containerRef = useRef(null); + const bookRef = useRef(null); + const renditionRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Track swipe gestures + const swipeStartRef = useRef({ x: 0, y: 0, time: 0 }); + + // Determine flow mode from settings + const flowMode = + settings.layoutMode === "scrolling" ? "scrolled-doc" : "paginated"; + + // Initialize book and rendition + useEffect(() => { + let cancelled = false; + let book: Book | null = null; + + async function init() { + if (!containerRef.current) return; + + setIsLoading(true); + setError(null); + + try { + const ePub = await getEpub(); + const arrayBuffer = await file.arrayBuffer(); + if (cancelled) return; + + book = ePub(arrayBuffer); + bookRef.current = book; + + const rendition = book.renderTo(containerRef.current, { + flow: flowMode, + width: "100%", + height: "100%", + allowScriptedContent: false, + }); + + renditionRef.current = rendition; + + // Apply theming + applyTheme(rendition); + + // Listen for location changes + rendition.on("relocated", (location: Location) => { + if (cancelled) return; + const cfi = location.start.cfi; + const fraction = location.start.percentage; + + // Try to get chapter title from navigation + let chapter: string | undefined; + if (book?.navigation) { + const spineItem = book.spine.get(location.start.href); + if (spineItem) { + const navItem = book.navigation.toc.find( + (item) => + item.href === location.start.href || + item.href.split("#")[0] === location.start.href, + ); + if (navItem) { + chapter = navItem.label?.trim(); + } + } + } + + onLocationChange(cfi, fraction, chapter); + }); + + // Display at initial position or beginning + if (initialCfi) { + await rendition.display(initialCfi); + } else { + await rendition.display(); + } + + if (cancelled) return; + + // Report total chapters + if (book.spine) { + let count = 0; + book.spine.each(() => { + count++; + }); + onReady?.(count); + } + + setIsLoading(false); + } catch (err) { + if (!cancelled) { + console.error("[epub-viewer] Error loading EPUB:", err); + setError(err instanceof Error ? err.message : "Failed to load EPUB"); + setIsLoading(false); + } + } + } + + init(); + + return () => { + cancelled = true; + if (renditionRef.current) { + renditionRef.current.destroy(); + renditionRef.current = null; + } + if (bookRef.current) { + bookRef.current.destroy(); + bookRef.current = null; + } + }; + // Only re-init when file or flow mode changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + file, + flowMode, // Apply theming + applyTheme, + initialCfi, + onLocationChange, + onReady, + ]); + + // Apply theme based on dark/light mode + function applyTheme(rendition: Rendition) { + const isDark = document.documentElement.classList.contains("dark"); + + if (isDark) { + rendition.themes.override("color", "#e4e4e7"); + rendition.themes.override("background", "#0d0d0d"); + } else { + rendition.themes.override("color", "#1a1a1a"); + rendition.themes.override("background", "#ffffff"); + } + + // Typography defaults + rendition.themes.default({ + body: { + "font-family": "Georgia, 'Times New Roman', serif !important", + "line-height": "1.6 !important", + "font-size": "18px !important", + }, + "p, div, span, li, td, th, blockquote, h1, h2, h3, h4, h5, h6": { + "font-family": "inherit !important", + }, + img: { + "max-width": "100% !important", + height: "auto !important", + }, + }); + } + + // Watch for dark mode changes + useEffect(() => { + const observer = new MutationObserver(() => { + if (renditionRef.current) { + applyTheme(renditionRef.current); + } + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); + }, [applyTheme]); + + // Resize handling + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver(() => { + if (renditionRef.current && containerRef.current) { + const { clientWidth, clientHeight } = containerRef.current; + renditionRef.current.resize(clientWidth, clientHeight); + } + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + // Apply brightness + useEffect(() => { + if (containerRef.current) { + containerRef.current.style.filter = `brightness(${settings.brightness}%)`; + } + }, [settings.brightness]); + + // Navigation functions + const goNext = useCallback(() => { + renditionRef.current?.next(); + }, []); + + const goPrev = useCallback(() => { + renditionRef.current?.prev(); + }, []); + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + + if (e.key === "ArrowRight" || e.key === "d") { + settings.readingDirection === "ltr" ? goNext() : goPrev(); + } else if (e.key === "ArrowLeft" || e.key === "a") { + settings.readingDirection === "ltr" ? goPrev() : goNext(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [goNext, goPrev, settings.readingDirection]); + + // Also bind keyboard events from within the epub iframe + useEffect(() => { + const rendition = renditionRef.current; + if (!rendition) return; + + const handleKeyInIframe = (e: KeyboardEvent) => { + if (e.key === "ArrowRight" || e.key === "d") { + settings.readingDirection === "ltr" ? goNext() : goPrev(); + } else if (e.key === "ArrowLeft" || e.key === "a") { + settings.readingDirection === "ltr" ? goPrev() : goNext(); + } + }; + + rendition.on("keydown", handleKeyInIframe); + return () => { + rendition.off("keydown", handleKeyInIframe); + }; + }, [goNext, goPrev, settings.readingDirection]); + + // Click zone navigation (left third / right third) + const handleContainerClick = useCallback( + (e: React.MouseEvent) => { + if (flowMode === "scrolled-doc") return; + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const clickX = e.clientX - rect.left; + const zoneWidth = rect.width / 3; + + if (clickX < zoneWidth) { + e.stopPropagation(); + settings.readingDirection === "ltr" ? goPrev() : goNext(); + } else if (clickX > zoneWidth * 2) { + e.stopPropagation(); + settings.readingDirection === "ltr" ? goNext() : goPrev(); + } + }, + [flowMode, goNext, goPrev, settings.readingDirection], + ); + + // Touch/swipe navigation + const handleTouchStart = useCallback((e: React.TouchEvent) => { + if (e.touches.length === 1) { + swipeStartRef.current = { + x: e.touches[0].clientX, + y: e.touches[0].clientY, + time: Date.now(), + }; + } + }, []); + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + if (!settings.swipeToTurnPages || flowMode === "scrolled-doc") return; + if (swipeStartRef.current.time === 0) return; + + const touch = e.changedTouches[0]; + const deltaX = touch.clientX - swipeStartRef.current.x; + const deltaY = touch.clientY - swipeStartRef.current.y; + const deltaTime = Date.now() - swipeStartRef.current.time; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + const velocity = distance / deltaTime; + + const MIN_SWIPE_DISTANCE = 50; + const MIN_SWIPE_VELOCITY = 0.3; + const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY); + + if ( + distance >= MIN_SWIPE_DISTANCE && + velocity >= MIN_SWIPE_VELOCITY && + isHorizontal + ) { + if (settings.readingDirection === "ltr") { + deltaX < 0 ? goNext() : goPrev(); + } else { + deltaX > 0 ? goNext() : goPrev(); + } + } + + swipeStartRef.current = { x: 0, y: 0, time: 0 }; + }, + [ + flowMode, + goNext, + goPrev, + settings.readingDirection, + settings.swipeToTurnPages, + ], + ); + + if (error) { + return ( +
+
+

Failed to load EPUB

+

{error}

+
+
+ ); + } + + return ( +
+ {isLoading && ( +
+
+
+

+ Loading EPUB... +

+
+
+ )} +
+
+ ); +} diff --git a/src/components/reader/page-indicator.tsx b/src/components/reader/page-indicator.tsx index 5d0240a..b02df7d 100644 --- a/src/components/reader/page-indicator.tsx +++ b/src/components/reader/page-indicator.tsx @@ -4,13 +4,19 @@ interface PageIndicatorProps { currentPage: number; totalPages: number; isVisible: boolean; + chapterTitle?: string; + fraction?: number; } export function PageIndicator({ currentPage, totalPages, isVisible, + chapterTitle, + fraction, }: PageIndicatorProps) { + const isEpub = fraction !== undefined; + return (
- {currentPage + 1} - / - {totalPages} + {isEpub ? ( + <> + {chapterTitle && ( + <> + + {chapterTitle} + + · + + )} + {Math.round(fraction * 100)}% + + ) : ( + <> + {currentPage + 1} + / + {totalPages} + + )}
); diff --git a/src/components/reader/reader-menu.tsx b/src/components/reader/reader-menu.tsx index 344a724..9f6d0b1 100644 --- a/src/components/reader/reader-menu.tsx +++ b/src/components/reader/reader-menu.tsx @@ -47,6 +47,7 @@ interface ReaderMenuProps { onRefreshBookmarks: () => void; onDelete: () => void; onComicUpdate?: () => void; + readingFraction?: number; } export function ReaderMenu({ @@ -62,6 +63,7 @@ export function ReaderMenu({ onRefreshBookmarks, onDelete, onComicUpdate, + readingFraction, }: ReaderMenuProps) { const { settings } = useReading(); const [isDesktop, setIsDesktop] = useState(false); @@ -74,6 +76,7 @@ export function ReaderMenu({ const [coverManagerOpen, setCoverManagerOpen] = useState(false); const comicId = comic.id; + const isEpub = comic.format === "epub" && readingFraction !== undefined; const isComplete = comic.totalPages && currentPage >= comic.totalPages - 1; const hasProgress = currentPage > 0; @@ -190,17 +193,38 @@ export function ReaderMenu({ {/* Page Scrubber - Always visible at top */}
- - {currentPage + 1} / {totalPages} - - onPageChange(value)} - className="flex-1" - /> + {isEpub ? ( + <> + + {Math.round((readingFraction ?? 0) * 100)}% + + { + /* EPUB scrubber is read-only for now — epubjs doesn't support seeking by percentage directly */ + }} + className="flex-1" + disabled + /> + + ) : ( + <> + + {currentPage + 1} / {totalPages} + + onPageChange(value)} + className="flex-1" + /> + + )}
@@ -221,16 +245,18 @@ export function ReaderMenu({ Bookmark - { - onPageChange(page); - onOpenChange(false); - }} - bookmarks={bookmarks} - variant="menu" - /> + {!isEpub && ( + { + onPageChange(page); + onOpenChange(false); + }} + bookmarks={bookmarks} + variant="menu" + /> + )} Promise) { handle, }: FileWithHandle): Promise { try { + const format = detectFormat(file); + const comicId = crypto.randomUUID(); + + // EPUB: fast metadata-only import, store raw file for epubjs + if (format === "epub") { + const epubMeta = await parseEpubMetadata(file); + let coverImage = ""; + if (epubMeta.coverBlob) { + coverImage = await generateThumbnail(epubMeta.coverBlob); + } + + const comic: Comic = { + id: comicId, + title: epubMeta.title, + coverImage, + totalPages: epubMeta.chapterCount, + currentPage: 0, + fileName: epubMeta.fileName, + fileSize: epubMeta.fileSize, + hasFile: true, + format: "epub", + author: epubMeta.author, + publisher: epubMeta.publisher, + fileHandle: handle, + sourceType: "local", + addedAt: new Date(), + }; + + await saveComic(comic); + await saveEpubFile(comicId, file); + return true; + } + + // Non-EPUB: existing rasterize flow const { pages, metadata } = await parseComicFile(file); const coverImage = await generateCoverImage(pages[0]); - const comicId = crypto.randomUUID(); - const comic: Comic = { id: comicId, title: metadata.title, diff --git a/src/lib/comic-parser.ts b/src/lib/comic-parser.ts index 0ef4b0a..8b1f2cb 100644 --- a/src/lib/comic-parser.ts +++ b/src/lib/comic-parser.ts @@ -290,6 +290,128 @@ export const SUPPORTED_FORMATS = { description: "CBZ, CBR, PDF, EPUB", }; +/** + * Parse EPUB metadata without rasterizing content. + * Extracts title, author, publisher, cover image, and chapter count + * for fast import. The raw EPUB file is stored separately for epubjs. + */ +export async function parseEpubMetadata(file: File): Promise<{ + title: string; + author?: string; + publisher?: string; + coverBlob: Blob | null; + chapterCount: number; + fileName: string; + fileSize: number; +}> { + const zip = new JSZip(); + const contents = await zip.loadAsync(file); + + // Parse container.xml to find OPF + const containerXml = await contents + .file("META-INF/container.xml") + ?.async("text"); + if (!containerXml) { + throw new EpubParseError("Invalid EPUB: Missing container.xml"); + } + + const rootfileMatch = containerXml.match( + /rootfile[^>]+full-path=["']([^"']+)["']/i, + ); + const opfPath = rootfileMatch?.[1]; + if (!opfPath) { + throw new EpubParseError("Invalid EPUB: Could not find OPF file path"); + } + + const opfDir = opfPath.substring(0, opfPath.lastIndexOf("/") + 1); + const opfContent = await contents.file(opfPath)?.async("text"); + if (!opfContent) { + throw new EpubParseError( + `Invalid EPUB: Could not read OPF file at ${opfPath}`, + ); + } + + // Extract metadata + const titleMatch = opfContent.match(/]*>([^<]+)<\/dc:title>/i); + const authorMatch = opfContent.match( + /]*>([^<]+)<\/dc:creator>/i, + ); + const publisherMatch = opfContent.match( + /]*>([^<]+)<\/dc:publisher>/i, + ); + + // Count spine items (chapters) + const spineSection = opfContent.match(/]*>([\s\S]*?)<\/spine>/i); + let chapterCount = 0; + if (spineSection) { + const itemRefRegex = /]+idref=["'][^"']+["'][^>]*\/?>/gi; + const matches = spineSection[1].match(itemRefRegex); + chapterCount = matches?.length ?? 0; + } + + // Extract cover image from manifest + let coverBlob: Blob | null = null; + + // Strategy 1: Look for meta cover element + const coverMetaMatch = opfContent.match( + /]+name=["']cover["'][^>]+content=["']([^"']+)["']/i, + ); + // Strategy 2: Look for item with properties="cover-image" + const coverPropertyMatch = opfContent.match( + /]+properties=["'][^"']*cover-image[^"']*["'][^>]+href=["']([^"']+)["']/i, + ); + + if (coverMetaMatch) { + // Find the manifest item with this id + const coverId = coverMetaMatch[1]; + const coverItemRegex = new RegExp( + `]+id=["']${coverId}["'][^>]+href=["']([^"']+)["']`, + "i", + ); + const coverItemMatch = opfContent.match(coverItemRegex); + if (coverItemMatch) { + const coverPath = opfDir + decodeURIComponent(coverItemMatch[1]); + coverBlob = (await contents.file(coverPath)?.async("blob")) ?? null; + } + } else if (coverPropertyMatch) { + const coverPath = opfDir + decodeURIComponent(coverPropertyMatch[1]); + coverBlob = (await contents.file(coverPath)?.async("blob")) ?? null; + } + + // Fallback: try to find first image in manifest + if (!coverBlob) { + const manifestSection = opfContent.match( + /]*>([\s\S]*?)<\/manifest>/i, + ); + if (manifestSection) { + const imageItemRegex = + /]+href=["']([^"']+)["'][^>]+media-type=["']image\/[^"']+["'][^>]*\/?>/gi; + const firstImage = imageItemRegex.exec(manifestSection[1]); + if (firstImage) { + const imagePath = opfDir + decodeURIComponent(firstImage[1]); + coverBlob = (await contents.file(imagePath)?.async("blob")) ?? null; + } + } + } + + const title = + titleMatch?.[1]?.trim() || + file.name + .replace(/\.epub$/i, "") + .replace(/[-_]/g, " ") + .trim(); + + return { + title, + author: authorMatch?.[1]?.trim(), + publisher: publisherMatch?.[1]?.trim(), + coverBlob, + chapterCount, + fileName: file.name, + fileSize: file.size, + }; +} + export { EpubParseError } from "./epub-parser"; // Re-export error types for consumers export { PdfParseError, PdfPasswordError } from "./pdf-parser"; diff --git a/src/lib/storage.ts b/src/lib/storage.ts index dd4efcd..323e4e0 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -9,7 +9,7 @@ import type { } from "./types"; const DB_NAME = "comic-reader-db"; -const DB_VERSION = 7; +const DB_VERSION = 8; const COMICS_STORE = "comics"; const PAGES_STORE = "pages"; // For iOS/Safari - stores pages when File System Access unavailable const BOOKMARKS_STORE = "bookmarks"; @@ -17,6 +17,7 @@ const NOTES_STORE = "notes"; const LISTS_STORE = "lists"; const SOURCES_STORE = "sources"; // Data sources (CSV imports) const REMOTE_PAGES_STORE = "remotePages"; // Page URLs for remote comics +const EPUB_FILES_STORE = "epubFiles"; // Raw EPUB blobs for reflowable reader // Release tracker stores const RELEASES_OVERRIDES_STORE = "releasesOverrides"; const CUSTOM_RELEASES_STORE = "customReleases"; @@ -180,6 +181,11 @@ async function initDB(): Promise { keyPath: "releaseId", }); } + + // EPUB files store (raw EPUB blobs for reflowable reader) + if (!database.objectStoreNames.contains(EPUB_FILES_STORE)) { + database.createObjectStore(EPUB_FILES_STORE, { keyPath: "comicId" }); + } }; }); } @@ -231,6 +237,7 @@ export async function deleteComic(id: string): Promise { NOTES_STORE, LISTS_STORE, REMOTE_PAGES_STORE, + EPUB_FILES_STORE, ], "readwrite", ); @@ -247,6 +254,10 @@ export async function deleteComic(id: string): Promise { const remotePagesStore = transaction.objectStore(REMOTE_PAGES_STORE); remotePagesStore.delete(id); + // Delete EPUB file (for reflowable reader) + const epubFilesStore = transaction.objectStore(EPUB_FILES_STORE); + epubFilesStore.delete(id); + // Delete all bookmarks for this comic const bookmarksStore = transaction.objectStore(BOOKMARKS_STORE); const bookmarksIndex = bookmarksStore.index("comicId"); @@ -765,6 +776,7 @@ export async function clearAllData(): Promise { LISTS_STORE, SOURCES_STORE, REMOTE_PAGES_STORE, + EPUB_FILES_STORE, ], "readwrite", ); @@ -775,6 +787,7 @@ export async function clearAllData(): Promise { transaction.objectStore(LISTS_STORE).clear(); transaction.objectStore(SOURCES_STORE).clear(); transaction.objectStore(REMOTE_PAGES_STORE).clear(); + transaction.objectStore(EPUB_FILES_STORE).clear(); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); }); @@ -1363,6 +1376,72 @@ export async function removeOfflineCache(comicId: string): Promise { await deletePagesForComic(comicId); } +// ============ EPUB FILES ============ + +/** + * Save a raw EPUB file blob to IndexedDB for the reflowable reader. + */ +export async function saveEpubFile(comicId: string, file: Blob): Promise { + const database = await initDB(); + return new Promise((resolve, reject) => { + const transaction = database.transaction([EPUB_FILES_STORE], "readwrite"); + const store = transaction.objectStore(EPUB_FILES_STORE); + const request = store.put({ comicId, file }); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +/** + * Get a raw EPUB file blob from IndexedDB. + */ +export async function getEpubFile(comicId: string): Promise { + const database = await initDB(); + return new Promise((resolve, reject) => { + const transaction = database.transaction([EPUB_FILES_STORE], "readonly"); + const store = transaction.objectStore(EPUB_FILES_STORE); + const request = store.get(comicId); + + request.onsuccess = () => { + const result = request.result; + resolve(result ? result.file : null); + }; + request.onerror = () => reject(request.error); + }); +} + +/** + * Update EPUB reading progress by saving a CFI string to the comic record. + */ +export async function updateEpubProgress( + comicId: string, + cfi: string, + currentPage?: number, +): Promise { + const database = await initDB(); + return new Promise((resolve, reject) => { + const transaction = database.transaction([COMICS_STORE], "readwrite"); + const store = transaction.objectStore(COMICS_STORE); + const request = store.get(comicId); + + request.onsuccess = () => { + const comic = request.result; + if (comic) { + comic.epubCfi = cfi; + comic.lastRead = new Date(); + if (currentPage !== undefined) { + comic.currentPage = currentPage; + } + store.put(comic); + } + }; + + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); +} + // ============ RELEASE TRACKER ============ /** diff --git a/src/lib/types.ts b/src/lib/types.ts index f30ad38..16c8854 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -24,6 +24,7 @@ export interface Comic { sourceType: ComicSourceType; sourceId?: string; // Links to ComicSource for remote comics coverUrl?: string; // Remote cover URL (fetched on demand) + epubCfi?: string; // EPUB reading position (Canonical Fragment Identifier) } export interface ComicPage { @@ -54,6 +55,7 @@ export interface Bookmark { note?: string; thumbnailUrl?: string; tags?: string[]; + epubCfi?: string; } export interface Note { @@ -68,6 +70,7 @@ export interface Note { y: number; }; color?: string; + epubCfi?: string; } export interface ComicList { From 676f98205e6907ba14f9a4bad92093da6746e170 Mon Sep 17 00:00:00 2001 From: TimMikeladze Date: Sat, 21 Feb 2026 04:47:14 +0700 Subject: [PATCH 2/3] fix: resolve lint errors in epub-viewer Move applyTheme above init effect and use biome-ignore for intentionally limited dependency array (only re-init on file/flowMode changes). Co-Authored-By: Claude Opus 4.6 --- src/components/reader/epub-viewer.tsx | 61 +++++++++++++-------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/src/components/reader/epub-viewer.tsx b/src/components/reader/epub-viewer.tsx index 92037c9..c4a79ef 100644 --- a/src/components/reader/epub-viewer.tsx +++ b/src/components/reader/epub-viewer.tsx @@ -44,6 +44,35 @@ export function EpubViewer({ const flowMode = settings.layoutMode === "scrolling" ? "scrolled-doc" : "paginated"; + // Apply theme based on dark/light mode + const applyTheme = useCallback((rendition: Rendition) => { + const isDark = document.documentElement.classList.contains("dark"); + + if (isDark) { + rendition.themes.override("color", "#e4e4e7"); + rendition.themes.override("background", "#0d0d0d"); + } else { + rendition.themes.override("color", "#1a1a1a"); + rendition.themes.override("background", "#ffffff"); + } + + // Typography defaults + rendition.themes.default({ + body: { + "font-family": "Georgia, 'Times New Roman', serif !important", + "line-height": "1.6 !important", + "font-size": "18px !important", + }, + "p, div, span, li, td, th, blockquote, h1, h2, h3, h4, h5, h6": { + "font-family": "inherit !important", + }, + img: { + "max-width": "100% !important", + height: "auto !important", + }, + }); + }, []); + // Initialize book and rendition useEffect(() => { let cancelled = false; @@ -141,8 +170,7 @@ export function EpubViewer({ bookRef.current = null; } }; - // Only re-init when file or flow mode changes - // eslint-disable-next-line react-hooks/exhaustive-deps + // biome-ignore lint/correctness/useExhaustiveDependencies: only re-init when file or flow mode changes }, [ file, flowMode, // Apply theming @@ -152,35 +180,6 @@ export function EpubViewer({ onReady, ]); - // Apply theme based on dark/light mode - function applyTheme(rendition: Rendition) { - const isDark = document.documentElement.classList.contains("dark"); - - if (isDark) { - rendition.themes.override("color", "#e4e4e7"); - rendition.themes.override("background", "#0d0d0d"); - } else { - rendition.themes.override("color", "#1a1a1a"); - rendition.themes.override("background", "#ffffff"); - } - - // Typography defaults - rendition.themes.default({ - body: { - "font-family": "Georgia, 'Times New Roman', serif !important", - "line-height": "1.6 !important", - "font-size": "18px !important", - }, - "p, div, span, li, td, th, blockquote, h1, h2, h3, h4, h5, h6": { - "font-family": "inherit !important", - }, - img: { - "max-width": "100% !important", - height: "auto !important", - }, - }); - } - // Watch for dark mode changes useEffect(() => { const observer = new MutationObserver(() => { From 1f313199524cfc1661aede9a10ba9e43523cdb22 Mon Sep 17 00:00:00 2001 From: TimMikeladze Date: Sat, 21 Feb 2026 05:16:07 +0700 Subject: [PATCH 3/3] feat: add EPUB to file picker accept types and empty state The upload dialog and attach file dialog were missing EPUB in their File System Access API types and iOS accept strings, preventing EPUB files from appearing in native file pickers. Co-Authored-By: Claude Opus 4.6 --- src/components/library/attach-file-dialog.tsx | 1 + src/components/library/library-empty-state.tsx | 7 +++++++ src/components/library/upload-dialog.tsx | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/library/attach-file-dialog.tsx b/src/components/library/attach-file-dialog.tsx index 4585a12..2a6a1a0 100644 --- a/src/components/library/attach-file-dialog.tsx +++ b/src/components/library/attach-file-dialog.tsx @@ -102,6 +102,7 @@ export function AttachFileDialog({ "application/zip": [".cbz", ".zip"], "application/x-rar-compressed": [".cbr", ".rar"], "application/pdf": [".pdf"], + "application/epub+zip": [".epub"], }, }, ], diff --git a/src/components/library/library-empty-state.tsx b/src/components/library/library-empty-state.tsx index 412c372..da2dad4 100644 --- a/src/components/library/library-empty-state.tsx +++ b/src/components/library/library-empty-state.tsx @@ -2,6 +2,7 @@ import { BookOpen, + BookText, FileArchive, FileText, FolderOpen, @@ -60,6 +61,12 @@ export function LibraryEmptyState({ onUpload }: LibraryEmptyStateProps) { PDF
+
+ + + EPUB + +
diff --git a/src/components/library/upload-dialog.tsx b/src/components/library/upload-dialog.tsx index 8bd5b1f..afaea60 100644 --- a/src/components/library/upload-dialog.tsx +++ b/src/components/library/upload-dialog.tsx @@ -23,7 +23,7 @@ interface UploadDialogProps { // iOS-friendly accept string - include common MIME types that iOS recognizes // Using * as fallback since iOS may not show .cbz/.cbr files with strict accept const IOS_FRIENDLY_ACCEPT = - ".cbz,.zip,.cbr,.rar,.pdf,application/zip,application/x-zip-compressed,application/pdf,application/x-rar-compressed,*/*"; + ".cbz,.zip,.cbr,.rar,.pdf,.epub,application/zip,application/x-zip-compressed,application/pdf,application/x-rar-compressed,application/epub+zip,*/*"; export function UploadDialog({ open, @@ -76,6 +76,7 @@ export function UploadDialog({ "application/zip": [".cbz", ".zip"], "application/x-rar-compressed": [".cbr", ".rar"], "application/pdf": [".pdf"], + "application/epub+zip": [".epub"], }, }, ],