Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f20ae4967 |
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Share an idea or suggestion
|
||||
title: 'FR: [a handful of words describing the FR]'
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Does your feature request involve difficulty completing a task? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you'd like to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any additional context or screenshots about the feature request here.
|
||||
@@ -56,20 +56,17 @@ jobs:
|
||||
- name: initialize git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: setup pnpm
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.14.4
|
||||
|
||||
- name: setup node
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: install rimraf
|
||||
run: npm install -g rimraf
|
||||
|
||||
- name: install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -86,14 +83,14 @@ jobs:
|
||||
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('apps/readest-app/src-tauri/Cargo.lock') }}
|
||||
workspaces: apps/readest-app/src-tauri -> target
|
||||
|
||||
- name: create .env.local file for Next.js
|
||||
- name: Create .env.local file for Next.js
|
||||
run: |
|
||||
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_DEEPL_API_KEY=${{ secrets.NEXT_PUBLIC_DEEPL_API_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
|
||||
|
||||
- name: copy .env.local to apps/readest-app
|
||||
- name: Copy .env.local to apps/readest-app
|
||||
run: cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Deploy to vercel on merge
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: write
|
||||
deployments: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@v25
|
||||
with:
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
vercel-args: '--prod'
|
||||
vercel-org-id: ${{ secrets.ORG_ID}}
|
||||
vercel-project-id: ${{ secrets.PROJECT_ID}}
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Create vercel preview URL on pull request
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
permissions:
|
||||
contents: write
|
||||
deployments: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@v25
|
||||
id: vercel-deploy
|
||||
with:
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
vercel-org-id: ${{ secrets.ORG_ID}}
|
||||
vercel-project-id: ${{ secrets.PROJECT_ID}}
|
||||
- name: preview-url
|
||||
run: |
|
||||
echo ${{ steps.vercel-deploy.outputs.preview-url }}
|
||||
@@ -1,104 +0,0 @@
|
||||
# Contribution Guidelines
|
||||
|
||||
When contributing to `Readest`, whether on GitHub or in other community spaces:
|
||||
|
||||
- Be respectful, civil, and open-minded.
|
||||
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/chrox/readest/issues) for known issues or fixes.
|
||||
- If you want to make code changes based on your personal opinion(s), make sure you open an issue first describing the changes you want to make, and open a pull request only when your suggestions get approved by maintainers.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Prerequisites
|
||||
|
||||
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/chrox/readest/issues/new/choose) describing the problem you would like to solve.
|
||||
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
Basically you need to install or update the following development tools:
|
||||
|
||||
- **Node.js** and **pnpm** for Next.js development
|
||||
- **Rust** and **Cargo** for Tauri development
|
||||
|
||||
```bash
|
||||
nvm install v22
|
||||
nvm use v22
|
||||
npm install -g pnpm
|
||||
rustup update
|
||||
```
|
||||
|
||||
### Getting Started
|
||||
|
||||
To get started with Readest, follow these steps to clone and build the project.
|
||||
|
||||
#### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/chrox/readest.git
|
||||
cd readest
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
#### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
# might need to rerun this when code is updated
|
||||
pnpm install
|
||||
# copy pdfjs-dist to Next.js public directory
|
||||
pnpm --filter @readest/readest-app setup-pdfjs
|
||||
```
|
||||
|
||||
#### 3. Verify Dependencies Installation
|
||||
|
||||
To confirm that all dependencies are correctly installed, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm tauri info
|
||||
```
|
||||
|
||||
This command will display information about the installed Tauri dependencies and configuration on your platform. Note that the output may vary depending on the operating system and environment setup. Please review the output specific to your platform for any potential issues.
|
||||
|
||||
For Windows targets, “Build Tools for Visual Studio 2022” (or a higher edition of Visual Studio) and the “Desktop development with C++” workflow must be installed. For Windows ARM64 targets, the “VS 2022 C++ ARM64 build tools” and "C++ Clang Compiler for Windows" components must be installed. And make sure `clang` can be found in the path by adding `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\Llvm\x64\bin` for example in the environment variable `Path`.
|
||||
|
||||
#### 4. Build for Development
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
#### 5. Build for Production
|
||||
|
||||
```bash
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
Now you're all setup and can start implementing your changes.
|
||||
|
||||
### Implement your changes
|
||||
|
||||
This project is a monorepo. The code for the `readest-app` is in the `app/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | -------------------------------------------------- |
|
||||
| `pnpm dev-web` | Starts the development server for the web app only |
|
||||
| `pnpm build-web` | Builds the web app |
|
||||
|
||||
Recommended Visual Studio Code plugins for development:
|
||||
|
||||
- JavaScript and TypeScript Nightly (ms-vscode.vscode-typescript-next)
|
||||
- VS Code ESLint extension (dbaeumer.vscode-eslint)
|
||||
- Prettier - Code formatter (esbenp.prettier-vscode)
|
||||
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
|
||||
|
||||
### When you're done
|
||||
|
||||
Check that your code follows the project's style guidelines by running:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Please also make a manual, functional test of your changes. When all that's done, it's time to file a pull request to upstream and fill out the title and body appropriately.
|
||||
|
||||
## Credits
|
||||
|
||||
This documented was inspired by the contributing guidelines for [cloudflare/wrangler2](https://github.com/cloudflare/wrangler2/blob/main/CONTRIBUTING.md).
|
||||
@@ -8,11 +8,10 @@
|
||||
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to offer a seamless cross-platform experience on macOS, Windows, Linux and Web, with support for mobile platforms coming soon.
|
||||
|
||||
[![Website][badge-website]][link-website]
|
||||
[![Web App][badge-web-app]][link-web-readest]
|
||||
[![AGPL Licence][badge-license]](LICENSE)
|
||||
[![OS][badge-platforms]][link-website]
|
||||
[![][badge-discord]][link-discord]
|
||||
<br>
|
||||
[![AGPL Licence][badge-license]](LICENSE)
|
||||
[![Latest release][badge-release]][link-gh-releases]
|
||||
[![Last commit][badge-last-commit]][link-gh-commits]
|
||||
[![Commits][badge-commit-activity]][link-gh-pulse]
|
||||
@@ -25,13 +24,13 @@
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#downloads">Downloads</a> •
|
||||
<a href="#getting-started">Getting Started</a> •
|
||||
<a href="#contributors">Contributors</a> •
|
||||
<a href="#contributing">Contributing</a> •
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://readest.com" target="_blank">
|
||||
<img src="./data/screenshots/landing_preview.png" alt="Readest Banner" width="100%" />
|
||||
<img src="./data/screenshots/readest_landing_preview.png" alt="Readest Banner" width="100%" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -39,19 +38,18 @@
|
||||
|
||||
<div align="left">✅ Implemented</div>
|
||||
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | -------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
|
||||
@@ -61,11 +59,11 @@
|
||||
| **Feature** | **Description** | **Priority** |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
|
||||
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🛠 |
|
||||
| **Sync Across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | 🛠 |
|
||||
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
|
||||
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
|
||||
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🔄 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
|
||||
@@ -76,15 +74,13 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -100,7 +96,7 @@ The Readest app is available for download! 🥳 🚀
|
||||
## Requirements
|
||||
|
||||
- **Node.js** and **pnpm** for Next.js development
|
||||
- **Rust** and **Cargo** for Tauri development
|
||||
- **Rust and Cargo** for Tauri development
|
||||
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
@@ -156,33 +152,31 @@ pnpm tauri dev
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please review our contributing guidelines before you start.
|
||||
|
||||
## Contributors
|
||||
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
|
||||
<!---
|
||||
npx contributor-faces --exclude "*bot*" --limit 100"
|
||||
--->
|
||||
|
||||
<a href="https://github.com/chrox/readest/graphs/contributors">
|
||||
<p align="left">
|
||||
<img width="100" src="https://contrib.rocks/image?repo=chrox/readest" alt="A table of avatars from the project's contributors" />
|
||||
</p>
|
||||
</a>
|
||||
[//]: contributor-faces
|
||||
|
||||
<a href="https://github.com/chrox"><img src="https://avatars.githubusercontent.com/u/751535?v=4" title="chrox" width="50" height="50"></a>
|
||||
|
||||
[//]: contributor-faces
|
||||
|
||||
## License
|
||||
|
||||
Readest is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
The following JavaScript libraries are bundled in this software:
|
||||
|
||||
- [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed.
|
||||
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
|
||||
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
|
||||
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
|
||||
Readest is distributed under the AGPL-3.0 License. See the [LICENSE](<(LICENSE)>) file for details.
|
||||
|
||||
---
|
||||
|
||||
<div align="center" style="color: gray;">Happy reading with Readest!</div>
|
||||
|
||||
[badge-website]: https://img.shields.io/badge/website-readest.com-orange
|
||||
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
|
||||
[badge-license]: https://img.shields.io/github/license/chrox/readest?color=teal
|
||||
[badge-release]: https://img.shields.io/github/release/chrox/readest?color=green
|
||||
[badge-platforms]: https://img.shields.io/badge/OS-macOS%2C%20Windows%2C%20Linux%2C%20Web-green
|
||||
@@ -195,6 +189,6 @@ The following JavaScript libraries are bundled in this software:
|
||||
[link-gh-releases]: https://github.com/chrox/readest/releases
|
||||
[link-gh-commits]: https://github.com/chrox/readest/commits/main
|
||||
[link-gh-pulse]: https://github.com/chrox/readest/pulse
|
||||
[link-discord]: https://discord.gg/gntyVNk3BJ
|
||||
[link-discord]: https://discord.gg/jb2nzDts
|
||||
[link-parallel-read]: https://readest.com/#parallel-read
|
||||
[link-koreader]: https://github.com/koreader/koreader
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
PDFJS_BUILD_PATH=../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build
|
||||
PDFJS_FONTS_PATH=../../packages/foliate-js/node_modules/pdfjs-dist
|
||||
PDFJS_STYLE_PATH=../../packages/foliate-js/vendor/pdfjs
|
||||
|
||||
NEXT_PUBLIC_DEV_SUPABASE_URL=https://gxkhxxxeapexynpouyjz.supabase.co
|
||||
NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd4a2h4eHhlYXBleHlucG91eWp6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ0MzAwNTksImV4cCI6MjA1MDAwNjA1OX0.jhinkQsimQoidsg_U59YD5ROw4PmMJQNKuyXbr4TiQA
|
||||
@@ -7,7 +7,7 @@ const internalHost = process.env.TAURI_DEV_HOST || 'localhost';
|
||||
const nextConfig = {
|
||||
// Ensure Next.js uses SSG instead of SSR
|
||||
// https://nextjs.org/docs/pages/building-your-application/deploying/static-exports
|
||||
output: process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web' ? undefined : 'export',
|
||||
output: process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web' ? undefined : 'output',
|
||||
// Note: This feature is required to use the Next.js Image component in SSG mode.
|
||||
// See https://nextjs.org/docs/messages/export-image-api for different workarounds.
|
||||
images: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.8.6",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -12,10 +12,10 @@
|
||||
"lint": "next lint",
|
||||
"tauri": "tauri",
|
||||
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
|
||||
"copy-flatten-pdfjs-annotation-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
|
||||
"copy-flatten-pdfjs-text-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
|
||||
"copy-pdfjs-js": "dotenv -- cross-var cpx \"%PDFJS_BUILD_PATH%/pdf*\" ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-fonts": "dotenv -- cross-var cpx \"%PDFJS_FONTS_PATH%/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
|
||||
"copy-flatten-pdfjs-annotation-layer-css": "dotenv -- cross-var npx postcss \"%PDFJS_STYLE_PATH%/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
|
||||
"copy-flatten-pdfjs-text-layer-css": "dotenv -- cross-var npx postcss \"%PDFJS_STYLE_PATH%/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
|
||||
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
|
||||
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
|
||||
"setup-pdfjs": "pnpm prepare-public-vendor && pnpm copy-pdfjs",
|
||||
@@ -28,51 +28,45 @@
|
||||
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
"@supabase/supabase-js": "^2.47.7",
|
||||
"@tauri-apps/api": "2.1.1",
|
||||
"@tauri-apps/plugin-cli": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-fs": "^2.2.0",
|
||||
"@tauri-apps/plugin-http": "^2.2.0",
|
||||
"@tauri-apps/plugin-log": "^2.2.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.2",
|
||||
"@tauri-apps/plugin-os": "^2.2.0",
|
||||
"@tauri-apps/plugin-process": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "~2.2.0",
|
||||
"@tauri-apps/plugin-updater": "^2.3.0",
|
||||
"@tauri-apps/plugin-cli": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.1",
|
||||
"@tauri-apps/plugin-fs": "^2.0.3",
|
||||
"@tauri-apps/plugin-http": "^2.0.1",
|
||||
"@tauri-apps/plugin-log": "^2.0.1",
|
||||
"@tauri-apps/plugin-os": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "~2.0.1",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"@zip.js/zip.js": "^2.7.53",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"cssbeautify": "^0.3.1",
|
||||
"epubjs": "^0.3.93",
|
||||
"foliate-js": "workspace:*",
|
||||
"js-md5": "^0.8.3",
|
||||
"next": "15.1.0",
|
||||
"next": "15.0.3",
|
||||
"posthog-js": "^1.194.1",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-icons": "^5.3.0",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"zustand": "5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.1.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/cssbeautify": "^0.3.5",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cpx2": "^8.0.0",
|
||||
"cpx": "^1.5.0",
|
||||
"cross-var": "^1.1.0",
|
||||
"daisyui": "^4.12.14",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "15.0.3",
|
||||
"mkdirp": "^3.0.1",
|
||||
"node-env-run": "^4.0.2",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-nested": "^7.0.2",
|
||||
|
||||
@@ -32,8 +32,6 @@ tauri-plugin-http = "2.0.3"
|
||||
tauri-plugin-devtools = "2.0.0"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-oauth = "2"
|
||||
tauri-plugin-opener = "2.2.2"
|
||||
[patch.crates-io]
|
||||
tauri = { path = "../../../packages/tauri/crates/tauri" }
|
||||
|
||||
|
||||
@@ -58,9 +58,6 @@
|
||||
"updater:default",
|
||||
"process:default",
|
||||
"process:allow-restart",
|
||||
"cli:default",
|
||||
"oauth:allow-start",
|
||||
"oauth:allow-cancel",
|
||||
"opener:default"
|
||||
"cli:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,12 +15,10 @@ mod tauri_traffic_light_positioner_plugin;
|
||||
use tauri::TitleBarStyle;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tauri::{command, Window};
|
||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
||||
use tauri::{WebviewUrl, WebviewWindowBuilder};
|
||||
use tauri_plugin_dialog;
|
||||
use tauri_plugin_fs::FsExt;
|
||||
use tauri_plugin_oauth::start;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use tauri::Listener;
|
||||
@@ -60,24 +58,11 @@ fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn start_server(window: Window) -> Result<u16, String> {
|
||||
start(move |url| {
|
||||
// Because of the unprotected localhost port, you must verify the URL here.
|
||||
// Preferebly send back only the token, or nothing at all if you can handle everything else in Rust.
|
||||
let _ = window.emit("redirect_uri", url);
|
||||
})
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_oauth::init())
|
||||
.invoke_handler(tauri::generate_handler![start_server])
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -130,6 +115,7 @@ pub fn run() {
|
||||
)?;
|
||||
}
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
.title("")
|
||||
.inner_size(800.0, 600.0)
|
||||
.resizable(true)
|
||||
.maximized(true);
|
||||
@@ -137,10 +123,10 @@ pub fn run() {
|
||||
#[cfg(target_os = "macos")]
|
||||
let win_builder = win_builder
|
||||
.decorations(true)
|
||||
.title_bar_style(TitleBarStyle::Overlay).title("");
|
||||
.title_bar_style(TitleBarStyle::Overlay);
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let win_builder = win_builder.decorations(false).transparent(true).title("Readest");
|
||||
let win_builder = win_builder.decorations(false).transparent(true);
|
||||
|
||||
win_builder.build().unwrap();
|
||||
// let win = win_builder.build().unwrap();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use tauri::menu::MenuEvent;
|
||||
use tauri::menu::{SubmenuBuilder, HELP_SUBMENU_ID};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
let global_menu = app.menu().unwrap();
|
||||
@@ -27,12 +27,17 @@ pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
}
|
||||
|
||||
pub fn handle_menu_event(app: &AppHandle, event: &MenuEvent) {
|
||||
let opener = app.opener();
|
||||
if event.id() == "privacy_policy" {
|
||||
let _ = opener.open_url("https://readest.com/privacy-policy", None::<&str>);
|
||||
if let Err(e) = app.shell().open("https://readest.com/privacy-policy", None) {
|
||||
eprintln!("Failed to open privacy policy: {}", e);
|
||||
}
|
||||
} else if event.id() == "report_issue" {
|
||||
let _ = opener.open_url("https://github.com/readest/readest/issues", None::<&str>);
|
||||
if let Err(e) = app.shell().open("mailto:support@bilingify.com", None) {
|
||||
eprintln!("Failed to open mail client: {}", e);
|
||||
}
|
||||
} else if event.id() == "readest_help" {
|
||||
let _ = opener.open_url("https://readest.com/support", None::<&str>);
|
||||
if let Err(e) = app.shell().open("https://readest.com/support", None) {
|
||||
eprintln!("Failed to open support page: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": {
|
||||
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://fonts.gstatic.com https://db.onlinewebfonts.com",
|
||||
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
|
||||
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://fonts.gstatic.com",
|
||||
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost",
|
||||
"frame-src": "'self' blob: asset: http://asset.localhost",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com"
|
||||
@@ -23,7 +23,7 @@
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": {
|
||||
"allow": ["$RESOURCE/**", "$APPDATA/**/*", "$TEMP/**/*"],
|
||||
"allow": ["$RESOURCE/**", "$DOCUMENT/**/*", "$APPDATA/**/*", "$TEMP/**/*"],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,6 @@
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "12.0"
|
||||
},
|
||||
"linux": {
|
||||
"deb": {
|
||||
"section": "text"
|
||||
}
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
"name": "epub",
|
||||
@@ -131,7 +126,7 @@
|
||||
},
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OTAxMURGQkUzQjFENTQKUldSVUhUdSszeEdRNUExdmFkWnlvYWNYNG5wamkxMmUxRk5SejlMOTJVd28yNXlVTDh6Wld4OC8K",
|
||||
"endpoints": ["https://github.com/readest/readest/releases/latest/download/latest.json"]
|
||||
"endpoints": ["https://github.com/chrox/readest/releases/latest/download/latest.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { handleAuthCallback } from '@/helpers/auth';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export default function AuthCallback() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const hash = window.location.hash || '';
|
||||
const params = new URLSearchParams(hash.slice(1));
|
||||
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
const next = params.get('next') ?? '/';
|
||||
|
||||
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
const router = useRouter();
|
||||
useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
router.push('/auth');
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className='bg-base-200/50 text-base-content hero h-screen items-center justify-center'>
|
||||
<div className='hero-content text-neutral-content text-center'>
|
||||
<div className='max-w-md'>
|
||||
<h1 className='mb-5 text-5xl font-bold'>Authentication Error</h1>
|
||||
<p className='mb-5'>
|
||||
Something went wrong during the authentication process. Please try again.
|
||||
</p>
|
||||
<p className='mb-5'>You will be redirected to the login page shortly...</p>
|
||||
<button className='btn btn-primary rounded-xl' onClick={() => router.push('/auth')}>
|
||||
Go to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
'use client';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Auth } from '@supabase/auth-ui-react';
|
||||
import { ThemeSupa } from '@supabase/auth-ui-shared';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
import { FaApple } from 'react-icons/fa';
|
||||
import { VscAzure } from 'react-icons/vsc';
|
||||
import { FaGithub } from 'react-icons/fa';
|
||||
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { handleAuthCallback } from '@/helpers/auth';
|
||||
import { IoArrowBack } from 'react-icons/io5';
|
||||
|
||||
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
|
||||
|
||||
interface ProviderLoginProp {
|
||||
provider: OAuthProvider;
|
||||
handleSignIn: (provider: OAuthProvider) => void;
|
||||
Icon: React.ElementType;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Icon, label }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSignIn(provider)}
|
||||
className={clsx(
|
||||
'mb-2 flex w-64 items-center justify-center rounded border p-2.5',
|
||||
'bg-base-100 border-gray-300 shadow-sm transition hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className='text-neutral-content/70 px-2 text-sm'>{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AuthPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const { isDarkMode } = useTheme();
|
||||
const [port, setPort] = useState<number | null>(null);
|
||||
const isOAuthServerRunning = useRef(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
const signIn = async (provider: OAuthProvider) => {
|
||||
if (!supabase) {
|
||||
throw new Error('No backend connected');
|
||||
}
|
||||
supabase.auth.signOut();
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: {
|
||||
skipBrowserRedirect: true,
|
||||
redirectTo: `http://localhost:${port}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Authentication error:', error);
|
||||
return;
|
||||
}
|
||||
openUrl(data.url);
|
||||
};
|
||||
|
||||
const startOAuthServer = async () => {
|
||||
try {
|
||||
const port = await start();
|
||||
setPort(port);
|
||||
console.log(`OAuth server started on port ${port}`);
|
||||
|
||||
await onUrl((url) => {
|
||||
console.log('Received OAuth URL:', url);
|
||||
const hashMatch = url.match(/#(.*)/);
|
||||
if (hashMatch) {
|
||||
const hash = hashMatch[1];
|
||||
const params = new URLSearchParams(hash);
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
const next = params.get('next') ?? '/';
|
||||
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
|
||||
}
|
||||
});
|
||||
|
||||
await onInvalidUrl((url) => {
|
||||
console.log('Received invalid OAuth URL:', url);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error starting OAuth server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const stopOAuthServer = async () => {
|
||||
try {
|
||||
if (port) {
|
||||
await cancel(port);
|
||||
console.log('OAuth server stopped');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping OAuth server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriAppPlatform()) return;
|
||||
if (isOAuthServerRunning.current) return;
|
||||
isOAuthServerRunning.current = true;
|
||||
|
||||
startOAuthServer();
|
||||
return () => {
|
||||
isOAuthServerRunning.current = false;
|
||||
stopOAuthServer();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const { data: subscription } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (session?.access_token && session.user) {
|
||||
login(session.access_token, session.user);
|
||||
router.push('/library');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription?.subscription.unsubscribe();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isTauriAppPlatform() ? (
|
||||
<div className='flex pt-11'>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className='btn btn-ghost fixed left-3 top-11 h-8 min-h-8 w-8 p-0'
|
||||
>
|
||||
<IoArrowBack size={20} />
|
||||
</button>
|
||||
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
|
||||
<ProviderLogin
|
||||
provider='google'
|
||||
handleSignIn={signIn}
|
||||
Icon={FcGoogle}
|
||||
label='Sign in with Google'
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='apple'
|
||||
handleSignIn={signIn}
|
||||
Icon={FaApple}
|
||||
label='Sign in with Apple'
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='azure'
|
||||
handleSignIn={signIn}
|
||||
Icon={VscAzure}
|
||||
label='Sign in with Azure'
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='github'
|
||||
handleSignIn={signIn}
|
||||
Icon={FaGithub}
|
||||
label='Sign in with GitHub'
|
||||
/>
|
||||
<hr className='my-3 mt-6 w-64 border-t border-gray-200' />
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={true}
|
||||
providers={[]}
|
||||
redirectTo={`http://localhost:${port}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem', paddingTop: '4rem' }}>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className='btn btn-ghost fixed left-10 top-6 h-8 min-h-8 w-8 p-0'
|
||||
>
|
||||
<IoArrowBack size={20} />
|
||||
</button>
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={true}
|
||||
providers={['google', 'apple', 'azure', 'github']}
|
||||
redirectTo='/auth/callback'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 83 KiB |
@@ -1,16 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import Providers from '@/components/Providers';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { CSPostHogProvider } from '@/context/PHContext';
|
||||
|
||||
import '../styles/globals.css';
|
||||
import '../styles/fonts.css';
|
||||
|
||||
const url = 'https://web.readest.com/';
|
||||
const title = 'Readest — Where You Read, Digest and Get Insight';
|
||||
const description =
|
||||
'Discover Readest, the ultimate online ebook reader for immersive and organized reading. ' +
|
||||
'Enjoy seamless access to your digital library, powerful tools for highlighting, bookmarking, ' +
|
||||
'and note-taking, and support for multiple book views. ' +
|
||||
'Perfect for deep reading, analysis, and understanding. Explore now!';
|
||||
const description = 'Readest brings your entire library to your fingertips.';
|
||||
const previewImage = 'https://cdn.readest.com/images/open_graph_preview_read_now.png';
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -18,8 +16,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<meta name='mobile-web-app-capable' content='yes' />
|
||||
<meta name='apple-mobile-web-app-status-bar-style' content='default' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1' />
|
||||
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
@@ -36,9 +32,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<meta name='twitter:description' content={description} />
|
||||
<meta name='twitter:image' content={previewImage} />
|
||||
</head>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
<CSPostHogProvider>
|
||||
<body>
|
||||
<EnvProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</EnvProvider>
|
||||
</body>
|
||||
</CSPostHogProvider>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { MdDelete, MdOpenInNew } from 'react-icons/md';
|
||||
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { Menu, MenuItem } from '@tauri-apps/api/menu';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { getFilename } from '@/utils/book';
|
||||
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
type BookshelfItem = Book | BooksGroup;
|
||||
|
||||
@@ -34,19 +26,19 @@ const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
|
||||
const booksGroup = acc[acc.findIndex((group) => group.name === book.group)];
|
||||
if (booksGroup) {
|
||||
booksGroup.books.push(book);
|
||||
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
|
||||
booksGroup.lastUpdated = Math.max(acc[groupIndex]!.lastUpdated, book.lastUpdated);
|
||||
} else {
|
||||
acc.push({
|
||||
name: book.group,
|
||||
books: [book],
|
||||
updatedAt: book.updatedAt,
|
||||
lastUpdated: book.lastUpdated,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
const ungroupedBooks: Book[] = groups.find((group) => group.name === UNGROUPED_NAME)?.books || [];
|
||||
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== UNGROUPED_NAME);
|
||||
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.lastUpdated - a.lastUpdated);
|
||||
};
|
||||
|
||||
interface BookshelfProps {
|
||||
@@ -57,42 +49,17 @@ interface BookshelfProps {
|
||||
|
||||
const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImportBooks }) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { envConfig } = useEnv();
|
||||
const { deleteBook } = useLibraryStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [clickedImage, setClickedImage] = useState<string | null>(null);
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
const isImportingBook = useRef(false);
|
||||
|
||||
const { setLibrary } = useLibraryStore();
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
setSelectedBooks([]);
|
||||
}, [isSelectMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isImportingBook.current) return;
|
||||
isImportingBook.current = true;
|
||||
|
||||
if (importBookUrl && appService) {
|
||||
const importBook = async () => {
|
||||
console.log('Importing book from URL:', importBookUrl);
|
||||
const book = await appService.importBook(importBookUrl, libraryBooks);
|
||||
if (book) {
|
||||
setLibrary(libraryBooks);
|
||||
appService.saveLibraryBooks(libraryBooks);
|
||||
navigateToReader(router, [book.hash]);
|
||||
}
|
||||
};
|
||||
importBook();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [importBookUrl, appService]);
|
||||
|
||||
const bookshelfItems = generateBookshelfItems(libraryBooks);
|
||||
|
||||
const handleBookClick = (id: string) => {
|
||||
@@ -132,32 +99,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
setShowDeleteAlert(true);
|
||||
};
|
||||
|
||||
const bookContextMenuHandler = async (book: Book, e: React.MouseEvent) => {
|
||||
if (!isTauriAppPlatform()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const osPlatform = getOSPlatform();
|
||||
const fileRevealLabel =
|
||||
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
|
||||
const showBookInFinderMenuItem = await MenuItem.new({
|
||||
text: fileRevealLabel,
|
||||
action: async () => {
|
||||
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
|
||||
revealItemInDir(folder);
|
||||
},
|
||||
});
|
||||
const deleteBookMenuItem = await MenuItem.new({
|
||||
text: 'Delete',
|
||||
action: async () => {
|
||||
deleteBook(envConfig, book);
|
||||
},
|
||||
});
|
||||
const menu = await Menu.new();
|
||||
menu.append(showBookInFinderMenuItem);
|
||||
menu.append(deleteBookMenuItem);
|
||||
menu.popup();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='bookshelf'>
|
||||
<div className='grid grid-cols-3 gap-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8'>
|
||||
@@ -170,7 +111,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
{'format' in item ? (
|
||||
<div
|
||||
className='book-item cursor-pointer'
|
||||
onContextMenu={bookContextMenuHandler.bind(null, item as Book)}
|
||||
onClick={() => handleBookClick(item.hash)}
|
||||
>
|
||||
<div key={(item as Book).hash} className='bg-base-100 shadow-md'>
|
||||
|
||||
@@ -7,9 +7,6 @@ import { PiSelectionAllDuotone } from 'react-icons/pi';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { MdOutlineMenu } from 'react-icons/md';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
isSelectMode: boolean;
|
||||
@@ -88,21 +85,12 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex h-full items-center'>
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end mr-2'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<MdOutlineMenu size={16} />}
|
||||
>
|
||||
<SettingsMenu />
|
||||
</Dropdown>
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
showMaximize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
showClose={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
/>
|
||||
</div>
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
showMaximize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
showClose={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
import { PiUserCircleCheck } from 'react-icons/pi';
|
||||
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface BookMenuProps {
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const router = useRouter();
|
||||
const { envConfig } = useEnv();
|
||||
const { user, logout } = useAuth();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
|
||||
const showAboutReadest = () => {
|
||||
setAboutDialogVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const downloadReadest = () => {
|
||||
window.open(DOWNLOAD_READEST_URL, '_blank');
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleUserLogin = () => {
|
||||
router.push('/auth');
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleUserLogout = () => {
|
||||
logout();
|
||||
settings.keepLogin = false;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const isWebApp = isWebAppPlatform();
|
||||
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
|
||||
const userFullName = user?.user_metadata?.['full_name'];
|
||||
const userDisplayName = userFullName ? userFullName.split(' ')[0] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-3 w-60 shadow-2xl'
|
||||
>
|
||||
{user ? (
|
||||
<MenuItem
|
||||
label={userDisplayName ? `Logged in as ${userDisplayName}` : 'Logged in'}
|
||||
labelClass='!max-w-40'
|
||||
icon={
|
||||
avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt='User Avatar'
|
||||
className='h-5 w-5 rounded-full'
|
||||
referrerPolicy='no-referrer'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
) : (
|
||||
<PiUserCircleCheck size={20} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
<MenuItem label='Sign Out' noIcon onClick={handleUserLogout} />
|
||||
</ul>
|
||||
</MenuItem>
|
||||
) : (
|
||||
<MenuItem
|
||||
label='Sign In'
|
||||
icon={<PiUserCircle size={20} />}
|
||||
onClick={handleUserLogin}
|
||||
></MenuItem>
|
||||
)}
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label='Download Readest' onClick={downloadReadest} />}
|
||||
<MenuItem label='About Readest' onClick={showAboutReadest} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMenu;
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { Book } from '@/types/book';
|
||||
import { getUserLang } from '@/utils/misc';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
import libraryEn from '@/data/demo/library.en.json';
|
||||
import libraryZh from '@/data/demo/library.zh.json';
|
||||
|
||||
const libraries = {
|
||||
en: libraryEn,
|
||||
zh: libraryZh,
|
||||
};
|
||||
|
||||
interface DemoBooks {
|
||||
library: string[];
|
||||
}
|
||||
|
||||
export const useDemoBooks = () => {
|
||||
const { envConfig } = useEnv();
|
||||
const userLang = getUserLang() as keyof typeof libraries;
|
||||
const [books, setBooks] = useState<Book[]>([]);
|
||||
const isLoading = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading.current) return;
|
||||
isLoading.current = true;
|
||||
|
||||
const fetchDemoBooks = async () => {
|
||||
try {
|
||||
const appService = await envConfig.getAppService();
|
||||
const demoBooks = libraries[userLang] || (libraries.en as DemoBooks);
|
||||
const books = await Promise.all(
|
||||
demoBooks.library.map((url) => appService.importBook(url, [], false, true)),
|
||||
);
|
||||
setBooks(books.filter((book) => book !== null) as Book[]);
|
||||
} catch (error) {
|
||||
console.error('Failed to import demo books:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const demoBooksFetchedFlag = localStorage.getItem('demoBooksFetched');
|
||||
if (isWebAppPlatform() && !demoBooksFetchedFlag) {
|
||||
fetchDemoBooks();
|
||||
localStorage.setItem('demoBooksFetched', 'true');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return books;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
@@ -13,21 +13,17 @@ import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useDemoBooks } from './hooks/useDemoBooks';
|
||||
|
||||
import Spinner from '@/components/Spinner';
|
||||
import LibraryHeader from './components/LibraryHeader';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import LibraryHeader from '@/app/library/components/LibraryHeader';
|
||||
import Bookshelf from '@/app/library/components/Bookshelf';
|
||||
|
||||
const LibraryPage = () => {
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
const {
|
||||
library: libraryBooks,
|
||||
setLibrary,
|
||||
@@ -35,14 +31,13 @@ const LibraryPage = () => {
|
||||
clearOpenWithBooks,
|
||||
} = useLibraryStore();
|
||||
useTheme();
|
||||
const { setSettings, saveSettings } = useSettingsStore();
|
||||
const { setSettings } = useSettingsStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
const demoBooks = useDemoBooks();
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
const doAppUpdates = async () => {
|
||||
if (isTauriAppPlatform()) {
|
||||
await checkForAppUpdates();
|
||||
@@ -72,24 +67,10 @@ const LibraryPage = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
|
||||
const initLogin = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
if (token && user) {
|
||||
if (!settings.keepLogin) {
|
||||
settings.keepLogin = true;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
}
|
||||
} else if (settings.keepLogin) {
|
||||
router.push('/auth');
|
||||
}
|
||||
};
|
||||
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 300);
|
||||
const initLibrary = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
@@ -120,7 +101,6 @@ const LibraryPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
initLogin();
|
||||
initLibrary();
|
||||
return () => {
|
||||
clearOpenWithBooks();
|
||||
@@ -128,23 +108,6 @@ const LibraryPage = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (demoBooks.length > 0 && libraryLoaded) {
|
||||
const newLibrary = [...libraryBooks];
|
||||
for (const book of demoBooks) {
|
||||
const idx = newLibrary.findIndex((b) => b.hash === book.hash);
|
||||
if (idx === -1) {
|
||||
newLibrary.push(book);
|
||||
} else {
|
||||
newLibrary[idx] = book;
|
||||
}
|
||||
}
|
||||
setLibrary(newLibrary);
|
||||
appService?.saveLibraryBooks(newLibrary);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [demoBooks, libraryLoaded]);
|
||||
|
||||
const importBooks = async (files: [string | File]) => {
|
||||
setLoading(true);
|
||||
for (const file of files) {
|
||||
@@ -225,13 +188,11 @@ const LibraryPage = () => {
|
||||
{libraryLoaded &&
|
||||
(libraryBooks.length > 0 ? (
|
||||
<div className='mt-12 flex-grow overflow-auto px-2'>
|
||||
<Suspense>
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
onImportBooks={handleImportBooks}
|
||||
/>
|
||||
</Suspense>
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
onImportBooks={handleImportBooks}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero h-screen items-center justify-center'>
|
||||
@@ -248,7 +209,6 @@ const LibraryPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<AboutWindow />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import Reader from '../components/Reader';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ ids: string }> }) {
|
||||
const ids = decodeURIComponent((await params).ids);
|
||||
return <Reader ids={ids} />;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
|
||||
const toggleBookmark = () => {
|
||||
const { booknotes: bookmarks = [] } = config;
|
||||
const { location: cfi, range } = progress;
|
||||
const { location: cfi, sectionHref: href, range } = progress;
|
||||
if (!cfi) return;
|
||||
if (!isBookmarked) {
|
||||
setIsBookmarked(true);
|
||||
@@ -36,20 +36,12 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
id: uniqueId(),
|
||||
type: 'bookmark',
|
||||
cfi,
|
||||
href,
|
||||
text: truncatedText,
|
||||
note: '',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
created: Date.now(),
|
||||
};
|
||||
const existingBookmark = bookmarks.find(
|
||||
(item) => item.type === 'bookmark' && item.cfi === cfi,
|
||||
);
|
||||
if (existingBookmark) {
|
||||
existingBookmark.deletedAt = null;
|
||||
existingBookmark.updatedAt = Date.now();
|
||||
} else {
|
||||
bookmarks.push(bookmark);
|
||||
}
|
||||
bookmarks.push(bookmark);
|
||||
const updatedConfig = updateBooknotes(bookKey, bookmarks);
|
||||
if (updatedConfig) {
|
||||
saveConfig(envConfig, bookKey, updatedConfig, settings);
|
||||
@@ -58,15 +50,14 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
setIsBookmarked(false);
|
||||
const start = CFI.collapse(cfi);
|
||||
const end = CFI.collapse(cfi, true);
|
||||
bookmarks.forEach((item) => {
|
||||
if (
|
||||
item.type === 'bookmark' &&
|
||||
CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0
|
||||
) {
|
||||
item.deletedAt = Date.now();
|
||||
}
|
||||
});
|
||||
const updatedConfig = updateBooknotes(bookKey, bookmarks);
|
||||
const updatedConfig = updateBooknotes(
|
||||
bookKey,
|
||||
bookmarks.filter(
|
||||
(item) =>
|
||||
item.type !== 'bookmark' ||
|
||||
CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) > 0,
|
||||
),
|
||||
);
|
||||
if (updatedConfig) {
|
||||
saveConfig(envConfig, bookKey, updatedConfig, settings);
|
||||
}
|
||||
@@ -81,7 +72,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
const start = CFI.collapse(cfi);
|
||||
const end = CFI.collapse(cfi, true);
|
||||
const locationBookmarked = booknotes
|
||||
.filter((booknote) => booknote.type === 'bookmark' && !booknote.deletedAt)
|
||||
.filter((booknote) => booknote.type === 'bookmark')
|
||||
.some((item) => CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0);
|
||||
setIsBookmarked(locationBookmarked);
|
||||
setBookmarkRibbonVisibility(bookKey, locationBookmarked);
|
||||
|
||||
@@ -12,7 +12,6 @@ import Ribbon from './Ribbon';
|
||||
import SettingsDialog from './settings/SettingsDialog';
|
||||
import Annotator from './annotator/Annotator';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import FootnotePopup from './FootnotePopup';
|
||||
|
||||
interface BooksGridProps {
|
||||
bookKeys: string[];
|
||||
@@ -61,7 +60,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
|
||||
/>
|
||||
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
|
||||
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
|
||||
{viewSettings.scrolled ? null : (
|
||||
<>
|
||||
<SectionInfo section={sectionLabel} gapLeft={marginGap} />
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { BookDoc, getDirection } from '@/libs/document';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { FoliateView, wrappedFoliateView } from '@/types/view';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { BookConfig, BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { useClickEvent } from '../hooks/useClickEvent';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useProgressSync } from '../hooks/useProgressSync';
|
||||
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
|
||||
import { getStyles, mountAdditionalFonts } from '@/utils/style';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
|
||||
import {
|
||||
@@ -18,7 +15,57 @@ import {
|
||||
handleClick,
|
||||
handleWheel,
|
||||
} from '../utils/iframeEventHandlers';
|
||||
import Toast from '@/components/Toast';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
export interface FoliateView extends HTMLElement {
|
||||
open: (book: BookDoc) => Promise<void>;
|
||||
close: () => void;
|
||||
init: (options: { lastLocation: string }) => void;
|
||||
goTo: (href: string) => void;
|
||||
goToFraction: (fraction: number) => void;
|
||||
prev: (distance: number) => void;
|
||||
next: (distance: number) => void;
|
||||
goLeft: () => void;
|
||||
goRight: () => void;
|
||||
getCFI: (index: number, range: Range) => string;
|
||||
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
|
||||
search: (config: BookSearchConfig) => AsyncGenerator<BookSearchResult | string, void, void>;
|
||||
clearSearch: () => void;
|
||||
select: (target: string | number | { fraction: number }) => void;
|
||||
deselect: () => void;
|
||||
history: {
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
back: () => void;
|
||||
forward: () => void;
|
||||
clear: () => void;
|
||||
};
|
||||
renderer: {
|
||||
scrolled?: boolean;
|
||||
viewSize: number;
|
||||
setAttribute: (name: string, value: string | number) => void;
|
||||
removeAttribute: (name: string) => void;
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
goTo?: (params: { index: number; anchor: number }) => void;
|
||||
setStyles?: (css: string) => void;
|
||||
addEventListener: (type: string, listener: EventListener) => void;
|
||||
removeEventListener: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const wrappedFoliateView = (originalView: FoliateView): FoliateView => {
|
||||
const originalAddAnnotation = originalView.addAnnotation.bind(originalView);
|
||||
originalView.addAnnotation = (note: BookNote, remove = false) => {
|
||||
// transform BookNote to foliate annotation
|
||||
const annotation = {
|
||||
value: note.cfi,
|
||||
...note,
|
||||
};
|
||||
return originalAddAnnotation(annotation, remove);
|
||||
};
|
||||
return originalView;
|
||||
};
|
||||
|
||||
const FoliateViewer: React.FC<{
|
||||
bookKey: string;
|
||||
@@ -28,18 +75,12 @@ const FoliateViewer: React.FC<{
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<FoliateView | null>(null);
|
||||
const isViewCreated = useRef(false);
|
||||
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getView, setView: setFoliateView, setProgress, getViewSettings } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { themeCode } = useTheme();
|
||||
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setToastMessage(''), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toastMessage]);
|
||||
|
||||
useProgressSync(bookKey, setToastMessage);
|
||||
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
|
||||
|
||||
const progressRelocateHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
@@ -47,21 +88,41 @@ const FoliateViewer: React.FC<{
|
||||
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
|
||||
};
|
||||
|
||||
const { shouldAutoHideScrollbar, handleScrollbarAutoHide } = useAutoHideScrollbar();
|
||||
const handleScrollbarAutoHide = (doc: Document) => {
|
||||
if (doc && doc.defaultView && doc.defaultView.frameElement) {
|
||||
const iframe = doc.defaultView.frameElement as HTMLIFrameElement;
|
||||
const container = iframe.parentElement?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
let hideScrollbarTimeout: ReturnType<typeof setTimeout>;
|
||||
const showScrollbar = () => {
|
||||
container.style.overflow = 'auto';
|
||||
container.style.scrollbarWidth = 'thin';
|
||||
};
|
||||
|
||||
const hideScrollbar = () => {
|
||||
container.style.overflow = 'hidden';
|
||||
container.style.scrollbarWidth = 'none';
|
||||
requestAnimationFrame(() => {
|
||||
container.style.overflow = 'auto';
|
||||
});
|
||||
};
|
||||
container.addEventListener('scroll', () => {
|
||||
showScrollbar();
|
||||
clearTimeout(hideScrollbarTimeout);
|
||||
hideScrollbarTimeout = setTimeout(hideScrollbar, 1000);
|
||||
});
|
||||
hideScrollbar();
|
||||
}
|
||||
};
|
||||
|
||||
const docLoadHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
console.log('doc loaded:', detail);
|
||||
if (detail.doc) {
|
||||
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewSettings.vertical = writingDir?.vertical || false;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (viewSettings.scrolled && shouldAutoHideScrollbar) {
|
||||
handleScrollbarAutoHide(detail.doc);
|
||||
}
|
||||
|
||||
mountAdditionalFonts(detail.doc);
|
||||
|
||||
if (!detail.doc.isEventListenersAdded) {
|
||||
detail.doc.isEventListenersAdded = true;
|
||||
detail.doc.addEventListener('keydown', handleKeydown.bind(null, bookKey));
|
||||
@@ -89,7 +150,69 @@ const FoliateViewer: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const { handleTurnPage } = useClickEvent(bookKey, viewRef, containerRef);
|
||||
const handleTurnPage = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
if (msg.data && msg.data.bookKey === bookKey) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (msg.data.type === 'iframe-single-click') {
|
||||
const viewElement = containerRef.current;
|
||||
if (viewElement) {
|
||||
const rect = viewElement.getBoundingClientRect();
|
||||
const { screenX, screenY } = msg.data;
|
||||
const consumed = eventDispatcher.dispatchSync('iframe-single-click', {
|
||||
screenX,
|
||||
screenY,
|
||||
});
|
||||
if (!consumed) {
|
||||
const centerStartX = rect.left + rect.width * 0.375;
|
||||
const centerEndX = rect.left + rect.width * 0.625;
|
||||
const centerStartY = rect.top + rect.height * 0.375;
|
||||
const centerEndY = rect.top + rect.height * 0.625;
|
||||
if (
|
||||
screenX >= centerStartX &&
|
||||
screenX <= centerEndX &&
|
||||
screenY >= centerStartY &&
|
||||
screenY <= centerEndY
|
||||
) {
|
||||
// toggle visibility of the header bar and the footer bar
|
||||
setHoveredBookKey(hoveredBookKey ? '' : bookKey);
|
||||
} else if (screenX >= rect.left + rect.width / 2) {
|
||||
viewRef.current?.goRight();
|
||||
} else if (screenX < rect.left + rect.width / 2) {
|
||||
viewRef.current?.goLeft();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
|
||||
const { deltaY } = msg.data;
|
||||
if (deltaY > 0) {
|
||||
viewRef.current?.next(1);
|
||||
} else if (deltaY < 0) {
|
||||
viewRef.current?.prev(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { clientX } = msg;
|
||||
const width = window.innerWidth;
|
||||
const leftThreshold = width * 0.5;
|
||||
const rightThreshold = width * 0.5;
|
||||
if (clientX < leftThreshold) {
|
||||
viewRef.current?.goLeft();
|
||||
} else if (clientX > rightThreshold) {
|
||||
viewRef.current?.goRight();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleTurnPage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleTurnPage);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hoveredBookKey, viewRef]);
|
||||
|
||||
useFoliateEvents(viewRef.current, {
|
||||
onLoad: docLoadHandler,
|
||||
onRelocate: progressRelocateHandler,
|
||||
@@ -114,12 +237,11 @@ const FoliateViewer: React.FC<{
|
||||
const view = wrappedFoliateView(document.createElement('foliate-view') as FoliateView);
|
||||
document.body.append(view);
|
||||
containerRef.current?.appendChild(view);
|
||||
setFoliateView(bookKey, view);
|
||||
|
||||
await view.open(bookDoc);
|
||||
// make sure we can listen renderer events after opening book
|
||||
viewRef.current = view;
|
||||
setFoliateView(bookKey, view);
|
||||
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
|
||||
@@ -158,16 +280,11 @@ const FoliateViewer: React.FC<{
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='foliate-viewer h-[100%] w-[100%]'
|
||||
onClick={(event) => handleTurnPage(event)}
|
||||
ref={containerRef}
|
||||
/>
|
||||
{toastMessage && (
|
||||
<Toast message={toastMessage} toastClass='toast-top toast-end' alertClass='alert-success' />
|
||||
)}
|
||||
</>
|
||||
<div
|
||||
className='foliate-viewer h-[100%] w-[100%]'
|
||||
onClick={(event) => handleTurnPage(event)}
|
||||
ref={containerRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
||||
import Popup from '@/components/Popup';
|
||||
|
||||
interface FootnotePopupProps {
|
||||
bookKey: string;
|
||||
bookDoc: BookDoc;
|
||||
}
|
||||
|
||||
const popupWidth = 360;
|
||||
const popupHeight = 88;
|
||||
const popupPadding = 10;
|
||||
|
||||
const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const footnoteRef = useRef<HTMLDivElement>(null);
|
||||
const footnoteViewRef = useRef<FoliateView | null>(null);
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position | null>();
|
||||
const [popupPosition, setPopupPosition] = useState<Position | null>();
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { themeCode } = useTheme();
|
||||
const view = getView(bookKey);
|
||||
const footnoteHandler = new FootnoteHandler();
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeRender = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
const { view } = detail;
|
||||
footnoteViewRef.current = view;
|
||||
footnoteRef.current?.replaceChildren(view);
|
||||
const { renderer } = view;
|
||||
renderer.setAttribute('flow', 'scrolled');
|
||||
renderer.setAttribute('margin', '0px');
|
||||
renderer.setAttribute('gap', '5%');
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const popupTheme = { ...themeCode };
|
||||
const popupContainer = document.getElementById('popup-container');
|
||||
if (popupContainer) {
|
||||
const backgroundColor = getComputedStyle(popupContainer).backgroundColor;
|
||||
popupTheme.bg = backgroundColor;
|
||||
}
|
||||
renderer.setStyles?.(getStyles(viewSettings, popupTheme));
|
||||
};
|
||||
|
||||
const handleRender = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
console.log('render footnote', detail);
|
||||
setShowPopup(true);
|
||||
};
|
||||
|
||||
footnoteHandler.addEventListener('before-render', handleBeforeRender);
|
||||
footnoteHandler.addEventListener('render', handleRender);
|
||||
return () => {
|
||||
footnoteHandler.removeEventListener('before-render', handleBeforeRender);
|
||||
footnoteHandler.removeEventListener('render', handleRender);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view, themeCode]);
|
||||
|
||||
const docLinkHandler = async (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
console.log('doc link click', detail);
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const triangPos = getPosition(detail.a, rect, viewSettings.vertical);
|
||||
const popupPos = getPopupPosition(triangPos, rect, popupWidth, popupHeight, popupPadding);
|
||||
setTrianglePosition(triangPos);
|
||||
setPopupPosition(popupPos);
|
||||
|
||||
footnoteHandler.handle(bookDoc, event)?.catch((err) => {
|
||||
console.warn(err);
|
||||
const detail = (event as CustomEvent).detail;
|
||||
view?.goTo(detail.href);
|
||||
});
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
const view = footnoteRef.current?.querySelector('foliate-view') as FoliateView;
|
||||
view?.close();
|
||||
view?.remove();
|
||||
};
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
closePopup();
|
||||
setPopupPosition(null);
|
||||
setTrianglePosition(null);
|
||||
setShowPopup(false);
|
||||
};
|
||||
|
||||
useFoliateEvents(view, {
|
||||
onLinkClick: docLinkHandler,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (footnoteViewRef.current) {
|
||||
footnoteRef.current?.replaceChildren(footnoteViewRef.current);
|
||||
}
|
||||
}, [footnoteRef]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showPopup && (
|
||||
<div
|
||||
className='fixed inset-0'
|
||||
onClick={handleDismissPopup}
|
||||
onContextMenu={handleDismissPopup}
|
||||
/>
|
||||
)}
|
||||
<Popup
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={showPopup ? popupPosition! : undefined}
|
||||
trianglePosition={showPopup ? trianglePosition! : undefined}
|
||||
className='select-text overflow-y-auto'
|
||||
>
|
||||
<div
|
||||
className=''
|
||||
ref={footnoteRef}
|
||||
style={{
|
||||
width: `${popupWidth}px`,
|
||||
height: `${popupHeight}px`,
|
||||
}}
|
||||
></div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FootnotePopup;
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -38,11 +38,11 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
|
||||
useBookShortcuts({ sideBarBookKey, bookKeys });
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
|
||||
const bookIds = ids || searchParams?.get('ids') || '';
|
||||
const bookIds = ids || searchParams.get('ids') || '';
|
||||
const initialIds = bookIds.split(BOOK_IDS_SEPARATOR).filter(Boolean);
|
||||
const initialBookKeys = initialIds.map((id) => `${id}-${uniqueId()}`);
|
||||
setBookKeys(initialBookKeys);
|
||||
@@ -60,28 +60,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', handleCloseBooks);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleCloseBooks);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKeys]);
|
||||
|
||||
const saveBookConfig = (bookKey: string) => {
|
||||
const saveConfigAndCloseBook = (bookKey: string) => {
|
||||
console.log('Closing book', bookKey);
|
||||
getView(bookKey)?.close();
|
||||
getView(bookKey)?.remove();
|
||||
const config = getConfig(bookKey);
|
||||
const { book } = getBookData(bookKey) || {};
|
||||
const { isPrimary } = getViewState(bookKey) || {};
|
||||
if (isPrimary && book && config) {
|
||||
saveConfig(envConfig, bookKey, config, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfigAndCloseBook = async (bookKey: string) => {
|
||||
console.log('Closing book', bookKey);
|
||||
getView(bookKey)?.close();
|
||||
getView(bookKey)?.remove();
|
||||
saveBookConfig(bookKey);
|
||||
clearViewState(bookKey);
|
||||
};
|
||||
|
||||
@@ -94,12 +82,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
bookKeys.forEach((key) => {
|
||||
saveConfigAndCloseBook(key);
|
||||
});
|
||||
saveSettings(envConfig, settings);
|
||||
};
|
||||
|
||||
const handleCloseBooksToLibrary = () => {
|
||||
handleCloseBooks();
|
||||
navigateToLibrary(router);
|
||||
saveSettingsAndGoToLibrary();
|
||||
};
|
||||
|
||||
const handleCloseBook = async (bookKey: string) => {
|
||||
@@ -133,7 +116,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
|
||||
return (
|
||||
<div className='flex h-screen'>
|
||||
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
|
||||
<SideBar onGoToLibrary={handleCloseBooks} />
|
||||
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
|
||||
<Notebook />
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,10 @@ import { Overlayer } from 'foliate-js/overlayer.js';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { BookNote, HighlightColor, HighlightStyle } from '@/types/book';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
|
||||
import { useNotesSync } from '../../hooks/useNotesSync';
|
||||
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import Toast from '@/components/Toast';
|
||||
@@ -26,22 +24,19 @@ import AnnotationPopup from './AnnotationPopup';
|
||||
import WiktionaryPopup from './WiktionaryPopup';
|
||||
import WikipediaPopup from './WikipediaPopup';
|
||||
import DeepLPopup from './DeepLPopup';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
|
||||
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
|
||||
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
|
||||
const { getProgress, getView, getViewsById } = useReaderStore();
|
||||
const { isNotebookPinned, isNotebookVisible } = useNotebookStore();
|
||||
const { setNotebookVisible, setNotebookNewAnnotation } = useNotebookStore();
|
||||
|
||||
useNotesSync(bookKey);
|
||||
|
||||
const config = getConfig(bookKey)!;
|
||||
const progress = getProgress(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const isShowingPopup = useRef(false);
|
||||
const isTextSelected = useRef(false);
|
||||
@@ -53,7 +48,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position>();
|
||||
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
|
||||
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
|
||||
const [translatorPopupPosition, setTranslatorPopupPosition] = useState<Position>();
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
|
||||
|
||||
@@ -65,9 +59,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
);
|
||||
|
||||
const dictPopupWidth = 480;
|
||||
const dictPopupHeight = 300;
|
||||
const transPopupWidth = 480;
|
||||
const transPopupHeight = 360;
|
||||
const dictPopupHeight = 360;
|
||||
const annotPopupWidth = 280;
|
||||
const annotPopupHeight = 44;
|
||||
const popupPadding = 10;
|
||||
@@ -104,9 +96,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const { value: cfi, index, range } = detail;
|
||||
const { booknotes = [] } = config;
|
||||
const annotations = booknotes.filter(
|
||||
(booknote) => booknote.type === 'annotation' && !booknote.deletedAt,
|
||||
);
|
||||
const annotations = booknotes.filter((booknote) => booknote.type === 'annotation');
|
||||
const annotation = annotations.find((annotation) => annotation.cfi === cfi);
|
||||
if (!annotation) return;
|
||||
const selection = { key: bookKey, annotated: true, text: annotation.text ?? '', range, index };
|
||||
@@ -115,7 +105,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setSelection(selection);
|
||||
};
|
||||
|
||||
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation });
|
||||
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation }, [config]);
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
setSelection(null);
|
||||
@@ -158,7 +148,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const triangPos = getPosition(selection.range, rect, viewSettings.vertical);
|
||||
const triangPos = getPosition(selection.range, rect);
|
||||
const annotPopupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
@@ -173,22 +163,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
dictPopupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
const transPopupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
transPopupWidth,
|
||||
transPopupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
if (triangPos.point.x == 0 || triangPos.point.y == 0) return;
|
||||
setShowAnnotPopup(true);
|
||||
setAnnotPopupPosition(annotPopupPos);
|
||||
setDictPopupPosition(dictPopupPos);
|
||||
setTranslatorPopupPosition(transPopupPos);
|
||||
setTrianglePosition(triangPos);
|
||||
isShowingPopup.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selection, bookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -204,7 +185,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { booknotes = [] } = config;
|
||||
const annotations = booknotes.filter(
|
||||
(item) =>
|
||||
!item.deletedAt &&
|
||||
item.type === 'annotation' &&
|
||||
item.style &&
|
||||
CFI.compare(item.cfi, start) >= 0 &&
|
||||
@@ -224,21 +204,21 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
const { booknotes: annotations = [] } = config;
|
||||
if (selection) navigator.clipboard.writeText(selection.text);
|
||||
const { sectionHref: href } = progress;
|
||||
const cfi = view?.getCFI(selection.index, selection.range);
|
||||
if (!cfi) return;
|
||||
const annotation: BookNote = {
|
||||
id: uniqueId(),
|
||||
type: 'excerpt',
|
||||
cfi,
|
||||
href,
|
||||
text: selection.text,
|
||||
note: '',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
created: Date.now(),
|
||||
};
|
||||
|
||||
const existingIndex = annotations.findIndex(
|
||||
(annotation) =>
|
||||
annotation.cfi === cfi && annotation.type === 'excerpt' && !annotation.deletedAt,
|
||||
(annotation) => annotation.cfi === cfi && annotation.type === 'excerpt',
|
||||
);
|
||||
if (existingIndex !== -1) {
|
||||
annotations[existingIndex] = annotation;
|
||||
@@ -257,6 +237,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
if (!selection || !selection.text) return;
|
||||
setHighlightOptionsVisible(true);
|
||||
const { booknotes: annotations = [] } = config;
|
||||
const { sectionHref: href } = progress;
|
||||
const cfi = view?.getCFI(selection.index, selection.range);
|
||||
if (!cfi) return;
|
||||
const style = settings.globalReadSettings.highlightStyle;
|
||||
@@ -265,16 +246,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
id: uniqueId(),
|
||||
type: 'annotation',
|
||||
cfi,
|
||||
href,
|
||||
style,
|
||||
color,
|
||||
text: selection.text,
|
||||
note: '',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
created: Date.now(),
|
||||
};
|
||||
const existingIndex = annotations.findIndex(
|
||||
(annotation) =>
|
||||
annotation.cfi === cfi && annotation.type === 'annotation' && !annotation.deletedAt,
|
||||
(annotation) => annotation.cfi === cfi && annotation.type === 'annotation',
|
||||
);
|
||||
const views = getViewsById(bookKey.split('-')[0]!);
|
||||
if (existingIndex !== -1) {
|
||||
@@ -283,7 +263,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
annotations[existingIndex] = annotation;
|
||||
views.forEach((view) => view?.addAnnotation(annotation));
|
||||
} else {
|
||||
annotations[existingIndex]!.deletedAt = Date.now();
|
||||
annotations.splice(existingIndex, 1);
|
||||
setShowAnnotPopup(false);
|
||||
}
|
||||
} else {
|
||||
@@ -377,13 +357,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
popupHeight={dictPopupHeight}
|
||||
/>
|
||||
)}
|
||||
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
|
||||
{showDeepLPopup && trianglePosition && dictPopupPosition && (
|
||||
<DeepLPopup
|
||||
text={selection?.text as string}
|
||||
position={translatorPopupPosition}
|
||||
position={dictPopupPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
popupWidth={transPopupWidth}
|
||||
popupHeight={transPopupHeight}
|
||||
popupWidth={dictPopupWidth}
|
||||
popupHeight={dictPopupHeight}
|
||||
/>
|
||||
)}
|
||||
{showAnnotPopup && trianglePosition && annotPopupPosition && (
|
||||
@@ -400,7 +380,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{toastMessage && <Toast message={toastMessage} alertClass='bg-neutual text-content' />}
|
||||
{toastMessage && <Toast message={toastMessage} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -120,7 +120,8 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
className='select-text'
|
||||
className='bg-neutral select-text'
|
||||
triangleClassName='text-neutral'
|
||||
>
|
||||
<div className='text-neutral-content relative h-[50%] overflow-y-auto border-b border-neutral-400/75 p-4 font-sans'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
|
||||
@@ -96,8 +96,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
|
||||
const langCode = bookLang ? bookLang.split('-')[0]! : 'en';
|
||||
const langCode = typeof lang === 'string' ? lang : lang?.[0];
|
||||
fetchSummary(text, langCode);
|
||||
}, [text, lang]);
|
||||
|
||||
@@ -108,7 +107,8 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
trianglePosition={trianglePosition}
|
||||
className='select-text overflow-y-auto'
|
||||
className='bg-neutral select-text overflow-y-auto'
|
||||
triangleClassName='text-neutral'
|
||||
>
|
||||
<main className='p-2 font-sans'></main>
|
||||
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
|
||||
@@ -163,7 +163,8 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
className='select-text overflow-y-auto'
|
||||
className='bg-neutral select-text overflow-y-auto'
|
||||
triangleClassName='text-neutral'
|
||||
>
|
||||
<main className='p-4 font-sans' />
|
||||
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
|
||||
@@ -72,9 +72,9 @@ const Notebook: React.FC = ({}) => {
|
||||
type: 'annotation',
|
||||
cfi,
|
||||
note,
|
||||
href: selection.href || '',
|
||||
text: selection.text,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
created: Date.now(),
|
||||
};
|
||||
annotations.push(annotation);
|
||||
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
||||
@@ -84,18 +84,14 @@ const Notebook: React.FC = ({}) => {
|
||||
setNotebookNewAnnotation(null);
|
||||
};
|
||||
|
||||
const handleEditNote = (note: BookNote, isDelete: boolean) => {
|
||||
const handleEditNote = (annotation: BookNote) => {
|
||||
if (!sideBarBookKey) return;
|
||||
const config = getConfig(sideBarBookKey)!;
|
||||
const { booknotes: annotations = [] } = config;
|
||||
const existingIndex = annotations.findIndex((item) => item.id === note.id);
|
||||
const existingIndex = annotations.findIndex((item) => item.id === annotation.id);
|
||||
if (existingIndex === -1) return;
|
||||
if (isDelete) {
|
||||
note.deletedAt = Date.now();
|
||||
} else {
|
||||
note.updatedAt = Date.now();
|
||||
}
|
||||
annotations[existingIndex] = note;
|
||||
annotation.modified = Date.now();
|
||||
annotations[existingIndex] = annotation;
|
||||
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
||||
if (updatedConfig) {
|
||||
saveConfig(envConfig, sideBarBookKey, updatedConfig, settings);
|
||||
@@ -104,17 +100,28 @@ const Notebook: React.FC = ({}) => {
|
||||
};
|
||||
|
||||
const { handleMouseDown } = useDragBar(handleDragMove);
|
||||
const deleteNote = (note: BookNote) => {
|
||||
if (!sideBarBookKey) return;
|
||||
const config = getConfig(sideBarBookKey);
|
||||
if (!config) return;
|
||||
const { booknotes = [] } = config;
|
||||
const updatedNotes = booknotes.filter((item) => item.id !== note.id);
|
||||
const updatedConfig = updateBooknotes(sideBarBookKey, updatedNotes);
|
||||
if (updatedConfig) {
|
||||
saveConfig(envConfig, sideBarBookKey, updatedConfig, settings);
|
||||
}
|
||||
};
|
||||
|
||||
if (!sideBarBookKey) return null;
|
||||
|
||||
const config = getConfig(sideBarBookKey);
|
||||
const { booknotes: allNotes = [] } = config || {};
|
||||
const annotationNotes = allNotes
|
||||
.filter((note) => note.type === 'annotation' && note.note && !note.deletedAt)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
.filter((note) => note.type === 'annotation' && note.note)
|
||||
.sort((a, b) => b.created - a.created);
|
||||
const excerptNotes = allNotes
|
||||
.filter((note) => note.type === 'excerpt' && note.text && !note.deletedAt)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
.filter((note) => note.type === 'excerpt')
|
||||
.sort((a, b) => a.created - b.created);
|
||||
|
||||
return isNotebookVisible ? (
|
||||
<>
|
||||
@@ -169,7 +176,7 @@ const Notebook: React.FC = ({}) => {
|
||||
'btn btn-ghost settings-content hover:bg-transparent',
|
||||
'flex h-[1em] min-h-[1em] items-end p-0',
|
||||
)}
|
||||
onClick={handleEditNote.bind(null, item, true)}
|
||||
onClick={deleteNote.bind(null, item)}
|
||||
>
|
||||
<div className='align-bottom text-xs text-red-400'>Delete</div>
|
||||
</button>
|
||||
@@ -185,7 +192,7 @@ const Notebook: React.FC = ({}) => {
|
||||
)}
|
||||
</div>
|
||||
{(notebookNewAnnotation || notebookEditAnnotation) && (
|
||||
<NoteEditor onSave={handleSaveNote} onEdit={(item) => handleEditNote(item, false)} />
|
||||
<NoteEditor onSave={handleSaveNote} onEdit={handleEditNote} />
|
||||
)}
|
||||
<ul className=''>
|
||||
{annotationNotes.map((item, index) => (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getStyles } from '@/utils/style';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
const cssRegex =
|
||||
/((?:\s*)([\w#.@*,:\-.:>+~$$$$\"=(),*\s]+)\s*{(?:[\s]*)((?:[^\}]+[:][^\}]+;?)*)*\s*}(?:\s*))/gim;
|
||||
/((?:\s*)([\w#.@*,:\-.:>+~\[\]\"=(),*\s]+)\s*{(?:[\s]*)((?:[A-Za-z\- \s]+[:]\s*['"0-9\w .,\/\()\-!#%]+;?)*)*\s*}(?:\s*))/gim;
|
||||
|
||||
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
|
||||
@@ -15,7 +15,6 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { themeCode } = useTheme();
|
||||
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [userStylesheet, setUserStylesheet] = useState(viewSettings.userStylesheet!);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -74,16 +73,6 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animated]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.disableClick = isDisableClick;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.disableClick = isDisableClick;
|
||||
setSettings(settings);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableClick]);
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='w-full'>
|
||||
@@ -103,23 +92,6 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>Behavior</h2>
|
||||
<div className='card bg-base-100 border shadow'>
|
||||
<div className='divide-y'>
|
||||
<div className='config-item config-item-top config-item-bottom'>
|
||||
<span className='text-gray-700'>Disable Click-to-Flip</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isDisableClick}
|
||||
onChange={() => setIsDisableClick(!isDisableClick)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>Custom CSS</h2>
|
||||
<div className={`card bg-base-100 border shadow ${error ? 'border-red-500' : ''}`}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { MdInfoOutline } from 'react-icons/md';
|
||||
import { formatAuthors } from '@/utils/book';
|
||||
|
||||
interface BookCardProps {
|
||||
cover: string;
|
||||
@@ -23,7 +24,7 @@ const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='line-clamp-2 w-[90%] text-sm font-semibold'>{title}</h4>
|
||||
<p className='text-neutral-content truncate text-xs'>{author}</p>
|
||||
<p className='text-neutral-content truncate text-xs'>{formatAuthors(author)}</p>
|
||||
</div>
|
||||
<button
|
||||
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
|
||||
|
||||
@@ -3,10 +3,8 @@ import Image from 'next/image';
|
||||
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import useBooksManager from '../../hooks/useBooksManager';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
|
||||
interface BookMenuProps {
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
@@ -27,12 +25,6 @@ const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setAboutDialogVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const downloadReadest = () => {
|
||||
window.open(DOWNLOAD_READEST_URL, '_blank');
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const isWebApp = isWebAppPlatform();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -64,7 +56,6 @@ const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
</MenuItem>
|
||||
<MenuItem label='Reload Page' noIcon shortcut='Shift+R' onClick={handleReloadPage} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label='Download Readest' noIcon onClick={downloadReadest} />}
|
||||
<MenuItem label='About Readest' noIcon onClick={showAboutReadest} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,12 +39,8 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, editable = f
|
||||
const config = getConfig(bookKey);
|
||||
if (!config) return;
|
||||
const { booknotes = [] } = config;
|
||||
booknotes.forEach((item) => {
|
||||
if (item.id === note.id) {
|
||||
item.deletedAt = Date.now();
|
||||
}
|
||||
});
|
||||
const updatedConfig = updateBooknotes(bookKey, booknotes);
|
||||
const updatedNotes = booknotes.filter((item) => item.id !== note.id);
|
||||
const updatedConfig = updateBooknotes(bookKey, updatedNotes);
|
||||
if (updatedConfig) {
|
||||
saveConfig(envConfig, bookKey, updatedConfig, settings);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import * as CFI from 'foliate-js/epubcfi.js';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { findTocItemBS } from '@/utils/toc';
|
||||
import { findParentPath } from '@/utils/toc';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
import { BookNote, BookNoteType } from '@/types/book';
|
||||
import BooknoteItem from './BooknoteItem';
|
||||
@@ -22,18 +22,21 @@ const BooknoteView: React.FC<{
|
||||
const { getConfig } = useBookDataStore();
|
||||
const config = getConfig(bookKey)!;
|
||||
const { booknotes: allNotes = [] } = config;
|
||||
const booknotes = allNotes.filter((note) => note.type === type && !note.deletedAt);
|
||||
const booknotes = allNotes.filter((note) => note.type === type);
|
||||
|
||||
const booknoteGroups: { [href: string]: BooknoteGroup } = {};
|
||||
for (const booknote of booknotes) {
|
||||
const tocItem = findTocItemBS(toc, booknote.cfi);
|
||||
const href = tocItem?.href || '';
|
||||
const label = tocItem?.label || '';
|
||||
const id = tocItem?.id || 0;
|
||||
if (!booknoteGroups[href]) {
|
||||
booknoteGroups[href] = { id, href, label, booknotes: [] };
|
||||
const parentPath = findParentPath(toc, booknote.href);
|
||||
if (parentPath.length > 0) {
|
||||
const href = parentPath[0]!.href || '';
|
||||
const label = parentPath[0]!.label || '';
|
||||
const id = toc.findIndex((item) => item.href === href) || Infinity;
|
||||
booknote.href = href;
|
||||
if (!booknoteGroups[href]) {
|
||||
booknoteGroups[href] = { id, href, label, booknotes: [] };
|
||||
}
|
||||
booknoteGroups[href].booknotes.push(booknote);
|
||||
}
|
||||
booknoteGroups[href].booknotes.push(booknote);
|
||||
}
|
||||
|
||||
Object.values(booknoteGroups).forEach((group) => {
|
||||
|
||||
@@ -34,7 +34,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
const view = getView(bookKey)!;
|
||||
const config = getConfig(bookKey)!;
|
||||
const progress = getProgress(bookKey)!;
|
||||
const searchConfig = config.searchConfig! as BookSearchConfig;
|
||||
const searchConfig = config.searchConfig!;
|
||||
|
||||
const queuedSearchTerm = useRef('');
|
||||
const isSearchPending = useRef(false);
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
|
||||
export const useAutoHideScrollbar = () => {
|
||||
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
|
||||
const handleScrollbarAutoHide = (doc: Document) => {
|
||||
if (doc && doc.defaultView && doc.defaultView.frameElement) {
|
||||
const iframe = doc.defaultView.frameElement as HTMLIFrameElement;
|
||||
const container = iframe.parentElement?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
let hideScrollbarTimeout: ReturnType<typeof setTimeout>;
|
||||
const showScrollbar = () => {
|
||||
container.style.overflow = 'auto';
|
||||
container.style.scrollbarWidth = 'thin';
|
||||
};
|
||||
|
||||
const hideScrollbar = () => {
|
||||
container.style.overflow = 'hidden';
|
||||
container.style.scrollbarWidth = 'none';
|
||||
requestAnimationFrame(() => {
|
||||
container.style.overflow = 'auto';
|
||||
});
|
||||
};
|
||||
container.addEventListener('scroll', () => {
|
||||
showScrollbar();
|
||||
clearTimeout(hideScrollbarTimeout);
|
||||
hideScrollbarTimeout = setTimeout(hideScrollbar, 1000);
|
||||
});
|
||||
hideScrollbar();
|
||||
}
|
||||
};
|
||||
|
||||
return { shouldAutoHideScrollbar, handleScrollbarAutoHide };
|
||||
};
|
||||
@@ -21,7 +21,7 @@ const useBooksManager = () => {
|
||||
if (shouldUpdateSearchParams) {
|
||||
const ids = bookKeys.map((key) => key.split('-')[0]!);
|
||||
if (ids) {
|
||||
navigateToReader(router, ids, searchParams?.toString() || '', { scroll: false });
|
||||
navigateToReader(router, ids, searchParams.toString(), { scroll: false });
|
||||
}
|
||||
setShouldUpdateSearchParams(false);
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
export const useClickEvent = (
|
||||
bookKey: string,
|
||||
viewRef: React.MutableRefObject<FoliateView | null>,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
) => {
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const handleTurnPage = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
if (msg.data && msg.data.bookKey === bookKey) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (msg.data.type === 'iframe-single-click') {
|
||||
if (viewSettings.disableClick!) {
|
||||
return;
|
||||
}
|
||||
const viewElement = containerRef.current;
|
||||
if (viewElement) {
|
||||
const rect = viewElement.getBoundingClientRect();
|
||||
const { screenX, screenY } = msg.data;
|
||||
const consumed = eventDispatcher.dispatchSync('iframe-single-click', {
|
||||
screenX,
|
||||
screenY,
|
||||
});
|
||||
if (!consumed) {
|
||||
const centerStartX = rect.left + rect.width * 0.375;
|
||||
const centerEndX = rect.left + rect.width * 0.625;
|
||||
const centerStartY = rect.top + rect.height * 0.375;
|
||||
const centerEndY = rect.top + rect.height * 0.625;
|
||||
if (
|
||||
screenX >= centerStartX &&
|
||||
screenX <= centerEndX &&
|
||||
screenY >= centerStartY &&
|
||||
screenY <= centerEndY
|
||||
) {
|
||||
// toggle visibility of the header bar and the footer bar
|
||||
setHoveredBookKey(hoveredBookKey ? '' : bookKey);
|
||||
} else if (screenX >= rect.left + rect.width / 2) {
|
||||
viewRef.current?.goRight();
|
||||
} else if (screenX < rect.left + rect.width / 2) {
|
||||
viewRef.current?.goLeft();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
|
||||
const { deltaY } = msg.data;
|
||||
if (deltaY > 0) {
|
||||
viewRef.current?.next(1);
|
||||
} else if (deltaY < 0) {
|
||||
viewRef.current?.prev(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { clientX } = msg;
|
||||
const width = window.innerWidth;
|
||||
const leftThreshold = width * 0.5;
|
||||
const rightThreshold = width * 0.5;
|
||||
if (clientX < leftThreshold) {
|
||||
viewRef.current?.goLeft();
|
||||
} else if (clientX > rightThreshold) {
|
||||
viewRef.current?.goRight();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleTurnPage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleTurnPage);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hoveredBookKey, viewRef]);
|
||||
|
||||
return {
|
||||
handleTurnPage,
|
||||
};
|
||||
};
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { FoliateView } from '@/app/reader/components/FoliateViewer';
|
||||
|
||||
type FoliateEventHandler = {
|
||||
onLoad?: (event: Event) => void;
|
||||
onRelocate?: (event: Event) => void;
|
||||
onLinkClick?: (event: Event) => void;
|
||||
onRendererRelocate?: (event: Event) => void;
|
||||
onDrawAnnotation?: (event: Event) => void;
|
||||
onShowAnnotation?: (event: Event) => void;
|
||||
};
|
||||
|
||||
export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEventHandler) => {
|
||||
export const useFoliateEvents = (
|
||||
view: FoliateView | null,
|
||||
handlers?: FoliateEventHandler,
|
||||
dependencies: React.DependencyList = [],
|
||||
) => {
|
||||
const onLoad = handlers?.onLoad;
|
||||
const onRelocate = handlers?.onRelocate;
|
||||
const onLinkClick = handlers?.onLinkClick;
|
||||
const onRendererRelocate = handlers?.onRendererRelocate;
|
||||
const onDrawAnnotation = handlers?.onDrawAnnotation;
|
||||
const onShowAnnotation = handlers?.onShowAnnotation;
|
||||
@@ -22,7 +24,6 @@ export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEve
|
||||
if (!view) return;
|
||||
if (onLoad) view.addEventListener('load', onLoad);
|
||||
if (onRelocate) view.addEventListener('relocate', onRelocate);
|
||||
if (onLinkClick) view.addEventListener('link', onLinkClick);
|
||||
if (onRendererRelocate) view.renderer.addEventListener('relocate', onRendererRelocate);
|
||||
if (onDrawAnnotation) view.addEventListener('draw-annotation', onDrawAnnotation);
|
||||
if (onShowAnnotation) view.addEventListener('show-annotation', onShowAnnotation);
|
||||
@@ -30,11 +31,10 @@ export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEve
|
||||
return () => {
|
||||
if (onLoad) view.removeEventListener('load', onLoad);
|
||||
if (onRelocate) view.removeEventListener('relocate', onRelocate);
|
||||
if (onLinkClick) view.removeEventListener('link', onLinkClick);
|
||||
if (onRendererRelocate) view.renderer.removeEventListener('relocate', onRendererRelocate);
|
||||
if (onDrawAnnotation) view.removeEventListener('draw-annotation', onDrawAnnotation);
|
||||
if (onShowAnnotation) view.removeEventListener('show-annotation', onShowAnnotation);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
}, [view, ...dependencies]);
|
||||
};
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { SYNC_NOTES_INTERVAL_SEC } from '@/services/constants';
|
||||
|
||||
export const useNotesSync = (bookKey: string) => {
|
||||
const { user } = useAuth();
|
||||
const { syncedNotes, syncNotes, lastSyncedAtNotes } = useSync(bookKey);
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
|
||||
const config = getConfig(bookKey);
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
syncNotes([], bookHash, 'pull');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const lastSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const getNewNotes = () => {
|
||||
if (!config?.location || !user) return [];
|
||||
const bookNotes = config.booknotes ?? [];
|
||||
const newNotes = bookNotes.filter(
|
||||
(note) => lastSyncedAtNotes < note.updatedAt || lastSyncedAtNotes < (note.deletedAt ?? 0),
|
||||
);
|
||||
newNotes.forEach((note) => {
|
||||
note.bookHash = bookHash;
|
||||
});
|
||||
return newNotes;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.location || !user) return;
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - lastSyncTime.current;
|
||||
if (timeSinceLastSync > SYNC_NOTES_INTERVAL_SEC * 1000) {
|
||||
lastSyncTime.current = now;
|
||||
const newNotes = getNewNotes();
|
||||
syncNotes(newNotes, bookHash, 'both');
|
||||
} else {
|
||||
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
lastSyncTime.current = Date.now();
|
||||
const newNotes = getNewNotes();
|
||||
syncNotes(newNotes, bookHash, 'both');
|
||||
syncTimeoutRef.current = null;
|
||||
},
|
||||
SYNC_NOTES_INTERVAL_SEC * 1000 - timeSinceLastSync,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncedNotes?.length && config?.location) {
|
||||
const newNotes = syncedNotes.filter((note) => note.bookHash === bookHash);
|
||||
if (!newNotes.length) return;
|
||||
const oldNotes = config.booknotes ?? [];
|
||||
const mergedNotes = [
|
||||
...oldNotes.filter((oldNote) => !newNotes.some((newNote) => newNote.id === oldNote.id)),
|
||||
...newNotes,
|
||||
];
|
||||
setConfig(bookKey, { ...config, booknotes: mergedNotes });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [syncedNotes]);
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
|
||||
|
||||
export const useProgressSync = (
|
||||
bookKey: string,
|
||||
setToastMessage?: React.Dispatch<React.SetStateAction<string>>,
|
||||
) => {
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
const { getView } = useReaderStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { syncedConfigs, syncConfigs } = useSync(bookKey);
|
||||
const { user } = useAuth();
|
||||
const view = getView(bookKey);
|
||||
const config = getConfig(bookKey);
|
||||
|
||||
const pushConfig = (bookKey: string, config: BookConfig | null) => {
|
||||
if (!config || !user) return;
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
const newConfig = { bookHash, ...config };
|
||||
const compressedConfig = JSON.parse(
|
||||
serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG),
|
||||
);
|
||||
syncConfigs([compressedConfig], bookHash, 'push');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
syncConfigs([], bookHash, 'pull');
|
||||
return () => {
|
||||
pushConfig(bookKey, config);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const lastProgressSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!config?.location || !user) return;
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - lastProgressSyncTime.current;
|
||||
if (configSynced.current && timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
|
||||
lastProgressSyncTime.current = now;
|
||||
pushConfig(bookKey, config);
|
||||
} else {
|
||||
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
lastProgressSyncTime.current = Date.now();
|
||||
pushConfig(bookKey, config);
|
||||
syncTimeoutRef.current = null;
|
||||
},
|
||||
SYNC_PROGRESS_INTERVAL_SEC * 1000 - timeSinceLastSync,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
// sync progress once when the book is opened
|
||||
const configSynced = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!configSynced.current && syncedConfigs?.length > 0) {
|
||||
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
|
||||
if (syncedConfig) {
|
||||
const newConfig = deserializeConfig(
|
||||
JSON.stringify(syncedConfig),
|
||||
settings.globalViewSettings,
|
||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||
);
|
||||
setConfig(bookKey, { ...config, ...newConfig });
|
||||
configSynced.current = true;
|
||||
if ((syncedConfig.progress?.[1] ?? 0) > 0 && (config?.progress?.[1] ?? 0) > 0) {
|
||||
const syncedFraction = syncedConfig.progress![0] / syncedConfig.progress![1];
|
||||
const configFraction = config!.progress![0] / config!.progress![1];
|
||||
if (syncedFraction > configFraction) {
|
||||
view?.goToFraction(syncedFraction);
|
||||
setToastMessage?.('Progress synced');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [syncedConfigs]);
|
||||
};
|
||||
@@ -104,7 +104,7 @@ export const handleClick = (bookKey: string, event: MouseEvent) => {
|
||||
if (Date.now() - lastClickTime >= doubleClickThreshold) {
|
||||
let element: HTMLElement | null = event.target as HTMLElement;
|
||||
while (element) {
|
||||
if (['sup', 'a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
|
||||
if (element.tagName.toLowerCase() === 'a') {
|
||||
return;
|
||||
}
|
||||
element = element.parentElement;
|
||||
|
||||
@@ -3,7 +3,6 @@ import React from 'react';
|
||||
|
||||
interface MenuItemProps {
|
||||
label: string;
|
||||
labelClass?: string;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
noIcon?: boolean;
|
||||
@@ -14,7 +13,6 @@ interface MenuItemProps {
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({
|
||||
label,
|
||||
labelClass,
|
||||
shortcut,
|
||||
disabled,
|
||||
noIcon = false,
|
||||
@@ -33,7 +31,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
{!noIcon && <span style={{ minWidth: '20px' }}>{icon}</span>}
|
||||
<span className={clsx('ml-2 max-w-32 truncate', labelClass)}>{label}</span>
|
||||
<span className='ml-2 max-w-32 truncate'>{label}</span>
|
||||
</div>
|
||||
{shortcut && <span className='text-neutral-content text-sm'>{shortcut}</span>}
|
||||
</button>
|
||||
|
||||
@@ -12,8 +12,8 @@ const Popup = ({
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
position?: Position;
|
||||
trianglePosition?: Position;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
triangleClassName?: string;
|
||||
@@ -21,58 +21,24 @@ const Popup = ({
|
||||
}) => (
|
||||
<div>
|
||||
<div
|
||||
className={`triangle text-base-200 absolute z-10 ${triangleClassName}`}
|
||||
className={`triangle absolute z-10 ${triangleClassName}`}
|
||||
style={{
|
||||
left:
|
||||
trianglePosition?.dir === 'left'
|
||||
? `${trianglePosition.point.x}px`
|
||||
: trianglePosition?.dir === 'right'
|
||||
? `${trianglePosition.point.x}px`
|
||||
: `${trianglePosition ? trianglePosition.point.x : -999}px`,
|
||||
top:
|
||||
trianglePosition?.dir === 'up'
|
||||
? `${trianglePosition.point.y}px`
|
||||
: trianglePosition?.dir === 'down'
|
||||
? `${trianglePosition.point.y}px`
|
||||
: `${trianglePosition ? trianglePosition.point.y : -999}px`,
|
||||
borderLeft:
|
||||
trianglePosition?.dir === 'right'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'left'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderRight:
|
||||
trianglePosition?.dir === 'left'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'right'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderTop:
|
||||
trianglePosition?.dir === 'down'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'up'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderBottom:
|
||||
trianglePosition?.dir === 'up'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'down'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
transform:
|
||||
trianglePosition?.dir === 'left' || trianglePosition?.dir === 'right'
|
||||
? 'translateY(-50%)'
|
||||
: 'translateX(-50%)',
|
||||
left: `${trianglePosition.point.x}px`,
|
||||
top: `${trianglePosition.point.y}px`,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderBottom: trianglePosition.dir === 'up' ? 'none' : `6px solid`,
|
||||
borderTop: trianglePosition.dir === 'up' ? `6px solid` : 'none',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
id='popup-container'
|
||||
className={`bg-base-200 absolute rounded-lg font-sans shadow-xl ${className}`}
|
||||
className={`absolute rounded-lg font-sans shadow-lg ${className}`}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${position ? position.point.x : -999}px`,
|
||||
top: `${position ? position.point.y : -999}px`,
|
||||
left: `${position.point.x}px`,
|
||||
top: `${position.point.y}px`,
|
||||
...additionalStyle,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { CSPostHogProvider } from '@/context/PHContext';
|
||||
import { SyncProvider } from '@/context/SyncContext';
|
||||
|
||||
const Providers = ({ children }: { children: React.ReactNode }) => (
|
||||
<CSPostHogProvider>
|
||||
<EnvProvider>
|
||||
<AuthProvider>
|
||||
<SyncProvider>{children}</SyncProvider>
|
||||
</AuthProvider>
|
||||
</EnvProvider>
|
||||
</CSPostHogProvider>
|
||||
);
|
||||
|
||||
export default Providers;
|
||||
@@ -1,13 +1,8 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
const Toast: React.FC<{ message: string; toastClass?: string; alertClass?: string }> = ({
|
||||
message,
|
||||
toastClass,
|
||||
alertClass,
|
||||
}) => (
|
||||
<div className={clsx('toast toast-center toast-middle', toastClass)}>
|
||||
<div className={clsx('alert flex items-center justify-center border-0', alertClass)}>
|
||||
const Toast: React.FC<{ message: string }> = ({ message }) => (
|
||||
<div className='toast toast-center toast-middle'>
|
||||
<div className='alert flex items-center justify-center border-0 bg-gray-600 text-white'>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { tauriHandleMinimize, tauriHandleToggleMaximize, tauriHandleClose } from '@/utils/window';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
interface WindowButtonsProps {
|
||||
className?: string;
|
||||
@@ -67,7 +66,6 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriAppPlatform()) return;
|
||||
const headerElement = headerRef?.current;
|
||||
headerElement?.addEventListener('mousedown', handleMouseDown);
|
||||
|
||||
|
||||
@@ -1,77 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import React, { createContext, useState, useContext, ReactNode } from 'react';
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
login: (token: string, user: User) => void;
|
||||
login: (token: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [token, setToken] = useState<string | null>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [user, setUser] = useState<User | null>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const userJson = localStorage.getItem('user');
|
||||
return userJson ? JSON.parse(userJson) : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSession = async () => {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (session) {
|
||||
const { access_token, user } = session;
|
||||
setToken(access_token);
|
||||
setUser(user);
|
||||
localStorage.setItem('token', access_token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
const login = (newToken: string) => setToken(newToken);
|
||||
const logout = () => setToken(null);
|
||||
|
||||
console.log('Fetching session');
|
||||
fetchSession();
|
||||
}, [token]);
|
||||
|
||||
const login = (newToken: string, newUser: User) => {
|
||||
console.log('Logging in');
|
||||
setToken(newToken);
|
||||
setUser(newUser);
|
||||
localStorage.setItem('token', newToken);
|
||||
localStorage.setItem('user', JSON.stringify(newUser));
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
console.log('Logging out');
|
||||
await supabase.auth.refreshSession();
|
||||
await supabase.auth.signOut();
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ token, user, login, logout }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
return <AuthContext.Provider value={{ token, login, logout }}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextType => {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { SyncClient } from '@/libs/sync';
|
||||
|
||||
const syncClient = new SyncClient();
|
||||
|
||||
interface SyncContextType {
|
||||
syncClient: SyncClient;
|
||||
}
|
||||
|
||||
const SyncContext = createContext<SyncContextType>({ syncClient });
|
||||
|
||||
export const SyncProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return <SyncContext.Provider value={{ syncClient }}>{children}</SyncContext.Provider>;
|
||||
};
|
||||
|
||||
export const useSyncContext = () => useContext(SyncContext);
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"library": [
|
||||
"https://cdn.readest.com/books/a-room-of-one-s-own.epub",
|
||||
"https://cdn.readest.com/books/hamlet.epub",
|
||||
"https://cdn.readest.com/books/meditations.epub",
|
||||
"https://cdn.readest.com/books/the-great-gatsby.epub",
|
||||
"https://cdn.readest.com/books/the-scarlet-letter.epub",
|
||||
"https://cdn.readest.com/books/this-side-of-paradise.epub"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"library": [
|
||||
"https://cdn.readest.com/books/four-great-classics.epub",
|
||||
"https://cdn.readest.com/books/journey-to-the-west.epub"
|
||||
]
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
|
||||
interface UseAuthCallbackOptions {
|
||||
accessToken?: string | null;
|
||||
refreshToken?: string | null;
|
||||
login: (accessToken: string, user: User) => void;
|
||||
navigate: (path: string) => void;
|
||||
next?: string;
|
||||
}
|
||||
|
||||
export function handleAuthCallback({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
login,
|
||||
navigate,
|
||||
next = '/',
|
||||
}: UseAuthCallbackOptions) {
|
||||
async function finalizeSession() {
|
||||
if (!accessToken || !refreshToken) {
|
||||
console.error('No access token or refresh token');
|
||||
navigate('/auth/error');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error setting session:', error);
|
||||
navigate('/auth/error');
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
login(accessToken, user);
|
||||
navigate(next);
|
||||
} else {
|
||||
console.error('Error fetching user data');
|
||||
navigate('/auth/error');
|
||||
}
|
||||
}
|
||||
|
||||
finalizeSession();
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSyncContext } from '@/context/SyncContext';
|
||||
import { SyncData, SyncOp, SyncResult, SyncType } from '@/libs/sync';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { transformBookConfigFromDB } from '@/utils/transform';
|
||||
import { transformBookNoteFromDB } from '@/utils/transform';
|
||||
import { transformBookFromDB } from '@/utils/transform';
|
||||
import { DBBook, DBBookConfig, DBBookNote } from '@/types/records';
|
||||
import { Book, BookConfig, BookDataRecord, BookNote } from '@/types/book';
|
||||
|
||||
const transformsFromDB = {
|
||||
books: transformBookFromDB,
|
||||
notes: transformBookNoteFromDB,
|
||||
configs: transformBookConfigFromDB,
|
||||
};
|
||||
|
||||
const computeMaxTimestamp = (records: BookDataRecord[]): number => {
|
||||
let maxTime = 0;
|
||||
for (const rec of records) {
|
||||
if (rec.updated_at) {
|
||||
const updatedTime = new Date(rec.updated_at).getTime();
|
||||
maxTime = Math.max(maxTime, updatedTime);
|
||||
}
|
||||
if (rec.deleted_at) {
|
||||
const deletedTime = new Date(rec.deleted_at).getTime();
|
||||
maxTime = Math.max(maxTime, deletedTime);
|
||||
}
|
||||
}
|
||||
return maxTime;
|
||||
};
|
||||
|
||||
export function useSync(bookKey?: string) {
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
const config = bookKey ? getConfig(bookKey) : null;
|
||||
|
||||
const [syncingBooks, setSyncingBooks] = useState(false);
|
||||
const [syncingConfigs, setSyncingConfigs] = useState(false);
|
||||
const [syncingNotes, setSyncingNotes] = useState(false);
|
||||
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState<number>(
|
||||
settings.lastSyncedAtBooks ?? 0,
|
||||
);
|
||||
const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState<number>(
|
||||
config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0,
|
||||
);
|
||||
const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState<number>(
|
||||
config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0,
|
||||
);
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<SyncResult>({
|
||||
books: [],
|
||||
configs: [],
|
||||
notes: [],
|
||||
});
|
||||
const [syncedBooks, setSyncedBooks] = useState<Book[]>([]);
|
||||
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[]>([]);
|
||||
const [syncedNotes, setSyncedNotes] = useState<BookNote[]>([]);
|
||||
|
||||
const { syncClient } = useSyncContext();
|
||||
|
||||
// bookId is for configs and notes only, if bookId is provided, only pull changes for that book
|
||||
// and update the lastSyncedAt for that book in the book config
|
||||
const pullChanges = async (
|
||||
type: SyncType,
|
||||
since: number,
|
||||
setLastSyncedAt: React.Dispatch<React.SetStateAction<number>>,
|
||||
setSyncing: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
bookId?: string,
|
||||
) => {
|
||||
setSyncing(true);
|
||||
setSyncError(null);
|
||||
|
||||
try {
|
||||
const result = await syncClient.pullChanges(since, type, bookId);
|
||||
const records = result[type];
|
||||
if (!records.length) return;
|
||||
const maxTime = computeMaxTimestamp(result[type]);
|
||||
setLastSyncedAt(maxTime);
|
||||
setSyncResult(result);
|
||||
switch (type) {
|
||||
case 'books':
|
||||
settings.lastSyncedAtBooks = maxTime;
|
||||
break;
|
||||
case 'configs':
|
||||
if (!bookId) {
|
||||
settings.lastSyncedAtConfigs = maxTime;
|
||||
} else if (bookKey && config) {
|
||||
config.lastSyncedAtConfig = maxTime;
|
||||
setConfig(bookKey, config);
|
||||
}
|
||||
break;
|
||||
case 'notes':
|
||||
if (!bookId) {
|
||||
settings.lastSyncedAtNotes = maxTime;
|
||||
} else if (bookKey && config) {
|
||||
config.lastSyncedAtNotes = maxTime;
|
||||
setConfig(bookKey, config);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
if (err instanceof Error) {
|
||||
setSyncError(err.message || `Error pulling ${type}`);
|
||||
} else {
|
||||
setSyncError(`Error pulling ${type}`);
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pushChanges = async (payload: SyncData) => {
|
||||
setSyncing(true);
|
||||
setSyncError(null);
|
||||
|
||||
try {
|
||||
const result = await syncClient.pushChanges(payload);
|
||||
setSyncResult(result);
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
if (err instanceof Error) {
|
||||
setSyncError(err.message || 'Error pushing changes');
|
||||
} else {
|
||||
setSyncError('Error pushing changes');
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncBooks = async (books?: Book[], op: SyncOp = 'both') => {
|
||||
if ((op === 'push' || op === 'both') && books?.length) {
|
||||
await pushChanges({ books });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks);
|
||||
}
|
||||
};
|
||||
|
||||
const syncConfigs = async (bookConfigs?: BookConfig[], bookId?: string, op: SyncOp = 'both') => {
|
||||
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
|
||||
await pushChanges({ configs: bookConfigs });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges(
|
||||
'configs',
|
||||
lastSyncedAtConfigs,
|
||||
setLastSyncedAtConfigs,
|
||||
setSyncingConfigs,
|
||||
bookId,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const syncNotes = async (bookNotes?: BookNote[], bookId?: string, op: SyncOp = 'both') => {
|
||||
if ((op === 'push' || op === 'both') && bookNotes?.length) {
|
||||
await pushChanges({ notes: bookNotes });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges('notes', lastSyncedAtNotes, setLastSyncedAtNotes, setSyncingNotes, bookId);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncing && syncResult) {
|
||||
const { books: dbBooks, configs: dbBookConfigs, notes: dbBookNotes } = syncResult;
|
||||
const books = dbBooks.map((dbBook) => transformsFromDB['books'](dbBook as unknown as DBBook));
|
||||
const configs = dbBookConfigs.map((dbBookConfig) =>
|
||||
transformsFromDB['configs'](dbBookConfig as unknown as DBBookConfig),
|
||||
);
|
||||
const notes = dbBookNotes.map((dbBookNote) =>
|
||||
transformsFromDB['notes'](dbBookNote as unknown as DBBookNote),
|
||||
);
|
||||
if (books.length) setSyncedBooks(books);
|
||||
if (configs.length) setSyncedConfigs(configs);
|
||||
if (notes.length) setSyncedNotes(notes);
|
||||
}
|
||||
}, [syncResult, syncing]);
|
||||
|
||||
return {
|
||||
syncing: syncingBooks || syncingConfigs || syncingNotes,
|
||||
syncError,
|
||||
syncResult,
|
||||
syncedBooks,
|
||||
syncedConfigs,
|
||||
syncedNotes,
|
||||
lastSyncedAtBooks,
|
||||
lastSyncedAtNotes,
|
||||
lastSyncedAtConfigs,
|
||||
pullChanges,
|
||||
pushChanges,
|
||||
syncBooks,
|
||||
syncConfigs,
|
||||
syncNotes,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BookFormat } from '@/types/book';
|
||||
import * as epubcfi from 'foliate-js/epubcfi.js';
|
||||
|
||||
// A groupBy polyfill for foliate-js
|
||||
Object.groupBy ??= (iterable, callbackfn) => {
|
||||
@@ -31,24 +30,15 @@ Map.groupBy ??= (iterable, callbackfn) => {
|
||||
return map;
|
||||
};
|
||||
|
||||
export const CFI = epubcfi;
|
||||
|
||||
export type DocumentFile = File;
|
||||
|
||||
export interface TOCItem {
|
||||
id: number;
|
||||
label: string;
|
||||
href: string;
|
||||
cfi?: string;
|
||||
subitems?: TOCItem[];
|
||||
}
|
||||
|
||||
export interface SectionItem {
|
||||
id: string;
|
||||
cfi: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface BookDoc {
|
||||
metadata: {
|
||||
title: string;
|
||||
@@ -58,8 +48,6 @@ export interface BookDoc {
|
||||
publisher?: string;
|
||||
};
|
||||
toc: Array<TOCItem>;
|
||||
sections: Array<SectionItem>;
|
||||
splitTOCHref(href: string): Array<string | number>;
|
||||
getCover(): Promise<Blob | null>;
|
||||
}
|
||||
|
||||
@@ -178,11 +166,3 @@ export class DocumentLoader {
|
||||
return { book, format } as { book: BookDoc; format: BookFormat };
|
||||
}
|
||||
}
|
||||
|
||||
export const getDirection = (doc: Document) => {
|
||||
const { defaultView } = doc;
|
||||
const { writingMode, direction } = defaultView!.getComputedStyle(doc.body);
|
||||
const vertical = writingMode === 'vertical-rl' || writingMode === 'vertical-lr';
|
||||
const rtl = doc.body.dir === 'rtl' || direction === 'rtl' || doc.documentElement.dir === 'rtl';
|
||||
return { vertical, rtl };
|
||||
};
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { Book, BookConfig, BookNote, BookDataRecord } from '@/types/book';
|
||||
import { READEST_WEB_BASE_URL } from '@/services/constants';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
// Develop Sync API only in development mode and web platform
|
||||
// with command `pnpm dev-web`
|
||||
const SYNC_API_ENDPOINT =
|
||||
process.env['NODE_ENV'] === 'development' && isWebAppPlatform()
|
||||
? '/api/sync'
|
||||
: `${READEST_WEB_BASE_URL}/api/sync`;
|
||||
|
||||
export type SyncType = 'books' | 'configs' | 'notes';
|
||||
export type SyncOp = 'push' | 'pull' | 'both';
|
||||
|
||||
interface BookRecord extends BookDataRecord, Book {}
|
||||
interface BookConfigRecord extends BookDataRecord, BookConfig {}
|
||||
interface BookNoteRecord extends BookDataRecord, BookNote {}
|
||||
|
||||
export interface SyncResult {
|
||||
books: BookRecord[];
|
||||
notes: BookNoteRecord[];
|
||||
configs: BookConfigRecord[];
|
||||
}
|
||||
|
||||
export interface SyncData {
|
||||
books?: Partial<BookRecord>[];
|
||||
notes?: Partial<BookNoteRecord>[];
|
||||
configs?: Partial<BookConfigRecord>[];
|
||||
}
|
||||
|
||||
export class SyncClient {
|
||||
/**
|
||||
* Pull incremental changes since a given timestamp (in ms).
|
||||
* Returns updated or deleted records since that time.
|
||||
*/
|
||||
async pullChanges(since: number, type?: SyncType, book?: string): Promise<SyncResult> {
|
||||
const token = await this.getAccessToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
const url = `${SYNC_API_ENDPOINT}?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(`Failed to pull changes: ${error.error || res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Push local changes to the server.
|
||||
* Uses last-writer-wins logic as implemented on the server side.
|
||||
*/
|
||||
async pushChanges(payload: SyncData): Promise<SyncResult> {
|
||||
const token = await this.getAccessToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
const res = await fetch(SYNC_API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(`Failed to push changes: ${error.error || res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string | null> {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data?.session?.access_token ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { AppProps } from 'next/app';
|
||||
import Providers from '@/components/Providers';
|
||||
|
||||
import '../styles/globals.css';
|
||||
import '../styles/fonts.css';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<Providers>
|
||||
<Component {...pageProps} />
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApp;
|
||||
@@ -1,289 +0,0 @@
|
||||
import Cors from 'cors';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PostgrestError } from '@supabase/supabase-js';
|
||||
import { supabase, createSupabaseClient } from '@/utils/supabase';
|
||||
import { BookDataRecord } from '@/types/book';
|
||||
import { transformBookConfigToDB } from '@/utils/transform';
|
||||
import { transformBookNoteToDB } from '@/utils/transform';
|
||||
import { transformBookToDB } from '@/utils/transform';
|
||||
import { SyncData, SyncResult, SyncType } from '@/libs/sync';
|
||||
|
||||
const transformsToDB = {
|
||||
books: transformBookToDB,
|
||||
book_notes: transformBookNoteToDB,
|
||||
book_configs: transformBookConfigToDB,
|
||||
};
|
||||
|
||||
const DBSyncTypeMap = {
|
||||
books: 'books',
|
||||
book_notes: 'notes',
|
||||
book_configs: 'configs',
|
||||
};
|
||||
|
||||
type TableName = keyof typeof transformsToDB;
|
||||
|
||||
type DBError = { table: TableName; error: PostgrestError };
|
||||
|
||||
const getUserAndToken = async (req: NextRequest) => {
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authHeader) return {};
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) return {};
|
||||
return { user, token };
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { user, token } = await getUserAndToken(req);
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
const supabase = createSupabaseClient(token);
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const sinceParam = searchParams.get('since');
|
||||
const typeParam = searchParams.get('type') as SyncType | undefined;
|
||||
const bookParam = searchParams.get('book');
|
||||
|
||||
if (!sinceParam) {
|
||||
return NextResponse.json({ error: '"since" query parameter is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const since = new Date(Number(sinceParam));
|
||||
if (isNaN(since.getTime())) {
|
||||
return NextResponse.json({ error: 'Invalid "since" timestamp' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sinceIso = since.toISOString();
|
||||
|
||||
try {
|
||||
const results: SyncResult = { books: [], configs: [], notes: [] };
|
||||
const errors: Record<TableName, DBError | null> = {
|
||||
books: null,
|
||||
book_notes: null,
|
||||
book_configs: null,
|
||||
};
|
||||
|
||||
const queryTables = async (table: TableName) => {
|
||||
let query = supabase.from(table).select('*').eq('user_id', user.id);
|
||||
if (bookParam) {
|
||||
query.eq('book_hash', bookParam);
|
||||
}
|
||||
query = query.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`);
|
||||
console.log('Querying table:', table, 'since:', sinceIso);
|
||||
const { data, error } = await query;
|
||||
if (error) throw { table, error } as DBError;
|
||||
results[DBSyncTypeMap[table] as SyncType] = data || [];
|
||||
};
|
||||
|
||||
if (!typeParam || typeParam === 'books') {
|
||||
await queryTables('books').catch((err) => (errors['books'] = err));
|
||||
}
|
||||
if (!typeParam || typeParam === 'configs') {
|
||||
await queryTables('book_configs').catch((err) => (errors['book_configs'] = err));
|
||||
}
|
||||
if (!typeParam || typeParam === 'notes') {
|
||||
await queryTables('book_notes').catch((err) => (errors['book_notes'] = err));
|
||||
}
|
||||
|
||||
const dbErrors = Object.values(errors).filter((err) => err !== null);
|
||||
if (dbErrors.length > 0) {
|
||||
console.error('Errors occurred:', dbErrors);
|
||||
const errorMsg = dbErrors
|
||||
.map((err) => `${err.table}: ${err.error.message || 'Unknown error'}`)
|
||||
.join('; ');
|
||||
return NextResponse.json({ error: errorMsg }, { status: 500 });
|
||||
}
|
||||
|
||||
const response = NextResponse.json(results, { status: 200 });
|
||||
response.headers.set('Cache-Control', 'no-store');
|
||||
response.headers.set('Pragma', 'no-cache');
|
||||
response.headers.delete('ETag');
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage = (error as PostgrestError).message || 'Unknown error';
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { user, token } = await getUserAndToken(req);
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
const supabase = createSupabaseClient(token);
|
||||
|
||||
const body = await req.json();
|
||||
const { books = [], configs = [], notes = [] } = body as SyncData;
|
||||
|
||||
const upsertRecords = async (
|
||||
table: TableName,
|
||||
primaryKeys: (keyof BookDataRecord)[],
|
||||
records: BookDataRecord[],
|
||||
) => {
|
||||
const authoritativeRecords: BookDataRecord[] = [];
|
||||
|
||||
for (const rec of records) {
|
||||
const dbRec = transformsToDB[table](rec, user.id);
|
||||
rec.user_id = user.id;
|
||||
rec.book_hash = dbRec.book_hash;
|
||||
const matchConditions: Record<string, string | number> = { user_id: user.id };
|
||||
for (const pk of primaryKeys) {
|
||||
matchConditions[pk] = rec[pk]!;
|
||||
}
|
||||
|
||||
const { data: serverData, error: fetchError } = await supabase
|
||||
.from(table)
|
||||
.select()
|
||||
.match(matchConditions)
|
||||
.single();
|
||||
|
||||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||
return { error: fetchError.message };
|
||||
}
|
||||
|
||||
if (!serverData) {
|
||||
// use server updated_at for new records
|
||||
dbRec.updated_at = new Date().toISOString();
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from(table)
|
||||
.insert(dbRec)
|
||||
.select()
|
||||
.single();
|
||||
console.log('Inserted record:', inserted);
|
||||
if (insertError) return { error: insertError.message };
|
||||
authoritativeRecords.push(inserted);
|
||||
} else {
|
||||
const clientUpdatedAt = dbRec.updated_at ? new Date(dbRec.updated_at).getTime() : 0;
|
||||
const serverUpdatedAt = serverData.updated_at
|
||||
? new Date(serverData.updated_at).getTime()
|
||||
: 0;
|
||||
const clientDeletedAt = dbRec.deleted_at ? new Date(dbRec.deleted_at).getTime() : 0;
|
||||
const serverDeletedAt = serverData.deleted_at
|
||||
? new Date(serverData.deleted_at).getTime()
|
||||
: 0;
|
||||
const clientIsNewer =
|
||||
clientDeletedAt > serverDeletedAt || clientUpdatedAt > serverUpdatedAt;
|
||||
|
||||
if (clientIsNewer) {
|
||||
// use server updated_at for updated records
|
||||
dbRec.updated_at = new Date().toISOString();
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from(table)
|
||||
.update(dbRec)
|
||||
.match(matchConditions)
|
||||
.select()
|
||||
.single();
|
||||
console.log('Updated record:', updated);
|
||||
if (updateError) return { error: updateError.message };
|
||||
authoritativeRecords.push(updated);
|
||||
} else {
|
||||
authoritativeRecords.push(serverData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { data: authoritativeRecords };
|
||||
};
|
||||
|
||||
try {
|
||||
const [booksResult, configsResult, notesResult] = await Promise.all([
|
||||
upsertRecords('books', ['book_hash'], books as BookDataRecord[]),
|
||||
upsertRecords('book_configs', ['book_hash'], configs as BookDataRecord[]),
|
||||
upsertRecords('book_notes', ['book_hash', 'id'], notes as BookDataRecord[]),
|
||||
]);
|
||||
|
||||
if (booksResult?.error) throw new Error(booksResult.error);
|
||||
if (configsResult?.error) throw new Error(configsResult.error);
|
||||
if (notesResult?.error) throw new Error(notesResult.error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
books: booksResult?.data || [],
|
||||
configs: configsResult?.data || [],
|
||||
notes: notesResult?.data || [],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const errorMessage = (error as PostgrestError).message || 'Unknown error';
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to wait for a middleware to execute before continuing
|
||||
// And to throw an error when an error happens in a middleware
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
function runMiddleware(req: NextApiRequest, res: NextApiResponse, fn: Function) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn(req, res, (result: unknown) => {
|
||||
if (result instanceof Error) {
|
||||
return reject(result);
|
||||
}
|
||||
|
||||
return resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const cors = Cors({
|
||||
methods: ['POST', 'GET', 'HEAD'],
|
||||
});
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (!req.url) {
|
||||
return res.status(400).json({ error: 'Invalid request URL' });
|
||||
}
|
||||
|
||||
const protocol = process.env['PROTOCOL'] || 'http';
|
||||
const host = process.env['HOST'] || 'localhost:3000';
|
||||
const url = new URL(req.url, `${protocol}://${host}`);
|
||||
|
||||
await runMiddleware(req, res, cors);
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const nextReq = new NextRequest(url.toString(), {
|
||||
headers: new Headers(req.headers as Record<string, string>),
|
||||
method: 'GET',
|
||||
});
|
||||
response = await GET(nextReq);
|
||||
} else if (req.method === 'POST') {
|
||||
const nextReq = new NextRequest(url.toString(), {
|
||||
headers: new Headers(req.headers as Record<string, string>),
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req.body), // Ensure the body is a string
|
||||
});
|
||||
response = await POST(nextReq);
|
||||
} else {
|
||||
res.setHeader('Allow', ['GET', 'POST']);
|
||||
return res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
|
||||
res.status(response.status);
|
||||
|
||||
response.headers.forEach((value, key) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
console.error('Error processing request:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { EnvProvider } from '@/context/EnvContext';
|
||||
import { CSPostHogProvider } from '@/context/PHContext';
|
||||
import { SyncProvider } from '@/context/SyncContext';
|
||||
import Reader from '@/app/reader/components/Reader';
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const ids = router.query['ids'] as string;
|
||||
return (
|
||||
<CSPostHogProvider>
|
||||
<EnvProvider>
|
||||
<AuthProvider>
|
||||
<SyncProvider>
|
||||
<Reader ids={ids} />
|
||||
</SyncProvider>
|
||||
</AuthProvider>
|
||||
</EnvProvider>
|
||||
</CSPostHogProvider>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { AppPlatform, AppService, ToastType } from '@/types/system';
|
||||
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { FileSystem, BaseDir } from '@/types/system';
|
||||
import { Book, BookConfig, BookContent, BookFormat } from '@/types/book';
|
||||
import { Book, BookConfig, BookContent, BookFormat, ViewSettings } from '@/types/book';
|
||||
import {
|
||||
getDir,
|
||||
getFilename,
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
getConfigFilename,
|
||||
getLibraryFilename,
|
||||
INIT_BOOK_CONFIG,
|
||||
formatTitle,
|
||||
formatAuthors,
|
||||
} from '@/utils/book';
|
||||
import { RemoteFile } from '@/utils/file';
|
||||
import { partialMD5 } from '@/utils/md5';
|
||||
@@ -25,8 +23,6 @@ import {
|
||||
SYSTEM_SETTINGS_VERSION,
|
||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||
} from './constants';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
|
||||
export abstract class BaseAppService implements AppService {
|
||||
localBooksDir: string = '';
|
||||
@@ -71,10 +67,6 @@ export abstract class BaseAppService implements AppService {
|
||||
settings = {
|
||||
version: SYSTEM_SETTINGS_VERSION,
|
||||
localBooksDir: await this.getInitBooksDir(),
|
||||
lastSyncedAtBooks: 0,
|
||||
lastSyncedAtConfigs: 0,
|
||||
lastSyncedAtNotes: 0,
|
||||
keepLogin: false,
|
||||
globalReadSettings: DEFAULT_READSETTINGS,
|
||||
globalViewSettings: {
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
@@ -101,8 +93,6 @@ export abstract class BaseAppService implements AppService {
|
||||
async importBook(
|
||||
file: string | File,
|
||||
books: Book[],
|
||||
saveBook: boolean = true,
|
||||
saveCover: boolean = true,
|
||||
overwrite: boolean = false,
|
||||
): Promise<Book | null> {
|
||||
try {
|
||||
@@ -131,31 +121,30 @@ export abstract class BaseAppService implements AppService {
|
||||
const hash = await partialMD5(fileobj);
|
||||
const existingBook = books.filter((b) => b.hash === hash)[0];
|
||||
if (existingBook) {
|
||||
if (existingBook.deletedAt) {
|
||||
existingBook.deletedAt = null;
|
||||
if (existingBook.isRemoved) {
|
||||
delete existingBook.isRemoved;
|
||||
}
|
||||
existingBook.updatedAt = Date.now();
|
||||
existingBook.lastUpdated = Date.now();
|
||||
}
|
||||
|
||||
const book: Book = {
|
||||
hash,
|
||||
format,
|
||||
title: formatTitle(loadedBook.metadata.title),
|
||||
author: formatAuthors(loadedBook.metadata.language, loadedBook.metadata.author),
|
||||
createdAt: existingBook ? existingBook.createdAt : Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
title: loadedBook.metadata.title,
|
||||
author: loadedBook.metadata.author,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
if (!(await this.fs.exists(getDir(book), 'Books'))) {
|
||||
await this.fs.createDir(getDir(book), 'Books');
|
||||
}
|
||||
if (saveBook && (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite)) {
|
||||
if (typeof file === 'string' && !isValidURL(file)) {
|
||||
if (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite) {
|
||||
if (typeof file === 'string') {
|
||||
await this.fs.copyFile(file, getFilename(book), 'Books');
|
||||
} else {
|
||||
await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer());
|
||||
await this.fs.writeFile(getFilename(book), 'Books', await file.arrayBuffer());
|
||||
}
|
||||
}
|
||||
if (saveCover && (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite)) {
|
||||
if (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite) {
|
||||
const cover = await loadedBook.getCover();
|
||||
if (cover) {
|
||||
await this.fs.writeFile(getCoverFilename(book), 'Books', await cover.arrayBuffer());
|
||||
@@ -167,9 +156,6 @@ export abstract class BaseAppService implements AppService {
|
||||
books.splice(0, 0, book);
|
||||
}
|
||||
|
||||
if (typeof file === 'string' && isValidURL(file)) {
|
||||
book.url = file;
|
||||
}
|
||||
if (this.appPlatform === 'web') {
|
||||
book.coverImageUrl = await this.getCoverImageBlobUrl(book);
|
||||
} else {
|
||||
@@ -192,19 +178,13 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
|
||||
async loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent> {
|
||||
let file: File;
|
||||
const fp = getFilename(book);
|
||||
if (await this.fs.exists(fp, 'Books')) {
|
||||
if (this.appPlatform === 'web') {
|
||||
const content = await this.fs.readFile(fp, 'Books', 'binary');
|
||||
file = new File([content], fp);
|
||||
} else {
|
||||
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
|
||||
}
|
||||
} else if (book.url) {
|
||||
file = await new RemoteFile(book.url).open();
|
||||
let file: File;
|
||||
if (this.appPlatform === 'web') {
|
||||
const content = await this.fs.readFile(fp, 'Books', 'binary');
|
||||
file = new File([content], fp);
|
||||
} else {
|
||||
throw new Error('Book file not found');
|
||||
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
|
||||
}
|
||||
return { book, file, config: await this.loadBookConfig(book, settings) };
|
||||
}
|
||||
@@ -212,22 +192,33 @@ export abstract class BaseAppService implements AppService {
|
||||
async loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig> {
|
||||
try {
|
||||
const str = await this.fs.readFile(getConfigFilename(book), 'Books', 'text');
|
||||
const config = JSON.parse(str as string) as BookConfig;
|
||||
const { globalViewSettings } = settings;
|
||||
return deserializeConfig(str as string, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||
const { viewSettings } = config;
|
||||
config.viewSettings = { ...globalViewSettings, ...viewSettings };
|
||||
config.searchConfig ??= DEFAULT_BOOK_SEARCH_CONFIG;
|
||||
return config;
|
||||
} catch {
|
||||
return INIT_BOOK_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) {
|
||||
let serializedConfig: string;
|
||||
if (settings) {
|
||||
const { globalViewSettings } = settings;
|
||||
serializedConfig = serializeConfig(config, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||
} else {
|
||||
serializedConfig = JSON.stringify(config);
|
||||
config = JSON.parse(JSON.stringify(config));
|
||||
const globalViewSettings = settings.globalViewSettings as ViewSettings;
|
||||
const viewSettings = config.viewSettings as Partial<ViewSettings>;
|
||||
config.viewSettings = Object.entries(viewSettings).reduce(
|
||||
(acc: Partial<Record<keyof ViewSettings, unknown>>, [key, value]) => {
|
||||
if (globalViewSettings[key as keyof ViewSettings] !== value) {
|
||||
acc[key as keyof ViewSettings] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Partial<Record<keyof ViewSettings, unknown>>,
|
||||
) as Partial<ViewSettings>;
|
||||
}
|
||||
await this.fs.writeFile(getConfigFilename(book), 'Books', serializedConfig);
|
||||
await this.fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config));
|
||||
}
|
||||
|
||||
async loadLibraryBooks(): Promise<Book[]> {
|
||||
@@ -250,7 +241,6 @@ export abstract class BaseAppService implements AppService {
|
||||
} else {
|
||||
book.coverImageUrl = this.getCoverImageUrl(book);
|
||||
}
|
||||
book.updatedAt ??= book.lastUpdated || Date.now();
|
||||
return book;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -38,12 +38,10 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
|
||||
marginPx: 44,
|
||||
gapPercent: 5,
|
||||
scrolled: false,
|
||||
disableClick: false,
|
||||
maxColumnCount: 2,
|
||||
maxInlineSize: 720,
|
||||
maxBlockSize: 1440,
|
||||
animated: false,
|
||||
vertical: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
@@ -82,10 +80,3 @@ export const MONOSPACE_FONTS = ['Fira Code', 'Lucida Console', 'Consolas', 'Cour
|
||||
export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
|
||||
|
||||
export const BOOK_IDS_SEPARATOR = '+';
|
||||
|
||||
export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web';
|
||||
|
||||
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
|
||||
|
||||
export const SYNC_PROGRESS_INTERVAL_SEC = 60;
|
||||
export const SYNC_NOTES_INTERVAL_SEC = 60;
|
||||
|
||||
@@ -18,7 +18,6 @@ import { type as osType } from '@tauri-apps/plugin-os';
|
||||
import { Book } from '@/types/book';
|
||||
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
|
||||
import { getCoverFilename } from '@/utils/book';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
|
||||
import { BaseAppService } from './appService';
|
||||
import { LOCAL_BOOKS_SUBDIR } from './constants';
|
||||
@@ -52,7 +51,7 @@ const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDi
|
||||
|
||||
export const nativeFileSystem: FileSystem = {
|
||||
getURL(path: string) {
|
||||
return isValidURL(path) ? path : convertFileSrc(path);
|
||||
return convertFileSrc(path);
|
||||
},
|
||||
async getBlobURL(path: string, base: BaseDir) {
|
||||
const content = await this.readFile(path, base, 'binary');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Book } from '@/types/book';
|
||||
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
|
||||
import { getCoverFilename } from '@/utils/book';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
|
||||
import { BaseAppService } from './appService';
|
||||
import { LOCAL_BOOKS_SUBDIR } from './constants';
|
||||
@@ -38,11 +37,7 @@ async function openIndexedDB(): Promise<IDBDatabase> {
|
||||
|
||||
const indexedDBFileSystem: FileSystem = {
|
||||
getURL(path: string) {
|
||||
if (isValidURL(path)) {
|
||||
return path;
|
||||
} else {
|
||||
return URL.createObjectURL(new Blob([path]));
|
||||
}
|
||||
return URL.createObjectURL(new Blob([path]));
|
||||
},
|
||||
async getBlobURL(path: string, base: BaseDir) {
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,6 @@ interface BookData {
|
||||
interface BookDataState {
|
||||
booksData: { [id: string]: BookData };
|
||||
getConfig: (key: string | null) => BookConfig | null;
|
||||
setConfig: (key: string, config: BookConfig) => void;
|
||||
saveConfig: (
|
||||
envConfig: EnvConfigType,
|
||||
bookKey: string,
|
||||
@@ -39,20 +38,6 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
||||
const id = key.split('-')[0]!;
|
||||
return get().booksData[id]?.config || null;
|
||||
},
|
||||
setConfig: (key: string, config: BookConfig) => {
|
||||
set((state: BookDataState) => {
|
||||
const id = key.split('-')[0]!;
|
||||
return {
|
||||
booksData: {
|
||||
...state.booksData,
|
||||
[id]: {
|
||||
...state.booksData[id]!,
|
||||
config,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
saveConfig: async (
|
||||
envConfig: EnvConfigType,
|
||||
bookKey: string,
|
||||
@@ -64,10 +49,10 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
||||
const bookIndex = library.findIndex((b) => b.hash === bookKey.split('-')[0]);
|
||||
if (bookIndex == -1) return;
|
||||
const book = library.splice(bookIndex, 1)[0]!;
|
||||
book.updatedAt = Date.now();
|
||||
book.lastUpdated = Date.now();
|
||||
library.unshift(book);
|
||||
setLibrary(library);
|
||||
config.updatedAt = Date.now();
|
||||
config.lastUpdated = Date.now();
|
||||
appService.saveBookConfig(book, config, settings);
|
||||
appService.saveLibraryBooks(library);
|
||||
},
|
||||
@@ -82,7 +67,7 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
||||
);
|
||||
updatedConfig = {
|
||||
...book.config,
|
||||
updatedAt: Date.now(),
|
||||
lastUpdated: Date.now(),
|
||||
booknotes: dedupedBooknotes,
|
||||
};
|
||||
return {
|
||||
@@ -92,7 +77,7 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
||||
...book,
|
||||
config: {
|
||||
...book.config,
|
||||
updatedAt: Date.now(),
|
||||
lastUpdated: Date.now(),
|
||||
booknotes: dedupedBooknotes,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,9 +2,9 @@ import { create } from 'zustand';
|
||||
|
||||
import { BookContent, BookConfig, PageInfo, BookProgress, ViewSettings } from '@/types/book';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { BookDoc, DocumentLoader, SectionItem, TOCItem } from '@/libs/document';
|
||||
import { updateTocCFI, updateTocID } from '@/utils/toc';
|
||||
import { FoliateView } from '@/app/reader/components/FoliateViewer';
|
||||
import { BookDoc, DocumentLoader, TOCItem } from '@/libs/document';
|
||||
import { updateTocID } from '@/utils/toc';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
import { useBookDataStore } from './bookDataStore';
|
||||
import { useLibraryStore } from './libraryStore';
|
||||
@@ -114,13 +114,8 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
console.log('Loading book', key);
|
||||
const { book: loadedBookDoc } = await new DocumentLoader(file).open();
|
||||
const bookDoc = loadedBookDoc as BookDoc;
|
||||
if (bookDoc.toc?.length > 0 && bookDoc.sections?.length > 0) {
|
||||
if (bookDoc.toc) {
|
||||
updateTocID(bookDoc.toc);
|
||||
const sections = bookDoc.sections.reduce((map: Record<string, SectionItem>, section) => {
|
||||
map[section.id] = section;
|
||||
return map;
|
||||
}, {});
|
||||
updateTocCFI(bookDoc, bookDoc.toc, sections);
|
||||
}
|
||||
useBookDataStore.setState((state) => ({
|
||||
booksData: {
|
||||
@@ -182,7 +177,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
...bookData,
|
||||
config: {
|
||||
...bookData.config,
|
||||
updatedAt: Date.now(),
|
||||
lastUpdated: Date.now(),
|
||||
viewSettings,
|
||||
},
|
||||
},
|
||||
@@ -216,7 +211,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const oldConfig = bookData.config;
|
||||
const newConfig = {
|
||||
...bookData.config,
|
||||
updatedAt: Date.now(),
|
||||
lastUpdated: Date.now(),
|
||||
progress: [pageinfo.current, pageinfo.total] as [number, number],
|
||||
location,
|
||||
};
|
||||
|
||||
@@ -4,21 +4,15 @@ export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
|
||||
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet';
|
||||
|
||||
export interface Book {
|
||||
// if Book is a remote book we just lazy load the book content
|
||||
url?: string;
|
||||
hash: string;
|
||||
format: BookFormat;
|
||||
title: string;
|
||||
author: string;
|
||||
group?: string;
|
||||
tags?: string[];
|
||||
lastUpdated: number;
|
||||
isRemoved?: boolean;
|
||||
coverImageUrl?: string | null;
|
||||
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
deletedAt?: number | null;
|
||||
|
||||
lastUpdated?: number; // deprecated in favor of updatedAt
|
||||
}
|
||||
|
||||
export interface PageInfo {
|
||||
@@ -28,30 +22,26 @@ export interface PageInfo {
|
||||
}
|
||||
|
||||
export interface BookNote {
|
||||
bookHash?: string;
|
||||
id: string;
|
||||
type: BookNoteType;
|
||||
cfi: string;
|
||||
href: string;
|
||||
text?: string;
|
||||
style?: HighlightStyle;
|
||||
color?: HighlightColor;
|
||||
note: string;
|
||||
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
deletedAt?: number | null;
|
||||
created: number;
|
||||
modified?: number;
|
||||
}
|
||||
|
||||
export interface BookLayout {
|
||||
marginPx: number;
|
||||
gapPercent: number;
|
||||
scrolled: boolean;
|
||||
disableClick: boolean;
|
||||
maxColumnCount: number;
|
||||
maxInlineSize: number;
|
||||
maxBlockSize: number;
|
||||
animated: boolean;
|
||||
vertical: boolean;
|
||||
}
|
||||
|
||||
export interface BookStyle {
|
||||
@@ -114,32 +104,21 @@ export interface BookSearchResult {
|
||||
}
|
||||
|
||||
export interface BookConfig {
|
||||
bookHash?: string;
|
||||
lastUpdated: number;
|
||||
progress?: [number, number];
|
||||
location?: string;
|
||||
|
||||
booknotes?: BookNote[];
|
||||
searchConfig?: Partial<BookSearchConfig>;
|
||||
removedNotesTimestamps?: Record<string, number>;
|
||||
|
||||
searchConfig?: BookSearchConfig;
|
||||
viewSettings?: Partial<ViewSettings>;
|
||||
|
||||
lastSyncedAtConfig?: number;
|
||||
lastSyncedAtNotes?: number;
|
||||
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface BookDataRecord {
|
||||
id: string;
|
||||
book_hash: string;
|
||||
user_id: string;
|
||||
updated_at: number | null;
|
||||
deleted_at: number | null;
|
||||
}
|
||||
|
||||
export interface BooksGroup {
|
||||
name: string;
|
||||
books: Book[];
|
||||
|
||||
updatedAt: number;
|
||||
lastUpdated: number;
|
||||
}
|
||||
export interface BookContent {
|
||||
book: Book;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
export interface DBBook {
|
||||
user_id: string;
|
||||
book_hash: string;
|
||||
format: string;
|
||||
title: string;
|
||||
author: string;
|
||||
group?: string;
|
||||
tags?: string;
|
||||
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DBBookConfig {
|
||||
user_id: string;
|
||||
book_hash: string;
|
||||
location?: string;
|
||||
progress?: string;
|
||||
search_config?: string;
|
||||
view_settings?: string;
|
||||
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DBBookNote {
|
||||
user_id: string;
|
||||
book_hash: string;
|
||||
id: string;
|
||||
type: string;
|
||||
cfi: string;
|
||||
text?: string;
|
||||
style?: string;
|
||||
color?: string;
|
||||
note: string;
|
||||
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
@@ -18,11 +18,6 @@ export interface ReadSettings {
|
||||
export interface SystemSettings {
|
||||
version: number;
|
||||
localBooksDir: string;
|
||||
keepLogin: boolean;
|
||||
|
||||
lastSyncedAtBooks: number;
|
||||
lastSyncedAtConfigs: number;
|
||||
lastSyncedAtNotes: number;
|
||||
|
||||
globalReadSettings: ReadSettings;
|
||||
globalViewSettings: ViewSettings;
|
||||
|
||||
@@ -29,13 +29,7 @@ export interface AppService {
|
||||
|
||||
loadSettings(): Promise<SystemSettings>;
|
||||
saveSettings(settings: SystemSettings): Promise<void>;
|
||||
importBook(
|
||||
file: string | File,
|
||||
books: Book[],
|
||||
saveBook?: boolean,
|
||||
saveCover?: boolean,
|
||||
overwrite?: boolean,
|
||||
): Promise<Book | null>;
|
||||
importBook(file: string | File, books: Book[], overwrite?: boolean): Promise<Book | null>;
|
||||
deleteBook(book: Book): Promise<void>;
|
||||
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
|
||||
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||
|
||||
export interface FoliateView extends HTMLElement {
|
||||
open: (book: BookDoc) => Promise<void>;
|
||||
close: () => void;
|
||||
init: (options: { lastLocation: string }) => void;
|
||||
goTo: (href: string) => void;
|
||||
goToFraction: (fraction: number) => void;
|
||||
prev: (distance: number) => void;
|
||||
next: (distance: number) => void;
|
||||
goLeft: () => void;
|
||||
goRight: () => void;
|
||||
getCFI: (index: number, range: Range) => string;
|
||||
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
|
||||
search: (config: BookSearchConfig) => AsyncGenerator<BookSearchResult | string, void, void>;
|
||||
clearSearch: () => void;
|
||||
select: (target: string | number | { fraction: number }) => void;
|
||||
deselect: () => void;
|
||||
history: {
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
back: () => void;
|
||||
forward: () => void;
|
||||
clear: () => void;
|
||||
};
|
||||
renderer: {
|
||||
scrolled?: boolean;
|
||||
viewSize: number;
|
||||
setAttribute: (name: string, value: string | number) => void;
|
||||
removeAttribute: (name: string) => void;
|
||||
next: () => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
goTo?: (params: { index: number; anchor: number }) => void;
|
||||
setStyles?: (css: string) => void;
|
||||
addEventListener: (type: string, listener: EventListener) => void;
|
||||
removeEventListener: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const wrappedFoliateView = (originalView: FoliateView): FoliateView => {
|
||||
const originalAddAnnotation = originalView.addAnnotation.bind(originalView);
|
||||
originalView.addAnnotation = (note: BookNote, remove = false) => {
|
||||
// transform BookNote to foliate annotation
|
||||
const annotation = {
|
||||
value: note.cfi,
|
||||
...note,
|
||||
};
|
||||
return originalAddAnnotation(annotation, remove);
|
||||
};
|
||||
return originalView;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EXTS } from '@/libs/document';
|
||||
import { Book, BookConfig } from '@/types/book';
|
||||
import { getUserLang, makeSafeFilename } from './misc';
|
||||
import { makeSafeFilename } from './misc';
|
||||
|
||||
export const getDir = (book: Book) => {
|
||||
return `${book.hash}`;
|
||||
@@ -26,7 +26,7 @@ export const getBaseFilename = (filename: string) => {
|
||||
return baseName;
|
||||
};
|
||||
export const INIT_BOOK_CONFIG: BookConfig = {
|
||||
updatedAt: 0,
|
||||
lastUpdated: 0,
|
||||
};
|
||||
|
||||
interface LanguageMap {
|
||||
@@ -37,38 +37,18 @@ interface Contributor {
|
||||
name: LanguageMap;
|
||||
}
|
||||
|
||||
const userLang = getUserLang();
|
||||
|
||||
const formatLanguageMap = (x: string | LanguageMap): string => {
|
||||
if (!x) return '';
|
||||
if (typeof x === 'string') return x;
|
||||
const keys = Object.keys(x);
|
||||
return x[userLang] || x[keys[0]!]!;
|
||||
return x[keys[0]!]!;
|
||||
};
|
||||
|
||||
const listFormat = (lang: string) => {
|
||||
if (lang === 'zh') {
|
||||
return new Intl.ListFormat('en', { style: 'narrow', type: 'unit' });
|
||||
} else {
|
||||
return new Intl.ListFormat(lang, { style: 'long', type: 'conjunction' });
|
||||
}
|
||||
};
|
||||
const listFormat = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
|
||||
|
||||
const getBookLangCode = (lang: string | string[] | undefined) => {
|
||||
try {
|
||||
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
|
||||
return bookLang ? bookLang.split('-')[0]! : 'en';
|
||||
} catch {
|
||||
return 'en';
|
||||
}
|
||||
};
|
||||
|
||||
export const formatAuthors = (
|
||||
bookLang: string | string[] | undefined,
|
||||
contributors: string | Contributor | [string | Contributor],
|
||||
) =>
|
||||
export const formatAuthors = (contributors: string | Contributor | [string | Contributor]) =>
|
||||
Array.isArray(contributors)
|
||||
? listFormat(getBookLangCode(bookLang)).format(
|
||||
? listFormat.format(
|
||||
contributors.map((contributor) =>
|
||||
typeof contributor === 'string' ? contributor : formatLanguageMap(contributor?.name),
|
||||
),
|
||||
@@ -76,7 +56,3 @@ export const formatAuthors = (
|
||||
: typeof contributors === 'string'
|
||||
? contributors
|
||||
: formatLanguageMap(contributors?.name);
|
||||
|
||||
export const formatTitle = (title: string | LanguageMap) => {
|
||||
return typeof title === 'string' ? title : formatLanguageMap(title);
|
||||
};
|
||||
|
||||
@@ -23,8 +23,6 @@ export const makeSafeFilename = (filename: string, replacement = '_') => {
|
||||
return safeName.trim();
|
||||
};
|
||||
|
||||
export const getUserLang = () => navigator?.language.split('-')[0] || 'en';
|
||||
|
||||
export const getOSPlatform = () => {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
@@ -36,12 +34,3 @@ export const getOSPlatform = () => {
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export const isValidURL = (url: string, allowedSchemes: string[] = ['http', 'https']) => {
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
return allowedSchemes.some((scheme) => `${scheme}:` === protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export const FILE_REVEAL_LABELS = {
|
||||
macos: 'Reveal in Finder',
|
||||
windows: 'Reveal in File Explorer',
|
||||
linux: 'Reveal in Folder',
|
||||
default: 'Reveal in Folder',
|
||||
};
|
||||
|
||||
export type FILE_REVEAL_PLATFORMS = keyof typeof FILE_REVEAL_LABELS;
|
||||
@@ -15,7 +15,7 @@ export interface Point {
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type PositionDir = 'up' | 'down' | 'left' | 'right';
|
||||
export type PositionDir = 'up' | 'down';
|
||||
|
||||
export interface Position {
|
||||
point: Point;
|
||||
@@ -42,15 +42,9 @@ const frameRect = (frame: Frame, rect: Rect, sx = 1, sy = 1) => {
|
||||
const pointIsInView = ({ x, y }: Point) =>
|
||||
x > 0 && y > 0 && x < window.innerWidth && y < window.innerHeight;
|
||||
|
||||
const getIframeElement = (nodeElement: Range | Element): HTMLIFrameElement | null => {
|
||||
let node: Node | null;
|
||||
if (nodeElement && typeof nodeElement === 'object' && 'tagName' in nodeElement) {
|
||||
node = nodeElement as Element;
|
||||
} else if (nodeElement && typeof nodeElement === 'object' && 'collapse' in nodeElement) {
|
||||
node = nodeElement.commonAncestorContainer;
|
||||
} else {
|
||||
node = nodeElement;
|
||||
}
|
||||
const getIframeElement = (range: Range): HTMLIFrameElement | null => {
|
||||
let node: Node | null = range.commonAncestorContainer;
|
||||
|
||||
while (node) {
|
||||
if (node.nodeType === Node.DOCUMENT_NODE) {
|
||||
const doc = node as Document;
|
||||
@@ -64,7 +58,8 @@ const getIframeElement = (nodeElement: Range | Element): HTMLIFrameElement | nul
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getPosition = (target: Range | Element, rect: Rect, isVertical: boolean = false) => {
|
||||
export const getPosition = (target: Range, rect: Rect) => {
|
||||
// TODO: vertical text
|
||||
const frameElement = getIframeElement(target);
|
||||
const transform = frameElement ? getComputedStyle(frameElement).transform : '';
|
||||
const match = transform.match(/matrix\((.+)\)/);
|
||||
@@ -74,22 +69,6 @@ export const getPosition = (target: Range | Element, rect: Rect, isVertical: boo
|
||||
const rects = Array.from(target.getClientRects());
|
||||
const first = frameRect(frame, rects[0] as Rect, sx, sy);
|
||||
const last = frameRect(frame, rects.at(-1) as Rect, sx, sy);
|
||||
|
||||
if (isVertical) {
|
||||
const leftSpace = first.left - rect.left;
|
||||
const rightSpace = rect.right - first.right;
|
||||
const dir = leftSpace > rightSpace ? 'left' : 'right';
|
||||
const position = {
|
||||
point: {
|
||||
x: dir === 'left' ? first.left - rect.left - 6 : first.right - rect.left + 6,
|
||||
y: (first.top + first.bottom) / 2 - rect.top,
|
||||
},
|
||||
dir,
|
||||
} as Position;
|
||||
const inView = pointIsInView(position.point);
|
||||
return inView ? position : ({ point: { x: 0, y: 0 }, dir } as Position);
|
||||
}
|
||||
|
||||
const start = {
|
||||
point: { x: (first.left + first.right) / 2 - rect.left, y: first.top - rect.top - 12 },
|
||||
dir: 'up',
|
||||
@@ -113,20 +92,10 @@ export const getPopupPosition = (
|
||||
popupHeightPx: number,
|
||||
popupPaddingPx: number,
|
||||
) => {
|
||||
const popupPoint = { x: 0, y: 0 };
|
||||
if (position.dir === 'up') {
|
||||
popupPoint.x = position.point.x - popupWidthPx / 2;
|
||||
popupPoint.y = position.point.y - popupHeightPx;
|
||||
} else if (position.dir === 'down') {
|
||||
popupPoint.x = position.point.x - popupWidthPx / 2;
|
||||
popupPoint.y = position.point.y + 6;
|
||||
} else if (position.dir === 'left') {
|
||||
popupPoint.x = position.point.x - popupWidthPx;
|
||||
popupPoint.y = position.point.y - popupHeightPx / 2;
|
||||
} else if (position.dir === 'right') {
|
||||
popupPoint.x = position.point.x + 6;
|
||||
popupPoint.y = position.point.y - popupHeightPx / 2;
|
||||
}
|
||||
const popupPoint = {
|
||||
x: position.point.x - popupWidthPx / 2,
|
||||
y: position.dir === 'up' ? position.point.y - popupHeightPx : position.point.y + 6,
|
||||
};
|
||||
|
||||
if (popupPoint.x < popupPaddingPx) {
|
||||
popupPoint.x = popupPaddingPx;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { BookConfig, BookSearchConfig, ViewSettings } from '@/types/book';
|
||||
|
||||
export const serializeConfig = (
|
||||
config: BookConfig,
|
||||
globalViewSettings: ViewSettings,
|
||||
defaultSearchConfig: BookSearchConfig,
|
||||
): string => {
|
||||
config = JSON.parse(JSON.stringify(config));
|
||||
const viewSettings = config.viewSettings as Partial<ViewSettings>;
|
||||
const searchConfig = config.searchConfig as Partial<BookSearchConfig>;
|
||||
config.viewSettings = Object.entries(viewSettings).reduce(
|
||||
(acc: Partial<Record<keyof ViewSettings, unknown>>, [key, value]) => {
|
||||
if (globalViewSettings[key as keyof ViewSettings] !== value) {
|
||||
acc[key as keyof ViewSettings] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Partial<Record<keyof ViewSettings, unknown>>,
|
||||
) as Partial<ViewSettings>;
|
||||
config.searchConfig = Object.entries(searchConfig).reduce(
|
||||
(acc: Partial<Record<keyof BookSearchConfig, unknown>>, [key, value]) => {
|
||||
if (defaultSearchConfig[key as keyof BookSearchConfig] !== value) {
|
||||
acc[key as keyof BookSearchConfig] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Partial<BookSearchConfig>,
|
||||
) as Partial<BookSearchConfig>;
|
||||
|
||||
return JSON.stringify(config);
|
||||
};
|
||||
|
||||
export const deserializeConfig = (
|
||||
str: string,
|
||||
globalViewSettings: ViewSettings,
|
||||
defaultSearchConfig: BookSearchConfig,
|
||||
): BookConfig => {
|
||||
const config = JSON.parse(str) as BookConfig;
|
||||
const { viewSettings, searchConfig } = config;
|
||||
config.viewSettings = { ...globalViewSettings, ...viewSettings };
|
||||
config.searchConfig = { ...defaultSearchConfig, ...searchConfig };
|
||||
config.updatedAt ??= Date.now();
|
||||
return config;
|
||||
};
|
||||
|
||||
export const compressConfig = (
|
||||
config: BookConfig,
|
||||
globalViewSettings: ViewSettings,
|
||||
defaultSearchConfig: BookSearchConfig,
|
||||
): string => {
|
||||
return JSON.parse(serializeConfig(config, globalViewSettings, defaultSearchConfig));
|
||||
};
|
||||
@@ -29,36 +29,6 @@ const getFontStyles = (
|
||||
return fontStyles;
|
||||
};
|
||||
|
||||
const getAdditionalFontFaces = () => `
|
||||
@font-face {
|
||||
font-family: "FangSong";
|
||||
src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot?#iefix") format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff2") format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff") format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.ttf") format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.svg#FangSong") format("svg");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Kaiti";
|
||||
src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot?#iefix")format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff2")format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff")format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.ttf")format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.svg#STKaiti")format("svg");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Heiti";
|
||||
src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot");
|
||||
src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot?#iefix")format("embedded-opentype"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff2")format("woff2"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff")format("woff"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.ttf")format("truetype"),
|
||||
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.svg#WenQuanYi Micro Hei")format("svg");
|
||||
}
|
||||
`;
|
||||
|
||||
const getLayoutStyles = (
|
||||
spacing: number,
|
||||
justify: boolean,
|
||||
@@ -104,17 +74,14 @@ const getLayoutStyles = (
|
||||
white-space: pre-wrap !important;
|
||||
tab-size: 2;
|
||||
}
|
||||
html, body {
|
||||
body {
|
||||
color: ${fg};
|
||||
zoom: ${zoomLevel}%;
|
||||
}
|
||||
body *:not(a):not(#b1):not(#b1 *):not(#b2):not(#b2 *):not(.bg):not(.bg *):not(.vol):not(.vol *):not(.background):not(.background *) {
|
||||
border-color: currentColor !important;
|
||||
${bg === '#ffffff' ? '' : `color: inherit;`}
|
||||
${bg === '#ffffff' ? '' : `background-color: ${bg} !important;`}
|
||||
background-color: ${bg} !important;
|
||||
}
|
||||
svg, img {
|
||||
background-color: transparent !important;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
p, li, blockquote, dd {
|
||||
text-align: ${justify ? 'justify' : 'start'};
|
||||
@@ -141,11 +108,6 @@ const getLayoutStyles = (
|
||||
aside[epub|type~="rearnote"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.duokan-footnote-content,
|
||||
.duokan-footnote-item {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface ThemeCode {
|
||||
@@ -176,9 +138,3 @@ export const getStyles = (viewSettings: ViewSettings, themeCode: ThemeCode) => {
|
||||
const userStylesheet = viewSettings.userStylesheet!;
|
||||
return `${layoutStyles}\n${fontStyles}\n${fontfacesCSS}\n${userStylesheet}`;
|
||||
};
|
||||
|
||||
export const mountAdditionalFonts = (document: Document) => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = getAdditionalFontFaces();
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl =
|
||||
process.env['NEXT_PUBLIC_SUPABASE_URL'] || process.env['NEXT_PUBLIC_DEV_SUPABASE_URL']!;
|
||||
const supabaseAnonKey =
|
||||
process.env['NEXT_PUBLIC_SUPABASE_ANON_KEY'] || process.env['NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY']!;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
|
||||
export const createSupabaseClient = (accessToken?: string) => {
|
||||
return createClient(supabaseUrl, supabaseAnonKey, {
|
||||
global: {
|
||||
headers: accessToken
|
||||
? {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SectionItem, TOCItem, CFI, BookDoc } from '@/libs/document';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
|
||||
export const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
|
||||
for (const item of toc) {
|
||||
@@ -15,28 +15,6 @@ export const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
|
||||
return [];
|
||||
};
|
||||
|
||||
export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
|
||||
let left = 0;
|
||||
let right = toc.length - 1;
|
||||
let result: TOCItem | null = null;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const currentCfi = toc[mid]!.cfi || '';
|
||||
const comparison = CFI.compare(currentCfi, cfi);
|
||||
if (comparison === 0) {
|
||||
return toc[mid]!;
|
||||
} else if (comparison < 0) {
|
||||
result = toc[mid]!;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const updateTocID = (items: TOCItem[], index = 0): number => {
|
||||
items.forEach((item) => {
|
||||
item.id ??= index++;
|
||||
@@ -46,22 +24,3 @@ export const updateTocID = (items: TOCItem[], index = 0): number => {
|
||||
});
|
||||
return index;
|
||||
};
|
||||
|
||||
export const updateTocCFI = (
|
||||
bookDoc: BookDoc,
|
||||
items: TOCItem[],
|
||||
sections: { [id: string]: SectionItem },
|
||||
): void => {
|
||||
items.forEach((item) => {
|
||||
if (item.href) {
|
||||
const id = bookDoc.splitTOCHref(item.href)[0]!;
|
||||
const section = sections[id];
|
||||
if (section) {
|
||||
item.cfi = section.cfi;
|
||||
}
|
||||
}
|
||||
if (item.subitems) {
|
||||
updateTocCFI(bookDoc, item.subitems, sections);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import {
|
||||
Book,
|
||||
BookConfig,
|
||||
BookFormat,
|
||||
BookNote,
|
||||
BookNoteType,
|
||||
HighlightColor,
|
||||
HighlightStyle,
|
||||
} from '@/types/book';
|
||||
import { DBBookConfig, DBBook, DBBookNote } from '@/types/records';
|
||||
|
||||
export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DBBookConfig => {
|
||||
const { bookHash, progress, location, searchConfig, viewSettings, updatedAt } =
|
||||
bookConfig as BookConfig;
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
book_hash: bookHash!,
|
||||
location: location,
|
||||
progress: progress && JSON.stringify(progress),
|
||||
search_config: searchConfig && JSON.stringify(searchConfig),
|
||||
view_settings: viewSettings && JSON.stringify(viewSettings),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfig => {
|
||||
const { book_hash, progress, location, search_config, view_settings, updated_at } = dbBookConfig;
|
||||
return {
|
||||
bookHash: book_hash,
|
||||
location,
|
||||
progress: progress && JSON.parse(progress),
|
||||
searchConfig: search_config && JSON.parse(search_config),
|
||||
viewSettings: view_settings && JSON.parse(view_settings),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
} as BookConfig;
|
||||
};
|
||||
|
||||
export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
const { hash, format, title, author, group, tags, createdAt, updatedAt, deletedAt } =
|
||||
book as Book;
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
book_hash: hash,
|
||||
format,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags: tags && JSON.stringify(tags),
|
||||
created_at: new Date(createdAt).toISOString(),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
const { book_hash, format, title, author, group, tags, created_at, updated_at, deleted_at } =
|
||||
dbBook;
|
||||
|
||||
return {
|
||||
hash: book_hash,
|
||||
format: format as BookFormat,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags: tags && JSON.parse(tags),
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : null,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBookNote => {
|
||||
const { bookHash, id, type, cfi, text, style, color, note, createdAt, updatedAt, deletedAt } =
|
||||
bookNote as BookNote;
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
book_hash: bookHash!,
|
||||
id,
|
||||
type,
|
||||
cfi,
|
||||
text,
|
||||
style,
|
||||
color,
|
||||
note,
|
||||
created_at: new Date(createdAt).toISOString(),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
// note that only null deleted_at is updated to the database, undefined is not
|
||||
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformBookNoteFromDB = (dbBookNote: DBBookNote): BookNote => {
|
||||
const { book_hash, id, type, cfi, text, style, color, note, created_at, updated_at, deleted_at } =
|
||||
dbBookNote;
|
||||
|
||||
return {
|
||||
bookHash: book_hash,
|
||||
id,
|
||||
type: type as BookNoteType,
|
||||
cfi,
|
||||
text,
|
||||
style: style as HighlightStyle,
|
||||
color: color as HighlightColor,
|
||||
note,
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : null,
|
||||
};
|
||||
};
|
||||
|
After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 948 KiB After Width: | Height: | Size: 948 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "@readest/monorepo",
|
||||
"private": true,
|
||||
"repository": "readest/readest",
|
||||
"repository": "chrox/readest",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"tauri": "pnpm --filter @readest/readest-app tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sindresorhus/tsconfig": "^6.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"husky": "^9.1.6",
|
||||
|
||||