Compare commits

..

121 Commits

Author SHA1 Message Date
Huang Xin cc97a1b36f Update README (#140) 2025-01-10 14:36:37 +01:00
Huang Xin 4e42ec4a44 Release 0.9.2 (#139) 2025-01-10 14:13:56 +01:00
Huang Xin 00003a9415 Preloading audio for Edge TTS and other fixes (#138)
* Blacklist some low-quality web tts voices

* Dropdown layout tweaks

* Handle no audio data received in Edge TTS

* Preloading audio for Edge TTS
2025-01-10 14:11:09 +01:00
Huang Xin 7402141237 Add Edge TTS backend, selecting different voices will change TTS backend dynamically (#137) 2025-01-10 02:35:23 +01:00
Huang Xin e0323bb0ed Fix access token sometimes invalid in browser context (#136)
* Update dependencies

* Fix access token sometimes invalid in browser context
2025-01-09 17:58:55 +01:00
Egan Gumiwang Pratama Bisma 112c5fa979 enforce numeric inputs on NumberInput and prevent NaN text (#135) 2025-01-09 12:51:06 +01:00
Egan Gumiwang Pratama Bisma 86dc32dab3 persist max block size setting in LayoutPanel component (#134)
https://github.com/readest/readest/issues/131
2025-01-09 12:23:11 +01:00
Huajin 33d4dcd740 Better animation for sidebar transitions (#133)
* Add transition for BooknoteItem

* Add Tab switch animations and tooltip 'Toc' translations.

* Use 'Table of Contents' instead of 'Toc' as key.
2025-01-09 12:16:07 +01:00
Huang Xin 36d6074468 Filter voices list and shutdown TTS when closing book (#130) 2025-01-09 03:33:52 +01:00
Huang Xin cc1396876e Add select voice button in the TTS panel (#129) 2025-01-09 01:34:30 +01:00
Huang Xin f26de3ab0d Add speech speed slider in the TTS panel (#128) 2025-01-08 21:31:12 +01:00
Huang Xin 814a0032d0 Add 'Speak' button in the footbar (#127) 2025-01-08 19:44:04 +01:00
Huang Xin c6defb97ee Fix TTS might stop at the end of the section (#126) 2025-01-08 17:50:36 +01:00
Huang Xin ddee347eb3 Fix text color in misc panel and adjust order of annotator tools (#125) 2025-01-08 16:50:57 +01:00
Huang Xin 83b10a1df8 Fix color of return button in auth page in dark mode (#124) 2025-01-08 15:44:14 +01:00
Huang Xin 3e43905321 Fix TTS paused after forward/backward (#123) 2025-01-08 15:18:58 +01:00
Huang Xin 89b2eba2af Refactor Toast to global component (#122)
* Refactor Toast to global component, use eventDispatcher to dispatch toast events

* Error handling for TTS
2025-01-08 14:12:38 +01:00
Huang Xin 15d8d61841 Support more than 2 columns in page layout, closes #119 (#121) 2025-01-07 22:32:07 +01:00
Huang Xin 07b04b82e6 Initial implementation of TTS with Web Speech API (#120)
* Initial implementation of TTS with Web Speech API

* Add translations for annotator tools
2025-01-07 21:57:55 +01:00
Huang Xin a59a3b633b Fix progress overridden by newly opened book (#117) 2025-01-06 19:19:02 +01:00
Huang Xin cccd4c2b16 Various fixes (#116)
* Fix missing translations in translator popup

* Disable annotator for PDFs for now, closes #115

* Fix triangle in shadow of popup making it darker
2025-01-06 01:33:35 +01:00
Huang Xin 990f61fb72 Fix layout issues on Windows (#113) 2025-01-05 20:11:23 +01:00
Huang Xin d51f5a632a Regenerate tauri signing key to solve InvalidSignature error when updating 2025-01-05 18:07:49 +01:00
Huang Xin 3e9b89de0e Swap font when downloading online fonts, closes #104 (#111) 2025-01-05 17:26:36 +01:00
Huang Xin f900608d6a Update report-bug template to collect OS and version info 2025-01-05 16:23:48 +01:00
Huang Xin ca56c5a73d Fix no rounded window in reader page on Linux, closes #107 (#110) 2025-01-05 15:50:12 +01:00
Huang Xin 57b72d6d57 Release exe portable files directly to workaround the stupid virus warning on Windows 2025-01-05 14:19:18 +01:00
Huang Xin 76c5f58547 Restore window position and size, closes #7 (#109) 2025-01-05 14:08:56 +01:00
Huang Xin e4a6ae01e0 Release portable binaries for Windows (#102) 2025-01-04 19:46:57 +01:00
Huang Xin 2763c18d6f Fix dropdown close overlay not working on Windows/Linux (#101) 2025-01-04 17:13:53 +01:00
Huang Xin 71d0735917 Various fixes (#99)
* Add shortcut to page turning with the space key on keyboard

* Properly handle auto refresh token with supabase

* Fix settings not stored in direct quit of app, closes #94
2025-01-04 14:18:57 +01:00
Huang Xin 3484c893df Fix deeplink on Windows (#95) 2025-01-04 11:20:31 +01:00
Huang Xin e885d22631 Add deeplink to parse oauth callback url in tauri apps (#92) 2025-01-03 23:24:30 +01:00
Huang Xin 3ad26d9d8f Add vertical/horizontal switch in Layout configs for CJK books (#91) 2025-01-03 21:24:15 +01:00
Huang Xin b699fc98c2 Fix title layout of book details modal and apply of custom css deletion (#90)
* Fix custom css cannot apply after deletion

* Fix title layout of book details modal, closes #89
2025-01-03 19:12:25 +01:00
Huang Xin 2c9fe8e4f4 Better Custom CSS editor (#88) 2025-01-03 14:52:31 +01:00
Huang Xin f425ff8ffa Release version 0.8.9 (#85)
* Update README

* Release version 0.8.9
2025-01-02 23:44:35 +01:00
Huang Xin d6e0fd3b4d Fix supabase settings of oauth for tauri app (#83) 2025-01-02 23:03:50 +01:00
dependabot[bot] 2e2fad849b Bump the github-actions group with 3 updates (#82) 2025-01-02 15:31:39 +01:00
Christian Clauss 572ddd923c Fix typos discovered by codespell (#80) 2025-01-02 15:29:27 +01:00
Christian Clauss adab6f8d9b Keep GitHub Actions up to date with GitHub's Dependabot (#81) 2025-01-02 15:27:16 +01:00
Ray-D-Song f3f8df033d Fix background color of search results (#79) 2025-01-02 09:34:19 +01:00
Huang Xin 50f75c1f18 Show error toast for failed imported books and styles for dropdown menus (#76)
* Styles for dropdown menus

* Show error toast for failed imported books
2024-12-31 18:35:51 +01:00
Huang Xin 1ad13d0b7e Fix translations and line-height settings not working for some books (#73) 2024-12-31 14:42:08 +01:00
Huang Xin cd67f6ec3f Various enhancements and fixes (#70)
* Fix excerpts cannot be deleted in Chrome browsers

* Show book details in reader sidebar

* Set minimum search term length to 1 for CJK languages

* Use more system fonts as custom fonts, closes #31
2024-12-31 01:08:27 +01:00
Huang Xin 1be567ed4a Setup CI for pull request: only build the web-app 2024-12-31 01:01:35 +01:00
Huang Xin 0a46fe8648 Add missing hasWindowBar in BaseAppService 2024-12-30 09:49:20 +01:00
QP Hou abb1b7ae9c add support for ios dev build (#68) 2024-12-30 09:37:01 +01:00
Huang Xin 75e872af51 Bookshelf enhancements: 1. show details in context menu; 2. support some not found book covers (#64)
* Add show book details and open book in context menu

* Support extracting some book covers not included in the meta or guide sections
2024-12-30 00:09:23 +01:00
chrox d1b6a0db1e Release version 0.8.8 2024-12-29 13:09:40 +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
chrox bad45a72ba Fix following link after page flipping on macOS, closes #12 2024-12-12 08:49:27 +01:00
chrox 852d16daf3 Fix body element color 2024-12-11 22:10:27 +01:00
chrox 410d57e6f1 Import books from URLs in library page with url parameter 2024-12-11 20:31:41 +01:00
chrox 89c6c4d971 Fix formating authors 2024-12-11 19:29:58 +01:00
chrox 3f4a274412 Add demo library (books from Feedbooks) 2024-12-11 18:57:39 +01:00
chrox 9dd7e6de5e Avoid overriding some background colors 2024-12-11 18:16:02 +01:00
chrox 902a230095 Fix background image color and font server csp 2024-12-11 03:45:51 +01:00
chrox 5a521b6e6f Mount additional fonts when loading documents 2024-12-11 02:31:39 +01:00
chrox fb31212005 Hide some footnotes elements and handle background image in two columns 2024-12-10 23:30:43 +01:00
chrox b57fd8bd3d Support left / right popover on vertical writing documents 2024-12-10 17:57:51 +01:00
chrox 811f377dc0 Update README for more screenshots 2024-12-10 15:23:40 +01:00
chrox 0cbb950c37 Add support of popover footnotes 2024-12-10 14:14:34 +01:00
chrox 6efdbb78e5 Format title and authors when importing books 2024-12-10 14:14:06 +01:00
chrox d940fb8d06 Fix click to page flip not responsive occasionally 2024-12-09 14:40:58 +01:00
chrox 515a133731 Add download readest menu item for readest web 2024-12-08 23:49:38 +01:00
chrox eb90854f69 Fix CSS for background image and background color 2024-12-08 23:15:14 +01:00
chrox 76667fa2f6 Click on media overlay should not trigger a page flip 2024-12-08 23:13:52 +01:00
chrox 7b2bd8a3f8 Add contributing guidelines in README 2024-12-07 19:10:13 +01:00
chrox 8bdcb65883 Fix dynamic route for Next.js
see https://github.com/vercel/next.js/discussions/55393
2024-12-06 23:38:56 +01:00
chrox b985c2664e Add NEXT_PUBLIC_APP_PLATFORM env variable in github workflow 2024-12-06 23:06:04 +01:00
188 changed files with 10121 additions and 3575 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.
+9 -3
View File
@@ -1,7 +1,7 @@
---
name: Report a bug
about: Report a bug or a functional regression
title: 'Ex: In DarkMode, a blank square appears in bottom right corner while scrolling'
title: 'Example: In DarkMode, a blank square appears in bottom right corner while scrolling'
labels: ['type: bug']
assignees: ''
---
@@ -30,9 +30,15 @@ The blank square should be transparent (invisible)
## Technical inputs
Operating System:
Readest Version:
**Example:**
```
- We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
- Probably fixable with CSS
Operating System: macOS 14.3.1
Readest Version: 0.9.0
We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
Probably fixable with CSS
```
+13
View File
@@ -0,0 +1,13 @@
# Keep GitHub Actions up to date with GitHub's Dependabot...
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
version: 2
updates:
- package-ecosystem: github-actions
directory: /
groups:
github-actions:
patterns:
- "*" # Group all Actions updates into a single larger pull request
schedule:
interval: weekly
+35
View File
@@ -0,0 +1,35 @@
name: Build Web Application on Pull Request
on:
pull_request:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
build_web_app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.1
- name: setup node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install && pnpm setup-pdfjs
- name: build the web App
working-directory: apps/readest-app
run: |
pnpm build-web
+84 -26
View File
@@ -6,63 +6,87 @@ on:
types: [published]
jobs:
create-release:
get-release:
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create-release.outputs.result }}
release_id: ${{ steps.get-release.outputs.release_id }}
release_tag: ${{ steps.get-release.outputs.release_tag }}
release_note: ${{ steps.get-release-notes.outputs.release_note }}
release_version: ${{ steps.get-release-notes.outputs.release_version }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
- name: get version
run: echo "PACKAGE_VERSION=$(node -p "require('./apps/readest-app/package.json').version")" >> $GITHUB_ENV
- name: create release
id: create-release
uses: actions/github-script@v6
- name: get release
id: get-release
uses: actions/github-script@v7
with:
script: |
const { data } = await github.rest.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo,
})
return data.id
core.setOutput('release_id', data.id);
core.setOutput('release_tag', data.tag_name);
- name: get release notes
id: get-release-notes
uses: actions/github-script@v7
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_version', version);
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
- uses: actions/checkout@v4
- name: initialize git submodules
run: git submodule update --init --recursive
- name: Setup pnpm
- name: setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.14.4
- name: Setup node
uses: actions/setup-node@v3
- name: setup node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
@@ -83,18 +107,20 @@ jobs:
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('apps/readest-app/src-tauri/Cargo.lock') }}
workspaces: apps/readest-app/src-tauri -> target
- name: Create .env.local file for Next.js
- name: create .env.local file for Next.js
run: |
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
echo "NEXT_PUBLIC_DEEPL_API_KEY=${{ secrets.NEXT_PUBLIC_DEEPL_API_KEY }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
- name: Copy .env.local to apps/readest-app
- name: copy .env.local to apps/readest-app
run: cp .env.local apps/readest-app/.env.local
- name: install dependencies (ubuntu only)
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
@@ -112,27 +138,59 @@ 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.outputs.release_id }}
releaseBody: ${{ needs.get-release.outputs.release_note }}
args: ${{ matrix.config.args || '' }}
publish-release:
- name: upload portable binaries (Windows only)
if: matrix.config.os == 'windows-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
echo "Uploading Portable Binaries"
arch=${{ matrix.config.arch }}
version=${{ needs.get-release.outputs.release_version }}
if [ "$arch" = "x86_64" ]; then
bin_file="Readest_${version}_x64-portable.exe"
elif [ "$arch" = "aarch64" ]; then
bin_file="Readest_${version}_arm64-portable.exe"
else
echo "Unknown architecture: $arch"
exit 1
fi
exe_file="apps/readest-app/src-tauri/target/${{ matrix.config.rust_target }}/release/readest.exe"
# Browsers on Windows won't download zip files that contain exe files
# so upload the exe files instead. This is totally stupid.
# powershell.exe -Command "Compress-Archive -Path $exe_file -DestinationPath $bin_file -Force"
cp $exe_file $bin_file
echo "Uploading $bin_file to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
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
uses: actions/github-script@v6
- name: update release
id: update-release
uses: actions/github-script@v7
env:
release_id: ${{ needs.create-release.outputs.release_id }}
release_id: ${{ needs.get-release.outputs.release_id }}
release_note: ${{ needs.get-release.outputs.release_note }}
with:
script: |
const body = `## Release Highlight\n${process.env.release_note}`;
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
body: body,
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}}
+104
View File
@@ -0,0 +1,104 @@
# Contribution Guidelines
When contributing to `Readest`, whether on GitHub or in other community spaces:
- Be respectful, civil, and open-minded.
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/chrox/readest/issues) for known issues or fixes.
- If you want to make code changes based on your personal opinion(s), make sure you open an issue first describing the changes you want to make, and open a pull request only when your suggestions get approved by maintainers.
## How to Contribute
### Prerequisites
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/chrox/readest/issues/new/choose) describing the problem you would like to solve.
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
Basically you need to install or update the following development tools:
- **Node.js** and **pnpm** for Next.js development
- **Rust** and **Cargo** for Tauri development
```bash
nvm install v22
nvm use v22
npm install -g pnpm
rustup update
```
### Getting Started
To get started with Readest, follow these steps to clone and build the project.
#### 1. Clone the Repository
```bash
git clone https://github.com/readest/readest.git
cd readest
git submodule update --init --recursive
```
#### 2. Install Dependencies
```bash
# might need to rerun this when code is updated
pnpm install
# copy pdfjs-dist to Next.js public directory
pnpm --filter @readest/readest-app setup-pdfjs
```
#### 3. Verify Dependencies Installation
To confirm that all dependencies are correctly installed, run the following command:
```bash
pnpm tauri info
```
This command will display information about the installed Tauri dependencies and configuration on your platform. Note that the output may vary depending on the operating system and environment setup. Please review the output specific to your platform for any potential issues.
For Windows targets, “Build Tools for Visual Studio 2022” (or a higher edition of Visual Studio) and the “Desktop development with C++” workflow must be installed. For Windows ARM64 targets, the “VS 2022 C++ ARM64 build tools” and "C++ Clang Compiler for Windows" components must be installed. And make sure `clang` can be found in the path by adding `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\Llvm\x64\bin` for example in the environment variable `Path`.
#### 4. Build for Development
```bash
pnpm tauri dev
```
#### 5. Build for Production
```bash
pnpm tauri build
```
Now you're all setup and can start implementing your changes.
### Implement your changes
This project is a monorepo. The code for the `readest-app` is in the `app/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
| Command | Description |
| ---------------- | -------------------------------------------------- |
| `pnpm dev-web` | Starts the development server for the web app only |
| `pnpm build-web` | Builds the web app |
Recommended Visual Studio Code plugins for development:
- JavaScript and TypeScript Nightly (ms-vscode.vscode-typescript-next)
- VS Code ESLint extension (dbaeumer.vscode-eslint)
- Prettier - Code formatter (esbenp.prettier-vscode)
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
### When you're done
Check that your code follows the project's style guidelines by running:
```bash
pnpm build
```
Please also make a manual, functional test of your changes. When all that's done, it's time to file a pull request to upstream and fill out the title and body appropriately.
## Credits
This documented was inspired by the contributing guidelines for [cloudflare/wrangler2](https://github.com/cloudflare/wrangler2/blob/main/CONTRIBUTING.md).
+73 -54
View File
@@ -1,6 +1,6 @@
<div align="center">
<a href="https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme" target="_blank">
<img src="https://github.com/chrox/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
<img src="https://github.com/readest/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
</a>
<h1>Readest</h1>
<br>
@@ -8,10 +8,11 @@
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to offer a seamless cross-platform experience on macOS, Windows, Linux and Web, with support for mobile platforms coming soon.
[![Website][badge-website]][link-website]
[![AGPL Licence][badge-license]](LICENSE)
[![Web App][badge-web-app]][link-web-readest]
[![OS][badge-platforms]][link-website]
[![][badge-discord]][link-discord]
<br>
[![AGPL Licence][badge-license]](LICENSE)
[![Latest release][badge-release]][link-gh-releases]
[![Last commit][badge-last-commit]][link-gh-commits]
[![Commits][badge-commit-activity]][link-gh-pulse]
@@ -24,13 +25,13 @@
<a href="#screenshots">Screenshots</a> •
<a href="#downloads">Downloads</a> •
<a href="#getting-started">Getting Started</a> •
<a href="#contributing">Contributing</a> •
<a href="#contributors">Contributors</a> •
<a href="#license">License</a>
</p>
<div align="center">
<a href="https://readest.com" target="_blank">
<img src="./data/screenshots/readest_landing_preview.png" alt="Readest Banner" width="100%" />
<img src="./data/screenshots/landing_preview.png" alt="Readest Banner" width="100%" />
</a>
</div>
@@ -38,49 +39,54 @@
<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. | ✅ |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
## Planned Features
<div align="left">🛠 Building</div>
<div align="left">🔄 Planned</div>
| **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. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **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. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
| **Feature** | **Description** | **Priority** |
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **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. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
## Screenshots
![Annotations](./data/screenshots/annotations.jpeg)
![Annotations](./data/screenshots/annotations.png)
![Wikipedia](./data/screenshots/wikipedia.jpeg)
![DeepL](./data/screenshots/deepl.png)
![DeepL](./data/screenshots/deepl.jpeg)
![TTS](./data/screenshots/tts_control.png)
![Dark Mode](./data/screenshots/dark_mode.jpeg)
![Footnote](./data/screenshots/footnote_popover.png)
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
![Themeing Dark Mode](./data/screenshots/theming_dark_mode.png)
---
@@ -96,7 +102,7 @@ The Readest app is available for download! 🥳 🚀
## Requirements
- **Node.js** and **pnpm** for Next.js development
- **Rust and Cargo** for Tauri development
- **Rust** and **Cargo** for Tauri development
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
@@ -114,7 +120,7 @@ To get started with Readest, follow these steps to clone and build the project.
### 1. Clone the Repository
```bash
git clone https://github.com/chrox/readest.git
git clone https://github.com/readest/readest.git
cd readest
git submodule update --init --recursive
```
@@ -146,49 +152,62 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
pnpm tauri dev
```
For iOS:
```bash
pnpm tauri ios dev
```
### 5. Build for Production
```bash
pnpm tauri build
```
## Contributing
### 6. More information
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please review our contributing guidelines before you start.
Please check the [wiki][link-gh-wiki] of this project for more information on development.
## Contributors
<!---
npx contributor-faces --exclude "*bot*" --limit 100"
--->
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
[//]: contributor-faces
<a href="https://github.com/chrox"><img src="https://avatars.githubusercontent.com/u/751535?v=4" title="chrox" width="50" height="50"></a>
[//]: contributor-faces
<a href="https://github.com/readest/readest/graphs/contributors">
<p align="left">
<img width="300" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
</p>
</a>
## License
Readest is distributed under the AGPL-3.0 License. See the [LICENSE](<(LICENSE)>) file for details.
Readest is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [LICENSE](LICENSE) file for details.
The following JavaScript libraries are bundled in this software:
- [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed.
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
---
<div align="center" style="color: gray;">Happy reading with Readest!</div>
[badge-website]: https://img.shields.io/badge/website-readest.com-orange
[badge-license]: https://img.shields.io/github/license/chrox/readest?color=teal
[badge-release]: https://img.shields.io/github/release/chrox/readest?color=green
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
[badge-platforms]: https://img.shields.io/badge/OS-macOS%2C%20Windows%2C%20Linux%2C%20Web-green
[badge-last-commit]: https://img.shields.io/github/last-commit/chrox/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/chrox/readest
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
[link-macos-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
[link-web-readest]: https://web.readest.com
[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-gh-releases]: https://github.com/readest/readest/releases
[link-gh-commits]: https://github.com/readest/readest/commits/main
[link-gh-pulse]: https://github.com/readest/readest/pulse
[link-gh-wiki]: https://github.com/readest/readest/wiki
[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: false,
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,
},
};
+1 -1
View File
@@ -7,7 +7,7 @@ const internalHost = process.env.TAURI_DEV_HOST || 'localhost';
const nextConfig = {
// Ensure Next.js uses SSG instead of SSR
// https://nextjs.org/docs/pages/building-your-application/deploying/static-exports
output: process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web' ? undefined : 'output',
output: process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web' ? undefined : 'export',
// Note: This feature is required to use the Next.js Image component in SSG mode.
// See https://nextjs.org/docs/messages/export-image-api for different workarounds.
images: {
+37 -24
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.8.2",
"version": "0.9.2",
"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,50 +29,62 @@
"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-deep-link": "^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",
"posthog-js": "^1.194.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-icons": "^5.3.0",
"next": "15.1.0",
"posthog-js": "^1.205.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.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",
"daisyui": "^4.12.14",
"cpx2": "^8.0.0",
"daisyui": "^4.12.23",
"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",
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.15",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}
@@ -0,0 +1,133 @@
{
"(detected)": "(erkannt)",
"About Readest": "Über Readest",
"Add your notes here...": "Fügen Sie hier Ihre Notizen hinzu...",
"Animation": "Animation",
"Annotate": "Annotieren",
"Apply": "Anwenden",
"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",
"Copied to notebook": "In Notizbuch kopiert",
"Copy": "Kopieren",
"Custom CSS": "Benutzerdefiniertes CSS",
"Dark Mode": "Dunkelmodus",
"Default": "Standard",
"Default Font": "Standardschriftart",
"Default Font Size": "Standard-Schriftgröße",
"Delete": "Löschen",
"Delete Highlight": "Hervorhebung löschen",
"Dictionary": "Wörterbuch",
"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",
"Failed to import book(s): {{filenames}}": "Fehler beim Importieren von Buch/Büchern: {{filenames}}",
"Fast": "Schnell",
"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",
"Highlight": "Hervorheben",
"Horizontal Direction": "Horizontale Richtung",
"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",
"Open Book": "Buch öffnen",
"Original Text": "Originaltext",
"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": "Suchen",
"Search books...": "Bücher suchen...",
"Search...": "Suchen...",
"Select Book": "Buch auswählen",
"Select books": "Bücher auswählen",
"Select multiple books": "Mehrere Bücher auswählen",
"Sepia": "Sepia",
"Serif Font": "Serifenschrift",
"Show Book Details": "Buchdetails anzeigen",
"Sidebar": "Seitenleiste",
"Sign In": "Anmelden",
"Sign Out": "Abmelden",
"Sky": "Himmelblau",
"Slow": "Langsam",
"Solarized": "Solarisiert",
"Speak": "Sprechen",
"Subjects:": "Themen:",
"System Fonts": "System-Schriftarten",
"Table of Contents": "Inhaltsverzeichnis",
"TTS not supported in this device": "TTS wird auf diesem Gerät nicht unterstützt",
"Theme Color": "Themenfarbe",
"Theme Mode": "Themenmodus",
"Translate": "Übersetzen",
"Translated Text": "Übersetzter Text",
"Unknown": "Unbekannt",
"Untitled": "Ohne Titel",
"Updated:": "Aktualisiert:",
"User avatar": "Benutzer-Avatar",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Vertikale Richtung",
"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.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Schreibmodus",
"Your Library": "Ihre Bibliothek"
}
@@ -0,0 +1 @@
{}
@@ -0,0 +1,133 @@
{
"(detected)": "(detectado)",
"About Readest": "Acerca de Readest",
"Add your notes here...": "Añade tus notas aquí...",
"Animation": "Animación",
"Annotate": "Anotar",
"Apply": "Aplicar",
"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",
"Copied to notebook": "Copiado al cuaderno",
"Copy": "Copiar",
"Custom CSS": "CSS personalizado",
"Dark Mode": "Modo oscuro",
"Default": "Predeterminado",
"Default Font": "Fuente predeterminada",
"Default Font Size": "Tamaño de fuente predeterminado",
"Delete": "Eliminar",
"Delete Highlight": "Eliminar resaltado",
"Dictionary": "Diccionario",
"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",
"Failed to import book(s): {{filenames}}": "Error al importar libro(s): {{filenames}}",
"Fast": "Rápido",
"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",
"Highlight": "Resaltar",
"Horizontal Direction": "Dirección horizontal",
"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",
"Open Book": "Abrir libro",
"Original Text": "Texto original",
"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": "Buscar",
"Search books...": "Buscar libros...",
"Search...": "Buscar...",
"Select Book": "Seleccionar libro",
"Select books": "Seleccionar libros",
"Select multiple books": "Seleccionar varios libros",
"Sepia": "Sepia",
"Serif Font": "Fuente serif",
"Show Book Details": "Mostrar detalles del libro",
"Sidebar": "Barra lateral",
"Sign In": "Iniciar sesión",
"Sign Out": "Cerrar sesión",
"Sky": "Cielo",
"Slow": "Lento",
"Solarized": "Solarizado",
"Speak": "Leer",
"Subjects:": "Temas:",
"System Fonts": "Fuentes del sistema",
"Table of Contents": "Tabla de contenidos",
"TTS not supported in this device": "TTS no es compatible con este dispositivo",
"Theme Color": "Color del tema",
"Theme Mode": "Modo del tema",
"Translate": "Traducir",
"Translated Text": "Texto traducido",
"Unknown": "Desconocido",
"Untitled": "Sin título",
"Updated:": "Actualizado:",
"User avatar": "Avatar del usuario",
"Version {{version}}": "Versión {{version}}",
"Vertical Direction": "Dirección vertical",
"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.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Modo de escritura",
"Your Library": "Tu biblioteca"
}
@@ -0,0 +1,133 @@
{
"(detected)": "(détecté)",
"About Readest": "À propos de Readest",
"Add your notes here...": "Ajoutez vos notes ici...",
"Animation": "Animation",
"Annotate": "Annoter",
"Apply": "Appliquer",
"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",
"Copied to notebook": "Copié dans le carnet de notes",
"Copy": "Copier",
"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",
"Delete Highlight": "Supprimer le surlignage",
"Dictionary": "Dictionnaire",
"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",
"Failed to import book(s): {{filenames}}": "Échec de l'importation du/des livre(s) : {{filenames}}",
"Fast": "Rapide",
"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",
"Highlight": "Surligner",
"Horizontal Direction": "Direction horizontale",
"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",
"Open Book": "Ouvrir le livre",
"Original Text": "Texte original",
"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": "Rechercher",
"Search books...": "Rechercher des livres...",
"Search...": "Rechercher...",
"Select Book": "Sélectionner un livre",
"Select books": "Sélectionner des livres",
"Select multiple books": "Sélectionner plusieurs livres",
"Sepia": "Sépia",
"Serif Font": "Police avec empattement",
"Show Book Details": "Afficher les détails du livre",
"Sidebar": "Barre latérale",
"Sign In": "Se connecter",
"Sign Out": "Se déconnecter",
"Sky": "Ciel",
"Slow": "Lent",
"Solarized": "Solarisé",
"Speak": "Lire",
"Subjects:": "Sujets :",
"System Fonts": "Polices système",
"Table of Contents": "Table des matières",
"TTS not supported in this device": "La synthèse vocale n'est pas prise en charge sur cet appareil",
"Theme Color": "Couleur du thème",
"Theme Mode": "Mode du thème",
"Translate": "Traduire",
"Translated Text": "Texte traduit",
"Unknown": "Inconnu",
"Untitled": "Sans titre",
"Updated:": "Mis à jour :",
"User avatar": "Avatar de l'utilisateur",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Direction verticale",
"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.",
"Wikipedia": "Wikipédia",
"Writing Mode": "Mode écriture",
"Your Library": "Votre bibliothèque"
}
@@ -0,0 +1,133 @@
{
"(detected)": "(terdeteksi)",
"About Readest": "Tentang Readest",
"Add your notes here...": "Tambahkan catatan Anda di sini...",
"Animation": "Animasi",
"Annotate": "Anotasi",
"Apply": "Terapkan",
"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",
"Copied to notebook": "Disalin ke buku catatan",
"Copy": "Salin",
"Custom CSS": "CSS Kustom",
"Dark Mode": "Mode Gelap",
"Default": "Default",
"Default Font": "Font Default",
"Default Font Size": "Ukuran Font Default",
"Delete": "Hapus",
"Delete Highlight": "Hapus Sorotan",
"Dictionary": "Kamus",
"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",
"Failed to import book(s): {{filenames}}": "Gagal mengimpor buku: {{filenames}}",
"Fast": "Cepat",
"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",
"Highlight": "Sorot",
"Horizontal Direction": "Arah Horizontal",
"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",
"Open Book": "Buka Buku",
"Original Text": "Teks Asli",
"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": "Cari",
"Search books...": "Cari buku...",
"Search...": "Cari...",
"Select Book": "Pilih Buku",
"Select books": "Pilih buku",
"Select multiple books": "Pilih beberapa buku",
"Sepia": "Sepia",
"Serif Font": "Font Serif",
"Show Book Details": "Tampilkan Detail Buku",
"Sidebar": "Bilah Samping",
"Sign In": "Masuk",
"Sign Out": "Keluar",
"Sky": "Langit",
"Slow": "Lambat",
"Solarized": "Solarized",
"Speak": "Bicara",
"Subjects:": "Subjek:",
"System Fonts": "Font Sistem",
"Table of Contents": "Daftar Isi",
"TTS not supported in this device": "TTS tidak didukung di perangkat ini",
"Theme Color": "Warna Tema",
"Theme Mode": "Mode Tema",
"Translate": "Terjemahkan",
"Translated Text": "Teks Terjemahan",
"Unknown": "Tidak Diketahui",
"Untitled": "Tanpa Judul",
"Updated:": "Diperbarui:",
"User avatar": "Avatar Pengguna",
"Version {{version}}": "Versi {{version}}",
"Vertical Direction": "Arah Vertikal",
"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.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Mode Menulis",
"Your Library": "Perpustakaan Anda"
}
@@ -0,0 +1,133 @@
{
"(detected)": "(rilevato)",
"About Readest": "Informazioni su Readest",
"Add your notes here...": "Aggiungi qui le tue note...",
"Animation": "Animazione",
"Annotate": "Annota",
"Apply": "Applica",
"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",
"Copied to notebook": "Copiato nel quaderno",
"Copy": "Copia",
"Custom CSS": "CSS personalizzato",
"Dark Mode": "Modalità scura",
"Default": "Predefinito",
"Default Font": "Font predefinito",
"Default Font Size": "Dimensione font predefinita",
"Delete": "Elimina",
"Delete Highlight": "Elimina evidenziazione",
"Dictionary": "Dizionario",
"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",
"Failed to import book(s): {{filenames}}": "Impossibile importare il/i libro/i: {{filenames}}",
"Fast": "Veloce",
"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",
"Highlight": "Evidenzia",
"Horizontal Direction": "Direzione orizzontale",
"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",
"Open Book": "Apri libro",
"Original Text": "Testo originale",
"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": "Cerca",
"Search books...": "Cerca libri...",
"Search...": "Cerca...",
"Select Book": "Seleziona libro",
"Select books": "Seleziona libri",
"Select multiple books": "Seleziona più libri",
"Sepia": "Seppia",
"Serif Font": "Font serif",
"Show Book Details": "Mostra dettagli libro",
"Sidebar": "Barra laterale",
"Sign In": "Accedi",
"Sign Out": "Esci",
"Sky": "Cielo",
"Slow": "Lento",
"Solarized": "Solarizzato",
"Speak": "Leggi",
"Subjects:": "Argomenti:",
"System Fonts": "Font di sistema",
"Table of Contents": "Indice",
"TTS not supported in this device": "TTS non supportato su questo dispositivo",
"Theme Color": "Colore tema",
"Theme Mode": "Modalità tema",
"Translate": "Traduci",
"Translated Text": "Testo tradotto",
"Unknown": "Sconosciuto",
"Untitled": "Senza titolo",
"Updated:": "Aggiornato:",
"User avatar": "Avatar utente",
"Version {{version}}": "Versione {{version}}",
"Vertical Direction": "Direzione verticale",
"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.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Modalità scrittura",
"Your Library": "La tua biblioteca"
}
@@ -0,0 +1,133 @@
{
"(detected)": "(検出)",
"About Readest": "Readestについて",
"Add your notes here...": "ここにメモを追加...",
"Animation": "アニメーション",
"Annotate": "注釈",
"Apply": "適用",
"Are you sure to delete the selected books?": "選択した書籍を削除してもよろしいですか?",
"Auto Mode": "自動モード",
"Behavior": "動作",
"Book": "書籍",
"Book Cover": "表紙",
"Bookmark": "ブックマーク",
"Cancel": "キャンセル",
"Chapter": "章",
"Cherry": "チェリー",
"Color": "色",
"Confirm": "確認",
"Confirm Deletion": "削除の確認",
"Copied to notebook": "ノートブックにコピー",
"Copy": "コピー",
"Custom CSS": "カスタムCSS",
"Dark Mode": "ダークモード",
"Default": "デフォルト",
"Default Font": "デフォルトフォント",
"Default Font Size": "デフォルトフォントサイズ",
"Delete": "削除",
"Delete Highlight": "ハイライトを削除",
"Dictionary": "辞書",
"Disable Click-to-Flip": "クリックめくりを無効化",
"Download Readest": "Readestをダウンロード",
"Edit": "編集",
"Enter your custom CSS here...": "ここにカスタムCSSを入力...",
"Excerpts": "抜粋",
"Failed to import book(s): {{filenames}}": "書籍のインポートに失敗しました:{{filenames}}",
"Fast": "高速",
"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": "グルーブボックス",
"Highlight": "ハイライト",
"Horizontal Direction": "横書",
"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": "開く",
"Open Book": "書籍を開く",
"Original Text": "原文",
"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": "検索",
"Search books...": "書籍を検索...",
"Search...": "検索...",
"Select Book": "書籍を選択",
"Select books": "書籍を選択",
"Select multiple books": "複数の書籍を選択",
"Sepia": "セピア",
"Serif Font": "明朝体",
"Show Book Details": "書籍の詳細を表示",
"Sidebar": "サイドバー",
"Sign In": "サインイン",
"Sign Out": "サインアウト",
"Sky": "スカイ",
"Slow": "低速",
"Solarized": "ソーラライズド",
"Speak": "読み上げ",
"Subjects:": "主題:",
"System Fonts": "システムフォント",
"Table of Contents": "目次",
"TTS not supported in this device": "このデバイスではTTSがサポートされていません",
"Theme Color": "テーマカラー",
"Theme Mode": "テーマモード",
"Translate": "翻訳",
"Translated Text": "翻訳されたテキスト",
"Unknown": "不明",
"Untitled": "無題",
"Updated:": "更新日:",
"User avatar": "ユーザーアバター",
"Version {{version}}": "バージョン {{version}}",
"Vertical Direction": "縦書",
"Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
"Wikipedia": "ウィキペディア",
"Writing Mode": "書き込みモード",
"Your Library": "ライブラリー"
}
@@ -0,0 +1,133 @@
{
"(detected)": "(감지됨)",
"About Readest": "Readest 정보",
"Add your notes here...": "여기에 메모를 추가하세요...",
"Animation": "애니메이션",
"Annotate": "주석",
"Apply": "적용",
"Are you sure to delete the selected books?": "선택한 책을 삭제하시겠습니까?",
"Auto Mode": "자동 모드",
"Behavior": "동작",
"Book": "책",
"Book Cover": "책 표지",
"Bookmark": "북마크",
"Cancel": "취소",
"Chapter": "챕터",
"Cherry": "벚꽃색",
"Color": "색상",
"Confirm": "확인",
"Confirm Deletion": "삭제 확인",
"Copied to notebook": "노트북에 복사됨",
"Copy": "복사",
"Custom CSS": "사용자 정의 CSS",
"Dark Mode": "다크 모드",
"Default": "기본값",
"Default Font": "기본 글꼴",
"Default Font Size": "기본 글꼴 크기",
"Delete": "삭제",
"Delete Highlight": "하이라이트 삭제",
"Dictionary": "사전",
"Disable Click-to-Flip": "클릭 넘기기 비활성화",
"Download Readest": "Readest 다운로드",
"Edit": "편집",
"Enter your custom CSS here...": "여기에 사용자 정의 CSS를 입력하세요...",
"Excerpts": "발췌",
"Failed to import book(s): {{filenames}}": "책 가져오기 실패: {{filenames}}",
"Fast": "빠름",
"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": "그러브박스",
"Highlight": "하이라이트",
"Horizontal Direction": "수평 방향",
"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": "열기",
"Open Book": "책 열기",
"Original Text": "원본 텍스트",
"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": "검색",
"Search books...": "책 검색...",
"Search...": "검색...",
"Select Book": "책 선택",
"Select books": "책 선택",
"Select multiple books": "여러 책 선택",
"Sepia": "세피아",
"Serif Font": "세리프체",
"Show Book Details": "책 세부 정보 표시",
"Sidebar": "사이드바",
"Sign In": "로그인",
"Sign Out": "로그아웃",
"Sky": "하늘색",
"Slow": "느림",
"Solarized": "솔라라이즈드",
"Speak": "말하기",
"Subjects:": "주제:",
"System Fonts": "시스템 글꼴",
"Table of Contents": "목차",
"TTS not supported in this device": "이 장치에서 TTS가 지원되지 않음",
"Theme Color": "테마 색상",
"Theme Mode": "테마 모드",
"Translate": "번역",
"Translated Text": "번역된 텍스트",
"Unknown": "알 수 없음",
"Untitled": "제목 없음",
"Updated:": "업데이트일:",
"User avatar": "사용자 아바타",
"Version {{version}}": "버전 {{version}}",
"Vertical Direction": "수직 방향",
"Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
"Wikipedia": "위키피디아",
"Writing Mode": "글쓰기 모드",
"Your Library": "내 서재"
}
@@ -0,0 +1,133 @@
{
"(detected)": "(detectado)",
"About Readest": "Sobre o Readest",
"Add your notes here...": "Adicione suas notas aqui...",
"Animation": "Animação",
"Annotate": "Anotar",
"Apply": "Aplicar",
"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",
"Copied to notebook": "Copiado para o caderno",
"Copy": "Copiar",
"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",
"Delete Highlight": "Excluir Destaque",
"Dictionary": "Dicionário",
"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",
"Failed to import book(s): {{filenames}}": "Falha ao importar livro(s): {{filenames}}",
"Fast": "Rápido",
"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",
"Highlight": "Destaque",
"Horizontal Direction": "Direção Horizontal",
"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",
"Open Book": "Abrir Livro",
"Original Text": "Texto Original",
"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": "Buscar",
"Search books...": "Procurar livros...",
"Search...": "Pesquisar...",
"Select Book": "Selecionar Livro",
"Select books": "Selecionar livros",
"Select multiple books": "Selecionar múltiplos livros",
"Sepia": "Sépia",
"Serif Font": "Fonte Serif",
"Show Book Details": "Mostrar Detalhes do Livro",
"Sidebar": "Barra Lateral",
"Sign In": "Entrar",
"Sign Out": "Sair",
"Sky": "Céu",
"Slow": "Lento",
"Solarized": "Solarizado",
"Speak": "Falar",
"Subjects:": "Assuntos:",
"System Fonts": "Fontes do Sistema",
"Table of Contents": "Índice",
"TTS not supported in this device": "TTS não suportado neste dispositivo",
"Theme Color": "Cor do Tema",
"Theme Mode": "Modo do Tema",
"Translate": "Traduzir",
"Translated Text": "Texto Traduzido",
"Unknown": "Desconhecido",
"Untitled": "Sem Título",
"Updated:": "Atualizado:",
"User avatar": "Avatar do usuário",
"Version {{version}}": "Versão {{version}}",
"Vertical Direction": "Direção Vertical",
"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.",
"Wikipedia": "Wikipédia",
"Writing Mode": "Modo de Escrita",
"Your Library": "Sua Biblioteca"
}
@@ -0,0 +1,133 @@
{
"(detected)": "(обнаружено)",
"About Readest": "О Readest",
"Add your notes here...": "Добавьте свои заметки здесь...",
"Animation": "Анимация",
"Annotate": "Аннотация",
"Apply": "Применить",
"Are you sure to delete the selected books?": "Вы уверены, что хотите удалить выбранные книги?",
"Auto Mode": "Автоматический режим",
"Behavior": "Поведение",
"Book": "Книга",
"Book Cover": "Обложка книги",
"Bookmark": "Закладка",
"Cancel": "Отмена",
"Chapter": "Глава",
"Cherry": "Вишня",
"Color": "Цвет",
"Confirm": "Подтвердить",
"Confirm Deletion": "Подтвердить удаление",
"Copied to notebook": "Скопировано в блокнот",
"Copy": "Копировать",
"Custom CSS": "Пользовательский CSS",
"Dark Mode": "Тёмная тема",
"Default": "По умолчанию",
"Default Font": "Шрифт по умолчанию",
"Default Font Size": "Размер шрифта по умолчанию",
"Delete": "Удалить",
"Delete Highlight": "Удалить выделение",
"Dictionary": "Словарь",
"Disable Click-to-Flip": "Отключить переворот по клику",
"Download Readest": "Скачать Readest",
"Edit": "Редактировать",
"Enter your custom CSS here...": "Введите ваш пользовательский CSS здесь...",
"Excerpts": "Отрывки",
"Failed to import book(s): {{filenames}}": "Не удалось импортировать книгу(и): {{filenames}}",
"Fast": "Быстро",
"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",
"Highlight": "Выделить",
"Horizontal Direction": "Горизонтальное направление",
"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": "Открыть",
"Open Book": "Открыть книгу",
"Original Text": "Оригинальный текст",
"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": "Поиск",
"Search books...": "Поиск книг...",
"Search...": "Поиск...",
"Select Book": "Выбрать книгу",
"Select books": "Выбрать книги",
"Select multiple books": "Выбрать несколько книг",
"Sepia": "Сепия",
"Serif Font": "Шрифт с засечками",
"Show Book Details": "Показать детали книги",
"Sidebar": "Боковая панель",
"Sign In": "Войти",
"Sign Out": "Выйти",
"Sky": "Небесный",
"Slow": "Медленно",
"Solarized": "Солнечный",
"Speak": "Произнести",
"Subjects:": "Темы:",
"System Fonts": "Системные шрифты",
"Table of Contents": "Содержание",
"TTS not supported in this device": "TTS не поддерживается на этом устройстве",
"Theme Color": "Цвет темы",
"Theme Mode": "Режим темы",
"Translate": "Перевести",
"Translated Text": "Переведённый текст",
"Unknown": "Неизвестно",
"Untitled": "Без названия",
"Updated:": "Обновлено:",
"User avatar": "Аватар пользователя",
"Version {{version}}": "Версия {{version}}",
"Vertical Direction": "Вертикальное направление",
"Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
"Wikipedia": "Википедия",
"Writing Mode": "Режим записи",
"Your Library": "Ваша библиотека"
}
@@ -0,0 +1,133 @@
{
"(detected)": "(algılandı)",
"About Readest": "Readest Hakkında",
"Add your notes here...": "Notlarınızı buraya ekleyin...",
"Animation": "Animasyon",
"Annotate": "Not Ekle",
"Apply": "Uygula",
"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",
"Copied to notebook": "Not defterine kopyalandı",
"Copy": "Kopyala",
"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",
"Delete Highlight": "Vurguyu Sil",
"Dictionary": "Sözlük",
"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",
"Failed to import book(s): {{filenames}}": "Kitap(lar) içe aktarılamadı: {{filenames}}",
"Fast": "Hızlı",
"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",
"Highlight": "Vurgula",
"Horizontal Direction": "Yatay Yön",
"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ç",
"Open Book": "Kitabı Aç",
"Original Text": "Orijinal Metin",
"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": "Ara",
"Search books...": "Kitap ara...",
"Search...": "Ara...",
"Select Book": "Kitap Seç",
"Select books": "Kitapları seç",
"Select multiple books": "Birden fazla kitap seç",
"Sepia": "Sepya",
"Serif Font": "Serif Yazı Tipi",
"Show Book Details": "Kitap Detaylarını Göster",
"Sidebar": "Kenar Çubuğu",
"Sign In": "Giriş Yap",
"Sign Out": "Çıkış Yap",
"Sky": "Gökyüzü",
"Slow": "Yavaş",
"Solarized": "Solarized",
"Speak": "Konuş",
"Subjects:": "Konular:",
"System Fonts": "Sistem Yazı Tipleri",
"Table of Contents": "İçindekiler",
"TTS not supported in this device": "Bu cihazda TTS desteklenmiyor",
"Theme Color": "Tema Rengi",
"Theme Mode": "Tema Modu",
"Translate": "Çevir",
"Translated Text": "Çevrilen Metin",
"Unknown": "Bilinmiyor",
"Untitled": "Başlıksız",
"Updated:": "Güncellendi:",
"User avatar": "Kullanıcı avatarı",
"Version {{version}}": "Sürüm {{version}}",
"Vertical Direction": "Dikey Yön",
"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.",
"Wikipedia": "Vikipedi",
"Writing Mode": "Yazma Modu",
"Your Library": "Kütüphaneniz"
}
@@ -0,0 +1,133 @@
{
"(detected)": "(đã phát hiện)",
"About Readest": "Về Readest",
"Add your notes here...": "Thêm ghi chú của bạn vào đây...",
"Animation": "Hiệu ứng động",
"Annotate": "Chú thích",
"Apply": "Áp dụ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",
"Copied to notebook": "Đã sao chép vào sổ ghi chú",
"Copy": "Sao chép",
"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",
"Delete Highlight": "Xóa văn bản được đánh dấu",
"Dictionary": "Từ điển",
"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",
"Failed to import book(s): {{filenames}}": "Không thể nhập sách: {{filenames}}",
"Fast": "Nhanh",
"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",
"Highlight": "Đánh dấu",
"Horizontal Direction": "Hướng ngang",
"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ở",
"Open Book": "Mở sách",
"Original Text": "Văn bản gốc",
"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": "Tìm kiếm",
"Search books...": "Tìm kiếm sách...",
"Search...": "Tìm kiếm...",
"Select Book": "Chọn sách",
"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",
"Show Book Details": "Hiển thị chi tiết sách",
"Sidebar": "Thanh bên",
"Sign In": "Đăng nhập",
"Sign Out": "Đăng xuất",
"Sky": "Xanh trời",
"Slow": "Chậm",
"Solarized": "Solarized",
"Speak": "Đọc",
"Subjects:": "Chủ đề:",
"System Fonts": "Phông chữ hệ thống",
"Table of Contents": "Mục lục",
"TTS not supported in this device": "TTS không được hỗ trợ trên thiết bị này",
"Theme Color": "Màu chủ đề",
"Theme Mode": "Chế độ chủ đề",
"Translate": "Dịch",
"Translated Text": "Văn bản dị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}}",
"Vertical Direction": "Hướng dọc",
"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.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Chế độ viết",
"Your Library": "Thư viện của bạn"
}
@@ -0,0 +1,133 @@
{
"(detected)": "(检测到)",
"About Readest": "关于 Readest",
"Add your notes here...": "在这里添加您的笔记...",
"Animation": "动画",
"Annotate": "笔记",
"Apply": "应用",
"Are you sure to delete the selected books?": "您确定要删除所选书籍吗?",
"Auto Mode": "自动主题",
"Behavior": "行为",
"Book": "书籍",
"Book Cover": "书籍封面",
"Bookmark": "书签",
"Cancel": "取消",
"Chapter": "章节",
"Cherry": "樱粉",
"Color": "颜色",
"Confirm": "确认",
"Confirm Deletion": "确认删除",
"Copied to notebook": "已复制到笔记本",
"Copy": "复制",
"Custom CSS": "自定义 CSS",
"Dark Mode": "深色主题",
"Default": "默认",
"Default Font": "默认字体",
"Default Font Size": "默认字号",
"Delete": "删除",
"Delete Highlight": "删除划线",
"Dictionary": "词典",
"Disable Click-to-Flip": "禁用点击翻页",
"Download Readest": "下载 Readest",
"Edit": "编辑",
"Enter your custom CSS here...": "在此处输入您的自定义 CSS...",
"Excerpts": "摘录",
"Failed to import book(s): {{filenames}}": "导入书籍失败:{{filenames}}",
"Fast": "快",
"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": "暖橘",
"Highlight": "划线",
"Horizontal Direction": "横排",
"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": "打开",
"Open Book": "打开书籍",
"Original Text": "原文",
"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": "搜索",
"Search books...": "搜索书籍...",
"Search...": "搜索...",
"Select Book": "选择书籍",
"Select books": "选择书籍",
"Select multiple books": "选择多本书籍",
"Sepia": "旧韵",
"Serif Font": "衬线字体",
"Show Book Details": "显示书籍详情",
"Sidebar": "侧边栏",
"Sign In": "登录",
"Sign Out": "登出",
"Sky": "天青",
"Slow": "慢",
"Solarized": "日晖",
"Speak": "朗读",
"Subjects:": "主题",
"System Fonts": "系统字体",
"Table of Contents": "目录",
"TTS not supported in this device": "此设备不支持 TTS",
"Theme Color": "主题颜色",
"Theme Mode": "主题模式",
"Translate": "翻译",
"Translated Text": "译文",
"Unknown": "未知",
"Untitled": "无标题",
"Updated:": "更新日期",
"User avatar": "用户头像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "竖排",
"Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。",
"Wikipedia": "维基百科",
"Writing Mode": "排版模式",
"Your Library": "书库"
}
@@ -0,0 +1,133 @@
{
"(detected)": "(檢測到)",
"About Readest": "關於 Readest",
"Add your notes here...": "在這裡添加您的筆記...",
"Animation": "動畫",
"Annotate": "筆記",
"Apply": "應用",
"Are you sure to delete the selected books?": "您確定要刪除所選書籍嗎?",
"Auto Mode": "自動主題",
"Behavior": "行為",
"Book": "書籍",
"Book Cover": "書籍封面",
"Bookmark": "書籤",
"Cancel": "取消",
"Chapter": "章節",
"Cherry": "櫻粉",
"Color": "顏色",
"Confirm": "確認",
"Confirm Deletion": "確認刪除",
"Copied to notebook": "已複製到筆記本",
"Copy": "複製",
"Custom CSS": "自定義 CSS",
"Dark Mode": "深色主題",
"Default": "預設",
"Default Font": "預設字體",
"Default Font Size": "預設字號",
"Delete": "刪除",
"Delete Highlight": "刪除劃線",
"Dictionary": "詞典",
"Disable Click-to-Flip": "禁用點擊翻頁",
"Download Readest": "下載 Readest",
"Edit": "編輯",
"Enter your custom CSS here...": "在此處輸入您的自定義 CSS...",
"Excerpts": "摘錄",
"Failed to import book(s): {{filenames}}": "導入書籍失敗:{{filenames}}",
"Fast": "快",
"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": "暖橘",
"Highlight": "劃線",
"Horizontal Direction": "橫排",
"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": "打開",
"Open Book": "打開書籍",
"Original Text": "原文",
"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": "搜索",
"Search books...": "搜索書籍...",
"Search...": "搜索...",
"Select Book": "選擇書籍",
"Select books": "選擇書籍",
"Select multiple books": "選擇多本書籍",
"Sepia": "舊韻",
"Serif Font": "襯線字體",
"Show Book Details": "顯示書籍詳情",
"Sidebar": "側邊欄",
"Sign In": "登入",
"Sign Out": "登出",
"Sky": "天青",
"Slow": "慢",
"Solarized": "日暉",
"Speak": "朗讀",
"Subjects:": "主題",
"System Fonts": "系統字體",
"Table of Contents": "目錄",
"TTS not supported in this device": "此設備不支持 TTS",
"Theme Color": "主題顏色",
"Theme Mode": "主題模式",
"Translate": "翻譯",
"Translated Text": "譯文",
"Unknown": "未知",
"Untitled": "無標題",
"Updated:": "更新日期",
"User avatar": "用戶頭像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "豎排",
"Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
"Wikipedia": "維基百科",
"Writing Mode": "排版模式",
"Your Library": "書庫"
}
+89
View File
@@ -0,0 +1,89 @@
{
"releases": {
"0.9.2": {
"date": "2025-01-10",
"notes": [
"Support Text-to-Speech with Edge TTS and Web Speech API.",
"Various fixes and enhancements."
]
},
"0.9.1": {
"date": "2025-01-06",
"notes": [
"Restore window position and size when reopening app.",
"Swap font when downloading online fonts.",
"Fix layout issues on Windows and Linux."
]
},
"0.9.0": {
"date": "2025-01-04",
"notes": [
"Better Custom CSS editor and OAuth process.",
"Add vertical/horizontal switch for CJK books.",
"Fix dropdown close overlay not working on Windows/Linux."
]
},
"0.8.9": {
"date": "2025-01-02",
"notes": [
"Context menu on bookshelf for quick actions.",
"Add more system fonts as custom fonts.",
"Fix google and github login for native apps."
]
},
"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.",
"Support 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."
]
}
}
}
+799 -326
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -1,12 +1,12 @@
[package]
name = "Readest"
version = "0.2.0"
version = "0.2.1"
description = "Your online library"
authors = ["Bilingify LLC"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.71"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -32,6 +32,9 @@ 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"
tauri-plugin-deep-link = "2"
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
@@ -42,4 +45,6 @@ rand = "0.8"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2.2.0"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2.2.0"
@@ -51,13 +51,21 @@
"os:default",
"core:window:default",
"core:window:allow-close",
"core:window:allow-center",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-set-size",
"core:window:allow-set-focus",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"shell:default",
"updater:default",
"process:default",
"process:allow-exit",
"process:allow-restart",
"cli:default"
"oauth:allow-start",
"oauth:allow-cancel",
"opener:default",
"deep-link:default"
]
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"windows": ["main"],
"platforms": ["linux", "macOS", "windows"],
"permissions": [
"updater:default",
"cli:default"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

+77 -13
View File
@@ -9,16 +9,18 @@ extern crate objc;
#[cfg(target_os = "macos")]
mod menu;
#[cfg(target_os = "macos")]
mod tauri_traffic_light_positioner_plugin;
mod traffic_light_plugin;
#[cfg(target_os = "macos")]
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,18 +60,55 @@ 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())
}
#[derive(Clone, serde::Serialize)]
struct Payload {
args: Vec<String>,
cwd: String,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.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())
.plugin(tauri_plugin_fs::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
let _ = app
.get_webview_window("main")
.expect("no main window")
.set_focus();
app.emit("single-instance", Payload { args: argv, cwd })
.unwrap();
}));
let builder = builder.plugin(tauri_plugin_deep_link::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
#[cfg(target_os = "macos")]
let builder = builder.plugin(tauri_traffic_light_positioner_plugin::init());
let builder = builder.plugin(traffic_light_plugin::init());
builder
.setup(|#[allow(unused_variables)] app| {
@@ -78,7 +117,7 @@ pub fn run() {
let mut files = Vec::new();
// NOTICE: `args` may include URL protocol (`your-app-protocol://`)
// or arguments (`--`) if your app supports them.
// files may aslo be passed as `file://path/to/file`
// files may also be passed as `file://path/to/file`
for maybe_file in std::env::args().skip(1) {
// skip flags like -f or --flag
if maybe_file.starts_with("-") {
@@ -104,8 +143,25 @@ pub fn run() {
});
}
}
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_cli::init())?;
{
app.handle().plugin(tauri_plugin_cli::init())?;
let app_handle = app.handle().clone();
app.listen("window-ready", move |_| {
app_handle.get_webview_window("main").unwrap()
.eval("window.__READEST_CLI_ACCESS = true; window.__READEST_UPDATER_ACCESS = true;")
.expect("Failed to set cli access config");
});
}
#[cfg(any(windows, target_os = "linux"))]
{
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
}
#[cfg(desktop)]
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -114,19 +170,27 @@ pub fn run() {
.build(),
)?;
}
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("")
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
#[cfg(desktop)]
let win_builder = win_builder
.inner_size(800.0, 600.0)
.resizable(true)
.maximized(true);
.resizable(true);
#[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);
#[cfg(all(not(target_os = "macos"), desktop))]
let win_builder = win_builder
.decorations(false)
.transparent(true)
.visible(false)
.shadow(true)
.title("Readest");
win_builder.build().unwrap();
// let win = win_builder.build().unwrap();
@@ -144,7 +208,7 @@ pub fn run() {
.run(
#[allow(unused_variables)]
|app_handle, event| {
#[cfg(any(target_os = "macos", target_os = "ios"))]
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = event {
let files = urls
.into_iter()
+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>);
}
}
+19 -7
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",
@@ -13,9 +14,9 @@
"windows": [],
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://fonts.gstatic.com",
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org",
"img-src": "'self' blob: data: asset: http://asset.localhost",
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://fonts.gstatic.com https://db.onlinewebfonts.com",
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.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",
@@ -124,9 +130,15 @@
}
]
},
"deep-link": {
"mobile": [{ "host": "web.readest.com" }],
"desktop": {
"schemes": ["readest"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OTAxMURGQkUzQjFENTQKUldSVUhUdSszeEdRNUExdmFkWnlvYWNYNG5wamkxMmUxRk5SejlMOTJVd28yNXlVTDh6Wld4OC8K",
"endpoints": ["https://github.com/chrox/readest/releases/latest/download/latest.json"]
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFMEQ1QjE2OEU1NEIzNTEKUldSUnMxU09GbHNOdmpEaWFMT1crRFpEV2VORzQ2MklxaFc0M1R0ci9xY2c1bENXS0xhM1R1L2sK",
"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>
);
}
+253
View File
@@ -0,0 +1,253 @@
'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 { onOpenUrl } from '@tauri-apps/plugin-deep-link';
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 SingleInstancePayload {
args: string[];
cwd: string;
}
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:
process.env.NODE_ENV === 'production'
? 'readest://auth/callback'
: `http://localhost:${port}`,
},
});
if (error) {
console.error('Authentication error:', error);
return;
}
openUrl(data.url);
};
const handleOAuthUrl = async (url: string) => {
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') ?? '/';
if (accessToken) {
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
}
}
};
const startTauriOAuth = async () => {
try {
if (process.env.NODE_ENV === 'production') {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
currentWindow.listen('single-instance', ({ event, payload }) => {
console.log('Received deep link:', event, payload);
const { args } = payload as SingleInstancePayload;
if (args?.[1]) {
handleOAuthUrl(args[1]);
}
});
await onOpenUrl((urls) => {
urls.forEach((url) => {
handleOAuthUrl(url);
});
});
} else {
const port = await start();
setPort(port);
console.log(`OAuth server started on port ${port}`);
await onUrl(handleOAuthUrl);
await onInvalidUrl((url) => {
console.log('Received invalid OAuth URL:', url);
});
}
} catch (error) {
console.error('Error starting OAuth server:', error);
}
};
const stopTauriOAuth = 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;
startTauriOAuth();
return () => {
isOAuthServerRunning.current = false;
stopTauriOAuth();
};
// 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;
}
// For tauri app development, use a custom OAuth server to handle the OAuth callback
// For tauri app production, use deeplink to handle the OAuth callback
// For web app, use the built-in OAuth callback page /auth/callback
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} className='text-base-content' />
</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} className='text-base-content' />
</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

+12 -12
View File
@@ -1,21 +1,25 @@
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' />
<meta name='apple-mobile-web-app-status-bar-style' content='default' />
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1' />
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
<link rel='icon' href='/favicon.ico' />
@@ -32,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>
);
}
@@ -1,19 +1,30 @@
import clsx from 'clsx';
import * as React from 'react';
import { useState } from 'react';
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 } from 'next/navigation';
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 { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
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,18 +59,53 @@ interface BookshelfProps {
}
const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImportBooks }) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig } = useEnv();
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 isImportingBook = useRef(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
React.useEffect(() => {
const showBookDetailsModal = (book: Book) => {
setShowDetailsBook(book);
};
const dismissBookDetailsModal = () => {
setShowDetailsBook(null);
};
const { setLibrary } = useLibraryStore();
useEffect(() => {
setSelectedBooks([]);
}, [isSelectMode]);
useEffect(() => {
if (isImportingBook.current) return;
isImportingBook.current = true;
if (importBookUrl && appService) {
const importBook = async () => {
console.log('Importing book from URL:', importBookUrl);
const book = await appService.importBook(importBookUrl, libraryBooks);
if (book) {
setLibrary(libraryBooks);
appService.saveLibraryBooks(libraryBooks);
navigateToReader(router, [book.hash]);
}
};
importBook();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importBookUrl, appService]);
const bookshelfItems = generateBookshelfItems(libraryBooks);
const handleBookClick = (id: string) => {
@@ -99,21 +145,65 @@ 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 openBookMenuItem = await MenuItem.new({
text: isSelectMode ? _('Select Book') : _('Open Book'),
action: async () => {
handleBookClick(book.hash);
},
});
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
revealItemInDir(folder);
},
});
const showBookDetailsMenuItem = await MenuItem.new({
text: _('Show Book Details'),
action: async () => {
showBookDetailsModal(book);
},
});
const deleteBookMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
deleteBook(envConfig, book);
},
});
const menu = await Menu.new();
menu.append(openBookMenuItem);
menu.append(showBookDetailsMenuItem);
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'>
{bookshelfItems.map((item, index) => (
<div
key={`library-item-${index}`}
className='hover:bg-base-300/50 flex h-full flex-col p-4'
className='hover:bg-base-300/50 group flex h-full flex-col p-4'
>
<div className='flex-grow'>
{'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!}
@@ -152,10 +242,19 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
)}
</div>
</div>
<div className='card-body p-0 pt-2'>
<div className='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>
{isWebAppPlatform() && (
<div
className='show-detail-button self-start opacity-0 group-hover:opacity-100'
role='button'
onClick={showBookDetailsModal.bind(null, item as Book)}
>
<CiCircleMore size={15} />
</div>
)}
</div>
</div>
) : (
@@ -194,11 +293,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>
)}
@@ -209,12 +308,20 @@ 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}
/>
)}
{showDetailsBook && (
<BookDetailModal
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={dismissBookDetailsModal}
/>
)}
</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',
@@ -66,17 +71,24 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</div>
<ul
tabIndex={-1}
className='dropdown-content dropdown-center menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
className='dropdown-content dropdown-center bg-base-100 menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
>
<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;
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { Book } from '@/types/book';
import { getUserLang } from '@/utils/misc';
import { isWebAppPlatform } from '@/services/environment';
import libraryEn from '@/data/demo/library.en.json';
import libraryZh from '@/data/demo/library.zh.json';
const libraries = {
en: libraryEn,
zh: libraryZh,
};
interface DemoBooks {
library: string[];
}
export const useDemoBooks = () => {
const { envConfig } = useEnv();
const userLang = getUserLang() as keyof typeof libraries;
const [books, setBooks] = useState<Book[]>([]);
const isLoading = useRef(false);
useEffect(() => {
if (isLoading.current) return;
isLoading.current = true;
const fetchDemoBooks = async () => {
try {
const appService = await envConfig.getAppService();
const demoBooks = libraries[userLang] || (libraries.en as DemoBooks);
const books = await Promise.all(
demoBooks.library.map((url) => appService.importBook(url, [], false, true)),
);
setBooks(books.filter((book) => book !== null) as Book[]);
} catch (error) {
console.error('Failed to import demo books:', error);
}
};
const demoBooksFetchedFlag = localStorage.getItem('demoBooksFetched');
if (isWebAppPlatform() && !demoBooksFetchedFlag) {
fetchDemoBooks();
localStorage.setItem('demoBooksFetched', 'true');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return books;
};
+88 -21
View File
@@ -1,29 +1,37 @@
'use client';
import * as React from 'react';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect, Suspense } from 'react';
import { useRouter } from 'next/navigation';
import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { navigateToReader } from '@/utils/nav';
import { getBaseFilename, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform } from '@/services/environment';
import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
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';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from '@/app/library/components/LibraryHeader';
import Bookshelf from '@/app/library/components/Bookshelf';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
const LibraryPage = () => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { token, user } = useAuth();
const {
library: libraryBooks,
setLibrary,
@@ -31,15 +39,17 @@ 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);
const [isSelectMode, setIsSelectMode] = useState(false);
const demoBooks = useDemoBooks();
React.useEffect(() => {
useEffect(() => {
const doAppUpdates = async () => {
if (isTauriAppPlatform()) {
if (hasUpdater()) {
await checkForAppUpdates();
}
};
@@ -50,9 +60,14 @@ const LibraryPage = () => {
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
const bookIds: string[] = [];
for (const file of openWithFiles) {
const book = await appService.importBook(file, libraryBooks);
if (book) {
bookIds.push(book.hash);
console.log('Open with book:', file);
try {
const book = await appService.importBook(file, libraryBooks);
if (book) {
bookIds.push(book.hash);
}
} catch (error) {
console.log('Failed to import book:', file, error);
}
}
setLibrary(libraryBooks);
@@ -67,10 +82,24 @@ const LibraryPage = () => {
[],
);
React.useEffect(() => {
useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
const initLogin = async () => {
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
if (token && user) {
if (!settings.keepLogin) {
settings.keepLogin = true;
setSettings(settings);
saveSettings(envConfig, settings);
}
} else if (settings.keepLogin) {
router.push('/auth');
}
};
const loadingTimeout = setTimeout(() => setLoading(true), 300);
const initLibrary = async () => {
const appService = await envConfig.getAppService();
@@ -101,6 +130,7 @@ const LibraryPage = () => {
}
};
initLogin();
initLibrary();
return () => {
clearOpenWithBooks();
@@ -108,11 +138,42 @@ const LibraryPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (demoBooks.length > 0 && libraryLoaded) {
const newLibrary = [...libraryBooks];
for (const book of demoBooks) {
const idx = newLibrary.findIndex((b) => b.hash === book.hash);
if (idx === -1) {
newLibrary.push(book);
} else {
newLibrary[idx] = book;
}
}
setLibrary(newLibrary);
appService?.saveLibraryBooks(newLibrary);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [demoBooks, libraryLoaded]);
const importBooks = async (files: [string | File]) => {
setLoading(true);
const failedFiles = [];
for (const file of files) {
await appService?.importBook(file, libraryBooks);
setLibrary(libraryBooks);
try {
await appService?.importBook(file, libraryBooks);
setLibrary(libraryBooks);
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getBaseFilename(filename);
failedFiles.push(baseFilename);
eventDispatcher.dispatch('toast', {
message: _('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}),
type: 'error',
});
console.error('Failed to import book:', filename, error);
}
}
appService?.saveLibraryBooks(libraryBooks);
setLoading(false);
@@ -188,27 +249,33 @@ const LibraryPage = () => {
{libraryLoaded &&
(libraryBooks.length > 0 ? (
<div className='mt-12 flex-grow overflow-auto px-2'>
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
onImportBooks={handleImportBooks}
/>
<Suspense>
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
onImportBooks={handleImportBooks}
/>
</Suspense>
</div>
) : (
<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 />
<Toast />
</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>
);
@@ -2,6 +2,7 @@ import React from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import FoliateViewer from './FoliateViewer';
import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
@@ -11,7 +12,7 @@ import PageInfoView from './PageInfo';
import Ribbon from './Ribbon';
import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
import { useBookDataStore } from '@/store/bookDataStore';
import FootnotePopup from './FootnotePopup';
interface BooksGridProps {
bookKeys: string[];
@@ -48,7 +49,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<div
id={`gridcell-${bookKey}`}
key={bookKey}
className='relative h-full w-full overflow-hidden'
className='rounded-window relative h-full w-full overflow-hidden'
>
{isBookmarked && <Ribbon width={marginGap} />}
<HeaderBar
@@ -60,6 +61,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
{viewSettings.scrolled ? null : (
<>
<SectionInfo section={sectionLabel} gapLeft={marginGap} />
@@ -1,11 +1,14 @@
import React, { useEffect, useRef } from 'react';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { BookDoc } from '@/libs/document';
import { BookConfig, BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
import React, { useEffect, useRef, useState } from 'react';
import { BookDoc, getDirection } from '@/libs/document';
import { BookConfig } from '@/types/book';
import { FoliateView, wrappedFoliateView } from '@/types/view';
import { useReaderStore } from '@/store/readerStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { getOSPlatform } from '@/utils/misc';
import { getStyles } from '@/utils/style';
import { useClickEvent } from '../hooks/useClickEvent';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
import { getStyles, mountAdditionalFonts } from '@/utils/style';
import { useTheme } from '@/hooks/useTheme';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import {
@@ -15,57 +18,6 @@ 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;
};
const FoliateViewer: React.FC<{
bookKey: string;
@@ -75,12 +27,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 } = 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);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -88,41 +46,21 @@ 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);
if (detail.doc) {
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.vertical = writingDir?.vertical || false;
setViewSettings(bookKey, viewSettings);
if (viewSettings.scrolled && shouldAutoHideScrollbar) {
handleScrollbarAutoHide(detail.doc);
}
mountAdditionalFonts(detail.doc);
if (!detail.doc.isEventListenersAdded) {
detail.doc.isEventListenersAdded = true;
detail.doc.addEventListener('keydown', handleKeydown.bind(null, bookKey));
@@ -150,69 +88,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,
@@ -237,11 +113,12 @@ const FoliateViewer: React.FC<{
const view = wrappedFoliateView(document.createElement('foliate-view') as FoliateView);
document.body.append(view);
containerRef.current?.appendChild(view);
setFoliateView(bookKey, view);
await view.open(bookDoc);
// make sure we can listen renderer events after opening book
viewRef.current = view;
setFoliateView(bookKey, view);
const viewSettings = getViewSettings(bookKey)!;
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
@@ -280,11 +157,13 @@ 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}
/>
</>
);
};
@@ -2,9 +2,12 @@ import React from 'react';
import clsx from 'clsx';
import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { FaHeadphones } from 'react-icons/fa6';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import Button from '@/components/Button';
interface FooterBarProps {
@@ -14,9 +17,11 @@ interface FooterBarProps {
}
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
const { hoveredBookKey, setHoveredBookKey, getView } = useReaderStore();
const _ = useTranslation();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const view = getView(bookKey);
const progress = getProgress(bookKey);
const handleProgressChange = (event: React.ChangeEvent) => {
const newProgress = parseInt((event.target as HTMLInputElement).value, 10);
@@ -39,6 +44,12 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
view?.history.forward();
};
const handleSpeakText = async () => {
if (!view || !progress) return;
const { range } = progress;
eventDispatcher.dispatch('tts-speak', { bookKey, range });
};
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
const progressFraction = pageinfoValid ? pageinfo.current / pageinfo.total : 0;
return (
@@ -53,17 +64,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 +92,12 @@ 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={<FaHeadphones size={20} />} onClick={handleSpeakText} tooltip={_('Speak')} />
<Button
icon={<RiArrowRightWideLine size={20} />}
onClick={handleGoNext}
tooltip={_('Go Right')}
/>
</div>
);
};
@@ -0,0 +1,149 @@
import React, { useEffect, useRef, useState } from 'react';
import { BookDoc } from '@/libs/document';
import { useReaderStore } from '@/store/readerStore';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
import { FoliateView } from '@/types/view';
import { FootnoteHandler } from 'foliate-js/footnotes.js';
import Popup from '@/components/Popup';
interface FootnotePopupProps {
bookKey: string;
bookDoc: BookDoc;
}
const popupWidth = 360;
const popupHeight = 88;
const popupPadding = 10;
const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const footnoteRef = useRef<HTMLDivElement>(null);
const footnoteViewRef = useRef<FoliateView | null>(null);
const [trianglePosition, setTrianglePosition] = useState<Position | null>();
const [popupPosition, setPopupPosition] = useState<Position | null>();
const [showPopup, setShowPopup] = useState(false);
const { getView, getViewSettings } = useReaderStore();
const { themeCode } = useTheme();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const footnoteHandler = new FootnoteHandler();
useEffect(() => {
const handleBeforeRender = (e: Event) => {
const detail = (e as CustomEvent).detail;
const { view } = detail;
footnoteViewRef.current = view;
footnoteRef.current?.replaceChildren(view);
const { renderer } = view;
renderer.setAttribute('flow', 'scrolled');
renderer.setAttribute('margin', '0px');
renderer.setAttribute('gap', '5%');
const viewSettings = getViewSettings(bookKey)!;
const popupTheme = { ...themeCode };
const popupContainer = document.getElementById('popup-container');
if (popupContainer) {
const backgroundColor = getComputedStyle(popupContainer).backgroundColor;
popupTheme.bg = backgroundColor;
}
renderer.setStyles?.(getStyles(viewSettings, popupTheme));
};
const handleRender = (e: Event) => {
const detail = (e as CustomEvent).detail;
console.log('render footnote', detail);
setShowPopup(true);
};
footnoteHandler.addEventListener('before-render', handleBeforeRender);
footnoteHandler.addEventListener('render', handleRender);
return () => {
footnoteHandler.removeEventListener('before-render', handleBeforeRender);
footnoteHandler.removeEventListener('render', handleRender);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, themeCode]);
const docLinkHandler = async (event: Event) => {
const detail = (event as CustomEvent).detail;
console.log('doc link click', detail);
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
if (!gridFrame) return;
const rect = gridFrame.getBoundingClientRect();
const viewSettings = getViewSettings(bookKey)!;
const triangPos = getPosition(detail.a, rect, viewSettings.vertical);
const popupPos = getPopupPosition(
triangPos,
rect,
viewSettings.vertical ? popupHeight : popupWidth,
viewSettings.vertical ? popupWidth : popupHeight,
popupPadding,
);
setTrianglePosition(triangPos);
setPopupPosition(popupPos);
footnoteHandler.handle(bookDoc, event)?.catch((err) => {
console.warn(err);
const detail = (event as CustomEvent).detail;
view?.goTo(detail.href);
});
};
const closePopup = () => {
const view = footnoteRef.current?.querySelector('foliate-view') as FoliateView;
view?.close();
view?.remove();
};
const handleDismissPopup = () => {
closePopup();
setPopupPosition(null);
setTrianglePosition(null);
setShowPopup(false);
};
useFoliateEvents(view, {
onLinkClick: docLinkHandler,
});
useEffect(() => {
if (footnoteViewRef.current) {
footnoteRef.current?.replaceChildren(footnoteViewRef.current);
}
}, [footnoteRef]);
const width = viewSettings.vertical ? popupHeight : popupWidth;
const height = viewSettings.vertical ? popupWidth : popupHeight;
return (
<div>
{showPopup && (
<div
className='fixed inset-0'
onClick={handleDismissPopup}
onContextMenu={handleDismissPopup}
/>
)}
<Popup
width={width}
height={height}
position={showPopup ? popupPosition! : undefined}
trianglePosition={showPopup ? trianglePosition! : undefined}
className='select-text overflow-y-auto'
>
<div
className=''
ref={footnoteRef}
style={{
width: `${width}px`,
height: `${height}px`,
}}
></div>
</Popup>
</div>
);
};
export default FootnotePopup;
@@ -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 (
@@ -5,10 +5,10 @@ import { useEffect, Suspense, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import ReaderContent from './ReaderContent';
import { AboutWindow } from '@/components/AboutWindow';
import { useSettingsStore } from '@/store/settingsStore';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig } = useEnv();
@@ -33,10 +33,11 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
return (
library.length > 0 &&
settings.globalReadSettings && (
<div className='reader-page bg-base-100 text-base-content min-h-screen select-none'>
<div className='reader-page rounded-window bg-base-100 text-base-content min-h-screen select-none'>
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
<Toast />
</Suspense>
</div>
)
@@ -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';
@@ -9,19 +9,24 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { Book } from '@/types/book';
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 { eventDispatcher } from '@/utils/event';
import { navigateToLibrary } from '@/utils/nav';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
import useBooksManager from '../hooks/useBooksManager';
import useBookShortcuts from '../hooks/useBookShortcuts';
import BookDetailModal from '@/components/BookDetailModal';
import Spinner from '@/components/Spinner';
import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid';
import TTSControl from './tts/TTSControl';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const router = useRouter();
@@ -33,16 +38,17 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { getConfig, getBookData, saveConfig } = useBookDataStore();
const { getView, setBookKeys } = useReaderStore();
const { initViewState, getViewState, clearViewState } = useReaderStore();
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const isInitiating = useRef(false);
const [loading, setLoading] = useState(false);
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);
@@ -57,19 +63,47 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
if (index === 0) setSideBarBookKey(key);
}
});
const handleShowBookDetails = (event: CustomEvent) => {
const book = event.detail as Book;
setShowDetailsBook(book);
return true;
};
eventDispatcher.onSync('show-book-details', handleShowBookDetails);
// 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);
const settings = useSettingsStore.getState().settings;
await saveConfig(envConfig, bookKey, config, settings);
}
};
const saveConfigAndCloseBook = async (bookKey: string) => {
console.log('Closing book', bookKey);
try {
getView(bookKey)?.close();
getView(bookKey)?.remove();
} catch {
console.info('Error closing book', bookKey);
}
eventDispatcher.dispatch('tts-stop', { bookKey });
await saveBookConfig(bookKey);
clearViewState(bookKey);
};
@@ -78,11 +112,15 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
navigateToLibrary(router);
};
const handleCloseBooks = () => {
bookKeys.forEach((key) => {
saveConfigAndCloseBook(key);
});
saveSettingsAndGoToLibrary();
const handleCloseBooks = async () => {
const settings = useSettingsStore.getState().settings;
await Promise.all(bookKeys.map((key) => saveConfigAndCloseBook(key)));
await saveSettings(envConfig, settings);
};
const handleCloseBooksToLibrary = () => {
handleCloseBooks();
navigateToLibrary(router);
};
const handleCloseBook = async (bookKey: string) => {
@@ -116,9 +154,17 @@ 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} />
<TTSControl />
<Notebook />
{showDetailsBook && (
<BookDetailModal
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={() => setShowDetailsBook(null)}
/>
)}
</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,31 +77,23 @@ 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]);
return (
<div
tabIndex={0}
className='view-menu dropdown-content dropdown-right no-triangle border-base-100 z-20 mt-1 w-72 border shadow-2xl'
className='view-menu dropdown-content bgcolor-base-200 dropdown-right no-triangle border-base-200 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}
@@ -6,37 +6,44 @@ import { FaWikipediaW } from 'react-icons/fa';
import { BsPencilSquare } from 'react-icons/bs';
import { RiDeleteBinLine } from 'react-icons/ri';
import { BsTranslate } from 'react-icons/bs';
import { SiDeepl } from 'react-icons/si';
import { TbHexagonLetterD } from 'react-icons/tb';
import { FaHeadphones } from 'react-icons/fa6';
import * as CFI from 'foliate-js/epubcfi.js';
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 { useTranslation } from '@/hooks/useTranslation';
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';
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 _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
const { getProgress, getView, getViewsById } = useReaderStore();
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)!;
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const isShowingPopup = useRef(false);
const isTextSelected = useRef(false);
@@ -48,7 +55,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [trianglePosition, setTrianglePosition] = useState<Position>();
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
const [toastMessage, setToastMessage] = useState('');
const [translatorPopupPosition, setTranslatorPopupPosition] = useState<Position>();
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(
@@ -59,7 +66,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
);
const dictPopupWidth = 480;
const dictPopupHeight = 360;
const dictPopupHeight = 300;
const transPopupWidth = 480;
const transPopupHeight = 360;
const annotPopupWidth = 280;
const annotPopupHeight = 44;
const popupPadding = 10;
@@ -74,7 +83,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelection({ key: bookKey, text: sel.toString(), range: sel.getRangeAt(0), index });
}
};
detail.doc?.addEventListener('pointerup', handlePointerup);
if (bookData.book?.format !== 'PDF') {
detail.doc?.addEventListener('pointerup', handlePointerup);
}
};
const onDrawAnnotation = (event: Event) => {
@@ -96,7 +107,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 };
@@ -105,7 +118,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelection(selection);
};
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation }, [config]);
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation });
const handleDismissPopup = () => {
setSelection(null);
@@ -148,7 +161,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
if (!gridFrame) return;
const rect = gridFrame.getBoundingClientRect();
const triangPos = getPosition(selection.range, rect);
const triangPos = getPosition(selection.range, rect, viewSettings.vertical);
const annotPopupPos = getPopupPosition(
triangPos,
rect,
@@ -163,20 +176,24 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
dictPopupHeight,
popupPadding,
);
const transPopupPos = getPopupPosition(
triangPos,
rect,
transPopupWidth,
transPopupHeight,
popupPadding,
);
if (triangPos.point.x == 0 || triangPos.point.y == 0) return;
setShowAnnotPopup(true);
setAnnotPopupPosition(annotPopupPos);
setDictPopupPosition(dictPopupPos);
setTranslatorPopupPosition(transPopupPos);
setTrianglePosition(triangPos);
isShowingPopup.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, bookKey]);
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
}, [toastMessage]);
useEffect(() => {
if (!progress) return;
const { location } = progress;
@@ -185,6 +202,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 &&
@@ -200,25 +218,30 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleCopy = () => {
if (!selection || !selection.text) return;
setToastMessage('Copied to notebook');
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Copied to notebook'),
className: 'whitespace-nowrap',
timeout: 2000,
});
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;
@@ -237,7 +260,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;
@@ -246,15 +268,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) {
@@ -263,7 +286,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 {
@@ -312,19 +335,26 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowDeepLPopup(true);
};
const handleSpeakText = async () => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
};
const selectionAnnotated = selection?.annotated;
const buttons = [
{ tooltipText: 'Copy', Icon: FiCopy, onClick: handleCopy },
{ tooltipText: _('Copy'), Icon: FiCopy, onClick: handleCopy },
{
tooltipText: selectionAnnotated ? 'Delete Highlight' : 'Highlight',
tooltipText: selectionAnnotated ? _('Delete Highlight') : _('Highlight'),
Icon: selectionAnnotated ? RiDeleteBinLine : PiHighlighterFill,
onClick: handleHighlight,
},
{ tooltipText: 'Annotate', Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: 'Search', Icon: FiSearch, onClick: handleSearch },
{ tooltipText: 'Dictionary', Icon: BsTranslate, onClick: handleDictionary },
{ tooltipText: 'Translate', Icon: SiDeepl, onClick: handleTranslation },
{ tooltipText: 'Wikipedia', Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Annotate'), Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: _('Search'), Icon: FiSearch, onClick: handleSearch },
{ tooltipText: _('Dictionary'), Icon: TbHexagonLetterD, onClick: handleDictionary },
{ tooltipText: _('Wikipedia'), Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Translate'), Icon: BsTranslate, onClick: handleTranslation },
{ tooltipText: _('Speak'), Icon: FaHeadphones, onClick: handleSpeakText },
];
return (
@@ -357,13 +387,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
popupHeight={dictPopupHeight}
/>
)}
{showDeepLPopup && trianglePosition && dictPopupPosition && (
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
<DeepLPopup
text={selection?.text as string}
position={dictPopupPosition}
position={translatorPopupPosition}
trianglePosition={trianglePosition}
popupWidth={dictPopupWidth}
popupHeight={dictPopupHeight}
popupWidth={transPopupWidth}
popupHeight={transPopupHeight}
/>
)}
{showAnnotPopup && trianglePosition && annotPopupPosition && (
@@ -379,8 +409,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onHighlight={handleHighlight}
/>
)}
{toastMessage && <Toast message={toastMessage} />}
</div>
);
};
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { isWebAppPlatform } from '@/services/environment';
const LANGUAGES = {
@@ -37,6 +38,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
popupWidth,
popupHeight,
}) => {
const _ = useTranslation();
const { settings, setSettings } = useSettingsStore();
const [sourceLang, setSourceLang] = useState('AUTO');
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
@@ -120,12 +122,11 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
width={popupWidth}
height={popupHeight}
position={position}
className='bg-neutral select-text'
triangleClassName='text-neutral'
className='select-text'
>
<div className='text-neutral-content relative h-[50%] overflow-y-auto border-b border-neutral-400/75 p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h1 className='text-base font-semibold'>Original Text</h1>
<h1 className='text-base font-semibold'>{_('Original Text')}</h1>
<select
value={sourceLang}
onChange={handleSourceLangChange}
@@ -135,7 +136,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
return (
<option key={code} value={code}>
{detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} (detected)`
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} ` + _('(detected)')
: name}
</option>
);
@@ -147,7 +148,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
<div className='text-neutral-content relative h-[50%] overflow-y-auto p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h2 className='text-base font-semibold'>Translated Text</h2>
<h2 className='text-base font-semibold'>{_('Translated Text')}</h2>
<select
value={targetLang}
onChange={handleTargetLangChange}
@@ -164,7 +165,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
</div>
{loading ? (
<p className='text-base italic text-gray-500'>Loading...</p>
<p className='text-base italic text-gray-500'>{_('Loading...')}</p>
) : error ? (
<p className='text-base text-red-600'>{error}</p>
) : (
@@ -51,7 +51,8 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
>
<div
className={clsx(
'h-4 w-4 p-0 text-center leading-none',
'w-4 p-0 text-center leading-none',
style === 'highlight' ? 'h-4' : 'h-5',
style === 'highlight' &&
(selectedStyle === 'highlight' ? `bg-${selectedColor}-400` : `bg-gray-300`),
(style === 'underline' || style === 'squiggly') &&
@@ -96,7 +96,8 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
}
};
const langCode = typeof lang === 'string' ? lang : lang?.[0];
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
const langCode = bookLang ? bookLang.split('-')[0]! : 'en';
fetchSummary(text, langCode);
}, [text, lang]);
@@ -107,8 +108,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
height={popupHeight}
position={position}
trianglePosition={trianglePosition}
className='bg-neutral select-text overflow-y-auto'
triangleClassName='text-neutral'
className='select-text overflow-y-auto'
>
<main className='p-2 font-sans'></main>
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
@@ -163,8 +163,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
width={popupWidth}
height={popupHeight}
position={position}
className='bg-neutral select-text overflow-y-auto'
triangleClassName='text-neutral'
className='select-text overflow-y-auto'
>
<main className='p-4 font-sans' />
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
@@ -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,15 +1,17 @@
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';
interface NoteEditorProps {
onSave: (selction: TextSelection, note: string) => void;
onSave: (selection: TextSelection, note: string) => void;
onEdit: (annotation: BookNote) => void;
}
const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
const _ = useTranslation();
const { notebookNewAnnotation, notebookEditAnnotation } = useNotebookStore();
const editorRef = useRef<HTMLTextAreaElement>(null);
const [note, setNote] = React.useState('');
@@ -55,25 +57,25 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
className={clsx(
'note-editor textarea textarea-ghost min-h-[1em] resize-none !outline-none',
'inset-0 w-full rounded-none border-0 bg-transparent p-0 leading-normal',
'text-base',
'text-sm',
)}
ref={editorRef}
value={note}
rows={1}
spellCheck={false}
onChange={handleChange}
placeholder='Add your notes here...'
placeholder={_('Add your notes here...')}
></textarea>
</div>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1.5em] min-h-[1.5em] items-end p-0',
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
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'>
@@ -165,21 +160,15 @@ const Notebook: React.FC = ({}) => {
>
<p className='line-clamp-1'>{item.text || `Excerpt ${index + 1}`}</p>
</div>
<div
className='collapse-content select-text px-3 pb-0 text-xs'
onClick={(e) => e.stopPropagation()}
>
<div className='collapse-content select-text px-3 pb-0 text-xs'>
<p className='hyphens-auto text-justify'>{item.text}</p>
<div className='flex justify-end'>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1em] min-h-[1em] items-end p-0',
)}
onClick={deleteNote.bind(null, item)}
<div
className='cursor-pointer align-bottom text-xs text-red-400'
onClick={handleEditNote.bind(null, item, true)}
>
<div className='align-bottom text-xs text-red-400'>Delete</div>
</button>
{_('Delete')}
</div>
</div>
</div>
</div>
@@ -188,20 +177,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>
@@ -1,11 +1,14 @@
import clsx from 'clsx';
import React from 'react';
import { FiChevronDown } from 'react-icons/fi';
import { FiChevronUp, FiChevronLeft } from 'react-icons/fi';
import { MdCheck } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
interface DropdownProps {
family?: string;
selected: string;
options: string[];
moreOptions?: string[];
onSelect: (option: string) => void;
onGetFontFamily: (option: string, family: string) => string;
}
@@ -14,21 +17,23 @@ const FontDropdown: React.FC<DropdownProps> = ({
family,
selected,
options,
moreOptions,
onSelect,
onGetFontFamily,
}) => {
const _ = useTranslation();
return (
<div className='dropdown dropdown-end'>
<div className='dropdown dropdown-top'>
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
>
<span style={{ fontFamily: onGetFontFamily(selected, family ?? '') }}>{selected}</span>
<FiChevronDown className='h-4 w-4' />
<FiChevronUp className='h-4 w-4' />
</button>
<ul
tabIndex={0}
className='dropdown-content dropdown-right menu bg-base-100 rounded-box z-[1] mt-4 w-44 shadow'
className='dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-0 z-[1] mt-4 w-44 shadow'
>
{options.map((option) => (
<li key={option} onClick={() => onSelect(option)}>
@@ -40,6 +45,36 @@ const FontDropdown: React.FC<DropdownProps> = ({
</div>
</li>
))}
{moreOptions && moreOptions.length > 0 && (
<li className='dropdown dropdown-left dropdown-top'>
<div className='flex items-center px-0'>
<span style={{ minWidth: '20px' }}>
<FiChevronLeft className='h-4 w-4' />
</span>
<span>{_('System Fonts')}</span>
</div>
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] overflow-y-scroll shadow',
'!mr-5 mb-[-46px] max-h-80 w-[292px]',
)}
>
{moreOptions.map((option) => (
<li key={option} onClick={() => onSelect(option)}>
<div className='flex items-center px-2'>
<span style={{ minWidth: '20px' }}>
{selected === option && <MdCheck size={20} className='text-base-content' />}
</span>
<span style={{ fontFamily: onGetFontFamily(option, family ?? '') }}>
{option}
</span>
</div>
</li>
))}
</ul>
</li>
)}
</ul>
</div>
);
@@ -3,17 +3,27 @@ import React, { useEffect, useState } from 'react';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
import { MONOSPACE_FONTS, SANS_SERIF_FONTS, SERIF_FONTS } from '@/services/constants';
import {
LINUX_FONTS,
MACOS_FONTS,
MONOSPACE_FONTS,
SANS_SERIF_FONTS,
SERIF_FONTS,
WINDOWS_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';
import { getOSPlatform } from '@/utils/misc';
interface FontFaceProps {
className?: string;
family: string;
label: string;
options: string[];
moreOptions?: string[];
selected: string;
onSelect: (option: string) => void;
}
@@ -24,12 +34,21 @@ const handleFontFaceFont = (option: string, family: string) => {
return `'${option}', ${family}`;
};
const FontFace = ({ className, family, label, options, selected, onSelect }: FontFaceProps) => (
const FontFace = ({
className,
family,
label,
options,
moreOptions,
selected,
onSelect,
}: FontFaceProps) => (
<div className={clsx('config-item', className)}>
<span className=''>{label}</span>
<FontDropdown
family={family}
options={options}
moreOptions={moreOptions}
selected={selected}
onSelect={onSelect}
onGetFontFamily={handleFontFaceFont}
@@ -38,12 +57,28 @@ 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);
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
const osPlatform = getOSPlatform();
let moreFonts: string[] = [];
switch (osPlatform) {
case 'macos':
moreFonts = MACOS_FONTS;
break;
case 'windows':
moreFonts = WINDOWS_FONTS;
break;
case 'linux':
moreFonts = LINUX_FONTS;
break;
default:
break;
}
const [defaultFontSize, setDefaultFontSize] = useState(viewSettings.defaultFontSize!);
const [minFontSize, setMinFontSize] = useState(viewSettings.minimumFontSize!);
const [overrideFont, setOverrideFont] = useState(viewSettings.overrideFont!);
@@ -145,12 +180,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 +193,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 +204,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 +218,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,29 +231,32 @@ 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}
moreOptions={moreFonts}
selected={serifFont}
onSelect={setSerifFont}
/>
<FontFace
family='sans-serif'
label='Sans-Serif Font'
label={_('Sans-Serif Font')}
options={SANS_SERIF_FONTS}
moreOptions={moreFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
/>
<FontFace
className='config-item-bottom'
family='monospace'
label='Monospace Font'
label={_('Monospace Font')}
options={MONOSPACE_FONTS}
moreOptions={moreFonts}
selected={monospaceFont}
onSelect={setMonospaceFont}
/>
@@ -1,15 +1,24 @@
import React, { useEffect, useState } from 'react';
import { MdOutlineAutoMode } from 'react-icons/md';
import { MdOutlineTextRotationDown, MdOutlineTextRotationNone } from 'react-icons/md';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { getStyles } from '@/utils/style';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import NumberInput from './NumberInput';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { getBookLangCode } from '@/utils/book';
import NumberInput from './NumberInput';
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
@@ -21,6 +30,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [maxColumnCount, setMaxColumnCount] = useState(viewSettings.maxColumnCount!);
const [maxInlineSize, setMaxInlineSize] = useState(viewSettings.maxInlineSize!);
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
const [writingMode, setWritingMode] = useState(viewSettings.writingMode!);
useEffect(() => {
viewSettings.lineHeight = lineHeight;
@@ -109,15 +119,73 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxInlineSize]);
useEffect(() => {
viewSettings.maxBlockSize = maxBlockSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.maxBlockSize = maxBlockSize;
setSettings(settings);
}
view?.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxBlockSize]);
useEffect(() => {
// global settings are not supported for writing mode
viewSettings.writingMode = writingMode;
setViewSettings(bookKey, viewSettings);
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writingMode]);
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
const isCJKBook = langCode === 'zh' || langCode === 'ja' || langCode === 'ko';
return (
<div className='my-4 w-full space-y-6'>
{isCJKBook && (
<div className='w-full'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Writing Mode')}</h2>
<div className='flex gap-2'>
<div className='tooltip tooltip-bottom' data-tip={_('Default')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('auto')}
>
<MdOutlineAutoMode size={20} />
</button>
</div>
<div className='tooltip tooltip-bottom' data-tip={_('Horizontal Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('horizontal-tb')}
>
<MdOutlineTextRotationNone size={20} />
</button>
</div>
<div className='tooltip tooltip-bottom' data-tip={_('Vertical Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('vertical-rl')}
>
<MdOutlineTextRotationDown size={20} />
</button>
</div>
</div>
</div>
</div>
)}
<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 +193,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 +202,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 +215,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 +228,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}
max={4}
/>
<NumberInput
label='Maximum Inline Size'
label={_('Maximum Inline Size')}
value={maxInlineSize}
onChange={setMaxInlineSize}
min={500}
@@ -182,7 +250,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,58 +1,67 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import cssbeautify from 'cssbeautify';
import { useTranslation } from '@/hooks/useTranslation';
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;
import cssbeautify from 'cssbeautify';
import cssValidate from '@/utils/css';
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 [userStylesheet, setUserStylesheet] = useState(viewSettings.userStylesheet!);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
const [error, setError] = useState<string | null>(null);
let cssInput = userStylesheet;
const validateCSS = (css: string) => {
return cssRegex.test(css);
};
const handleUserStylesheetChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
cssInput = e.target.value;
const cssInput = e.target.value;
setDraftStylesheet(cssInput);
setDraftStylesheetSaved(false);
try {
const formattedCSS = cssbeautify(cssInput, {
indent: ' ',
openbrace: 'end-of-line',
autosemicolon: true,
});
setUserStylesheet(formattedCSS);
if (cssInput && !validateCSS(cssInput)) {
throw new Error('Invalid CSS');
const { isValid, error } = cssValidate(cssInput);
if (cssInput && !isValid) {
throw new Error(error || 'Invalid CSS');
}
setError(null);
viewSettings.userStylesheet = formattedCSS;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Invalid CSS: Please check your input.');
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
} catch (err) {
setError('Invalid CSS: Please check your input.');
console.log('CSS Error:', err);
}
};
const applyStyles = () => {
const formattedCSS = cssbeautify(draftStylesheet, {
indent: ' ',
openbrace: 'end-of-line',
autosemicolon: true,
});
setDraftStylesheet(formattedCSS);
setDraftStylesheetSaved(true);
viewSettings.userStylesheet = formattedCSS;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
};
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
@@ -73,14 +82,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>
<div className='card bg-base-100 border shadow'>
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
<div className='card border-base-200 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=''>{_('Paging Animation')}</span>
<input
type='checkbox'
className='toggle'
@@ -93,24 +112,53 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</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' : ''}`}>
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<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...'
spellCheck='false'
value={cssInput}
onInput={handleInput}
onKeyDown={handleInput}
onKeyUp={handleInput}
onChange={handleUserStylesheetChange}
<div className='config-item config-item-top config-item-bottom'>
<span className=''>{_('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 border-base-200 bg-base-100 border shadow ${error ? 'border-red-500' : ''}`}
>
<div className='relative p-1'>
<textarea
className='textarea textarea-ghost h-48 w-full border-0 p-3 !outline-none'
placeholder={_('Enter your custom CSS here...')}
spellCheck='false'
value={draftStylesheet}
onInput={handleInput}
onKeyDown={handleInput}
onKeyUp={handleInput}
onChange={handleUserStylesheetChange}
/>
<button
className={clsx(
'btn btn-ghost bg-base-200 absolute bottom-2 right-4 h-8 min-h-8 px-4 py-2',
draftStylesheetSaved ? 'hidden' : '',
error ? 'btn-disabled' : '',
)}
onClick={applyStyles}
disabled={!!error}
>
{_('Apply')}
</button>
</div>
</div>
{error && <p className='mt-1 text-sm text-red-500'>{error}</p>}
</div>
</div>
);
};
@@ -25,11 +25,17 @@ const NumberInput: React.FC<NumberInputProps> = ({
const numberStep = step || 1;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value === '' ? 0 : parseFloat(e.target.value);
setLocalValue(newValue);
if (!isNaN(newValue)) {
const roundedValue = Math.round(newValue * 10) / 10;
onChange(Math.max(min, Math.min(max, roundedValue)));
const value = e.target.value;
// Allow empty string or valid numbers without leading zeros
if (value === '' || /^[1-9]\d*\.?\d*$|^0?\.?\d*$/.test(value)) {
const newValue = value === '' ? 0 : parseFloat(value);
setLocalValue(newValue);
if (!isNaN(newValue)) {
const roundedValue = Math.round(newValue * 10) / 10;
onChange(Math.max(min, Math.min(max, roundedValue)));
}
}
};
@@ -59,6 +65,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
<div className='text-base-content flex items-center gap-2'>
<input
type='text'
inputMode='decimal'
value={localValue}
onChange={handleChange}
onBlur={handleOnBlur}
@@ -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,8 +38,8 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
}, []);
return (
<dialog className='modal modal-open min-w-90 w-full'>
<div className='modal-box settings-content flex h-[60%] w-1/2 min-w-[480px] max-w-full flex-col p-0'>
<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-[65%] w-1/2 min-w-[540px] 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'>
<button
@@ -43,33 +47,33 @@ 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'>
<Dropdown
className='dropdown-bottom dropdown-end absolute right-[7%]'
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeVerticalBold size={16} />}
>
@@ -1,20 +1,23 @@
import React from 'react';
import Image from 'next/image';
import { MdInfoOutline } from 'react-icons/md';
import { formatAuthors } from '@/utils/book';
import { Book } from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
interface BookCardProps {
cover: string;
title: string;
author: string;
}
const BookCard = ({ book }: { book: Book }) => {
const { coverImageUrl, title, author } = book;
const _ = useTranslation();
const showBookDetails = () => {
eventDispatcher.dispatchSync('show-book-details', book);
};
const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
return (
<div className='flex h-20 w-full items-center'>
<Image
src={cover}
alt='Book cover'
src={coverImageUrl!}
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'
@@ -24,13 +27,13 @@ const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
/>
<div className='min-w-0 flex-1'>
<h4 className='line-clamp-2 w-[90%] text-sm font-semibold'>{title}</h4>
<p className='text-neutral-content truncate text-xs'>{formatAuthors(author)}</p>
<p className='text-neutral-content truncate text-xs'>{author}</p>
</div>
<button
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
aria-label='More info'
aria-label={_('More Info')}
>
<MdInfoOutline size={18} className='fill-base-content' />
<MdInfoOutline size={18} className='fill-base-content' onClick={showBookDetails} />
</button>
</div>
);
@@ -3,14 +3,18 @@ import Image from 'next/image';
import MenuItem from '@/components/MenuItem';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import useBooksManager from '../../hooks/useBooksManager';
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';
interface BookMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const { library } = useLibraryStore();
const { openParallelView } = useBooksManager();
const handleParallelView = (id: string) => {
@@ -25,13 +29,19 @@ const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
setAboutDialogVisible(true);
setIsDropdownOpen?.(false);
};
const downloadReadest = () => {
window.open(DOWNLOAD_READEST_URL, '_blank');
setIsDropdownOpen?.(false);
};
const isWebApp = isWebAppPlatform();
return (
<div
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
@@ -54,9 +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' />
<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,52 @@ 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={clsx(
'align-bottom text-blue-400',
'transition duration-300 ease-in-out',
'group-hover:opacity-100 opacity-0',
'hover:text-blue-600',
)}>{_('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={clsx(
'align-bottom text-red-400',
'transition duration-300 ease-in-out',
'group-hover:opacity-100 opacity-0',
'hover:text-red-600',
)}>{_('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,22 @@
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');
const [fade, setFade] = useState(false);
const [targetTab, setTargetTab] = useState(activeTab);
useEffect(() => {
const container = scrollContainerRef.current;
@@ -39,6 +43,27 @@ 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) => {
setFade(true);
const timeout = setTimeout(() => {
setFade(false)
setTargetTab(tab);
setConfig(sideBarBookKey!, config);
clearTimeout(timeout);
}, 300);
setActiveTab(tab);
const config = getConfig(sideBarBookKey!)!;
config.viewSettings!.sideBarTab = tab;
};
return (
<>
<div
@@ -47,20 +72,26 @@ const SidebarContent: React.FC<{
'font-sans text-sm font-normal shadow-inner',
)}
>
<div ref={scrollContainerRef} className='scroll-container overflow-y-auto'>
{activeTab === 'toc' && bookDoc.toc && (
<div
ref={scrollContainerRef}
className={clsx(
'scroll-container overflow-y-auto transition-opacity duration-300 ease-in-out',
{ 'opacity-0': fade, 'opacity-100': !fade }
)}
>
{targetTab === 'toc' && bookDoc.toc && (
<TOCView toc={bookDoc.toc} bookKey={sideBarBookKey} />
)}
{activeTab === 'annotations' && (
<BooknoteView type='annotation' toc={bookDoc.toc} bookKey={sideBarBookKey} />
{targetTab === 'annotations' && (
<BooknoteView type='annotation' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
{activeTab === 'bookmarks' && (
<BooknoteView type='bookmark' toc={bookDoc.toc} bookKey={sideBarBookKey} />
{targetTab === 'bookmarks' && (
<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,11 +5,13 @@ 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';
const MINIMUM_SEARCH_TERM_LENGTH = 2;
const MINIMUM_SEARCH_TERM_LENGTH_DEFAULT = 2;
const MINIMUM_SEARCH_TERM_LENGTH_CJK = 1;
interface SearchBarProps {
isVisible: boolean;
@@ -24,6 +26,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
searchTerm: term,
onSearchResultChange,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig } = useBookDataStore();
@@ -34,7 +37,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);
@@ -95,8 +98,15 @@ const SearchBar: React.FC<SearchBarProps> = ({
handleSearchTermChange(searchTerm);
};
const exceedMinSearchTermLength = (searchTerm: string) => {
const isCJK = /[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/.test(searchTerm);
const minLength = isCJK ? MINIMUM_SEARCH_TERM_LENGTH_CJK : MINIMUM_SEARCH_TERM_LENGTH_DEFAULT;
return searchTerm.length >= minLength;
};
const handleSearchTermChange = (term: string) => {
if (term.length >= MINIMUM_SEARCH_TERM_LENGTH) {
if (exceedMinSearchTermLength(term)) {
handleSearch(term);
} else {
resetSearch();
@@ -116,7 +126,10 @@ const SearchBar: React.FC<SearchBarProps> = ({
onSearchResultChange([...results]);
isSearchPending.current = false;
console.log('search done');
if (queuedSearchTerm.current !== term && queuedSearchTerm.current.length > 2) {
if (
queuedSearchTerm.current !== term &&
exceedMinSearchTermLength(queuedSearchTerm.current)
) {
handleSearch(queuedSearchTerm.current);
}
}
@@ -149,7 +162,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>
@@ -26,7 +26,7 @@ const SearchResultItem: React.FC<SearchResultItemProps> = ({
ref={viewRef}
className={clsx(
'my-2 cursor-pointer rounded-lg p-2 text-sm',
isCurrent ? 'bg-base-300 hover:bg-gray-300/70' : 'hover:bg-base-300 bg-white',
isCurrent ? 'bg-base-300 hover:bg-gray-300/70' : 'hover:bg-base-300 bg-base-100',
)}
onClick={() => onSelectResult(cfi)}
>
@@ -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) {
@@ -134,7 +125,7 @@ const SideBar: React.FC<{
/>
</div>
<div className='border-base-300/50 border-b px-3'>
<BookCard cover={book.coverImageUrl!} title={book.title} author={book.author} />
<BookCard book={book} />
</div>
</div>
{isSearchBarVisible && searchResults ? (
@@ -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'
@@ -126,7 +126,7 @@ const TOCView: React.FC<{
const rect = currentItem.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!isVisible) {
(currentItem as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
(currentItem as HTMLElement).scrollIntoView({ behavior: 'instant', block: 'center' });
}
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
}
@@ -1,23 +1,54 @@
import clsx from 'clsx';
import React from 'react';
import { MdToc, MdEditNote, MdBookmarkBorder } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
const TabNavigation: React.FC<{
activeTab: string;
onTabChange: (tab: string) => void;
}> = ({ activeTab, onTabChange }) => (
<div className='bottom-tab border-base-300/50 flex w-full border-t'>
{['toc', 'annotations', 'bookmarks'].map((tab) => (
<button
key={tab}
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === tab ? 'bg-base-300' : ''}`}
onClick={() => onTabChange(tab)}
>
{tab === 'toc' && <MdToc size={20} className='mx-auto' />}
{tab === 'annotations' && <MdEditNote size={20} className='mx-auto' />}
{tab === 'bookmarks' && <MdBookmarkBorder size={20} className='mx-auto' />}
</button>
))}
</div>
);
}> = ({ activeTab, onTabChange }) => {
const _ = useTranslation();
const tabs = ['toc', 'annotations', 'bookmarks'];
return (
<div className="bottom-tab border-base-300/50 flex w-full border-t relative">
<div
className={clsx(
'absolute bottom-0 left-0 h-full w-1/3 bg-base-300 -z-10',
'transition-transform duration-300 transform',
activeTab === 'toc' && 'translate-x-0',
activeTab === 'annotations' && 'translate-x-full',
activeTab === 'bookmarks' && 'translate-x-[200%]',
)}
/>
{tabs.map((tab) => (
<div
key={tab}
className='tooltip z-50 tooltip-top m-1.5 flex-1 rounded-md p-2 cursor-pointer'
data-tip={tab === 'toc' ? _('Table of Contents') : tab === 'annotations' ? _('Annotate') : _('Bookmark')}
>
<div
className={clsx(
'',
)}
onClick={() => onTabChange(tab)}
>
{
tab === 'toc' ? (
<MdToc size={20} className='mx-auto' />
) : tab === 'annotations' ? (
<MdEditNote size={20} className='mx-auto' />
) : (
<MdBookmarkBorder size={20} className='mx-auto' />
)
}
</div>
</div>
))}
</div>
)
};
export default TabNavigation;
@@ -0,0 +1,254 @@
import React, { useState, useRef, useEffect } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { TTSController } from '@/services/tts/TTSController';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
const POPUP_WIDTH = 282;
const POPUP_HEIGHT = 160;
const POPUP_PADDING = 10;
const TTSControl = () => {
const _ = useTranslation();
const { getView, getViewSettings } = useReaderStore();
const [bookKey, setBookKey] = useState<string>('');
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [showPanel, setShowPanel] = useState(false);
const [panelPosition, setPanelPosition] = useState<Position>();
const [trianglePosition, setTrianglePosition] = useState<Position>();
const iconRef = useRef<HTMLDivElement>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
useEffect(() => {
return () => {
if (ttsControllerRef.current) {
ttsControllerRef.current.kill();
ttsControllerRef.current = null;
}
};
}, []);
useEffect(() => {
eventDispatcher.on('tts-speak', handleTTSSpeak);
eventDispatcher.on('tts-stop', handleTTSStop);
return () => {
eventDispatcher.off('tts-speak', handleTTSSpeak);
eventDispatcher.off('tts-stop', handleTTSStop);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey, range } = event.detail;
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
if (!view || !viewSettings) return;
setBookKey(bookKey);
if (ttsControllerRef.current) {
ttsControllerRef.current.stop();
ttsControllerRef.current = null;
}
try {
const ttsController = new TTSController(view);
await ttsController.init();
await ttsController.initViewTTS();
const ssml = view.tts?.from(range);
if (ssml) {
ttsController.setRate(viewSettings.ttsRate);
ttsController.setVoice(viewSettings.ttsVoice);
ttsController.speak(ssml);
ttsControllerRef.current = ttsController;
const lang = parseSSMLLang(ssml) || 'en';
setTtsLang(lang);
setIsPlaying(true);
}
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported in this device'),
type: 'error',
});
console.error(error);
}
};
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey } = event.detail;
if (bookKey === bookKey) {
handleStop();
}
};
const handleTogglePlay = async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
if (isPlaying) {
ttsController.pause();
setIsPlaying(false);
setIsPaused(true);
} else if (isPaused) {
// start for forward/backward/setvoice-paused
// set rate don't pause the tts
if (ttsController.state === 'paused') {
ttsController.resume();
} else {
ttsController.start();
}
setIsPlaying(true);
setIsPaused(false);
}
};
const handleBackward = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.backward();
}
};
const handleForward = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.forward();
}
};
const handleStop = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
ttsController.stop();
ttsControllerRef.current = null;
setIsPlaying(false);
setShowPanel(false);
}
};
// rate range: 0.5 - 3, 1.0 is normal speed
const handleSetRate = async (rate: number) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
ttsController.pause();
ttsController.setRate(rate);
ttsController.start();
} else {
ttsController.setRate(rate);
}
}
};
const handleSetVoice = async (voice: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
ttsController.pause();
ttsController.setVoice(voice);
ttsController.start();
} else {
ttsController.setVoice(voice);
}
}
};
const handleGetVoices = async (lang: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoices(lang);
}
return [];
};
const handleGetVoiceId = () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoiceId();
}
return '';
};
const updatePanelPosition = () => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
const windowRect = document.documentElement.getBoundingClientRect();
const trianglePos = {
dir: 'up',
point: { x: rect.left + rect.width / 2, y: rect.top - 12 },
} as Position;
const popupPos = getPopupPosition(
trianglePos,
windowRect,
POPUP_WIDTH,
POPUP_HEIGHT,
POPUP_PADDING,
);
setPanelPosition(popupPos);
setTrianglePosition(trianglePos);
}
};
const togglePopup = () => {
updatePanelPosition();
setShowPanel((prev) => !prev);
};
const handleDismissPopup = () => {
setShowPanel(false);
};
return (
<div>
{showPanel && (
<div
className='fixed inset-0'
onClick={handleDismissPopup}
onContextMenu={handleDismissPopup}
/>
)}
{ttsControllerRef.current && (
<div ref={iconRef} className='absolute bottom-12 right-6 h-12 w-12'>
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
</div>
)}
{showPanel && panelPosition && trianglePosition && (
<Popup
width={POPUP_WIDTH}
height={POPUP_HEIGHT}
position={panelPosition}
trianglePosition={trianglePosition}
className='bg-base-200 absolute flex shadow-lg'
>
<TTSPanel
bookKey={bookKey}
ttsLang={ttsLang}
isPlaying={isPlaying}
onTogglePlay={handleTogglePlay}
onBackward={handleBackward}
onForward={handleForward}
onStop={handleStop}
onSetRate={handleSetRate}
onGetVoices={handleGetVoices}
onSetVoice={handleSetVoice}
onGetVoiceId={handleGetVoiceId}
/>
</Popup>
)}
</div>
);
};
export default TTSControl;

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