Compare commits

..

52 Commits

Author SHA1 Message Date
chrox 90fee08be2 Release version 0.8.8 2024-12-29 12:48:55 +01:00
chrox 870607f9c3 Add missing translations 2024-12-29 10:42:35 +01:00
chrox fc163926ad Use mainBinaryName for binary execuable name 2024-12-29 10:02:30 +01:00
Huang Xin 4a33409559 Some CSS tweaks to better support dark mode and embedded fonts (#63) 2024-12-28 23:24:59 +01:00
chrox b8af483229 Rename binary execuable to readest for better cli interface, closes #61
Note that this does not affect the product name which is still Readest
and the package name is not changed.
2024-12-28 17:13:37 +01:00
chrox ba9490ad00 Use ubuntu-22.04 to build Linux binaries to support Debian Bookworm
Add Windows arm64 build
Fix release notes format
2024-12-28 10:16:55 +01:00
chrox 0e7db64fe1 Release version 0.8.7 2024-12-27 22:59:44 +01:00
Huang Xin a5f94cced9 Add more keyboard shortcuts (#59)
1. use ctrl/cmd+=, ctrl/cmd+- and ctrl/cmd+0 to zoom in/out and reset zoom;
2. use ctrl/cmd+q to quit app;
2024-12-27 22:35:27 +01:00
Huang Xin 71ffe76e29 Various enhancements (#58)
* Add H and L keyboard shortcuts for back and forward navigation, consistent with Vimium

* Fallback bookmark text to page number if current page has no text, closes #51

* Add delete and edit buttons for highlights and booknotes in the sidebar, closes #57

* Each book now has its own sidebar default tab
2024-12-27 20:53:30 +01:00
Huang Xin 3dcab5a1ee Various fixes (#56)
* Add missing translation for progress synced toast

* Graceful shutdown after all books closed, closes #50

* Throttle app update check to once every day

* Avoid infinite loop to redirect to the login page, closes #54
2024-12-27 16:59:06 +01:00
Huang Xin cb2c7b5c89 Update missing translations (#55) 2024-12-27 11:33:38 +01:00
Huang Xin 07b08ee568 Optimize book details modal (#47) 2024-12-26 23:39:05 +01:00
Huang Xin 4612730474 Add i18n support (#46) 2024-12-26 23:29:54 +01:00
Jiucheng(Oliver) dc500c5bb4 Refactor BookDetailModal to use Next.js Image component and restore conditional rendering for modal visibility (#45) 2024-12-26 17:39:38 +01:00
Jiucheng(Oliver) 329c907f80 Add information modal for book details (#27)
* Add BookDetailModal component and implement details view in Bookshelf

* Enhance BookDetailModal to fetch and display detailed book metadata

* Refactor Bookshelf and BookDetailModal for improved click handling and styling consistency

* Enhance BookDetailModal with additional metadata fields and improved layout

* Refactor BookDetailModal to use appService for fetching book details and remove deprecated bookService
2024-12-26 16:59:54 +01:00
Huang Xin acfce9da67 Show release note in auto updater (#44) 2024-12-26 12:36:57 +01:00
chrox 72c6455fca Release hot-fix version of 0.8.6 2024-12-25 22:06:13 +01:00
Huang Xin 3d0a2b82ba Fix Bookmarks restore from deletion (#43) 2024-12-25 22:02:01 +01:00
Huang Xin 72949e726f Fix deleted bootnotes still shown in sidebar (#42) 2024-12-25 19:39:43 +01:00
Huang Xin 23206bd528 Add context menu for books in Tauri apps (#41)
Currently two menu items in the context menu:
1. Reveal in Folder
2. Delete
2024-12-25 18:43:03 +01:00
chrox 8f26f9a7fa Fix potential 304 Not Modified when pulling in sync 2024-12-25 17:26:08 +01:00
chrox a16ca22814 Refresh session once before logout to ensure logout successfully 2024-12-25 17:26:08 +01:00
Huang Xin 82fb92cf86 Replace deprecated shell-open with opener plugin to open urls and folders (#40) 2024-12-25 16:17:03 +01:00
Huang Xin f76bce5575 Add missing debian section, closes #38 (#39) 2024-12-25 13:07:43 +01:00
Ray-D-Song a23447a813 Add back navigation button and fix dev hydration failed. (#37) 2024-12-25 09:42:37 +01:00
chrox f385c2bf02 Bump update of tauri and plugins 2024-12-24 23:56:47 +01:00
chrox 2c6535450e Update features list in README 2024-12-24 23:13:58 +01:00
chrox 83cb7166fb Support book notes synchronization 2024-12-24 23:09:15 +01:00
chrox b40654a671 Sync progress once per minute 2024-12-24 20:09:25 +01:00
chrox 116b831102 Hide window title only on macOS 2024-12-24 19:25:42 +01:00
chrox 17336d7ac9 Add CORS for the sync API 2024-12-24 18:02:39 +01:00
chrox 447eb09272 Add vercel deploy workflows for readest org 2024-12-24 14:31:02 +01:00
chrox a074542e96 Remove unnecessary favicon and add description for better SEO 2024-12-24 12:37:52 +01:00
Huang Xin 9be9bc8a34 Support reading progress synchronization across platforms (#34) 2024-12-24 12:31:02 +01:00
chrox d7ccbbcaa2 Use splitTOCHref to support other ebook formats as discussed in foliate-js#45 2024-12-20 22:44:35 +01:00
chrox b581251b58 Add referrerpolicy to show avatar images properly 2024-12-18 12:43:07 +01:00
chrox f63ecba5fe Add Readest title in taskbar on Windows, closes #26 2024-12-18 12:04:32 +01:00
chrox bd549d2a0f Get rid of unnecessary data fields in BookConfig 2024-12-17 22:33:18 +01:00
chrox 419db86a4d Package only the minified pdf worker 2024-12-17 15:00:30 +01:00
chrox 54e8798468 Remove obsolete packages 2024-12-17 12:53:36 +01:00
chrox 69276b6beb Add a supabase instance for dev 2024-12-17 11:19:20 +01:00
chrox ed177530ac Add basic user auth 2024-12-17 10:50:52 +01:00
Jiucheng(Oliver) 3be4a26b14 Add 'Disable Click' feature to view settings (#23)
* Add 'Disable Click' feature to view settings

* Add 'Disable Page Turn on Click' option to MiscPanel and update view settings

* Rename 'Control' section to 'Behavior' and update 'Disable Page Turn on Click' label to 'Disable Click-to-Flip'

* Update view settings to include disableClick functionality

* Refactor disableClick handling in MiscPanel to separate useEffect for improved clarity

* Remove unused 'use' import from MiscPanel for cleaner code
2024-12-15 19:11:45 +01:00
chrox 3dbb41fca0 Add feature request template 2024-12-15 17:17:59 +01:00
chrox ace59fb34c Skip titlebar drag events for web platform 2024-12-13 21:53:00 +01:00
chrox 61067dba93 Update next.js to v15.1 2024-12-13 21:51:13 +01:00
caiwenchao 97a0592a43 Custom CSS Add Non-ASCII characters Supporting 2024-12-13 18:19:36 +08:00
chrox d15e661038 Save book configs and settings before page is reloaded, closes #18 2024-12-13 09:02:20 +01:00
chrox c69d58a270 Update discord invite link, closes #17 2024-12-13 06:54:11 +01:00
chrox 960a56cc6f Update demo library 2024-12-12 23:16:32 +01:00
chrox eb346b46b2 Fix background color of popover footnotes and some corner cases for dark mode bg colors 2024-12-12 16:33:48 +01:00
chrox beaa9e42ce Load demo library books only in web app 2024-12-12 09:49:50 +01:00
124 changed files with 6142 additions and 3172 deletions
+19
View File
@@ -0,0 +1,19 @@
---
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.
+37 -23
View File
@@ -6,12 +6,10 @@ on:
types: [published]
jobs:
create-release:
get-release:
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create-release.outputs.result }}
steps:
- uses: actions/checkout@v3
@@ -19,8 +17,8 @@ jobs:
uses: actions/setup-node@v3
- name: get version
run: echo "PACKAGE_VERSION=$(node -p "require('./apps/readest-app/package.json').version")" >> $GITHUB_ENV
- name: create release
id: create-release
- name: get release
id: get-release
uses: actions/github-script@v6
with:
script: |
@@ -28,28 +26,47 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
})
return data.id
core.setOutput('release_id', data.id);
- name: get release notes
id: get-release-notes
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
const version = require('./apps/readest-app/package.json').version;
const releaseNotesFileContent = fs.readFileSync('./apps/readest-app/release-notes.json', 'utf8');
const releaseNotes = JSON.parse(releaseNotesFileContent).releases[version] || {};
const notes = releaseNotes.notes || [];
const releaseNote = notes.map((note, index) => `${index + 1}. ${note}`).join(' ');
console.log('Formatted release note:', releaseNote);
core.setOutput('release_note', releaseNote);
build-tauri:
needs: create-release
needs: get-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
config:
- os: ubuntu-latest
- os: ubuntu-22.04
arch: x86_64
rust_target: x86_64-unknown-linux-gnu
- os: macos-latest
arch: aarch64
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
args: '--target universal-apple-darwin'
- os: windows-latest
arch: x86_64
rust_target: x86_64-pc-windows-msvc
args: '--target x86_64-pc-windows-msvc'
- os: windows-latest
arch: aarch64
rust_target: aarch64-pc-windows-msvc
args: '--target aarch64-pc-windows-msvc --bundles nsis'
runs-on: ${{ matrix.config.os }}
timeout-minutes: 30
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
@@ -67,9 +84,6 @@ jobs:
node-version: 22
cache: pnpm
- name: install rimraf
run: npm install -g rimraf
- name: install dependencies
run: pnpm install
@@ -96,11 +110,8 @@ jobs:
- name: copy .env.local to apps/readest-app
run: cp .env.local apps/readest-app/.env.local
- name: fix dynamic route for Next.js, see https://github.com/vercel/next.js/discussions/55393
run: rimraf "apps/readest-app/src/app/reader/[ids]"
- name: install dependencies (ubuntu only)
if: matrix.config.os == 'ubuntu-latest'
if: matrix.config.os == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
@@ -118,27 +129,30 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
projectPath: apps/readest-app
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.config.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
releaseId: ${{ needs.get-release.steps.get-release.outputs.release_id }}
releaseBody: ${{ needs.get-release.steps.get-release-notes.outputs.release_note }}
args: ${{ matrix.config.args || '' }}
publish-release:
update-release:
permissions:
contents: write
runs-on: ubuntu-latest
needs: [create-release, build-tauri]
needs: [get-release, build-tauri]
steps:
- name: publish release
id: publish-release
- name: update release
id: update-release
uses: actions/github-script@v6
env:
release_id: ${{ needs.create-release.outputs.release_id }}
release_id: ${{ needs.get-release.steps.get-release.outputs.release_id }}
release_note: ${{ needs.get-release.steps.get-release-notes.outputs.release_note }}
with:
script: |
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
body: process.env.release_note,
draft: false,
prerelease: false
})
+23
View File
@@ -0,0 +1,23 @@
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}}
+25
View File
@@ -0,0 +1,25 @@
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 }}
+7
View File
@@ -82,6 +82,13 @@ This project is a monorepo. The code for the `readest-app` is in the `app/reades
| `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:
+17 -17
View File
@@ -39,18 +39,19 @@
<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. | ✅ |
| **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. | ✅ |
## Planned Features
@@ -60,12 +61,11 @@
| **Feature** | **Description** | **Priority** |
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
| **Sync Across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | 🛠 |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🛠 |
| **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. | 🔄 |
@@ -84,7 +84,7 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
![Dark Mode](./data/screenshots/dark_mode.png)
![Themeing Dark Mode](./data/screenshots/theming_dark_mode.png)
---
@@ -162,7 +162,7 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
<a href="https://github.com/chrox/readest/graphs/contributors">
<p align="left">
<img width="50" src="https://contrib.rocks/image?repo=chrox/readest" alt="A table of avatars from the project's contributors" />
<img width="100" src="https://contrib.rocks/image?repo=chrox/readest" alt="A table of avatars from the project's contributors" />
</p>
</a>
@@ -195,6 +195,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/jb2nzDts
[link-discord]: https://discord.gg/gntyVNk3BJ
[link-parallel-read]: https://readest.com/#parallel-read
[link-koreader]: https://github.com/koreader/koreader
+3
View File
@@ -1,3 +1,6 @@
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
@@ -0,0 +1,31 @@
module.exports = {
input: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.test.{js,jsx,ts,tsx}'],
output: '.',
options: {
debug: false,
sort: true,
func: {
list: ['_'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
lngs: ['de', 'ja', 'es', 'fr', 'it', 'ko', 'pt', 'ru', 'tr', 'id', 'vi', 'zh-CN', 'zh-TW'],
ns: ['translation'],
defaultNs: 'translation',
defaultValue: '__STRING_NOT_TRANSLATED__',
resource: {
loadPath: './public/locales/{{lng}}/{{ns}}.json',
savePath: './public/locales/{{lng}}/{{ns}}.json',
jsonIndent: 2,
lineEnding: '\n',
},
keySeparator: false,
nsSeparator: false,
interpolation: {
prefix: '{{',
suffix: '}}',
},
metadata: {},
allowDynamicKeys: true,
removeUnusedKeys: true,
},
};
+32 -20
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.8.3",
"version": "0.8.8",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -9,13 +9,14 @@
"dev-web": "dotenv -e .env.web -- next dev",
"build-web": "dotenv -e .env.web -- next build",
"start-web": "dotenv -e .env.web -- next start",
"i18n:extract": "i18next-scanner",
"lint": "next lint",
"tauri": "tauri",
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs",
"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-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-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,45 +29,56 @@
"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.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",
"@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",
"@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:*",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"next": "15.0.3",
"next": "15.1.0",
"posthog-js": "^1.194.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.2.0",
"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",
"cpx": "^1.5.0",
"cross-var": "^1.1.0",
"cpx2": "^8.0.0",
"daisyui": "^4.12.14",
"dotenv-cli": "^7.4.4",
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
"i18next-scanner": "^4.6.0",
"mkdirp": "^3.0.1",
"node-env-run": "^4.0.2",
"postcss": "^8.4.49",
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
@@ -0,0 +1,107 @@
{
"About Readest": "Über Readest",
"Add your notes here...": "Fügen Sie hier Ihre Notizen hinzu...",
"Animation": "Animation",
"Are you sure to delete the selected books?": "Möchten Sie die ausgewählten Bücher wirklich löschen?",
"Auto Mode": "Automatischer Modus",
"Behavior": "Verhalten",
"Book": "Buch",
"Book Cover": "Buchcover",
"Bookmark": "Lesezeichen",
"Cancel": "Abbrechen",
"Chapter": "Kapitel",
"Cherry": "Kirschrot",
"Color": "Farbe",
"Confirm": "Bestätigen",
"Confirm Deletion": "Löschen bestätigen",
"Custom CSS": "Benutzerdefiniertes CSS",
"Dark Mode": "Dunkelmodus",
"Default": "Standard",
"Default Font": "Standardschriftart",
"Default Font Size": "Standard-Schriftgröße",
"Delete": "Löschen",
"Disable Click-to-Flip": "Klick zum Blättern deaktivieren",
"Download Readest": "Readest herunterladen",
"Edit": "Bearbeiten",
"Enter your custom CSS here...": "Geben Sie hier Ihr benutzerdefiniertes CSS ein...",
"Excerpts": "Auszüge",
"Font": "Schriftart",
"Font & Layout": "Schrift & Layout",
"Font Face": "Schriftschnitt",
"Font Family": "Schriftfamilie",
"Font Size": "Schriftgröße",
"From Local File": "Aus lokaler Datei",
"Full Justification": "Blocksatz",
"Gaps (%)": "Abstände (%)",
"Global Settings": "Globale Einstellungen",
"Go Back": "Zurück",
"Go Forward": "Vorwärts",
"Go Left": "Nach links",
"Go Right": "Nach rechts",
"Grass": "Grasgrün",
"Gray": "Grau",
"Gruvbox": "Gruvbox",
"Hyphenation": "Silbentrennung",
"Identifier:": "Kennung:",
"Import Books": "Bücher importieren",
"Invert Colors in Dark Mode": "Farben im Dunkelmodus invertieren",
"Language:": "Sprache:",
"Layout": "Layout",
"Light Mode": "Hellmodus",
"Line Height": "Zeilenhöhe",
"Loading...": "Wird geladen...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Angemeldet",
"Logged in as {{userDisplayName}}": "Angemeldet als {{userDisplayName}}",
"Margins (px)": "Ränder (px)",
"Match Case": "Groß-/Kleinschreibung beachten",
"Match Diacritics": "Akzente beachten",
"Match Whole Words": "Ganze Wörter",
"Maximum Block Size": "Maximale Blockgröße",
"Maximum Inline Size": "Maximale Zeilengröße",
"Maximum Number of Columns": "Maximale Spaltenanzahl",
"Minimum Font Size": "Minimale Schriftgröße",
"Misc": "Sonstiges",
"Monospace Font": "Monospace-Schriftart",
"More Info": "Weitere Informationen",
"Nord": "Nord",
"Notebook": "Notizbuch",
"Notes": "Notizen",
"Open": "Öffnen",
"Override Publisher Font": "Verleger-Schriftart überschreiben",
"Page": "Seite",
"Paging Animation": "Blätter-Animation",
"Paragraph": "Absatz",
"Parallel Read": "Paralleles Lesen",
"Published:": "Veröffentlicht:",
"Publisher:": "Verlag:",
"Reading progress synced": "Lesevorgang synchronisiert",
"Reload Page": "Seite neu laden",
"Reveal in File Explorer": "Im Datei-Explorer anzeigen",
"Reveal in Finder": "Im Finder anzeigen",
"Reveal in Folder": "Im Ordner anzeigen",
"Sans-Serif Font": "Serifenlose Schriftart",
"Save": "Speichern",
"Scrolled Mode": "Scroll-Modus",
"Search books...": "Bücher suchen...",
"Search...": "Suchen...",
"Select books": "Bücher auswählen",
"Select multiple books": "Mehrere Bücher auswählen",
"Sepia": "Sepia",
"Serif Font": "Serifenschrift",
"Sidebar": "Seitenleiste",
"Sign In": "Anmelden",
"Sign Out": "Abmelden",
"Sky": "Himmelblau",
"Solarized": "Solarisiert",
"Subjects:": "Themen:",
"Theme Color": "Themenfarbe",
"Theme Mode": "Themenmodus",
"Unknown": "Unbekannt",
"Untitled": "Ohne Titel",
"Updated:": "Aktualisiert:",
"User avatar": "Benutzer-Avatar",
"Version {{version}}": "Version {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "Willkommen in Ihrer Bibliothek. Sie können hier Ihre Bücher importieren und jederzeit lesen.",
"Your Library": "Ihre Bibliothek"
}
@@ -0,0 +1 @@
{}
@@ -0,0 +1,107 @@
{
"About Readest": "Acerca de Readest",
"Add your notes here...": "Añade tus notas aquí...",
"Animation": "Animación",
"Are you sure to delete the selected books?": "¿Estás seguro de eliminar los libros seleccionados?",
"Auto Mode": "Modo automático",
"Behavior": "Comportamiento",
"Book": "Libro",
"Book Cover": "Portada del libro",
"Bookmark": "Marcador",
"Cancel": "Cancelar",
"Chapter": "Capítulo",
"Cherry": "Cereza",
"Color": "Color",
"Confirm": "Confirmar",
"Confirm Deletion": "Confirmar eliminación",
"Custom CSS": "CSS personalizado",
"Dark Mode": "Modo oscuro",
"Default": "Predeterminado",
"Default Font": "Fuente predeterminada",
"Default Font Size": "Tamaño de fuente predeterminado",
"Delete": "Eliminar",
"Disable Click-to-Flip": "Desactivar clic para voltear",
"Download Readest": "Descargar Readest",
"Edit": "Editar",
"Enter your custom CSS here...": "Introduce tu CSS personalizado aquí...",
"Excerpts": "Extractos",
"Font": "Fuente",
"Font & Layout": "Fuente y diseño",
"Font Face": "Tipo de fuente",
"Font Family": "Familia de fuente",
"Font Size": "Tamaño de fuente",
"From Local File": "Desde archivo local",
"Full Justification": "Justificación completa",
"Gaps (%)": "Espacios (%)",
"Global Settings": "Configuración global",
"Go Back": "Volver",
"Go Forward": "Avanzar",
"Go Left": "Ir a la izquierda",
"Go Right": "Ir a la derecha",
"Grass": "Hierba",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Hyphenation": "Guionización",
"Identifier:": "Identificador:",
"Import Books": "Importar libros",
"Invert Colors in Dark Mode": "Invertir colores en modo oscuro",
"Language:": "Idioma:",
"Layout": "Diseño",
"Light Mode": "Modo claro",
"Line Height": "Altura de línea",
"Loading...": "Cargando...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Sesión iniciada",
"Logged in as {{userDisplayName}}": "Sesión iniciada como {{userDisplayName}}",
"Margins (px)": "Márgenes (px)",
"Match Case": "Coincidir mayúsculas y minúsculas",
"Match Diacritics": "Coincidir diacríticos",
"Match Whole Words": "Coincidir palabras completas",
"Maximum Block Size": "Tamaño máximo de bloque",
"Maximum Inline Size": "Tamaño máximo en línea",
"Maximum Number of Columns": "Número máximo de columnas",
"Minimum Font Size": "Tamaño de fuente mínimo",
"Misc": "Varios",
"Monospace Font": "Fuente monoespaciada",
"More Info": "Más información",
"Nord": "Nord",
"Notebook": "Cuaderno",
"Notes": "Notas",
"Open": "Abrir",
"Override Publisher Font": "Sobrescribir fuente del editor",
"Page": "Página",
"Paging Animation": "Animación de paginación",
"Paragraph": "Párrafo",
"Parallel Read": "Lectura paralela",
"Published:": "Publicado:",
"Publisher:": "Editorial:",
"Reading progress synced": "Progreso de lectura sincronizado",
"Reload Page": "Recargar página",
"Reveal in File Explorer": "Mostrar en el Explorador de archivos",
"Reveal in Finder": "Mostrar en Finder",
"Reveal in Folder": "Mostrar en carpeta",
"Sans-Serif Font": "Fuente sans-serif",
"Save": "Guardar",
"Scrolled Mode": "Modo desplazamiento",
"Search books...": "Buscar libros...",
"Search...": "Buscar...",
"Select books": "Seleccionar libros",
"Select multiple books": "Seleccionar varios libros",
"Sepia": "Sepia",
"Serif Font": "Fuente serif",
"Sidebar": "Barra lateral",
"Sign In": "Iniciar sesión",
"Sign Out": "Cerrar sesión",
"Sky": "Cielo",
"Solarized": "Solarizado",
"Subjects:": "Temas:",
"Theme Color": "Color del tema",
"Theme Mode": "Modo del tema",
"Unknown": "Desconocido",
"Untitled": "Sin título",
"Updated:": "Actualizado:",
"User avatar": "Avatar del usuario",
"Version {{version}}": "Versión {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenido a tu biblioteca. Puedes importar tus libros aquí y leerlos en cualquier momento.",
"Your Library": "Tu biblioteca"
}
@@ -0,0 +1,107 @@
{
"About Readest": "À propos de Readest",
"Add your notes here...": "Ajoutez vos notes ici...",
"Animation": "Animation",
"Are you sure to delete the selected books?": "Êtes-vous sûr de vouloir supprimer les livres sélectionnés ?",
"Auto Mode": "Mode automatique",
"Behavior": "Comportement",
"Book": "Livre",
"Book Cover": "Couverture du livre",
"Bookmark": "Signet",
"Cancel": "Annuler",
"Chapter": "Chapitre",
"Cherry": "Cerise",
"Color": "Couleur",
"Confirm": "Confirmer",
"Confirm Deletion": "Confirmer la suppression",
"Custom CSS": "CSS personnalisé",
"Dark Mode": "Mode sombre",
"Default": "Par défaut",
"Default Font": "Police par défaut",
"Default Font Size": "Taille de police par défaut",
"Delete": "Supprimer",
"Disable Click-to-Flip": "Désactiver le clic pour tourner",
"Download Readest": "Télécharger Readest",
"Edit": "Modifier",
"Enter your custom CSS here...": "Saisissez votre CSS personnalisé ici...",
"Excerpts": "Extraits",
"Font": "Police",
"Font & Layout": "Police et mise en page",
"Font Face": "Style de police",
"Font Family": "Famille de police",
"Font Size": "Taille de police",
"From Local File": "Depuis un fichier local",
"Full Justification": "Justification complète",
"Gaps (%)": "Espaces (%)",
"Global Settings": "Paramètres globaux",
"Go Back": "Retour",
"Go Forward": "Avancer",
"Go Left": "Aller à gauche",
"Go Right": "Aller à droite",
"Grass": "Herbe",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Hyphenation": "Césure",
"Identifier:": "Identifiant :",
"Import Books": "Importer des livres",
"Invert Colors in Dark Mode": "Inverser les couleurs en mode sombre",
"Language:": "Langue :",
"Layout": "Mise en page",
"Light Mode": "Mode clair",
"Line Height": "Hauteur de ligne",
"Loading...": "Chargement...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Connecté",
"Logged in as {{userDisplayName}}": "Connecté en tant que {{userDisplayName}}",
"Margins (px)": "Marges (px)",
"Match Case": "Respecter la casse",
"Match Diacritics": "Respecter les accents",
"Match Whole Words": "Mots entiers",
"Maximum Block Size": "Taille maximale du bloc",
"Maximum Inline Size": "Taille maximale en ligne",
"Maximum Number of Columns": "Nombre maximal de colonnes",
"Minimum Font Size": "Taille minimale de police",
"Misc": "Divers",
"Monospace Font": "Police monospace",
"More Info": "Plus d'informations",
"Nord": "Nord",
"Notebook": "Carnet de notes",
"Notes": "Notes",
"Open": "Ouvrir",
"Override Publisher Font": "Remplacer la police de l'éditeur",
"Page": "Page",
"Paging Animation": "Animation de page",
"Paragraph": "Paragraphe",
"Parallel Read": "Lecture parallèle",
"Published:": "Publié :",
"Publisher:": "Éditeur :",
"Reading progress synced": "Progression de lecture synchronisée",
"Reload Page": "Recharger la page",
"Reveal in File Explorer": "Afficher dans l'explorateur de fichiers",
"Reveal in Finder": "Afficher dans le Finder",
"Reveal in Folder": "Afficher dans le dossier",
"Sans-Serif Font": "Police sans empattement",
"Save": "Enregistrer",
"Scrolled Mode": "Mode défilement",
"Search books...": "Rechercher des livres...",
"Search...": "Rechercher...",
"Select books": "Sélectionner des livres",
"Select multiple books": "Sélectionner plusieurs livres",
"Sepia": "Sépia",
"Serif Font": "Police avec empattement",
"Sidebar": "Barre latérale",
"Sign In": "Se connecter",
"Sign Out": "Se déconnecter",
"Sky": "Ciel",
"Solarized": "Solarisé",
"Subjects:": "Sujets :",
"Theme Color": "Couleur du thème",
"Theme Mode": "Mode du thème",
"Unknown": "Inconnu",
"Untitled": "Sans titre",
"Updated:": "Mis à jour :",
"User avatar": "Avatar de l'utilisateur",
"Version {{version}}": "Version {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenue dans votre bibliothèque. Vous pouvez importer vos livres ici et les lire à tout moment.",
"Your Library": "Votre bibliothèque"
}
@@ -0,0 +1,107 @@
{
"About Readest": "Tentang Readest",
"Add your notes here...": "Tambahkan catatan Anda di sini...",
"Animation": "Animasi",
"Are you sure to delete the selected books?": "Apakah Anda yakin ingin menghapus buku yang dipilih?",
"Auto Mode": "Mode Otomatis",
"Behavior": "Perilaku",
"Book": "Buku",
"Book Cover": "Sampul Buku",
"Bookmark": "Penanda",
"Cancel": "Batal",
"Chapter": "Bab",
"Cherry": "Ceri",
"Color": "Warna",
"Confirm": "Konfirmasi",
"Confirm Deletion": "Konfirmasi Penghapusan",
"Custom CSS": "CSS Kustom",
"Dark Mode": "Mode Gelap",
"Default": "Default",
"Default Font": "Font Default",
"Default Font Size": "Ukuran Font Default",
"Delete": "Hapus",
"Disable Click-to-Flip": "Nonaktifkan Klik untuk Membalik",
"Download Readest": "Unduh Readest",
"Edit": "Edit",
"Enter your custom CSS here...": "Masukkan CSS kustom Anda di sini...",
"Excerpts": "Kutipan",
"Font": "Font",
"Font & Layout": "Font & Tata Letak",
"Font Face": "Jenis Font",
"Font Family": "Keluarga Font",
"Font Size": "Ukuran Font",
"From Local File": "Dari File Lokal",
"Full Justification": "Rata Penuh",
"Gaps (%)": "Jarak (%)",
"Global Settings": "Pengaturan Global",
"Go Back": "Kembali",
"Go Forward": "Maju",
"Go Left": "Ke Kiri",
"Go Right": "Ke Kanan",
"Grass": "Rumput",
"Gray": "Abu-abu",
"Gruvbox": "Gruvbox",
"Hyphenation": "Pemenggalan Kata",
"Identifier:": "Pengenal:",
"Import Books": "Impor Buku",
"Invert Colors in Dark Mode": "Balik Warna di Mode Gelap",
"Language:": "Bahasa:",
"Layout": "Tata Letak",
"Light Mode": "Mode Terang",
"Line Height": "Tinggi Baris",
"Loading...": "Memuat...",
"Loc. {{currentPage}} / {{totalPage}}": "Lok. {{currentPage}} / {{totalPage}}",
"Logged in": "Sudah Masuk",
"Logged in as {{userDisplayName}}": "Masuk sebagai {{userDisplayName}}",
"Margins (px)": "Margin (px)",
"Match Case": "Cocokkan Huruf Besar/Kecil",
"Match Diacritics": "Cocokkan Diakritik",
"Match Whole Words": "Cocokkan Kata Utuh",
"Maximum Block Size": "Ukuran Blok Maksimum",
"Maximum Inline Size": "Ukuran Sebaris Maksimum",
"Maximum Number of Columns": "Jumlah Kolom Maksimum",
"Minimum Font Size": "Ukuran Font Minimum",
"Misc": "Lain-lain",
"Monospace Font": "Font Monospace",
"More Info": "Info Lebih Lanjut",
"Nord": "Nord",
"Notebook": "Buku Catatan",
"Notes": "Catatan",
"Open": "Buka",
"Override Publisher Font": "Ganti Font Penerbit",
"Page": "Halaman",
"Paging Animation": "Animasi Halaman",
"Paragraph": "Paragraf",
"Parallel Read": "Baca Paralel",
"Published:": "Diterbitkan:",
"Publisher:": "Penerbit:",
"Reading progress synced": "Progres membaca disinkronkan",
"Reload Page": "Muat Ulang Halaman",
"Reveal in File Explorer": "Tampilkan di File Explorer",
"Reveal in Finder": "Tampilkan di Finder",
"Reveal in Folder": "Tampilkan di Folder",
"Sans-Serif Font": "Font Sans-Serif",
"Save": "Simpan",
"Scrolled Mode": "Mode Gulir",
"Search books...": "Cari buku...",
"Search...": "Cari...",
"Select books": "Pilih buku",
"Select multiple books": "Pilih beberapa buku",
"Sepia": "Sepia",
"Serif Font": "Font Serif",
"Sidebar": "Bilah Samping",
"Sign In": "Masuk",
"Sign Out": "Keluar",
"Sky": "Langit",
"Solarized": "Solarized",
"Subjects:": "Subjek:",
"Theme Color": "Warna Tema",
"Theme Mode": "Mode Tema",
"Unknown": "Tidak Diketahui",
"Untitled": "Tanpa Judul",
"Updated:": "Diperbarui:",
"User avatar": "Avatar Pengguna",
"Version {{version}}": "Versi {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "Selamat datang di perpustakaan Anda. Anda dapat mengimpor buku-buku Anda di sini dan membacanya kapan saja.",
"Your Library": "Perpustakaan Anda"
}
@@ -0,0 +1,107 @@
{
"About Readest": "Informazioni su Readest",
"Add your notes here...": "Aggiungi qui le tue note...",
"Animation": "Animazione",
"Are you sure to delete the selected books?": "Sei sicuro di voler eliminare i libri selezionati?",
"Auto Mode": "Modalità automatica",
"Behavior": "Comportamento",
"Book": "Libro",
"Book Cover": "Copertina",
"Bookmark": "Segnalibro",
"Cancel": "Annulla",
"Chapter": "Capitolo",
"Cherry": "Ciliegia",
"Color": "Colore",
"Confirm": "Conferma",
"Confirm Deletion": "Conferma eliminazione",
"Custom CSS": "CSS personalizzato",
"Dark Mode": "Modalità scura",
"Default": "Predefinito",
"Default Font": "Font predefinito",
"Default Font Size": "Dimensione font predefinita",
"Delete": "Elimina",
"Disable Click-to-Flip": "Disattiva click per voltare pagina",
"Download Readest": "Scarica Readest",
"Edit": "Modifica",
"Enter your custom CSS here...": "Inserisci qui il tuo CSS personalizzato...",
"Excerpts": "Estratti",
"Font": "Font",
"Font & Layout": "Font e Layout",
"Font Face": "Tipo di carattere",
"Font Family": "Famiglia di caratteri",
"Font Size": "Dimensione carattere",
"From Local File": "Da file locale",
"Full Justification": "Giustificazione completa",
"Gaps (%)": "Spaziature (%)",
"Global Settings": "Impostazioni globali",
"Go Back": "Indietro",
"Go Forward": "Avanti",
"Go Left": "Vai a sinistra",
"Go Right": "Vai a destra",
"Grass": "Erba",
"Gray": "Grigio",
"Gruvbox": "Gruvbox",
"Hyphenation": "Sillabazione",
"Identifier:": "Identificatore:",
"Import Books": "Importa libri",
"Invert Colors in Dark Mode": "Inverti colori in modalità scura",
"Language:": "Lingua:",
"Layout": "Layout",
"Light Mode": "Modalità chiara",
"Line Height": "Altezza riga",
"Loading...": "Caricamento...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Accesso effettuato",
"Logged in as {{userDisplayName}}": "Accesso effettuato come {{userDisplayName}}",
"Margins (px)": "Margini (px)",
"Match Case": "Maiuscole/minuscole",
"Match Diacritics": "Corrispondenza diacritici",
"Match Whole Words": "Parole intere",
"Maximum Block Size": "Dimensione massima blocco",
"Maximum Inline Size": "Dimensione massima in linea",
"Maximum Number of Columns": "Numero massimo di colonne",
"Minimum Font Size": "Dimensione minima font",
"Misc": "Varie",
"Monospace Font": "Font monospazio",
"More Info": "Maggiori informazioni",
"Nord": "Nord",
"Notebook": "Quaderno",
"Notes": "Note",
"Open": "Apri",
"Override Publisher Font": "Sostituisci font editore",
"Page": "Pagina",
"Paging Animation": "Animazione cambio pagina",
"Paragraph": "Paragrafo",
"Parallel Read": "Lettura parallela",
"Published:": "Pubblicato:",
"Publisher:": "Editore:",
"Reading progress synced": "Progresso lettura sincronizzato",
"Reload Page": "Ricarica pagina",
"Reveal in File Explorer": "Mostra in Esplora file",
"Reveal in Finder": "Mostra nel Finder",
"Reveal in Folder": "Mostra nella cartella",
"Sans-Serif Font": "Font sans-serif",
"Save": "Salva",
"Scrolled Mode": "Modalità scorrimento",
"Search books...": "Cerca libri...",
"Search...": "Cerca...",
"Select books": "Seleziona libri",
"Select multiple books": "Seleziona più libri",
"Sepia": "Seppia",
"Serif Font": "Font serif",
"Sidebar": "Barra laterale",
"Sign In": "Accedi",
"Sign Out": "Esci",
"Sky": "Cielo",
"Solarized": "Solarizzato",
"Subjects:": "Argomenti:",
"Theme Color": "Colore tema",
"Theme Mode": "Modalità tema",
"Unknown": "Sconosciuto",
"Untitled": "Senza titolo",
"Updated:": "Aggiornato:",
"User avatar": "Avatar utente",
"Version {{version}}": "Versione {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "Benvenuto nella tua biblioteca. Puoi importare i tuoi libri qui e leggerli in qualsiasi momento.",
"Your Library": "La tua biblioteca"
}
@@ -0,0 +1,107 @@
{
"About Readest": "Readestについて",
"Add your notes here...": "ここにメモを追加...",
"Animation": "アニメーション",
"Are you sure to delete the selected books?": "選択した書籍を削除してもよろしいですか?",
"Auto Mode": "自動モード",
"Behavior": "動作",
"Book": "書籍",
"Book Cover": "表紙",
"Bookmark": "ブックマーク",
"Cancel": "キャンセル",
"Chapter": "章",
"Cherry": "チェリー",
"Color": "色",
"Confirm": "確認",
"Confirm Deletion": "削除の確認",
"Custom CSS": "カスタムCSS",
"Dark Mode": "ダークモード",
"Default": "デフォルト",
"Default Font": "デフォルトフォント",
"Default Font Size": "デフォルトフォントサイズ",
"Delete": "削除",
"Disable Click-to-Flip": "クリックめくりを無効化",
"Download Readest": "Readestをダウンロード",
"Edit": "編集",
"Enter your custom CSS here...": "ここにカスタムCSSを入力...",
"Excerpts": "抜粋",
"Font": "フォント",
"Font & Layout": "フォントとレイアウト",
"Font Face": "書体",
"Font Family": "フォントファミリー",
"Font Size": "フォントサイズ",
"From Local File": "ローカルファイルから",
"Full Justification": "両端揃え",
"Gaps (%)": "間隔 (%)",
"Global Settings": "全体設定",
"Go Back": "戻る",
"Go Forward": "進む",
"Go Left": "左へ",
"Go Right": "右へ",
"Grass": "グリーン",
"Gray": "グレー",
"Gruvbox": "グルーブボックス",
"Hyphenation": "ハイフネーション",
"Identifier:": "識別子:",
"Import Books": "書籍をインポート",
"Invert Colors in Dark Mode": "ダークモードで色を反転",
"Language:": "言語:",
"Layout": "レイアウト",
"Light Mode": "ライトモード",
"Line Height": "行間",
"Loading...": "読み込み中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "ログイン済み",
"Logged in as {{userDisplayName}}": "{{userDisplayName}}としてログイン中",
"Margins (px)": "余白 (px)",
"Match Case": "大文字・小文字を区別",
"Match Diacritics": "アクセント記号を区別",
"Match Whole Words": "単語全体を一致",
"Maximum Block Size": "最大ブロックサイズ",
"Maximum Inline Size": "最大インラインサイズ",
"Maximum Number of Columns": "最大列数",
"Minimum Font Size": "最小フォントサイズ",
"Misc": "その他",
"Monospace Font": "等幅フォント",
"More Info": "詳細情報",
"Nord": "ノルド",
"Notebook": "ノート",
"Notes": "メモ",
"Open": "開く",
"Override Publisher Font": "出版社フォントを上書き",
"Page": "ページ",
"Paging Animation": "ページめくりアニメーション",
"Paragraph": "段落",
"Parallel Read": "並列読書",
"Published:": "出版日:",
"Publisher:": "出版社:",
"Reading progress synced": "読書進捗が同期されました",
"Reload Page": "ページを再読み込み",
"Reveal in File Explorer": "エクスプローラーで表示",
"Reveal in Finder": "Finderで表示",
"Reveal in Folder": "フォルダーで表示",
"Sans-Serif Font": "ゴシック体",
"Save": "保存",
"Scrolled Mode": "スクロールモード",
"Search books...": "書籍を検索...",
"Search...": "検索...",
"Select books": "書籍を選択",
"Select multiple books": "複数の書籍を選択",
"Sepia": "セピア",
"Serif Font": "明朝体",
"Sidebar": "サイドバー",
"Sign In": "サインイン",
"Sign Out": "サインアウト",
"Sky": "スカイ",
"Solarized": "ソーラライズド",
"Subjects:": "主題:",
"Theme Color": "テーマカラー",
"Theme Mode": "テーマモード",
"Unknown": "不明",
"Untitled": "無題",
"Updated:": "更新日:",
"User avatar": "ユーザーアバター",
"Version {{version}}": "バージョン {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
"Your Library": "ライブラリー"
}
@@ -0,0 +1,107 @@
{
"About Readest": "Readest 정보",
"Add your notes here...": "여기에 메모를 추가하세요...",
"Animation": "애니메이션",
"Are you sure to delete the selected books?": "선택한 책을 삭제하시겠습니까?",
"Auto Mode": "자동 모드",
"Behavior": "동작",
"Book": "책",
"Book Cover": "책 표지",
"Bookmark": "북마크",
"Cancel": "취소",
"Chapter": "챕터",
"Cherry": "벚꽃색",
"Color": "색상",
"Confirm": "확인",
"Confirm Deletion": "삭제 확인",
"Custom CSS": "사용자 정의 CSS",
"Dark Mode": "다크 모드",
"Default": "기본값",
"Default Font": "기본 글꼴",
"Default Font Size": "기본 글꼴 크기",
"Delete": "삭제",
"Disable Click-to-Flip": "클릭 넘기기 비활성화",
"Download Readest": "Readest 다운로드",
"Edit": "편집",
"Enter your custom CSS here...": "여기에 사용자 정의 CSS를 입력하세요...",
"Excerpts": "발췌",
"Font": "글꼴",
"Font & Layout": "글꼴 및 레이아웃",
"Font Face": "글꼴 스타일",
"Font Family": "글꼴 패밀리",
"Font Size": "글꼴 크기",
"From Local File": "로컬 파일에서",
"Full Justification": "양쪽 정렬",
"Gaps (%)": "간격 (%)",
"Global Settings": "전역 설정",
"Go Back": "뒤로",
"Go Forward": "앞으로",
"Go Left": "왼쪽으로",
"Go Right": "오른쪽으로",
"Grass": "잔디색",
"Gray": "회색",
"Gruvbox": "그러브박스",
"Hyphenation": "하이픈 넣기",
"Identifier:": "식별자:",
"Import Books": "책 가져오기",
"Invert Colors in Dark Mode": "다크 모드에서 색상 반전",
"Language:": "언어:",
"Layout": "레이아웃",
"Light Mode": "라이트 모드",
"Line Height": "줄 간격",
"Loading...": "로딩 중...",
"Loc. {{currentPage}} / {{totalPage}}": "위치 {{currentPage}} / {{totalPage}}",
"Logged in": "로그인됨",
"Logged in as {{userDisplayName}}": "{{userDisplayName}}(으)로 로그인됨",
"Margins (px)": "여백 (px)",
"Match Case": "대소문자 구분",
"Match Diacritics": "발음 구별 부호 구분",
"Match Whole Words": "전체 단어 일치",
"Maximum Block Size": "최대 블록 크기",
"Maximum Inline Size": "최대 인라인 크기",
"Maximum Number of Columns": "최대 열 수",
"Minimum Font Size": "최소 글꼴 크기",
"Misc": "기타",
"Monospace Font": "고정폭 글꼴",
"More Info": "추가 정보",
"Nord": "노드",
"Notebook": "노트북",
"Notes": "메모",
"Open": "열기",
"Override Publisher Font": "출판사 글꼴 재정의",
"Page": "페이지",
"Paging Animation": "페이지 넘김 애니메이션",
"Paragraph": "단락",
"Parallel Read": "병렬 읽기",
"Published:": "출판일:",
"Publisher:": "출판사:",
"Reading progress synced": "읽기 진행 상황 동기화됨",
"Reload Page": "페이지 새로고침",
"Reveal in File Explorer": "파일 탐색기에서 표시",
"Reveal in Finder": "Finder에서 표시",
"Reveal in Folder": "폴더에서 표시",
"Sans-Serif Font": "산세리프체",
"Save": "저장",
"Scrolled Mode": "스크롤 모드",
"Search books...": "책 검색...",
"Search...": "검색...",
"Select books": "책 선택",
"Select multiple books": "여러 책 선택",
"Sepia": "세피아",
"Serif Font": "세리프체",
"Sidebar": "사이드바",
"Sign In": "로그인",
"Sign Out": "로그아웃",
"Sky": "하늘색",
"Solarized": "솔라라이즈드",
"Subjects:": "주제:",
"Theme Color": "테마 색상",
"Theme Mode": "테마 모드",
"Unknown": "알 수 없음",
"Untitled": "제목 없음",
"Updated:": "업데이트일:",
"User avatar": "사용자 아바타",
"Version {{version}}": "버전 {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
"Your Library": "내 서재"
}
@@ -0,0 +1,107 @@
{
"About Readest": "Sobre o Readest",
"Add your notes here...": "Adicione suas notas aqui...",
"Animation": "Animação",
"Are you sure to delete the selected books?": "Tem certeza de que deseja excluir os livros selecionados?",
"Auto Mode": "Modo Automático",
"Behavior": "Comportamento",
"Book": "Livro",
"Book Cover": "Capa do Livro",
"Bookmark": "Marcador",
"Cancel": "Cancelar",
"Chapter": "Capítulo",
"Cherry": "Cereja",
"Color": "Cor",
"Confirm": "Confirmar",
"Confirm Deletion": "Confirmar Exclusão",
"Custom CSS": "CSS Personalizado",
"Dark Mode": "Modo Escuro",
"Default": "Padrão",
"Default Font": "Fonte Padrão",
"Default Font Size": "Tamanho da Fonte Padrão",
"Delete": "Excluir",
"Disable Click-to-Flip": "Desativar Clique para Virar",
"Download Readest": "Baixar Readest",
"Edit": "Editar",
"Enter your custom CSS here...": "Insira seu CSS personalizado aqui...",
"Excerpts": "Trechos",
"Font": "Fonte",
"Font & Layout": "Fonte e Layout",
"Font Face": "Estilo da Fonte",
"Font Family": "Família da Fonte",
"Font Size": "Tamanho da Fonte",
"From Local File": "Do Arquivo Local",
"Full Justification": "Justificação Completa",
"Gaps (%)": "Espaçamentos (%)",
"Global Settings": "Configurações Globais",
"Go Back": "Voltar",
"Go Forward": "Avançar",
"Go Left": "Ir para Esquerda",
"Go Right": "Ir para Direita",
"Grass": "Grama",
"Gray": "Cinza",
"Gruvbox": "Gruvbox",
"Hyphenation": "Hifenização",
"Identifier:": "Identificador:",
"Import Books": "Importar Livros",
"Invert Colors in Dark Mode": "Inverter Cores no Modo Escuro",
"Language:": "Idioma:",
"Layout": "Layout",
"Light Mode": "Modo Claro",
"Line Height": "Altura da Linha",
"Loading...": "Carregando...",
"Loc. {{currentPage}} / {{totalPage}}": "Loc. {{currentPage}} / {{totalPage}}",
"Logged in": "Conectado",
"Logged in as {{userDisplayName}}": "Conectado como {{userDisplayName}}",
"Margins (px)": "Margens (px)",
"Match Case": "Diferenciar Maiúsculas e Minúsculas",
"Match Diacritics": "Corresponder Acentos",
"Match Whole Words": "Corresponder Palavras Inteiras",
"Maximum Block Size": "Tamanho Máximo do Bloco",
"Maximum Inline Size": "Tamanho Máximo em Linha",
"Maximum Number of Columns": "Número Máximo de Colunas",
"Minimum Font Size": "Tamanho Mínimo da Fonte",
"Misc": "Diversos",
"Monospace Font": "Fonte Monoespaçada",
"More Info": "Mais Informações",
"Nord": "Nord",
"Notebook": "Caderno",
"Notes": "Notas",
"Open": "Abrir",
"Override Publisher Font": "Substituir Fonte do Editor",
"Page": "Página",
"Paging Animation": "Animação de Página",
"Paragraph": "Parágrafo",
"Parallel Read": "Leitura Paralela",
"Published:": "Publicado:",
"Publisher:": "Editora:",
"Reading progress synced": "Progresso de leitura sincronizado",
"Reload Page": "Recarregar Página",
"Reveal in File Explorer": "Mostrar no Explorador de Arquivos",
"Reveal in Finder": "Mostrar no Finder",
"Reveal in Folder": "Mostrar na Pasta",
"Sans-Serif Font": "Fonte Sans-Serif",
"Save": "Salvar",
"Scrolled Mode": "Modo de Rolagem",
"Search books...": "Procurar livros...",
"Search...": "Pesquisar...",
"Select books": "Selecionar livros",
"Select multiple books": "Selecionar múltiplos livros",
"Sepia": "Sépia",
"Serif Font": "Fonte Serif",
"Sidebar": "Barra Lateral",
"Sign In": "Entrar",
"Sign Out": "Sair",
"Sky": "Céu",
"Solarized": "Solarizado",
"Subjects:": "Assuntos:",
"Theme Color": "Cor do Tema",
"Theme Mode": "Modo do Tema",
"Unknown": "Desconhecido",
"Untitled": "Sem Título",
"Updated:": "Atualizado:",
"User avatar": "Avatar do usuário",
"Version {{version}}": "Versão {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "Bem-vindo à sua biblioteca. Você pode importar seus livros aqui e lê-los a qualquer momento.",
"Your Library": "Sua Biblioteca"
}
@@ -0,0 +1,107 @@
{
"About Readest": "О Readest",
"Add your notes here...": "Добавьте свои заметки здесь...",
"Animation": "Анимация",
"Are you sure to delete the selected books?": "Вы уверены, что хотите удалить выбранные книги?",
"Auto Mode": "Автоматический режим",
"Behavior": "Поведение",
"Book": "Книга",
"Book Cover": "Обложка книги",
"Bookmark": "Закладка",
"Cancel": "Отмена",
"Chapter": "Глава",
"Cherry": "Вишня",
"Color": "Цвет",
"Confirm": "Подтвердить",
"Confirm Deletion": "Подтвердить удаление",
"Custom CSS": "Пользовательский CSS",
"Dark Mode": "Тёмная тема",
"Default": "По умолчанию",
"Default Font": "Шрифт по умолчанию",
"Default Font Size": "Размер шрифта по умолчанию",
"Delete": "Удалить",
"Disable Click-to-Flip": "Отключить переворот по клику",
"Download Readest": "Скачать Readest",
"Edit": "Редактировать",
"Enter your custom CSS here...": "Введите ваш пользовательский CSS здесь...",
"Excerpts": "Отрывки",
"Font": "Шрифт",
"Font & Layout": "Шрифт и макет",
"Font Face": "Начертание шрифта",
"Font Family": "Семейство шрифтов",
"Font Size": "Размер шрифта",
"From Local File": "Из локального файла",
"Full Justification": "Полное выравнивание",
"Gaps (%)": "Промежутки (%)",
"Global Settings": "Общие настройки",
"Go Back": "Назад",
"Go Forward": "Вперёд",
"Go Left": "Влево",
"Go Right": "Вправо",
"Grass": "Трава",
"Gray": "Серый",
"Gruvbox": "Gruvbox",
"Hyphenation": "Перенос слов",
"Identifier:": "Идентификатор:",
"Import Books": "Импорт книг",
"Invert Colors in Dark Mode": "Инвертировать цвета в тёмной теме",
"Language:": "Язык:",
"Layout": "Макет",
"Light Mode": "Светлая тема",
"Line Height": "Межстрочный интервал",
"Loading...": "Загрузка...",
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Выполнен вход",
"Logged in as {{userDisplayName}}": "Вход выполнен как {{userDisplayName}}",
"Margins (px)": "Отступы (px)",
"Match Case": "Учитывать регистр",
"Match Diacritics": "Учитывать диакритические знаки",
"Match Whole Words": "Только целые слова",
"Maximum Block Size": "Максимальный размер блока",
"Maximum Inline Size": "Максимальный размер строки",
"Maximum Number of Columns": "Максимальное количество колонок",
"Minimum Font Size": "Минимальный размер шрифта",
"Misc": "Разное",
"Monospace Font": "Моноширинный шрифт",
"More Info": "Подробнее",
"Nord": "Nord",
"Notebook": "Блокнот",
"Notes": "Заметки",
"Open": "Открыть",
"Override Publisher Font": "Переопределить шрифт издателя",
"Page": "Страница",
"Paging Animation": "Анимация перелистывания",
"Paragraph": "Абзац",
"Parallel Read": "Параллельное чтение",
"Published:": "Опубликовано:",
"Publisher:": "Издатель:",
"Reading progress synced": "Синхронизирован прогресс чтения",
"Reload Page": "Перезагрузить страницу",
"Reveal in File Explorer": "Показать в проводнике",
"Reveal in Finder": "Показать в Finder",
"Reveal in Folder": "Показать в папке",
"Sans-Serif Font": "Шрифт без засечек",
"Save": "Сохранить",
"Scrolled Mode": "Режим прокрутки",
"Search books...": "Поиск книг...",
"Search...": "Поиск...",
"Select books": "Выбрать книги",
"Select multiple books": "Выбрать несколько книг",
"Sepia": "Сепия",
"Serif Font": "Шрифт с засечками",
"Sidebar": "Боковая панель",
"Sign In": "Войти",
"Sign Out": "Выйти",
"Sky": "Небесный",
"Solarized": "Солнечный",
"Subjects:": "Темы:",
"Theme Color": "Цвет темы",
"Theme Mode": "Режим темы",
"Unknown": "Неизвестно",
"Untitled": "Без названия",
"Updated:": "Обновлено:",
"User avatar": "Аватар пользователя",
"Version {{version}}": "Версия {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
"Your Library": "Ваша библиотека"
}
@@ -0,0 +1,107 @@
{
"About Readest": "Readest Hakkında",
"Add your notes here...": "Notlarınızı buraya ekleyin...",
"Animation": "Animasyon",
"Are you sure to delete the selected books?": "Seçili kitapları silmek istediğinizden emin misiniz?",
"Auto Mode": "Otomatik Mod",
"Behavior": "Davranış",
"Book": "Kitap",
"Book Cover": "Kitap Kapağı",
"Bookmark": "Yer İmi",
"Cancel": "İptal",
"Chapter": "Bölüm",
"Cherry": "Kiraz",
"Color": "Renk",
"Confirm": "Onayla",
"Confirm Deletion": "Silmeyi Onayla",
"Custom CSS": "Özel CSS",
"Dark Mode": "Karanlık Mod",
"Default": "Varsayılan",
"Default Font": "Varsayılan Yazı Tipi",
"Default Font Size": "Varsayılan Yazı Boyutu",
"Delete": "Sil",
"Disable Click-to-Flip": "Tıkla-Çevir'i Devre Dışı Bırak",
"Download Readest": "Readest'i İndir",
"Edit": "Düzenle",
"Enter your custom CSS here...": "Özel CSS'nizi buraya girin...",
"Excerpts": "Alıntılar",
"Font": "Yazı Tipi",
"Font & Layout": "Yazı Tipi ve Düzen",
"Font Face": "Yazı Tipi Yüzü",
"Font Family": "Yazı Tipi Ailesi",
"Font Size": "Yazı Boyutu",
"From Local File": "Yerel Dosyadan",
"Full Justification": "Tam Hizalama",
"Gaps (%)": "Boşluklar (%)",
"Global Settings": "Genel Ayarlar",
"Go Back": "Geri Git",
"Go Forward": "İleri Git",
"Go Left": "Sola Git",
"Go Right": "Sağa Git",
"Grass": "Çimen",
"Gray": "Gri",
"Gruvbox": "Gruvbox",
"Hyphenation": "Heceleme",
"Identifier:": "Tanımlayıcı:",
"Import Books": "Kitapları İçe Aktar",
"Invert Colors in Dark Mode": "Karanlık Modda Renkleri Ters Çevir",
"Language:": "Dil:",
"Layout": "Düzen",
"Light Mode": "Aydınlık Mod",
"Line Height": "Satır Yüksekliği",
"Loading...": "Yükleniyor...",
"Loc. {{currentPage}} / {{totalPage}}": "Konum {{currentPage}} / {{totalPage}}",
"Logged in": "Giriş Yapıldı",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} olarak giriş yapıldı",
"Margins (px)": "Kenar Boşlukları (px)",
"Match Case": "Büyük/Küçük Harf Eşleştir",
"Match Diacritics": "Aksan İşaretlerini Eşleştir",
"Match Whole Words": "Tam Kelimeleri Eşleştir",
"Maximum Block Size": "Maksimum Blok Boyutu",
"Maximum Inline Size": "Maksimum Satır İçi Boyut",
"Maximum Number of Columns": "Maksimum Sütun Sayısı",
"Minimum Font Size": "Minimum Yazı Boyutu",
"Misc": "Diğer",
"Monospace Font": "Eş Aralıklı Yazı Tipi",
"More Info": "Daha Fazla Bilgi",
"Nord": "Nord",
"Notebook": "Not Defteri",
"Notes": "Notlar",
"Open": "Aç",
"Override Publisher Font": "Yayıncı Yazı Tipini Geçersiz Kıl",
"Page": "Sayfa",
"Paging Animation": "Sayfa Çevirme Animasyonu",
"Paragraph": "Paragraf",
"Parallel Read": "Paralel Okuma",
"Published:": "Yayınlanma:",
"Publisher:": "Yayıncı:",
"Reading progress synced": "Okuma ilerlemesi senkronize edildi",
"Reload Page": "Sayfayı Yenile",
"Reveal in File Explorer": "Dosya Gezgininde Göster",
"Reveal in Finder": "Finder'da Göster",
"Reveal in Folder": "Klasörde Göster",
"Sans-Serif Font": "Sans-Serif Yazı Tipi",
"Save": "Kaydet",
"Scrolled Mode": "Kaydırma Modu",
"Search books...": "Kitap ara...",
"Search...": "Ara...",
"Select books": "Kitapları seç",
"Select multiple books": "Birden fazla kitap seç",
"Sepia": "Sepya",
"Serif Font": "Serif Yazı Tipi",
"Sidebar": "Kenar Çubuğu",
"Sign In": "Giriş Yap",
"Sign Out": "Çıkış Yap",
"Sky": "Gökyüzü",
"Solarized": "Solarized",
"Subjects:": "Konular:",
"Theme Color": "Tema Rengi",
"Theme Mode": "Tema Modu",
"Unknown": "Bilinmiyor",
"Untitled": "Başlıksız",
"Updated:": "Güncellendi:",
"User avatar": "Kullanıcı avatarı",
"Version {{version}}": "Sürüm {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "Kütüphanenize hoş geldiniz. Buradan kitaplarınızı içe aktarabilir ve istediğiniz zaman okuyabilirsiniz.",
"Your Library": "Kütüphaneniz"
}
@@ -0,0 +1,107 @@
{
"About Readest": "Về Readest",
"Add your notes here...": "Thêm ghi chú của bạn vào đây...",
"Animation": "Hiệu ứng động",
"Are you sure to delete the selected books?": "Bạn có chắc chắn muốn xóa các sách đã chọn?",
"Auto Mode": "Chế độ tự động",
"Behavior": "Hành vi",
"Book": "Sách",
"Book Cover": "Bìa sách",
"Bookmark": "Đánh dấu",
"Cancel": "Hủy",
"Chapter": "Chương",
"Cherry": "Đỏ anh đào",
"Color": "Màu sắc",
"Confirm": "Xác nhận",
"Confirm Deletion": "Xác nhận xóa",
"Custom CSS": "CSS tùy chỉnh",
"Dark Mode": "Chế độ tối",
"Default": "Mặc định",
"Default Font": "Phông chữ mặc định",
"Default Font Size": "Cỡ chữ mặc định",
"Delete": "Xóa",
"Disable Click-to-Flip": "Tắt tính năng nhấp để lật trang",
"Download Readest": "Tải Readest",
"Edit": "Chỉnh sửa",
"Enter your custom CSS here...": "Nhập CSS tùy chỉnh của bạn vào đây...",
"Excerpts": "Trích dẫn",
"Font": "Phông chữ",
"Font & Layout": "Phông chữ & Bố cục",
"Font Face": "Kiểu chữ",
"Font Family": "Họ phông chữ",
"Font Size": "Cỡ chữ",
"From Local File": "Từ tệp cục bộ",
"Full Justification": "Căn đều hai bên",
"Gaps (%)": "Khoảng cách (%)",
"Global Settings": "Cài đặt chung",
"Go Back": "Quay lại",
"Go Forward": "Tiến tới",
"Go Left": "Sang trái",
"Go Right": "Sang phải",
"Grass": "Xanh cỏ",
"Gray": "Xám",
"Gruvbox": "Gruvbox",
"Hyphenation": "Gạch nối từ",
"Identifier:": "Định danh:",
"Import Books": "Nhập sách",
"Invert Colors in Dark Mode": "Đảo màu trong chế độ tối",
"Language:": "Ngôn ngữ:",
"Layout": "Bố cục",
"Light Mode": "Chế độ sáng",
"Line Height": "Độ cao dòng",
"Loading...": "Đang tải...",
"Loc. {{currentPage}} / {{totalPage}}": "Vị trí {{currentPage}} / {{totalPage}}",
"Logged in": "Đã đăng nhập",
"Logged in as {{userDisplayName}}": "Đăng nhập với tên {{userDisplayName}}",
"Margins (px)": "Lề (px)",
"Match Case": "Phân biệt chữ hoa/thường",
"Match Diacritics": "Phân biệt dấu",
"Match Whole Words": "Khớp toàn bộ từ",
"Maximum Block Size": "Kích thước khối tối đa",
"Maximum Inline Size": "Kích thước nội tuyến tối đa",
"Maximum Number of Columns": "Số cột tối đa",
"Minimum Font Size": "Cỡ chữ tối thiểu",
"Misc": "Khác",
"Monospace Font": "Phông chữ đơn cách",
"More Info": "Thông tin thêm",
"Nord": "Nord",
"Notebook": "Sổ ghi chép",
"Notes": "Ghi chú",
"Open": "Mở",
"Override Publisher Font": "Ghi đè phông chữ của nhà xuất bản",
"Page": "Trang",
"Paging Animation": "Hiệu ứng lật trang",
"Paragraph": "Đoạn văn",
"Parallel Read": "Đọc song song",
"Published:": "Xuất bản:",
"Publisher:": "Nhà xuất bản:",
"Reading progress synced": "Tiến độ đọc đã được đồng bộ",
"Reload Page": "Tải lại trang",
"Reveal in File Explorer": "Hiển thị trong File Explorer",
"Reveal in Finder": "Hiển thị trong Finder",
"Reveal in Folder": "Hiển thị trong thư mục",
"Sans-Serif Font": "Phông chữ không chân",
"Save": "Lưu",
"Scrolled Mode": "Chế độ cuộn",
"Search books...": "Tìm kiếm sách...",
"Search...": "Tìm kiếm...",
"Select books": "Chọn sách",
"Select multiple books": "Chọn nhiều sách",
"Sepia": "Nâu cổ",
"Serif Font": "Phông chữ có chân",
"Sidebar": "Thanh bên",
"Sign In": "Đăng nhập",
"Sign Out": "Đăng xuất",
"Sky": "Xanh trời",
"Solarized": "Solarized",
"Subjects:": "Chủ đề:",
"Theme Color": "Màu chủ đề",
"Theme Mode": "Chế độ chủ đề",
"Unknown": "Không xác định",
"Untitled": "Không có tiêu đề",
"Updated:": "Cập nhật:",
"User avatar": "Ảnh đại diện",
"Version {{version}}": "Phiên bản {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "Chào mừng đến với thư viện của bạn. Bạn có thể nhập sách vào đây và đọc bất cứ lúc nào.",
"Your Library": "Thư viện của bạn"
}
@@ -0,0 +1,107 @@
{
"About Readest": "关于 Readest",
"Add your notes here...": "在这里添加您的笔记...",
"Animation": "动画",
"Are you sure to delete the selected books?": "您确定要删除所选书籍吗?",
"Auto Mode": "自动主题",
"Behavior": "行为",
"Book": "书籍",
"Book Cover": "书籍封面",
"Bookmark": "书签",
"Cancel": "取消",
"Chapter": "章节",
"Cherry": "樱粉",
"Color": "颜色",
"Confirm": "确认",
"Confirm Deletion": "确认删除",
"Custom CSS": "自定义 CSS",
"Dark Mode": "深色主题",
"Default": "默认",
"Default Font": "默认字体",
"Default Font Size": "默认字号",
"Delete": "删除",
"Disable Click-to-Flip": "禁用点击翻页",
"Download Readest": "下载 Readest",
"Edit": "编辑",
"Enter your custom CSS here...": "在此处输入您的自定义 CSS...",
"Excerpts": "摘录",
"Font": "字体",
"Font & Layout": "字体和布局",
"Font Face": "字型",
"Font Family": "字族",
"Font Size": "字号",
"From Local File": "从本地文件导入",
"Full Justification": "两端对齐",
"Gaps (%)": "间距 (%)",
"Global Settings": "全局设置",
"Go Back": "返回",
"Go Forward": "前进",
"Go Left": "向左",
"Go Right": "向右",
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Hyphenation": "断字",
"Identifier:": "识别码",
"Import Books": "导入书籍",
"Invert Colors in Dark Mode": "深色主题下反色",
"Language:": "语言",
"Layout": "布局",
"Light Mode": "浅色主题",
"Line Height": "行高",
"Loading...": "加载中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登录",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登录",
"Margins (px)": "页边距 (px)",
"Match Case": "匹配大小写",
"Match Diacritics": "匹配重音符号",
"Match Whole Words": "匹配整个单词",
"Maximum Block Size": "内容最大宽度",
"Maximum Inline Size": "每栏最大宽度",
"Maximum Number of Columns": "分栏数",
"Minimum Font Size": "最小字号",
"Misc": "杂项",
"Monospace Font": "等宽字体",
"More Info": "更多信息",
"Nord": "极光",
"Notebook": "笔记本",
"Notes": "笔记",
"Open": "打开",
"Override Publisher Font": "覆盖内置字体",
"Page": "页",
"Paging Animation": "翻页动画",
"Paragraph": "段落",
"Parallel Read": "并排阅读",
"Published:": "出版日期",
"Publisher:": "出版商",
"Reading progress synced": "阅读进度已同步",
"Reload Page": "重新加载页面",
"Reveal in File Explorer": "在文件管理器中显示",
"Reveal in Finder": "在访达中显示",
"Reveal in Folder": "在文件夹中显示",
"Sans-Serif Font": "无衬线字体",
"Save": "保存",
"Scrolled Mode": "滚动模式",
"Search books...": "搜索书籍...",
"Search...": "搜索...",
"Select books": "选择书籍",
"Select multiple books": "选择多本书籍",
"Sepia": "旧韵",
"Serif Font": "衬线字体",
"Sidebar": "侧边栏",
"Sign In": "登录",
"Sign Out": "登出",
"Sky": "天青",
"Solarized": "日晖",
"Subjects:": "主题",
"Theme Color": "主题颜色",
"Theme Mode": "主题模式",
"Unknown": "未知",
"Untitled": "无标题",
"Updated:": "更新日期",
"User avatar": "用户头像",
"Version {{version}}": "版本 {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。",
"Your Library": "书库"
}
@@ -0,0 +1,107 @@
{
"About Readest": "關於 Readest",
"Add your notes here...": "在這裡添加您的筆記...",
"Animation": "動畫",
"Are you sure to delete the selected books?": "您確定要刪除所選書籍嗎?",
"Auto Mode": "自動主題",
"Behavior": "行為",
"Book": "書籍",
"Book Cover": "書籍封面",
"Bookmark": "書籤",
"Cancel": "取消",
"Chapter": "章節",
"Cherry": "櫻粉",
"Color": "顏色",
"Confirm": "確認",
"Confirm Deletion": "確認刪除",
"Custom CSS": "自定義 CSS",
"Dark Mode": "深色主題",
"Default": "預設",
"Default Font": "預設字體",
"Default Font Size": "預設字號",
"Delete": "刪除",
"Disable Click-to-Flip": "禁用點擊翻頁",
"Download Readest": "下載 Readest",
"Edit": "編輯",
"Enter your custom CSS here...": "在此處輸入您的自定義 CSS...",
"Excerpts": "摘錄",
"Font": "字體",
"Font & Layout": "字體和版面",
"Font Face": "字型",
"Font Family": "字族",
"Font Size": "字號",
"From Local File": "從本地檔案導入",
"Full Justification": "兩端對齊",
"Gaps (%)": "間距 (%)",
"Global Settings": "全局設置",
"Go Back": "返回",
"Go Forward": "前進",
"Go Left": "向左",
"Go Right": "向右",
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Hyphenation": "斷字",
"Identifier:": "識別碼",
"Import Books": "導入書籍",
"Invert Colors in Dark Mode": "深色主題下反色",
"Language:": "語言",
"Layout": "版面",
"Light Mode": "淺色主題",
"Line Height": "行高",
"Loading...": "載入中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登入",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登入",
"Margins (px)": "頁邊距 (px)",
"Match Case": "匹配大小寫",
"Match Diacritics": "匹配重音符號",
"Match Whole Words": "匹配整個單詞",
"Maximum Block Size": "內容最大寬度",
"Maximum Inline Size": "每欄最大寬度",
"Maximum Number of Columns": "分欄數",
"Minimum Font Size": "最小字號",
"Misc": "雜項",
"Monospace Font": "等寬字體",
"More Info": "更多信息",
"Nord": "極光",
"Notebook": "筆記本",
"Notes": "筆記",
"Open": "打開",
"Override Publisher Font": "覆蓋內置字體",
"Page": "頁",
"Paging Animation": "翻頁動畫",
"Paragraph": "段落",
"Parallel Read": "並排閱讀",
"Published:": "出版日期",
"Publisher:": "出版商",
"Reading progress synced": "閱讀進度已同步",
"Reload Page": "重新載入頁面",
"Reveal in File Explorer": "在檔案管理器中顯示",
"Reveal in Finder": "在訪達中顯示",
"Reveal in Folder": "在資料夾中顯示",
"Sans-Serif Font": "無襯線字體",
"Save": "保存",
"Scrolled Mode": "滾動模式",
"Search books...": "搜索書籍...",
"Search...": "搜索...",
"Select books": "選擇書籍",
"Select multiple books": "選擇多本書籍",
"Sepia": "舊韻",
"Serif Font": "襯線字體",
"Sidebar": "側邊欄",
"Sign In": "登入",
"Sign Out": "登出",
"Sky": "天青",
"Solarized": "日暉",
"Subjects:": "主題",
"Theme Color": "主題顏色",
"Theme Mode": "主題模式",
"Unknown": "未知",
"Untitled": "無標題",
"Updated:": "更新日期",
"User avatar": "用戶頭像",
"Version {{version}}": "版本 {{version}}",
"Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
"Your Library": "書庫"
}
+58
View File
@@ -0,0 +1,58 @@
{
"releases": {
"0.8.8": {
"date": "2024-12-29",
"notes": [
"Use readest binary name for better CLI interface.",
"Some CSS tweaks to better support dark mode and embedded fonts."
]
},
"0.8.7": {
"date": "2024-12-27",
"notes": [
"Support i18n/l10n with popular languages.",
"Support information modal for book details.",
"Various fixes and enhancements."
]
},
"0.8.6": {
"date": "2024-12-25",
"notes": [
"Support back navigation in login page.",
"Support context menu on book cover for desktop version.",
"Resolve the issue with restoring footnotes and bookmarks after deletion."
]
},
"0.8.5": {
"date": "2024-12-24",
"notes": [
"Support progress and notes sync across devices.",
"Support for Non-ASCII characters in custom CSS.",
"Support to disable click-to-flip."
]
},
"0.8.3": {
"date": "2024-12-12",
"notes": [
"Support popover footnotes.",
"Support left / right popover on vertical writing documents.",
"Support loading additional fonts."
]
},
"0.8.2": {
"date": "2024-12-06",
"notes": [
"Support file associations for one-click open with Readest.",
"Support APP auto updater.",
"Suppport online web version."
]
},
"0.7.9": {
"date": "2024-12-01",
"notes": [
"Initial release with full functionality for ePub rendering.",
"Support multi-book reading with Parallel Read."
]
}
}
}
+562 -258
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "Readest"
version = "0.2.0"
version = "0.2.1"
description = "Your online library"
authors = ["Bilingify LLC"]
license = ""
@@ -32,6 +32,8 @@ 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" }
@@ -57,7 +57,11 @@
"shell:default",
"updater:default",
"process:default",
"process:allow-exit",
"process:allow-restart",
"cli:default"
"cli:default",
"oauth:allow-start",
"oauth:allow-cancel",
"opener:default"
]
}
+22 -3
View File
@@ -15,10 +15,12 @@ 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;
@@ -58,11 +60,25 @@ 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_process::init())
.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())
@@ -115,7 +131,6 @@ pub fn run() {
)?;
}
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("")
.inner_size(800.0, 600.0)
.resizable(true)
.maximized(true);
@@ -123,10 +138,14 @@ pub fn run() {
#[cfg(target_os = "macos")]
let win_builder = win_builder
.decorations(true)
.title_bar_style(TitleBarStyle::Overlay);
.title_bar_style(TitleBarStyle::Overlay)
.title("");
#[cfg(not(target_os = "macos"))]
let win_builder = win_builder.decorations(false).transparent(true);
let win_builder = win_builder
.decorations(false)
.transparent(true)
.title("Readest");
win_builder.build().unwrap();
// let win = win_builder.build().unwrap();
+5 -10
View File
@@ -1,7 +1,7 @@
use tauri::menu::MenuEvent;
use tauri::menu::{SubmenuBuilder, HELP_SUBMENU_ID};
use tauri::AppHandle;
use tauri_plugin_shell::ShellExt;
use tauri_plugin_opener::OpenerExt;
pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
let global_menu = app.menu().unwrap();
@@ -27,17 +27,12 @@ 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" {
if let Err(e) = app.shell().open("https://readest.com/privacy-policy", None) {
eprintln!("Failed to open privacy policy: {}", e);
}
let _ = opener.open_url("https://readest.com/privacy-policy", None::<&str>);
} else if event.id() == "report_issue" {
if let Err(e) = app.shell().open("mailto:support@bilingify.com", None) {
eprintln!("Failed to open mail client: {}", e);
}
let _ = opener.open_url("https://github.com/readest/readest/issues", None::<&str>);
} else if event.id() == "readest_help" {
if let Err(e) = app.shell().open("https://readest.com/support", None) {
eprintln!("Failed to open support page: {}", e);
}
let _ = opener.open_url("https://readest.com/support", None::<&str>);
}
}
+11 -5
View File
@@ -1,8 +1,9 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Readest",
"version": "../package.json",
"mainBinaryName": "readest",
"identifier": "com.bilingify.readest",
"version": "../package.json",
"build": {
"frontendDist": "../out",
"devUrl": "http://localhost:3000",
@@ -14,8 +15,8 @@
"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",
"img-src": "'self' blob: data: asset: http://asset.localhost",
"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://*",
"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 +24,7 @@
"assetProtocol": {
"enable": true,
"scope": {
"allow": ["$RESOURCE/**", "$DOCUMENT/**/*", "$APPDATA/**/*", "$TEMP/**/*"],
"allow": ["$RESOURCE/**", "$APPDATA/**/*", "$TEMP/**/*"],
"deny": []
}
}
@@ -49,6 +50,11 @@
"macOS": {
"minimumSystemVersion": "12.0"
},
"linux": {
"deb": {
"section": "text"
}
},
"fileAssociations": [
{
"name": "epub",
@@ -126,7 +132,7 @@
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OTAxMURGQkUzQjFENTQKUldSVUhUdSszeEdRNUExdmFkWnlvYWNYNG5wamkxMmUxRk5SejlMOTJVd28yNXlVTDh6Wld4OC8K",
"endpoints": ["https://github.com/chrox/readest/releases/latest/download/latest.json"]
"endpoints": ["https://github.com/readest/readest/releases/latest/download/latest.json"]
}
}
}
@@ -0,0 +1,24 @@
'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;
}
@@ -0,0 +1,35 @@
'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>
);
}
+221
View File
@@ -0,0 +1,221 @@
'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 { IoArrowBack } from 'react-icons/io5';
import { useAuth } from '@/context/AuthContext';
import { supabase } from '@/utils/supabase';
import { useTheme } from '@/hooks/useTheme';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
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';
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 { envConfig } = useEnv();
const { isDarkMode } = useTheme();
const { settings, setSettings, saveSettings } = useSettingsStore();
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);
}
};
const handleGoBack = () => {
// Keep login false to avoid infinite loop to redirect to the login page
settings.keepLogin = false;
setSettings(settings);
saveSettings(envConfig, settings);
router.back();
};
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={handleGoBack}
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={handleGoBack}
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>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

+10 -12
View File
@@ -1,19 +1,21 @@
import * as React from 'react';
import { AuthProvider } from '@/context/AuthContext';
import { EnvProvider } from '@/context/EnvContext';
import { CSPostHogProvider } from '@/context/PHContext';
import Providers from '@/components/Providers';
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 = 'Readest brings your entire library to your fingertips.';
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 previewImage = 'https://cdn.readest.com/images/open_graph_preview_read_now.png';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en'>
<html>
<head>
<title>{title}</title>
<meta name='mobile-web-app-capable' content='yes' />
@@ -34,13 +36,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<meta name='twitter:description' content={description} />
<meta name='twitter:image' content={previewImage} />
</head>
<CSPostHogProvider>
<body>
<EnvProvider>
<AuthProvider>{children}</AuthProvider>
</EnvProvider>
</body>
</CSPostHogProvider>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
@@ -4,16 +4,27 @@ import { useEffect, useRef, useState } from 'react';
import { PiPlus } from 'react-icons/pi';
import { MdDelete, MdOpenInNew } from 'react-icons/md';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { CiCircleMore } from 'react-icons/ci';
import { useRouter, useSearchParams } 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 { useTranslation } from '@/hooks/useTranslation';
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';
import BookDetailModal from '@/components/BookDetailModal';
type BookshelfItem = Book | BooksGroup;
@@ -26,19 +37,19 @@ const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
const booksGroup = acc[acc.findIndex((group) => group.name === book.group)];
if (booksGroup) {
booksGroup.books.push(book);
booksGroup.lastUpdated = Math.max(acc[groupIndex]!.lastUpdated, book.lastUpdated);
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
} else {
acc.push({
name: book.group,
books: [book],
lastUpdated: book.lastUpdated,
updatedAt: book.updatedAt,
});
}
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.lastUpdated - a.lastUpdated);
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
};
interface BookshelfProps {
@@ -48,16 +59,29 @@ interface BookshelfProps {
}
const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImportBooks }) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
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 [importBookUrl] = useState(searchParams?.get('url') || '');
const isImportingBook = useRef(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const showMoreDetails = (book: Book) => {
setIsModalOpen(true);
setSelectedBook(book);
};
const closeModal = () => {
setIsModalOpen(false);
};
const { setLibrary } = useLibraryStore();
@@ -123,6 +147,32 @@ 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'>
@@ -135,9 +185,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
{'format' in item ? (
<div
className='book-item cursor-pointer'
onClick={() => handleBookClick(item.hash)}
onContextMenu={bookContextMenuHandler.bind(null, item as Book)}
>
<div key={(item as Book).hash} className='bg-base-100 shadow-md'>
<div
key={(item as Book).hash}
className='bg-base-100 shadow-md'
onClick={() => handleBookClick(item.hash)}
>
<div className='relative aspect-[28/41]'>
<Image
src={item.coverImageUrl!}
@@ -176,10 +230,17 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
)}
</div>
</div>
<div className='card-body p-0 pt-2'>
<div className='card-body flex flex-row items-center justify-between p-0 pt-2'>
<h4 className='card-title line-clamp-1 text-[0.6em] text-xs font-semibold'>
{(item as Book).title}
</h4>
<div
className='card-detail'
role='button'
onClick={showMoreDetails.bind(null, item as Book)}
>
<CiCircleMore size={15} />
</div>
</div>
</div>
) : (
@@ -218,11 +279,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
<div className='text-base-content bg-base-300 fixed bottom-4 left-1/2 flex -translate-x-1/2 transform space-x-4 rounded-lg p-4 shadow-lg'>
<button onClick={openSelectedBooks} className='flex items-center space-x-2'>
<MdOpenInNew />
<span>Open</span>
<span>{_('Open')}</span>
</button>
<button onClick={deleteSelectedBooks} className='flex items-center space-x-2'>
<MdDelete className='fill-red-500' />
<span className='text-red-500'>Delete</span>
<span className='text-red-500'>{_('Delete')}</span>
</button>
</div>
)}
@@ -233,12 +294,16 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
)}
{showDeleteAlert && (
<Alert
title='Confirm Deletion'
message='Are you sure to delete the selected books?'
title={_('Confirm Deletion')}
message={_('Are you sure to delete the selected books?')}
onClickCancel={() => setShowDeleteAlert(false)}
onClickConfirm={confirmDelete}
/>
)}
{selectedBook && (
<BookDetailModal isOpen={isModalOpen} book={selectedBook} onClose={closeModal} />
)}
</div>
);
};
@@ -3,10 +3,14 @@ import React, { useEffect, useRef } from 'react';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
import { PiSelectionAllDuotone } from 'react-icons/pi';
import { MdOutlineMenu } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import useTrafficLight from '@/hooks/useTrafficLight';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SettingsMenu from './SettingsMenu';
interface LibraryHeaderProps {
isSelectMode: boolean;
@@ -19,6 +23,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onImportBooks,
onToggleSelectMode,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { isTrafficLightVisible } = useTrafficLight();
const headerRef = useRef<HTMLDivElement>(null);
@@ -50,7 +55,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</span>
<input
type='text'
placeholder='Search books...'
placeholder={_('Search books...')}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
@@ -70,13 +75,20 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
>
<li>
<button className='text-base-content' onClick={onImportBooks}>
From Local File
{_('From Local File')}
</button>
</li>
</ul>
</div>
<button onClick={onToggleSelectMode} aria-label='Select Multiple Books' className='h-6'>
<div className='lg:tooltip lg:tooltip-bottom cursor-pointer' data-tip='Select books'>
<button
onClick={onToggleSelectMode}
aria-label={_('Select multiple books')}
className='h-6'
>
<div
className='lg:tooltip lg:tooltip-bottom cursor-pointer'
data-tip={_('Select books')}
>
<PiSelectionAllDuotone
role='button'
className={`h-6 w-6 ${isSelectMode ? 'fill-gray-400' : 'fill-gray-500'}`}
@@ -85,12 +97,21 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</button>
</div>
</div>
<WindowButtons
headerRef={headerRef}
showMinimize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
showMaximize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
showClose={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
/>
<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>
</div>
</div>
);
@@ -0,0 +1,100 @@
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 { useTranslation } from '@/hooks/useTranslation';
import MenuItem from '@/components/MenuItem';
interface BookMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
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}}', { 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;
@@ -3,6 +3,7 @@ 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';
@@ -40,7 +41,7 @@ export const useDemoBooks = () => {
};
const demoBooksFetchedFlag = localStorage.getItem('demoBooksFetched');
if (!demoBooksFetchedFlag) {
if (isWebAppPlatform() && !demoBooksFetchedFlag) {
fetchDemoBooks();
localStorage.setItem('demoBooksFetched', 'true');
}
+27 -4
View File
@@ -13,7 +13,9 @@ 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 { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDemoBooks } from './hooks/useDemoBooks';
@@ -21,10 +23,12 @@ 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';
const LibraryPage = () => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { token, user } = useAuth();
const {
library: libraryBooks,
setLibrary,
@@ -32,7 +36,8 @@ const LibraryPage = () => {
clearOpenWithBooks,
} = useLibraryStore();
useTheme();
const { setSettings } = useSettingsStore();
const _ = useTranslation();
const { setSettings, saveSettings } = useSettingsStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
@@ -73,6 +78,20 @@ const LibraryPage = () => {
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();
@@ -103,6 +122,7 @@ const LibraryPage = () => {
}
};
initLogin();
initLibrary();
return () => {
clearOpenWithBooks();
@@ -219,17 +239,20 @@ const LibraryPage = () => {
<div className='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'>Your Library</h1>
<h1 className='mb-5 text-5xl font-bold'>{_('Your Library')}</h1>
<p className='mb-5'>
Welcome to your library. You can import your books here and read them anytime.
{_(
'Welcome to your library. You can import your books here and read them anytime.',
)}
</p>
<button className='btn btn-primary rounded-xl' onClick={handleImportBooks}>
Import Books
{_('Import Books')}
</button>
</div>
</div>
</div>
))}
<AboutWindow />
</div>
);
};
@@ -1,6 +0,0 @@
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} />;
}
@@ -5,28 +5,32 @@ import * as CFI from 'foliate-js/epubcfi.js';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import { BookNote } from '@/types/book';
import { uniqueId } from '@/utils/misc';
import Button from '@/components/Button';
import { getCurrentPage } from '@/utils/book';
interface BookmarkTogglerProps {
bookKey: string;
}
const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
const { getProgress, setBookmarkRibbonVisibility } = useReaderStore();
const config = getConfig(bookKey)!;
const progress = getProgress(bookKey)!;
const bookData = getBookData(bookKey)!;
const [isBookmarked, setIsBookmarked] = useState(false);
const toggleBookmark = () => {
const { booknotes: bookmarks = [] } = config;
const { location: cfi, sectionHref: href, range } = progress;
const { location: cfi, range } = progress;
if (!cfi) return;
if (!isBookmarked) {
setIsBookmarked(true);
@@ -36,12 +40,21 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
id: uniqueId(),
type: 'bookmark',
cfi,
href,
text: truncatedText,
text: truncatedText ? truncatedText : `${getCurrentPage(bookData.book!, progress)}`,
note: '',
created: Date.now(),
createdAt: Date.now(),
updatedAt: Date.now(),
};
bookmarks.push(bookmark);
const existingBookmark = bookmarks.find(
(item) => item.type === 'bookmark' && item.cfi === cfi,
);
if (existingBookmark) {
existingBookmark.deletedAt = null;
existingBookmark.updatedAt = Date.now();
existingBookmark.text = bookmark.text;
} else {
bookmarks.push(bookmark);
}
const updatedConfig = updateBooknotes(bookKey, bookmarks);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
@@ -50,14 +63,15 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
setIsBookmarked(false);
const start = CFI.collapse(cfi);
const end = CFI.collapse(cfi, true);
const updatedConfig = updateBooknotes(
bookKey,
bookmarks.filter(
(item) =>
item.type !== 'bookmark' ||
CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) > 0,
),
);
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);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
@@ -72,7 +86,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')
.filter((booknote) => booknote.type === 'bookmark' && !booknote.deletedAt)
.some((item) => CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0);
setIsBookmarked(locationBookmarked);
setBookmarkRibbonVisibility(bookKey, locationBookmarked);
@@ -89,7 +103,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
)
}
onClick={toggleBookmark}
tooltip='Bookmark'
tooltip={_('Bookmark')}
tooltipDirection='bottom'
></Button>
);
@@ -1,10 +1,13 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { BookDoc, getDirection } from '@/libs/document';
import { BookConfig, BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
import { BookConfig } from '@/types/book';
import { FoliateView, wrappedFoliateView } from '@/types/view';
import { useReaderStore } from '@/store/readerStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useClickEvent } from '../hooks/useClickEvent';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { getOSPlatform } from '@/utils/misc';
import { useProgressSync } from '../hooks/useProgressSync';
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
import { getStyles, mountAdditionalFonts } from '@/utils/style';
import { useTheme } from '@/hooks/useTheme';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
@@ -15,57 +18,7 @@ import {
handleClick,
handleWheel,
} from '../utils/iframeEventHandlers';
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;
};
import Toast from '@/components/Toast';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -75,12 +28,18 @@ const FoliateViewer: React.FC<{
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<FoliateView | null>(null);
const isViewCreated = useRef(false);
const { getView, setView: setFoliateView, setProgress, getViewSettings } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey, setViewSettings } = useReaderStore();
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { themeCode } = useTheme();
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
const [toastMessage, setToastMessage] = useState('');
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
}, [toastMessage]);
useProgressSync(bookKey, setToastMessage);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -88,34 +47,7 @@ const FoliateViewer: React.FC<{
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
};
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 { shouldAutoHideScrollbar, handleScrollbarAutoHide } = useAutoHideScrollbar();
const docLoadHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
console.log('doc loaded:', detail);
@@ -157,69 +89,7 @@ const FoliateViewer: React.FC<{
}
};
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]);
const { handleTurnPage } = useClickEvent(bookKey, viewRef, containerRef);
useFoliateEvents(viewRef.current, {
onLoad: docLoadHandler,
onRelocate: progressRelocateHandler,
@@ -288,11 +158,16 @@ const FoliateViewer: React.FC<{
}, []);
return (
<div
className='foliate-viewer h-[100%] w-[100%]'
onClick={(event) => handleTurnPage(event)}
ref={containerRef}
/>
<>
<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' />
)}
</>
);
};
@@ -5,6 +5,7 @@ import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import Button from '@/components/Button';
interface FooterBarProps {
@@ -14,6 +15,7 @@ interface FooterBarProps {
}
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
const _ = useTranslation();
const { hoveredBookKey, setHoveredBookKey, getView } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const view = getView(bookKey);
@@ -53,17 +55,21 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
onMouseEnter={() => setHoveredBookKey(bookKey)}
onMouseLeave={() => setHoveredBookKey('')}
>
<Button icon={<RiArrowLeftWideLine size={20} />} onClick={handleGoPrev} tooltip='Go Left' />
<Button
icon={<RiArrowLeftWideLine size={20} />}
onClick={handleGoPrev}
tooltip={_('Go Left')}
/>
<Button
icon={<RiArrowGoBackLine size={20} />}
onClick={handleGoBack}
tooltip='Go Back'
tooltip={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={<RiArrowGoForwardLine size={20} />}
onClick={handleGoForward}
tooltip='Go Forward'
tooltip={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<span className='mx-2 text-center text-sm'>
@@ -77,7 +83,11 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
value={pageinfoValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<Button icon={<RiArrowRightWideLine size={20} />} onClick={handleGoNext} tooltip='Go Right' />
<Button
icon={<RiArrowRightWideLine size={20} />}
onClick={handleGoNext}
tooltip={_('Go Right')}
/>
</div>
);
};
@@ -6,7 +6,7 @@ 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 './FoliateViewer';
import { FoliateView } from '@/types/view';
import { FootnoteHandler } from 'foliate-js/footnotes.js';
import Popup from '@/components/Popup';
@@ -41,7 +41,13 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
renderer.setAttribute('margin', '0px');
renderer.setAttribute('gap', '5%');
const viewSettings = getViewSettings(bookKey)!;
renderer.setStyles?.(getStyles(viewSettings, themeCode));
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) => {
@@ -57,7 +63,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
footnoteHandler.removeEventListener('render', handleRender);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
}, [view, themeCode]);
const docLinkHandler = async (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -3,6 +3,7 @@ import { TbLayoutSidebarRight, TbLayoutSidebarRightFilled } from 'react-icons/tb
import { useSidebarStore } from '@/store/sidebarStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import Button from '@/components/Button';
interface NotebookTogglerProps {
@@ -10,6 +11,7 @@ interface NotebookTogglerProps {
}
const NotebookToggler: React.FC<NotebookTogglerProps> = ({ bookKey }) => {
const _ = useTranslation();
const { sideBarBookKey, setSideBarBookKey } = useSidebarStore();
const { isNotebookVisible, toggleNotebook } = useNotebookStore();
const handleToggleSidebar = () => {
@@ -30,7 +32,7 @@ const NotebookToggler: React.FC<NotebookTogglerProps> = ({ bookKey }) => {
)
}
onClick={handleToggleSidebar}
tooltip='Notebook'
tooltip={_('Notebook')}
tooltipDirection='bottom'
></Button>
);
@@ -1,5 +1,6 @@
import { PageInfo } from '@/types/book';
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { PageInfo } from '@/types/book';
interface PageInfoProps {
bookFormat: string;
@@ -9,13 +10,17 @@ interface PageInfoProps {
}
const PageInfoView: React.FC<PageInfoProps> = ({ bookFormat, section, pageinfo, gapRight }) => {
const _ = useTranslation();
const pageInfo =
bookFormat === 'PDF'
? section
? `${section.current + 1} / ${section.total}`
: ''
: pageinfo
? `Loc. ${pageinfo.current + 1} / ${pageinfo.total}`
? _('Loc. {{currentPage}} / {{totalPage}}', {
currentPage: pageinfo.current + 1,
totalPage: pageinfo.total,
})
: '';
return (
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
@@ -11,7 +11,8 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { SystemSettings } from '@/types/settings';
import { parseOpenWithFiles } from '@/helpers/cli';
import { tauriHandleClose } from '@/utils/window';
import { tauriHandleClose, tauriHandleOnCloseWindow } from '@/utils/window';
import { isTauriAppPlatform } from '@/services/environment';
import { uniqueId } from '@/utils/misc';
import { navigateToLibrary } from '@/utils/nav';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
@@ -22,6 +23,7 @@ import Spinner from '@/components/Spinner';
import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid';
import { eventDispatcher } from '@/utils/event';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const router = useRouter();
@@ -38,11 +40,11 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
useBookShortcuts({ sideBarBookKey, bookKeys });
React.useEffect(() => {
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,16 +62,31 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const saveConfigAndCloseBook = (bookKey: string) => {
console.log('Closing book', bookKey);
getView(bookKey)?.close();
getView(bookKey)?.remove();
useEffect(() => {
if (isTauriAppPlatform()) tauriHandleOnCloseWindow(handleCloseBooks);
window.addEventListener('beforeunload', handleCloseBooks);
eventDispatcher.on('quit-app', handleCloseBooks);
return () => {
window.removeEventListener('beforeunload', handleCloseBooks);
eventDispatcher.off('quit-app', handleCloseBooks);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKeys]);
const saveBookConfig = async (bookKey: string) => {
const config = getConfig(bookKey);
const { book } = getBookData(bookKey) || {};
const { isPrimary } = getViewState(bookKey) || {};
if (isPrimary && book && config) {
saveConfig(envConfig, bookKey, config, settings);
await saveConfig(envConfig, bookKey, config, settings);
}
};
const saveConfigAndCloseBook = async (bookKey: string) => {
console.log('Closing book', bookKey);
getView(bookKey)?.close();
getView(bookKey)?.remove();
await saveBookConfig(bookKey);
clearViewState(bookKey);
};
@@ -78,11 +95,14 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
navigateToLibrary(router);
};
const handleCloseBooks = () => {
bookKeys.forEach((key) => {
saveConfigAndCloseBook(key);
});
saveSettingsAndGoToLibrary();
const handleCloseBooks = async () => {
await Promise.all(bookKeys.map((key) => saveConfigAndCloseBook(key)));
await saveSettings(envConfig, settings);
};
const handleCloseBooksToLibrary = () => {
handleCloseBooks();
navigateToLibrary(router);
};
const handleCloseBook = async (bookKey: string) => {
@@ -116,7 +136,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
return (
<div className='flex h-screen'>
<SideBar onGoToLibrary={handleCloseBooks} />
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
<Notebook />
</div>
@@ -2,6 +2,7 @@ import React from 'react';
import { TbLayoutSidebar, TbLayoutSidebarFilled } from 'react-icons/tb';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import Button from '@/components/Button';
interface SidebarTogglerProps {
@@ -9,6 +10,7 @@ interface SidebarTogglerProps {
}
const SidebarToggler: React.FC<SidebarTogglerProps> = ({ bookKey }) => {
const _ = useTranslation();
const { sideBarBookKey, isSideBarVisible, setSideBarBookKey, toggleSideBar } = useSidebarStore();
const handleToggleSidebar = () => {
if (sideBarBookKey === bookKey) {
@@ -28,7 +30,7 @@ const SidebarToggler: React.FC<SidebarTogglerProps> = ({ bookKey }) => {
)
}
onClick={handleToggleSidebar}
tooltip='Sidebar'
tooltip={_('Sidebar')}
tooltipDirection='bottom'
/>
);
@@ -5,9 +5,10 @@ import { BiMoon, BiSun } from 'react-icons/bi';
import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import MenuItem from '@/components/MenuItem';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme, ThemeMode } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
@@ -22,6 +23,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
setIsDropdownOpen,
onSetSettingsDialogOpen,
}) => {
const _ = useTranslation();
const { getView, getViews, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
@@ -30,8 +32,8 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const [isInvertedColors, setInvertedColors] = useState(viewSettings!.invert);
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + 10, 200));
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - 10, 50));
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + 1, MAX_ZOOM_LEVEL));
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - 1, MIN_ZOOM_LEVEL));
const resetZoom = () => setZoomLevel(100);
const toggleScrolledMode = () => setScrolledMode(!isScrolledMode);
const toggleInvertedColors = () => setInvertedColors(!isInvertedColors);
@@ -75,12 +77,9 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
useEffect(() => {
const view = getView(bookKey);
if (!view) return;
// FIXME: zoom level is not working in paginated mode
if (viewSettings?.scrolled) {
view.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
}
viewSettings!.zoomLevel = zoomLevel;
setViewSettings(bookKey, viewSettings!);
view.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomLevel]);
@@ -89,17 +88,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
tabIndex={0}
className='view-menu dropdown-content dropdown-right no-triangle border-base-100 z-20 mt-1 w-72 border shadow-2xl'
>
<div
className={clsx(
'flex items-center justify-between rounded-md',
!isScrolledMode && 'text-gray-400',
)}
>
<div className={clsx('flex items-center justify-between rounded-md')}>
<button
onClick={zoomOut}
className={clsx(
'hover:bg-base-200 text-base-content rounded-full p-2',
!isScrolledMode && 'btn-disabled text-gray-400',
zoomLevel <= MIN_ZOOM_LEVEL && 'btn-disabled text-gray-400',
)}
>
<MdZoomOut size={20} />
@@ -107,17 +101,16 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<button
className={clsx(
'hover:bg-base-200 text-base-content h-8 min-h-8 w-[50%] rounded-md p-1 text-center',
!isScrolledMode && 'btn-disabled text-gray-400',
)}
onClick={resetZoom}
>
{zoomLevel}%
{100 - (100 - zoomLevel) * 10}%
</button>
<button
onClick={zoomIn}
className={clsx(
'hover:bg-base-200 text-base-content rounded-full p-2',
!isScrolledMode && 'btn-disabled text-gray-400',
zoomLevel >= MAX_ZOOM_LEVEL && 'btn-disabled text-gray-400',
)}
>
<MdZoomIn size={20} />
@@ -126,10 +119,10 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<hr className='border-base-200 my-1' />
<MenuItem label='Font & Layout' shortcut='Shift+F' onClick={openFontLayoutMenu} />
<MenuItem label={_('Font & Layout')} shortcut='Shift+F' onClick={openFontLayoutMenu} />
<MenuItem
label='Scrolled Mode'
label={_('Scrolled Mode')}
shortcut='Shift+J'
icon={isScrolledMode ? <MdCheck size={20} /> : undefined}
onClick={toggleScrolledMode}
@@ -139,7 +132,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<MenuItem
label={
themeMode === 'dark' ? 'Dark Mode' : themeMode === 'light' ? 'Light Mode' : 'Auto Mode'
themeMode === 'dark'
? _('Dark Mode')
: themeMode === 'light'
? _('Light Mode')
: _('Auto Mode')
}
icon={
themeMode === 'dark' ? (
@@ -153,7 +150,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
onClick={cycleThemeMode}
/>
<MenuItem
label='Invert Colors in Dark Mode'
label={_('Invert Colors in Dark Mode')}
icon={isInvertedColors ? <MdCheck size={20} className='text-base-content' /> : undefined}
onClick={toggleInvertedColors}
disabled={!isDarkMode}
@@ -13,10 +13,12 @@ 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';
@@ -24,7 +26,6 @@ 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();
@@ -33,6 +34,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
const { isNotebookPinned, isNotebookVisible } = useNotebookStore();
const { setNotebookVisible, setNotebookNewAnnotation } = useNotebookStore();
useNotesSync(bookKey);
const config = getConfig(bookKey)!;
const progress = getProgress(bookKey)!;
const bookData = getBookData(bookKey)!;
@@ -100,7 +104,9 @@ 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');
const annotations = booknotes.filter(
(booknote) => booknote.type === 'annotation' && !booknote.deletedAt,
);
const annotation = annotations.find((annotation) => annotation.cfi === cfi);
if (!annotation) return;
const selection = { key: bookKey, annotated: true, text: annotation.text ?? '', range, index };
@@ -198,6 +204,7 @@ 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 &&
@@ -217,21 +224,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: '',
created: Date.now(),
createdAt: Date.now(),
updatedAt: Date.now(),
};
const existingIndex = annotations.findIndex(
(annotation) => annotation.cfi === cfi && annotation.type === 'excerpt',
(annotation) =>
annotation.cfi === cfi && annotation.type === 'excerpt' && !annotation.deletedAt,
);
if (existingIndex !== -1) {
annotations[existingIndex] = annotation;
@@ -250,7 +257,6 @@ 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;
@@ -259,15 +265,16 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
id: uniqueId(),
type: 'annotation',
cfi,
href,
style,
color,
text: selection.text,
note: '',
created: Date.now(),
createdAt: Date.now(),
updatedAt: Date.now(),
};
const existingIndex = annotations.findIndex(
(annotation) => annotation.cfi === cfi && annotation.type === 'annotation',
(annotation) =>
annotation.cfi === cfi && annotation.type === 'annotation' && !annotation.deletedAt,
);
const views = getViewsById(bookKey.split('-')[0]!);
if (existingIndex !== -1) {
@@ -276,7 +283,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
annotations[existingIndex] = annotation;
views.forEach((view) => view?.addAnnotation(annotation));
} else {
annotations.splice(existingIndex, 1);
annotations[existingIndex]!.deletedAt = Date.now();
setShowAnnotPopup(false);
}
} else {
@@ -393,7 +400,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
)}
{toastMessage && <Toast message={toastMessage} />}
{toastMessage && <Toast message={toastMessage} alertClass='bg-neutual text-content' />}
</div>
);
};
@@ -2,27 +2,31 @@ import React from 'react';
import { FiSearch } from 'react-icons/fi';
import { MdOutlinePushPin, MdPushPin } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
const NotebookHeader: React.FC<{
isPinned: boolean;
handleTogglePin: () => void;
}> = ({ isPinned, handleTogglePin }) => (
<div className='notebook-header relative flex h-11 items-center px-3'>
<div className='absolute inset-0 flex items-center justify-center'>
<div className='notebook-title text-sm font-medium'>Notebook</div>
}> = ({ isPinned, handleTogglePin }) => {
const _ = useTranslation();
return (
<div className='notebook-header relative flex h-11 items-center px-3'>
<div className='absolute inset-0 flex items-center justify-center'>
<div className='notebook-title text-sm font-medium'>{_('Notebook')}</div>
</div>
<div className='z-10 flex items-center space-x-3'>
<button
onClick={handleTogglePin}
className={`${isPinned ? 'bg-base-300' : 'bg-base-300/65'} btn btn-ghost btn-circle h-6 min-h-6 w-6`}
>
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
</button>
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
<FiSearch size={18} />
</button>
</div>
</div>
<div className='z-10 flex items-center space-x-3'>
<button
onClick={handleTogglePin}
className={`${isPinned ? 'bg-base-300' : 'bg-base-300/65'} btn btn-ghost btn-circle h-6 min-h-6 w-6`}
>
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
</button>
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
<FiSearch size={18} />
</button>
</div>
</div>
);
);
};
export default NotebookHeader;
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { TextSelection } from '@/utils/sel';
import { BookNote } from '@/types/book';
@@ -10,6 +11,7 @@ interface NoteEditorProps {
}
const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
const _ = useTranslation();
const { notebookNewAnnotation, notebookEditAnnotation } = useNotebookStore();
const editorRef = useRef<HTMLTextAreaElement>(null);
const [note, setNote] = React.useState('');
@@ -62,7 +64,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
rows={1}
spellCheck={false}
onChange={handleChange}
placeholder='Add your notes here...'
placeholder={_('Add your notes here...')}
></textarea>
</div>
<button
@@ -73,7 +75,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
)}
onClick={handleSaveNote}
>
<div className='pr-1 align-bottom text-xs text-blue-400'>Save</div>
<div className='pr-1 align-bottom text-xs text-blue-400'>{_('Save')}</div>
</button>
</div>
<div className='flex items-start pt-2'>
@@ -6,6 +6,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import useDragBar from '../../hooks/useDragBar';
import NotebookHeader from './Header';
@@ -19,6 +20,7 @@ const MIN_NOTEBOOK_WIDTH = 0.15;
const MAX_NOTEBOOK_WIDTH = 0.45;
const Notebook: React.FC = ({}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
@@ -72,9 +74,9 @@ const Notebook: React.FC = ({}) => {
type: 'annotation',
cfi,
note,
href: selection.href || '',
text: selection.text,
created: Date.now(),
createdAt: Date.now(),
updatedAt: Date.now(),
};
annotations.push(annotation);
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
@@ -84,14 +86,18 @@ const Notebook: React.FC = ({}) => {
setNotebookNewAnnotation(null);
};
const handleEditNote = (annotation: BookNote) => {
const handleEditNote = (note: BookNote, isDelete: boolean) => {
if (!sideBarBookKey) return;
const config = getConfig(sideBarBookKey)!;
const { booknotes: annotations = [] } = config;
const existingIndex = annotations.findIndex((item) => item.id === annotation.id);
const existingIndex = annotations.findIndex((item) => item.id === note.id);
if (existingIndex === -1) return;
annotation.modified = Date.now();
annotations[existingIndex] = annotation;
if (isDelete) {
note.deletedAt = Date.now();
} else {
note.updatedAt = Date.now();
}
annotations[existingIndex] = note;
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
if (updatedConfig) {
saveConfig(envConfig, sideBarBookKey, updatedConfig, settings);
@@ -100,28 +106,17 @@ 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)
.sort((a, b) => b.created - a.created);
.filter((note) => note.type === 'annotation' && note.note && !note.deletedAt)
.sort((a, b) => b.createdAt - a.createdAt);
const excerptNotes = allNotes
.filter((note) => note.type === 'excerpt')
.sort((a, b) => a.created - b.created);
.filter((note) => note.type === 'excerpt' && note.text && !note.deletedAt)
.sort((a, b) => a.createdAt - b.createdAt);
return isNotebookVisible ? (
<>
@@ -146,7 +141,7 @@ const Notebook: React.FC = ({}) => {
/>
<NotebookHeader isPinned={isNotebookPinned} handleTogglePin={handleTogglePin} />
<div className='max-h-[calc(100vh-44px)] overflow-y-auto px-3'>
<div>{excerptNotes.length > 0 && <p className='pt-1 text-sm'>Excerpts</p>}</div>
<div>{excerptNotes.length > 0 && <p className='pt-1 text-sm'>{_('Excerpts')}</p>}</div>
<ul className=''>
{excerptNotes.map((item, index) => (
<li key={`${index}-${item.id}`} className='my-2'>
@@ -176,9 +171,9 @@ const Notebook: React.FC = ({}) => {
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1em] min-h-[1em] items-end p-0',
)}
onClick={deleteNote.bind(null, item)}
onClick={handleEditNote.bind(null, item, true)}
>
<div className='align-bottom text-xs text-red-400'>Delete</div>
<div className='align-bottom text-xs text-red-400'>{_('Delete')}</div>
</button>
</div>
</div>
@@ -188,20 +183,15 @@ const Notebook: React.FC = ({}) => {
</ul>
<div>
{(notebookNewAnnotation || annotationNotes.length > 0) && (
<p className='pt-1 text-sm'>Notes</p>
<p className='pt-1 text-sm'>{_('Notes')}</p>
)}
</div>
{(notebookNewAnnotation || notebookEditAnnotation) && (
<NoteEditor onSave={handleSaveNote} onEdit={handleEditNote} />
<NoteEditor onSave={handleSaveNote} onEdit={(item) => handleEditNote(item, false)} />
)}
<ul className=''>
{annotationNotes.map((item, index) => (
<BooknoteItem
key={`${index}-${item.cfi}`}
bookKey={sideBarBookKey}
item={item}
editable={true}
/>
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={sideBarBookKey} item={item} />
))}
</ul>
</div>
@@ -6,9 +6,11 @@ import { TbSunMoon } from 'react-icons/tb';
import { useTheme } from '@/hooks/useTheme';
import { themes } from '@/styles/themes';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { themeMode, themeColor, themeCode, isDarkMode, updateThemeMode, updateThemeColor } =
useTheme();
const { getViews, getViewSettings } = useReaderStore();
@@ -24,9 +26,9 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>Theme Mode</h2>
<h2 className='font-medium'>{_('Theme Mode')}</h2>
<div className='flex gap-2'>
<div className='tooltip tooltip-bottom' data-tip='Auto Mode'>
<div className='tooltip tooltip-bottom' data-tip={_('Auto Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('auto')}
@@ -35,7 +37,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</button>
</div>
<div className='tooltip tooltip-bottom' data-tip='Light Mode'>
<div className='tooltip tooltip-bottom' data-tip={_('Light Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('light')}
@@ -44,7 +46,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</button>
</div>
<div className='tooltip tooltip-bottom' data-tip='Dark Mode'>
<div className='tooltip tooltip-bottom' data-tip={_('Dark Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('dark')}
@@ -56,7 +58,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
<div>
<h2 className='mb-2 font-medium'>Theme Color</h2>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
{themes.map(({ name, label, colors }) => (
<label
@@ -82,7 +84,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
) : (
<MdRadioButtonUnchecked size={24} />
)}
<span>{label}</span>
<span>{_(label)}</span>
</label>
))}
</div>
@@ -1,12 +1,14 @@
import React from 'react';
import { MdCheck } from 'react-icons/md';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
interface DialogMenuProps {
toggleDropdown?: () => void;
}
const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
const _ = useTranslation();
const { isFontLayoutSettingsGlobal, setFontLayoutSettingsGlobal } = useSettingsStore();
const handleToggleGlobal = () => {
@@ -28,7 +30,7 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
{isFontLayoutSettingsGlobal && <MdCheck size={20} className='text-base-content' />}
</span>
<div className='tooltip' data-tip='Uncheck for current book settings'>
<span className='ml-2'>Global Settings</span>
<span className='ml-2'>{_('Global Settings')}</span>
</div>
</div>
</button>
@@ -6,8 +6,9 @@ import FontDropdown from './FontDropDown';
import { MONOSPACE_FONTS, SANS_SERIF_FONTS, SERIF_FONTS } from '@/services/constants';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { getStyles } from '@/utils/style';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
interface FontFaceProps {
className?: string;
@@ -38,6 +39,7 @@ const FontFace = ({ className, family, label, options, selected, onSelect }: Fon
);
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const view = getView(bookKey);
@@ -145,12 +147,12 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<h2 className='mb-2 font-medium'>Font Size</h2>
<h2 className='mb-2 font-medium'>{_('Font Size')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label='Default Font Size'
label={_('Default Font Size')}
value={defaultFontSize}
onChange={setDefaultFontSize}
min={minFontSize}
@@ -158,7 +160,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
<NumberInput
className='config-item-bottom'
label='Minimum Font Size'
label={_('Minimum Font Size')}
value={minFontSize}
onChange={setMinFontSize}
min={1}
@@ -169,11 +171,11 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>Font Family</h2>
<h2 className='mb-2 font-medium'>{_('Font Family')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item config-item-top'>
<span className=''>Default Font</span>
<span className=''>{_('Default Font')}</span>
<FontDropdown
options={fontFamilyOptions}
selected={defaultFont}
@@ -183,7 +185,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
<div className='config-item config-item-bottom'>
<span className=''>Override Publisher Font</span>
<span className=''>{_('Override Publisher Font')}</span>
<input
type='checkbox'
className='toggle'
@@ -196,20 +198,20 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>Font Face</h2>
<h2 className='mb-2 font-medium'>{_('Font Face')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<FontFace
className='config-item-top'
family='serif'
label='Serif Font'
label={_('Serif Font')}
options={SERIF_FONTS}
selected={serifFont}
onSelect={setSerifFont}
/>
<FontFace
family='sans-serif'
label='Sans-Serif Font'
label={_('Sans-Serif Font')}
options={SANS_SERIF_FONTS}
selected={sansSerifFont}
onSelect={setSansSerifFont}
@@ -217,7 +219,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<FontFace
className='config-item-bottom'
family='monospace'
label='Monospace Font'
label={_('Monospace Font')}
options={MONOSPACE_FONTS}
selected={monospaceFont}
onSelect={setMonospaceFont}
@@ -1,12 +1,14 @@
import React, { useEffect, useState } from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import NumberInput from './NumberInput';
import { useTheme } from '@/hooks/useTheme';
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const view = getView(bookKey);
@@ -112,12 +114,12 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<h2 className='mb-2 font-medium'>Paragraph</h2>
<h2 className='mb-2 font-medium'>{_('Paragraph')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label='Line Height'
label={_('Line Height')}
value={lineHeight}
onChange={setLineHeight}
min={1.0}
@@ -125,7 +127,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
step={0.1}
/>
<div className='config-item config-item-bottom'>
<span className=''>Full Justification</span>
<span className=''>{_('Full Justification')}</span>
<input
type='checkbox'
className='toggle'
@@ -134,7 +136,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
</div>
<div className='config-item config-item-bottom'>
<span className=''>Hyphenation</span>
<span className=''>{_('Hyphenation')}</span>
<input
type='checkbox'
className='toggle'
@@ -147,12 +149,12 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>Page</h2>
<h2 className='mb-2 font-medium'>{_('Page')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label='Margins (px)'
label={_('Margins (px)')}
value={marginPx}
onChange={setMarginPx}
min={0}
@@ -160,21 +162,21 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
step={4}
/>
<NumberInput
label='Gaps (%)'
label={_('Gaps (%)')}
value={gapPercent}
onChange={setGapPercent}
min={0}
max={30}
/>
<NumberInput
label='Maximum Number of Columns'
label={_('Maximum Number of Columns')}
value={maxColumnCount}
onChange={setMaxColumnCount}
min={1}
max={2}
/>
<NumberInput
label='Maximum Inline Size'
label={_('Maximum Inline Size')}
value={maxInlineSize}
onChange={setMaxInlineSize}
min={500}
@@ -182,7 +184,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
step={100}
/>
<NumberInput
label='Maximum Block Size'
label={_('Maximum Block Size')}
value={maxBlockSize}
onChange={setMaxBlockSize}
min={500}
@@ -1,20 +1,23 @@
import React, { useEffect, useState } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import cssbeautify from 'cssbeautify';
import { getStyles } from '@/utils/style';
import { useTheme } from '@/hooks/useTheme';
const cssRegex =
/((?:\s*)([\w#.@*,:\-.:>+~\[\]\"=(),*\s]+)\s*{(?:[\s]*)((?:[A-Za-z\- \s]+[:]\s*['"0-9\w .,\/\()\-!#%]+;?)*)*\s*}(?:\s*))/gim;
/((?:\s*)([\w#.@*,:\-.:>+~$$$$\"=(),*\s]+)\s*{(?:[\s]*)((?:[^\}]+[:][^\}]+;?)*)*\s*}(?:\s*))/gim;
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(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);
@@ -73,14 +76,24 @@ 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'>
<h2 className='mb-2 font-medium'>Animation</h2>
<h2 className='mb-2 font-medium'>{_('Animation')}</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'>Paging Animation</span>
<span className='text-gray-700'>{_('Paging Animation')}</span>
<input
type='checkbox'
className='toggle'
@@ -93,13 +106,30 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>Custom CSS</h2>
<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' : ''}`}>
<div className='divide-y'>
<div className='css-text-area config-item-top config-item-bottom p-1'>
<textarea
className='textarea textarea-ghost h-48 w-full border-0 p-3 !outline-none'
placeholder='Enter your custom CSS here...'
placeholder={_('Enter your custom CSS here...')}
spellCheck='false'
value={cssInput}
onInput={handleInput}
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { BookConfig } from '@/types/book';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { RiFontSize } from 'react-icons/ri';
import { RiDashboardLine } from 'react-icons/ri';
import { VscSymbolColor } from 'react-icons/vsc';
@@ -15,8 +16,11 @@ import Dropdown from '@/components/Dropdown';
import DialogMenu from './DialogMenu';
import MiscPanel from './MiscPanel';
type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Misc';
const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ bookKey }) => {
const [activePanel, setActivePanel] = useState('Font');
const _ = useTranslation();
const [activePanel, setActivePanel] = useState<SettingsPanelType>('Font');
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const handleKeyDown = (event: KeyboardEvent) => {
@@ -34,7 +38,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
}, []);
return (
<dialog className='modal modal-open min-w-90 w-full'>
<dialog className='modal modal-open min-w-90 w-full !bg-[rgba(0,0,0,0.2)]'>
<div className='modal-box settings-content flex h-[60%] w-1/2 min-w-[480px] max-w-full flex-col p-0'>
<div className='dialog-header bg-base-100 sticky top-0 z-10 flex items-center justify-center px-4 pt-2'>
<div className='dialog-tabs flex h-10 max-w-[80%] flex-grow items-center justify-around'>
@@ -43,28 +47,28 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
onClick={() => setActivePanel('Font')}
>
<RiFontSize size={20} className='mr-0' />
Font
{_('Font')}
</button>
<button
className={`btn btn-ghost text-base-content h-8 min-h-8 px-4 ${activePanel === 'Layout' ? 'btn-active' : ''}`}
onClick={() => setActivePanel('Layout')}
>
<RiDashboardLine size={20} className='mr-0' />
Layout
{_('Layout')}
</button>
<button
className={`btn btn-ghost text-base-content h-8 min-h-8 px-4 ${activePanel === 'Color' ? 'btn-active' : ''}`}
onClick={() => setActivePanel('Color')}
>
<VscSymbolColor size={20} className='mr-0' />
Color
{_('Color')}
</button>
<button
className={`btn btn-ghost text-base-content h-8 min-h-8 px-4 ${activePanel === 'Misc' ? 'btn-active' : ''}`}
onClick={() => setActivePanel('Misc')}
>
<IoAccessibilityOutline size={20} className='mr-0' />
Misc
{_('Misc')}
</button>
</div>
<div className='flex h-full items-center justify-end'>
@@ -1,6 +1,7 @@
import React from 'react';
import Image from 'next/image';
import { MdInfoOutline } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
interface BookCardProps {
cover: string;
@@ -9,11 +10,12 @@ interface BookCardProps {
}
const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
const _ = useTranslation();
return (
<div className='flex h-20 w-full items-center'>
<Image
src={cover}
alt='Book cover'
alt={_('Book Cover')}
width={56}
height={80}
className='mr-4 aspect-auto max-h-20 w-[15%] max-w-14 rounded-sm object-cover shadow-md'
@@ -27,7 +29,7 @@ const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
</div>
<button
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
aria-label='More info'
aria-label={_('More Info')}
>
<MdInfoOutline size={18} className='fill-base-content' />
</button>
@@ -4,6 +4,7 @@ import Image from 'next/image';
import MenuItem from '@/components/MenuItem';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import useBooksManager from '../../hooks/useBooksManager';
@@ -13,6 +14,7 @@ interface BookMenuProps {
}
const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const { library } = useLibraryStore();
const { openParallelView } = useBooksManager();
const handleParallelView = (id: string) => {
@@ -39,7 +41,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
tabIndex={0}
className='book-menu dropdown-content dropdown-center border-base-100 z-20 mt-3 w-60 shadow-2xl'
>
<MenuItem label='Parallel Read' noIcon>
<MenuItem label={_('Parallel Read')} noIcon>
<ul className='max-h-60 overflow-y-auto'>
{library.slice(0, 20).map((book) => (
<MenuItem
@@ -62,10 +64,10 @@ const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
))}
</ul>
</MenuItem>
<MenuItem label='Reload Page' noIcon shortcut='Shift+R' onClick={handleReloadPage} />
<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} />
{isWebApp && <MenuItem label={_('Download Readest')} noIcon onClick={downloadReadest} />}
<MenuItem label={_('About Readest')} noIcon onClick={showAboutReadest} />
</div>
);
};
@@ -6,20 +6,21 @@ import { BookNote } from '@/types/book';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useNotebookStore } from '@/store/notebookStore';
import useScrollToItem from '../../hooks/useScrollToItem';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import useScrollToItem from '../../hooks/useScrollToItem';
interface BooknoteItemProps {
bookKey: string;
item: BookNote;
editable?: boolean;
}
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, editable = false }) => {
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getProgress, getView } = useReaderStore();
const { getProgress, getView, getViewsById } = useReaderStore();
const { setNotebookEditAnnotation, setNotebookVisible } = useNotebookStore();
const { text, cfi, note } = item;
@@ -39,14 +40,21 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, editable = f
const config = getConfig(bookKey);
if (!config) return;
const { booknotes = [] } = config;
const updatedNotes = booknotes.filter((item) => item.id !== note.id);
const updatedConfig = updateBooknotes(bookKey, updatedNotes);
booknotes.forEach((item) => {
if (item.id === note.id) {
item.deletedAt = Date.now();
const views = getViewsById(bookKey.split('-')[0]!);
views.forEach((view) => view?.addAnnotation(item, true));
}
});
const updatedConfig = updateBooknotes(bookKey, booknotes);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
};
const editNote = (note: BookNote) => {
setNotebookVisible(true);
setNotebookEditAnnotation(note);
};
@@ -54,15 +62,15 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, editable = f
<li
ref={viewRef}
className={clsx(
'border-base-300 my-2 cursor-pointer rounded-lg p-2 text-sm',
editable && 'collapse-arrow collapse',
isCurrent ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-200 bg-base-100',
'border-base-300 group relative my-2 cursor-pointer rounded-lg p-2 text-sm',
isCurrent ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-300/55 bg-base-100',
'transition-all duration-300 ease-in-out',
)}
tabIndex={0}
onClick={handleClickItem}
>
<div
className={clsx('collapse-title min-h-4 p-0', editable && 'pr-4')}
className={clsx('min-h-4 p-0 transition-all duration-300 ease-in-out')}
style={
{
'--top-override': '0.7rem',
@@ -92,40 +100,42 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, editable = f
</div>
</div>
</div>
{editable && (
<div
className={clsx('collapse-content invisible !p-0 text-xs')}
style={
{
'--bottom-override': 0,
} as React.CSSProperties
}
onClick={(e) => e.stopPropagation()}
>
<div className='flex justify-end space-x-3'>
{item.note && (
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-4 min-h-4 items-end p-0',
)}
onClick={editNote.bind(null, item)}
>
<div className='align-bottom text-blue-400'>Edit</div>
</button>
)}
<div
className={clsx(
'max-h-0 overflow-hidden p-0 text-xs',
'transition-[max-height] duration-300 ease-in-out',
'group-hover:max-h-8 group-hover:overflow-visible',
)}
style={
{
'--bottom-override': 0,
} as React.CSSProperties
}
onClick={(e) => e.stopPropagation()}
>
<div className='flex justify-end space-x-3 p-2'>
{item.note && (
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-4 min-h-4 items-end p-0',
)}
onClick={deleteNote.bind(null, item)}
onClick={editNote.bind(null, item)}
>
<div className='align-bottom text-red-400'>Delete</div>
<div className='align-bottom text-blue-400'>{_('Edit')}</div>
</button>
</div>
)}
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-4 min-h-4 items-end p-0',
)}
onClick={deleteNote.bind(null, item)}
>
<div className='align-bottom text-red-400'>{_('Delete')}</div>
</button>
</div>
)}
</div>
</li>
);
};
@@ -2,7 +2,7 @@ import React from 'react';
import * as CFI from 'foliate-js/epubcfi.js';
import { useBookDataStore } from '@/store/bookDataStore';
import { findParentPath } from '@/utils/toc';
import { findTocItemBS } from '@/utils/toc';
import { TOCItem } from '@/libs/document';
import { BookNote, BookNoteType } from '@/types/book';
import BooknoteItem from './BooknoteItem';
@@ -22,21 +22,18 @@ const BooknoteView: React.FC<{
const { getConfig } = useBookDataStore();
const config = getConfig(bookKey)!;
const { booknotes: allNotes = [] } = config;
const booknotes = allNotes.filter((note) => note.type === type);
const booknotes = allNotes.filter((note) => note.type === type && !note.deletedAt);
const booknoteGroups: { [href: string]: BooknoteGroup } = {};
for (const booknote of 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);
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: [] };
}
booknoteGroups[href].booknotes.push(booknote);
}
Object.values(booknoteGroups).forEach((group) => {
@@ -1,18 +1,20 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { BookDoc } from '@/libs/document';
import { useBookDataStore } from '@/store/bookDataStore';
import TOCView from './TOCView';
import BooknoteView from './BooknoteView';
import TabNavigation from './TabNavigation';
const SidebarContent: React.FC<{
activeTab: string;
bookDoc: BookDoc;
sideBarBookKey: string;
onTabChange: (tab: string) => void;
}> = ({ activeTab, bookDoc, sideBarBookKey, onTabChange }) => {
}> = ({ bookDoc, sideBarBookKey }) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const { getConfig, setConfig } = useBookDataStore();
const config = getConfig(sideBarBookKey);
const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc');
useEffect(() => {
const container = scrollContainerRef.current;
@@ -39,6 +41,20 @@ const SidebarContent: React.FC<{
};
}, []);
useEffect(() => {
if (!sideBarBookKey) return;
const config = getConfig(sideBarBookKey!)!;
setActiveTab(config.viewSettings!.sideBarTab!);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey]);
const handleTabChange = (tab: string) => {
setActiveTab(tab);
const config = getConfig(sideBarBookKey!)!;
config.viewSettings!.sideBarTab = tab;
setConfig(sideBarBookKey!, config);
};
return (
<>
<div
@@ -52,15 +68,15 @@ const SidebarContent: React.FC<{
<TOCView toc={bookDoc.toc} bookKey={sideBarBookKey} />
)}
{activeTab === 'annotations' && (
<BooknoteView type='annotation' toc={bookDoc.toc} bookKey={sideBarBookKey} />
<BooknoteView type='annotation' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
{activeTab === 'bookmarks' && (
<BooknoteView type='bookmark' toc={bookDoc.toc} bookKey={sideBarBookKey} />
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
</div>
</div>
<div className='flex-shrink-0'>
<TabNavigation activeTab={activeTab} onTabChange={onTabChange} />
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
</div>
</>
);
@@ -5,6 +5,7 @@ import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { BookSearchConfig, BookSearchResult } from '@/types/book';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
@@ -24,6 +25,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
searchTerm: term,
onSearchResultChange,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig } = useBookDataStore();
@@ -34,7 +36,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
const view = getView(bookKey)!;
const config = getConfig(bookKey)!;
const progress = getProgress(bookKey)!;
const searchConfig = config.searchConfig!;
const searchConfig = config.searchConfig! as BookSearchConfig;
const queuedSearchTerm = useRef('');
const isSearchPending = useRef(false);
@@ -149,7 +151,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
value={searchTerm}
spellCheck={false}
onChange={handleInputChange}
placeholder='Search...'
placeholder={_('Search...')}
className='w-full bg-transparent p-2 font-sans text-sm font-light focus:outline-none'
/>
@@ -1,6 +1,7 @@
import React from 'react';
import { MdCheck } from 'react-icons/md';
import { BookSearchConfig } from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
interface SearchOptionsProps {
searchConfig: BookSearchConfig;
@@ -13,6 +14,7 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
onSearchConfigChanged,
setIsDropdownOpen,
}) => {
const _ = useTranslation();
const handleSetScope = () => {
onSearchConfigChanged({
...searchConfig,
@@ -46,7 +48,7 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
<span style={{ minWidth: '20px' }}>
{searchConfig.scope === 'book' && <MdCheck size={20} className='text-base-content' />}
</span>
<span className='ml-2'>Book</span>
<span className='ml-2'>{_('Book')}</span>
</div>
</button>
@@ -60,7 +62,7 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
<MdCheck size={20} className='text-base-content' />
)}
</span>
<span className='ml-2'>Chapter</span>
<span className='ml-2'>{_('Chapter')}</span>
</div>
</button>
@@ -74,7 +76,7 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
<span style={{ minWidth: '20px' }}>
{searchConfig.matchCase && <MdCheck size={20} className='text-base-content' />}
</span>
<span className='ml-2'>Match Case</span>
<span className='ml-2'>{_('Match Case')}</span>
</div>
</button>
@@ -86,7 +88,7 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
<span style={{ minWidth: '20px' }}>
{searchConfig.matchWholeWords && <MdCheck size={20} className='text-base-content' />}
</span>
<span className='ml-2'>Match Whole Words</span>
<span className='ml-2'>{_('Match Whole Words')}</span>
</div>
</button>
@@ -98,7 +100,7 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
<span style={{ minWidth: '20px' }}>
{searchConfig.matchDiacritics && <MdCheck size={20} className='text-base-content' />}
</span>
<span className='ml-2'>Match Diacritics</span>
<span className='ml-2'>{_('Match Diacritics')}</span>
</div>
</button>
</div>
@@ -1,7 +1,6 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
@@ -23,15 +22,13 @@ const MAX_SIDEBAR_WIDTH = 0.45;
const SideBar: React.FC<{
onGoToLibrary: () => void;
}> = ({ onGoToLibrary }) => {
const { envConfig } = useEnv();
const { settings, saveSettings } = useSettingsStore();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { getBookData } = useBookDataStore();
const { getView } = useReaderStore();
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
const [searchResults, setSearchResults] = useState<BookSearchResult[] | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState(settings.globalReadSettings.sideBarTab);
const {
sideBarWidth,
isSideBarPinned,
@@ -70,12 +67,6 @@ const SideBar: React.FC<{
setSideBarVisible(false);
};
const handleTabChange = (tab: string) => {
setActiveTab(tab);
settings.globalReadSettings.sideBarTab = tab;
saveSettings(envConfig, settings);
};
const handleToggleSearchBar = () => {
setIsSearchBarVisible((prev) => !prev);
if (isSearchBarVisible) {
@@ -144,12 +135,7 @@ const SideBar: React.FC<{
onSelectResult={handleSearchResultClick}
/>
) : (
<SidebarContent
activeTab={activeTab}
bookDoc={bookDoc}
sideBarBookKey={sideBarBookKey!}
onTabChange={handleTabChange}
/>
<SidebarContent bookDoc={bookDoc} sideBarBookKey={sideBarBookKey!} />
)}
<div
className='drag-bar absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
@@ -0,0 +1,34 @@
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 };
};
@@ -1,9 +1,14 @@
import { useReaderStore } from '@/store/readerStore';
import { useNotebookStore } from '@/store/notebookStore';
import { isTauriAppPlatform } from '@/services/environment';
import useShortcuts from '@/hooks/useShortcuts';
import useBooksManager from './useBooksManager';
import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { eventDispatcher } from '@/utils/event';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL } from '@/services/constants';
interface UseBookShortcutsProps {
sideBarBookKey: string | null;
@@ -16,6 +21,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const { toggleNotebook } = useNotebookStore();
const { getNextBookKey } = useBooksManager();
const { themeCode } = useTheme();
const viewSettings = getViewSettings(sideBarBookKey ?? '');
const fontSize = viewSettings?.defaultFontSize ?? 16;
const lineHeight = viewSettings?.lineHeight ?? 1.6;
@@ -51,10 +57,59 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
getView(sideBarBookKey)?.next(distance);
};
const goBack = () => {
getView(sideBarBookKey)?.history.back();
};
const goForward = () => {
getView(sideBarBookKey)?.history.forward();
};
const reloadPage = () => {
window.location.reload();
};
const quitApp = async () => {
// on web platform use browser's default shortcut to close the tab
if (isTauriAppPlatform()) {
await eventDispatcher.dispatch('quit-app');
const { exit } = await import('@tauri-apps/plugin-process');
await exit(0);
}
};
const zoomIn = () => {
if (!sideBarBookKey) return;
const view = getView(sideBarBookKey);
if (!view?.renderer?.setStyles) return;
const viewSettings = getViewSettings(sideBarBookKey)!;
const zoomLevel = viewSettings!.zoomLevel + 1;
viewSettings!.zoomLevel = Math.min(zoomLevel, MAX_ZOOM_LEVEL);
setViewSettings(sideBarBookKey, viewSettings!);
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
};
const zoomOut = () => {
if (!sideBarBookKey) return;
const view = getView(sideBarBookKey);
if (!view?.renderer?.setStyles) return;
const viewSettings = getViewSettings(sideBarBookKey)!;
const zoomLevel = viewSettings!.zoomLevel - 1;
viewSettings!.zoomLevel = Math.max(zoomLevel, MIN_ZOOM_LEVEL);
setViewSettings(sideBarBookKey, viewSettings!);
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
};
const resetZoom = () => {
if (!sideBarBookKey) return;
const view = getView(sideBarBookKey);
if (!view?.renderer?.setStyles) return;
const viewSettings = getViewSettings(sideBarBookKey)!;
viewSettings!.zoomLevel = 100;
setViewSettings(sideBarBookKey, viewSettings!);
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
};
useShortcuts(
{
onSwitchSideBar: switchSideBar,
@@ -63,10 +118,16 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onToggleScrollMode: toggleScrollMode,
onOpenFontLayoutSettings: () => setFontLayoutSettingsDialogOpen(true),
onReloadPage: reloadPage,
onQuitApp: quitApp,
onGoLeft: goLeft,
onGoRight: goRight,
onGoPrev: goPrev,
onGoNext: goNext,
onGoBack: goBack,
onGoForward: goForward,
onZoomIn: zoomIn,
onZoomOut: zoomOut,
onResetZoom: resetZoom,
},
[sideBarBookKey, bookKeys],
);
@@ -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);
}
@@ -0,0 +1,82 @@
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,5 +1,5 @@
import { useEffect } from 'react';
import { FoliateView } from '@/app/reader/components/FoliateViewer';
import { FoliateView } from '@/types/view';
type FoliateEventHandler = {
onLoad?: (event: Event) => void;
@@ -0,0 +1,72 @@
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]);
};
@@ -0,0 +1,93 @@
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 { useTranslation } from '@/hooks/useTranslation';
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 _ = useTranslation();
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?.(_('Reading progress synced'));
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [syncedConfigs]);
};
@@ -2,6 +2,7 @@ import React from 'react';
import Image from 'next/image';
import packageJson from '../../package.json';
import WindowButtons from './WindowButtons';
import { useTranslation } from '@/hooks/useTranslation';
export const setAboutDialogVisible = (visible: boolean) => {
const dialog = document.getElementById('about_window');
@@ -13,6 +14,7 @@ export const setAboutDialogVisible = (visible: boolean) => {
};
export const AboutWindow = () => {
const _ = useTranslation();
return (
<dialog id='about_window' className='modal'>
<form method='dialog' className='modal-box w-96 max-w-lg p-4'>
@@ -30,7 +32,9 @@ export const AboutWindow = () => {
</div>
<h2 className='text-2xl font-bold'>Readest</h2>
<p className='text-neutral-content text-sm'>Bilingify LLC</p>
<span className='badge badge-primary mt-2'>Version {packageJson.version}</span>
<span className='badge badge-primary mt-2'>
{_('Version {{version}}', { version: packageJson.version })}
</span>
</div>
<div className='divider'></div>
@@ -55,7 +59,7 @@ export const AboutWindow = () => {
<p className='text-neutral-content mt-2 text-xs'>
Source code is available at{' '}
<a
href='https://github.com/chrox/readest'
href='https://github.com/readest/readest'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
+4 -2
View File
@@ -1,5 +1,6 @@
import React from 'react';
import clsx from 'clsx';
import { useTranslation } from '@/hooks/useTranslation';
const Alert: React.FC<{
title: string;
@@ -7,6 +8,7 @@ const Alert: React.FC<{
onClickCancel: () => void;
onClickConfirm: () => void;
}> = ({ title, message, onClickCancel, onClickConfirm }) => {
const _ = useTranslation();
return (
<div
role='alert'
@@ -37,10 +39,10 @@ const Alert: React.FC<{
</div>
<div className='flex space-x-2'>
<button className='btn btn-sm' onClick={onClickCancel}>
Cancel
{_('Cancel')}
</button>
<button className='btn btn-sm btn-warning' onClick={onClickConfirm}>
Confirm
{_('Confirm')}
</button>
</div>
</div>
@@ -0,0 +1,159 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { formatDate, formatSubject } from '@/utils/book';
import WindowButtons from '@/components/WindowButtons';
import Spinner from './Spinner';
interface BookDetailModalProps {
book: Book;
isOpen: boolean;
onClose: () => void;
}
const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
const _ = useTranslation();
const [loading, setLoading] = useState(false);
const [bookMeta, setBookMeta] = useState<null | {
title: string;
language: string | string[];
editor?: string;
publisher?: string;
published?: string;
description?: string;
subject?: string[];
identifier?: string;
}>(null);
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
useEffect(() => {
const loadingTimeout = setTimeout(() => setLoading(true), 300);
const fetchBookDetails = async () => {
const appService = await envConfig.getAppService();
const details = await appService.fetchBookDetails(book, settings);
setBookMeta(details);
setLoading(false);
if (loadingTimeout) clearTimeout(loadingTimeout);
};
fetchBookDetails();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [book]);
const handleClose = () => {
setBookMeta(null);
onClose();
};
if (!isOpen) return null;
if (!bookMeta)
return (
loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)
);
return (
<div className='fixed inset-0 z-50 flex w-full select-text items-center justify-center'>
<div className='min-w-[480px] max-w-md'>
<div className='fixed inset-0 bg-gray-800 bg-opacity-70' onClick={handleClose} />
<div className='bg-base-200 relative z-50 w-full rounded-lg p-6 shadow-xl'>
<div className='absolute right-4 top-4 flex space-x-2'>
<WindowButtons
className='window-buttons flex'
showMinimize={false}
showMaximize={false}
onClose={handleClose}
/>
</div>
<div className='mb-6 flex h-40 items-start'>
<div className='book-cover relative mr-10 aspect-[28/41] h-40 items-end shadow-lg'>
<Image
src={book.coverImageUrl!}
alt={book.title}
fill={true}
className='w-10 object-cover'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
}}
/>
<div
className={clsx(
'invisible absolute inset-0 flex items-center justify-center p-1',
'text-neutral-content rounded-none text-center font-serif text-base font-medium',
)}
>
{book.title}
</div>
</div>
<div className='title-author flex h-40 flex-col justify-between pr-4'>
<div>
<h2 className='text-base-content mb-2 line-clamp-2 text-2xl font-bold'>
{bookMeta.title || _('Untitled')}
</h2>
<p className='text-neutral-content line-clamp-1'>{book.author || _('Unknown')}</p>
</div>
<button className='btn-disabled bg-primary/25 hover:bg-primary/85 w-36 rounded px-4 py-2 text-white'>
{_('More Info')}
</button>
</div>
</div>
<div className='text-base-content mb-4'>
<div className='mb-4 grid grid-cols-3 gap-4'>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Publisher:')}</span>
<p className='text-neutral-content line-clamp-1 text-sm'>
{bookMeta.publisher || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Published:')}</span>
<p className='text-neutral-content max-w-28 text-ellipsis text-sm'>
{formatDate(bookMeta.published) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Updated:')}</span>
<p className='text-neutral-content text-sm'>{formatDate(book.lastUpdated) || ''}</p>
</div>
</div>
<div className='grid grid-cols-3 gap-4'>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Language:')}</span>
<p className='text-neutral-content text-sm'>{bookMeta.language || _('Unknown')}</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Identifier:')}</span>
<p className='text-neutral-content line-clamp-1 text-sm'>
{bookMeta.identifier || 'N/A'}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Subjects:')}</span>
<p className='text-neutral-content line-clamp-1 text-sm'>
{formatSubject(bookMeta.subject) || _('Unknown')}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default BookDetailModal;
+3 -1
View File
@@ -3,6 +3,7 @@ import React from 'react';
interface MenuItemProps {
label: string;
labelClass?: string;
shortcut?: string;
disabled?: boolean;
noIcon?: boolean;
@@ -13,6 +14,7 @@ interface MenuItemProps {
const MenuItem: React.FC<MenuItemProps> = ({
label,
labelClass,
shortcut,
disabled,
noIcon = false,
@@ -31,7 +33,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
>
<div className='flex items-center'>
{!noIcon && <span style={{ minWidth: '20px' }}>{icon}</span>}
<span className='ml-2 max-w-32 truncate'>{label}</span>
<span className={clsx('ml-2 max-w-32 truncate', labelClass)}>{label}</span>
</div>
{shortcut && <span className='text-neutral-content text-sm'>{shortcut}</span>}
</button>
@@ -66,6 +66,7 @@ const Popup = ({
}}
/>
<div
id='popup-container'
className={`bg-base-200 absolute rounded-lg font-sans shadow-xl ${className}`}
style={{
width: `${width}px`,
@@ -0,0 +1,16 @@
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;
+3 -1
View File
@@ -1,8 +1,10 @@
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
const Spinner: React.FC<{
loading: boolean;
}> = ({ loading }) => {
const _ = useTranslation();
if (!loading) return null;
return (
@@ -11,7 +13,7 @@ const Spinner: React.FC<{
role='status'
>
<span className='loading loading-dots loading-lg'></span>
<span className='hidden'>Loading...</span>
<span className='hidden'>{_('Loading...')}</span>
</div>
);
};
+8 -3
View File
@@ -1,8 +1,13 @@
import clsx from 'clsx';
import React from 'react';
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'>
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)}>
<span>{message}</span>
</div>
</div>
@@ -2,6 +2,7 @@ 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;
@@ -66,6 +67,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
};
useEffect(() => {
if (!isTauriAppPlatform()) return;
const headerElement = headerRef?.current;
headerElement?.addEventListener('mousedown', handleMouseDown);
+61 -6
View File
@@ -1,22 +1,77 @@
'use client';
import React, { createContext, useState, useContext, ReactNode } from 'react';
import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react';
import { User } from '@supabase/supabase-js';
import { supabase } from '@/utils/supabase';
interface AuthContextType {
token: string | null;
login: (token: string) => void;
user: User | null;
login: (token: string, user: User) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [token, setToken] = useState<string | null>(null);
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 login = (newToken: string) => setToken(newToken);
const logout = () => setToken(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);
}
};
return <AuthContext.Provider value={{ token, login, logout }}>{children}</AuthContext.Provider>;
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>
);
};
export const useAuth = (): AuthContextType => {
@@ -0,0 +1,18 @@
'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,3 +1,6 @@
{
"library": ["https://cdn.readest.com/books/four-great-classics.epub"]
"library": [
"https://cdn.readest.com/books/four-great-classics.epub",
"https://cdn.readest.com/books/journey-to-the-west.epub"
]
}
+50
View File
@@ -0,0 +1,50 @@
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();
}
+12
View File
@@ -6,10 +6,16 @@ export interface ShortcutConfig {
onToggleScrollMode: string[];
onOpenFontLayoutSettings: string[];
onReloadPage: string[];
onQuitApp: string[];
onGoLeft: string[];
onGoRight: string[];
onGoNext: string[];
onGoPrev: string[];
onGoBack: string[];
onGoForward: string[];
onZoomIn: string[];
onZoomOut: string[];
onResetZoom: string[];
}
const DEFAULT_SHORTCUTS: ShortcutConfig = {
@@ -20,10 +26,16 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
onToggleScrollMode: ['shift+j'],
onOpenFontLayoutSettings: ['shift+f'],
onReloadPage: ['shift+r'],
onQuitApp: ['ctrl+q', 'cmd+q'],
onGoLeft: ['ArrowLeft', 'PageUp', 'h'],
onGoRight: ['ArrowRight', 'PageDown', 'l'],
onGoNext: ['ArrowDown', 'j'],
onGoPrev: ['ArrowUp', 'k'],
onGoBack: ['shift+ArrowLeft', 'shift+h'],
onGoForward: ['shift+ArrowRight', 'shift+l'],
onZoomIn: ['ctrl+=', 'cmd+=', 'shift+='],
onZoomOut: ['ctrl+-', 'cmd+-', 'shift+-'],
onResetZoom: ['ctrl+0', 'cmd+0'],
};
// Load shortcuts from localStorage or fallback to defaults
+9
View File
@@ -1,9 +1,18 @@
import { check } from '@tauri-apps/plugin-updater';
import { ask } from '@tauri-apps/plugin-dialog';
import { relaunch } from '@tauri-apps/plugin-process';
import { CHECK_UPDATE_INTERVAL_SEC } from '@/services/constants';
const LAST_CHECK_KEY = 'lastAppUpdateCheck';
export const checkForAppUpdates = async () => {
const lastCheck = localStorage.getItem(LAST_CHECK_KEY);
const now = Date.now();
if (lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000) return;
localStorage.setItem(LAST_CHECK_KEY, now.toString());
const update = await check();
console.log('update found', update);
if (update) {
const yes = await ask(
`
+201
View File
@@ -0,0 +1,201 @@
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,
};
}
@@ -0,0 +1,8 @@
import '@/i18n/i18n';
import { useTranslation as _useTranslation } from 'react-i18next';
export const useTranslation = (namespace: string = 'translation') => {
const { t } = _useTranslation(namespace);
return (key: string, options = {}) => t(key, { defaultValue: key, ...options });
};
+46
View File
@@ -0,0 +1,46 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpApi from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
import { options } from '../../i18next-scanner.config';
i18n
.use(HttpApi)
.use(LanguageDetector)
.use(initReactI18next)
.init({
supportedLngs: ['en', ...options.lngs],
fallbackLng: {
'zh-HK': ['zh-TW', 'en'],
kk: ['ru', 'en'],
ky: ['ru', 'en'],
tk: ['ru', 'en'],
uz: ['ru', 'en'],
ug: ['ru', 'en'],
tt: ['ru', 'en'],
default: ['en'],
},
ns: options.ns,
defaultNS: options.defaultNs,
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
detection: {
order: ['querystring', 'localStorage', 'navigator'],
caches: ['localStorage'],
},
keySeparator: false,
nsSeparator: false,
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
i18n.on('languageChanged', (lng) => {
console.log('Language changed to', lng);
});
export default i18n;
+13 -1
View File
@@ -1,4 +1,5 @@
import { BookFormat } from '@/types/book';
import * as epubcfi from 'foliate-js/epubcfi.js';
// A groupBy polyfill for foliate-js
Object.groupBy ??= (iterable, callbackfn) => {
@@ -30,15 +31,24 @@ 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;
@@ -47,7 +57,9 @@ export interface BookDoc {
editor?: string;
publisher?: string;
};
toc: Array<TOCItem>;
toc?: Array<TOCItem>;
sections?: Array<SectionItem>;
splitTOCHref(href: string): Array<string | number>;
getCover(): Promise<Blob | null>;
}
+85
View File
@@ -0,0 +1,85 @@
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;
}
}
+15
View File
@@ -0,0 +1,15 @@
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;
+289
View File
@@ -0,0 +1,289 @@
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;
@@ -0,0 +1,22 @@
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>
);
}
+28 -23
View File
@@ -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, ViewSettings } from '@/types/book';
import { Book, BookConfig, BookContent, BookFormat } from '@/types/book';
import {
getDir,
getFilename,
@@ -21,11 +21,13 @@ import {
DEFAULT_BOOK_LAYOUT,
DEFAULT_BOOK_STYLE,
DEFAULT_BOOK_FONT,
DEFAULT_VIEW_CONFIG,
DEFAULT_READSETTINGS,
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 = '';
@@ -64,17 +66,23 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
...settings.globalViewSettings,
};
} catch {
settings = {
version: SYSTEM_SETTINGS_VERSION,
localBooksDir: await this.getInitBooksDir(),
lastSyncedAtBooks: 0,
lastSyncedAtConfigs: 0,
lastSyncedAtNotes: 0,
keepLogin: false,
globalReadSettings: DEFAULT_READSETTINGS,
globalViewSettings: {
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
},
};
@@ -126,10 +134,10 @@ export abstract class BaseAppService implements AppService {
const hash = await partialMD5(fileobj);
const existingBook = books.filter((b) => b.hash === hash)[0];
if (existingBook) {
if (existingBook.isRemoved) {
delete existingBook.isRemoved;
if (existingBook.deletedAt) {
existingBook.deletedAt = null;
}
existingBook.lastUpdated = Date.now();
existingBook.updatedAt = Date.now();
}
const book: Book = {
@@ -137,7 +145,8 @@ export abstract class BaseAppService implements AppService {
format,
title: formatTitle(loadedBook.metadata.title),
author: formatAuthors(loadedBook.metadata.language, loadedBook.metadata.author),
lastUpdated: Date.now(),
createdAt: existingBook ? existingBook.createdAt : Date.now(),
updatedAt: Date.now(),
};
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
@@ -206,33 +215,28 @@ 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;
const { viewSettings } = config;
config.viewSettings = { ...globalViewSettings, ...viewSettings };
config.searchConfig ??= DEFAULT_BOOK_SEARCH_CONFIG;
return config;
return deserializeConfig(str as string, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
} catch {
return INIT_BOOK_CONFIG;
}
}
async fetchBookDetails(book: Book, settings: SystemSettings) {
const { file } = (await this.loadBookContent(book, settings)) as BookContent;
const bookDoc = (await new DocumentLoader(file).open()).book as BookDoc;
return bookDoc.metadata;
}
async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) {
let serializedConfig: string;
if (settings) {
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>;
const { globalViewSettings } = settings;
serializedConfig = serializeConfig(config, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
} else {
serializedConfig = JSON.stringify(config);
}
await this.fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config));
await this.fs.writeFile(getConfigFilename(book), 'Books', serializedConfig);
}
async loadLibraryBooks(): Promise<Book[]> {
@@ -255,6 +259,7 @@ export abstract class BaseAppService implements AppService {
} else {
book.coverImageUrl = this.getCoverImageUrl(book);
}
book.updatedAt ??= book.lastUpdated || Date.now();
return book;
}),
);
+15 -2
View File
@@ -1,4 +1,4 @@
import { BookFont, BookLayout, BookSearchConfig, BookStyle } from '@/types/book';
import { BookFont, BookLayout, BookSearchConfig, BookStyle, ViewConfig } from '@/types/book';
import { ReadSettings } from '@/types/settings';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
@@ -10,7 +10,6 @@ export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).j
export const DEFAULT_READSETTINGS: ReadSettings = {
sideBarWidth: '25%',
isSideBarPinned: true,
sideBarTab: 'toc',
notebookWidth: '25%',
isNotebookPinned: false,
autohideCursor: true,
@@ -38,6 +37,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
marginPx: 44,
gapPercent: 5,
scrolled: false,
disableClick: false,
maxColumnCount: 2,
maxInlineSize: 720,
maxBlockSize: 1440,
@@ -56,6 +56,10 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
userStylesheet: '',
};
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
sideBarTab: 'toc',
};
export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
scope: 'book',
matchCase: false,
@@ -83,3 +87,12 @@ 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;
export const CHECK_UPDATE_INTERVAL_SEC = 24 * 60 * 60;
export const MAX_ZOOM_LEVEL = 140;
export const MIN_ZOOM_LEVEL = 95;

Some files were not shown because too many files have changed in this diff Show More