Compare commits

...

73 Commits

Author SHA1 Message Date
Huang Xin 6d4ad34e32 release: version 0.9.26 (#723) 2025-03-26 17:44:26 +01:00
Huang Xin 3239ff0a80 feat: add LXGW WenKai to serif fonts list (#722) 2025-03-26 17:28:14 +01:00
Huang Xin 8b7e3c6f79 fix: textarea hidden behind keyboard in fullscreen Android app. closes #653 (#720) 2025-03-26 15:57:35 +01:00
Huang Xin ace4b78420 fix: uncrash scroll prev at start and scroll next at end (#712) 2025-03-26 12:42:39 +01:00
Huang Xin 39ac89fead fix: don't override bg color for img, closes #656 (#711) 2025-03-26 12:23:46 +01:00
Huang Xin 490fe7e70c ui: fix library header padding (#710) 2025-03-26 09:07:38 +01:00
Huang Xin 06a7a8ea82 feat: add option for transient import from file managers, closes #456 (#709) 2025-03-26 07:11:47 +01:00
Huang Xin b7f4a3503f doc: add TXT to supported formats list (#708)
* fix: set max width for annotation toolbar

* doc: add TXT to supported formats list
2025-03-26 11:45:56 +08:00
Huang Xin 835ed443a5 fix: style tweaks for Windows (#707) 2025-03-25 18:58:23 +01:00
Huang Xin 3f3c6983ba fix: also export newly created annotations (#704) 2025-03-25 06:26:11 +01:00
Huang Xin f22ce1c225 fix: access runtime variables in function invoke (#703) 2025-03-25 05:29:22 +01:00
Huang Xin 9990de112f chore: bump updates of next.js and tauri (#702) 2025-03-25 04:46:07 +01:00
Huang Xin ede37757db chore: support deploy in cloudflare with opennext and fix android build 2025-03-25 03:52:04 +08:00
Huang Xin 0c65d44bc9 chore: add android release in github actions (#695) 2025-03-25 01:57:05 +08:00
Huang Xin 07c9813ec1 fix: no background color for section and page info in vertical scroll mode (#694) 2025-03-24 07:13:38 +01:00
Huang Xin 03f0ee1f7d release: version 0.9.25 (#690) 2025-03-23 19:39:39 +01:00
Huang Xin ebc23c4ce3 feat: export annotations in markdown format, closes #152 (#689) 2025-03-23 19:33:05 +01:00
Huang Xin d098487cd1 fix: add metadata parsed from cbz zip comment, closes #639 (#688) 2025-03-23 18:18:08 +01:00
Huang Xin 14f2db730a fix: add option to override justify style in books, closes #671 (#687) 2025-03-23 16:59:45 +01:00
Huang Xin a79f301165 feat: add language preference options in settings (#686) 2025-03-23 16:39:58 +01:00
Huang Xin 05d086352f chore: add f-droid metadata (#682) 2025-03-23 08:59:34 +01:00
Huang Xin ef864a32c2 feat: action bar at the bottom on mobile devices (#681) 2025-03-23 08:56:57 +01:00
Huang Xin 0d93a13407 fix: format footer in popup window (#665) 2025-03-21 05:52:56 +01:00
Huang Xin ab0208b7ab fix: now img in paragraph won't get indented and default to override book layout (#657) 2025-03-20 17:10:26 +01:00
Huang Xin c52f3c0430 feat: support importing TXT files, closes #308 (#655) 2025-03-20 15:00:30 +01:00
Huang Xin accf6fb5c7 feat: snap dialog to preview effects of book settings adjustments (#646) 2025-03-19 14:23:52 +01:00
Huang Xin 18c2ce1274 fix: various fixes on layout settings and styles, closes #637 (#644) 2025-03-19 10:14:02 +01:00
Huang Xin 9a104b918c fix: dismiss popup footnotes on window resize, also closes #634 and closes #635 (#638) 2025-03-19 04:22:15 +01:00
Huang Xin 948303c43a release: version 0.9.23 (#633) 2025-03-18 17:13:43 +01:00
Huang Xin 82f303fcfe feat: responsive window size for popup footnotes, closes #538 (#632) 2025-03-18 17:00:07 +01:00
Huang Xin 359fa8df35 refactor: always save when changes are made with settings, also closes #626 (#630) 2025-03-18 12:58:01 +01:00
Huang Xin 72e6c058f5 fix: completed books now have 100% progress (#625) 2025-03-18 06:00:33 +01:00
Huang Xin 7648aa47b2 fix: don't reinit footnote handler when theme is changed, closes #619 (#621) 2025-03-17 17:47:31 +01:00
Huang Xin 48074f0f53 feat: add options to show/hide header and footer widgets in paginated and scrolled mode, also closes #602 (#620) 2025-03-17 15:30:12 +01:00
Huang Xin 66bd8d74f3 fix: also set color-scheme in iframe for Safari (#616) 2025-03-17 05:53:42 +01:00
Huang Xin 37f3c8b829 chore: fix node out of memory when building 2025-03-16 03:43:32 +08:00
Huang Xin 8775f90bc1 fix: refresh page when vertical writing mode is set (#615) 2025-03-15 20:07:54 +01:00
Huang Xin d6962055b9 release: version 0.9.22 (#614) 2025-03-15 19:43:44 +01:00
Huang Xin 05489282df cjk: always show vertical layout options in CJK locale (#613) 2025-03-15 19:37:05 +01:00
Huang Xin 58f1b5833f feat: add border frame for vertical layout, also closes #563 (#612) 2025-03-15 18:55:12 +01:00
Huang Xin c66911b060 fix: hiding scrollbars may cause layout changes, closes #600 (#610) 2025-03-15 06:38:48 +01:00
Huang Xin 54debbfe86 api: load balance of translation requests (#609) 2025-03-15 05:24:34 +01:00
Huang Xin 6d580565fd fix: use deepl jsonrpc for free plan translation (#608) 2025-03-14 18:31:51 +01:00
Huang Xin f0270eec4e fix: handle system theme change and set color scheme, closes #571 (#607) 2025-03-14 16:55:27 +01:00
Huang Xin cd9290240a fix: force focus on dropdown menu on older Safari, closes #574 (#606) 2025-03-14 10:14:24 +01:00
Huang Xin 2fc7bc4744 fix: don't handle iframe wheel events for scrolled mode, closes #604 (#605) 2025-03-14 08:43:50 +01:00
Huang Xin 5370a9e7f6 bump: bump update next.js to 15.2.2 (#601) 2025-03-13 19:03:21 +01:00
Huang Xin 08f348ada5 fix: don't hide horizontal scroll bar at the bottom, closes #570 (#599) 2025-03-13 17:45:41 +01:00
Huang Xin 4452f0b810 fix: prevent the footer bar from popping up by tapping at the bottom on mobile devices, closes #597 (#598) 2025-03-13 17:19:31 +01:00
Huang Xin b7dc880a5e feat: add scrolled mode toggler in font/layout settings dialog, closes #580 (#596) 2025-03-13 15:07:17 +01:00
Huang Xin 35596094c2 fix: clear select mode when importing files, closes #581 (#595) 2025-03-13 14:41:19 +01:00
Huang Xin e8eb409f9f feat: add theme editor for custom theme colors, closes #462 (#594) 2025-03-13 14:23:58 +01:00
Huang Xin 6a74a0c5b8 fix: following ordinary link in footnotes, closes #559 (#560) 2025-03-10 18:09:47 +01:00
Huang Xin dfa0468d24 release: hot fix version 0.9.21 (#551)
* sync: update partial config to sync more actively

* rtl: make the order of go back/forward buttons consistent

* fix: wheel events conflict with touchpad scroll on macOS

* release: hot fix version 0.9.21
2025-03-09 20:22:47 +01:00
Huang Xin 4869b756ea release: version 0.9.20 (#549)
* fix: occasionally traffic lights disappear in library page

* release: version 0.9.20
2025-03-09 17:40:30 +01:00
Huang Xin 693c79c9b7 fix: remove seldomly used option to invert colors in dark mode (#548)
This option is hard to understand and explain. Most hardcoded color
in dark mode that affects readability has already been fixed in the
reader so there is no necessity to have this option.
2025-03-09 17:02:01 +01:00
Huang Xin e846875973 doc: update screenshots in readme (#546) 2025-03-09 14:15:41 +01:00
Huang Xin b1f2941247 fix: various fixes for updater, footerbar and note item (#545)
* fix: don't open book if failed to download book

* fix: various fixes for updater, footerbar and note item
1. now footerbar when invisible won't process pointer events
2. manually check updater won't be prevented by check interval
3. get rid of clamped text in note item
2025-03-09 11:33:52 +01:00
Huang Xin 6688c813e6 fix: don't open book if failed to download book (#541) 2025-03-09 10:29:16 +01:00
Huang Xin 664dc45fcc fix: search options menu in mobile & user id in posthog (#539) 2025-03-09 07:07:42 +01:00
Huang Xin 27fa753486 feat: support drag and drop to import books, closes #524 (#536) 2025-03-08 19:12:00 +01:00
Huang Xin 71e392544c fix: rtl for notebook, closes #451 (#535) 2025-03-08 16:51:24 +01:00
Huang Xin 7f24081205 feat: add fullscreen option for desktop app, closes #493 (#534) 2025-03-08 22:36:25 +08:00
Huang Xin 89f1c43984 fix: don't hide horizontal scrollbar at the bottom, closes #515 (#533) 2025-03-08 21:26:10 +08:00
Huang Xin f841004e7a chore: bump update of tauri (#532) 2025-03-08 10:53:31 +01:00
Huang Xin 0922f557bb fix: make the height of the drag handle in dialog stable (#530) 2025-03-08 10:03:00 +01:00
Huang Xin 0b72ab156d refactor: move useTheme hook into separate store, also closes #528 (#529) 2025-03-08 09:12:36 +01:00
Huang Xin dfc6cc9c8a Update feature-request template 2025-03-08 04:02:02 +01:00
Huang Xin 83b2908266 compat: replace absolute font sizes with rem units in css, closes #521 (#523) 2025-03-07 18:37:46 +01:00
Huang Xin c25f2a7abc fix: make continuous scroll an option and disabled by default, closes #516, #373 (#522) 2025-03-07 17:24:01 +01:00
Huang Xin de1e396f6e fix: switch label for column width/height for vertical layout and fix column size calculation (#520)
* fix: scroll mode should be updated when calculating column size

* fix: switch label for column width/height for vertical layout, closes #517
2025-03-07 16:42:40 +01:00
Huajin 80a3861816 Fixes the scrollbar gutter issue for DaisyUI (#514)
* Fixes the scrollbar gutter issue for DaisyUI
https://github.com/saadeghi/daisyui/issues/3040#issuecomment-2250530354
2025-03-07 15:15:52 +01:00
Huang Xin fd8eeb07c2 fix: set sidebar rtl only for mandatory rtl languages, closes #512 (#519) 2025-03-07 15:06:12 +01:00
141 changed files with 8202 additions and 1844 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: Feature request
about: Share an idea or suggestion
title: 'FR: [a handful of words describing the FR]'
title: 'FR: describing your feature request'
labels: enhancement
assignees: ''
---
+64 -8
View File
@@ -56,27 +56,35 @@ jobs:
fail-fast: false
matrix:
config:
- os: ubuntu-latest
release: android
rust_target: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- os: ubuntu-22.04
release: linux
arch: x86_64
rust_target: x86_64-unknown-linux-gnu
- os: ubuntu-22.04-arm
release: linux
arch: aarch64
rust_target: aarch64-unknown-linux-gnu
- os: macos-latest
release: macos
arch: aarch64
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
args: '--target universal-apple-darwin'
- os: windows-latest
release: windows
arch: x86_64
rust_target: x86_64-pc-windows-msvc
args: '--target x86_64-pc-windows-msvc'
- os: windows-latest
release: windows
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: 60
steps:
- uses: actions/checkout@v4
@@ -94,6 +102,21 @@ jobs:
node-version: 22
cache: pnpm
- name: setup Java (for Android build only)
if: matrix.config.release == 'android'
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: setup Android SDK (for Android build only)
if: matrix.config.release == 'android'
uses: android-actions/setup-android@v3
- name: install NDK (for Android build only)
if: matrix.config.release == 'android'
run: sdkmanager "ndk;27.0.11902837"
- name: install dependencies
run: pnpm install
@@ -109,6 +132,12 @@ jobs:
with:
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('Cargo.lock') }}
- name: install dependencies (ubuntu only)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android'
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: create .env.local file for Next.js
run: |
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
@@ -116,17 +145,43 @@ jobs:
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
cp .env.local apps/readest-app/.env.local
- name: copy .env.local to apps/readest-app
run: cp .env.local apps/readest-app/.env.local
- name: install dependencies (ubuntu only)
if: contains(matrix.config.os, 'ubuntu')
- name: build and upload Android apks
if: matrix.config.release == 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.0.11902837
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
cd apps/readest-app/
rm -rf src-tauri/gen/android
pnpm tauri android init
pnpm tauri icon ../../data/icons/readest-book.png
git checkout .
pushd src-tauri/gen/android
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks
echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties
popd
version=${{ needs.get-release.outputs.release_version }}
apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release
universial_apk=Readest_${version}_universal.apk
arm64_apk=Readest_${version}_arm64.apk
pnpm tauri android build
cp ${apk_path}/app-universal-release.apk $universial_apk
pnpm tauri android build -t aarch64
cp ${apk_path}/app-universal-release.apk $arm64_apk
echo "Uploading $universial_apk to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk --clobber
echo "Uploading $arm64_apk to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk --clobber
- uses: tauri-apps/tauri-action@v0
if: matrix.config.release != 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -137,6 +192,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NODE_OPTIONS: '--max-old-space-size=8192'
with:
projectPath: apps/readest-app
releaseId: ${{ needs.get-release.outputs.release_id }}
+2 -2
View File
@@ -1,6 +1,6 @@
[submodule "packages/foliate-js"]
path = packages/foliate-js
url = https://github.com/chrox/foliate-js.git
url = https://github.com/readest/foliate-js.git
[submodule "packages/tauri"]
path = packages/tauri
url = https://github.com/chrox/tauri.git
url = https://github.com/tauri-apps/tauri.git
Generated
+787 -772
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -32,7 +32,7 @@
<div align="center">
<a href="https://readest.com" target="_blank">
<img src="./data/screenshots/landing_preview.png" alt="Readest Banner" width="100%" />
<img src="./data/screenshots/landing_all_platforms.png" alt="Readest Banner" width="100%" />
</a>
</div>
@@ -42,7 +42,7 @@
| **Feature** | **Description** | **Status** |
| --------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------- |
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, 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. | ✅ |
@@ -80,7 +80,7 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
![DeepL](./data/screenshots/deepl.png)
![TTS](./data/screenshots/tts_control.png)
![TTS](./data/screenshots/tts_speak_aloud.png)
![Footnote](./data/screenshots/footnote_popover.png)
@@ -205,7 +205,7 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
<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" />
<img width="500" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
</p>
</a>
+1 -3
View File
@@ -13,9 +13,7 @@ const nextConfig = {
images: {
unoptimized: true,
},
devIndicators: {
appIsrStatus: false,
},
devIndicators: false,
// Configure assetPrefix or else the server won't properly resolve your assets.
assetPrefix: '',
reactStrictMode: true,
+14 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.19",
"version": "0.9.26",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -42,20 +42,21 @@
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.47.7",
"@tauri-apps/api": "2.2.0",
"@tauri-apps/api": "2.3.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-haptics": "^2.2.3",
"@tauri-apps/plugin-http": "^2.3.0",
"@tauri-apps/plugin-log": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.2.5",
"@tauri-apps/plugin-log": "^2.2.3",
"@tauri-apps/plugin-opener": "^2.2.6",
"@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.4.0",
"@tauri-apps/plugin-updater": "^2.5.1",
"@zip.js/zip.js": "^2.7.53",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cssbeautify": "^0.3.1",
@@ -65,9 +66,10 @@
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"next": "15.1.6",
"next": "15.2.4",
"posthog-js": "^1.205.0",
"react": "19.0.0",
"react-color": "^2.19.3",
"react-dom": "19.0.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
@@ -76,16 +78,18 @@
"zustand": "5.0.1"
},
"devDependencies": {
"@tauri-apps/cli": "2.2.7",
"@opennextjs/cloudflare": "^0.5.12",
"@tauri-apps/cli": "2.3.1",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
"@types/react": "18.3.12",
"@types/react-color": "^3.0.13",
"@types/react-dom": "18.3.1",
"@types/tinycolor2": "^1.4.6",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",
"daisyui": "^4.12.23",
"daisyui": "^4.12.24",
"dotenv-cli": "^7.4.4",
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
@@ -97,6 +101,7 @@
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"wrangler": "^4.4.0"
}
}
@@ -53,19 +53,15 @@
"Hyphenation": "الواصلة",
"Identifier:": "المعرف:",
"Import Books": "استيراد الكتب",
"Invert Colors in Dark Mode": "عكس الألوان في الوضع المظلم",
"Language:": "اللغة:",
"Layout": "التخطيط",
"Light Mode": "الوضع الفاتح",
"Loading...": "جارٍ التحميل...",
"Loc. {{currentPage}} / {{totalPage}}": "الصفحة {{currentPage}} \\ {{totalPage}}",
"Logged in": "تم تسجيل الدخول",
"Logged in as {{userDisplayName}}": "تم تسجيل الدخول باسم {{userDisplayName}}",
"Match Case": "مطابقة حالة الأحرف",
"Match Diacritics": "مطابقة التشكيل",
"Match Whole Words": "مطابقة الكلمات الكاملة",
"Maximum Block Size": "أقصى مساحة رأسية",
"Maximum Inline Size": "أقصى مساحة أفقية",
"Maximum Number of Columns": "العدد الأقصى للأعمدة",
"Minimum Font Size": "الحد الأدنى لحجم الخط",
"Misc": "أخرى",
@@ -230,5 +226,39 @@
"Delete Permanently": "حذف نهائي",
"Free Tier": "الفئة المجانية",
"Plus Tier": "فئة Plus",
"Pro Tier": "فئة Pro"
"Pro Tier": "فئة Pro",
"RTL Direction": "الاتجاه من اليمين إلى اليسار",
"Maximum Column Height": "الارتفاع الأقصى للعمود",
"Maximum Column Width": "العرض الأقصى للعمود",
"Continuous Scroll": "التمرير المستمر",
"Fullscreen": "ملء الشاشة",
"No supported files found. Supported formats: {{formats}}": "لم يتم العثور على ملفات مدعومة. الصيغ المدعومة: {{formats}}",
"Drop to Import Books": "اسحب لاستيراد الكتب",
"Custom": "مخصص",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "العالم مسرح،\nوالناس فيه ممثلون؛\nلهم مخارج ومداخل،\nوالإنسان في حياته يلعب دورًا كثيرًا،\nوأفعاله تمر بسبع مراحل.\n\n— ويليام شكسبير",
"Custom Theme": "سمة مخصصة",
"Theme Name": "اسم السمة",
"Text Color": "لون النص",
"Background Color": "لون الخلفية",
"Preview": "معاينة",
"Contrast": "التباين",
"Sunset": "غروب الشمس",
"Double Border": "حدود مزدوجة",
"Border Color": "لون الحدود",
"Border Frame": "إطار الحدود",
"Show Header": "عرض الرأس",
"Show Footer": "عرض التذييل",
"Small": "صغير",
"Large": "كبير",
"Auto": "تلقائي",
"Language": "اللغة",
"No annotations to export": "لا توجد تعليقات للتصدير",
"Author": "المؤلف",
"Exported from Readest": "تم التصدير من ريديست",
"Highlights & Annotations": "التمييزات والتعليقات",
"Note": "ملاحظة",
"Copied to clipboard": "تم النسخ إلى الحافظة",
"Export Annotations": "تصدير التعليقات",
"Auto Import on File Open": "استيراد تلقائي عند فتح الملف",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"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",
"Loading...": "Wird geladen...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Angemeldet",
"Logged in as {{userDisplayName}}": "Angemeldet als {{userDisplayName}}",
"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",
@@ -230,5 +226,39 @@
"Delete Permanently": "Dauerhaft löschen",
"Free Tier": "Kostenlose Stufe",
"Plus Tier": "Plus-Stufe",
"Pro Tier": "Pro-Stufe"
"Pro Tier": "Pro-Stufe",
"RTL Direction": "RTL-Richtung",
"Maximum Column Height": "Maximale Spaltenhöhe",
"Maximum Column Width": "Maximale Spaltenbreite",
"Continuous Scroll": "Kontinuierliches Scrollen",
"Fullscreen": "Vollbild",
"No supported files found. Supported formats: {{formats}}": "Keine unterstützten Dateien gefunden. Unterstützte Formate: {{formats}}",
"Drop to Import Books": "Zum Importieren von Büchern ablegen",
"Custom": "Benutzerdefiniert",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Die ganze Welt ist eine Bühne,\nUnd alle Männer und Frauen sind bloß Spieler;\nSie haben ihre Abgänge und ihre Auftritte,\nUnd ein Mann spielt in seinem Leben viele Rollen,\nSeine Taten sind sieben Zeitalter.\n\n— William Shakespeare",
"Custom Theme": "Benutzerdefiniertes Thema",
"Theme Name": "Themenname",
"Text Color": "Textfarbe",
"Background Color": "Hintergrundfarbe",
"Preview": "Vorschau",
"Contrast": "Kontrast",
"Sunset": "Sonnenuntergang",
"Double Border": "Doppelter Rand",
"Border Color": "Randfarbe",
"Border Frame": "Rahmenrand",
"Show Header": "Kopfzeile anzeigen",
"Show Footer": "Fußzeile anzeigen",
"Small": "Klein",
"Large": "Groß",
"Auto": "Automatisch",
"Language": "Sprache",
"No annotations to export": "Keine Anmerkungen zum Exportieren",
"Author": "Autor",
"Exported from Readest": "Exportiert von Readest",
"Highlights & Annotations": "Hervorhebungen & Anmerkungen",
"Note": "Notiz",
"Copied to clipboard": "In die Zwischenablage kopiert",
"Export Annotations": "Anmerkungen exportieren",
"Auto Import on File Open": "Automatischer Import beim Öffnen einer Datei",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Συλλαβισμός",
"Identifier:": "Αναγνωριστικό:",
"Import Books": "Εισαγωγή βιβλίων",
"Invert Colors in Dark Mode": "Αντιστροφή χρωμάτων στη σκοτεινή λειτουργία",
"Language:": "Γλώσσα:",
"Layout": "Διάταξη",
"Light Mode": "Φωτεινή λειτουργία",
"Loading...": "Φόρτωση...",
"Loc. {{currentPage}} / {{totalPage}}": "Θέση {{currentPage}} / {{totalPage}}",
"Logged in": "Συνδεδεμένος",
"Logged in as {{userDisplayName}}": "Συνδεδεμένος ως {{userDisplayName}}",
"Match Case": "Ταίριασμα πεζών-κεφαλαίων",
"Match Diacritics": "Ταίριασμα διακριτικών σημείων",
"Match Whole Words": "Ταίριασμα ολόκληρων λέξεων",
"Maximum Block Size": "Μέγιστο μέγεθος μπλοκ",
"Maximum Inline Size": "Μέγιστο μέγεθος γραμμής",
"Maximum Number of Columns": "Μέγιστος αριθμός στηλών",
"Minimum Font Size": "Ελάχιστο μέγεθος γραμματοσειράς",
"Misc": "Διάφορα",
@@ -230,5 +226,39 @@
"Delete Permanently": "Μόνιμη διαγραφή",
"Free Tier": "Δωρεάν βαθμίδα",
"Plus Tier": "Βαθμίδα Plus",
"Pro Tier": "Βαθμίδα Pro"
"Pro Tier": "Βαθμίδα Pro",
"RTL Direction": "Κατεύθυνση RTL",
"Maximum Column Height": "Μέγιστο ύψος στήλης",
"Maximum Column Width": "Μέγιστο πλάτος στήλης",
"Continuous Scroll": "Συνεχής κύλιση",
"Fullscreen": "Πλήρης οθόνη",
"No supported files found. Supported formats: {{formats}}": "Δεν βρέθηκαν υποστηριζόμενα αρχεία. Υποστηριζόμενες μορφές: {{formats}}",
"Drop to Import Books": "Ρίξτε για εισαγωγή βιβλίων",
"Custom": "Προσαρμοσμένο",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Όλος ο κόσμος είναι σκηνή,\nΚαι όλοι οι άνδρες και οι γυναίκες απλώς ηθοποιοί·\nΈχουν τις εξόδους και τις εισόδους τους,\nΚαι ένας άνθρωπος στον χρόνο του παίζει πολλούς ρόλους,\nΟι πράξεις του υπάρχουν σε επτά ηλικίες.\n\n— William Shakespeare",
"Custom Theme": "Προσαρμοσμένο θέμα",
"Theme Name": "Όνομα θέματος",
"Text Color": "Χρώμα κειμένου",
"Background Color": "Χρώμα φόντου",
"Preview": "Προεπισκόπηση",
"Contrast": "Αντίθεση",
"Sunset": "Ηλιοβασίλεμα",
"Double Border": "Διπλό περιθώριο",
"Border Color": "Χρώμα περιγράμματος",
"Border Frame": "Πλαίσιο περιγράμματος",
"Show Header": "Εμφάνιση κεφαλίδας",
"Show Footer": "Εμφάνιση υποσέλιδου",
"Small": "Μικρό",
"Large": "Μεγάλο",
"Auto": "Αυτόματο",
"Language": "Γλώσσα",
"No annotations to export": "Δεν υπάρχουν σχολιασμοί για εξαγωγή",
"Author": "Συγγραφέας",
"Exported from Readest": "Εξήχθη από το Readest",
"Highlights & Annotations": "Επισημάνσεις & Σχολιασμοί",
"Note": "Σημείωση",
"Copied to clipboard": "Αντιγράφηκε στο πρόχειρο",
"Export Annotations": "Εξαγωγή σχολιασμών",
"Auto Import on File Open": "Αυτόματη εισαγωγή κατά το άνοιγμα αρχείου",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -1 +1,3 @@
{}
{
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"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",
"Loading...": "Cargando...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Sesión iniciada",
"Logged in as {{userDisplayName}}": "Sesión iniciada como {{userDisplayName}}",
"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",
@@ -230,5 +226,39 @@
"Delete Permanently": "Eliminar permanentemente",
"Free Tier": "Nivel gratuito",
"Plus Tier": "Nivel Plus",
"Pro Tier": "Nivel Pro"
"Pro Tier": "Nivel Pro",
"RTL Direction": "Dirección RTL",
"Maximum Column Height": "Altura máxima de columna",
"Maximum Column Width": "Ancho máximo de columna",
"Continuous Scroll": "Desplazamiento continuo",
"Fullscreen": "Pantalla completa",
"No supported files found. Supported formats: {{formats}}": "No se encontraron archivos compatibles. Formatos compatibles: {{formats}}",
"Drop to Import Books": "Soltar para importar libros",
"Custom": "Personalizado",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Todo el mundo es un escenario,\nY todos los hombres y mujeres son meros actores;\nTienen sus salidas y sus entradas,\nY un hombre en su tiempo interpreta muchos papeles,\nSus actos son siete edades.\n\n— William Shakespeare",
"Custom Theme": "Tema personalizado",
"Theme Name": "Nombre del tema",
"Text Color": "Color del texto",
"Background Color": "Color de fondo",
"Preview": "Vista previa",
"Contrast": "Contraste",
"Sunset": "Atardecer",
"Double Border": "Doble borde",
"Border Color": "Color del borde",
"Border Frame": "Marco de borde",
"Show Header": "Mostrar encabezado",
"Show Footer": "Mostrar pie de página",
"Small": "Pequeño",
"Large": "Grande",
"Auto": "Automático",
"Language": "Idioma",
"No annotations to export": "No hay anotaciones para exportar",
"Author": "Autor",
"Exported from Readest": "Exportado desde Readest",
"Highlights & Annotations": "Resaltados y anotaciones",
"Note": "Nota",
"Copied to clipboard": "Copiado al portapapeles",
"Export Annotations": "Exportar anotaciones",
"Auto Import on File Open": "Importación automática al abrir archivo",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"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",
"Loading...": "Chargement...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Connecté",
"Logged in as {{userDisplayName}}": "Connecté en tant que {{userDisplayName}}",
"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",
@@ -230,5 +226,39 @@
"Delete Permanently": "Supprimer définitivement",
"Free Tier": "Niveau gratuit",
"Plus Tier": "Niveau Plus",
"Pro Tier": "Niveau Pro"
"Pro Tier": "Niveau Pro",
"RTL Direction": "Direction RTL",
"Maximum Column Height": "Hauteur maximale de colonne",
"Maximum Column Width": "Largeur maximale de colonne",
"Continuous Scroll": "Défilement continu",
"Fullscreen": "Plein écran",
"No supported files found. Supported formats: {{formats}}": "Aucun fichier pris en charge trouvé. Formats pris en charge : {{formats}}",
"Drop to Import Books": "Déposez pour importer des livres",
"Custom": "Personnalisé",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Le monde entier est un théâtre,\nEt tous les hommes et les femmes ne sont que des acteurs ;\nIls ont leurs sorties et leurs entrées,\nEt un homme, dans sa vie, joue de nombreux rôles,\nSes actes étant sept âges.\n\n— William Shakespeare",
"Custom Theme": "Thème personnalisé",
"Theme Name": "Nom du thème",
"Text Color": "Couleur du texte",
"Background Color": "Couleur de fond",
"Preview": "Aperçu",
"Contrast": "Contraste",
"Sunset": "Coucher de soleil",
"Double Border": "Double bordure",
"Border Color": "Couleur de la bordure",
"Border Frame": "Cadre de bordure",
"Show Header": "Afficher l'en-tête",
"Show Footer": "Afficher le pied de page",
"Small": "Petit",
"Large": "Grand",
"Auto": "Automatique",
"Language": "Langue",
"No annotations to export": "Aucune annotation à exporter",
"Author": "Auteur",
"Exported from Readest": "Exporté depuis Readest",
"Highlights & Annotations": "Surlignages et annotations",
"Note": "Note",
"Copied to clipboard": "Copié dans le presse-papiers",
"Export Annotations": "Exporter les annotations",
"Auto Import on File Open": "Importation automatique à l'ouverture du fichier",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"Hyphenation": "हाइफ़नेशन",
"Identifier:": "पहचानकर्ता:",
"Import Books": "पुस्तकें आयात करें",
"Invert Colors in Dark Mode": "डार्क मोड में रंग उलटें",
"Language:": "भाषा:",
"Layout": "लेआउट",
"Light Mode": "लाइट मोड",
"Loading...": "लोड हो रहा है...",
"Loc. {{currentPage}} / {{totalPage}}": "स्थान {{currentPage}} / {{totalPage}}",
"Logged in": "लॉग इन किया",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} के रूप में लॉग इन किया",
"Match Case": "केस मैच करें",
"Match Diacritics": "डायाक्रिटिक्स मैच करें",
"Match Whole Words": "पूरे शब्द मैच करें",
"Maximum Block Size": "अधिकतम ब्लॉक साइज़",
"Maximum Inline Size": "अधिकतम इनलाइन साइज़",
"Maximum Number of Columns": "कॉलम की अधिकतम संख्या",
"Minimum Font Size": "न्यूनतम फ़ॉन्ट साइज़",
"Misc": "विविध",
@@ -230,5 +226,39 @@
"Delete Permanently": "स्थायी रूप से हटाएं",
"Free Tier": "फ्री टियर",
"Plus Tier": "प्लस टियर",
"Pro Tier": "प्रो टियर"
"Pro Tier": "प्रो टियर",
"RTL Direction": "RTL दिशा",
"Maximum Column Height": "कॉलम की अधिकतम ऊँचाई",
"Maximum Column Width": "कॉलम की अधिकतम चौड़ाई",
"Continuous Scroll": "निरंतर स्क्रॉल",
"Fullscreen": "पूर्णस्क्रीन",
"No supported files found. Supported formats: {{formats}}": "कोई समर्थित फ़ाइलें नहीं मिलीं। समर्थित प्रारूप: {{formats}}",
"Drop to Import Books": "पुस्तकें आयात करने के लिए छोड़ें",
"Custom": "कस्टम",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "पूरी दुनिया एक स्टेज है,\nऔर सभी आदमी और महिलाएँ केवल अभिनेता हैं;\nउनके निकास और प्रवेश होते हैं,\nऔर एक आदमी अपने समय में कई भूमिकाएँ निभाता है,\nउसके कार्य सात युग होते हैं।\n\n— विलियम शेक्सपियर",
"Custom Theme": "कस्टम थीम",
"Theme Name": "थीम का नाम",
"Text Color": "टेक्स्ट रंग",
"Background Color": "पृष्ठभूमि रंग",
"Preview": "पूर्वावलोकन",
"Contrast": "विरोधाभास",
"Sunset": "सनसेट",
"Double Border": "डबल बॉर्डर",
"Border Color": "बॉर्डर रंग",
"Border Frame": "बॉर्डर फ्रेम",
"Show Header": "हेडर दिखाएं",
"Show Footer": "फ़ुटर दिखाएं",
"Small": "छोटा",
"Large": "बड़ा",
"Auto": "स्वचालित",
"Language": "भाषा",
"No annotations to export": "निर्यात करने के लिए कोई टिप्पणियाँ नहीं हैं",
"Author": "लेखक",
"Exported from Readest": "Readest से निर्यात किया गया",
"Highlights & Annotations": "हाइलाइट और टिप्पणियाँ",
"Note": "टिप्पणी",
"Copied to clipboard": "क्लिपबोर्ड पर कॉपी किया गया",
"Export Annotations": "टिप्पणियाँ निर्यात करें",
"Auto Import on File Open": "फ़ाइल खोलने पर स्वचालित आयात",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"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",
"Loading...": "Memuat...",
"Loc. {{currentPage}} / {{totalPage}}": "Lok. {{currentPage}} / {{totalPage}}",
"Logged in": "Sudah Masuk",
"Logged in as {{userDisplayName}}": "Masuk sebagai {{userDisplayName}}",
"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",
@@ -230,5 +226,39 @@
"Delete Permanently": "Hapus Secara Permanen",
"Free Tier": "Tingkat Gratis",
"Plus Tier": "Tingkat Plus",
"Pro Tier": "Tingkat Pro"
"Pro Tier": "Tingkat Pro",
"RTL Direction": "Arah RTL",
"Maximum Column Height": "Tinggi Kolom Maksimum",
"Maximum Column Width": "Lebar Kolom Maksimum",
"Continuous Scroll": "Gulir Terus",
"Fullscreen": "Layar Penuh",
"No supported files found. Supported formats: {{formats}}": "Tidak ada file yang didukung ditemukan. Format yang didukung: {{formats}}",
"Drop to Import Books": "Jatuhkan untuk Mengimpor Buku",
"Custom": "Kustom",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Seluruh dunia adalah panggung,\nDan semua pria dan wanita hanyalah pemain;\nMereka memiliki keluar dan masuk mereka,\nDan seorang pria dalam hidupnya memainkan banyak peran,\nTindakannya ada tujuh zaman.\n\n— William Shakespeare",
"Custom Theme": "Tema Kustom",
"Theme Name": "Nama Tema",
"Text Color": "Warna Teks",
"Background Color": "Warna Latar Belakang",
"Preview": "Pratinjau",
"Contrast": "Kontras",
"Sunset": "Senja",
"Double Border": "Garis Ganda",
"Border Color": "Warna Garis",
"Border Frame": "Bingkai Garis",
"Show Header": "Tampilkan Header",
"Show Footer": "Tampilkan Footer",
"Small": "Kecil",
"Large": "Besar",
"Auto": "Otomatis",
"Language": "Bahasa",
"No annotations to export": "Tidak ada anotasi yang diekspor",
"Author": "Penulis",
"Exported from Readest": "Diekspor dari Readest",
"Highlights & Annotations": "Sorotan & Anotasi",
"Note": "Catatan",
"Copied to clipboard": "Disalin ke papan klip",
"Export Annotations": "Ekspor Anotasi",
"Auto Import on File Open": "Impor Otomatis saat File Dibuka",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"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",
"Loading...": "Caricamento...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Accesso effettuato",
"Logged in as {{userDisplayName}}": "Accesso effettuato come {{userDisplayName}}",
"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",
@@ -230,5 +226,39 @@
"Delete Permanently": "Elimina definitivamente",
"Free Tier": "Piano gratuito",
"Plus Tier": "Piano Plus",
"Pro Tier": "Piano Pro"
"Pro Tier": "Piano Pro",
"RTL Direction": "Direzione RTL",
"Maximum Column Height": "Altezza massima colonna",
"Maximum Column Width": "Larghezza massima colonna",
"Continuous Scroll": "Scorrimento continuo",
"Fullscreen": "Schermo intero",
"No supported files found. Supported formats: {{formats}}": "Nessun file supportato trovato. Formati supportati: {{formats}}",
"Drop to Import Books": "Rilascia per importare libri",
"Custom": "Personalizzato",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Tutto il mondo è un palcoscenico,\nE tutti gli uomini e le donne sono solo attori;\nHanno le loro uscite e le loro entrate,\nE un uomo nella sua vita interpreta molte parti,\nI suoi atti sono sette età.\n\n— William Shakespeare",
"Custom Theme": "Tema personalizzato",
"Theme Name": "Nome tema",
"Text Color": "Colore testo",
"Background Color": "Colore sfondo",
"Preview": "Anteprima",
"Contrast": "Contrasto",
"Sunset": "Tramonto",
"Double Border": "Doppio bordo",
"Border Color": "Colore bordo",
"Border Frame": "Cornice bordo",
"Show Header": "Mostra intestazione",
"Show Footer": "Mostra piè di pagina",
"Small": "Piccolo",
"Large": "Grande",
"Auto": "Automatico",
"Language": "Lingua",
"No annotations to export": "Nessuna annotazione da esportare",
"Author": "Autore",
"Exported from Readest": "Esportato da Readest",
"Highlights & Annotations": "Evidenziazioni e annotazioni",
"Note": "Nota",
"Copied to clipboard": "Copiato negli appunti",
"Export Annotations": "Esporta annotazioni",
"Auto Import on File Open": "Importazione automatica all'apertura del file",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"Hyphenation": "ハイフネーション",
"Identifier:": "識別子:",
"Import Books": "書籍をインポート",
"Invert Colors in Dark Mode": "ダークモードで色を反転",
"Language:": "言語:",
"Layout": "レイアウト",
"Light Mode": "ライトモード",
"Loading...": "読み込み中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "ログイン済み",
"Logged in as {{userDisplayName}}": "{{userDisplayName}}としてログイン中",
"Match Case": "大文字・小文字を区別",
"Match Diacritics": "アクセント記号を区別",
"Match Whole Words": "単語全体を一致",
"Maximum Block Size": "最大ブロックサイズ",
"Maximum Inline Size": "最大インラインサイズ",
"Maximum Number of Columns": "最大列数",
"Minimum Font Size": "最小フォントサイズ",
"Misc": "その他",
@@ -230,5 +226,39 @@
"Delete Permanently": "完全に削除",
"Free Tier": "無料プラン",
"Plus Tier": "Plusプラン",
"Pro Tier": "Proプラン"
"Pro Tier": "Proプラン",
"RTL Direction": "RTL方向",
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列幅",
"Continuous Scroll": "連続スクロール",
"Fullscreen": "全画面",
"No supported files found. Supported formats: {{formats}}": "サポートされているファイルが見つかりません。サポートされている形式:{{formats}}",
"Drop to Import Books": "書籍をインポートするにはドロップ",
"Custom": "カスタム",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "全世界は舞台であり、\nそしてすべての男性と女性は単なる役者です。\n彼らは出入り口を持っており、\nそして一人の男性は時に多くの役を演じます。\n彼の行為は七つの時代です。\n\n— ウィリアム・シェイクスピア",
"Custom Theme": "カスタムテーマ",
"Theme Name": "テーマ名",
"Text Color": "テキストカラー",
"Background Color": "背景色",
"Preview": "プレビュー",
"Contrast": "コントラスト",
"Sunset": "サンセット",
"Double Border": "二重枠",
"Border Color": "ボーダーカラー",
"Border Frame": "ボーダーフレーム",
"Show Header": "ヘッダーを表示",
"Show Footer": "フッターを表示",
"Small": "小",
"Large": "大",
"Auto": "自動",
"Language": "言語",
"No annotations to export": "エクスポートする注釈はありません",
"Author": "著者",
"Exported from Readest": "Readestからエクスポート",
"Highlights & Annotations": "ハイライトと注釈",
"Note": "メモ",
"Copied to clipboard": "クリップボードにコピー",
"Export Annotations": "注釈をエクスポート",
"Auto Import on File Open": "ファイルを開くと自動インポート",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"Hyphenation": "하이픈 넣기",
"Identifier:": "식별자:",
"Import Books": "책 가져오기",
"Invert Colors in Dark Mode": "다크 모드에서 색상 반전",
"Language:": "언어:",
"Layout": "레이아웃",
"Light Mode": "라이트 모드",
"Loading...": "로딩 중...",
"Loc. {{currentPage}} / {{totalPage}}": "위치 {{currentPage}} / {{totalPage}}",
"Logged in": "로그인됨",
"Logged in as {{userDisplayName}}": "{{userDisplayName}}(으)로 로그인됨",
"Match Case": "대소문자 구분",
"Match Diacritics": "발음 구별 부호 구분",
"Match Whole Words": "전체 단어 일치",
"Maximum Block Size": "최대 블록 크기",
"Maximum Inline Size": "최대 인라인 크기",
"Maximum Number of Columns": "최대 열 수",
"Minimum Font Size": "최소 글꼴 크기",
"Misc": "기타",
@@ -230,5 +226,39 @@
"Delete Permanently": "영구 삭제",
"Free Tier": "무료 등급",
"Plus Tier": "플러스 등급",
"Pro Tier": "프로 등급"
"Pro Tier": "프로 등급",
"RTL Direction": "RTL 방향",
"Maximum Column Height": "최대 열 높이",
"Maximum Column Width": "최대 열 너비",
"Continuous Scroll": "연속 스크롤",
"Fullscreen": "전체 화면",
"No supported files found. Supported formats: {{formats}}": "지원되는 파일이 없습니다. 지원되는 형식: {{formats}}",
"Drop to Import Books": "책 가져오기 위해 드롭",
"Custom": "사용자 정의",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "세상은 무대이며,\n그리고 모든 남자와 여자는 단지 배우일 뿐입니다;\n그들은 나가는 곳과 들어오는 곳이 있으며,\n그리고 한 남자는 그의 시간에 많은 역할을 합니다,\n그의 행동은 일곱 시대입니다.\n\n— 윌리엄 셰익스피어",
"Custom Theme": "사용자 정의 테마",
"Theme Name": "테마 이름",
"Text Color": "텍스트 색상",
"Background Color": "배경 색상",
"Preview": "미리보기",
"Contrast": "대비",
"Sunset": "일몰",
"Double Border": "이중 테두리",
"Border Color": "테두리 색상",
"Border Frame": "테두리 프레임",
"Show Header": "헤더 표시",
"Show Footer": "푸터 표시",
"Small": "작음",
"Large": "큼",
"Auto": "자동",
"Language": "언어",
"No annotations to export": "내보낼 주석이 없습니다",
"Author": "저자",
"Exported from Readest": "Readest에서 내보냄",
"Highlights & Annotations": "하이라이트 및 주석",
"Note": "노트",
"Copied to clipboard": "클립보드에 복사",
"Export Annotations": "주석 내보내기",
"Auto Import on File Open": "파일 열 때 자동 가져오기",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Dzielenie wyrazów",
"Identifier:": "Identyfikator:",
"Import Books": "Importuj książki",
"Invert Colors in Dark Mode": "Odwróć kolory w trybie ciemnym",
"Language:": "Język:",
"Layout": "Układ",
"Light Mode": "Tryb jasny",
"Loading...": "Ładowanie...",
"Loc. {{currentPage}} / {{totalPage}}": "Poz. {{currentPage}} / {{totalPage}}",
"Logged in": "Zalogowano",
"Logged in as {{userDisplayName}}": "Zalogowano jako {{userDisplayName}}",
"Match Case": "Uwzględnij wielkość liter",
"Match Diacritics": "Uwzględnij znaki diakrytyczne",
"Match Whole Words": "Uwzględnij całe słowa",
"Maximum Block Size": "Maksymalny rozmiar bloku",
"Maximum Inline Size": "Maksymalny rozmiar linii",
"Maximum Number of Columns": "Maksymalna liczba kolumn",
"Minimum Font Size": "Minimalny rozmiar czcionki",
"Misc": "Różne",
@@ -230,5 +226,39 @@
"Delete Permanently": "Usuń trwale",
"Free Tier": "Plan darmowy",
"Plus Tier": "Plan Plus",
"Pro Tier": "Plan Pro"
"Pro Tier": "Plan Pro",
"RTL Direction": "Kierunek RTL",
"Maximum Column Height": "Maksymalna wysokość kolumny",
"Maximum Column Width": "Maksymalna szerokość kolumny",
"Continuous Scroll": "Przewijanie ciągłe",
"Fullscreen": "Pełny ekran",
"No supported files found. Supported formats: {{formats}}": "Nie znaleziono obsługiwanych plików. Obsługiwane formaty: {{formats}}",
"Drop to Import Books": "Upuść, aby zaimportować książki",
"Custom": "Niestandardowy",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Cały świat jest sceną,\nA wszyscy mężczyźni i kobiety są tylko aktorami;\nMają swoje wyjścia i wejścia,\nI jeden człowiek w swoim czasie gra wiele ról,\nJego czyny to siedem wieków.\n\n— William Shakespeare",
"Custom Theme": "Niestandardowy motyw",
"Theme Name": "Nazwa motywu",
"Text Color": "Kolor tekstu",
"Background Color": "Kolor tła",
"Preview": "Podgląd",
"Contrast": "Kontrast",
"Sunset": "Zachód słońca",
"Double Border": "Podwójna ramka",
"Border Color": "Kolor ramki",
"Border Frame": "Ramka",
"Show Header": "Pokaż nagłówek",
"Show Footer": "Pokaż stopkę",
"Small": "Mały",
"Large": "Duży",
"Auto": "Automatyczny",
"Language": "Język",
"No annotations to export": "Brak adnotacji do eksportu",
"Author": "Autor",
"Exported from Readest": "Eksportowane z Readest",
"Highlights & Annotations": "Zaznaczenia i adnotacje",
"Note": "Notatka",
"Copied to clipboard": "Skopiowano do schowka",
"Export Annotations": "Eksportuj adnotacje",
"Auto Import on File Open": "Automatyczne importowanie przy otwieraniu pliku",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"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",
"Loading...": "Carregando...",
"Loc. {{currentPage}} / {{totalPage}}": "Loc. {{currentPage}} / {{totalPage}}",
"Logged in": "Conectado",
"Logged in as {{userDisplayName}}": "Conectado como {{userDisplayName}}",
"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",
@@ -230,5 +226,39 @@
"Delete Permanently": "Excluir permanentemente",
"Free Tier": "Plano gratuito",
"Plus Tier": "Plano Plus",
"Pro Tier": "Plano Pro"
"Pro Tier": "Plano Pro",
"RTL Direction": "Direção RTL",
"Maximum Column Height": "Altura Máxima da Coluna",
"Maximum Column Width": "Largura Máxima da Coluna",
"Continuous Scroll": "Rolagem Contínua",
"Fullscreen": "Tela Cheia",
"No supported files found. Supported formats: {{formats}}": "Nenhum arquivo suportado encontrado. Formatos suportados: {{formats}}",
"Drop to Import Books": "Arraste para Importar Livros",
"Custom": "Personalizado",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Todo o mundo é um palco,\nE todos os homens e mulheres são meros atores;\nEles têm suas saídas e suas entradas,\nE um homem em seu tempo desempenha muitos papéis,\nSeus atos sendo sete idades.\n\n— William Shakespeare",
"Custom Theme": "Tema Personalizado",
"Theme Name": "Nome do Tema",
"Text Color": "Cor do Texto",
"Background Color": "Cor de Fundo",
"Preview": "Visualizar",
"Contrast": "Contraste",
"Sunset": "Pôr do Sol",
"Double Border": "Borda Dupla",
"Border Color": "Cor da Borda",
"Border Frame": "Moldura da Borda",
"Show Header": "Mostrar Cabeçalho",
"Show Footer": "Mostrar Rodapé",
"Small": "Pequeno",
"Large": "Grande",
"Auto": "Automático",
"Language": "Idioma",
"No annotations to export": "Nenhuma anotação para exportar",
"Author": "Autor",
"Exported from Readest": "Exportado do Readest",
"Highlights & Annotations": "Destaques e Anotações",
"Note": "Nota",
"Copied to clipboard": "Copiado para a área de transferência",
"Export Annotations": "Exportar Anotações",
"Auto Import on File Open": "Importação Automática ao Abrir Arquivo",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Перенос слов",
"Identifier:": "Идентификатор:",
"Import Books": "Импорт книг",
"Invert Colors in Dark Mode": "Инвертировать цвета в тёмной теме",
"Language:": "Язык:",
"Layout": "Макет",
"Light Mode": "Светлая тема",
"Loading...": "Загрузка...",
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Выполнен вход",
"Logged in as {{userDisplayName}}": "Вход выполнен как {{userDisplayName}}",
"Match Case": "Учитывать регистр",
"Match Diacritics": "Учитывать диакритические знаки",
"Match Whole Words": "Только целые слова",
"Maximum Block Size": "Максимальный размер блока",
"Maximum Inline Size": "Максимальный размер строки",
"Maximum Number of Columns": "Максимальное количество колонок",
"Minimum Font Size": "Минимальный размер шрифта",
"Misc": "Разное",
@@ -230,5 +226,39 @@
"Delete Permanently": "Удалить навсегда",
"Free Tier": "Бесплатный тариф",
"Plus Tier": "Тариф Plus",
"Pro Tier": "Тариф Pro"
"Pro Tier": "Тариф Pro",
"RTL Direction": "Направление RTL",
"Maximum Column Height": "Максимальная высота колонки",
"Maximum Column Width": "Максимальная ширина колонки",
"Continuous Scroll": "Непрерывная прокрутка",
"Fullscreen": "Полноэкранный режим",
"No supported files found. Supported formats: {{formats}}": "Поддерживаемые файлы не найдены. Поддерживаемые форматы: {{formats}}",
"Drop to Import Books": "Перетащите сюда книги для импорта",
"Custom": "Пользовательский",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Весь мир — театр,\nИ все мужчины и женщины — просто актёры;\nУ них есть свои выходы и входы,\nИ один человек в своё время играет много ролей,\nЕго поступки — это семь веков.\n\n— Уильям Шекспир",
"Custom Theme": "Пользовательская тема",
"Theme Name": "Название темы",
"Text Color": "Цвет текста",
"Background Color": "Цвет фона",
"Preview": "Предпросмотр",
"Contrast": "Контраст",
"Sunset": "Закат",
"Double Border": "Двойная граница",
"Border Color": "Цвет границы",
"Border Frame": "Граница рамки",
"Show Header": "Показать заголовок",
"Show Footer": "Показать нижний колонтитул",
"Small": "Маленький",
"Large": "Большой",
"Auto": "Авто",
"Language": "Язык",
"No annotations to export": "Нет аннотаций для экспорта",
"Author": "Автор",
"Exported from Readest": "Экспортировано из Readest",
"Highlights & Annotations": "Выделения и аннотации",
"Note": "Заметка",
"Copied to clipboard": "Скопировано в буфер обмена",
"Export Annotations": "Экспортировать аннотации",
"Auto Import on File Open": "Автоматический импорт при открытии файла",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"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",
"Loading...": "Yükleniyor...",
"Loc. {{currentPage}} / {{totalPage}}": "Konum {{currentPage}} / {{totalPage}}",
"Logged in": "Giriş Yapıldı",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} olarak giriş yapıldı",
"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",
@@ -230,5 +226,39 @@
"Delete Permanently": "Kalıcı Olarak Sil",
"Free Tier": "Ücretsiz Paket",
"Plus Tier": "Plus Paket",
"Pro Tier": "Pro Paket"
"Pro Tier": "Pro Paket",
"RTL Direction": "RTL Yönü",
"Maximum Column Height": "Maksimum Sütun Yüksekliği",
"Maximum Column Width": "Maksimum Sütun Genişliği",
"Continuous Scroll": "Sürekli Kaydırma",
"Fullscreen": "Tam Ekran",
"No supported files found. Supported formats: {{formats}}": "Desteklenen dosya bulunamadı. Desteklenen formatlar: {{formats}}",
"Drop to Import Books": "Kitapları İçe Aktarmak İçin Bırak",
"Custom": "Özel",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Tüm dünya bir sahnedir,\nVe tüm erkekler ve kadınlar sadece oyunculardır;\nOnların çıkışları ve girişleri vardır,\nVe bir adam zamanında birçok rol oynar,\nOnun eylemleri yedi yaşındadır.\n\n— William Shakespeare",
"Custom Theme": "Özel Tema",
"Theme Name": "Tema Adı",
"Text Color": "Metin Rengi",
"Background Color": "Arka Plan Rengi",
"Preview": "Önizleme",
"Contrast": "Kontrast",
"Sunset": "Gün Batımı",
"Double Border": "Çift Sınır",
"Border Color": "Sınır Rengi",
"Border Frame": "Sınır Çerçevesi",
"Show Header": "Başlık Göster",
"Show Footer": "Altbilgi Göster",
"Small": "Küçük",
"Large": "Büyük",
"Auto": "Otomatik",
"Language": "Dil",
"No annotations to export": "Dışa aktarılacak not yok",
"Author": "Yazar",
"Exported from Readest": "Readest'ten dışa aktarıldı",
"Highlights & Annotations": "Vurgular ve Notlar",
"Note": "Not",
"Copied to clipboard": "Panoya kopyalandı",
"Export Annotations": "Notları Dışa Aktar",
"Auto Import on File Open": "Dosya Açıldığında Otomatik İçe Aktar",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"Hyphenation": "Перенесення слів",
"Identifier:": "Ідентифікатор:",
"Import Books": "Імпортувати книги",
"Invert Colors in Dark Mode": "Інвертувати кольори в темному режимі",
"Language:": "Мова:",
"Layout": "Макет",
"Light Mode": "Світлий режим",
"Loading...": "Завантаження...",
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Увійшли",
"Logged in as {{userDisplayName}}": "Увійшли як {{userDisplayName}}",
"Match Case": "Враховувати регістр",
"Match Diacritics": "Враховувати діакритичні знаки",
"Match Whole Words": "Шукати цілі слова",
"Maximum Block Size": "Максимальний розмір блоку",
"Maximum Inline Size": "Максимальний розмір рядка",
"Maximum Number of Columns": "Максимальна кількість колонок",
"Minimum Font Size": "Мінімальний розмір шрифту",
"Misc": "Різне",
@@ -230,5 +226,39 @@
"Delete Permanently": "Видалити назавжди",
"Free Tier": "Безкоштовний тариф",
"Plus Tier": "Тариф Plus",
"Pro Tier": "Тариф Pro"
"Pro Tier": "Тариф Pro",
"RTL Direction": "Напрямок RTL",
"Maximum Column Height": "Максимальна висота колонки",
"Maximum Column Width": "Максимальна ширина колонки",
"Continuous Scroll": "Постійна прокрутка",
"Fullscreen": "Повноекранний",
"No supported files found. Supported formats: {{formats}}": "Не знайдено підтримуваних файлів. Підтримувані формати: {{formats}}",
"Drop to Import Books": "Перетягніть для імпорту книг",
"Custom": "Користувацький",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Весь світ — театр,\nІ всі чоловіки та жінки — просто актори;\nУ них є свої виходи та входи,\nІ один чоловік у свій час грає багато ролей,\nЙого вчинки — це сім віков.\n\n— Вільям Шекспір",
"Custom Theme": "Користувацька тема",
"Theme Name": "Назва теми",
"Text Color": "Колір тексту",
"Background Color": "Колір фону",
"Preview": "Попередній перегляд",
"Contrast": "Контраст",
"Sunset": "Захід сонця",
"Double Border": "Подвійна рамка",
"Border Color": "Колір рамки",
"Border Frame": "Рамка рамки",
"Show Header": "Показати заголовок",
"Show Footer": "Показати нижню частину",
"Small": "Малий",
"Large": "Великий",
"Auto": "Автоматичний",
"Language": "Мова",
"No annotations to export": "Немає анотацій для експорту",
"Author": "Автор",
"Exported from Readest": "Експортовано з Readest",
"Highlights & Annotations": "Виділення та анотації",
"Note": "Примітка",
"Copied to clipboard": "Скопійовано в буфер обміну",
"Export Annotations": "Експортувати анотації",
"Auto Import on File Open": "Автоматичний імпорт при відкритті файлу",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"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",
"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}}",
"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",
@@ -230,5 +226,39 @@
"Delete Permanently": "Xóa vĩnh viễn",
"Free Tier": "Gói miễn phí",
"Plus Tier": "Gói Plus",
"Pro Tier": "Gói Pro"
"Pro Tier": "Gói Pro",
"RTL Direction": "Hướng RTL",
"Maximum Column Height": "Chiều cao cột tối đa",
"Maximum Column Width": "Chiều rộng cột tối đa",
"Continuous Scroll": "Cuộn liên tục",
"Fullscreen": "Toàn màn hình",
"No supported files found. Supported formats: {{formats}}": "Không tìm thấy tệp được hỗ trợ. Định dạng được hỗ trợ: {{formats}}",
"Drop to Import Books": "Kéo và thả để nhập sách",
"Custom": "Tùy chỉnh",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Cả thế giới này là một sân khấu,\nVà tất cả đàn ông và phụ nữ chỉ là những diễn viên;\nHọ có cửa ra và cửa vào của mình,\nVà một người trong cuộc đời mình đóng nhiều vai,\nHành động của mình là bảy tuổi.\n\n— William Shakespeare",
"Custom Theme": "Chủ đề tùy chỉnh",
"Theme Name": "Tên chủ đề",
"Text Color": "Màu văn bản",
"Background Color": "Màu nền",
"Preview": "Xem trước",
"Contrast": "Tương phản",
"Sunset": "Hoàng hôn",
"Double Border": "Đôi viền",
"Border Color": "Màu viền",
"Border Frame": "Khung viền",
"Show Header": "Hiển thị tiêu đề",
"Show Footer": "Hiển thị chân trang",
"Small": "Nhỏ",
"Large": "Lớn",
"Auto": "Tự động",
"Language": "Ngôn ngữ",
"No annotations to export": "Không có chú thích để xuất",
"Author": "Tác giả",
"Exported from Readest": "Đã xuất từ Readest",
"Highlights & Annotations": "Điểm nổi bật & Chú thích",
"Note": "Ghi chú",
"Copied to clipboard": "Đã sao chép vào bảng ghi",
"Export Annotations": "Xuất chú thích",
"Auto Import on File Open": "Tự động nhập khi mở tệp",
"LXGW WenKai GB Screen": "LXGW WenKai"
}
@@ -53,19 +53,15 @@
"Hyphenation": "断字",
"Identifier:": "识别码",
"Import Books": "导入书籍",
"Invert Colors in Dark Mode": "深色主题下反色",
"Language:": "语言",
"Layout": "布局",
"Light Mode": "浅色主题",
"Loading...": "加载中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登录",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登录",
"Match Case": "匹配大小写",
"Match Diacritics": "匹配重音符号",
"Match Whole Words": "匹配整个单词",
"Maximum Block Size": "内容最大高度",
"Maximum Inline Size": "每栏最大宽度",
"Maximum Number of Columns": "分栏数",
"Minimum Font Size": "最小字号",
"Misc": "杂项",
@@ -230,5 +226,39 @@
"Delete Permanently": "永久删除",
"Free Tier": "免费版",
"Plus Tier": "增强版",
"Pro Tier": "专业版"
"Pro Tier": "专业版",
"RTL Direction": "从右向左",
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列宽",
"Continuous Scroll": "连续滚动",
"Fullscreen": "全屏",
"No supported files found. Supported formats: {{formats}}": "未找到支持的文件。支持的格式:{{formats}}",
"Drop to Import Books": "拖放导入书籍",
"Custom": "自定义",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "世界是座舞台, \n所有男女都只是演员; \n各有其出场和入场; \n每个人皆扮演许多角色, \n而演出共分为七个阶段。\n\n——威廉·莎士比亚",
"Custom Theme": "自定义主题",
"Theme Name": "主题名称",
"Text Color": "文本颜色",
"Background Color": "背景颜色",
"Preview": "预览",
"Contrast": "对比",
"Sunset": "日落",
"Double Border": "显示版框",
"Border Color": "版框颜色",
"Border Frame": "页面版框",
"Show Header": "显示页眉",
"Show Footer": "显示页脚",
"Small": "小",
"Large": "大",
"Auto": "自动",
"Language": "语言",
"No annotations to export": "没有要导出的笔记",
"Author": "作者",
"Exported from Readest": "从 Readest 导出",
"Highlights & Annotations": "划线和笔记",
"Note": "笔记",
"Copied to clipboard": "已复制到剪贴板",
"Export Annotations": "导出笔记",
"Auto Import on File Open": "打开文件时自动导入",
"LXGW WenKai GB Screen": "霞鹜文楷"
}
@@ -53,19 +53,15 @@
"Hyphenation": "斷字",
"Identifier:": "識別碼",
"Import Books": "導入書籍",
"Invert Colors in Dark Mode": "深色主題下反色",
"Language:": "語言",
"Layout": "版面",
"Light Mode": "淺色主題",
"Loading...": "載入中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登入",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登入",
"Match Case": "匹配大小寫",
"Match Diacritics": "匹配重音符號",
"Match Whole Words": "匹配整個單詞",
"Maximum Block Size": "內容最大高度",
"Maximum Inline Size": "每欄最大寬度",
"Maximum Number of Columns": "分欄數",
"Minimum Font Size": "最小字號",
"Misc": "雜項",
@@ -230,5 +226,39 @@
"Delete Permanently": "永久刪除",
"Free Tier": "免費方案",
"Plus Tier": "Plus方案",
"Pro Tier": "專業方案"
"Pro Tier": "專業方案",
"RTL Direction": "從右向左",
"Maximum Column Height": "最大列高",
"Maximum Column Width": "最大列寬",
"Continuous Scroll": "連續滾動",
"Fullscreen": "全螢幕",
"No supported files found. Supported formats: {{formats}}": "未找到支援的檔案。支援的格式:{{formats}}",
"Drop to Import Books": "拖放導入書籍",
"Custom": "自定義",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "世界是一個舞台,\n所有的男人和女人都只是演員:\n他們有出口,也有入口;\n一個人在他的時代扮演許多角​​色。 \n\n——威廉·莎士比亞",
"Custom Theme": "自定義主題",
"Theme Name": "主題名稱",
"Text Color": "文字顏色",
"Background Color": "背景顏色",
"Preview": "預覽",
"Contrast": "對比",
"Sunset": "日落",
"Double Border": "顯示邊框",
"Border Color": "邊框顏色",
"Border Frame": "頁面邊框",
"Show Header": "顯示頁眉",
"Show Footer": "顯示頁腳",
"Small": "小",
"Large": "大",
"Auto": "自動",
"Language": "語言",
"No annotations to export": "沒有要匯出的筆記",
"Author": "作者",
"Exported from Readest": "從 Readest 匯出",
"Highlights & Annotations": "高亮和筆記",
"Note": "筆記",
"Copied to clipboard": "已複製到剪貼板",
"Export Annotations": "匯出筆記",
"Auto Import on File Open": "打開文件時自動導入",
"LXGW WenKai GB Screen": "霞鶩文楷"
}
+56
View File
@@ -1,5 +1,61 @@
{
"releases": {
"0.9.26": {
"date": "2025-03-27",
"notes": [
"Support open book from file manager without importing",
"Add LXGW WenKai font in Serif fonts list",
"Various fixes on layout and styles on Windows and Android"
]
},
"0.9.25": {
"date": "2025-03-24",
"notes": [
"Support exporting annotations in markdown format",
"Support importing TXT files",
"Support setting reader language in the UI",
"Add bottom action bar on mobile platforms",
"Various fixes on layout settings and styles"
]
},
"0.9.23": {
"date": "2025-03-19",
"notes": [
"Add options to show/hide header and footer",
"Add responsive window size for popup footnotes",
"Fix theme color not applied on Safari browsers",
"Fix progress not updated to 100% when finished reading"
]
},
"0.9.22": {
"date": "2025-03-16",
"notes": [
"Add theme color editor for more customization",
"Add border frame for vertical layout",
"Fix layout glitches on macOS in scroll mode",
"Various fixes and enhancements on translator, footnote, and scrollbar"
]
},
"0.9.21": {
"date": "2025-03-10",
"notes": [
"Fix column height in vertical layout on mobile",
"Fix drag handle height not constant on mobile",
"Add fullscreen option on desktop",
"Add drag and drop to import books on desktop",
"Various fixes and enhancements on updater, footerbar and note"
]
},
"0.9.20": {
"date": "2025-03-10",
"notes": [
"Fix column height in vertical layout on mobile",
"Fix drag handle height not constant on mobile",
"Add fullscreen option on desktop",
"Add drag and drop to import books on desktop",
"Various fixes and enhancements on updater, footerbar and note"
]
},
"0.9.19": {
"date": "2025-03-07",
"notes": [
@@ -62,6 +62,7 @@
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:window:allow-set-fullscreen",
"shell:default",
"process:default",
"process:allow-exit",
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:largeHeap="true"
android:label="@string/app_name"
android:theme="@style/Theme.readest"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="web.readest.com" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -49,7 +49,7 @@
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
@@ -111,7 +111,7 @@
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
+16 -2
View File
@@ -63,6 +63,15 @@ fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
}
}
#[cfg(desktop)]
fn set_rounded_window(app: &AppHandle, rounded: bool) {
let window = app.get_webview_window("main").unwrap();
let script = format!("window.IS_ROUNDED = {};", rounded);
if let Err(e) = window.eval(&script) {
eprintln!("Failed to set IS_ROUNDED variable: {}", e);
}
}
#[command]
async fn start_server(window: Window) -> Result<u16, String> {
start(move |url| {
@@ -185,6 +194,12 @@ pub fn run() {
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");
set_rounded_window(&app_handle, true);
#[cfg(target_os = "windows")]
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
set_rounded_window(&app_handle, false);
}
});
}
@@ -225,8 +240,7 @@ pub fn run() {
.title("Readest");
if cfg!(target_os = "windows") {
let version = tauri_plugin_os::version();
if version <= tauri_plugin_os::Version::from_string("10.0.19045") {
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
win_builder = win_builder.shadow(false);
} else {
win_builder = win_builder.shadow(true);
+27 -14
View File
@@ -12,8 +12,8 @@ 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 { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { isTauriAppPlatform } from '@/services/environment';
@@ -23,6 +23,7 @@ import { openUrl } from '@tauri-apps/plugin-opener';
import { handleAuthCallback } from '@/helpers/auth';
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
import { authWithSafari } from './utils/safariAuth';
import WindowButtons from '@/components/WindowButtons';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
@@ -47,11 +48,11 @@ const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Ic
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',
'bg-base-100 border-base-300 hover:bg-base-200 shadow-sm transition',
)}
>
<Icon />
<span className='text-neutral-content/70 px-2 text-sm'>{label}</span>
<span className='text-base-content/75 px-2 text-sm'>{label}</span>
</button>
);
};
@@ -61,12 +62,14 @@ export default function AuthPage() {
const router = useRouter();
const { login } = useAuth();
const { envConfig, appService } = useEnv();
const { isDarkMode } = useTheme();
const { isDarkMode } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [port, setPort] = useState<number | null>(null);
const isOAuthServerRunning = useRef(false);
const [isMounted, setIsMounted] = useState(false);
const headerRef = useRef<HTMLDivElement>(null);
const getTauriRedirectTo = (isOAuth: boolean) => {
if (process.env.NODE_ENV === 'production' || appService?.isMobile) {
if (appService?.isIOSApp) {
@@ -298,21 +301,31 @@ export default function AuthPage() {
return isTauriAppPlatform() ? (
<div
className={clsx(
'mt-6 flex',
'bg-base-100 border-base-200 flex h-dvh w-full select-none flex-col items-center border',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
appService?.hasTrafficLight && 'pt-11',
)}
>
<button
onClick={handleGoBack}
<div
ref={headerRef}
className={clsx(
'btn btn-ghost fixed left-4 h-8 min-h-8 w-8 p-0',
appService?.hasSafeAreaInset && 'top-[calc(env(safe-area-inset-top)+16px)]',
appService?.hasTrafficLight && 'top-11',
'flex w-full items-center justify-between py-2 pe-6 ps-4',
appService?.hasTrafficLight && 'pt-11',
)}
>
<IoArrowBack className='text-base-content' />
</button>
<button onClick={handleGoBack} className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0')}>
<IoArrowBack className='text-base-content' />
</button>
{appService?.hasWindowBar && (
<WindowButtons
headerRef={headerRef}
showMinimize={appService?.hasWindowBar}
showMaximize={appService?.hasWindowBar}
showClose={appService?.hasWindowBar}
onClose={handleGoBack}
/>
)}
</div>
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
<ProviderLogin
provider='google'
@@ -332,7 +345,7 @@ export default function AuthPage() {
Icon={FaGithub}
label={_('Sign in with GitHub')}
/>
<hr className='my-3 mt-6 w-64 border-t border-gray-200' />
<hr className='border-base-300 my-3 mt-6 w-64 border-t' />
<Auth
supabaseClient={supabase}
appearance={{ theme: ThemeSupa }}
@@ -21,9 +21,9 @@ interface BookshelfProps {
libraryBooks: Book[];
isSelectMode: boolean;
handleImportBooks: () => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDelete: (book: Book) => void;
handleBookUpload: (book: Book) => Promise<boolean>;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDelete: (book: Book) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
booksTransferProgress: { [key: string]: number | null };
@@ -72,9 +72,9 @@ interface BookshelfItemProps {
transferProgress: number | null;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDelete: (book: Book) => void;
handleBookUpload: (book: Book) => Promise<boolean>;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDelete: (book: Book) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
}
@@ -109,9 +109,8 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
let available = false;
const loadingTimeout = setTimeout(() => setLoading(true), 200);
try {
await handleBookDownload(book);
available = await handleBookDownload(book);
updateBook(envConfig, book);
available = true;
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
@@ -37,6 +37,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
isTrafficLightVisible,
initializeTrafficLightStore,
initializeTrafficLightListeners,
setTrafficLightVisibility,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
@@ -53,6 +54,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
setTrafficLightVisibility(true);
return () => {
cleanupTrafficLightListeners();
};
@@ -66,7 +68,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar h-13 z-10 flex w-full items-center py-2 pr-4 sm:pr-6',
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:pr-6',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
@@ -13,7 +13,8 @@ const getProgressPercentage = (book: Book) => {
if (book.progress && book.progress[1] === 1) {
return 100;
}
return Math.round((book.progress[0] / book.progress[1]) * 100);
const percentage = Math.round((book.progress[0] / book.progress[1]) * 100);
return Math.max(0, Math.min(100, percentage));
};
const ReadingProgress: React.FC<ReadingProgressProps> = memo(
@@ -6,7 +6,7 @@ import { PiUserCircleCheck } from 'react-icons/pi';
import { MdCheck } from 'react-icons/md';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { hasUpdater, isWebAppPlatform } from '@/services/environment';
import { hasUpdater, isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
@@ -14,6 +14,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStoragePlanData } from '@/utils/access';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleToggleFullScreen } from '@/utils/window';
import { QuotaType } from '@/types/user';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
@@ -25,13 +26,16 @@ interface BookMenuProps {
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { token, user } = useAuth();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
settings.autoImportBooksOnOpen,
);
const showAboutReadest = () => {
setAboutDialogVisible(true);
@@ -57,6 +61,11 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const handleFullScreen = () => {
tauriHandleToggleFullScreen();
setIsDropdownOpen?.(false);
};
const toggleAutoUploadBooks = () => {
settings.autoUpload = !settings.autoUpload;
setSettings(settings);
@@ -68,6 +77,13 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
}
};
const toggleAutoImportBooksOnOpen = () => {
settings.autoImportBooksOnOpen = !settings.autoImportBooksOnOpen;
setSettings(settings);
saveSettings(envConfig, settings);
setIsAutoImportBooksOnOpen(settings.autoImportBooksOnOpen);
};
const toggleAutoCheckUpdates = () => {
settings.autoCheckUpdates = !settings.autoCheckUpdates;
setSettings(settings);
@@ -144,6 +160,13 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
icon={isAutoUpload ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleAutoUploadBooks}
/>
{isTauriAppPlatform() && !appService?.isMobile && (
<MenuItem
label={_('Auto Import on File Open')}
icon={isAutoImportBooksOnOpen ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleAutoImportBooksOnOpen}
/>
)}
{hasUpdater() && (
<MenuItem
label={_('Check Updates on Start')}
@@ -157,6 +180,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleScreenWakeLock}
/>
<hr className='border-base-200 my-1' />
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
<hr className='border-base-200 my-1' />
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
@@ -85,9 +85,8 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
}
const mergedBook =
matchingBook.updatedAt > oldBook.updatedAt
? { ...oldBook, ...matchingBook, updatedAt: oldBook.updatedAt }
: { ...matchingBook, ...oldBook, updatedAt: oldBook.updatedAt };
mergedBook.progress = matchingBook.progress ?? oldBook.progress;
? { ...oldBook, ...matchingBook }
: { ...matchingBook, ...oldBook };
return mergedBook;
}
return oldBook;
+109 -9
View File
@@ -17,14 +17,16 @@ import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import { useTheme } from '@/hooks/useTheme';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
@@ -38,6 +40,7 @@ import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import BookDetailModal from '@/components/BookDetailModal';
import useShortcuts from '@/hooks/useShortcuts';
import DropIndicator from '@/components/DropIndicator';
const LibraryPageWithSearchParams = () => {
const searchParams = useSearchParams();
@@ -56,7 +59,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setCheckOpenWithBooks,
} = useLibraryStore();
const _ = useTranslation();
const { updateAppTheme } = useTheme();
useTheme();
const { updateAppTheme } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
@@ -66,8 +70,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const [booksTransferProgress, setBooksTransferProgress] = useState<{
[key: string]: number | null;
}>({});
const [isDragging, setIsDragging] = useState(false);
const demoBooks = useDemoBooks();
const containerRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
useOpenWithBooks();
@@ -102,13 +108,96 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings]);
const handleDropedFiles = async (files: File[] | string[]) => {
if (files.length === 0) return;
const supportedFiles = files.filter((file) => {
let fileExt;
if (typeof file === 'string') {
fileExt = file.split('.').pop()?.toLowerCase();
} else {
fileExt = file.name.split('.').pop()?.toLowerCase();
}
return FILE_ACCEPT_FORMATS.includes(`.${fileExt}`);
});
if (supportedFiles.length === 0) {
eventDispatcher.dispatch('toast', {
message: _('No supported files found. Supported formats: {{formats}}', {
formats: FILE_ACCEPT_FORMATS,
}),
type: 'error',
});
return;
}
if (appService?.hasHaptics) {
impactFeedback('medium');
}
await importBooks(supportedFiles);
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
const files = Array.from(event.dataTransfer.files);
handleDropedFiles(files);
}
};
useEffect(() => {
const libraryPage = document.querySelector('.library-page');
libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener);
if (isTauriAppPlatform()) {
const unlisten = getCurrentWebview().onDragDropEvent((event) => {
if (event.payload.type === 'over') {
setIsDragging(true);
} else if (event.payload.type === 'drop') {
setIsDragging(false);
handleDropedFiles(event.payload.paths);
} else {
setIsDragging(false);
}
});
return () => {
unlisten.then((fn) => fn());
};
}
return () => {
libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageRef.current]);
const processOpenWithFiles = React.useCallback(
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
const settings = await appService.loadSettings();
const bookIds: string[] = [];
for (const file of openWithFiles) {
console.log('Open with book:', file);
try {
const book = await appService.importBook(file, libraryBooks);
const temp = !settings.autoImportBooksOnOpen;
const book = await appService.importBook(file, libraryBooks, true, true, false, temp);
if (book) {
bookIds.push(book.hash);
}
@@ -204,7 +293,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [demoBooks, libraryLoaded]);
const importBooks = async (files: [string | File]) => {
const importBooks = async (files: (string | File)[]) => {
setLoading(true);
const failedFiles = [];
for (const file of files) {
@@ -273,17 +362,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
title: book.title,
}),
});
return true;
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
navigateToLogin(router);
return;
return false;
} else if (err.message.includes('Insufficient storage quota')) {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Insufficient storage quota'),
});
return;
return false;
}
}
eventDispatcher.dispatch('toast', {
@@ -292,6 +382,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
title: book.title,
}),
});
return false;
}
};
@@ -308,6 +399,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
@@ -315,6 +407,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}),
type: 'error',
});
return false;
}
};
@@ -330,6 +423,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to delete book: {{title}}', {
@@ -337,10 +431,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}),
type: 'error',
});
return false;
}
};
const handleImportBooks = async () => {
setIsSelectMode(false);
console.log('Importing books...');
let files;
@@ -390,6 +486,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
return (
<div
ref={pageRef}
className={clsx(
'library-page bg-base-200 text-base-content flex select-none flex-col overflow-hidden',
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
@@ -413,11 +510,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<div
ref={containerRef}
className={clsx(
'scroll-container mt-12 flex-grow overflow-auto px-4 sm:px-2',
appService?.hasSafeAreaInset && 'mt-[calc(48px+env(safe-area-inset-top))]',
'scroll-container drop-zone mt-[52px] flex-grow overflow-y-auto px-4 sm:px-2',
appService?.hasSafeAreaInset && 'mt-[calc(52px+env(safe-area-inset-top))]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
isDragging && 'drag-over',
)}
>
<DropIndicator />
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
@@ -431,7 +530,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
/>
</div>
) : (
<div className='hero h-screen items-center justify-center'>
<div className='hero drop-zone h-screen items-center justify-center'>
<DropIndicator />
<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>
@@ -17,6 +17,7 @@ import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
import FootnotePopup from './FootnotePopup';
import HintInfo from './HintInfo';
import DoubleBorder from './DoubleBorder';
interface BooksGridProps {
bookKeys: string[];
@@ -60,7 +61,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { section, pageinfo, sectionLabel } = progress || {};
const isBookmarked = getViewState(bookKey)?.ribbonVisible;
const marginGap = `${viewSettings.gapPercent}%`;
const horizontalGapPercent = viewSettings.gapPercent;
const verticalMarginPixels = viewSettings.marginPx;
return (
<div
@@ -68,7 +70,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
key={bookKey}
className={`${appService?.hasRoundedWindow ? 'rounded-window' : ''} relative h-full w-full overflow-hidden`}
>
{isBookmarked && <Ribbon width={marginGap} />}
{isBookmarked && <Ribbon width={`${horizontalGapPercent}%`} />}
<HeaderBar
bookKey={bookKey}
bookTitle={book.title}
@@ -79,18 +81,62 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
{viewSettings.scrolled ? null : (
{viewSettings.vertical && viewSettings.scrolled && (
<>
<SectionInfo section={sectionLabel} gapLeft={marginGap} />
<HintInfo bookKey={bookKey} gapRight={marginGap} />
<PageInfoView
bookFormat={book.format}
section={section}
pageinfo={pageinfo}
gapRight={marginGap}
<div
className='bg-base-100 absolute left-0 top-0 h-full'
style={{
width: `calc(${horizontalGapPercent}%)`,
height: `calc(100% - ${verticalMarginPixels}px)`,
}}
/>
<div
className='bg-base-100 absolute right-0 top-0 h-full'
style={{
width: `calc(${horizontalGapPercent}%)`,
height: `calc(100% - ${verticalMarginPixels}px)`,
}}
/>
</>
)}
{viewSettings.vertical && viewSettings.doubleBorder && (
<DoubleBorder
showHeader={viewSettings.showHeader}
showFooter={viewSettings.showFooter}
borderColor={viewSettings.borderColor}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
)}
{viewSettings.showHeader && (
<SectionInfo
section={sectionLabel}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isScrolled={viewSettings.scrolled}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
)}
<HintInfo
bookKey={bookKey}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
{viewSettings.showFooter && (
<PageInfoView
bookFormat={book.format}
section={section}
pageinfo={pageinfo}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isScrolled={viewSettings.scrolled}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
)}
<Annotator bookKey={bookKey} />
<FooterBar
bookKey={bookKey}
@@ -0,0 +1,76 @@
interface DoubleBorderProps {
borderColor: string;
horizontalGap: number;
verticalMargin: number;
showHeader: boolean;
showFooter: boolean;
}
const paddingPx = 10;
const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderColor,
horizontalGap,
verticalMargin,
showHeader,
showFooter,
}) => {
return (
<>
{/* outter frame */}
<div
className={'borderframe pointer-events-none absolute'}
style={{
border: `4px solid ${borderColor}`,
height: `calc(100% - ${verticalMargin * 2}px + ${paddingPx * 2}px)`,
top: `calc(${verticalMargin}px - ${paddingPx}px)`,
left: `calc(${horizontalGap}% - ${showFooter ? 32 : 0}px - ${paddingPx}px)`,
right: `calc(${horizontalGap}% - ${showHeader ? 32 : 0}px - ${paddingPx}px)`,
}}
></div>
{/* inner frame */}
<div
className={'borderframe pointer-events-none absolute'}
style={{
border: `1px solid ${borderColor}`,
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: showFooter ? `${horizontalGap}%` : `calc(${horizontalGap}%)`,
right: showHeader ? `${horizontalGap}%` : `calc(${horizontalGap}%)`,
}}
/>
{/* footer */}
{showFooter && (
<div
className={'borderframe pointer-events-none absolute'}
style={{
borderTop: `1px solid ${borderColor}`,
borderBottom: `1px solid ${borderColor}`,
borderLeft: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: `calc(${horizontalGap}% - 32px)`,
}}
/>
)}
{/* header */}
{showHeader && (
<div
className={'borderframe pointer-events-none absolute'}
style={{
borderTop: `1px solid ${borderColor}`,
borderBottom: `1px solid ${borderColor}`,
borderRight: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: `calc(100% - ${horizontalGap}%)`,
}}
/>
)}
</>
);
};
export default DoubleBorder;
@@ -2,16 +2,15 @@ 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 { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
import { getStyles, mountAdditionalFonts } from '@/utils/style';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useTheme } from '@/hooks/useTheme';
import { useUICSS } from '@/hooks/useUICSS';
import {
handleKeydown,
@@ -36,7 +35,7 @@ const FoliateViewer: React.FC<{
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { themeCode } = useTheme();
const { themeCode, isDarkMode } = useThemeStore();
const viewSettings = getViewSettings(bookKey)!;
const [toastMessage, setToastMessage] = useState('');
@@ -54,7 +53,6 @@ const FoliateViewer: React.FC<{
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
};
const { shouldAutoHideScrollbar, handleScrollbarAutoHide } = useAutoHideScrollbar();
const docLoadHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
console.log('doc loaded:', detail);
@@ -64,10 +62,7 @@ const FoliateViewer: React.FC<{
viewSettings.vertical =
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
viewSettings.rtl = writingDir?.rtl || viewSettings.writingMode.includes('rl') || false;
setViewSettings(bookKey, viewSettings);
if (viewSettings.scrolled && shouldAutoHideScrollbar) {
handleScrollbarAutoHide(detail.doc);
}
setViewSettings(bookKey, { ...viewSettings });
mountAdditionalFonts(detail.doc);
@@ -91,7 +86,8 @@ const FoliateViewer: React.FC<{
if (detail.reason === 'scroll') {
const renderer = viewRef.current?.renderer;
if (renderer) {
const viewSettings = getViewSettings(bookKey)!;
if (renderer && viewSettings.continuousScroll) {
if (renderer.start <= 0) {
viewRef.current?.prev(1);
// sometimes viewSize has subpixel value that the end never reaches
@@ -125,10 +121,10 @@ const FoliateViewer: React.FC<{
useEffect(() => {
if (viewRef.current && viewRef.current.renderer) {
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings, themeCode));
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode]);
}, [themeCode, isDarkMode]);
useEffect(() => {
if (isViewCreated.current) return;
@@ -158,7 +154,7 @@ const FoliateViewer: React.FC<{
viewRef.current = view;
setFoliateView(bookKey, view);
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view.renderer.setStyles?.(getStyles(viewSettings));
const isScrolled = viewSettings.scrolled!;
const marginPx = viewSettings.marginPx!;
@@ -3,14 +3,25 @@ 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 { IoIosList as TOCIcon } from 'react-icons/io';
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
import { RxSlider as SliderIcon } from 'react-icons/rx';
import { RiFontFamily as FontIcon } from 'react-icons/ri';
import { MdOutlineHeadphones as TTSIcon } from 'react-icons/md';
import { TbBoxMargin } from 'react-icons/tb';
import { RxLineHeight } from 'react-icons/rx';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import { PageInfo } from '@/types/book';
import { getStyles } from '@/utils/style';
import Button from '@/components/Button';
import Slider from '@/components/Slider';
interface FooterBarProps {
bookKey: string;
@@ -28,17 +39,44 @@ const FooterBar: React.FC<FooterBarProps> = ({
isHoveredAnim,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { envConfig, appService } = useEnv();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const [actionTab, setActionTab] = React.useState('');
const sliderHeight = useResponsiveSize(28);
const tocIconSize = useResponsiveSize(23);
const fontIconSize = useResponsiveSize(18);
const marginIconSize = useResponsiveSize(20);
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const handleProgressChange = (event: React.ChangeEvent) => {
const newProgress = parseInt((event.target as HTMLInputElement).value, 10);
view?.goToFraction(newProgress / 100.0);
const handleProgressChange = (value: number) => {
view?.goToFraction(value / 100.0);
};
const handleFontSizeChange = (value: number) => {
saveViewSettings(envConfig, bookKey, 'defaultFontSize', value);
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
const handleMarginChange = (value: number) => {
const marginPx = Math.round((value / 100) * 88);
const gapPercent = Math.round((value / 100) * 10);
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx);
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent);
view?.renderer.setAttribute('margin', `${marginPx}px`);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (viewSettings?.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
}
};
const handleLineHeightChange = (value: number) => {
saveViewSettings(envConfig, bookKey, 'lineHeight', value / 10);
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
const handleGoPrev = () => {
@@ -67,63 +105,225 @@ const FooterBar: React.FC<FooterBarProps> = ({
}
};
const handleSetActionTab = (tab: string) => {
console.log('handleSetActionTab', tab);
setActionTab(actionTab === tab ? '' : tab);
if (tab === 'tts') {
setHoveredBookKey('');
handleSpeakText();
} else if (tab === 'toc') {
setHoveredBookKey('');
if (viewSettings) {
viewSettings.sideBarTab = 'toc';
}
setSideBarVisible(true);
} else if (tab === 'note') {
setHoveredBookKey('');
setSideBarVisible(true);
if (viewSettings) {
viewSettings.sideBarTab = 'annotations';
}
}
};
const getMarginProgressValue = (marginPx: number, gapPercent: number) => {
return (marginPx / 88 + gapPercent / 10) * 50;
};
const isVisible = hoveredBookKey === bookKey;
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
const progressValid = !!progressInfo;
const progressFraction = progressValid ? (progressInfo!.current + 1) / progressInfo!.total : 0;
const progressFraction = progressValid
? ((progressInfo!.next ?? progressInfo!.current) + 1) / progressInfo!.total
: 0;
return (
<div
className={clsx(
'footer-bar absolute bottom-0 z-10 flex h-12 w-full items-center gap-x-4 px-4',
'shadow-xs bg-base-100 transition-opacity duration-300',
appService?.hasSafeAreaInset && 'pb-[env(safe-area-inset-bottom)]',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
hoveredBookKey === bookKey ? `opacity-100` : `opacity-0`,
)}
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
onMouseEnter={() => setHoveredBookKey(bookKey)}
onMouseLeave={() => setHoveredBookKey('')}
>
<div className='hidden sm:flex'>
<Button
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
/>
<>
<div
className={clsx(
'absolute bottom-0 left-0 z-10 hidden w-full sm:flex sm:h-[52px]',
// show scroll bar when vertical and scrolled in desktop
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
<div
className={clsx(
'footer-bar shadow-xs absolute bottom-0 z-50 flex w-full flex-col',
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border sm:border-none',
'transition-[opacity,transform] duration-300',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
// show scroll bar when vertical and scrolled in desktop
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
isVisible
? `pointer-events-auto translate-y-0 opacity-100`
: `pointer-events-none translate-y-full opacity-0 sm:translate-y-0`,
)}
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
onMouseLeave={() => window.innerWidth >= 640 && setHoveredBookKey('')}
aria-hidden={!isVisible}
>
{/* Mobile footer bar */}
<div
className={clsx(
'bg-base-200 absolute bottom-16 flex w-full items-center gap-x-2 px-4 transition-all sm:hidden',
actionTab === 'progress'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: appService?.hasSafeAreaInset
? 'calc(env(safe-area-inset-bottom) + 64px)'
: '64px',
}}
>
<Button
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
onClick={handleGoBack}
tooltip={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
onClick={handleGoForward}
tooltip={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<Slider
heightPx={sliderHeight}
bubbleLabel={`${Math.round(progressFraction * 100)}%`}
initialValue={progressValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
tooltip={viewSettings?.rtl ? _('Go Left') : _('Go Right')}
/>
</div>
<div
className={clsx(
'bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
actionTab === 'font'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: appService?.hasSafeAreaInset
? 'calc(env(safe-area-inset-bottom) + 64px)'
: '64px',
}}
>
<Slider
initialValue={viewSettings?.defaultFontSize ?? 16}
bubbleLabel={`${viewSettings?.defaultFontSize ?? 16}`}
minLabel='A'
maxLabel='A'
minClassName='text-xs'
maxClassName='text-base'
onChange={handleFontSizeChange}
min={8}
max={30}
/>
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
initialValue={getMarginProgressValue(
viewSettings?.marginPx ?? 44,
viewSettings?.gapPercent ?? 5,
)}
bubbleElement={<TbBoxMargin size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
step={10}
onChange={handleMarginChange}
/>
<Slider
initialValue={(viewSettings?.lineHeight ?? 1.6) * 10}
bubbleElement={<RxLineHeight size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
min={8}
max={24}
onChange={handleLineHeightChange}
/>
</div>
</div>
<div
className={clsx(
'bg-base-200 z-50 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
>
<Button
icon={<TOCIcon size={tocIconSize} className='' />}
onClick={() => handleSetActionTab('toc')}
/>
<Button icon={<NoteIcon className='' />} onClick={() => handleSetActionTab('note')} />
<Button
icon={<SliderIcon className={clsx(actionTab === 'progress' && 'text-blue-500')} />}
onClick={() => handleSetActionTab('progress')}
/>
<Button
icon={
<FontIcon
size={fontIconSize}
className={clsx(actionTab === 'font' && 'text-blue-500')}
/>
}
onClick={() => handleSetActionTab('font')}
/>
<Button icon={<TTSIcon className='' />} onClick={() => handleSetActionTab('tts')} />
</div>
{/* Desktop footer bar */}
<div className='hidden w-full items-center gap-x-4 px-4 sm:flex'>
<Button
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
onClick={handleGoBack}
tooltip={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
onClick={handleGoForward}
tooltip={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<span className='mx-2 text-center text-sm'>
{progressValid ? `${Math.round(progressFraction * 100)}%` : ''}
</span>
<input
type='range'
className='text-base-content mx-2 w-full'
min={0}
max={100}
value={progressValid ? progressFraction * 100 : 0}
onChange={(e) =>
handleProgressChange(parseInt((e.target as HTMLInputElement).value, 10))
}
/>
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
<Button
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
tooltip={viewSettings?.rtl ? _('Go Left') : _('Go Right')}
/>
</div>
</div>
<Button
icon={<RiArrowGoBackLine />}
onClick={handleGoBack}
tooltip={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={<RiArrowGoForwardLine />}
onClick={handleGoForward}
tooltip={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<span className='mx-2 text-center text-sm'>
{progressValid ? `${Math.round(progressFraction * 100)}%` : ''}
</span>
<input
type='range'
className='text-base-content mx-2 w-full'
min={0}
max={100}
value={progressValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
<div className='hidden sm:flex'>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
tooltip={viewSettings?.rtl ? _('Go Left') : _('Go Right')}
/>
</div>
</div>
</>
);
};
@@ -3,8 +3,7 @@ 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 { getFootnoteStyles, getStyles } from '@/utils/style';
import { getFootnoteStyles, getStyles, getThemeCode } from '@/utils/style';
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { FoliateView } from '@/types/view';
@@ -26,23 +25,42 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
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();
const [gridRect, setGridRect] = useState<DOMRect | null>(null);
const [responsiveWidth, setResponsiveWidth] = useState(popupWidth);
const [responsiveHeight, setResponsiveHeight] = useState(popupHeight);
const getResponsivePopupSize = (size: number, isVertical: boolean) => {
const maxSize = isVertical ? window.innerWidth / 2 : window.innerHeight / 2;
return Math.min(size, maxSize - popupPadding - 12);
};
useEffect(() => {
const handleBeforeRender = (e: Event) => {
const detail = (e as CustomEvent).detail;
const { view } = detail;
view.addEventListener('link', (e: Event) => {
e.preventDefault();
const { detail: popupLinkDetail } = e as CustomEvent;
popupLinkDetail['follow'] = true;
footnoteHandler.handle(bookDoc, e)?.catch((err) => {
console.warn(err);
getView(bookKey)?.goTo(popupLinkDetail.href);
});
});
footnoteViewRef.current = view;
footnoteRef.current?.replaceChildren(view);
const { renderer } = view;
renderer.setAttribute('flow', 'scrolled');
renderer.setAttribute('margin', '0px');
renderer.setAttribute('gap', '5%');
renderer.setAttribute('gap', '0%');
const viewSettings = getViewSettings(bookKey)!;
const themeCode = getThemeCode();
const popupTheme = { ...themeCode };
const popupContainer = document.getElementById('popup-container');
if (popupContainer) {
@@ -56,8 +74,17 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const handleRender = (e: Event) => {
const detail = (e as CustomEvent).detail;
console.log('render footnote', detail);
setShowPopup(true);
// console.log('render footnote', detail);
const { view } = detail;
view.addEventListener('relocate', () => {
const { renderer } = view as FoliateView;
if (viewSettings.vertical) {
setResponsiveWidth(getResponsivePopupSize(renderer.viewSize, true));
} else {
setResponsiveHeight(getResponsivePopupSize(renderer.viewSize, false));
}
setShowPopup(true);
});
};
footnoteHandler.addEventListener('before-render', handleBeforeRender);
@@ -67,25 +94,41 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
footnoteHandler.removeEventListener('render', handleRender);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, themeCode]);
}, [view]);
useEffect(() => {
if (viewSettings.vertical) {
setResponsiveWidth(popupHeight);
setResponsiveHeight(Math.max(popupWidth, window.innerHeight / 4));
} else {
setResponsiveWidth(Math.max(popupWidth, window.innerWidth / 4));
setResponsiveHeight(popupHeight);
}
}, [viewSettings]);
useEffect(() => {
if (trianglePosition && gridRect) {
const popupPos = getPopupPosition(
trianglePosition,
gridRect,
responsiveWidth,
responsiveHeight,
popupPadding,
);
setPopupPosition(popupPos);
}
}, [trianglePosition, gridRect, responsiveWidth, responsiveHeight]);
const docLinkHandler = async (event: Event) => {
const detail = (event as CustomEvent).detail;
console.log('doc link click', 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, popupPadding, viewSettings.vertical);
const popupPos = getPopupPosition(
triangPos,
rect,
viewSettings.vertical ? popupHeight : popupWidth,
viewSettings.vertical ? popupWidth : popupHeight,
popupPadding,
);
setGridRect(rect);
setTrianglePosition(triangPos);
setPopupPosition(popupPos);
footnoteHandler.handle(bookDoc, event)?.catch((err) => {
console.warn(err);
@@ -102,11 +145,13 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const handleDismissPopup = () => {
closePopup();
setGridRect(null);
setPopupPosition(null);
setTrianglePosition(null);
setShowPopup(false);
};
// Handle custom footnote popup event from iframe event
const handleFootnotePopupEvent = (event: CustomEvent) => {
const { element, footnote } = event.detail;
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
@@ -114,21 +159,30 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const rect = gridFrame.getBoundingClientRect();
const viewSettings = getViewSettings(bookKey)!;
const triangPos = getPosition(element, rect, popupPadding, viewSettings.vertical);
const popupPos = getPopupPosition(
triangPos,
rect,
viewSettings.vertical ? popupHeight : popupWidth,
viewSettings.vertical ? popupWidth : popupHeight,
popupPadding,
);
if (footnoteRef.current) {
const elem = document.createElement('p');
elem.textContent = footnote;
elem.setAttribute('style', `padding: 16px; hanging-punctuation: allow-end last;`);
elem.setAttribute('style', `padding: 1em; hanging-punctuation: allow-end last;`);
elem.style.visibility = 'hidden';
if (viewSettings.vertical) {
elem.style.height = `${responsiveHeight}px`;
} else {
elem.style.width = `${responsiveWidth}px`;
}
document.body.appendChild(elem);
const popupSize = elem.getBoundingClientRect();
if (viewSettings.vertical) {
setResponsiveWidth(getResponsivePopupSize(popupSize.width, true));
} else {
setResponsiveHeight(getResponsivePopupSize(popupSize.height, false));
}
document.body.removeChild(elem);
elem.style.visibility = 'visible';
footnoteRef.current.replaceChildren(elem);
setShowPopup(true);
setGridRect(rect);
setTrianglePosition(triangPos);
setPopupPosition(popupPos);
setShowPopup(true);
}
};
@@ -137,8 +191,10 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
});
useEffect(() => {
window.addEventListener('resize', handleDismissPopup);
eventDispatcher.on('footnote-popup', handleFootnotePopupEvent);
return () => {
window.removeEventListener('resize', handleDismissPopup);
eventDispatcher.off('footnote-popup', handleFootnotePopupEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -150,9 +206,6 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
}
}, [footnoteRef]);
const width = viewSettings.vertical ? popupHeight : popupWidth;
const height = viewSettings.vertical ? popupWidth : popupHeight;
return (
<div>
{showPopup && (
@@ -163,8 +216,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
/>
)}
<Popup
width={width}
height={height}
width={responsiveWidth}
height={responsiveHeight}
position={showPopup ? popupPosition! : undefined}
trianglePosition={showPopup ? trianglePosition! : undefined}
className='select-text overflow-y-auto'
@@ -173,8 +226,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
className=''
ref={footnoteRef}
style={{
width: `${width}px`,
height: `${height}px`,
width: `${responsiveWidth}px`,
height: `${responsiveHeight}px`,
}}
></div>
</Popup>
@@ -56,11 +56,18 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
setTrafficLightVisibility(isSideBarVisible);
setTrafficLightVisibility(true);
return () => {
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!appService?.hasTrafficLight) return;
setTrafficLightVisibility(isSideBarVisible);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSideBarVisible]);
return (
@@ -79,8 +86,10 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
onMouseEnter={() => setHoveredBookKey(bookKey)}
onMouseLeave={() => setHoveredBookKey('')}
>
<div className='sidebar-bookmark-toggler bg-base-100 z-20 flex h-full items-center space-x-4'>
<SidebarToggler bookKey={bookKey} />
<div className='sidebar-bookmark-toggler bg-base-100 z-20 flex h-full items-center gap-x-4'>
<div className='hidden sm:flex'>
<SidebarToggler bookKey={bookKey} />
</div>
<BookmarkToggler bookKey={bookKey} />
</div>
@@ -1,17 +1,22 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { eventDispatcher } from '@/utils/event';
interface SectionInfoProps {
bookKey: string;
gapRight: string;
showDoubleBorder: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const HintInfo: React.FC<SectionInfoProps> = ({ bookKey, gapRight }) => {
const { isSideBarVisible } = useSidebarStore();
const { isTrafficLightVisible } = useTrafficLightStore();
const HintInfo: React.FC<SectionInfoProps> = ({
bookKey,
showDoubleBorder,
isVertical,
horizontalGap,
verticalMargin,
}) => {
const [hintMessage, setHintMessage] = React.useState<string | null>(null);
const hintTimeout = useRef(2000);
const dismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -42,13 +47,21 @@ const HintInfo: React.FC<SectionInfoProps> = ({ bookKey, gapRight }) => {
return (
<div
className={clsx(
'pageinfo absolute right-0 top-0 flex max-w-[50%] items-end',
isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]',
hintMessage ? '' : '',
'hintinfo absolute flex items-center justify-end overflow-hidden',
hintMessage ? 'bg-base-100' : 'bg-transparent',
isVertical ? 'writing-vertical-rl max-h-[50%]' : 'top-0 h-[44px] max-w-[50%]',
)}
style={{ right: gapRight }}
style={
isVertical
? {
bottom: `${verticalMargin * 1.5}px`,
left: `calc(100% - ${horizontalGap}%)`,
width: showDoubleBorder ? '30px' : `${horizontalGap}%`,
}
: { insetInlineEnd: `${horizontalGap}%` }
}
>
<h2 className='text-neutral-content line-clamp-1 text-center font-sans text-xs font-light'>
<h2 className={clsx('text-neutral-content text-center font-sans text-xs font-light')}>
{hintMessage || ''}
</h2>
</div>
@@ -1,4 +1,6 @@
import clsx from 'clsx';
import React from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { PageInfo } from '@/types/book';
@@ -6,27 +8,58 @@ interface PageInfoProps {
bookFormat: string;
section?: PageInfo;
pageinfo?: PageInfo;
gapRight: string;
showDoubleBorder: boolean;
isScrolled: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const PageInfoView: React.FC<PageInfoProps> = ({ bookFormat, section, pageinfo, gapRight }) => {
const PageInfoView: React.FC<PageInfoProps> = ({
bookFormat,
section,
pageinfo,
showDoubleBorder,
isScrolled,
isVertical,
horizontalGap,
verticalMargin,
}) => {
const _ = useTranslation();
const pageInfo =
bookFormat === 'PDF'
? section
? `${section.current + 1} / ${section.total}`
: ''
: pageinfo
? _('Loc. {{currentPage}} / {{totalPage}}', {
currentPage: pageinfo.current + 1,
totalPage: pageinfo.total,
})
: '';
const { appService } = useEnv();
const pageInfo = ['PDF', 'CBZ'].includes(bookFormat)
? section
? isVertical
? `${section.current + 1} · ${section.total}`
: `${section.current + 1} / ${section.total}`
: ''
: pageinfo
? _(isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}', {
currentPage: (pageinfo.next ?? pageinfo.current) + 1,
totalPage: pageinfo.total,
})
: '';
return (
<div
className='pageinfo absolute bottom-0 left-0 right-0 flex h-12 items-center justify-end'
style={{ paddingRight: gapRight }}
className={clsx(
'pageinfo absolute bottom-0 flex items-center justify-end',
isVertical ? 'writing-vertical-rl' : 'h-12 w-full',
isScrolled && !isVertical && 'bg-base-100',
)}
style={
isVertical
? {
bottom: `${verticalMargin * 1.5}px`,
left: showDoubleBorder ? `calc(${horizontalGap}% - 32px)` : 0,
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: {
insetInlineEnd: `${horizontalGap}%`,
paddingBottom: appService?.hasSafeAreaInset ? 'env(safe-area-inset-bottom)' : 0,
}
}
>
<h2 className='text-neutral-content text-right font-sans text-xs font-extralight'>
{pageInfo}
@@ -6,6 +6,7 @@ import { useEffect, Suspense, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
@@ -19,7 +20,8 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { getVisibleLibrary, setLibrary } = useLibraryStore();
const isInitiating = useRef(false);
const { updateAppTheme } = useTheme();
const { updateAppTheme } = useThemeStore();
useTheme();
useScreenWakeLock(settings.screenWakeLock);
useEffect(() => {
@@ -1,26 +1,47 @@
import clsx from 'clsx';
import React from 'react';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
interface SectionInfoProps {
section?: string;
gapLeft: string;
showDoubleBorder: boolean;
isScrolled: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const SectionInfo: React.FC<SectionInfoProps> = ({ section, gapLeft }) => {
const { isSideBarVisible } = useSidebarStore();
const { isTrafficLightVisible } = useTrafficLightStore();
const SectionInfo: React.FC<SectionInfoProps> = ({
section,
showDoubleBorder,
isScrolled,
isVertical,
horizontalGap,
verticalMargin,
}) => {
return (
<div
className={clsx(
'pageinfo absolute right-0 top-0 flex max-w-[50%] items-end',
isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]',
'sectioninfo absolute flex items-center overflow-hidden',
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'top-0 h-[44px]',
isScrolled && !isVertical && 'bg-base-100',
)}
style={{ left: gapLeft }}
style={
isVertical
? {
top: `${verticalMargin * 1.5}px`,
left: `calc(100% - ${horizontalGap}%)`,
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: { insetInlineStart: `${horizontalGap}%`, width: `calc(100% - ${horizontalGap * 2}%)` }
}
>
<h2 className='text-neutral-content line-clamp-1 text-center font-sans text-xs font-light'>
<h2
className={clsx(
'text-neutral-content text-center font-sans text-xs font-light',
isVertical ? '' : 'line-clamp-1',
)}
>
{section || ''}
</h2>
</div>
@@ -6,12 +6,15 @@ import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import MenuItem from '@/components/MenuItem';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme, ThemeMode } from '@/hooks/useTheme';
import { ThemeMode } from '@/styles/themes';
import { getStyles } from '@/utils/style';
import { getMaxInlineSize } from '@/utils/config';
import { tauriHandleToggleFullScreen } from '@/utils/window';
import MenuItem from '@/components/MenuItem';
interface ViewMenuProps {
bookKey: string;
@@ -25,19 +28,18 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
onSetSettingsDialogOpen,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { getView, getViews, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const { themeMode, isDarkMode, themeCode, updateThemeMode } = useTheme();
const { themeMode, themeCode, setThemeMode } = useThemeStore();
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
const [isInvertedColors, setInvertedColors] = useState(viewSettings!.invert);
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
const resetZoom = () => setZoomLevel(100);
const toggleScrolledMode = () => setScrolledMode(!isScrolledMode);
const toggleInvertedColors = () => setInvertedColors(!isInvertedColors);
const openFontLayoutMenu = () => {
setIsDropdownOpen?.(false);
@@ -47,40 +49,39 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const cycleThemeMode = () => {
const nextMode: ThemeMode =
themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
updateThemeMode(nextMode);
setThemeMode(nextMode);
};
const handleFullScreen = () => {
tauriHandleToggleFullScreen();
setIsDropdownOpen?.(false);
};
useEffect(() => {
getViews().forEach((view) => {
view.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
view.renderer.setStyles?.(getStyles(viewSettings!));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode]);
useEffect(() => {
viewSettings!.scrolled = isScrolledMode;
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute(
'max-inline-size',
`${getMaxInlineSize(viewSettings)}px`,
);
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
viewSettings!.scrolled = isScrolledMode;
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!));
setViewSettings(bookKey, viewSettings!);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isScrolledMode]);
useEffect(() => {
document.body.classList.toggle('invert', isInvertedColors);
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isInvertedColors]);
useEffect(() => {
const view = getView(bookKey);
if (!view) return;
viewSettings!.zoomLevel = zoomLevel;
setViewSettings(bookKey, viewSettings!);
view.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
view.renderer.setStyles?.(getStyles(viewSettings!));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomLevel]);
@@ -131,6 +132,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<hr className='border-base-300 my-1' />
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
<MenuItem
label={
themeMode === 'dark'
@@ -142,12 +144,6 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
icon={themeMode === 'dark' ? <BiMoon /> : themeMode === 'light' ? <BiSun /> : <TbSunMoon />}
onClick={cycleThemeMode}
/>
<MenuItem
label={_('Invert Colors in Dark Mode')}
icon={isInvertedColors ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleInvertedColors}
disabled={!isDarkMode}
/>
</div>
);
};
@@ -12,7 +12,7 @@ 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 { BookNote, BooknoteGroup, HighlightColor, HighlightStyle } from '@/types/book';
import { getOSPlatform, uniqueId } from '@/utils/misc';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -24,6 +24,7 @@ import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { useNotesSync } from '../../hooks/useNotesSync';
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { findTocItemBS } from '@/utils/toc';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
@@ -76,9 +77,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const dictPopupHeight = Math.min(300, maxHeight);
const transPopupWidth = Math.min(480, maxWidth);
const transPopupHeight = Math.min(360, maxHeight);
const annotPopupWidth = useResponsiveSize(300);
const annotPopupWidth = Math.min(useResponsiveSize(300), maxWidth);
const annotPopupHeight = useResponsiveSize(44);
const androidSelectionHandlerHeight = 8;
const androidSelectionHandlerHeight = 0;
const onLoad = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -240,8 +241,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
};
eventDispatcher.onSync('iframe-single-click', handleSingleClick);
eventDispatcher.on('export-annotations', handleExportMarkdown);
return () => {
eventDispatcher.offSync('iframe-single-click', handleSingleClick);
eventDispatcher.off('export-annotations', handleExportMarkdown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -438,6 +441,91 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
};
const handleExportMarkdown = (event: CustomEvent) => {
const { bookKey: exportBookKey } = event.detail;
if (bookKey !== exportBookKey) return;
const { bookDoc, book } = bookData;
if (!bookDoc || !book || !bookDoc.toc) return;
const config = getConfig(bookKey)!;
const { booknotes: allNotes = [] } = config;
const booknotes = allNotes.filter((note) => !note.deletedAt);
if (booknotes.length === 0) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('No annotations to export'),
className: 'whitespace-nowrap',
timeout: 2000,
});
return;
}
const booknoteGroups: { [href: string]: BooknoteGroup } = {};
for (const booknote of booknotes) {
const tocItem = findTocItemBS(bookDoc.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) => {
group.booknotes.sort((a, b) => {
return CFI.compare(a.cfi, b.cfi);
});
});
const sortedGroups = Object.values(booknoteGroups).sort((a, b) => {
return a.id - b.id;
});
const lines: string[] = [];
lines.push(`# ${book.title}`);
lines.push(`**${_('Author')}**: ${book.author || ''}`);
lines.push('');
lines.push(`**${_('Exported from Readest')}**: ${new Date().toISOString().slice(0, 10)}`);
lines.push('');
lines.push('---');
lines.push('');
lines.push(`## ${_('Highlights & Annotations')}`);
lines.push('');
for (const group of sortedGroups) {
const chapterTitle = group.label || _('Untitled');
lines.push(`### ${chapterTitle}`);
for (const note of group.booknotes) {
lines.push(`> "${note.text}"`);
if (note.note) {
lines.push(`**${_('Note')}**:: ${note.note}`);
}
lines.push('');
}
lines.push('---');
lines.push('');
}
const markdownContent = lines.join('\n');
navigator.clipboard?.writeText(markdownContent);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Copied to clipboard'),
className: 'whitespace-nowrap',
timeout: 2000,
});
if (appService?.isMobile) return;
const blob = new Blob([markdownContent], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${book.title.replace(/\s+/g, '_')}.md`;
a.click();
URL.revokeObjectURL(url);
};
const selectionAnnotated = selection?.annotated;
const buttons = [
{ tooltipText: _('Copy'), Icon: FiCopy, onClick: handleCopy },
@@ -165,13 +165,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
) : (
<div>
<p className='text-base'>{translation || 'No translation available.'}</p>
<div className='pt-4 text-sm opacity-60'>
Translated by{' '}
<a href='https://www.deepl.com/' target='_blank' rel='noopener noreferrer'>
DeepL
</a>
.
</div>
<div className='pt-4 text-sm opacity-60'>Translated by DeepL.</div>
</div>
)}
</div>
@@ -114,11 +114,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
<main className='flex-grow overflow-y-auto px-2 font-sans'></main>
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
From <a id='link'>Wikipedia</a>, released under the{' '}
<a href='https://en.wikipedia.org/wiki/Wikipedia:Text_of_the_Creative_Commons_Attribution-ShareAlike_4.0_International_License'>
CC BY-SA License
</a>
.
Source: Wikipedia (CC BY-SA)
</div>
</footer>
</div>
@@ -169,8 +169,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
<main className='flex-grow overflow-y-auto p-4 font-sans' />
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
From <a id='link'>Wiktionary</a>, released under the{' '}
<a href='https://creativecommons.org/licenses/by-sa/4.0/'>CC BY-SA License</a>.
Source: Wiktionary (CC BY-SA)
</div>
</footer>
</div>
@@ -15,10 +15,10 @@ const NotebookHeader: React.FC<{
const _ = useTranslation();
const iconSize14 = useResponsiveSize(14);
return (
<div className='notebook-header relative flex h-11 items-center px-3'>
<div className='notebook-header relative flex h-11 items-center px-3' dir='ltr'>
<div className='absolute inset-0 flex items-center justify-center space-x-2'>
<LuNotebookPen />
<div className='notebook-title text-sm font-medium'>{_('Notebook')}</div>
<div className='notebook-title hidden text-sm font-medium sm:flex'>{_('Notebook')}</div>
</div>
<div className='z-10 flex items-center gap-x-4'>
<button
@@ -114,6 +114,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
'inset-0 w-full rounded-none border-0 bg-transparent p-0',
'content font-size-sm',
)}
dir='auto'
ref={editorRef}
value={note}
rows={1}
@@ -130,7 +131,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
<span className='content font-size-xs inline text-gray-500'>{getAnnotationText()}</span>
</div>
</div>
<div className='flex justify-end p-2'>
<div className='flex justify-end p-2' dir='ltr'>
<button
className={clsx(
'content btn btn-ghost font-size-sm hover:bg-transparent',
@@ -7,13 +7,14 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
import { TextSelection } from '@/utils/sel';
import { BookNote } from '@/types/book';
import { uniqueId } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
import NoteEditor from './NoteEditor';
@@ -23,14 +24,14 @@ const MAX_NOTEBOOK_WIDTH = 0.45;
const Notebook: React.FC = ({}) => {
const _ = useTranslation();
const { updateAppTheme } = useTheme();
const { updateAppTheme } = useThemeStore();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { notebookWidth, isNotebookVisible, isNotebookPinned } = useNotebookStore();
const { notebookNewAnnotation, notebookEditAnnotation, setNotebookPin } = useNotebookStore();
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getView } = useReaderStore();
const { getBookData, getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const { setNotebookWidth, setNotebookVisible, toggleNotebookPin } = useNotebookStore();
const { setNotebookNewAnnotation, setNotebookEditAnnotation } = useNotebookStore();
@@ -136,6 +137,14 @@ const Notebook: React.FC = ({}) => {
if (!sideBarBookKey) return null;
const bookData = getBookData(sideBarBookKey);
const viewSettings = getViewSettings(sideBarBookKey);
if (!bookData || !bookData.bookDoc) {
return null;
}
const { bookDoc } = bookData;
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
const config = getConfig(sideBarBookKey);
const { booknotes: allNotes = [] } = config || {};
const annotationNotes = allNotes
@@ -159,6 +168,7 @@ const Notebook: React.FC = ({}) => {
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
!isNotebookPinned && 'shadow-2xl',
)}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
width: `${notebookWidth}`,
maxWidth: `${MAX_NOTEBOOK_WIDTH * 100}%`,
@@ -183,7 +193,7 @@ const Notebook: React.FC = ({}) => {
handleTogglePin={handleTogglePin}
/>
<div className='max-h-[calc(100vh-44px)] overflow-y-auto px-3'>
<div>
<div dir='ltr'>
{excerptNotes.length > 0 && (
<p className='content font-size-base pt-1'>{_('Excerpts')}</p>
)}
@@ -196,7 +206,7 @@ const Notebook: React.FC = ({}) => {
className='collapse-arrow border-base-300 bg-base-100 collapse border'
>
<div
className='collapse-title font-size-sm h-9 min-h-9 p-2 pr-8 font-medium'
className='collapse-title font-size-sm h-9 min-h-9 p-2 pe-8 font-medium'
style={
{
'--top-override': '1.2rem',
@@ -208,7 +218,7 @@ const Notebook: React.FC = ({}) => {
</div>
<div className='collapse-content font-size-xs select-text px-3 pb-0'>
<p className='hyphens-auto text-justify'>{item.text}</p>
<div className='flex justify-end'>
<div className='flex justify-end' dir='ltr'>
<div
className='font-size-xs cursor-pointer align-bottom text-red-500 hover:text-red-600'
onClick={handleEditNote.bind(null, item, true)}
@@ -221,7 +231,7 @@ const Notebook: React.FC = ({}) => {
</li>
))}
</ul>
<div>
<div dir='ltr'>
{(notebookNewAnnotation || annotationNotes.length > 0) && (
<p className='content font-size-base pt-1'>{_('Notes')}</p>
)}
@@ -0,0 +1,66 @@
import React, { useState, useEffect, useRef } from 'react';
import { SketchPicker, ColorResult } from 'react-color';
type ColorInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
};
const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
const handlePickerChange = (colorResult: ColorResult) => {
onChange(colorResult.hex);
};
return (
<div className='mb-3'>
<label className='mb-1 block text-sm font-medium'>{label}</label>
<div className='flex items-center'>
<div
className='border-base-200 relative mr-2 flex h-7 w-8 cursor-pointer items-center justify-center overflow-hidden rounded border'
style={{ backgroundColor: value }}
onClick={() => setIsOpen(!isOpen)}
/>
<input
type='text'
value={value}
onChange={(e) => onChange(e.target.value)}
className='bg-base-100 text-base-content border-base-200 min-w-4 max-w-36 flex-1 rounded border p-1 font-mono text-sm'
/>
</div>
{isOpen && (
<div ref={pickerRef} className='relative z-50 mt-2'>
<div className='absolute'>
<SketchPicker
width='100%'
color={value}
onChange={handlePickerChange}
disableAlpha={true}
/>
</div>
</div>
)}
</div>
);
};
export default ColorInput;
@@ -1,96 +1,183 @@
import React, { useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { MdOutlineLightMode, MdOutlineDarkMode } from 'react-icons/md';
import { MdRadioButtonUnchecked, MdRadioButtonChecked } from 'react-icons/md';
import { CgColorPicker } from 'react-icons/cg';
import { TbSunMoon } from 'react-icons/tb';
import { useTheme } from '@/hooks/useTheme';
import { themes } from '@/styles/themes';
import { PiPlus } from 'react-icons/pi';
import {
applyCustomTheme,
CustomTheme,
generateDarkPalette,
generateLightPalette,
Theme,
themes,
} from '@/styles/themes';
import { getStyles } from '@/utils/style';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import ThemeEditor from './ThemeEditor';
const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { themeMode, themeColor, themeCode, isDarkMode, updateThemeMode, updateThemeColor } =
useTheme();
const {
themeMode,
themeColor,
themeCode,
isDarkMode,
setThemeMode,
setThemeColor,
saveCustomTheme,
} = useThemeStore();
const { envConfig } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { getViews, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const iconSize16 = useResponsiveSize(16);
const iconSize24 = useResponsiveSize(24);
const [editTheme, setEditTheme] = useState<CustomTheme | null>(null);
const [customThems, setCustomThemes] = useState<Theme[]>([]);
const [showCustomThemeEditor, setShowCustomThemeEditor] = useState(false);
useEffect(() => {
getViews().forEach((view) => {
view.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
view.renderer.setStyles?.(getStyles(viewSettings));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode]);
useEffect(() => {
const customThemes = settings.globalReadSettings.customThemes ?? [];
setCustomThemes(
customThemes.map((customTheme) => ({
name: customTheme.name,
label: customTheme.label,
colors: {
light: generateLightPalette(customTheme.colors.light),
dark: generateDarkPalette(customTheme.colors.dark),
},
isCustomizale: true,
})),
);
}, [settings]);
const handleSaveCustomTheme = (customTheme: CustomTheme) => {
applyCustomTheme(customTheme);
saveCustomTheme(envConfig, settings, customTheme);
setSettings({ ...settings });
setThemeColor(customTheme.name);
setShowCustomThemeEditor(false);
};
const handleDeleteCustomTheme = (customTheme: CustomTheme) => {
saveCustomTheme(envConfig, settings, customTheme, true);
setSettings({ ...settings });
setThemeColor('default');
setShowCustomThemeEditor(false);
};
const handleEditTheme = (name: string) => {
const customTheme = settings.globalReadSettings.customThemes.find((t) => t.name === name);
if (customTheme) {
setEditTheme(customTheme);
setShowCustomThemeEditor(true);
}
};
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Theme Mode')}</h2>
<div className='flex gap-2'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('auto')}
>
<TbSunMoon />
</button>
{showCustomThemeEditor ? (
<ThemeEditor
customTheme={editTheme}
onSave={handleSaveCustomTheme}
onDelete={handleDeleteCustomTheme}
onCancel={() => setShowCustomThemeEditor(false)}
/>
) : (
<>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Theme Mode')}</h2>
<div className='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('auto')}
>
<TbSunMoon />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Light Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('light')}
>
<MdOutlineLightMode />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Dark Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('dark')}
>
<MdOutlineDarkMode />
</button>
</div>
</div>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Light Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('light')}
>
<MdOutlineLightMode />
</button>
<div>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
{themes.concat(customThems).map(({ name, label, colors, isCustomizale }) => (
<label
key={name}
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg p-4 shadow-md ${
themeColor === name ? 'ring-2 ring-indigo-500 ring-offset-2' : ''
}`}
style={{
backgroundColor: isDarkMode
? colors.dark['base-100']
: colors.light['base-100'],
color: isDarkMode ? colors.dark['base-content'] : colors.light['base-content'],
}}
>
<input
type='radio'
name='theme'
value={name}
checked={themeColor === name}
onChange={() => setThemeColor(name)}
className='hidden'
/>
{themeColor === name ? (
<MdRadioButtonChecked size={iconSize24} />
) : (
<MdRadioButtonUnchecked size={iconSize24} />
)}
<span>{_(label)}</span>
{isCustomizale && themeColor === name && (
<button onClick={() => handleEditTheme(name)}>
<CgColorPicker size={iconSize16} className='absolute right-2 top-2' />
</button>
)}
</label>
))}
<label
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed p-4 shadow-md`}
onClick={() => setShowCustomThemeEditor(true)}
>
<PiPlus size={iconSize24} />
<span>{_('Custom')}</span>
</label>
</div>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Dark Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('dark')}
>
<MdOutlineDarkMode />
</button>
</div>
</div>
</div>
<div>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
{themes.map(({ name, label, colors }) => (
<label
key={name}
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg p-4 shadow-md ${
themeColor === name ? 'ring-2 ring-indigo-500 ring-offset-2' : ''
}`}
style={{
backgroundColor: isDarkMode ? colors.dark['base-100'] : colors.light['base-100'],
color: isDarkMode ? colors.dark['base-content'] : colors.light['base-content'],
}}
>
<input
type='radio'
name='theme'
value={name}
checked={themeColor === name}
onChange={() => updateThemeColor(name)}
className='hidden'
/>
{themeColor === name ? (
<MdRadioButtonChecked size={iconSize24} />
) : (
<MdRadioButtonUnchecked size={iconSize24} />
)}
<span>{_(label)}</span>
</label>
))}
</div>
</div>
</>
)}
</div>
);
};
@@ -0,0 +1,49 @@
import clsx from 'clsx';
import React from 'react';
import { FiChevronDown } from 'react-icons/fi';
import { MdCheck } from 'react-icons/md';
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
interface DropDownProps {
selected: { option: string; label: string };
options: { option: string; label: string }[];
onSelect: (option: string) => void;
}
const DropDown: React.FC<DropDownProps> = ({ selected, options, onSelect }) => {
const iconSize16 = useResponsiveSize(16);
const defaultIconSize = useDefaultIconSize();
return (
<div className='dropdown dropdown-bottom'>
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
onClick={(e) => e.currentTarget.focus()}
>
<span>{selected.label}</span>
<FiChevronDown size={iconSize16} />
</button>
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute z-[1] shadow',
'menu-vertical right-[-32px] mt-2 inline max-h-80 w-44 overflow-y-scroll sm:right-0',
)}
>
{options.map(({ option, label }) => (
<li key={option} onClick={() => onSelect(option)}>
<div className='flex items-center px-0'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{selected.option === option && <MdCheck className='text-base-content' />}
</span>
<span>{label || option}</span>
</div>
</li>
))}
</ul>
</div>
);
};
export default DropDown;
@@ -32,6 +32,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
onClick={(e) => e.currentTarget.focus()}
>
<span style={{ fontFamily: onGetFontFamily(selectedOption.option, family ?? '') }}>
{selectedOption.label}
@@ -1,8 +1,6 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
import {
ANDROID_FONTS,
IOS_FONTS,
@@ -14,14 +12,15 @@ import {
WINDOWS_FONTS,
} from '@/services/constants';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { useEnv } from '@/context/EnvContext';
import { getStyles } from '@/utils/style';
import { getOSPlatform } from '@/utils/misc';
import { getSysFontsList } from '@/utils/font';
import { isTauriAppPlatform } from '@/services/environment';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import NumberInput from './NumberInput';
import FontDropdown from './FontDropDown';
interface FontFaceProps {
className?: string;
@@ -45,28 +44,29 @@ const FontFace = ({
moreOptions,
selected,
onSelect,
}: FontFaceProps) => (
<div className={clsx('config-item', className)}>
<span className=''>{label}</span>
<FontDropdown
family={family}
options={options.map((option) => ({ option, label: option }))}
moreOptions={moreOptions?.map((option) => ({ option, label: option })) ?? []}
selected={selected}
onSelect={onSelect}
onGetFontFamily={handleFontFaceFont}
/>
</div>
);
}: FontFaceProps) => {
const _ = useTranslation();
return (
<div className={clsx('config-item', className)}>
<span className=''>{label}</span>
<FontDropdown
family={family}
options={options.map((option) => ({ option, label: _(option) }))}
moreOptions={moreOptions?.map((option) => ({ option, label: option })) ?? []}
selected={selected}
onSelect={onSelect}
onGetFontFamily={handleFontFaceFont}
/>
</div>
);
};
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const { envConfig, appService } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
const fontFamilyOptions = [
{
@@ -120,90 +120,50 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, []);
useEffect(() => {
viewSettings.defaultFont = defaultFont;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.defaultFont = defaultFont;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'defaultFont', defaultFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultFont]);
useEffect(() => {
viewSettings.defaultFontSize = defaultFontSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.defaultFontSize = defaultFontSize;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'defaultFontSize', defaultFontSize);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultFontSize]);
useEffect(() => {
viewSettings.minimumFontSize = minFontSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.minimumFontSize = minFontSize;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'minimumFontSize', minFontSize);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minFontSize]);
useEffect(() => {
viewSettings.fontWeight = fontWeight;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.fontWeight = fontWeight;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'fontWeight', fontWeight);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fontWeight]);
useEffect(() => {
viewSettings.serifFont = serifFont;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.serifFont = serifFont;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'serifFont', serifFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serifFont]);
useEffect(() => {
viewSettings.sansSerifFont = sansSerifFont;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.sansSerifFont = sansSerifFont;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'sansSerifFont', sansSerifFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sansSerifFont]);
useEffect(() => {
viewSettings.monospaceFont = monospaceFont;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.monospaceFont = monospaceFont;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'monospaceFont', monospaceFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [monospaceFont]);
useEffect(() => {
viewSettings.overrideFont = overrideFont;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.overrideFont = overrideFont;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'overrideFont', overrideFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideFont]);
@@ -227,7 +187,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label={_('Default Font Size')}
value={defaultFontSize}
onChange={setDefaultFontSize}
@@ -235,7 +194,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
max={120}
/>
<NumberInput
className='config-item-bottom'
label={_('Minimum Font Size')}
value={minFontSize}
onChange={setMinFontSize}
@@ -251,7 +209,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label={_('Font Weight')}
value={fontWeight}
onChange={setFontWeight}
@@ -267,7 +224,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<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'>
<div className='config-item'>
<span className=''>{_('Default Font')}</span>
<FontDropdown
options={fontFamilyOptions}
@@ -277,7 +234,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
</div>
<div className='config-item config-item-bottom'>
<div className='config-item'>
<span className=''>{_('Override Book Font')}</span>
<input
type='checkbox'
@@ -3,25 +3,26 @@ import { MdOutlineAutoMode } from 'react-icons/md';
import { MdOutlineTextRotationNone, MdTextRotateVertical } from 'react-icons/md';
import { TbTextDirectionRtl } from 'react-icons/tb';
import { useSettingsStore } from '@/store/settingsStore';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { isCJKEnv } from '@/utils/misc';
import { getStyles } from '@/utils/style';
import { getMaxInlineSize } from '@/utils/config';
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
import { MIGHT_BE_RTL_LANGS } from '@/services/constants';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import NumberInput from './NumberInput';
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { envConfig } = useEnv();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
const [paragraphMargin, setParagraphMargin] = useState(viewSettings.paragraphMargin!);
const [lineHeight, setLineHeight] = useState(viewSettings.lineHeight!);
@@ -37,102 +38,62 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
const [writingMode, setWritingMode] = useState(viewSettings.writingMode!);
const [overrideLayout, setOverrideLayout] = useState(viewSettings.overrideLayout!);
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled!);
const [doubleBorder, setDoubleBorder] = useState(viewSettings.doubleBorder!);
const [borderColor, setBorderColor] = useState(viewSettings.borderColor!);
const [showHeader, setShowHeader] = useState(viewSettings.showHeader!);
const [showFooter, setShowFooter] = useState(viewSettings.showFooter!);
useEffect(() => {
viewSettings.paragraphMargin = paragraphMargin;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.paragraphMargin = paragraphMargin;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'paragraphMargin', paragraphMargin);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paragraphMargin]);
useEffect(() => {
viewSettings.lineHeight = lineHeight;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.lineHeight = lineHeight;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'lineHeight', lineHeight);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lineHeight]);
useEffect(() => {
viewSettings.wordSpacing = wordSpacing;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.wordSpacing = wordSpacing;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'wordSpacing', wordSpacing);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordSpacing]);
useEffect(() => {
viewSettings.letterSpacing = letterSpacing;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.letterSpacing = letterSpacing;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'letterSpacing', letterSpacing);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [letterSpacing]);
useEffect(() => {
viewSettings.textIndent = textIndent;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.textIndent = textIndent;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'textIndent', textIndent);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textIndent]);
useEffect(() => {
viewSettings.fullJustification = fullJustification;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.fullJustification = fullJustification;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'fullJustification', fullJustification);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fullJustification]);
useEffect(() => {
viewSettings.hyphenation = hyphenation;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.hyphenation = hyphenation;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'hyphenation', hyphenation);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hyphenation]);
useEffect(() => {
viewSettings.marginPx = marginPx;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.marginPx = marginPx;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx);
view?.renderer.setAttribute('margin', `${marginPx}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [marginPx]);
useEffect(() => {
viewSettings.gapPercent = gapPercent;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.gapPercent = gapPercent;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (viewSettings.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
@@ -141,35 +102,20 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [gapPercent]);
useEffect(() => {
viewSettings.maxColumnCount = maxColumnCount;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.maxColumnCount = maxColumnCount;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'maxColumnCount', maxColumnCount);
view?.renderer.setAttribute('max-column-count', maxColumnCount);
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxColumnCount]);
useEffect(() => {
viewSettings.maxInlineSize = maxInlineSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.maxInlineSize = maxInlineSize;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'maxInlineSize', maxInlineSize);
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxInlineSize]);
useEffect(() => {
viewSettings.maxBlockSize = maxBlockSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.maxBlockSize = maxBlockSize;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'maxBlockSize', maxBlockSize);
view?.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxBlockSize]);
@@ -177,15 +123,18 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
// global settings are not supported for writing mode
const prevWritingMode = viewSettings.writingMode;
viewSettings.writingMode = writingMode;
setViewSettings(bookKey, viewSettings);
if (!writingMode.includes('vertical')) {
viewSettings.vertical = false;
}
saveViewSettings(envConfig, bookKey, 'writingMode', writingMode, true);
if (view) {
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
view.renderer.setStyles?.(getStyles(viewSettings));
view.book.dir = getBookDirFromWritingMode(writingMode);
}
if (
prevWritingMode !== writingMode &&
(writingMode === 'horizontal-rl' || prevWritingMode === 'horizontal-rl')
(['horizontal-rl', 'vertical-rl'].includes(writingMode) ||
['horizontal-rl', 'vertical-rl'].includes(prevWritingMode))
) {
setTimeout(() => window.location.reload(), 100);
}
@@ -193,30 +142,99 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [writingMode]);
useEffect(() => {
viewSettings.overrideLayout = overrideLayout;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.overrideLayout = overrideLayout;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
saveViewSettings(envConfig, bookKey, 'overrideLayout', overrideLayout);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideLayout]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'scrolled', isScrolledMode);
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute(
'max-inline-size',
`${getMaxInlineSize(viewSettings)}px`,
);
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isScrolledMode]);
useEffect(() => {
if (doubleBorder && viewSettings.vertical) {
viewSettings.gapPercent = Math.max(
viewSettings.gapPercent,
Math.ceil(4800 / window.innerWidth),
);
setGapPercent(viewSettings.gapPercent);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'doubleBorder', doubleBorder);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doubleBorder]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'borderColor', borderColor);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [borderColor]);
useEffect(() => {
if (showHeader && !viewSettings.vertical) {
viewSettings.marginPx = Math.max(viewSettings.marginPx, 44);
setMarginPx(viewSettings.marginPx);
setViewSettings(bookKey, viewSettings);
} else if (showHeader && viewSettings.vertical) {
viewSettings.gapPercent = Math.max(
viewSettings.gapPercent,
Math.ceil(4800 / window.innerWidth),
);
setGapPercent(viewSettings.gapPercent);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showHeader', showHeader);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showHeader]);
useEffect(() => {
if (showFooter && !viewSettings.vertical) {
viewSettings.marginPx = Math.max(viewSettings.marginPx, 44);
setMarginPx(viewSettings.marginPx);
setViewSettings(bookKey, viewSettings);
} else if (showFooter && viewSettings.vertical) {
viewSettings.gapPercent = Math.max(
viewSettings.gapPercent,
Math.ceil(4800 / window.innerWidth),
);
setGapPercent(viewSettings.gapPercent);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'showFooter', showFooter);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showFooter]);
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
const mightBeRTLBook =
langCode === 'zh' || langCode === 'ja' || langCode === 'ko' || langCode === '';
const mightBeRTLBook = MIGHT_BE_RTL_LANGS.includes(langCode) || isCJKEnv();
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Scrolled Mode')}</h2>
<input
type='checkbox'
className='toggle'
checked={isScrolledMode}
onChange={() => setScrolledMode(!isScrolledMode)}
/>
</div>
</div>
{mightBeRTLBook && (
<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='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Default')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('auto')}
>
<MdOutlineAutoMode />
@@ -225,7 +243,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Horizontal Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('horizontal-tb')}
>
<MdOutlineTextRotationNone />
@@ -234,7 +252,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Vertical Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('vertical-rl')}
>
<MdTextRotateVertical />
@@ -243,7 +261,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('RTL Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'horizontal-rl' ? 'btn-active bg-base-300' : ''}`}
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-rl' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('horizontal-rl')}
>
<TbTextDirectionRtl />
@@ -254,12 +272,45 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
)}
{viewSettings.vertical && (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Border Frame')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Double Border')}</span>
<input
type='checkbox'
className='toggle'
checked={doubleBorder}
onChange={() => setDoubleBorder(!doubleBorder)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Border Color')}</span>
<div className='flex gap-4'>
<button
className={`btn btn-circle btn-sm bg-red-300 hover:bg-red-500 ${borderColor === 'red' ? 'btn-active !bg-red-500' : ''}`}
onClick={() => setBorderColor('red')}
></button>
<button
className={`btn btn-circle btn-sm bg-black/50 hover:bg-black ${borderColor === 'black' ? 'btn-active !bg-black' : ''}`}
onClick={() => setBorderColor('black')}
></button>
</div>
</div>
</div>
</div>
</div>
)}
<div className='w-full'>
<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={_('Paragraph Margin')}
value={paragraphMargin}
onChange={setParagraphMargin}
@@ -275,21 +326,23 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
max={3.0}
step={0.1}
/>
<NumberInput
label={_('Word Spacing')}
value={wordSpacing}
onChange={setWordSpacing}
min={-4}
max={8}
step={0.5}
/>
{langCode !== 'zh' && (
<NumberInput
label={_('Word Spacing')}
value={wordSpacing}
onChange={setWordSpacing}
min={-4}
max={8}
step={0.5}
/>
)}
<NumberInput
label={_('Letter Spacing')}
value={letterSpacing}
onChange={setLetterSpacing}
min={-2}
max={4}
step={0.1}
step={0.5}
/>
<NumberInput
label={_('Text Indent')}
@@ -299,7 +352,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
max={4}
step={1}
/>
<div className='config-item config-item-bottom'>
<div className='config-item'>
<span className=''>{_('Full Justification')}</span>
<input
type='checkbox'
@@ -308,7 +361,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onChange={() => setFullJustification(!fullJustification)}
/>
</div>
<div className='config-item config-item-bottom'>
<div className='config-item'>
<span className=''>{_('Hyphenation')}</span>
<input
type='checkbox'
@@ -317,7 +370,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onChange={() => setHyphenation(!hyphenation)}
/>
</div>
<div className='config-item config-item-bottom'>
<div className='config-item'>
<span className=''>{_('Override Book Layout')}</span>
<input
type='checkbox'
@@ -334,12 +387,29 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<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'>
<div className='config-item'>
<span className=''>{_('Show Header')}</span>
<input
type='checkbox'
className='toggle'
checked={showHeader}
onChange={() => setShowHeader(!showHeader)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Footer')}</span>
<input
type='checkbox'
className='toggle'
checked={showFooter}
onChange={() => setShowFooter(!showFooter)}
/>
</div>
<NumberInput
className='config-item-top'
label={_('Vertical Margins (px)')}
value={marginPx}
onChange={setMarginPx}
min={0}
min={!viewSettings.vertical && (showFooter || showHeader) ? 44 : 0}
max={88}
step={4}
/>
@@ -347,7 +417,11 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
label={_('Horizontal Margins (%)')}
value={gapPercent}
onChange={setGapPercent}
min={0}
min={
viewSettings.vertical && (showFooter || showHeader)
? Math.ceil(4800 / window.innerWidth)
: 0
}
max={30}
/>
<NumberInput
@@ -358,19 +432,20 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
max={4}
/>
<NumberInput
label={_('Maximum Inline Size')}
label={viewSettings.vertical ? _('Maximum Column Height') : _('Maximum Column Width')}
value={maxInlineSize}
onChange={setMaxInlineSize}
disabled={maxColumnCount === 1 || viewSettings.scrolled}
min={500}
min={400}
max={9999}
step={100}
/>
<NumberInput
label={_('Maximum Block Size')}
label={viewSettings.vertical ? _('Maximum Column Width') : _('Maximum Column Height')}
value={maxBlockSize}
onChange={setMaxBlockSize}
min={500}
disabled={maxColumnCount === 1 || viewSettings.scrolled}
min={400}
max={9999}
step={100}
/>
@@ -1,26 +1,34 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import i18n from 'i18next';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { useTheme } from '@/hooks/useTheme';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import { TRANSLATED_LANGS } from '@/services/constants';
import cssbeautify from 'cssbeautify';
import cssValidate from '@/utils/css';
import DropDown from './DropDown';
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
const [animated, setAnimated] = useState(viewSettings.animated!);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
const [error, setError] = useState<string | null>(null);
const [inputFocusInAndroid, setInputFocusInAndroid] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const handleUserStylesheetChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const cssInput = e.target.value;
setDraftStylesheet(cssInput);
@@ -59,7 +67,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSettings(settings);
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings));
};
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
@@ -67,13 +75,51 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
e.nativeEvent.stopImmediatePropagation();
};
useEffect(() => {
viewSettings.animated = animated;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.animated = animated;
setSettings(settings);
const handleInputFocus = () => {
if (appService?.isAndroidApp) {
setInputFocusInAndroid(true);
}
setTimeout(() => {
textareaRef.current?.scrollIntoView({
behavior: 'instant',
block: 'center',
});
}, 300);
};
const handleInputBlur = () => {
if (appService?.isAndroidApp) {
setInputFocusInAndroid(false);
}
};
const getCurrentUILangOption = () => {
const uiLanguage = viewSettings.uiLanguage;
return {
option: uiLanguage,
label:
uiLanguage === ''
? _('Auto')
: TRANSLATED_LANGS[uiLanguage as keyof typeof TRANSLATED_LANGS],
};
};
const getUILangOptions = () => {
const langs = TRANSLATED_LANGS as Record<string, string>;
const options = Object.entries(langs).map(([option, label]) => ({ option, label }));
options.sort((a, b) => a.label.localeCompare(b.label));
options.unshift({ option: '', label: _('Auto') });
return options;
};
const handleSelectUILang = (option: string) => {
viewSettings.uiLanguage = option;
saveViewSettings(envConfig, bookKey, 'uiLanguage', option);
i18n.changeLanguage(option ? option : navigator.language);
};
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'animated', animated);
if (animated) {
getView(bookKey)?.renderer.setAttribute('animated', '');
} else {
@@ -83,22 +129,40 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [animated]);
useEffect(() => {
viewSettings.disableClick = isDisableClick;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.disableClick = isDisableClick;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'disableClick', isDisableClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisableClick]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isContinuousScroll]);
return (
<div className='my-4 w-full space-y-6'>
<div
className={clsx('my-4 w-full space-y-6', inputFocusInAndroid && 'h-[50%] overflow-y-auto')}
>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Language')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Language')}</span>
<DropDown
selected={getCurrentUILangOption()}
options={getUILangOptions()}
onSelect={handleSelectUILang}
/>
</div>
</div>
</div>
</div>
<div className='w-full'>
<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'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Paging Animation')}</span>
<input
type='checkbox'
@@ -114,8 +178,17 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='w-full'>
<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='config-item config-item-top config-item-bottom'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Continuous Scroll')}</span>
<input
type='checkbox'
className='toggle'
checked={isContinuousScroll}
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Disable Click-to-Flip')}</span>
<input
type='checkbox'
@@ -135,6 +208,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
>
<div className='relative p-1'>
<textarea
ref={textareaRef}
className={clsx(
'textarea textarea-ghost h-48 w-full border-0 p-3 text-base !outline-none sm:text-sm',
'placeholder:text-base-content/70',
@@ -142,6 +216,8 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
placeholder={_('Enter your custom CSS here...')}
spellCheck='false'
value={draftStylesheet}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onInput={handleInput}
onKeyDown={handleInput}
onKeyUp={handleInput}
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { FiMinus, FiPlus } from 'react-icons/fi';
interface NumberInputProps {
@@ -26,6 +26,10 @@ const NumberInput: React.FC<NumberInputProps> = ({
const [localValue, setLocalValue] = useState(value);
const numberStep = step || 1;
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
@@ -28,7 +28,9 @@ type TabConfig = {
const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ bookKey }) => {
const _ = useTranslation();
const [activePanel, setActivePanel] = useState<SettingsPanelType>('Font');
const [activePanel, setActivePanel] = useState<SettingsPanelType>(
(localStorage.getItem('lastConfigPanel') || 'Font') as SettingsPanelType,
);
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const tabConfig = [
@@ -54,6 +56,11 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
},
] as TabConfig[];
const handleSetActivePanel = (tab: SettingsPanelType) => {
setActivePanel(tab);
localStorage.setItem('lastConfigPanel', tab);
};
const handleClose = () => {
setFontLayoutSettingsDialogOpen(false);
};
@@ -65,6 +72,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
onClose={handleClose}
className='modal-open'
boxClassName='sm:min-w-[520px]'
snapHeight={window.innerWidth < 640 ? 0.7 : undefined}
header={
<div className='flex w-full items-center justify-between'>
<button
@@ -76,7 +84,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
>
<MdArrowBackIosNew />
</button>
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center pl-4 gap-2'>
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center gap-2 pl-4'>
{tabConfig.map(({ tab, icon: Icon, label }) => (
<button
key={tab}
@@ -84,7 +92,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
'btn btn-ghost text-base-content btn-sm',
activePanel === tab ? 'btn-active' : '',
)}
onClick={() => setActivePanel(tab)}
onClick={() => handleSetActivePanel(tab)}
>
<Icon className='mr-0' />
{window.innerWidth >= 500 ? label : ''}
@@ -0,0 +1,161 @@
import { useState } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { CustomTheme } from '@/styles/themes';
import { md5Fingerprint } from '@/utils/md5';
import { CUSTOM_THEME_TEMPLATES } from '@/services/constants';
import { useSettingsStore } from '@/store/settingsStore';
import clsx from 'clsx';
import ColorInput from './ColorInput';
type ThemeEditorProps = {
customTheme: CustomTheme | null;
onSave: (customTheme: CustomTheme) => void;
onDelete: (customTheme: CustomTheme) => void;
onCancel: () => void;
};
const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete, onCancel }) => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const template =
CUSTOM_THEME_TEMPLATES[Math.floor(Math.random() * CUSTOM_THEME_TEMPLATES.length)]!;
const [lightTextColor, setLightTextColor] = useState(
customTheme?.colors.light.fg || template.light.fg,
);
const [lightBackgroundColor, setLightBackgroundColor] = useState(
customTheme?.colors.light.bg || template.light.bg,
);
const [darkTextColor, setDarkTextColor] = useState(
customTheme?.colors.dark.fg || template.dark.fg,
);
const [darkBackgroundColor, setDarkBackgroundColor] = useState(
customTheme?.colors.dark.bg || template.dark.bg,
);
const [themeName, setThemeName] = useState(customTheme?.label || _('Custom'));
const ThemePreview: React.FC<{
textColor: string;
backgroundColor: string;
label: string;
}> = ({ textColor, backgroundColor, label }) => (
<div className='mb-2 mt-4'>
<label className='mb-1 block text-sm font-medium'>{label}</label>
<div
className='border-base-300 overflow-hidden rounded border p-3'
style={{
backgroundColor: backgroundColor,
color: textColor,
}}
>
<p className='mb-2 whitespace-pre-line text-sm'>
{_(
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare",
)}
</p>
</div>
</div>
);
const getCustomTheme = () => {
return {
name: md5Fingerprint(themeName),
label: themeName,
colors: {
light: {
fg: lightTextColor,
bg: lightBackgroundColor,
primary: '#3b82f6',
},
dark: {
fg: darkTextColor,
bg: darkBackgroundColor,
primary: '#60a5fa',
},
},
};
};
return (
<div className='mt-6 rounded-lg'>
<div className='mb-4'>
<div className='mb-4 flex items-center justify-between'>
<label className='font-medium'>{_('Custom Theme')}</label>
<div className='flex w-[calc(50%-12px)] justify-between'>
<button
className='btn btn-ghost btn-sm text-base-content px-2'
onClick={() => onSave(getCustomTheme())}
>
{_('Save')}
</button>
<button
className={clsx(
'btn btn-ghost btn-sm px-2',
!settings.globalReadSettings.customThemes.find(
(theme) => theme.name === md5Fingerprint(themeName),
) && 'btn-disabled',
)}
onClick={() => onDelete(getCustomTheme())}
>
{_('Delete')}
</button>
<button className='btn btn-ghost btn-sm px-2' onClick={onCancel}>
{_('Cancel')}
</button>
</div>
</div>
<div className='mb-4 flex items-center justify-between'>
<label className='font-medium'>{_('Theme Name')}</label>
<input
type='text'
value={themeName}
onChange={(e) => setThemeName(e.target.value)}
className='bg-base-100 text-base-content border-base-200 w-[calc(50%-12px)] rounded border p-2 text-sm'
/>
</div>
</div>
<div className='grid grid-cols-2 gap-6'>
<div className='bg-base-200 rounded-lg p-3'>
<h3 className='mb-3 text-center font-medium'>{_('Light Mode')}</h3>
<ColorInput label={_('Text Color')} value={lightTextColor} onChange={setLightTextColor} />
<ColorInput
label={_('Background Color')}
value={lightBackgroundColor}
onChange={setLightBackgroundColor}
/>
<ThemePreview
textColor={lightTextColor}
backgroundColor={lightBackgroundColor}
label={_('Preview')}
/>
</div>
<div className='bg-base-300 rounded-lg p-3'>
<h3 className='mb-3 text-center font-medium'>{_('Dark Mode')}</h3>
<ColorInput label={_('Text Color')} value={darkTextColor} onChange={setDarkTextColor} />
<ColorInput
label={_('Background Color')}
value={darkBackgroundColor}
onChange={setDarkBackgroundColor}
/>
<ThemePreview
textColor={darkTextColor}
backgroundColor={darkBackgroundColor}
label={_('Preview')}
/>
</div>
</div>
</div>
);
};
export default ThemeEditor;
@@ -1,7 +1,9 @@
import clsx from 'clsx';
import React from 'react';
import Image from 'next/image';
import { MdInfoOutline } from 'react-icons/md';
import { Book } from '@/types/book';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
@@ -10,6 +12,7 @@ import { formatAuthors, formatTitle } from '@/utils/book';
const BookCard = ({ book }: { book: Book }) => {
const { coverImageUrl, title, author } = book;
const _ = useTranslation();
const { isDarkMode } = useThemeStore();
const iconSize18 = useResponsiveSize(18);
const showBookDetails = () => {
@@ -23,14 +26,17 @@ const BookCard = ({ book }: { book: Book }) => {
alt={_('Book Cover')}
width={56}
height={80}
className='me-4 aspect-auto max-h-16 w-[15%] max-w-12 rounded-sm object-cover shadow-md'
className={clsx(
'me-4 aspect-auto max-h-16 w-[15%] max-w-12 rounded-sm object-cover shadow-md',
isDarkMode ? 'mix-blend-screen' : 'mix-blend-multiply',
)}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<div className='min-w-0 flex-1'>
<h4 className='line-clamp-2 w-[90%] text-sm font-semibold'>{formatTitle(title)}</h4>
<p className='text-neutral-content truncate text-xs'>{formatAuthors(author)}</p>
<p className='truncate text-xs opacity-75'>{formatAuthors(author)}</p>
</div>
<button
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
@@ -5,8 +5,10 @@ import Image from 'next/image';
import MenuItem from '@/components/MenuItem';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { isWebAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import useBooksManager from '../../hooks/useBooksManager';
@@ -19,6 +21,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
const _ = useTranslation();
const { getVisibleLibrary } = useLibraryStore();
const { openParallelView } = useBooksManager();
const { sideBarBookKey } = useSidebarStore();
const handleParallelView = (id: string) => {
openParallelView(id);
setIsDropdownOpen?.(false);
@@ -35,6 +39,10 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
window.open(DOWNLOAD_READEST_URL, '_blank');
setIsDropdownOpen?.(false);
};
const handleExportAnnotations = () => {
eventDispatcher.dispatch('export-annotations', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const isWebApp = isWebAppPlatform();
@@ -49,6 +57,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
<MenuItem label={_('Parallel Read')} noIcon>
<ul className='max-h-60 overflow-y-auto'>
{getVisibleLibrary()
.filter((book) => book.format !== 'PDF' && book.format !== 'CBZ')
.slice(0, 20)
.map((book) => (
<MenuItem
@@ -72,6 +81,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
))}
</ul>
</MenuItem>
<MenuItem label={_('Export Annotations')} noIcon onClick={handleExportAnnotations} />
<MenuItem label={_('Reload Page')} noIcon shortcut='Shift+R' onClick={handleReloadPage} />
<hr className='border-base-200 my-1' />
{isWebApp && <MenuItem label={_('Download Readest')} noIcon onClick={downloadReadest} />}
@@ -81,12 +81,16 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
} as React.CSSProperties
}
>
{item.note && <span className='content font-size-sm font-normal'>{item.note}</span>}
{item.note && (
<span className='content font-size-sm font-normal' dir='auto'>
{item.note}
</span>
)}
<div className='flex items-start'>
{item.note && (
<div className='my-1 mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
<div className='my-1 me-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
)}
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'py-2')}>
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'my-2')}>
<span
className={clsx(
'inline',
@@ -116,7 +120,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
}
onClick={(e) => e.stopPropagation()}
>
<div className='flex justify-end space-x-3 p-2'>
<div className='flex justify-end space-x-3 p-2' dir='ltr'>
{item.note && (
<button
className={clsx(
@@ -4,16 +4,9 @@ import * as CFI from 'foliate-js/epubcfi.js';
import { useBookDataStore } from '@/store/bookDataStore';
import { findTocItemBS } from '@/utils/toc';
import { TOCItem } from '@/libs/document';
import { BookNote, BookNoteType } from '@/types/book';
import { BooknoteGroup, BookNoteType } from '@/types/book';
import BooknoteItem from './BooknoteItem';
interface BooknoteGroup {
id: number;
href: string;
label: string;
booknotes: BookNote[];
}
const BooknoteView: React.FC<{
type: BookNoteType;
bookKey: string;
@@ -38,7 +38,10 @@ const SidebarHeader: React.FC<{
>
<MdArrowBackIosNew size={iconSize22} />
</button>
<button className='btn btn-ghost h-8 min-h-8 w-8 p-0' onClick={onGoToLibrary}>
<button
className='btn btn-ghost hidden h-8 min-h-8 w-8 p-0 sm:flex'
onClick={onGoToLibrary}
>
<GiBookshelf className='fill-base-content' />
</button>
</div>
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { FaSearch, FaChevronDown } from 'react-icons/fa';
@@ -7,9 +8,9 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { BookSearchConfig, BookSearchResult } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
const MINIMUM_SEARCH_TERM_LENGTH_DEFAULT = 2;
const MINIMUM_SEARCH_TERM_LENGTH_CJK = 1;
@@ -172,7 +173,11 @@ const SearchBar: React.FC<SearchBarProps> = ({
<div className='bg-base-300 flex h-8 w-8 items-center rounded-r-lg'>
<Dropdown
className='dropdown-bottom flex justify-center'
className={clsx(
window.innerWidth < 640 && 'dropdown-end',
'dropdown-bottom flex justify-center',
)}
menuClassName={window.innerWidth < 640 ? 'no-triangle mt-1' : 'dropdown-center mt-3'}
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 rounded-none rounded-r-lg'
toggleButton={<FaChevronDown size={iconSize12} className='text-gray-500' />}
>
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import { MdCheck } from 'react-icons/md';
import { BookSearchConfig } from '@/types/book';
@@ -6,6 +7,7 @@ import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
interface SearchOptionsProps {
searchConfig: BookSearchConfig;
menuClassName?: string;
onSearchConfigChanged: (searchConfig: BookSearchConfig) => void;
setIsDropdownOpen?: (isOpen: boolean) => void;
}
@@ -32,6 +34,7 @@ const Option: React.FC<OptionProps> = ({ label, isActive, onClick }) => (
const SearchOptions: React.FC<SearchOptionsProps> = ({
searchConfig,
menuClassName,
onSearchConfigChanged,
setIsDropdownOpen,
}) => {
@@ -44,7 +47,10 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
return (
<div
tabIndex={0}
className='book-menu dropdown-content dropdown-center border-base-200 z-20 mt-3 w-56 border shadow-2xl'
className={clsx(
'book-menu dropdown-content dropdown-center border-base-200 z-20 w-56 border shadow-2xl',
menuClassName,
)}
>
<Option
label={_('Book')}
@@ -8,9 +8,10 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { BookSearchResult } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useDrag } from '@/hooks/useDrag';
import { useThemeStore } from '@/store/themeStore';
import SidebarHeader from './Header';
import SidebarContent from './Content';
import BookCard from './BookCard';
@@ -28,7 +29,7 @@ const SideBar: React.FC<{
onGoToLibrary: () => void;
}> = ({ onGoToLibrary }) => {
const { appService } = useEnv();
const { updateAppTheme } = useTheme();
const { updateAppTheme } = useThemeStore();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { getBookData } = useBookDataStore();
@@ -168,6 +169,7 @@ const SideBar: React.FC<{
return null;
}
const { book, bookDoc } = bookData;
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
return isSideBarVisible ? (
<>
@@ -179,7 +181,7 @@ const SideBar: React.FC<{
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
!isSideBarPinned && 'shadow-2xl',
)}
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
style={{
width: `${sideBarWidth}`,
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
@@ -68,7 +68,7 @@ const TOCItemView: React.FC<{
aria-selected={isActive ? 'true' : 'false'}
data-href={item.href ? getContentMd5(item.href) : undefined}
className={`flex w-full cursor-pointer items-center rounded-md py-2 ${
isActive ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-300/85'
isActive ? 'bg-base-300/85 hover:bg-base-300' : 'sm:hover:bg-base-300/85'
}`}
>
{item.subitems && (
@@ -1,6 +1,8 @@
import clsx from 'clsx';
import React from 'react';
import { MdToc, MdEditNote, MdBookmarkBorder } from 'react-icons/md';
import { MdBookmarkBorder as BookmarkIcon } from 'react-icons/md';
import { IoIosList as TOCIcon } from 'react-icons/io';
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
import { useTranslation } from '@/hooks/useTranslation';
@@ -34,13 +36,13 @@ const TabNavigation: React.FC<{
tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark')
}
>
<div className={clsx('')} onClick={() => onTabChange(tab)}>
<div className={clsx('flex h-6 items-center')} onClick={() => onTabChange(tab)}>
{tab === 'toc' ? (
<MdToc className='mx-auto' />
<TOCIcon className='mx-auto' />
) : tab === 'annotations' ? (
<MdEditNote className='mx-auto' />
<NoteIcon className='mx-auto' />
) : (
<MdBookmarkBorder className='mx-auto' />
<BookmarkIcon className='mx-auto' />
)}
</div>
</div>
@@ -1,5 +1,8 @@
import { getOSPlatform } from '@/utils/misc';
// NOTE: Be careful to use this hook. When the scrollbar is set always visible in macOS,
// hide it will change the layout. Make sure to only use it in constrained widgets.
// See https://github.com/readest/readest/issues/600
export const useAutoHideScrollbar = () => {
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
const handleScrollbarAutoHide = (doc: Document) => {
@@ -5,7 +5,6 @@ import useShortcuts from '@/hooks/useShortcuts';
import useBooksManager from './useBooksManager';
import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { tauriQuitApp } from '@/utils/window';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
@@ -21,7 +20,6 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const { toggleNotebook } = useNotebookStore();
const { getNextBookKey } = useBooksManager();
const { themeCode } = useTheme();
const viewSettings = getViewSettings(sideBarBookKey ?? '');
const fontSize = viewSettings?.defaultFontSize ?? 16;
const lineHeight = viewSettings?.lineHeight ?? 1.6;
@@ -100,7 +98,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const zoomLevel = viewSettings!.zoomLevel + ZOOM_STEP;
viewSettings!.zoomLevel = Math.min(zoomLevel, MAX_ZOOM_LEVEL);
setViewSettings(sideBarBookKey, viewSettings!);
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
const zoomOut = () => {
@@ -111,7 +109,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const zoomLevel = viewSettings!.zoomLevel - ZOOM_STEP;
viewSettings!.zoomLevel = Math.max(zoomLevel, MIN_ZOOM_LEVEL);
setViewSettings(sideBarBookKey, viewSettings!);
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
const resetZoom = () => {
@@ -121,7 +119,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const viewSettings = getViewSettings(sideBarBookKey)!;
viewSettings!.zoomLevel = 100;
setViewSettings(sideBarBookKey, viewSettings!);
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
view?.renderer.setStyles?.(getStyles(viewSettings!));
};
useShortcuts(
@@ -59,6 +59,7 @@ export const useClickEvent = (
}
}
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
// The wheel event is handled by the iframe itself in scrolled mode.
const { deltaY } = msg.data;
if (deltaY > 0) {
viewRef.current?.next(1);
@@ -136,7 +137,11 @@ export const useTouchEvent = (
if (hoveredBookKey && touchEnd) {
const deltaY = touchEnd.screenY - touchStart.screenY;
const deltaX = touchEnd.screenX - touchStart.screenX;
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
if (!viewSettings!.scrolled && !viewSettings!.vertical) {
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
setHoveredBookKey(null);
}
} else {
setHoveredBookKey(null);
}
}
@@ -160,8 +165,10 @@ export const useTouchEvent = (
Math.abs(deltaY) > Math.abs(deltaX) &&
Math.abs(deltaX) < windowWidth * 0.3
) {
// swipe up to toggle the header bar and the footer bar
setHoveredBookKey(hoveredBookKey ? null : bookKey);
// swipe up to toggle the header bar and the footer bar, only for horizontal page mode
if (!viewSettings!.scrolled && !viewSettings!.vertical) {
setHoveredBookKey(hoveredBookKey ? null : bookKey);
}
} else {
if (hoveredBookKey) {
setHoveredBookKey(null);
@@ -186,15 +193,10 @@ export const useTouchEvent = (
};
useEffect(() => {
// swipe touch is not compatible with scrolled mode, so only enable it in page mode
if (!viewSettings!.scrolled) {
window.addEventListener('message', handleTouch);
return () => {
window.removeEventListener('message', handleTouch);
};
} else {
return () => {};
}
window.addEventListener('message', handleTouch);
return () => {
window.removeEventListener('message', handleTouch);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey, viewRef]);
};
@@ -72,7 +72,7 @@ export const useNotesSync = (bookKey: string) => {
...oldNotes.filter((oldNote) => !newNotes.some((newNote) => newNote.id === oldNote.id)),
...newNotes.map(processNewNote),
];
setConfig(bookKey, { ...config, booknotes: mergedNotes });
setConfig(bookKey, { booknotes: mergedNotes });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [syncedNotes]);
@@ -6,7 +6,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { serializeConfig } from '@/utils/serializer';
import { CFI } from '@/libs/document';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
@@ -106,14 +106,9 @@ export const useProgressSync = (bookKey: string) => {
configSynced.current = true;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const newConfig = deserializeConfig(
JSON.stringify(syncedConfig),
settings.globalViewSettings,
DEFAULT_BOOK_SEARCH_CONFIG,
);
setConfig(bookKey, { ...config, ...newConfig });
const syncedCFI = syncedConfig.location;
const configCFI = config?.location;
const syncedCFI = syncedConfig.location;
setConfig(bookKey, syncedConfig);
if (syncedCFI && configCFI) {
if (CFI.compare(configCFI, syncedCFI) < 0) {
if (view) {
-2
View File
@@ -1,7 +1,6 @@
'use client';
import { useEffect } from 'react';
import { useTheme } from '@/hooks/useTheme';
import { hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { useTranslation } from '@/hooks/useTranslation';
@@ -13,7 +12,6 @@ export default function Page() {
const _ = useTranslation();
const { settings } = useSettingsStore();
useTheme();
useOpenWithBooks();
useEffect(() => {
@@ -0,0 +1,29 @@
import { ViewSettings } from '@/types/book';
import { EnvConfigType } from '@/services/environment';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
export const saveViewSettings = async <K extends keyof ViewSettings>(
envConfig: EnvConfigType,
bookKey: string,
key: K,
value: ViewSettings[K],
skipGlobal?: boolean,
) => {
const { settings, isFontLayoutSettingsGlobal, setSettings, saveSettings } =
useSettingsStore.getState();
const { getViewSettings, setViewSettings } = useReaderStore.getState();
const { getConfig, saveConfig } = useBookDataStore.getState();
const viewSettings = getViewSettings(bookKey)!;
const config = getConfig(bookKey)!;
viewSettings[key] = value;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal && !skipGlobal) {
settings.globalViewSettings[key] = value;
setSettings(settings);
}
await saveConfig(envConfig, bookKey, config, settings);
await saveSettings(envConfig, settings);
};
+26 -13
View File
@@ -1,7 +1,7 @@
'use client';
import clsx from 'clsx';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { IoArrowBack } from 'react-icons/io5';
@@ -17,6 +17,7 @@ import { navigateToLibrary } from '@/utils/nav';
import { deleteUser } from '@/libs/user';
import { eventDispatcher } from '@/utils/event';
import { Toast } from '@/components/Toast';
import WindowButtons from '@/components/WindowButtons';
import Quota from '@/components/Quota';
const ProfilePage = () => {
@@ -29,6 +30,8 @@ const ProfilePage = () => {
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const headerRef = useRef<HTMLDivElement>(null);
useTheme();
useEffect(() => {
@@ -154,24 +157,34 @@ const ProfilePage = () => {
return (
<div
className={clsx(
'mt-6 flex justify-center',
'bg-base-100 border-base-200 flex h-dvh w-full select-none flex-col items-center border',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
appService?.hasTrafficLight && 'pt-11',
)}
>
<button
onClick={handleGoBack}
<div
ref={headerRef}
className={clsx(
'btn btn-ghost fixed left-4 h-8 min-h-8 w-8 p-0',
appService?.hasSafeAreaInset && 'top-[calc(env(safe-area-inset-top)+16px)]',
appService?.hasTrafficLight && 'top-11',
'flex w-full items-center justify-between py-2 pe-6 ps-4',
appService?.hasTrafficLight && 'pt-11',
)}
>
<IoArrowBack className='text-base-content' />
</button>
<div className='w-full max-w-4xl px-6 py-10'>
<button onClick={handleGoBack} className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0')}>
<IoArrowBack className='text-base-content' />
</button>
{appService?.hasWindowBar && (
<WindowButtons
headerRef={headerRef}
showMinimize={appService?.hasWindowBar}
showMaximize={appService?.hasWindowBar}
showClose={appService?.hasWindowBar}
onClose={handleGoBack}
/>
)}
</div>
<div className='w-full max-w-4xl px-4 py-10'>
<div className='bg-base-200 overflow-hidden rounded-lg p-2 shadow-md sm:p-6'>
<div className='p-6'>
<div className='p-2 sm:p-6'>
<div className='mb-8 flex flex-col items-center gap-x-6 gap-y-4 md:flex-row md:items-start'>
<div className='flex-shrink-0'>
{avatarUrl ? (
@@ -202,7 +215,7 @@ const ProfilePage = () => {
</div>
</div>
<div className='bg-base-100 mb-8 rounded-lg p-6'>
<div className='bg-base-100 mb-8 rounded-lg px-4 py-6'>
<h3 className='text-base-content mb-2 text-lg font-semibold'>{_('Features')}</h3>
<div className='mt-6'>
<ul className='text-base-content/60 grid grid-cols-1 gap-2 md:grid-cols-2'>
@@ -20,7 +20,7 @@ export const AboutWindow = () => {
const [isUpdated, setIsUpdated] = React.useState(false);
const handleCheckUpdate = async () => {
const update = await checkForAppUpdates(_);
const update = await checkForAppUpdates(_, false);
if (!update) {
setIsUpdated(true);
}
+47 -6
View File
@@ -7,11 +7,13 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
const VELOCITY_THRESHOLD = 0.5;
const SNAP_THRESHOLD = 0.2;
interface DialogProps {
id?: string;
isOpen: boolean;
children: ReactNode;
snapHeight?: number;
header?: ReactNode;
title?: string;
className?: string;
@@ -25,6 +27,7 @@ const Dialog: React.FC<DialogProps> = ({
id,
isOpen,
children,
snapHeight,
header,
title,
className,
@@ -34,6 +37,7 @@ const Dialog: React.FC<DialogProps> = ({
onClose,
}) => {
const { appService } = useEnv();
const [isFullHeightInMobile, setIsFullHeightInMobile] = React.useState(!snapHeight);
const iconSize22 = useResponsiveSize(22);
const isMobile = window.innerWidth < 640;
@@ -61,9 +65,12 @@ const Dialog: React.FC<DialogProps> = ({
const newTop = Math.max(0.0, Math.min(1, heightFraction));
if (modal && overlay) {
modal.style.transition = '';
modal.style.height = '100%';
modal.style.transform = `translateY(${newTop * 100}%)`;
overlay.style.opacity = `${1 - heightFraction}`;
setIsFullHeightInMobile(data.clientY < 44);
modal.style.transition = `padding-top 0.3s ease-out`;
}
};
@@ -72,11 +79,14 @@ const Dialog: React.FC<DialogProps> = ({
const overlay = document.querySelector('.overlay') as HTMLElement;
if (!modal || !overlay) return;
const snapUpper = snapHeight ? 1 - snapHeight - SNAP_THRESHOLD : 0.5;
const snapLower = snapHeight ? 1 - snapHeight + SNAP_THRESHOLD : 0.5;
if (
data.velocity > VELOCITY_THRESHOLD ||
(data.velocity >= 0 && data.clientY >= window.innerHeight * 0.5)
(data.velocity >= 0 && data.clientY >= window.innerHeight * snapLower)
) {
const transitionDuration = 0.15 / Math.max(data.velocity, 0.5);
modal.style.height = '100%';
modal.style.transition = `transform ${transitionDuration}s ease-out`;
modal.style.transform = 'translateY(100%)';
overlay.style.transition = `opacity ${transitionDuration}s ease-out`;
@@ -88,7 +98,22 @@ const Dialog: React.FC<DialogProps> = ({
if (appService?.hasHaptics) {
impactFeedback('medium');
}
} else if (
snapHeight &&
data.clientY > window.innerHeight * snapUpper &&
data.clientY < window.innerHeight * snapLower
) {
modal.style.transition = `transform 0.3s ease-out`;
modal.style.transform = `translateY(${(1 - snapHeight) * window.innerHeight}px)`;
setTimeout(() => {
modal.style.height = `${snapHeight * 100}%`;
}, 100);
if (appService?.hasHaptics) {
impactFeedback('medium');
}
} else {
setIsFullHeightInMobile(true);
modal.style.height = '100%';
modal.style.transition = `transform 0.3s ease-out`;
modal.style.transform = `translateY(0%)`;
overlay.style.opacity = '0';
@@ -104,9 +129,15 @@ const Dialog: React.FC<DialogProps> = ({
<dialog
id={id ?? 'dialog'}
open={isOpen}
className={clsx('modal sm:min-w-90 z-50 h-full w-full !bg-transparent sm:w-full', className)}
className={clsx(
'modal sm:min-w-90 z-50 h-full w-full !items-start !bg-transparent sm:w-full sm:!items-center',
className,
)}
>
<div className={clsx('overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20', bgClassName)} />
<div
className={clsx('overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20', bgClassName)}
onClick={onClose}
/>
<div
className={clsx(
'modal-box settings-content z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
@@ -114,13 +145,23 @@ const Dialog: React.FC<DialogProps> = ({
window.innerWidth < window.innerHeight
? 'sm:h-[50%] sm:w-3/4'
: 'sm:h-[65%] sm:w-1/2 sm:max-w-[600px]',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)] sm:pt-0',
appService?.hasSafeAreaInset &&
isFullHeightInMobile &&
'pt-[env(safe-area-inset-top)] sm:pt-0',
boxClassName,
)}
style={
snapHeight
? {
height: `${snapHeight * 100}%`,
transform: `translateY(${(1 - snapHeight) * window.innerHeight}px)`,
}
: {}
}
>
{window.innerWidth < 640 && (
<div
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
className='drag-handle flex h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center'
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
>
@@ -0,0 +1,20 @@
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { HiArrowDownTray } from 'react-icons/hi2';
const DropIndicator: React.FC = () => {
const _ = useTranslation();
return (
<>
<div className='drag-overlay'></div>
<div className='drop-indicator'>
<div className='flex flex-col items-center justify-center'>
<HiArrowDownTray className='h-12 w-12' />
<p className='mt-2 font-medium'>{_('Drop to Import Books')}</p>
</div>
</div>
</>
);
};
export default DropIndicator;
@@ -0,0 +1,93 @@
import React, { useEffect, useState } from 'react';
interface SliderProps {
min?: number;
max?: number;
step?: number;
initialValue?: number;
heightPx?: number;
minLabel?: string;
maxLabel?: string;
bubbleElement?: React.ReactNode;
bubbleLabel?: string;
className?: string;
minClassName?: string;
maxClassName?: string;
bubbleClassName?: string;
onChange?: (value: number) => void;
}
const Slider: React.FC<SliderProps> = ({
min = 0,
max = 100,
step = 1,
initialValue = 50,
heightPx = 40,
minLabel = '',
maxLabel = '',
bubbleElement,
bubbleLabel = '',
className = '',
minClassName = '',
maxClassName = '',
bubbleClassName = '',
onChange,
}) => {
const [value, setValue] = useState(initialValue);
const handleChange = (e: React.ChangeEvent) => {
const newValue = parseInt((e.target as HTMLInputElement).value, 10);
setValue(newValue);
if (onChange) {
onChange(newValue);
}
};
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className={`slider bg-base-200 mx-auto w-full max-w-md rounded-xl ${className}`}>
<div className='relative' style={{ height: `${heightPx}px` }}>
<div className='bg-base-300/40 absolute h-full w-full rounded-full'></div>
<div
className='bg-base-300 absolute h-full rounded-full'
style={{ width: percentage > 0 ? `calc(${percentage}% + ${heightPx / 2}px)` : '0px' }}
></div>
<div className='absolute inset-0 flex items-center justify-between px-4 text-sm'>
<span className={`ml-2 ${minClassName}`}>{minLabel}</span>
<span className={`mr-2 ${maxClassName}`}>{maxLabel}</span>
</div>
<div
className='pointer-events-none absolute top-0 z-10'
style={{
left: `max(${heightPx / 2}px, calc(${percentage}%))`,
transform: 'translateX(calc(-50%))',
height: '100%',
}}
>
<div
className={`bg-base-200 flex h-full items-center justify-center rounded-full text-sm shadow-md ${bubbleClassName}`}
style={{ width: `${heightPx}px` }}
>
{bubbleElement || bubbleLabel}
</div>
</div>
<input
type='range'
min={min}
max={max}
step={step}
value={value}
className='absolute inset-0 h-full w-full cursor-pointer opacity-0'
onChange={handleChange}
/>
</div>
</div>
);
};
export default Slider;
+2 -2
View File
@@ -64,8 +64,8 @@ export const Toast = () => {
>
<span
className={clsx(
'max-h-[50vh] min-w-[30vw] max-w-80',
'overflow-scroll whitespace-normal break-words text-center',
'max-h-[50vh] min-w-32 max-w-80',
'overflow-y-auto whitespace-normal break-words text-center',
messageClass.current,
)}
>
@@ -3,6 +3,7 @@
import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react';
import { User } from '@supabase/supabase-js';
import { supabase } from '@/utils/supabase';
import posthog from 'posthog-js';
interface AuthContextType {
token: string | null;
@@ -38,6 +39,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
localStorage.setItem('token', access_token);
localStorage.setItem('refresh_token', refresh_token);
localStorage.setItem('user', JSON.stringify(user));
posthog.identify(user.id);
setToken(access_token);
setUser(user);
} else {
+3 -2
View File
@@ -6,10 +6,11 @@ import { TranslationFunc } from '@/hooks/useTranslation';
const LAST_CHECK_KEY = 'lastAppUpdateCheck';
export const checkForAppUpdates = async (_: TranslationFunc) => {
export const checkForAppUpdates = async (_: TranslationFunc, autoCheck = true) => {
const lastCheck = localStorage.getItem(LAST_CHECK_KEY);
const now = Date.now();
if (lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000) return;
if (autoCheck && lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000)
return;
localStorage.setItem(LAST_CHECK_KEY, now.toString());
console.log('Checking for updates');
+15 -103
View File
@@ -1,109 +1,21 @@
'use client';
import { useEffect, useState } from 'react';
import { ThemeCode } from '@/utils/style';
import { themes, Palette } from '@/styles/themes';
import { isWebAppPlatform } from '@/services/environment';
export type ThemeMode = 'auto' | 'light' | 'dark';
import { useEffect } from 'react';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { applyCustomTheme } from '@/styles/themes';
export const useTheme = () => {
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
if (typeof window !== 'undefined' && localStorage) {
return (localStorage.getItem('themeMode') as ThemeMode) || 'auto';
}
return 'auto';
});
const [themeColor, setThemeColor] = useState(() => {
if (typeof window !== 'undefined' && localStorage) {
return localStorage.getItem('themeColor') || 'default';
}
return 'default';
});
const [systemIsDarkMode, setSystemIsDarkMode] = useState(() => {
return (
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
);
});
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
const [themeCode, setThemeCode] = useState<ThemeCode>(() => {
let themeMode = 'auto';
let themeColor = 'default';
let systemIsDarkMode = false;
if (typeof window !== 'undefined') {
themeColor = localStorage.getItem('themeColor') || 'default';
themeMode = localStorage.getItem('themeMode') as ThemeMode;
systemIsDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
const defaultTheme = themes.find((theme) => theme.name === themeColor);
const defaultPalette = isDarkMode ? defaultTheme!.colors.dark : defaultTheme!.colors.light;
return {
bg: defaultPalette['base-100'],
fg: defaultPalette['base-content'],
primary: defaultPalette.primary,
palette: defaultPalette,
};
});
const { settings } = useSettingsStore();
const { themeColor, isDarkMode } = useThemeStore();
useEffect(() => {
const customThemes = settings.globalReadSettings?.customThemes ?? [];
customThemes.forEach((customTheme) => {
applyCustomTheme(customTheme);
});
}, [settings]);
useEffect(() => {
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
setSystemIsDarkMode(mediaQuery.matches);
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
return () => mediaQuery.removeEventListener('change', handleSystemThemeChange);
}
return undefined;
}, []);
useEffect(() => {
document.documentElement.setAttribute(
'data-theme',
`${themeColor}-${isDarkMode ? 'dark' : 'light'}`,
);
const colorScheme = isDarkMode ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', `${themeColor}-${colorScheme}`);
document.documentElement.style.setProperty('color-scheme', colorScheme);
}, [themeColor, isDarkMode]);
useEffect(() => {
if (typeof window !== 'undefined' && localStorage) {
localStorage.setItem('themeMode', themeMode);
localStorage.setItem('themeColor', themeColor);
}
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
const theme = themes.find((t) => t.name === themeColor);
const palette = isDarkMode ? theme!.colors.dark : theme!.colors.light;
const bg = palette['base-100'];
const fg = palette['base-content'];
const primary = palette.primary;
if (isWebAppPlatform()) {
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg);
}
setThemeCode({ bg, fg, primary, palette });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeMode, themeColor, isDarkMode]);
const updateThemeMode = (mode: ThemeMode) => setThemeMode(mode);
const updateThemeColor = (color: string) => setThemeColor(color);
const updateAppTheme = (color: keyof Palette) => {
if (isWebAppPlatform()) {
const { palette } = themeCode;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', palette[color]);
}
};
return {
themeMode,
themeColor,
themeCode,
isDarkMode,
updateThemeMode,
updateThemeColor,
updateAppTheme,
};
};
+1 -1
View File
@@ -12,7 +12,7 @@ export const useUICSS = (bookKey: string, viewSettings: ViewSettings) => {
styleElement.remove();
}
const rawCSS = viewSettings.userStylesheet;
const rawCSS = viewSettings.userStylesheet || '';
const newStyleEl = document.createElement('style');
newStyleEl.textContent = rawCSS.replace('foliate-view', `#foliate-view-${bookKey}`);
document.head.appendChild(newStyleEl);
+27 -2
View File
@@ -99,6 +99,31 @@ export class DocumentLoader {
}
private async makeZipLoader() {
const getComment = async (): Promise<string | null> => {
const EOCD_SIGNATURE = [0x50, 0x4b, 0x05, 0x06];
const maxEOCDSearch = 1024 * 64;
const sliceSize = Math.min(maxEOCDSearch, this.file.size);
const tail = await this.file.slice(this.file.size - sliceSize, this.file.size).arrayBuffer();
const bytes = new Uint8Array(tail);
for (let i = bytes.length - 22; i >= 0; i--) {
if (
bytes[i] === EOCD_SIGNATURE[0] &&
bytes[i + 1] === EOCD_SIGNATURE[1] &&
bytes[i + 2] === EOCD_SIGNATURE[2] &&
bytes[i + 3] === EOCD_SIGNATURE[3]
) {
const commentLength = bytes[i + 20]! + (bytes[i + 21]! << 8);
const commentStart = i + 22;
const commentBytes = bytes.slice(commentStart, commentStart + commentLength);
return new TextDecoder().decode(commentBytes);
}
}
return null;
};
const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = await import(
'@zip.js/zip.js'
);
@@ -120,7 +145,7 @@ export class DocumentLoader {
);
const getSize = (name: string) => map.get(name)?.uncompressedSize ?? 0;
return { entries, loadText, loadBlob, getSize, sha1: undefined };
return { entries, loadText, loadBlob, getSize, getComment, sha1: undefined };
}
private isCBZ(): boolean {
@@ -155,7 +180,7 @@ export class DocumentLoader {
if (this.isCBZ()) {
const { makeComicBook } = await import('foliate-js/comic-book.js');
book = makeComicBook(loader, this.file);
book = await makeComicBook(loader, this.file);
format = 'CBZ';
} else if (this.isFBZ()) {
const entry = entries.find((entry) => entry.filename.endsWith(`.${EXTS.FB2}`));
@@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { supabase } from '@/utils/supabase';
import { getUserPlan } from '@/utils/access';
import { query as deeplQuery } from '@/utils/deepl';
const DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
const DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
@@ -27,8 +28,9 @@ const getDeepLAPIKey = (keys: string | undefined) => {
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { user, token } = await getUserAndToken(req.headers['authorization']);
let deeplApiUrl = DEEPL_FREE_API;
let userPlan = 'free';
if (user && token) {
const userPlan = getUserPlan(token);
userPlan = getUserPlan(token);
if (userPlan !== 'free') deeplApiUrl = DEEPL_PRO_API;
}
const deeplAuthKey =
@@ -38,17 +40,31 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await runMiddleware(req, res, corsAllMethods);
const {
text,
source_lang: sourceLang = 'auto',
target_lang: targetLang = 'en',
}: { text: string[]; source_lang: string; target_lang: string } = req.body;
try {
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${deeplAuthKey}`,
'Content-Type': 'application/json',
},
body: req.method === 'POST' ? JSON.stringify(req.body) : undefined,
});
res.status(response.status);
res.json(await response.json());
if (targetLang.toLowerCase().includes('zh')) {
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${deeplAuthKey}`,
'Content-Type': 'application/json',
},
body: req.method === 'POST' ? JSON.stringify(req.body) : undefined,
});
res.status(response.status);
res.json(await response.json());
} else {
const result = await deeplQuery({
text: text[0] ?? '',
sourceLang,
targetLang,
});
res.status(200).json(result);
}
} catch (error) {
console.error('Error proxying DeepL request:', error);
res.status(500).json({ error: 'Internal Server Error' });
@@ -2,8 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { createSupabaseClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { s3Client } from '@/utils/s3';
import { deleteObject } from '@/utils/r2';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
@@ -41,13 +40,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(403).json({ error: 'Unauthorized access to the file' });
}
const deleteCommand = new DeleteObjectCommand({
Bucket: process.env['R2_BUCKET_NAME'] || '',
Key: fileKey,
});
try {
await s3Client.send(deleteCommand);
await deleteObject(process.env['R2_BUCKET_NAME'] || '', fileKey);
const { error: deleteError } = await supabase.from('files').delete().eq('id', fileRecord.id);
if (deleteError) {
@@ -1,9 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase, createSupabaseClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3Client } from '@/utils/s3';
import { getDownloadSignedUrl } from '@/utils/r2';
const getUserAndToken = async (authHeader: string | undefined) => {
if (!authHeader) return {};
@@ -56,15 +54,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(403).json({ error: 'Unauthorized access to the file' });
}
const getCommand = new GetObjectCommand({
Bucket: process.env['R2_BUCKET_NAME'] || '',
Key: fileKey,
});
try {
const downloadUrl = await getSignedUrl(s3Client, getCommand, {
expiresIn: 1800,
});
const bucketName = process.env['R2_BUCKET_NAME'] || '';
const downloadUrl = await getDownloadSignedUrl(bucketName, fileKey, 1800);
res.status(200).json({
downloadUrl,
@@ -1,10 +1,8 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase, createSupabaseClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getStoragePlanData } from '@/utils/access';
import { s3Client } from '@/utils/s3';
import { getUploadSignedUrl } from '@/utils/r2';
const getUserAndToken = async (authHeader: string | undefined) => {
if (!authHeader) return {};
@@ -75,19 +73,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (insertError) return res.status(500).json({ error: insertError.message });
}
const signableHeaders = new Set<string>();
signableHeaders.add('content-length');
const putCommand = new PutObjectCommand({
Bucket: process.env['R2_BUCKET_NAME'] || '',
Key: objectKey,
ContentLength: objSize,
});
try {
const uploadUrl = await getSignedUrl(s3Client, putCommand, {
expiresIn: 1800,
signableHeaders,
});
const uploadUrl = await getUploadSignedUrl(
process.env['R2_BUCKET_NAME'] || '',
objectKey,
objSize,
1800,
);
res.status(200).json({
uploadUrl,
+14 -3
View File
@@ -36,6 +36,7 @@ import { getOSPlatform, isCJKEnv, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
import { ProgressHandler } from '@/utils/transfer';
import { TxtToEpubConverter } from '@/utils/txt';
import { BOOK_FILE_NOT_FOUND_ERROR } from './errors';
export abstract class BaseAppService implements AppService {
@@ -48,6 +49,7 @@ export abstract class BaseAppService implements AppService {
abstract isAndroidApp: boolean;
abstract isIOSApp: boolean;
abstract hasTrafficLight: boolean;
abstract hasWindow: boolean;
abstract hasWindowBar: boolean;
abstract hasContextMenu: boolean;
abstract hasRoundedWindow: boolean;
@@ -125,11 +127,12 @@ export abstract class BaseAppService implements AppService {
}
async importBook(
file: string | File,
file: string | File, // file path/url or file object
books: Book[],
saveBook: boolean = true,
saveCover: boolean = true,
overwrite: boolean = false,
transient: boolean = false,
): Promise<Book | null> {
try {
let loadedBook: BookDoc;
@@ -145,6 +148,10 @@ export abstract class BaseAppService implements AppService {
filename = file.name;
fileobj = file;
}
if (filename.endsWith('.txt')) {
const txt2epub = new TxtToEpubConverter();
({ file: fileobj } = await txt2epub.convert({ file: fileobj }));
}
({ book: loadedBook, format } = await new DocumentLoader(fileobj).open());
if (!loadedBook.metadata.title) {
loadedBook.metadata.title = getBaseFilename(filename);
@@ -157,7 +164,7 @@ export abstract class BaseAppService implements AppService {
const hash = await partialMD5(fileobj);
const existingBook = books.filter((b) => b.hash === hash)[0];
if (existingBook) {
if (existingBook.deletedAt) {
if (!transient) {
existingBook.deletedAt = null;
}
existingBook.updatedAt = Date.now();
@@ -170,6 +177,7 @@ export abstract class BaseAppService implements AppService {
author: formatAuthors(loadedBook.metadata.author, loadedBook.metadata.language),
createdAt: existingBook ? existingBook.createdAt : Date.now(),
uploadedAt: existingBook ? existingBook.uploadedAt : null,
deletedAt: transient ? Date.now() : null,
downloadedAt: Date.now(),
updatedAt: Date.now(),
};
@@ -177,7 +185,7 @@ export abstract class BaseAppService implements AppService {
await this.fs.createDir(getDir(book), 'Books');
}
if (saveBook && (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite)) {
if (typeof file === 'string' && !isValidURL(file)) {
if (typeof file === 'string' && !isValidURL(file) && !filename.endsWith('.txt')) {
await this.fs.copyFile(file, getFilename(book), 'Books');
} else {
await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer());
@@ -193,6 +201,9 @@ export abstract class BaseAppService implements AppService {
if (!existingBook) {
await this.saveBookConfig(book, INIT_BOOK_CONFIG);
books.splice(0, 0, book);
} else {
existingBook.title = book.title;
existingBook.author = book.author;
}
if (typeof file === 'string' && isValidURL(file)) {

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