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/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"], }, }, ], diff --git a/src/components/reader/epub-viewer.tsx b/src/components/reader/epub-viewer.tsx new file mode 100644 index 0000000..c4a79ef --- /dev/null +++ b/src/components/reader/epub-viewer.tsx @@ -0,0 +1,374 @@ +"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"; + + // 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; + 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; + } + }; + // biome-ignore lint/correctness/useExhaustiveDependencies: only re-init when file or flow mode changes + }, [ + file, + flowMode, // Apply theming + applyTheme, + initialCfi, + onLocationChange, + onReady, + ]); + + // 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 {