forked from akai/readest
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebdfc39ef6 | |||
| 9f0d8b5c12 | |||
| a267671671 | |||
| aa62ebd3f0 | |||
| cc85e5c304 | |||
| b0cc0b6f1a | |||
| 6b033f7509 | |||
| 5c0670031a | |||
| 35735cd931 | |||
| 699a01c78f | |||
| 033e161455 | |||
| e6428656c2 | |||
| 68c3c45da3 | |||
| 36e87d4eb6 | |||
| 7f5059a8ea | |||
| 8dc04f4095 | |||
| 6a48f3feb4 | |||
| facf5fba75 | |||
| 9700942e24 | |||
| e238b49fbf | |||
| 32dfadf182 | |||
| 66744108e2 | |||
| 401f28847c | |||
| 9674b45cf0 | |||
| a4bc88d305 | |||
| b6b8b17760 | |||
| 14810d6737 | |||
| ed6c92a65a | |||
| 20d7a1185e | |||
| 32bcfda9ad | |||
| def157cacb | |||
| 7c464b9a8d | |||
| 90f3512ae2 | |||
| 12d93ba749 | |||
| 554d786105 | |||
| eb71c59524 | |||
| 93e81ee05b | |||
| b83972e366 | |||
| 967536aa23 | |||
| 4523437e34 | |||
| e49dd4accb | |||
| b7c1f5703c | |||
| 5373b67aae | |||
| ddc9673260 | |||
| 16b0821eab | |||
| 93876a73cc | |||
| 048867e595 | |||
| 602be376a1 | |||
| c52ae43cd9 | |||
| 348d743e82 | |||
| c7e495c379 | |||
| e368076cac | |||
| 382e0ca3c6 | |||
| d96fb32bcf | |||
| 4779e2638f | |||
| d094ba9916 | |||
| e4ba0c302e | |||
| 0150177a9d | |||
| 74e32d47b0 | |||
| 2dd7cf54e6 | |||
| a5cbfe97db | |||
| da49526f4c | |||
| 2e46bc1627 | |||
| 7e542e8694 | |||
| 2a624ef57a | |||
| 60ae0c9ac3 | |||
| 110b4b37cd | |||
| f0edbf14a9 | |||
| 20669a9cb3 | |||
| 3756bec66c | |||
| 3e1d03dc17 | |||
| 4aebbf679f | |||
| 0a562d90ac | |||
| e91a39e8b6 | |||
| 2006baac4b | |||
| 0d4f61f08a | |||
| 0b5f0ede3b | |||
| 99a146525f | |||
| 6080f9e0f3 | |||
| 6b3d41a4b8 | |||
| e403c009c2 | |||
| 14bf2bcf8f | |||
| 7f66af71c8 | |||
| cbab201138 |
@@ -7,13 +7,17 @@ assignees: ''
|
||||
---
|
||||
|
||||
**Does your feature request involve difficulty for you to complete a task? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
|
||||
> A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you'd like to happen.
|
||||
|
||||
> A clear and concise description of what you'd like to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
> A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any additional context or screenshots about the feature request here.
|
||||
|
||||
> Add any additional context or screenshots about the feature request here.
|
||||
|
||||
@@ -11,22 +11,16 @@ assignees: ''
|
||||
A clear and concise description of what the current behavior is.
|
||||
Please also add **screenshots** of the existing application.
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
|
||||
[screenshot]
|
||||
```
|
||||
> **Example:**
|
||||
> In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
|
||||
> [screenshot]
|
||||
|
||||
## Expected behavior
|
||||
|
||||
A clear and concise description of what the expected behavior is.
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
The blank square should be transparent (invisible)
|
||||
```
|
||||
> **Example:**
|
||||
> The blank square should be transparent (invisible)
|
||||
|
||||
## Technical inputs
|
||||
|
||||
@@ -34,11 +28,8 @@ Operating System:
|
||||
|
||||
Readest Version:
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
Operating System: macOS 14.3.1
|
||||
Readest Version: 0.9.0
|
||||
We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
|
||||
Probably fixable with CSS
|
||||
```
|
||||
> **Example:**
|
||||
> Operating System: Android 14 (WebView 135.0)
|
||||
> Readest Version: 0.9.0
|
||||
> We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
|
||||
> Probably fixable with CSS
|
||||
|
||||
@@ -67,6 +67,11 @@ jobs:
|
||||
release: linux
|
||||
arch: aarch64
|
||||
rust_target: aarch64-unknown-linux-gnu
|
||||
- os: ubuntu-22.04-arm
|
||||
release: linux
|
||||
arch: armhf
|
||||
rust_target: arm-unknown-linux-gnueabihf
|
||||
args: '--target arm-unknown-linux-gnueabihf --bundles deb'
|
||||
- os: macos-latest
|
||||
release: macos
|
||||
arch: aarch64
|
||||
@@ -76,7 +81,7 @@ jobs:
|
||||
release: windows
|
||||
arch: x86_64
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
args: '--target x86_64-pc-windows-msvc'
|
||||
args: '--target x86_64-pc-windows-msvc --bundles nsis'
|
||||
- os: windows-latest
|
||||
release: windows
|
||||
arch: aarch64
|
||||
@@ -133,11 +138,22 @@ jobs:
|
||||
key: ${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android'
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android' && matrix.config.arch != 'armhf'
|
||||
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: install dependencies (ubuntu only - armhf specific)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.arch == 'armhf'
|
||||
run: |
|
||||
sudo dpkg --add-architecture armhf
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev:armhf libgtk-3-dev:armhf libwebkit2gtk-4.1-dev:armhf libappindicator3-dev:armhf librsvg2-dev:armhf gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf
|
||||
echo 'PKG_CONFIG_ALLOW_CROSS=1' >> $GITHUB_ENV
|
||||
echo 'PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/share/pkgconfig' >> $GITHUB_ENV
|
||||
echo 'PKG_CONFIG_SYSROOT_DIR=/usr/arm-linux-gnueabihf' >> $GITHUB_ENV
|
||||
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc' >> $GITHUB_ENV
|
||||
|
||||
- name: create .env.local file for Next.js
|
||||
run: |
|
||||
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
url = https://github.com/readest/foliate-js.git
|
||||
[submodule "packages/tauri"]
|
||||
path = packages/tauri
|
||||
url = https://github.com/tauri-apps/tauri.git
|
||||
url = https://github.com/readest/tauri.git
|
||||
|
||||
Generated
+490
-446
File diff suppressed because it is too large
Load Diff
@@ -48,12 +48,12 @@
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
|
||||
| **Translate with DeepL** | From a single sentence to the entire book—translate instantly with DeepL. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
@@ -78,10 +78,10 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -17,6 +17,7 @@ module.exports = {
|
||||
'el',
|
||||
'ko',
|
||||
'uk',
|
||||
'nl',
|
||||
'pl',
|
||||
'pt',
|
||||
'ru',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.39",
|
||||
"version": "0.9.50",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -32,6 +32,7 @@
|
||||
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
|
||||
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler dev",
|
||||
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler deploy",
|
||||
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -42,19 +43,19 @@
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
"@supabase/supabase-js": "^2.47.7",
|
||||
"@tauri-apps/api": "2.4.1",
|
||||
"@tauri-apps/api": "2.5.0",
|
||||
"@tauri-apps/plugin-cli": "^2.2.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-fs": "^2.2.1",
|
||||
"@tauri-apps/plugin-haptics": "^2.2.4",
|
||||
"@tauri-apps/plugin-http": "^2.4.3",
|
||||
"@tauri-apps/plugin-log": "^2.3.1",
|
||||
"@tauri-apps/plugin-log": "^2.4.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.6",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-process": "^2.2.1",
|
||||
"@tauri-apps/plugin-shell": "~2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.7.0",
|
||||
"@tauri-apps/plugin-updater": "^2.7.1",
|
||||
"@zip.js/zip.js": "^2.7.53",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -67,7 +68,7 @@
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "15.2.4",
|
||||
"posthog-js": "^1.205.0",
|
||||
"posthog-js": "^1.246.0",
|
||||
"react": "19.0.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "19.0.0",
|
||||
@@ -80,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opennextjs/cloudflare": "^0.5.12",
|
||||
"@tauri-apps/cli": "2.4.1",
|
||||
"@tauri-apps/cli": "2.5.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/cssbeautify": "^0.3.5",
|
||||
"@types/node": "^22.10.1",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "الرسوم المتحركة",
|
||||
"Annotate": "إضافة ملاحظة",
|
||||
"Apply": "تطبيق",
|
||||
"Are you sure to delete the selected books?": "هل أنت متأكد من رغبتك في حذف الكتب المحددة؟",
|
||||
"Auto Mode": "الوضع التلقائي",
|
||||
"Behavior": "السلوك",
|
||||
"Book": "الكتاب",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "الإعدادات العامة",
|
||||
"Go Back": "العودة",
|
||||
"Go Forward": "التقدم",
|
||||
"Go Left": "الذهاب لليسار",
|
||||
"Go Right": "الذهاب لليمين",
|
||||
"Grass": "عشبي",
|
||||
"Gray": "رمادي",
|
||||
"Gruvbox": "چروفبوكس",
|
||||
"Highlight": "تمييز",
|
||||
"Horizontal Direction": "الاتجاه الأفقي",
|
||||
"Hyphenation": "الواصلة",
|
||||
"Identifier:": "المعرف:",
|
||||
"Import Books": "استيراد الكتب",
|
||||
"Language:": "اللغة:",
|
||||
"Layout": "التخطيط",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "مطابقة الكلمات الكاملة",
|
||||
"Maximum Number of Columns": "العدد الأقصى للأعمدة",
|
||||
"Minimum Font Size": "الحد الأدنى لحجم الخط",
|
||||
"Misc": "أخرى",
|
||||
"Monospace Font": "خط أحادي المسافة (Monospace)",
|
||||
"More Info": "مزيد من المعلومات",
|
||||
"Nord": "نورد",
|
||||
@@ -303,5 +298,41 @@
|
||||
"Orientation": "الاتجاه",
|
||||
"Portrait": "العمودي",
|
||||
"Landscape": "الأفقي",
|
||||
"Open Last Book on Start": "فتح آخر كتاب عند البدء"
|
||||
"Open Last Book on Start": "فتح آخر كتاب عند البدء",
|
||||
"Checking for updates...": "جارٍ التحقق من التحديثات...",
|
||||
"Error checking for updates": "خطأ في التحقق من التحديثات",
|
||||
"Details": "التفاصيل",
|
||||
"File Size:": "حجم الملف:",
|
||||
"Auto Detect": "الكشف التلقائي",
|
||||
"Next Section": "القسم التالي",
|
||||
"Previous Section": "القسم السابق",
|
||||
"Next Page": "الصفحة التالية",
|
||||
"Previous Page": "الصفحة السابقة",
|
||||
"Are you sure to delete {{count}} selected book(s)?_zero": "هل أنت متأكد من حذف الكتب المحددة؟",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "هل أنت متأكد من حذف الكتاب المحدد؟",
|
||||
"Are you sure to delete {{count}} selected book(s)?_two": "هل أنت متأكد من حذف الكتابين المحددين؟",
|
||||
"Are you sure to delete {{count}} selected book(s)?_few": "هل أنت متأكد من حذف {{count}} كتب محددة؟",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "هل أنت متأكد من حذف {{count}} كتابًا محددًا؟",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "هل أنت متأكد من حذف {{count}} من الكتب المحددة؟",
|
||||
"Are you sure to delete the selected book?": "هل أنت متأكد من حذف الكتاب المحدد؟",
|
||||
"Deselect": "إلغاء",
|
||||
"Select All": "الكل",
|
||||
"No translation available.": "لا توجد ترجمة متاحة.",
|
||||
"Translated by {{provider}}.": "ترجم بواسطة {{provider}}.",
|
||||
"DeepL": "ديب إل",
|
||||
"Google Translate": "ترجمة جوجل",
|
||||
"Azure Translator": "مترجم أزور",
|
||||
"Invert Image In Dark Mode": "عكس الصورة في الوضع المظلم",
|
||||
"Help improve Readest": "ساعد في تحسين ريديست",
|
||||
"Sharing anonymized statistics": "مشاركة إحصائيات مجهولة الهوية",
|
||||
"Interface Language": "لغة الواجهة",
|
||||
"Translation": "الترجمة",
|
||||
"Enable Translation": "تمكين الترجمة",
|
||||
"Translation Service": "خدمة الترجمة",
|
||||
"Translate To": "ترجمة إلى",
|
||||
"Disable Translation": "تعطيل الترجمة",
|
||||
"Scroll": "تمرير",
|
||||
"Overlap Pixels": "تداخل البكسلات",
|
||||
"Click": "نقر",
|
||||
"System Language": "لغة النظام"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animation",
|
||||
"Annotate": "Annotieren",
|
||||
"Apply": "Anwenden",
|
||||
"Are you sure to delete the selected books?": "Möchten Sie die ausgewählten Bücher wirklich löschen?",
|
||||
"Auto Mode": "Automatischer Modus",
|
||||
"Behavior": "Verhalten",
|
||||
"Book": "Buch",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Globale Einstellungen",
|
||||
"Go Back": "Zurück",
|
||||
"Go Forward": "Vorwärts",
|
||||
"Go Left": "Nach links",
|
||||
"Go Right": "Nach rechts",
|
||||
"Grass": "Grasgrün",
|
||||
"Gray": "Grau",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Hervorheben",
|
||||
"Horizontal Direction": "Horizontale Richtung",
|
||||
"Hyphenation": "Silbentrennung",
|
||||
"Identifier:": "Kennung:",
|
||||
"Import Books": "Bücher importieren",
|
||||
"Language:": "Sprache:",
|
||||
"Layout": "Layout",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Ganze Wörter",
|
||||
"Maximum Number of Columns": "Maximale Spaltenanzahl",
|
||||
"Minimum Font Size": "Minimale Schriftgröße",
|
||||
"Misc": "Sonstiges",
|
||||
"Monospace Font": "Monospace-Schriftart",
|
||||
"More Info": "Weitere Informationen",
|
||||
"Nord": "Nord",
|
||||
@@ -134,7 +129,7 @@
|
||||
"Book downloaded: {{title}}": "Buch heruntergeladen: {{title}}",
|
||||
"Failed to download book: {{title}}": "Fehler beim Herunterladen des Buches: {{title}}",
|
||||
"Upload Book": "Buch hochladen",
|
||||
"Auto Upload Books to Cloud": "Bücher automatisch in die Cloud hochladen",
|
||||
"Auto Upload Books to Cloud": "Bücher automatisch hochladen",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% des Cloud-Speichers verwendet.",
|
||||
"Storage": "Speicher",
|
||||
"Book deleted: {{title}}": "Buch gelöscht: {{title}}",
|
||||
@@ -303,5 +298,37 @@
|
||||
"Orientation": "Orientierung",
|
||||
"Portrait": "Hochformat",
|
||||
"Landscape": "Querformat",
|
||||
"Open Last Book on Start": "Letztes Buch beim Start öffnen"
|
||||
"Open Last Book on Start": "Letztes Buch beim Start öffnen",
|
||||
"Checking for updates...": "Auf Updates prüfen...",
|
||||
"Error checking for updates": "Fehler beim Überprüfen auf Updates",
|
||||
"Details": "Details",
|
||||
"File Size:": "Dateigröße:",
|
||||
"Auto Detect": "Automatisch erkennen",
|
||||
"Next Section": "Nächster Abschnitt",
|
||||
"Previous Section": "Vorheriger Abschnitt",
|
||||
"Next Page": "Nächste Seite",
|
||||
"Previous Page": "Vorherige Seite",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Möchtest du das ausgewählte Buch wirklich löschen?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Möchtest du die {{count}} ausgewählten Bücher wirklich löschen?",
|
||||
"Are you sure to delete the selected book?": "Möchtest du das ausgewählte Buch wirklich löschen?",
|
||||
"Deselect": "Aufheben",
|
||||
"Select All": "Alle",
|
||||
"No translation available.": "Keine Übersetzung verfügbar.",
|
||||
"Translated by {{provider}}.": "Übersetzt von {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Übersetzer",
|
||||
"Azure Translator": "Azure Übersetzer",
|
||||
"Invert Image In Dark Mode": "Invertiere Bild im Dunkeln",
|
||||
"Help improve Readest": "Readest verbessern helfen",
|
||||
"Sharing anonymized statistics": "Anonymisierte Statistiken teilen",
|
||||
"Interface Language": "Benutzeroberflächensprache",
|
||||
"Translation": "Übersetzung",
|
||||
"Enable Translation": "Übersetzung aktivieren",
|
||||
"Translation Service": "Übersetzungsdienst",
|
||||
"Translate To": "Übersetzen nach",
|
||||
"Disable Translation": "Übersetzung deaktivieren",
|
||||
"Scroll": "Scrollen",
|
||||
"Overlap Pixels": "Überlappungspixel",
|
||||
"Click": "Tippen",
|
||||
"System Language": "Systemsprache"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Κινούμενα σχέδια",
|
||||
"Annotate": "Σχολιασμός",
|
||||
"Apply": "Εφαρμογή",
|
||||
"Are you sure to delete the selected books?": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τα επιλεγμένα βιβλία;",
|
||||
"Auto Mode": "Αυτόματη λειτουργία",
|
||||
"Behavior": "Συμπεριφορά",
|
||||
"Book": "Βιβλίο",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Γενικές ρυθμίσεις",
|
||||
"Go Back": "Πίσω",
|
||||
"Go Forward": "Μπροστά",
|
||||
"Go Left": "Αριστερά",
|
||||
"Go Right": "Δεξιά",
|
||||
"Grass": "Γρασίδι",
|
||||
"Gray": "Γκρι",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Επισήμανση",
|
||||
"Horizontal Direction": "Οριζόντια κατεύθυνση",
|
||||
"Hyphenation": "Συλλαβισμός",
|
||||
"Identifier:": "Αναγνωριστικό:",
|
||||
"Import Books": "Εισαγωγή βιβλίων",
|
||||
"Language:": "Γλώσσα:",
|
||||
"Layout": "Διάταξη",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Ταίριασμα ολόκληρων λέξεων",
|
||||
"Maximum Number of Columns": "Μέγιστος αριθμός στηλών",
|
||||
"Minimum Font Size": "Ελάχιστο μέγεθος γραμματοσειράς",
|
||||
"Misc": "Διάφορα",
|
||||
"Monospace Font": "Γραμματοσειρά σταθερού πλάτους",
|
||||
"More Info": "Περισσότερες πληροφορίες",
|
||||
"Nord": "Nord",
|
||||
@@ -134,12 +129,12 @@
|
||||
"Book downloaded: {{title}}": "Το βιβλίο με τίτλο {{title}} κατέβηκε",
|
||||
"Failed to download book: {{title}}": "Αποτυχία λήψης βιβλίου: {{title}}",
|
||||
"Upload Book": "Ανέβασμα βιβλίου",
|
||||
"Auto Upload Books to Cloud": "Αυτόματο ανέβασμα βιβλίων στο cloud",
|
||||
"Auto Upload Books to Cloud": "Αυτόματη αποθήκευση στο cloud",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% της αποθήκευσης στο cloud χρησιμοποιήθηκε.",
|
||||
"Storage": "Αποθήκευση",
|
||||
"Book deleted: {{title}}": "Το βιβλίο με τίτλο {{title}} διαγράφηκε",
|
||||
"Failed to delete book: {{title}}": "Αποτυχία διαγραφής βιβλίου: {{title}}",
|
||||
"Check Updates on Start": "Έλεγχος ενημερώσεων κατά την εκκίνηση",
|
||||
"Check Updates on Start": "Αυτόματος έλεγχος ενημερώσεων",
|
||||
"Insufficient storage quota": "Ανεπαρκές όριο αποθήκευσης",
|
||||
"Font Weight": "Βάρος γραμματοσειράς",
|
||||
"Line Spacing": "Διάκενο γραμμών",
|
||||
@@ -296,12 +291,44 @@
|
||||
"Grid": "Πλέγμα",
|
||||
"(from 'As You Like It', Act II)": "('Όπως σας αρέσει', Πράξη II)",
|
||||
"Link Color": "Χρώμα συνδέσμου",
|
||||
"Volume Keys for Page Flip": "Πλήκτρα έντασης για ανατροπή σελίδας",
|
||||
"Volume Keys for Page Flip": "Ανατροπή με πλήκτρα έντασης",
|
||||
"Clicks for Page Flip": "Κλικ για ανατροπή σελίδας",
|
||||
"Swap Clicks Area": "Ανταλλαγή περιοχής κλικ",
|
||||
"Screen": "Οθόνη",
|
||||
"Orientation": "Προσανατολισμός",
|
||||
"Portrait": "Πορτραίτο",
|
||||
"Landscape": "Τοπίο",
|
||||
"Open Last Book on Start": "Άνοιγμα τελευταίου βιβλίου κατά την εκκίνηση"
|
||||
"Open Last Book on Start": "Άνοιγμα τελευταίου βιβλίου",
|
||||
"Checking for updates...": "Έλεγχος για ενημερώσεις...",
|
||||
"Error checking for updates": "Σφάλμα κατά τον έλεγχο για ενημερώσεις",
|
||||
"Details": "Πληρ.",
|
||||
"File Size:": "Μέγεθος αρχείου:",
|
||||
"Auto Detect": "Αυτόματη ανίχνευση",
|
||||
"Next Section": "Επόμενη ενότητα",
|
||||
"Previous Section": "Προηγούμενη ενότητα",
|
||||
"Next Page": "Επόμενη σελίδα",
|
||||
"Previous Page": "Προηγούμενη σελίδα",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Είστε σίγουροι ότι θέλετε να διαγράψετε το επιλεγμένο βιβλίο;",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Είστε σίγουροι ότι θέλετε να διαγράψετε τα {{count}} επιλεγμένα βιβλία;",
|
||||
"Are you sure to delete the selected book?": "Είστε σίγουροι ότι θέλετε να διαγράψετε το επιλεγμένο βιβλίο;",
|
||||
"Deselect": "Άρση",
|
||||
"Select All": "Όλα",
|
||||
"No translation available.": "Δεν είναι διαθέσιμη καμία μετάφραση.",
|
||||
"Translated by {{provider}}.": "Μεταφρασμένο από {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Μετάφραση Google",
|
||||
"Azure Translator": "Μεταφραστής Azure",
|
||||
"Invert Image In Dark Mode": "Αντιστροφή σε σκούρο",
|
||||
"Help improve Readest": "Βοηθήστε στη βελτίωση του Readest",
|
||||
"Sharing anonymized statistics": "Μοιράζομαι ανώνυμες στατιστικές",
|
||||
"Interface Language": "Γλώσσα διεπαφής",
|
||||
"Translation": "Μετάφραση",
|
||||
"Enable Translation": "Ενεργοποίηση μετάφρασης",
|
||||
"Translation Service": "Υπηρεσία μετάφρασης",
|
||||
"Translate To": "Μετάφραση σε",
|
||||
"Disable Translation": "Απενεργοποίηση μετάφρασης",
|
||||
"Scroll": "Κύλιση",
|
||||
"Overlap Pixels": "Επικάλυψη εικονοστοιχείων",
|
||||
"Click": "Κλικ",
|
||||
"System Language": "Γλώσσα συστήματος"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animación",
|
||||
"Annotate": "Anotar",
|
||||
"Apply": "Aplicar",
|
||||
"Are you sure to delete the selected books?": "¿Estás seguro de eliminar los libros seleccionados?",
|
||||
"Auto Mode": "Modo automático",
|
||||
"Behavior": "Comportamiento",
|
||||
"Book": "Libro",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Configuración global",
|
||||
"Go Back": "Volver",
|
||||
"Go Forward": "Avanzar",
|
||||
"Go Left": "Ir a la izquierda",
|
||||
"Go Right": "Ir a la derecha",
|
||||
"Grass": "Hierba",
|
||||
"Gray": "Gris",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Resaltar",
|
||||
"Horizontal Direction": "Dirección horizontal",
|
||||
"Hyphenation": "Guionización",
|
||||
"Identifier:": "Identificador:",
|
||||
"Import Books": "Importar libros",
|
||||
"Language:": "Idioma:",
|
||||
"Layout": "Diseño",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Coincidir palabras completas",
|
||||
"Maximum Number of Columns": "Número máximo de columnas",
|
||||
"Minimum Font Size": "Tamaño de fuente mínimo",
|
||||
"Misc": "Varios",
|
||||
"Monospace Font": "Fuente monoespaciada",
|
||||
"More Info": "Más información",
|
||||
"Nord": "Nord",
|
||||
@@ -303,5 +298,38 @@
|
||||
"Orientation": "Orientación",
|
||||
"Portrait": "Vertical",
|
||||
"Landscape": "Horizontal",
|
||||
"Open Last Book on Start": "Abrir último libro al iniciar"
|
||||
"Open Last Book on Start": "Abrir último libro al iniciar",
|
||||
"Checking for updates...": "Comprobando actualizaciones...",
|
||||
"Error checking for updates": "Error al comprobar actualizaciones",
|
||||
"Details": "Detalles",
|
||||
"File Size:": "Peso del archivo:",
|
||||
"Auto Detect": "Detección automática",
|
||||
"Next Section": "Siguiente sección",
|
||||
"Previous Section": "Sección anterior",
|
||||
"Next Page": "Página siguiente",
|
||||
"Previous Page": "Página anterior",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "¿Seguro que quieres eliminar el libro seleccionado?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "¿Seguro que quieres eliminar los {{count}} libros seleccionados?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "¿Seguro que quieres eliminar los {{count}} libros seleccionados?",
|
||||
"Are you sure to delete the selected book?": "¿Seguro que quieres eliminar el libro seleccionado?",
|
||||
"Deselect": "Quitar",
|
||||
"Select All": "Todo",
|
||||
"No translation available.": "No hay traducción disponible.",
|
||||
"Translated by {{provider}}.": "Traducido por {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Invertir imagen en oscuro",
|
||||
"Help improve Readest": "Ayuda a mejorar Readest",
|
||||
"Sharing anonymized statistics": "Compartiendo estadísticas anónimas",
|
||||
"Interface Language": "Idioma de la interfaz",
|
||||
"Translation": "Traducción",
|
||||
"Enable Translation": "Habilitar traducción",
|
||||
"Translation Service": "Servicio de traducción",
|
||||
"Translate To": "Traducir a",
|
||||
"Disable Translation": "Deshabilitar traducción",
|
||||
"Scroll": "Desplazar",
|
||||
"Overlap Pixels": "Superponer píxeles",
|
||||
"Click": "Click",
|
||||
"System Language": "Idioma del sistema"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animation",
|
||||
"Annotate": "Annoter",
|
||||
"Apply": "Appliquer",
|
||||
"Are you sure to delete the selected books?": "Êtes-vous sûr de vouloir supprimer les livres sélectionnés ?",
|
||||
"Auto Mode": "Mode automatique",
|
||||
"Behavior": "Comportement",
|
||||
"Book": "Livre",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Paramètres globaux",
|
||||
"Go Back": "Retour",
|
||||
"Go Forward": "Avancer",
|
||||
"Go Left": "Aller à gauche",
|
||||
"Go Right": "Aller à droite",
|
||||
"Grass": "Herbe",
|
||||
"Gray": "Gris",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Surligner",
|
||||
"Horizontal Direction": "Direction horizontale",
|
||||
"Hyphenation": "Césure",
|
||||
"Identifier:": "Identifiant :",
|
||||
"Import Books": "Importer des livres",
|
||||
"Language:": "Langue :",
|
||||
"Layout": "Mise en page",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Mots entiers",
|
||||
"Maximum Number of Columns": "Nombre maximal de colonnes",
|
||||
"Minimum Font Size": "Taille minimale de police",
|
||||
"Misc": "Divers",
|
||||
"Monospace Font": "Police monospace",
|
||||
"More Info": "Plus d'informations",
|
||||
"Nord": "Nord",
|
||||
@@ -303,5 +298,38 @@
|
||||
"Orientation": "Orientation",
|
||||
"Portrait": "Portrait",
|
||||
"Landscape": "Paysage",
|
||||
"Open Last Book on Start": "Ouvrir le dernier livre au démarrage"
|
||||
"Open Last Book on Start": "Ouvrir le dernier livre au démarrage",
|
||||
"Checking for updates...": "Vérification des mises à jour...",
|
||||
"Error checking for updates": "Erreur lors de la vérification des mises à jour",
|
||||
"Details": "Détails",
|
||||
"File Size:": "Taille du fichier :",
|
||||
"Auto Detect": "Détection automatique",
|
||||
"Next Section": "Section suivante",
|
||||
"Previous Section": "Section précédente",
|
||||
"Next Page": "Page suivante",
|
||||
"Previous Page": "Page précédente",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Supprimer le livre sélectionné ?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Supprimer les {{count}} livres sélectionnés ?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Supprimer les {{count}} livres sélectionnés ?",
|
||||
"Are you sure to delete the selected book?": "Supprimer le livre sélectionné ?",
|
||||
"Deselect": "Annuler",
|
||||
"Select All": "Tous",
|
||||
"No translation available.": "Aucune traduction disponible.",
|
||||
"Translated by {{provider}}.": "Traduit par {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Traduction",
|
||||
"Azure Translator": "Traducteur Azure",
|
||||
"Invert Image In Dark Mode": "Inverser l'image en mode sombre",
|
||||
"Help improve Readest": "Contribuer à améliorer Readest",
|
||||
"Sharing anonymized statistics": "Partage de statistiques anonymisées",
|
||||
"Interface Language": "Langue de l'interface",
|
||||
"Translation": "Traduction",
|
||||
"Enable Translation": "Activer la traduction",
|
||||
"Translation Service": "Service de traduction",
|
||||
"Translate To": "Traduire vers",
|
||||
"Disable Translation": " désactiver la traduction",
|
||||
"Scroll": "Défilement",
|
||||
"Overlap Pixels": "Pixels de chevauchement",
|
||||
"Click": "Cliquer",
|
||||
"System Language": "Langue du système"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "एनीमेशन",
|
||||
"Annotate": "टिप्पणी",
|
||||
"Apply": "लागू करें",
|
||||
"Are you sure to delete the selected books?": "क्या आप चयनित पुस्तकों को हटाना चाहते हैं?",
|
||||
"Auto Mode": "स्वचालित मोड",
|
||||
"Behavior": "व्यवहार",
|
||||
"Book": "पुस्तक",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "वैश्विक सेटिंग्स",
|
||||
"Go Back": "वापस जाएं",
|
||||
"Go Forward": "आगे जाएं",
|
||||
"Go Left": "बाएं जाएं",
|
||||
"Go Right": "दाएं जाएं",
|
||||
"Grass": "घास",
|
||||
"Gray": "ग्रे",
|
||||
"Gruvbox": "ग्रूवबॉक्स",
|
||||
"Highlight": "हाइलाइट",
|
||||
"Horizontal Direction": "क्षैतिज दिशा",
|
||||
"Hyphenation": "हाइफ़नेशन",
|
||||
"Identifier:": "पहचानकर्ता:",
|
||||
"Import Books": "पुस्तकें आयात करें",
|
||||
"Language:": "भाषा:",
|
||||
"Layout": "लेआउट",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "पूरे शब्द मैच करें",
|
||||
"Maximum Number of Columns": "कॉलम की अधिकतम संख्या",
|
||||
"Minimum Font Size": "न्यूनतम फ़ॉन्ट साइज़",
|
||||
"Misc": "विविध",
|
||||
"Monospace Font": "मोनोस्पेस फ़ॉन्ट",
|
||||
"More Info": "अधिक जानकारी",
|
||||
"Nord": "नॉर्ड",
|
||||
@@ -303,5 +298,37 @@
|
||||
"Orientation": "अवयव",
|
||||
"Portrait": "पोर्ट्रेट",
|
||||
"Landscape": "लैंडस्केप",
|
||||
"Open Last Book on Start": "शुरू में अंतिम पुस्तक खोलें"
|
||||
"Open Last Book on Start": "शुरू में अंतिम पुस्तक खोलें",
|
||||
"Checking for updates...": "अपडेट की जांच कर रहा है...",
|
||||
"Error checking for updates": "अपडेट की जांच करते समय त्रुटि",
|
||||
"Details": "विवरण",
|
||||
"File Size:": "फ़ाइल आकार:",
|
||||
"Auto Detect": "स्वचालित पहचान",
|
||||
"Next Section": "अगला अनुभाग",
|
||||
"Previous Section": "पिछला अनुभाग",
|
||||
"Next Page": "अगला पृष्ठ",
|
||||
"Previous Page": "पिछला पृष्ठ",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "क्या आप चयनित पुस्तक हटाना चाहते हैं?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "क्या आप चयनित {{count}} पुस्तकें हटाना चाहते हैं?",
|
||||
"Are you sure to delete the selected book?": "क्या आप चयनित पुस्तक हटाना चाहते हैं?",
|
||||
"Deselect": "रद्द करें",
|
||||
"Select All": "सब चुनें",
|
||||
"No translation available.": "कोई अनुवाद उपलब्ध नहीं है।",
|
||||
"Translated by {{provider}}.": "{{provider}} द्वारा अनुवादित।",
|
||||
"DeepL": "डीपएल",
|
||||
"Google Translate": "गूगल अनुवाद",
|
||||
"Azure Translator": "एज़्योर अनुवादक",
|
||||
"Invert Image In Dark Mode": "अंधेरे में छवि को उलटें",
|
||||
"Help improve Readest": "Readest में सुधार करने में मदद करें",
|
||||
"Sharing anonymized statistics": "सांख्यिकी साझा करना",
|
||||
"Interface Language": "इंटरफ़ेस भाषा",
|
||||
"Translation": "अनुवाद",
|
||||
"Enable Translation": "अनुवाद सक्षम करें",
|
||||
"Translation Service": "अनुवाद सेवा",
|
||||
"Translate To": "अनुवाद करें",
|
||||
"Disable Translation": "अनुवाद अक्षम करें",
|
||||
"Scroll": "स्क्रॉल",
|
||||
"Overlap Pixels": "ओवरलैप पिक्सेल",
|
||||
"Click": "क्लिक करें",
|
||||
"System Language": "सिस्टम भाषा"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animasi",
|
||||
"Annotate": "Anotasi",
|
||||
"Apply": "Terapkan",
|
||||
"Are you sure to delete the selected books?": "Apakah Anda yakin ingin menghapus buku yang dipilih?",
|
||||
"Auto Mode": "Mode Otomatis",
|
||||
"Behavior": "Perilaku",
|
||||
"Book": "Buku",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Pengaturan Global",
|
||||
"Go Back": "Kembali",
|
||||
"Go Forward": "Maju",
|
||||
"Go Left": "Ke Kiri",
|
||||
"Go Right": "Ke Kanan",
|
||||
"Grass": "Rumput",
|
||||
"Gray": "Abu-abu",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Sorot",
|
||||
"Horizontal Direction": "Arah Horizontal",
|
||||
"Hyphenation": "Pemenggalan Kata",
|
||||
"Identifier:": "Pengenal:",
|
||||
"Import Books": "Impor Buku",
|
||||
"Language:": "Bahasa:",
|
||||
"Layout": "Tata Letak",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Cocokkan Kata Utuh",
|
||||
"Maximum Number of Columns": "Jumlah Kolom Maksimum",
|
||||
"Minimum Font Size": "Ukuran Font Minimum",
|
||||
"Misc": "Lain-lain",
|
||||
"Monospace Font": "Font Monospace",
|
||||
"More Info": "Info Lebih Lanjut",
|
||||
"Nord": "Nord",
|
||||
@@ -303,5 +298,36 @@
|
||||
"Orientation": "Orientasi",
|
||||
"Portrait": "Potret",
|
||||
"Landscape": "Lanskap",
|
||||
"Open Last Book on Start": "Buka Buku Terakhir saat Memulai"
|
||||
"Open Last Book on Start": "Buka Buku Terakhir saat Memulai",
|
||||
"Checking for updates...": "Memeriksa pembaruan...",
|
||||
"Error checking for updates": "Kesalahan saat memeriksa pembaruan",
|
||||
"Details": "Detail",
|
||||
"File Size:": "Ukuran File:",
|
||||
"Auto Detect": "Deteksi Otomatis",
|
||||
"Next Section": "Bagian Selanjutnya",
|
||||
"Previous Section": "Bagian Sebelumnya",
|
||||
"Next Page": "Halaman Berikutnya",
|
||||
"Previous Page": "Halaman Sebelumnya",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Apakah Anda yakin ingin menghapus {{count}} buku yang dipilih?",
|
||||
"Are you sure to delete the selected book?": "Apakah Anda yakin ingin menghapus buku yang dipilih?",
|
||||
"Deselect": "Batal",
|
||||
"Select All": "Pilih Semua",
|
||||
"No translation available.": "Tidak ada terjemahan yang tersedia.",
|
||||
"Translated by {{provider}}.": "Terjemahan oleh {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Inversi Gambar di Gelap",
|
||||
"Help improve Readest": "Bantu tingkatkan Readest",
|
||||
"Sharing anonymized statistics": "Berbagi statistik anonim",
|
||||
"Interface Language": "Bahasa Antarmuka",
|
||||
"Translation": "Terjemahan",
|
||||
"Enable Translation": "Aktifkan Terjemahan",
|
||||
"Translation Service": "Layanan Terjemahan",
|
||||
"Translate To": "Terjemahkan Ke",
|
||||
"Disable Translation": "Nonaktifkan Terjemahan",
|
||||
"Scroll": "Scroll",
|
||||
"Overlap Pixels": "Overlap Piksel",
|
||||
"Click": "Klik",
|
||||
"System Language": "Bahasa Sistem"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animazione",
|
||||
"Annotate": "Annota",
|
||||
"Apply": "Applica",
|
||||
"Are you sure to delete the selected books?": "Sei sicuro di voler eliminare i libri selezionati?",
|
||||
"Auto Mode": "Modalità automatica",
|
||||
"Behavior": "Comportamento",
|
||||
"Book": "Libro",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Impostazioni globali",
|
||||
"Go Back": "Indietro",
|
||||
"Go Forward": "Avanti",
|
||||
"Go Left": "Vai a sinistra",
|
||||
"Go Right": "Vai a destra",
|
||||
"Grass": "Erba",
|
||||
"Gray": "Grigio",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Evidenzia",
|
||||
"Horizontal Direction": "Direzione orizzontale",
|
||||
"Hyphenation": "Sillabazione",
|
||||
"Identifier:": "Identificatore:",
|
||||
"Import Books": "Importa libri",
|
||||
"Language:": "Lingua:",
|
||||
"Layout": "Layout",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Parole intere",
|
||||
"Maximum Number of Columns": "Numero massimo di colonne",
|
||||
"Minimum Font Size": "Dimensione minima font",
|
||||
"Misc": "Varie",
|
||||
"Monospace Font": "Font monospazio",
|
||||
"More Info": "Maggiori informazioni",
|
||||
"Nord": "Nord",
|
||||
@@ -303,5 +298,38 @@
|
||||
"Orientation": "Orientamento",
|
||||
"Portrait": "Ritratto",
|
||||
"Landscape": "Paesaggio",
|
||||
"Open Last Book on Start": "Apri ultimo libro all'avvio"
|
||||
"Open Last Book on Start": "Apri ultimo libro all'avvio",
|
||||
"Checking for updates...": "Controllo aggiornamenti...",
|
||||
"Error checking for updates": "Errore durante il controllo degli aggiornamenti",
|
||||
"Details": "Dettagli",
|
||||
"File Size:": "Dimensione file:",
|
||||
"Auto Detect": "Rilevamento automatico",
|
||||
"Next Section": "Sezione successiva",
|
||||
"Previous Section": "Sezione precedente",
|
||||
"Next Page": "Pagina successiva",
|
||||
"Previous Page": "Pagina precedente",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Sei sicuro di voler eliminare {{count}} libro selezionato?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Sei sicuro di voler eliminare {{count}} libri selezionati?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Sei sicuro di voler eliminare {{count}} libri selezionati?",
|
||||
"Are you sure to delete the selected book?": "Sei sicuro di voler eliminare il libro selezionato?",
|
||||
"Deselect": "Annulla",
|
||||
"Select All": "Tutti",
|
||||
"No translation available.": "Nessuna traduzione disponibile.",
|
||||
"Translated by {{provider}}.": "Tradotto da {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Traduttore Azure",
|
||||
"Invert Image In Dark Mode": "Inverti immagine in scuro",
|
||||
"Help improve Readest": "Aiuta a migliorare Readest",
|
||||
"Sharing anonymized statistics": "Condivisione di statistiche anonime",
|
||||
"Interface Language": "Lingua dell'interfaccia",
|
||||
"Translation": "Traduzione",
|
||||
"Enable Translation": "Abilita traduzione",
|
||||
"Translation Service": "Servizio di traduzione",
|
||||
"Translate To": "Traduci in",
|
||||
"Disable Translation": "Disabilita traduzione",
|
||||
"Scroll": "Scorri",
|
||||
"Overlap Pixels": "Pixel di sovrapposizione",
|
||||
"Click": "Fai clic",
|
||||
"System Language": "Lingua di sistema"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "アニメーション",
|
||||
"Annotate": "注釈",
|
||||
"Apply": "適用",
|
||||
"Are you sure to delete the selected books?": "選択した書籍を削除してもよろしいですか?",
|
||||
"Auto Mode": "自動モード",
|
||||
"Behavior": "動作",
|
||||
"Book": "書籍",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "全体設定",
|
||||
"Go Back": "戻る",
|
||||
"Go Forward": "進む",
|
||||
"Go Left": "左へ",
|
||||
"Go Right": "右へ",
|
||||
"Grass": "グリーン",
|
||||
"Gray": "グレー",
|
||||
"Gruvbox": "グルーブボックス",
|
||||
"Highlight": "ハイライト",
|
||||
"Horizontal Direction": "横書",
|
||||
"Hyphenation": "ハイフネーション",
|
||||
"Identifier:": "識別子:",
|
||||
"Import Books": "書籍をインポート",
|
||||
"Language:": "言語:",
|
||||
"Layout": "レイアウト",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "単語全体を一致",
|
||||
"Maximum Number of Columns": "最大列数",
|
||||
"Minimum Font Size": "最小フォントサイズ",
|
||||
"Misc": "その他",
|
||||
"Monospace Font": "等幅フォント",
|
||||
"More Info": "詳細情報",
|
||||
"Nord": "ノルド",
|
||||
@@ -303,5 +298,36 @@
|
||||
"Orientation": "向き",
|
||||
"Portrait": "縦",
|
||||
"Landscape": "横",
|
||||
"Open Last Book on Start": "開始時に最後の書籍を開く"
|
||||
"Open Last Book on Start": "開始時に最後の書籍を開く",
|
||||
"Checking for updates...": "更新を確認中...",
|
||||
"Error checking for updates": "更新の確認中にエラーが発生しました",
|
||||
"Details": "詳細",
|
||||
"File Size:": "ファイルサイズ:",
|
||||
"Auto Detect": "自動検出",
|
||||
"Next Section": "次のセクション",
|
||||
"Previous Section": "前のセクション",
|
||||
"Next Page": "次のページ",
|
||||
"Previous Page": "前のページ",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "選択した{{count}}冊の本を削除してもよろしいですか?",
|
||||
"Are you sure to delete the selected book?": "選択した本を削除してもよろしいですか?",
|
||||
"Deselect": "解除",
|
||||
"Select All": "全選択",
|
||||
"No translation available.": "翻訳はありません。",
|
||||
"Translated by {{provider}}.": "{{provider}}によって翻訳されました。",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google翻訳",
|
||||
"Azure Translator": "Azure翻訳",
|
||||
"Invert Image In Dark Mode": "ダークモードで画像を反転",
|
||||
"Help improve Readest": "Readestを改善する手助け",
|
||||
"Sharing anonymized statistics": "匿名化された統計情報を共有",
|
||||
"Interface Language": "インターフェースの言語",
|
||||
"Translation": "翻訳",
|
||||
"Enable Translation": "翻訳を有効にする",
|
||||
"Translation Service": "翻訳サービス",
|
||||
"Translate To": "翻訳先",
|
||||
"Disable Translation": "翻訳を無効にする",
|
||||
"Scroll": "スクロール",
|
||||
"Overlap Pixels": "重なりピクセル",
|
||||
"Click": "クリック",
|
||||
"System Language": "システム言語"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "애니메이션",
|
||||
"Annotate": "주석",
|
||||
"Apply": "적용",
|
||||
"Are you sure to delete the selected books?": "선택한 책을 삭제하시겠습니까?",
|
||||
"Auto Mode": "자동 모드",
|
||||
"Behavior": "동작",
|
||||
"Book": "책",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "전역 설정",
|
||||
"Go Back": "뒤로",
|
||||
"Go Forward": "앞으로",
|
||||
"Go Left": "왼쪽으로",
|
||||
"Go Right": "오른쪽으로",
|
||||
"Grass": "잔디색",
|
||||
"Gray": "회색",
|
||||
"Gruvbox": "그러브박스",
|
||||
"Highlight": "하이라이트",
|
||||
"Horizontal Direction": "수평 방향",
|
||||
"Hyphenation": "하이픈 넣기",
|
||||
"Identifier:": "식별자:",
|
||||
"Import Books": "책 가져오기",
|
||||
"Language:": "언어:",
|
||||
"Layout": "레이아웃",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "전체 단어 일치",
|
||||
"Maximum Number of Columns": "최대 열 수",
|
||||
"Minimum Font Size": "최소 글꼴 크기",
|
||||
"Misc": "기타",
|
||||
"Monospace Font": "고정폭 글꼴",
|
||||
"More Info": "추가 정보",
|
||||
"Nord": "노드",
|
||||
@@ -303,5 +298,36 @@
|
||||
"Orientation": "방향",
|
||||
"Portrait": "세로",
|
||||
"Landscape": "가로",
|
||||
"Open Last Book on Start": "시작 시 마지막 책 열기"
|
||||
"Open Last Book on Start": "시작 시 마지막 책 열기",
|
||||
"Checking for updates...": "업데이트 확인 중...",
|
||||
"Error checking for updates": "업데이트 확인 중 오류 발생",
|
||||
"Details": "세부정보",
|
||||
"File Size:": "파일 크기:",
|
||||
"Auto Detect": "자동 감지",
|
||||
"Next Section": "다음 섹션",
|
||||
"Previous Section": "이전 섹션",
|
||||
"Next Page": "다음 페이지",
|
||||
"Previous Page": "이전 페이지",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "선택한 {{count}}권의 책을 삭제하시겠습니까?",
|
||||
"Are you sure to delete the selected book?": "선택한 책을 삭제하시겠습니까?",
|
||||
"Deselect": "해제",
|
||||
"Select All": "전체선택",
|
||||
"No translation available.": "번역이 없습니다.",
|
||||
"Translated by {{provider}}.": "{{provider}}에 의해 번역됨.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "구글 번역",
|
||||
"Azure Translator": "Azure 번역기",
|
||||
"Invert Image In Dark Mode": "어두운 곳에서 이미지 반전",
|
||||
"Help improve Readest": "Readest 개선에 도움을 주세요",
|
||||
"Sharing anonymized statistics": "익명화된 통계 공유",
|
||||
"Interface Language": "인터페이스 언어",
|
||||
"Translation": "번역",
|
||||
"Enable Translation": "번역 활성화",
|
||||
"Translation Service": "번역 서비스",
|
||||
"Translate To": "번역 대상 언어",
|
||||
"Disable Translation": "번역 비활성화",
|
||||
"Scroll": "스크롤",
|
||||
"Overlap Pixels": "겹치는 픽셀",
|
||||
"Click": "클릭",
|
||||
"System Language": "시스템 언어"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
{
|
||||
"Email address": "E-mailadres",
|
||||
"Your Password": "Uw wachtwoord",
|
||||
"Your email address": "Uw e-mailadres",
|
||||
"Your password": "Uw wachtwoord",
|
||||
"Sign in": "Inloggen",
|
||||
"Signing in...": "Bezig met inloggen...",
|
||||
"Sign in with {{provider}}": "Inloggen met {{provider}}",
|
||||
"Already have an account? Sign in": "Heeft u al een account? Log in",
|
||||
"Create a Password": "Maak een wachtwoord aan",
|
||||
"Sign up": "Registreren",
|
||||
"Signing up...": "Bezig met registreren...",
|
||||
"Check your email for the confirmation link": "Controleer uw e-mail voor de bevestigingslink",
|
||||
"Signing in ...": "Bezig met inloggen ...",
|
||||
"Send a magic link email": "Stuur een magic link e-mail",
|
||||
"Check your email for the magic link": "Controleer uw e-mail voor de magic link",
|
||||
"Send reset password instructions": "Stuur instructies voor wachtwoordherstel",
|
||||
"Sending reset instructions ...": "Bezig met versturen van herstelinstructies ...",
|
||||
"Forgot your password?": "Wachtwoord vergeten?",
|
||||
"Check your email for the password reset link": "Controleer uw e-mail voor de link om uw wachtwoord te herstellen",
|
||||
"Phone number": "Telefoonnummer",
|
||||
"Your phone number": "Uw telefoonnummer",
|
||||
"Token": "Token",
|
||||
"Your OTP token": "Uw OTP-token",
|
||||
"Verify token": "Token verifiëren",
|
||||
"Sign in with Google": "Inloggen met Google",
|
||||
"Sign in with Apple": "Inloggen met Apple",
|
||||
"Sign in with GitHub": "Inloggen met GitHub",
|
||||
"New Password": "Nieuw wachtwoord",
|
||||
"Your new password": "Uw nieuwe wachtwoord",
|
||||
"Update password": "Wachtwoord bijwerken",
|
||||
"Updating password ...": "Wachtwoord bijwerken ...",
|
||||
"Your password has been updated": "Uw wachtwoord is bijgewerkt",
|
||||
"Open": "Openen",
|
||||
"Group": "Groep",
|
||||
"Details": "Details",
|
||||
"Delete": "Verwijderen",
|
||||
"Cancel": "Annuleren",
|
||||
"Confirm Deletion": "Verwijdering bevestigen",
|
||||
"Deselect Book": "Boek deselecteren",
|
||||
"Select Book": "Boek selecteren",
|
||||
"Show Book Details": "Boekdetails weergeven",
|
||||
"Download Book": "Boek downloaden",
|
||||
"Upload Book": "Boek uploaden",
|
||||
"Deselect Group": "Groep deselecteren",
|
||||
"Select Group": "Groep selecteren",
|
||||
"Untitled Group": "Groep zonder titel",
|
||||
"Group Books": "Boeken groeperen",
|
||||
"Remove From Group": "Uit groep verwijderen",
|
||||
"Create New Group": "Nieuwe groep maken",
|
||||
"Save": "Opslaan",
|
||||
"Confirm": "Bevestigen",
|
||||
"From Local File": "Van lokaal bestand",
|
||||
"Go Back": "Terug",
|
||||
"Search Books...": "Boeken zoeken...",
|
||||
"Clear Search": "Zoekopdracht wissen",
|
||||
"Import Books": "Boeken importeren",
|
||||
"Select Multiple Books": "Meerdere boeken selecteren",
|
||||
"Select Books": "Boeken selecteren",
|
||||
"Storage": "Opslag",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% van cloudopslag gebruikt.",
|
||||
"Logged in as {{userDisplayName}}": "Ingelogd als {{userDisplayName}}",
|
||||
"Logged in": "Ingelogd",
|
||||
"Account": "Account",
|
||||
"Sign In": "Inloggen",
|
||||
"Auto Upload Books to Cloud": "Boeken automatisch uploaden naar cloud",
|
||||
"Auto Import on File Open": "Automatisch importeren bij bestand openen",
|
||||
"Open Last Book on Start": "Open laatste boek bij opstarten",
|
||||
"Check Updates on Start": "Controleer updates bij opstarten",
|
||||
"Fullscreen": "Volledig scherm",
|
||||
"Always on Top": "Altijd bovenaan",
|
||||
"Keep Screen Awake": "Scherm wakker houden",
|
||||
"Reload Page": "Pagina herladen",
|
||||
"Download Readest": "Readest downloaden",
|
||||
"About Readest": "Over Readest",
|
||||
"List": "Lijst",
|
||||
"Grid": "Raster",
|
||||
"Title": "Titel",
|
||||
"Author": "Auteur",
|
||||
"Format": "Formaat",
|
||||
"Date Read": "Datum gelezen",
|
||||
"Date Added": "Datum toegevoegd",
|
||||
"Ascending": "Oplopend",
|
||||
"Descending": "Aflopend",
|
||||
"Sort by...": "Sorteren op...",
|
||||
"No supported files found. Supported formats: {{formats}}": "Geen ondersteunde bestanden gevonden. Ondersteunde formaten: {{formats}}",
|
||||
"No chapters detected.": "Geen hoofdstukken gedetecteerd.",
|
||||
"Failed to parse the EPUB file.": "EPUB-bestand kon niet worden verwerkt.",
|
||||
"This book format is not supported.": "Dit boekformaat wordt niet ondersteund.",
|
||||
"Failed to import book(s): {{filenames}}": "Importeren van boek(en) mislukt: {{filenames}}",
|
||||
"Book uploaded: {{title}}": "Boek geüpload: {{title}}",
|
||||
"Insufficient storage quota": "Onvoldoende opslagruimte",
|
||||
"Failed to upload book: {{title}}": "Uploaden van boek mislukt: {{title}}",
|
||||
"Book downloaded: {{title}}": "Boek gedownload: {{title}}",
|
||||
"Failed to download book: {{title}}": "Downloaden van boek mislukt: {{title}}",
|
||||
"Book deleted: {{title}}": "Boek verwijderd: {{title}}",
|
||||
"Failed to delete book: {{title}}": "Verwijderen van boek mislukt: {{title}}",
|
||||
"Your Library": "Uw bibliotheek",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Welkom bij uw bibliotheek. U kunt hier uw boeken importeren en ze op elk moment lezen.",
|
||||
"Copied to notebook": "Gekopieerd naar notitieblok",
|
||||
"No annotations to export": "Geen annotaties om te exporteren",
|
||||
"Exported from Readest": "Geëxporteerd uit Readest",
|
||||
"Highlights & Annotations": "Markeringen & annotaties",
|
||||
"Untitled": "Zonder titel",
|
||||
"Note": "Notitie",
|
||||
"Copied to clipboard": "Gekopieerd naar klembord",
|
||||
"Copy": "Kopiëren",
|
||||
"Delete Highlight": "Markering verwijderen",
|
||||
"Highlight": "Markeren",
|
||||
"Annotate": "Annoteren",
|
||||
"Search": "Zoeken",
|
||||
"Dictionary": "Woordenboek",
|
||||
"Wikipedia": "Wikipedia",
|
||||
"Translate": "Vertalen",
|
||||
"Speak": "Voorlezen",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Kan de vertaling niet ophalen. Log eerst in en probeer het opnieuw.",
|
||||
"Unable to fetch the translation. Try again later.": "Kan de vertaling niet ophalen. Probeer het later opnieuw.",
|
||||
"Original Text": "Originele tekst",
|
||||
"Auto Detect": "Automatisch detecteren",
|
||||
"(detected)": "(gedetecteerd)",
|
||||
"Translated Text": "Vertaalde tekst",
|
||||
"Loading...": "Laden...",
|
||||
"Bookmark": "Bladwijzer",
|
||||
"Go Forward": "Vooruit",
|
||||
"Small": "Klein",
|
||||
"Large": "Groot",
|
||||
"Notebook": "Notitieblok",
|
||||
"Excerpts": "Fragmenten",
|
||||
"Notes": "Notities",
|
||||
"Add your notes here...": "Voeg hier uw notities toe...",
|
||||
"Theme Mode": "Thema modus",
|
||||
"Auto Mode": "Automatische modus",
|
||||
"Light Mode": "Lichte modus",
|
||||
"Dark Mode": "Donkere modus",
|
||||
"Theme Color": "Themakleur",
|
||||
"Custom": "Aangepast",
|
||||
"Apply to All Books": "Toepassen op alle boeken",
|
||||
"Apply to This Book": "Toepassen op dit boek",
|
||||
"Global Settings": "Algemene instellingen",
|
||||
"System Fonts": "Systeemlettertypen",
|
||||
"Serif Font": "Serif lettertype",
|
||||
"Sans-Serif Font": "Sans-serif lettertype",
|
||||
"Font Size": "Lettergrootte",
|
||||
"Default Font Size": "Standaard lettergrootte",
|
||||
"Minimum Font Size": "Minimale lettergrootte",
|
||||
"Font Weight": "Letterdikte",
|
||||
"Font Family": "Lettertypefamilie",
|
||||
"Default Font": "Standaard lettertype",
|
||||
"CJK Font": "CJK lettertype",
|
||||
"Override Book Font": "Boek lettertype overschrijven",
|
||||
"Font Face": "Lettertype",
|
||||
"Monospace Font": "Monospace lettertype",
|
||||
"Scrolled Mode": "Scroll-modus",
|
||||
"Writing Mode": "Schrijfmodus",
|
||||
"Default": "Standaard",
|
||||
"Horizontal Direction": "Horizontale richting",
|
||||
"Vertical Direction": "Verticale richting",
|
||||
"RTL Direction": "RTL-richting",
|
||||
"Border Frame": "Randkader",
|
||||
"Double Border": "Dubbele rand",
|
||||
"Border Color": "Randkleur",
|
||||
"Paragraph": "Alinea",
|
||||
"Paragraph Margin": "Alinea marge",
|
||||
"Line Spacing": "Regelafstand",
|
||||
"Word Spacing": "Woordafstand",
|
||||
"Letter Spacing": "Letterafstand",
|
||||
"Text Indent": "Tekstinspringing",
|
||||
"Full Justification": "Volledig uitvullen",
|
||||
"Hyphenation": "Woordafbreking",
|
||||
"Override Book Layout": "Boekopmaak overschrijven",
|
||||
"Page": "Pagina",
|
||||
"Vertical Margins (px)": "Verticale marges (px)",
|
||||
"Horizontal Margins (%)": "Horizontale marges (%)",
|
||||
"Maximum Number of Columns": "Maximum aantal kolommen",
|
||||
"Maximum Column Height": "Maximale kolomhoogte",
|
||||
"Maximum Column Width": "Maximale kolombreedte",
|
||||
"Header & Footer": "Koptekst & voettekst",
|
||||
"Show Header": "Koptekst weergeven",
|
||||
"Show Footer": "Voettekst weergeven",
|
||||
"Apply also in Scrolled Mode": "Ook toepassen in scroll-modus",
|
||||
"Auto": "Automatisch",
|
||||
"Language": "Taal",
|
||||
"Animation": "Animatie",
|
||||
"Paging Animation": "Paginering animatie",
|
||||
"Screen": "Scherm",
|
||||
"Orientation": "Oriëntatie",
|
||||
"Portrait": "Portret",
|
||||
"Landscape": "Landschap",
|
||||
"Behavior": "Gedrag",
|
||||
"Continuous Scroll": "Doorlopend scrollen",
|
||||
"Volume Keys for Page Flip": "Volumetoetsen voor pagina omslaan",
|
||||
"Clicks for Page Flip": "Klikken voor pagina omslaan",
|
||||
"Swap Clicks Area": "Klikgebieden omwisselen",
|
||||
"Custom CSS": "Aangepaste CSS",
|
||||
"Enter your custom CSS here...": "Voer hier uw aangepaste CSS in...",
|
||||
"Apply": "Toepassen",
|
||||
"Font": "Lettertype",
|
||||
"Layout": "Opmaak",
|
||||
"Color": "Kleur",
|
||||
"(from 'As You Like It', Act II)": "(uit 'As You Like It', Akte II)",
|
||||
"Custom Theme": "Aangepast thema",
|
||||
"Theme Name": "Themanaam",
|
||||
"Text Color": "Tekstkleur",
|
||||
"Background Color": "Achtergrondkleur",
|
||||
"Link Color": "Linkkleur",
|
||||
"Preview": "Voorbeeld",
|
||||
"Font & Layout": "Lettertype & opmaak",
|
||||
"Book Cover": "Boekomslag",
|
||||
"More Info": "Meer informatie",
|
||||
"Parallel Read": "Parallel lezen",
|
||||
"Export Annotations": "Annotaties exporteren",
|
||||
"Edit": "Bewerken",
|
||||
"Search...": "Zoeken...",
|
||||
"Book": "Boek",
|
||||
"Chapter": "Hoofdstuk",
|
||||
"Match Case": "Hoofdlettergevoelig",
|
||||
"Match Whole Words": "Hele woorden",
|
||||
"Match Diacritics": "Diakritische tekens",
|
||||
"TOC": "Inhoudsopgave",
|
||||
"Sidebar": "Zijbalk",
|
||||
"TTS not supported for PDF": "TTS niet ondersteund voor PDF",
|
||||
"TTS not supported in this device": "TTS niet ondersteund op dit apparaat",
|
||||
"No Timeout": "Geen time-out",
|
||||
"{{value}} minute": "{{value}} minuut",
|
||||
"{{value}} minutes": "{{value}} minuten",
|
||||
"{{value}} hour": "{{value}} uur",
|
||||
"{{value}} hours": "{{value}} uren",
|
||||
"Slow": "Langzaam",
|
||||
"Fast": "Snel",
|
||||
"Reading Progress Synced": "Leesvoortgang gesynchroniseerd",
|
||||
"Cloud Storage": "Cloudopslag",
|
||||
"Failed to delete user. Please try again later.": "Gebruiker verwijderen mislukt. Probeer het later opnieuw.",
|
||||
"Free Tier": "Gratis niveau",
|
||||
"Community Support": "Community-ondersteuning",
|
||||
"DeepL Free Access": "DeepL gratis toegang",
|
||||
"Essential Text-to-Speech Voices": "Essentiële tekst-naar-spraak stemmen",
|
||||
"Unlimited Offline/Online Reading": "Onbeperkt offline/online lezen",
|
||||
"Unlimited Cloud Sync Devices": "Onbeperkt aantal cloud-synchronisatie apparaten",
|
||||
"500 MB Cloud Sync Space": "500 MB cloud-synchronisatieruimte",
|
||||
"Plus Tier": "Plus niveau",
|
||||
"Includes All Free Tier Benefits": "Inclusief alle voordelen van het gratis niveau",
|
||||
"Priority Support": "Prioriteitsondersteuning",
|
||||
"AI Summaries": "AI-samenvattingen",
|
||||
"AI Translations": "AI-vertalingen",
|
||||
"DeepL Pro Access": "DeepL Pro toegang",
|
||||
"Expanded Text-to-Speech Voices": "Uitgebreide tekst-naar-spraak stemmen",
|
||||
"2000 MB Cloud Sync Space": "2000 MB cloud-synchronisatieruimte",
|
||||
"Pro Tier": "Pro niveau",
|
||||
"Includes All Plus Tier Benefits": "Inclusief alle voordelen van het Plus niveau",
|
||||
"Unlimited AI Summaries": "Onbeperkte AI-samenvattingen",
|
||||
"Unlimited AI Translations": "Onbeperkte AI-vertalingen",
|
||||
"Unlimited DeepL Pro Access": "Onbeperkte DeepL Pro toegang",
|
||||
"Advanced AI Tools": "Geavanceerde AI-tools",
|
||||
"Early Feature Access": "Vroege toegang tot functies",
|
||||
"10 GB Cloud Sync Space": "10 GB cloud-synchronisatieruimte",
|
||||
"Loading profile...": "Profiel laden...",
|
||||
"Features": "Functies",
|
||||
"Sign Out": "Uitloggen",
|
||||
"Delete Account": "Account verwijderen",
|
||||
"Delete Your Account?": "Uw account verwijderen?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Deze actie kan niet ongedaan worden gemaakt. Al uw gegevens in de cloud worden permanent verwijderd.",
|
||||
"Delete Permanently": "Permanent verwijderen",
|
||||
"Version {{version}}": "Versie {{version}}",
|
||||
"Check Update": "Update controleren",
|
||||
"Already the latest version": "Reeds de nieuwste versie",
|
||||
"Checking for updates...": "Controleren op updates...",
|
||||
"Error checking for updates": "Fout bij controleren op updates",
|
||||
"Book Details": "Boekdetails",
|
||||
"Unknown": "Onbekend",
|
||||
"Publisher:": "Uitgever:",
|
||||
"Published:": "Gepubliceerd:",
|
||||
"Updated:": "Bijgewerkt:",
|
||||
"Added:": "Toegevoegd:",
|
||||
"Language:": "Taal:",
|
||||
"Subjects:": "Onderwerpen:",
|
||||
"Format:": "Formaat:",
|
||||
"File Size:": "Bestandsgrootte:",
|
||||
"Description:": "Beschrijving:",
|
||||
"No description available": "Geen beschrijving beschikbaar",
|
||||
"Drop to Import Books": "Sleep om boeken te importeren",
|
||||
"A new version of Readest is Available!": "Een nieuwe versie van Readest is beschikbaar!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} is beschikbaar (geïnstalleerde versie {{currentVersion}}).",
|
||||
"Download and install now?": "Nu downloaden en installeren?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Downloaden {{downloaded}} van {{contentLength}}",
|
||||
"Download finished": "Download voltooid",
|
||||
"DOWNLOAD & INSTALL": "DOWNLOADEN & INSTALLEREN",
|
||||
"Changelog:": "Wijzigingslogboek:",
|
||||
"Software Update": "Software-update",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai GB Scherm",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Gray": "Grijs",
|
||||
"Sepia": "Sepia",
|
||||
"Grass": "Gras",
|
||||
"Cherry": "Kers",
|
||||
"Sky": "Hemel",
|
||||
"Solarized": "Gesolariseerd",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Nord": "Noord",
|
||||
"Contrast": "Contrast",
|
||||
"Sunset": "Zonsondergang",
|
||||
"Reveal in Finder": "Tonen in Finder",
|
||||
"Reveal in File Explorer": "Tonen in Verkenner",
|
||||
"Reveal in Folder": "Tonen in map",
|
||||
"Don’t have an account? Sign up": "Nog geen account? Registreer u",
|
||||
"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": "De hele wereld is een toneel,\nEn alle mannen en vrouwen slechts spelers;\nZij hebben hun uitgangen en hun entrees,\nEn één man speelt in zijn tijd vele rollen,\nZijn daden zijnde zeven leeftijden.\n\n— William Shakespeare",
|
||||
"Next Section": "Volgende sectie",
|
||||
"Previous Section": "Vorige sectie",
|
||||
"Next Page": "Volgende pagina",
|
||||
"Previous Page": "Vorige pagina",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Weet je zeker dat je {{count}} geselecteerd boek wilt verwijderen?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Weet je zeker dat je {{count}} geselecteerde boeken wilt verwijderen?",
|
||||
"Are you sure to delete the selected book?": "Weet je zeker dat je het geselecteerde boek wilt verwijderen?",
|
||||
"Deselect": "Deselecteer",
|
||||
"Select All": "Alles",
|
||||
"No translation available.": "Geen vertaling beschikbaar.",
|
||||
"Translated by {{provider}}.": "Vertaald door {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Vertalen",
|
||||
"Azure Translator": "Azure Vertaler",
|
||||
"Invert Image In Dark Mode": "Inverteer afbeelding in het donker",
|
||||
"Help improve Readest": "Help Readest verbeteren",
|
||||
"Sharing anonymized statistics": "Anonieme statistieken delen",
|
||||
"Interface Language": "Interface-taal",
|
||||
"Translation": "Vertaling",
|
||||
"Enable Translation": "Vertaling inschakelen",
|
||||
"Translation Service": "Vertaalservice",
|
||||
"Translate To": "Vertalen naar",
|
||||
"Disable Translation": "Vertaling uitschakelen",
|
||||
"Scroll": "Scrollen",
|
||||
"Overlap Pixels": "Overlap pixels",
|
||||
"Click": "Klik",
|
||||
"System Language": "Systemtaal"
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animacja",
|
||||
"Annotate": "Adnotacja",
|
||||
"Apply": "Zastosuj",
|
||||
"Are you sure to delete the selected books?": "Czy na pewno chcesz usunąć wybrane książki?",
|
||||
"Auto Mode": "Tryb automatyczny",
|
||||
"Behavior": "Zachowanie",
|
||||
"Book": "Książka",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Ustawienia globalne",
|
||||
"Go Back": "Wstecz",
|
||||
"Go Forward": "Dalej",
|
||||
"Go Left": "W lewo",
|
||||
"Go Right": "W prawo",
|
||||
"Grass": "Trawiasty",
|
||||
"Gray": "Szary",
|
||||
"Gruvbox": "Ciepły pomarańczowy",
|
||||
"Highlight": "Zaznacz",
|
||||
"Horizontal Direction": "Kierunek poziomy",
|
||||
"Hyphenation": "Dzielenie wyrazów",
|
||||
"Identifier:": "Identyfikator:",
|
||||
"Import Books": "Importuj książki",
|
||||
"Language:": "Język:",
|
||||
"Layout": "Układ",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Uwzględnij całe słowa",
|
||||
"Maximum Number of Columns": "Maksymalna liczba kolumn",
|
||||
"Minimum Font Size": "Minimalny rozmiar czcionki",
|
||||
"Misc": "Różne",
|
||||
"Monospace Font": "Czcionka o stałej szerokości",
|
||||
"More Info": "Więcej informacji",
|
||||
"Nord": "Północny",
|
||||
@@ -303,5 +298,39 @@
|
||||
"Orientation": "Orientacja",
|
||||
"Portrait": "Portret",
|
||||
"Landscape": "Krajobraz",
|
||||
"Open Last Book on Start": "Otwórz ostatnią książkę przy starcie"
|
||||
"Open Last Book on Start": "Otwórz ostatnią książkę przy starcie",
|
||||
"Checking for updates...": "Sprawdzanie aktualizacji...",
|
||||
"Error checking for updates": "Błąd podczas sprawdzania aktualizacji",
|
||||
"Details": "Szczegóły",
|
||||
"File Size:": "Rozmiar pliku:",
|
||||
"Auto Detect": "Automatyczne wykrywanie",
|
||||
"Next Section": "Następna sekcja",
|
||||
"Previous Section": "Poprzednia sekcja",
|
||||
"Next Page": "Następna strona",
|
||||
"Previous Page": "Poprzednia strona",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Czy na pewno chcesz usunąć {{count}} wybraną książkę?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_few": "Czy na pewno chcesz usunąć {{count}} wybrane książki?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Czy na pewno chcesz usunąć {{count}} wybranych książek?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Czy na pewno chcesz usunąć {{count}} wybranych książek?",
|
||||
"Are you sure to delete the selected book?": "Czy na pewno chcesz usunąć wybraną książkę?",
|
||||
"Deselect": "Odznacz",
|
||||
"Select All": "Zaznacz",
|
||||
"No translation available.": "Brak dostępnego tłumaczenia.",
|
||||
"Translated by {{provider}}.": "Przetłumaczone przez {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Tłumacz Google",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Odwróć obraz w ciemności",
|
||||
"Help improve Readest": "Pomóż ulepszyć Readest",
|
||||
"Sharing anonymized statistics": "Udostępnianie zanonimizowanych statystyk",
|
||||
"Interface Language": "Język interfejsu",
|
||||
"Translation": "Tłumaczenie",
|
||||
"Enable Translation": "Włącz tłumaczenie",
|
||||
"Translation Service": "Usługa tłumaczenia",
|
||||
"Translate To": "Przetłumacz na",
|
||||
"Disable Translation": "Wyłącz tłumaczenie",
|
||||
"Scroll": "Przewijaj",
|
||||
"Overlap Pixels": "Przesunięcie pikseli",
|
||||
"Click": "Kliknij",
|
||||
"System Language": "Język systemu"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animação",
|
||||
"Annotate": "Anotar",
|
||||
"Apply": "Aplicar",
|
||||
"Are you sure to delete the selected books?": "Tem certeza de que deseja excluir os livros selecionados?",
|
||||
"Auto Mode": "Modo Automático",
|
||||
"Behavior": "Comportamento",
|
||||
"Book": "Livro",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Configurações Globais",
|
||||
"Go Back": "Voltar",
|
||||
"Go Forward": "Avançar",
|
||||
"Go Left": "Ir para Esquerda",
|
||||
"Go Right": "Ir para Direita",
|
||||
"Grass": "Grama",
|
||||
"Gray": "Cinza",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Destaque",
|
||||
"Horizontal Direction": "Direção Horizontal",
|
||||
"Hyphenation": "Hifenização",
|
||||
"Identifier:": "Identificador:",
|
||||
"Import Books": "Importar Livros",
|
||||
"Language:": "Idioma:",
|
||||
"Layout": "Layout",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Corresponder Palavras Inteiras",
|
||||
"Maximum Number of Columns": "Número Máximo de Colunas",
|
||||
"Minimum Font Size": "Tamanho Mínimo da Fonte",
|
||||
"Misc": "Diversos",
|
||||
"Monospace Font": "Fonte Monoespaçada",
|
||||
"More Info": "Mais Informações",
|
||||
"Nord": "Nord",
|
||||
@@ -303,5 +298,38 @@
|
||||
"Orientation": "Orientação",
|
||||
"Portrait": "Retrato",
|
||||
"Landscape": "Paisagem",
|
||||
"Open Last Book on Start": "Abrir o Último Livro ao Iniciar"
|
||||
"Open Last Book on Start": "Abrir o Último Livro ao Iniciar",
|
||||
"Checking for updates...": "Verificando atualizações...",
|
||||
"Error checking for updates": "Erro ao verificar atualizações",
|
||||
"Details": "Detalhes",
|
||||
"File Size:": "Tamanho do arquivo:",
|
||||
"Auto Detect": "Detectar Automaticamente",
|
||||
"Next Section": "Próxima seção",
|
||||
"Previous Section": "Seção anterior",
|
||||
"Next Page": "Próxima página",
|
||||
"Previous Page": "Página anterior",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Tem certeza que deseja excluir {{count}} livro selecionado?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Tem certeza que deseja excluir {{count}} livros selecionados?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Tem certeza que deseja excluir {{count}} livros selecionados?",
|
||||
"Are you sure to delete the selected book?": "Tem certeza que deseja excluir o livro selecionado?",
|
||||
"Deselect": "Desmarcar",
|
||||
"Select All": "Marcar tudo",
|
||||
"No translation available.": "Sem tradução disponível.",
|
||||
"Translated by {{provider}}.": "Traduzido por {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Tradutor",
|
||||
"Azure Translator": "Tradutor Azure",
|
||||
"Invert Image In Dark Mode": "Inverter Imagem no Escuro",
|
||||
"Help improve Readest": "Ajude a melhorar o Readest",
|
||||
"Sharing anonymized statistics": "Compartilhando estatísticas anonimizadas",
|
||||
"Interface Language": "Idioma da interface",
|
||||
"Translation": "Tradução",
|
||||
"Enable Translation": "Ativar tradução",
|
||||
"Translation Service": "Serviço de tradução",
|
||||
"Translate To": "Traduzir para",
|
||||
"Disable Translation": "Desativar tradução",
|
||||
"Scroll": "Rolar",
|
||||
"Overlap Pixels": "Sobreposição de Pixels",
|
||||
"Click": "Clique",
|
||||
"System Language": "Idioma do Sistema"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Анимация",
|
||||
"Annotate": "Аннотация",
|
||||
"Apply": "Применить",
|
||||
"Are you sure to delete the selected books?": "Вы уверены, что хотите удалить выбранные книги?",
|
||||
"Auto Mode": "Автоматический режим",
|
||||
"Behavior": "Поведение",
|
||||
"Book": "Книга",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Общие настройки",
|
||||
"Go Back": "Назад",
|
||||
"Go Forward": "Вперёд",
|
||||
"Go Left": "Влево",
|
||||
"Go Right": "Вправо",
|
||||
"Grass": "Трава",
|
||||
"Gray": "Серый",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Выделить",
|
||||
"Horizontal Direction": "Горизонтальное направление",
|
||||
"Hyphenation": "Перенос слов",
|
||||
"Identifier:": "Идентификатор:",
|
||||
"Import Books": "Импорт книг",
|
||||
"Language:": "Язык:",
|
||||
"Layout": "Макет",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Только целые слова",
|
||||
"Maximum Number of Columns": "Максимальное количество колонок",
|
||||
"Minimum Font Size": "Минимальный размер шрифта",
|
||||
"Misc": "Разное",
|
||||
"Monospace Font": "Моноширинный шрифт",
|
||||
"More Info": "Подробнее",
|
||||
"Nord": "Nord",
|
||||
@@ -303,5 +298,39 @@
|
||||
"Orientation": "Ориентация",
|
||||
"Portrait": "Портрет",
|
||||
"Landscape": "Ландшафт",
|
||||
"Open Last Book on Start": "Открыть последнюю книгу при запуске"
|
||||
"Open Last Book on Start": "Открыть последнюю книгу при запуске",
|
||||
"Checking for updates...": "Проверка обновлений...",
|
||||
"Error checking for updates": "Ошибка при проверке обновлений",
|
||||
"Details": "Инфо",
|
||||
"File Size:": "Размер файла:",
|
||||
"Auto Detect": "Автоопределение",
|
||||
"Next Section": "Следующий раздел",
|
||||
"Previous Section": "Предыдущий раздел",
|
||||
"Next Page": "Следующая страница",
|
||||
"Previous Page": "Предыдущая страница",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Вы уверены, что хотите удалить {{count}} выбранную книгу?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_few": "Вы уверены, что хотите удалить {{count}} выбранные книги?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Вы уверены, что хотите удалить {{count}} выбранных книг?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Вы уверены, что хотите удалить {{count}} выбранных книг?",
|
||||
"Are you sure to delete the selected book?": "Вы уверены, что хотите удалить выбранную книгу?",
|
||||
"Deselect": "Снять",
|
||||
"Select All": "Выбрать все",
|
||||
"No translation available.": "Нет доступного перевода.",
|
||||
"Translated by {{provider}}.": "Переведено {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Инвертировать в тёмной теме",
|
||||
"Help improve Readest": "Помогите улучшить Readest",
|
||||
"Sharing anonymized statistics": "Отправка анонимной статистики",
|
||||
"Interface Language": "Язык интерфейса",
|
||||
"Translation": "Перевод",
|
||||
"Enable Translation": "Включить перевод",
|
||||
"Translation Service": "Сервис перевода",
|
||||
"Translate To": "Перевести на",
|
||||
"Disable Translation": "Отключить перевод",
|
||||
"Scroll": "Прокрутка",
|
||||
"Overlap Pixels": "Перекрывающиеся пиксели",
|
||||
"Click": "Клик",
|
||||
"System Language": "Системный язык"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Animasyon",
|
||||
"Annotate": "Not Ekle",
|
||||
"Apply": "Uygula",
|
||||
"Are you sure to delete the selected books?": "Seçili kitapları silmek istediğinizden emin misiniz?",
|
||||
"Auto Mode": "Otomatik Mod",
|
||||
"Behavior": "Davranış",
|
||||
"Book": "Kitap",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Genel Ayarlar",
|
||||
"Go Back": "Geri Git",
|
||||
"Go Forward": "İleri Git",
|
||||
"Go Left": "Sola Git",
|
||||
"Go Right": "Sağa Git",
|
||||
"Grass": "Çimen",
|
||||
"Gray": "Gri",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Vurgula",
|
||||
"Horizontal Direction": "Yatay Yön",
|
||||
"Hyphenation": "Heceleme",
|
||||
"Identifier:": "Tanımlayıcı:",
|
||||
"Import Books": "Kitapları İçe Aktar",
|
||||
"Language:": "Dil:",
|
||||
"Layout": "Düzen",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Tam Kelimeleri Eşleştir",
|
||||
"Maximum Number of Columns": "Maksimum Sütun Sayısı",
|
||||
"Minimum Font Size": "Minimum Yazı Boyutu",
|
||||
"Misc": "Diğer",
|
||||
"Monospace Font": "Eş Aralıklı Yazı Tipi",
|
||||
"More Info": "Daha Fazla Bilgi",
|
||||
"Nord": "Nord",
|
||||
@@ -303,5 +298,37 @@
|
||||
"Orientation": "Yönlendirme",
|
||||
"Portrait": "Portre",
|
||||
"Landscape": "Yatay",
|
||||
"Open Last Book on Start": "Açılışta Son Kitabı Aç"
|
||||
"Open Last Book on Start": "Açılışta Son Kitabı Aç",
|
||||
"Checking for updates...": "Yenilikler kontrol ediliyor...",
|
||||
"Error checking for updates": "Güncellemeleri kontrol ederken hata oluştu",
|
||||
"Details": "Detaylar",
|
||||
"File Size:": "Dosya Boyutu:",
|
||||
"Auto Detect": "Algıla",
|
||||
"Next Section": "Sonraki Bölüm",
|
||||
"Previous Section": "Önceki Bölüm",
|
||||
"Next Page": "Sonraki Sayfa",
|
||||
"Previous Page": "Önceki Sayfa",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "{{count}} seçili kitabı silmek istediğinize emin misiniz?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "{{count}} seçili kitabı silmek istediğinize emin misiniz?",
|
||||
"Are you sure to delete the selected book?": "Seçili kitabı silmek istediğinize emin misiniz?",
|
||||
"Deselect": "Kaldır",
|
||||
"Select All": "Hepsi",
|
||||
"No translation available.": "Çeviri mevcut değil.",
|
||||
"Translated by {{provider}}.": "{{provider}} tarafından çevrildi.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Çeviri",
|
||||
"Azure Translator": "Azure Çevirmen",
|
||||
"Invert Image In Dark Mode": "Karartılmış Görüntüyü Ters Çevir",
|
||||
"Help improve Readest": "Readest’i geliştirin",
|
||||
"Sharing anonymized statistics": "Anonim verileri paylaş",
|
||||
"Interface Language": "Arayüz Dili",
|
||||
"Translation": "Çeviri",
|
||||
"Enable Translation": "Çeviriyi Etkinleştir",
|
||||
"Translation Service": "Çeviri Hizmeti",
|
||||
"Translate To": "Şuna Çevir",
|
||||
"Disable Translation": "Çeviriyi Devre Dışı Bırak",
|
||||
"Scroll": "Kaydır",
|
||||
"Overlap Pixels": "Örtüşen Pikseller",
|
||||
"Click": "Klik",
|
||||
"System Language": "System Dili"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Анімація",
|
||||
"Annotate": "Анотація",
|
||||
"Apply": "Застосувати",
|
||||
"Are you sure to delete the selected books?": "Ви впевнені, що хочете видалити вибрані книги?",
|
||||
"Auto Mode": "Автоматичний режим",
|
||||
"Behavior": "Поведінка",
|
||||
"Book": "Книга",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Глобальні налаштування",
|
||||
"Go Back": "Назад",
|
||||
"Go Forward": "Вперед",
|
||||
"Go Left": "Вліво",
|
||||
"Go Right": "Вправо",
|
||||
"Grass": "Трав'яний",
|
||||
"Gray": "Сірий",
|
||||
"Gruvbox": "Теплий помаранчевий",
|
||||
"Highlight": "Виділення",
|
||||
"Horizontal Direction": "Горизонтальний напрямок",
|
||||
"Hyphenation": "Перенесення слів",
|
||||
"Identifier:": "Ідентифікатор:",
|
||||
"Import Books": "Імпортувати книги",
|
||||
"Language:": "Мова:",
|
||||
"Layout": "Макет",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Шукати цілі слова",
|
||||
"Maximum Number of Columns": "Максимальна кількість колонок",
|
||||
"Minimum Font Size": "Мінімальний розмір шрифту",
|
||||
"Misc": "Різне",
|
||||
"Monospace Font": "Моноширинний шрифт",
|
||||
"More Info": "Більше інформації",
|
||||
"Nord": "Північний",
|
||||
@@ -303,5 +298,39 @@
|
||||
"Orientation": "Орієнтація",
|
||||
"Portrait": "Портрет",
|
||||
"Landscape": "Ландшафт",
|
||||
"Open Last Book on Start": "Відкрити останню книгу при запуску"
|
||||
"Open Last Book on Start": "Відкрити останню книгу при запуску",
|
||||
"Checking for updates...": "Перевірка наявності оновлень...",
|
||||
"Error checking for updates": "Помилка перевірки наявності оновлень",
|
||||
"Details": "Деталі",
|
||||
"File Size:": "Розмір файлу:",
|
||||
"Auto Detect": "Авто виявлення",
|
||||
"Next Section": "Наступний розділ",
|
||||
"Previous Section": "Попередній розділ",
|
||||
"Next Page": "Наступна сторінка",
|
||||
"Previous Page": "Попередня сторінка",
|
||||
"Are you sure to delete {{count}} selected book(s)?_one": "Ви впевнені, що хочете видалити {{count}} вибрану книгу?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_few": "Ви впевнені, що хочете видалити {{count}} вибрані книги?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_many": "Ви впевнені, що хочете видалити {{count}} вибраних книг?",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Ви впевнені, що хочете видалити {{count}} вибраних книг?",
|
||||
"Are you sure to delete the selected book?": "Ви впевнені, що хочете видалити вибрану книгу?",
|
||||
"Deselect": "Зняти",
|
||||
"Select All": "Усі",
|
||||
"No translation available.": "Переклад недоступний.",
|
||||
"Translated by {{provider}}.": "Перекладено {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Інвертувати зображення в темряві",
|
||||
"Help improve Readest": "Допоможіть покращити Readest",
|
||||
"Sharing anonymized statistics": "Поділ анонімною статистикою",
|
||||
"Interface Language": "Мова інтерфейсу",
|
||||
"Translation": "Переклад",
|
||||
"Enable Translation": "Увімкнути переклад",
|
||||
"Translation Service": "Служба перекладу",
|
||||
"Translate To": "Перекласти на",
|
||||
"Disable Translation": "Вимкнути переклад",
|
||||
"Scroll": "Прокрутка",
|
||||
"Overlap Pixels": "Пікселі перекриття",
|
||||
"Click": "Клацнути",
|
||||
"System Language": "Системна мова"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "Hiệu ứng động",
|
||||
"Annotate": "Chú thích",
|
||||
"Apply": "Áp dụng",
|
||||
"Are you sure to delete the selected books?": "Bạn có chắc chắn muốn xóa các sách đã chọn?",
|
||||
"Auto Mode": "Chế độ tự động",
|
||||
"Behavior": "Hành vi",
|
||||
"Book": "Sách",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "Cài đặt chung",
|
||||
"Go Back": "Quay lại",
|
||||
"Go Forward": "Tiến tới",
|
||||
"Go Left": "Sang trái",
|
||||
"Go Right": "Sang phải",
|
||||
"Grass": "Xanh cỏ",
|
||||
"Gray": "Xám",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Highlight": "Đánh dấu",
|
||||
"Horizontal Direction": "Hướng ngang",
|
||||
"Hyphenation": "Gạch nối từ",
|
||||
"Identifier:": "Định danh:",
|
||||
"Import Books": "Nhập sách",
|
||||
"Language:": "Ngôn ngữ:",
|
||||
"Layout": "Bố cục",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "Khớp toàn bộ từ",
|
||||
"Maximum Number of Columns": "Số cột tối đa",
|
||||
"Minimum Font Size": "Cỡ chữ tối thiểu",
|
||||
"Misc": "Khác",
|
||||
"Monospace Font": "Phông chữ đơn cách",
|
||||
"More Info": "Thông tin thêm",
|
||||
"Nord": "Nord",
|
||||
@@ -303,5 +298,36 @@
|
||||
"Orientation": "Định hướng",
|
||||
"Portrait": "Chân dung",
|
||||
"Landscape": "Phong cảnh",
|
||||
"Open Last Book on Start": "Mở sách cuối cùng khi khởi động"
|
||||
"Open Last Book on Start": "Mở sách cuối cùng khi khởi động",
|
||||
"Checking for updates...": "Đang kiểm tra cập nhật...",
|
||||
"Error checking for updates": "Lỗi khi kiểm tra cập nhật",
|
||||
"Details": "Chi tiết",
|
||||
"File Size:": "Kích thước tệp:",
|
||||
"Auto Detect": "Phát hiện tự động",
|
||||
"Next Section": "Phần tiếp theo",
|
||||
"Previous Section": "Phần trước",
|
||||
"Next Page": "Trang tiếp theo",
|
||||
"Previous Page": "Trang trước",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Bạn có chắc muốn xóa {{count}} cuốn sách đã chọn không?",
|
||||
"Are you sure to delete the selected book?": "Bạn có chắc muốn xóa cuốn sách đã chọn không?",
|
||||
"Deselect": "Bỏ chọn",
|
||||
"Select All": "Chọn tất",
|
||||
"No translation available.": "Không có bản dịch nào.",
|
||||
"Translated by {{provider}}.": "Được dịch bởi {{provider}}.",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Dịch",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "Đảo ảnh chế độ tối",
|
||||
"Help improve Readest": "Giúp cải thiện Readest",
|
||||
"Sharing anonymized statistics": "Chia sẻ thống kê ẩn danh",
|
||||
"Interface Language": "Ngôn ngữ giao diện",
|
||||
"Translation": "Dịch",
|
||||
"Enable Translation": "Bật dịch",
|
||||
"Translation Service": "Dịch vụ dịch thuật",
|
||||
"Translate To": "Dịch sang",
|
||||
"Disable Translation": "Tắt dịch",
|
||||
"Scroll": "Cuộn",
|
||||
"Overlap Pixels": "Chồng lấn pixel",
|
||||
"Click": "Nhấp",
|
||||
"System Language": "Ngôn ngữ hệ thống"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "动画",
|
||||
"Annotate": "笔记",
|
||||
"Apply": "应用",
|
||||
"Are you sure to delete the selected books?": "您确定要删除所选书籍吗?",
|
||||
"Auto Mode": "自动主题",
|
||||
"Behavior": "行为",
|
||||
"Book": "书籍",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "全局设置",
|
||||
"Go Back": "返回",
|
||||
"Go Forward": "前进",
|
||||
"Go Left": "向左",
|
||||
"Go Right": "向右",
|
||||
"Grass": "青草",
|
||||
"Gray": "素雅",
|
||||
"Gruvbox": "暖橘",
|
||||
"Highlight": "划线",
|
||||
"Horizontal Direction": "横排",
|
||||
"Hyphenation": "断字",
|
||||
"Identifier:": "识别码",
|
||||
"Import Books": "导入书籍",
|
||||
"Language:": "语言",
|
||||
"Layout": "布局",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "匹配整个单词",
|
||||
"Maximum Number of Columns": "分栏数",
|
||||
"Minimum Font Size": "最小字号",
|
||||
"Misc": "杂项",
|
||||
"Monospace Font": "等宽字体",
|
||||
"More Info": "更多信息",
|
||||
"Nord": "极光",
|
||||
@@ -303,5 +298,36 @@
|
||||
"Orientation": "屏幕方向",
|
||||
"Portrait": "竖屏",
|
||||
"Landscape": "横屏",
|
||||
"Open Last Book on Start": "启动时打开上次阅读的书籍"
|
||||
"Open Last Book on Start": "启动时打开上次阅读的书籍",
|
||||
"Checking for updates...": "检查更新中...",
|
||||
"Error checking for updates": "检查更新时出错",
|
||||
"Details": "详情",
|
||||
"File Size:": "文件大小",
|
||||
"Auto Detect": "自动检测",
|
||||
"Next Section": "下一章",
|
||||
"Previous Section": "上一章",
|
||||
"Next Page": "下一页",
|
||||
"Previous Page": "上一页",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "您确定要删除 {{count}} 本选中的书籍吗?",
|
||||
"Are you sure to delete the selected book?": "您确定要删除选中的书籍吗?",
|
||||
"Deselect": "取消选择",
|
||||
"Select All": "全选",
|
||||
"No translation available.": "暂无可用的翻译",
|
||||
"Translated by {{provider}}.": "由 {{provider}} 提供翻译",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "深色主题下图片反色",
|
||||
"Help improve Readest": "帮助改进 Readest",
|
||||
"Sharing anonymized statistics": "分享匿名统计数据",
|
||||
"Interface Language": "界面语言",
|
||||
"Translation": "翻译",
|
||||
"Enable Translation": "启用翻译",
|
||||
"Translation Service": "翻译服务",
|
||||
"Translate To": "翻译到",
|
||||
"Disable Translation": "禁用翻译",
|
||||
"Scroll": "滚动",
|
||||
"Overlap Pixels": "重叠像素",
|
||||
"Click": "点击",
|
||||
"System Language": "系统语言"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"Animation": "動畫",
|
||||
"Annotate": "筆記",
|
||||
"Apply": "應用",
|
||||
"Are you sure to delete the selected books?": "您確定要刪除所選書籍嗎?",
|
||||
"Auto Mode": "自動主題",
|
||||
"Behavior": "行為",
|
||||
"Book": "書籍",
|
||||
@@ -42,15 +41,12 @@
|
||||
"Global Settings": "全局設置",
|
||||
"Go Back": "返回",
|
||||
"Go Forward": "前進",
|
||||
"Go Left": "向左",
|
||||
"Go Right": "向右",
|
||||
"Grass": "青草",
|
||||
"Gray": "素雅",
|
||||
"Gruvbox": "暖橘",
|
||||
"Highlight": "劃線",
|
||||
"Horizontal Direction": "橫排",
|
||||
"Hyphenation": "斷字",
|
||||
"Identifier:": "識別碼",
|
||||
"Import Books": "導入書籍",
|
||||
"Language:": "語言",
|
||||
"Layout": "版面",
|
||||
@@ -63,7 +59,6 @@
|
||||
"Match Whole Words": "匹配整個單詞",
|
||||
"Maximum Number of Columns": "分欄數",
|
||||
"Minimum Font Size": "最小字號",
|
||||
"Misc": "雜項",
|
||||
"Monospace Font": "等寬字體",
|
||||
"More Info": "更多信息",
|
||||
"Nord": "極光",
|
||||
@@ -303,5 +298,36 @@
|
||||
"Orientation": "螢幕方向",
|
||||
"Portrait": "豎屏",
|
||||
"Landscape": "橫屏",
|
||||
"Open Last Book on Start": "啟動時打開上次閱讀的書籍"
|
||||
"Open Last Book on Start": "啟動時打開上次閱讀的書籍",
|
||||
"Checking for updates...": "檢查更新中...",
|
||||
"Error checking for updates": "檢查更新時出錯",
|
||||
"Details": "詳情",
|
||||
"File Size:": "檔案大小",
|
||||
"Auto Detect": "自動檢測",
|
||||
"Next Section": "下一章",
|
||||
"Previous Section": "上一章",
|
||||
"Next Page": "下一頁",
|
||||
"Previous Page": "上一頁",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "確定要刪除已選擇的 {{count}} 本書嗎?",
|
||||
"Are you sure to delete the selected book?": "確定要刪除已選擇的書籍嗎?",
|
||||
"Deselect": "取消選取",
|
||||
"Select All": "全選",
|
||||
"No translation available.": "暫無翻譯",
|
||||
"Translated by {{provider}}.": "由 {{provider}} 提供翻譯",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"Invert Image In Dark Mode": "深色主題下圖片反相",
|
||||
"Help improve Readest": "幫助改進 Readest",
|
||||
"Sharing anonymized statistics": "分享匿名統計數據",
|
||||
"Interface Language": "介面語言",
|
||||
"Translation": "翻譯",
|
||||
"Enable Translation": "啟用翻譯",
|
||||
"Translation Service": "翻譯服務",
|
||||
"Translate To": "翻譯至",
|
||||
"Disable Translation": "停用翻譯",
|
||||
"Scroll": "滾動",
|
||||
"Overlap Pixels": "重疊像素",
|
||||
"Click": "點擊",
|
||||
"System Language": "系統語言"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.50": {
|
||||
"date": "2025-05-28",
|
||||
"notes": [
|
||||
"Bilingual TTS: You can now choose voices for each language in bilingual text-to-speech mode",
|
||||
"Settings Layout: Improved header layout in the settings dialog for different screen sizes",
|
||||
"CSS Fixes: Corrected anchor text color and fixed image dimensions within paragraphs"
|
||||
]
|
||||
},
|
||||
"0.9.49": {
|
||||
"date": "2025-05-27",
|
||||
"notes": [
|
||||
"Select All in Library: You can now select all books at once when in selection mode",
|
||||
"More Translation Options: Added support for Azure and Google Translate services",
|
||||
"Dark Mode Enhancement: New option to invert image colors for better visibility in dark mode",
|
||||
"Bilingual TTS: Enjoy bilingual text-to-speech for books with content in two languages",
|
||||
"Full Book Translation: Translate books in any language into bilingual ebooks",
|
||||
"Privacy Settings: Added a new option to opt-out of telemetry"
|
||||
]
|
||||
},
|
||||
"0.9.43": {
|
||||
"date": "2025-05-19",
|
||||
"notes": [
|
||||
"Enhanced compatibility with Gutenberg and Feedbooks ebooks for better styling",
|
||||
"Reduced sensitivity of trackpad and mouse during page flips",
|
||||
"Cloud backup status is now visible for each book on mobile and desktop",
|
||||
"New option to control scroll overlap when paginating in scroll mode",
|
||||
"Added Dutch language translations",
|
||||
"Added more font weight options for built-in fonts",
|
||||
"Added previous and next section buttons in the footer bar for easier navigation"
|
||||
]
|
||||
},
|
||||
"0.9.41": {
|
||||
"date": "2025-05-13",
|
||||
"notes": [
|
||||
"Improved UI and layout on mobile devices",
|
||||
"Avatar images are now cached for offline use",
|
||||
"Book file size is now displayed in the book details view",
|
||||
"Update status is now correctly shown in the About window",
|
||||
"TTS control positioning improved for notebook and RTL modes",
|
||||
"TXT files are segmented by paragraph count if no chapter titles are found",
|
||||
"Android: App now respects system auto-rotation settings",
|
||||
"Linux: Updater is disabled on Linux when not using AppImage",
|
||||
"Linux: Added support for Linux ARMHF builds",
|
||||
"CSS: Resolved layout and color issues caused by hardcoded CSS"
|
||||
]
|
||||
},
|
||||
"0.9.40": {
|
||||
"date": "2025-05-08",
|
||||
"notes": [
|
||||
"Prevented oversized images from causing layout issues",
|
||||
"Resolved issue with transient navigation bar on Android 9",
|
||||
"Action bar now closes properly when the footbar is dismissed",
|
||||
"Smoother experience when expanding Table of Contents sections",
|
||||
"Reverted unintended style overrides"
|
||||
]
|
||||
},
|
||||
"0.9.39": {
|
||||
"date": "2025-05-07",
|
||||
"notes": [
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Updating bundleVersion in tauri.appstore.conf.json..."
|
||||
|
||||
CONFIG_FILE="src-tauri/tauri.appstore.conf.json"
|
||||
CURRENT_DATE=$(date "+%Y%m%d.%H%M%S")
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "Error: Config file $CONFIG_FILE not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_FILE=$(mktemp)
|
||||
cat "$CONFIG_FILE" | jq --arg version "$CURRENT_DATE" '.bundle.macOS.bundleVersion = $version' > "$TMP_FILE"
|
||||
mv "$TMP_FILE" "$CONFIG_FILE"
|
||||
echo "Updated bundleVersion to $CURRENT_DATE"
|
||||
|
||||
echo "Building macOS universal app for App Store..."
|
||||
pnpm run build-macos-universial-appstore
|
||||
|
||||
BUNDLE_DIR=../../target/universal-apple-darwin/release/bundle/macos
|
||||
|
||||
@@ -31,9 +31,9 @@ reqwest = { version = "0.12", default-features = false, features = [
|
||||
"stream",
|
||||
] }
|
||||
# FIXME: remove the devtools feature in production
|
||||
tauri = { version = "2.4.1", features = [ "protocol-asset", "devtools"] }
|
||||
tauri = { version = "2.5.1", features = [ "protocol-asset", "devtools"] }
|
||||
tauri-build = "2.1.1"
|
||||
tauri-plugin-log = "2.3.1"
|
||||
tauri-plugin-log = "2.4.0"
|
||||
tauri-plugin-fs = "2.2.1"
|
||||
tauri-plugin-dialog = "2.2.1"
|
||||
tauri-plugin-os = "2.2.1"
|
||||
|
||||
@@ -61,6 +61,15 @@
|
||||
},
|
||||
{
|
||||
"url": "https://*.cloudflarestorage.com"
|
||||
},
|
||||
{
|
||||
"url": "https://edge.microsoft.com"
|
||||
},
|
||||
{
|
||||
"url": "https://*.microsofttranslator.com"
|
||||
},
|
||||
{
|
||||
"url": "https://translate.googleapis.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 87 KiB |
+20
-2
@@ -221,11 +221,12 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
if (visible) {
|
||||
controller.show(WindowInsets.Type.statusBars())
|
||||
controller.hide(WindowInsets.Type.navigationBars())
|
||||
} else {
|
||||
controller.hide(WindowInsets.Type.systemBars())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val compatController = WindowCompat.getInsetsController(window, decorView)
|
||||
compatController?.let {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
@@ -236,10 +237,27 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
if (visible) {
|
||||
it.show(WindowInsetsCompat.Type.statusBars())
|
||||
it.hide(WindowInsetsCompat.Type.navigationBars())
|
||||
} else {
|
||||
it.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
decorView.systemUiVisibility = buildList {
|
||||
add(View.SYSTEM_UI_FLAG_LAYOUT_STABLE)
|
||||
add(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
|
||||
add(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION)
|
||||
add(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
|
||||
add(View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
|
||||
|
||||
if (!visible) {
|
||||
add(View.SYSTEM_UI_FLAG_FULLSCREEN)
|
||||
}
|
||||
if (visible && !isDarkMode) {
|
||||
add(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR)
|
||||
}
|
||||
}.reduce { acc, flag -> acc or flag }
|
||||
}
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
@@ -340,7 +358,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
when (orientation) {
|
||||
"portrait" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
"landscape" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
"auto" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR
|
||||
"auto" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
|
||||
else -> {
|
||||
invoke.reject("Invalid orientation mode")
|
||||
return
|
||||
|
||||
@@ -191,9 +191,8 @@ pub fn run() {
|
||||
|
||||
let app_handle = app.handle().clone();
|
||||
app.listen("window-ready", move |_| {
|
||||
app_handle
|
||||
.get_webview_window("main")
|
||||
.unwrap()
|
||||
let webview = app_handle.get_webview_window("main").unwrap();
|
||||
webview
|
||||
.eval("window.__READEST_CLI_ACCESS = true;")
|
||||
.expect("Failed to set cli access config");
|
||||
|
||||
@@ -204,6 +203,19 @@ pub fn run() {
|
||||
{
|
||||
set_rounded_window(&app_handle, false);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let is_appimage = std::env::var("APPIMAGE").is_ok()
|
||||
|| std::env::current_exe()
|
||||
.map(|path| path.to_string_lossy().contains("/tmp/.mount_"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let script =
|
||||
format!("window.__READEST_UPDATER_DISABLED = {};", !is_appimage);
|
||||
webview
|
||||
.eval(&script)
|
||||
.expect("Failed to set updater disabled config");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"security": {
|
||||
"csp": {
|
||||
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
|
||||
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com",
|
||||
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://*.microsofttranslator.com https://edge.microsoft.com",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://fontsapi.zeoseven.com",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://fontsapi.zeoseven.com",
|
||||
|
||||
@@ -78,8 +78,8 @@ export default function AuthPage() {
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
const getTauriRedirectTo = (isOAuth: boolean) => {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) {
|
||||
if (appService?.isMobile) {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobileApp || USE_APPLE_SIGN_IN) {
|
||||
if (appService?.isMobileApp) {
|
||||
return isOAuth ? DEEPLINK_CALLBACK : WEB_AUTH_CALLBACK;
|
||||
}
|
||||
return DEEPLINK_CALLBACK;
|
||||
@@ -172,7 +172,7 @@ export default function AuthPage() {
|
||||
|
||||
const startTauriOAuth = async () => {
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobileApp || USE_APPLE_SIGN_IN) {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
currentWindow.listen('single-instance', ({ event, payload }) => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from 'react';
|
||||
import Providers from '@/components/Providers';
|
||||
|
||||
import '../styles/globals.css';
|
||||
import '../styles/fonts.css';
|
||||
|
||||
const url = 'https://web.readest.com/';
|
||||
const title = 'Readest — Where You Read, Digest and Get Insight';
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import clsx from 'clsx';
|
||||
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
|
||||
import { CiCircleMore } from 'react-icons/ci';
|
||||
import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/lia';
|
||||
import {
|
||||
LiaCloudUploadAltSolid,
|
||||
LiaCloudDownloadAltSolid,
|
||||
LiaInfoCircleSolid,
|
||||
} from 'react-icons/lia';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { LibraryViewModeType } from '@/types/settings';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { formatAuthors } from '@/utils/book';
|
||||
import ReadingProgress from './ReadingProgress';
|
||||
import BookCover from '@/components/BookCover';
|
||||
@@ -32,8 +38,10 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
handleBookDownload,
|
||||
showBookDetailsModal,
|
||||
}) => {
|
||||
const iconSize15 = useResponsiveSize(15);
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { appService } = useEnv();
|
||||
const iconSize15 = useResponsiveSize(15);
|
||||
|
||||
const stopEvent = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -96,11 +104,26 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
|
||||
>
|
||||
{book.progress && <ReadingProgress book={book} />}
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<div className='flex items-center justify-center gap-x-2'>
|
||||
{!appService?.isMobile && (
|
||||
<button
|
||||
className='show-detail-button -m-2 p-2 sm:opacity-0 sm:group-hover:opacity-100'
|
||||
onPointerDown={(e) => stopEvent(e)}
|
||||
onPointerUp={(e) => stopEvent(e)}
|
||||
onPointerMove={(e) => stopEvent(e)}
|
||||
onPointerCancel={(e) => stopEvent(e)}
|
||||
onPointerLeave={(e) => stopEvent(e)}
|
||||
onClick={() => showBookDetailsModal(book)}
|
||||
>
|
||||
<div className='pt-[1px]'>
|
||||
<LiaInfoCircleSolid size={iconSize15} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{transferProgress !== null ? (
|
||||
transferProgress === 100 ? null : (
|
||||
<div
|
||||
className='radial-progress opacity-0 group-hover:opacity-100'
|
||||
className='radial-progress'
|
||||
style={
|
||||
{
|
||||
'--value': transferProgress,
|
||||
@@ -112,38 +135,33 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
></div>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onPointerDown={(e) => stopEvent(e)}
|
||||
onPointerUp={(e) => stopEvent(e)}
|
||||
onPointerMove={(e) => stopEvent(e)}
|
||||
onPointerCancel={(e) => stopEvent(e)}
|
||||
onPointerLeave={(e) => stopEvent(e)}
|
||||
onClick={() => {
|
||||
if (!book.uploadedAt) {
|
||||
handleBookUpload(book);
|
||||
} else if (!book.downloadedAt) {
|
||||
handleBookDownload(book);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!book.uploadedAt && <LiaCloudUploadAltSolid size={iconSize15} />}
|
||||
{book.uploadedAt && !book.downloadedAt && (
|
||||
<LiaCloudDownloadAltSolid size={iconSize15} />
|
||||
)}
|
||||
</button>
|
||||
(!book.uploadedAt || (book.uploadedAt && !book.downloadedAt)) && (
|
||||
<button
|
||||
className='show-cloud-button -m-2 p-2'
|
||||
onPointerDown={(e) => stopEvent(e)}
|
||||
onPointerUp={(e) => stopEvent(e)}
|
||||
onPointerMove={(e) => stopEvent(e)}
|
||||
onPointerCancel={(e) => stopEvent(e)}
|
||||
onPointerLeave={(e) => stopEvent(e)}
|
||||
onClick={() => {
|
||||
if (!user) {
|
||||
navigateToLogin(router);
|
||||
return;
|
||||
}
|
||||
if (!book.uploadedAt) {
|
||||
handleBookUpload(book);
|
||||
} else if (!book.downloadedAt) {
|
||||
handleBookDownload(book);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!book.uploadedAt && <LiaCloudUploadAltSolid size={iconSize15} />}
|
||||
{book.uploadedAt && !book.downloadedAt && (
|
||||
<LiaCloudDownloadAltSolid size={iconSize15} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onPointerDown={(e) => stopEvent(e)}
|
||||
onPointerUp={(e) => stopEvent(e)}
|
||||
onPointerMove={(e) => stopEvent(e)}
|
||||
onPointerCancel={(e) => stopEvent(e)}
|
||||
onPointerLeave={(e) => stopEvent(e)}
|
||||
onClick={() => showBookDetailsModal(book)}
|
||||
>
|
||||
<CiCircleMore size={iconSize15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MdDelete, MdOpenInNew, MdOutlineCancel } from 'react-icons/md';
|
||||
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
|
||||
import { LuFolderPlus } from 'react-icons/lu';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
@@ -17,12 +17,15 @@ import { isMd5 } from '@/utils/md5';
|
||||
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import ModalPortal from '@/components/ModalPortal';
|
||||
import BookshelfItem, { generateGridItems, generateListItems } from './BookshelfItem';
|
||||
import GroupingModal from './GroupingModal';
|
||||
|
||||
interface BookshelfProps {
|
||||
libraryBooks: Book[];
|
||||
isSelectMode: boolean;
|
||||
isSelectAll: boolean;
|
||||
isSelectNone: boolean;
|
||||
handleImportBooks: () => void;
|
||||
handleBookUpload: (book: Book) => Promise<boolean>;
|
||||
handleBookDownload: (book: Book) => Promise<boolean>;
|
||||
@@ -35,6 +38,8 @@ interface BookshelfProps {
|
||||
const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
libraryBooks,
|
||||
isSelectMode,
|
||||
isSelectAll,
|
||||
isSelectNone,
|
||||
handleImportBooks,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
@@ -67,14 +72,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const allBookshelfItems =
|
||||
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelectMode) {
|
||||
setShowSelectModeActions(true);
|
||||
} else {
|
||||
setSelectedBooks([]);
|
||||
setShowSelectModeActions(false);
|
||||
}
|
||||
}, [isSelectMode]);
|
||||
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
|
||||
|
||||
useEffect(() => {
|
||||
if (isImportingBook.current) return;
|
||||
@@ -162,14 +160,29 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
navigateToReader(router, selectedBooks);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const openBookDetails = () => {
|
||||
const book = libraryBooks.find((book) => book.hash === selectedBooks[0]);
|
||||
if (book) {
|
||||
handleShowDetailsBook(book);
|
||||
}
|
||||
};
|
||||
|
||||
const getBooksToDelete = () => {
|
||||
const booksToDelete: Book[] = [];
|
||||
selectedBooks.forEach((id) => {
|
||||
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
|
||||
if (book && !book.deletedAt) {
|
||||
handleBookDelete(book);
|
||||
booksToDelete.push(book);
|
||||
}
|
||||
}
|
||||
});
|
||||
return booksToDelete;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
for (const book of getBooksToDelete()) {
|
||||
handleBookDelete(book);
|
||||
}
|
||||
setSelectedBooks([]);
|
||||
setShowDeleteAlert(false);
|
||||
setShowSelectModeActions(true);
|
||||
@@ -185,7 +198,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
setShowGroupingModal(true);
|
||||
};
|
||||
|
||||
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
|
||||
const bookFilter = (item: Book, queryTerm: string) => {
|
||||
if (item.deletedAt) return false;
|
||||
const searchTerm = new RegExp(queryTerm, 'i');
|
||||
@@ -238,6 +250,23 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelectMode) {
|
||||
setShowSelectModeActions(true);
|
||||
if (isSelectAll) {
|
||||
setSelectedBooks(
|
||||
filteredBookshelfItems.map((item) => ('hash' in item ? item.hash : item.id)),
|
||||
);
|
||||
} else if (isSelectNone) {
|
||||
setSelectedBooks([]);
|
||||
}
|
||||
} else {
|
||||
setSelectedBooks([]);
|
||||
setShowSelectModeActions(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSelectMode, isSelectAll, isSelectNone]);
|
||||
|
||||
return (
|
||||
<div className='bookshelf'>
|
||||
<div
|
||||
@@ -290,14 +319,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-center shadow-lg',
|
||||
'text-base-content bg-base-300 text-sm',
|
||||
'bg-gray-600 text-xs text-white',
|
||||
'mx-auto w-fit space-x-6 rounded-lg p-4',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={openSelectedBooks}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center',
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
(!selectedBooks.length || !selectedBooks.every((id) => isMd5(id))) &&
|
||||
'btn-disabled opacity-50',
|
||||
)}
|
||||
@@ -308,17 +337,28 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
<button
|
||||
onClick={groupSelectedBooks}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center',
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
!selectedBooks.length && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<LuFolderPlus />
|
||||
<div>{_('Group')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={openBookDetails}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
(selectedBooks.length !== 1 || !selectedBooks.every((id) => isMd5(id))) &&
|
||||
'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<MdInfoOutline />
|
||||
<div>{_('Details')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteSelectedBooks}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center',
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
!selectedBooks.length && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
@@ -327,7 +367,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSetSelectMode(false)}
|
||||
className={clsx('flex flex-col items-center justify-center')}
|
||||
className={clsx('flex flex-col items-center justify-center gap-1')}
|
||||
>
|
||||
<MdOutlineCancel />
|
||||
<div>{_('Cancel')}</div>
|
||||
@@ -336,7 +376,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{showGroupingModal && (
|
||||
<div>
|
||||
<ModalPortal>
|
||||
<GroupingModal
|
||||
libraryBooks={libraryBooks}
|
||||
selectedBooks={selectedBooks}
|
||||
@@ -349,7 +389,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleSetSelectMode(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
)}
|
||||
{showDeleteAlert && (
|
||||
<div
|
||||
@@ -360,7 +400,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
>
|
||||
<Alert
|
||||
title={_('Confirm Deletion')}
|
||||
message={_('Are you sure to delete the selected books?')}
|
||||
message={_('Are you sure to delete {{count}} selected book(s)?', {
|
||||
count: getBooksToDelete().length,
|
||||
})}
|
||||
onCancel={() => {
|
||||
setShowDeleteAlert(false);
|
||||
setShowSelectModeActions(true);
|
||||
|
||||
@@ -140,7 +140,7 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
}, [selectedBooks]);
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50'>
|
||||
<div className='fixed inset-0 flex items-center justify-center'>
|
||||
<div
|
||||
className={clsx(
|
||||
'modal-box bg-base-100 overflow-y-auto rounded-2xl shadow-xl',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
@@ -8,6 +10,7 @@ interface ImportMenuProps {
|
||||
|
||||
const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBooks }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
|
||||
const handleImportBooks = () => {
|
||||
onImportBooks();
|
||||
@@ -17,7 +20,10 @@ const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBook
|
||||
return (
|
||||
<ul
|
||||
tabIndex={-1}
|
||||
className='dropdown-content dropdown-center bg-base-100 menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
|
||||
className={clsx(
|
||||
'dropdown-content bg-base-100 menu rounded-box z-[1] mt-3 w-52 p-2 shadow',
|
||||
appService?.isMobile ? 'no-triangle' : 'dropdown-center',
|
||||
)}
|
||||
>
|
||||
<MenuItem label={_('From Local File')} onClick={handleImportBooks} />
|
||||
</ul>
|
||||
|
||||
@@ -24,14 +24,20 @@ import SortMenu from './SortMenu';
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
isSelectMode: boolean;
|
||||
isSelectAll: boolean;
|
||||
onImportBooks: () => void;
|
||||
onToggleSelectMode: () => void;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
}
|
||||
|
||||
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
isSelectMode,
|
||||
isSelectAll,
|
||||
onImportBooks,
|
||||
onToggleSelectMode,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -142,7 +148,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
setSearchQuery('');
|
||||
throttledUpdateQueryParam('');
|
||||
}}
|
||||
className='text-gray-400 hover:text-gray-600'
|
||||
className='pe-1 text-gray-400 hover:text-gray-600'
|
||||
aria-label={_('Clear Search')}
|
||||
>
|
||||
<IoMdCloseCircle className='h-4 w-4' />
|
||||
@@ -150,7 +156,10 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
)}
|
||||
<span className='bg-base-content/50 mx-2 h-4 w-[0.5px]'></span>
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center'
|
||||
className={clsx(
|
||||
'exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center',
|
||||
appService?.isMobile ? 'dropdown-end' : 'dropdown-center',
|
||||
)}
|
||||
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center'
|
||||
toggleButton={
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Import Books')}>
|
||||
@@ -160,47 +169,68 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
>
|
||||
<ImportMenu onImportBooks={onImportBooks} />
|
||||
</Dropdown>
|
||||
<button
|
||||
onClick={onToggleSelectMode}
|
||||
aria-label={_('Select Multiple Books')}
|
||||
className='h-6'
|
||||
>
|
||||
<div
|
||||
className='lg:tooltip lg:tooltip-bottom cursor-pointer'
|
||||
data-tip={_('Select Books')}
|
||||
{appService?.isMobile ? null : (
|
||||
<button
|
||||
onClick={onToggleSelectMode}
|
||||
aria-label={_('Select Multiple Books')}
|
||||
className='h-6'
|
||||
>
|
||||
<PiSelectionAllDuotone
|
||||
role='button'
|
||||
className={`h-6 w-6 ${isSelectMode ? 'fill-gray-400' : 'fill-gray-500'}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className='lg:tooltip lg:tooltip-bottom cursor-pointer'
|
||||
data-tip={_('Select Books')}
|
||||
>
|
||||
<PiSelectionAllDuotone
|
||||
role='button'
|
||||
className={`h-6 w-6 ${isSelectMode ? 'fill-gray-400' : 'fill-gray-500'}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex h-full items-center gap-x-2 sm:gap-x-4'>
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiDotsThreeCircle size={iconSize18} />}
|
||||
{isSelectMode ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-full items-center',
|
||||
'w-max-[72px] w-min-[72px] sm:w-max-[80px] sm:w-min-[80px]',
|
||||
)}
|
||||
>
|
||||
<SortMenu />
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<MdOutlineMenu size={iconSize18} />}
|
||||
>
|
||||
<SettingsMenu />
|
||||
</Dropdown>
|
||||
{appService?.hasWindowBar && (
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={windowButtonVisible}
|
||||
showMaximize={windowButtonVisible}
|
||||
showClose={windowButtonVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={isSelectAll ? onDeselectAll : onSelectAll}
|
||||
className='btn btn-ghost text-base-content/85 h-8 min-h-8 w-[72px] p-0 sm:w-[80px]'
|
||||
aria-label={isSelectAll ? _('Deselect') : _('Select All')}
|
||||
>
|
||||
<span className='font-sans text-base font-normal sm:text-sm'>
|
||||
{isSelectAll ? _('Deselect') : _('Select All')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex h-full items-center gap-x-2 sm:gap-x-4'>
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiDotsThreeCircle size={iconSize18} />}
|
||||
>
|
||||
<SortMenu />
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<MdOutlineMenu size={iconSize18} />}
|
||||
>
|
||||
<SettingsMenu />
|
||||
</Dropdown>
|
||||
{appService?.hasWindowBar && (
|
||||
<WindowButtons
|
||||
headerRef={headerRef}
|
||||
showMinimize={windowButtonVisible}
|
||||
showMaximize={windowButtonVisible}
|
||||
showClose={windowButtonVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
@@ -15,6 +16,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { getStoragePlanData } from '@/utils/access';
|
||||
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
|
||||
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
|
||||
import { QuotaType } from '@/types/user';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
@@ -39,6 +41,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
|
||||
settings.autoImportBooksOnOpen,
|
||||
);
|
||||
const [isTelemetryEnabled, setIsTelemetryEnabled] = useState(settings.telemetryEnabled);
|
||||
const iconSize = useResponsiveSize(16);
|
||||
|
||||
const showAboutReadest = () => {
|
||||
@@ -118,6 +121,18 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsOpenLastBooks(settings.openLastBooks);
|
||||
};
|
||||
|
||||
const toggleTelemetry = () => {
|
||||
settings.telemetryEnabled = !settings.telemetryEnabled;
|
||||
if (settings.telemetryEnabled) {
|
||||
optInTelemetry();
|
||||
} else {
|
||||
optOutTelemetry();
|
||||
}
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsTelemetryEnabled(settings.telemetryEnabled);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
const storagPlan = getStoragePlanData(token);
|
||||
@@ -134,7 +149,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const isWebApp = isWebAppPlatform();
|
||||
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
|
||||
const userFullName = user?.user_metadata?.['full_name'];
|
||||
const userDisplayName = userFullName ? userFullName.split(' ')[0] : null;
|
||||
@@ -142,7 +156,10 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
|
||||
className={clsx(
|
||||
'settings-menu dropdown-content no-triangle border-base-100',
|
||||
'z-20 mt-2 max-w-[90vw] shadow-2xl',
|
||||
)}
|
||||
>
|
||||
{user ? (
|
||||
<MenuItem
|
||||
@@ -180,11 +197,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
onClick={toggleAutoImportBooksOnOpen}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
label={_('Open Last Book on Start')}
|
||||
Icon={isOpenLastBooks ? MdCheck : undefined}
|
||||
onClick={toggleOpenLastBooks}
|
||||
/>
|
||||
{isTauriAppPlatform() && (
|
||||
<MenuItem
|
||||
label={_('Open Last Book on Start')}
|
||||
Icon={isOpenLastBooks ? MdCheck : undefined}
|
||||
onClick={toggleOpenLastBooks}
|
||||
/>
|
||||
)}
|
||||
{appService?.hasUpdater && (
|
||||
<MenuItem
|
||||
label={_('Check Updates on Start')}
|
||||
@@ -208,8 +227,14 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
/>
|
||||
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
|
||||
<MenuItem
|
||||
label={_('Help improve Readest')}
|
||||
description={isTelemetryEnabled ? _('Sharing anonymized statistics') : ''}
|
||||
Icon={isTelemetryEnabled ? MdCheck : undefined}
|
||||
onClick={toggleTelemetry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { mountAdditionalFonts } from '@/utils/style';
|
||||
import {
|
||||
tauriHandleSetAlwaysOnTop,
|
||||
tauriHandleToggleFullScreen,
|
||||
@@ -74,10 +75,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const isInitiating = useRef(false);
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
const [isSelectAll, setIsSelectAll] = useState(false);
|
||||
const [isSelectNone, setIsSelectNone] = useState(false);
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const [booksTransferProgress, setBooksTransferProgress] = useState<{
|
||||
[key: string]: number | null;
|
||||
}>({});
|
||||
const [pendingNavigationBookIds, setPendingNavigationBookIds] = useState<string[] | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const demoBooks = useDemoBooks();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -106,6 +110,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mountAdditionalFonts(document);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const doCheckAppUpdates = async () => {
|
||||
if (appService?.hasUpdater && settings.autoCheckUpdates) {
|
||||
@@ -121,7 +129,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
|
||||
useEffect(() => {
|
||||
if (appService?.isMobileApp) {
|
||||
lockScreenOrientation({ orientation: 'portrait' });
|
||||
lockScreenOrientation({ orientation: 'auto' });
|
||||
}
|
||||
}, [appService]);
|
||||
|
||||
@@ -231,10 +239,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
|
||||
console.log('Opening books:', bookIds);
|
||||
if (bookIds.length > 0) {
|
||||
setTimeout(() => {
|
||||
navigateToReader(router, bookIds);
|
||||
}, 0);
|
||||
setPendingNavigationBookIds(bookIds);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
@@ -245,21 +253,31 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
lastBookIds: string[],
|
||||
libraryBooks: Book[],
|
||||
) => {
|
||||
if (lastBookIds.length === 0) return;
|
||||
if (lastBookIds.length === 0) return false;
|
||||
const bookIds: string[] = [];
|
||||
for (const bookId of lastBookIds) {
|
||||
const book = libraryBooks.find((b) => b.hash === bookId);
|
||||
if (book && (await appService.isBookAvailable(book))) {
|
||||
bookIds.push(book.hash);
|
||||
}
|
||||
console.log('Opening last books:', bookIds);
|
||||
}
|
||||
console.log('Opening last books:', bookIds);
|
||||
if (bookIds.length > 0) {
|
||||
setPendingNavigationBookIds(bookIds);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingNavigationBookIds) {
|
||||
const bookIds = pendingNavigationBookIds;
|
||||
setPendingNavigationBookIds(null);
|
||||
if (bookIds.length > 0) {
|
||||
setTimeout(() => {
|
||||
navigateToReader(router, bookIds);
|
||||
}, 0);
|
||||
navigateToReader(router, bookIds);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [pendingNavigationBookIds, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
@@ -285,34 +303,28 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const settings = await appService.loadSettings();
|
||||
setSettings(settings);
|
||||
|
||||
const libraryBooks = await appService.loadLibraryBooks();
|
||||
if (checkOpenWithBooks) {
|
||||
await handleOpenWithBooks(appService, libraryBooks);
|
||||
} else {
|
||||
setCheckOpenWithBooks(false);
|
||||
setLibrary(libraryBooks);
|
||||
}
|
||||
|
||||
if (checkLastOpenBooks && settings.openLastBooks) {
|
||||
await handleOpenLastBooks(appService, settings.lastOpenBooks, libraryBooks);
|
||||
} else {
|
||||
setCheckLastOpenBooks(false);
|
||||
}
|
||||
// Reuse the library from the store when we return from the reader
|
||||
const library = libraryBooks.length > 0 ? libraryBooks : await appService.loadLibraryBooks();
|
||||
setCheckOpenWithBooks(checkOpenWithBooks && (await handleOpenWithBooks(appService, library)));
|
||||
setCheckLastOpenBooks(
|
||||
checkLastOpenBooks &&
|
||||
settings.openLastBooks &&
|
||||
(await handleOpenLastBooks(appService, settings.lastOpenBooks, library)),
|
||||
);
|
||||
|
||||
setLibrary(library);
|
||||
setLibraryLoaded(true);
|
||||
if (loadingTimeout) clearTimeout(loadingTimeout);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleOpenWithBooks = async (appService: AppService, libraryBooks: Book[]) => {
|
||||
const handleOpenWithBooks = async (appService: AppService, library: Book[]) => {
|
||||
const openWithFiles = (await parseOpenWithFiles()) || [];
|
||||
|
||||
if (openWithFiles.length > 0) {
|
||||
await processOpenWithFiles(appService, openWithFiles, libraryBooks);
|
||||
} else {
|
||||
setCheckOpenWithBooks(false);
|
||||
setLibrary(libraryBooks);
|
||||
return await processOpenWithFiles(appService, openWithFiles, library);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
initLogin();
|
||||
@@ -350,10 +362,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
['Failed to parse EPUB.', _('Failed to parse the EPUB file.')],
|
||||
['Unsupported format.', _('This book format is not supported.')],
|
||||
];
|
||||
const { library } = useLibraryStore.getState();
|
||||
for (const file of files) {
|
||||
try {
|
||||
const book = await appService?.importBook(file, libraryBooks);
|
||||
setLibrary(libraryBooks);
|
||||
const book = await appService?.importBook(file, library);
|
||||
setLibrary(library);
|
||||
if (user && book && !book.uploadedAt && settings.autoUpload) {
|
||||
console.log('Uploading book:', book.title);
|
||||
handleBookUpload(book);
|
||||
@@ -376,7 +389,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
console.error('Failed to import book:', filename, error);
|
||||
}
|
||||
}
|
||||
appService?.saveLibraryBooks(libraryBooks);
|
||||
appService?.saveLibraryBooks(library);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -516,18 +529,23 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
importBooks(files);
|
||||
};
|
||||
|
||||
const handleToggleSelectMode = () => {
|
||||
if (!isSelectMode && appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
setIsSelectMode((pre) => !pre);
|
||||
};
|
||||
|
||||
const handleSetSelectMode = (selectMode: boolean) => {
|
||||
if (selectMode && appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
setIsSelectMode(selectMode);
|
||||
setIsSelectAll(false);
|
||||
setIsSelectNone(false);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setIsSelectAll(true);
|
||||
setIsSelectNone(false);
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setIsSelectNone(true);
|
||||
setIsSelectAll(false);
|
||||
};
|
||||
|
||||
const handleShowDetailsBook = (book: Book) => {
|
||||
@@ -560,8 +578,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<div className='fixed top-0 z-40 w-full'>
|
||||
<LibraryHeader
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
onImportBooks={handleImportBooks}
|
||||
onToggleSelectMode={handleToggleSelectMode}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
/>
|
||||
</div>
|
||||
{loading && (
|
||||
@@ -589,6 +610,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
isSelectNone={isSelectNone}
|
||||
handleImportBooks={handleImportBooks}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { redirectToLibrary } from '@/utils/nav';
|
||||
'use client';
|
||||
|
||||
const HomePage = () => {
|
||||
redirectToLibrary();
|
||||
};
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default HomePage;
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/library');
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import Annotator from './annotator/Annotator';
|
||||
import FootnotePopup from './FootnotePopup';
|
||||
import HintInfo from './HintInfo';
|
||||
import DoubleBorder from './DoubleBorder';
|
||||
import TTSControl from './tts/TTSControl';
|
||||
|
||||
interface BooksGridProps {
|
||||
bookKeys: string[];
|
||||
@@ -42,7 +43,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('grid h-full flex-grow')}
|
||||
className={clsx('relative grid h-full flex-grow')}
|
||||
style={{
|
||||
gridTemplateColumns: gridTemplate.columns,
|
||||
gridTemplateRows: gridTemplate.rows,
|
||||
@@ -132,6 +133,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
<HintInfo
|
||||
bookKey={bookKey}
|
||||
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
|
||||
isScrolled={viewSettings.scrolled}
|
||||
isVertical={viewSettings.vertical}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
@@ -161,6 +163,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<TTSControl />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getMaxInlineSize } from '@/utils/config';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { useTextTranslation } from '../hooks/useTextTranslation';
|
||||
|
||||
const FoliateViewer: React.FC<{
|
||||
bookKey: string;
|
||||
@@ -42,7 +43,6 @@ const FoliateViewer: React.FC<{
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { themeCode, isDarkMode } = useThemeStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
useEffect(() => {
|
||||
@@ -50,9 +50,10 @@ const FoliateViewer: React.FC<{
|
||||
return () => clearTimeout(timer);
|
||||
}, [toastMessage]);
|
||||
|
||||
useUICSS(bookKey, viewSettings);
|
||||
useUICSS(bookKey);
|
||||
useProgressSync(bookKey);
|
||||
useProgressAutoSave(bookKey);
|
||||
useTextTranslation(bookKey, viewRef.current);
|
||||
|
||||
const progressRelocateHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
@@ -72,7 +73,7 @@ const FoliateViewer: React.FC<{
|
||||
bookKey,
|
||||
viewSettings,
|
||||
content: data,
|
||||
transformers: ['punctuation'],
|
||||
transformers: ['punctuation', 'footnote'],
|
||||
};
|
||||
return Promise.resolve(transformContent(ctx));
|
||||
}
|
||||
@@ -170,6 +171,7 @@ const FoliateViewer: React.FC<{
|
||||
const width = containerRect?.width || window.innerWidth;
|
||||
const height = containerRect?.height || window.innerHeight;
|
||||
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const writingMode = viewSettings.writingMode;
|
||||
if (writingMode) {
|
||||
const settingsDir = getBookDirFromWritingMode(writingMode);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
|
||||
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
|
||||
import { RiArrowLeftDoubleLine, RiArrowRightDoubleLine } 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';
|
||||
@@ -17,6 +18,7 @@ import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { viewPagination } from '../hooks/usePagination';
|
||||
import { saveViewSettings } from '../utils/viewSettingsHelper';
|
||||
import { PageInfo } from '@/types/book';
|
||||
import Button from '@/components/Button';
|
||||
@@ -76,12 +78,24 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
saveViewSettings(envConfig, bookKey, 'lineHeight', value / 10);
|
||||
};
|
||||
|
||||
const handleGoPrev = () => {
|
||||
view?.goLeft();
|
||||
const handleGoPrevPage = () => {
|
||||
viewPagination(view, viewSettings, 'left');
|
||||
};
|
||||
|
||||
const handleGoNext = () => {
|
||||
view?.goRight();
|
||||
const handleGoNextPage = () => {
|
||||
viewPagination(view, viewSettings, 'right');
|
||||
};
|
||||
|
||||
const handleGoPrevSection = () => {
|
||||
if (view?.renderer.prevSection) {
|
||||
view?.renderer.prevSection();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoNextSection = () => {
|
||||
if (view?.renderer.nextSection) {
|
||||
view?.renderer.nextSection();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
@@ -122,6 +136,12 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredBookKey !== bookKey) {
|
||||
setActionTab('');
|
||||
}
|
||||
}, [hoveredBookKey, bookKey]);
|
||||
|
||||
const getMarginProgressValue = (marginPx: number, gapPercent: number) => {
|
||||
return (marginPx / 88 + gapPercent / 10) * 50;
|
||||
};
|
||||
@@ -167,7 +187,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
{/* 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',
|
||||
'bg-base-200 absolute bottom-16 flex w-full flex-col items-center gap-y-8 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',
|
||||
@@ -178,34 +198,48 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
: '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 className='flex w-full items-center justify-between gap-x-6'>
|
||||
<Slider
|
||||
heightPx={sliderHeight}
|
||||
bubbleLabel={`${Math.round(progressFraction * 100)}%`}
|
||||
initialValue={progressValid ? progressFraction * 100 : 0}
|
||||
onChange={(e) => handleProgressChange(e)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex w-full items-center justify-between gap-x-6'>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowRightDoubleLine /> : <RiArrowLeftDoubleLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoNextSection : handleGoPrevSection}
|
||||
tooltip={viewSettings?.rtl ? _('Next Section') : _('Previous Section')}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowRightSLine /> : <RiArrowLeftSLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoNextPage : handleGoPrevPage}
|
||||
tooltip={viewSettings?.rtl ? _('Next Page') : _('Previous Page')}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
|
||||
tooltip={viewSettings?.rtl ? _('Previous Page') : _('Next Page')}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowLeftDoubleLine /> : <RiArrowRightDoubleLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoPrevSection : handleGoNextSection}
|
||||
tooltip={viewSettings?.rtl ? _('Previous Section') : _('Next Section')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -283,9 +317,14 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
{/* 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')}
|
||||
icon={viewSettings?.rtl ? <RiArrowRightDoubleLine /> : <RiArrowLeftDoubleLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoNextSection : handleGoPrevSection}
|
||||
tooltip={viewSettings?.rtl ? _('Next Section') : _('Previous Section')}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowRightSLine /> : <RiArrowLeftSLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoNextPage : handleGoPrevPage}
|
||||
tooltip={viewSettings?.rtl ? _('Next Page') : _('Previous Page')}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
|
||||
@@ -314,9 +353,14 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
/>
|
||||
<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')}
|
||||
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
|
||||
tooltip={viewSettings?.rtl ? _('Previous Page') : _('Next Page')}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowLeftDoubleLine /> : <RiArrowRightDoubleLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoPrevSection : handleGoNextSection}
|
||||
tooltip={viewSettings?.rtl ? _('Previous Section') : _('Next Section')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import SidebarToggler from './SidebarToggler';
|
||||
import BookmarkToggler from './BookmarkToggler';
|
||||
import NotebookToggler from './NotebookToggler';
|
||||
import SettingsToggler from './SettingsToggler';
|
||||
import TranslationToggler from './TranslationToggler';
|
||||
import ViewMenu from './ViewMenu';
|
||||
|
||||
interface HeaderBarProps {
|
||||
@@ -128,6 +129,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
</div>
|
||||
|
||||
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4'>
|
||||
<TranslationToggler bookKey={bookKey} />
|
||||
<SettingsToggler />
|
||||
<NotebookToggler bookKey={bookKey} />
|
||||
<Dropdown
|
||||
|
||||
@@ -5,6 +5,7 @@ import { eventDispatcher } from '@/utils/event';
|
||||
interface SectionInfoProps {
|
||||
bookKey: string;
|
||||
showDoubleBorder: boolean;
|
||||
isScrolled: boolean;
|
||||
isVertical: boolean;
|
||||
horizontalGap: number;
|
||||
verticalMargin: number;
|
||||
@@ -13,6 +14,7 @@ interface SectionInfoProps {
|
||||
const HintInfo: React.FC<SectionInfoProps> = ({
|
||||
bookKey,
|
||||
showDoubleBorder,
|
||||
isScrolled,
|
||||
isVertical,
|
||||
horizontalGap,
|
||||
verticalMargin,
|
||||
@@ -57,7 +59,14 @@ const HintInfo: React.FC<SectionInfoProps> = ({
|
||||
'hintinfo absolute flex items-center justify-end overflow-hidden ps-2',
|
||||
'mt-[env(safe-area-inset-top)]',
|
||||
hintMessage ? 'bg-base-100' : 'bg-transparent',
|
||||
isVertical ? 'writing-vertical-rl max-h-[50%]' : 'top-0 h-[44px] max-w-[50%]',
|
||||
isVertical ? 'writing-vertical-rl' : 'top-0 h-[44px]',
|
||||
isScrolled
|
||||
? isVertical
|
||||
? 'h-full'
|
||||
: 'w-full'
|
||||
: isVertical
|
||||
? 'max-h-[50%]'
|
||||
: 'max-w-[50%]',
|
||||
)}
|
||||
style={
|
||||
isVertical
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { interceptGlobalOpen } from '@/utils/open';
|
||||
import { mountAdditionalFonts } from '@/utils/style';
|
||||
import { setSystemUIVisibility } from '@/utils/bridge';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
@@ -36,6 +38,11 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
useTheme({ systemUIVisible: false, appThemeColor: 'base-100' });
|
||||
useScreenWakeLock(settings.screenWakeLock);
|
||||
|
||||
useEffect(() => {
|
||||
mountAdditionalFonts(document);
|
||||
interceptGlobalOpen();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (appService?.isMobileApp && !document.hidden) {
|
||||
@@ -94,7 +101,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.isMobile) return;
|
||||
if (!appService?.isMobileApp) return;
|
||||
const systemUIVisible = !!hoveredBookKey;
|
||||
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
|
||||
if (systemUIVisible) {
|
||||
|
||||
@@ -27,7 +27,6 @@ import Spinner from '@/components/Spinner';
|
||||
import SideBar from './sidebar/SideBar';
|
||||
import Notebook from './notebook/Notebook';
|
||||
import BooksGrid from './BooksGrid';
|
||||
import TTSControl from './tts/TTSControl';
|
||||
|
||||
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
|
||||
const router = useRouter();
|
||||
@@ -166,7 +165,6 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
<div className={clsx('flex', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
|
||||
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
|
||||
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
|
||||
<TTSControl />
|
||||
<Notebook />
|
||||
{showDetailsBook && (
|
||||
<BookDetailModal
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React from 'react';
|
||||
import { RiFontSize } from 'react-icons/ri';
|
||||
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
const SettingsToggler = () => {
|
||||
const _ = useTranslation();
|
||||
const { setHoveredBookKey } = useReaderStore();
|
||||
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
const handleToggleSettings = () => {
|
||||
setHoveredBookKey('');
|
||||
setFontLayoutSettingsDialogOpen(!isFontLayoutSettingsDialogOpen);
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { RiTranslateAi } from 'react-icons/ri';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { saveViewSettings } from '../utils/viewSettingsHelper';
|
||||
import { isSameLang } from '@/utils/lang';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getViewSettings, setViewSettings, setHoveredBookKey } = useReaderStore();
|
||||
|
||||
const bookData = getBookData(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
|
||||
|
||||
useEffect(() => {
|
||||
if (translationEnabled === viewSettings.translationEnabled) return;
|
||||
setHoveredBookKey('');
|
||||
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
|
||||
viewSettings.translationEnabled = translationEnabled;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [translationEnabled]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={
|
||||
<RiTranslateAi className={translationEnabled ? 'text-blue-500' : 'text-base-content'} />
|
||||
}
|
||||
disabled={
|
||||
!bookData || isSameLang(bookData.book?.primaryLanguage, viewSettings.translateTargetLang!)
|
||||
}
|
||||
onClick={() => setTranslationEnabled(!translationEnabled)}
|
||||
tooltip={translationEnabled ? _('Disable Translation') : _('Enable Translation')}
|
||||
tooltipDirection='bottom'
|
||||
></Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default TranslationToggler;
|
||||
@@ -33,9 +33,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const { themeMode, setThemeMode } = useThemeStore();
|
||||
const { themeMode, isDarkMode, setThemeMode } = useThemeStore();
|
||||
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
|
||||
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
|
||||
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
|
||||
viewSettings!.invertImgColorInDark,
|
||||
);
|
||||
|
||||
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
|
||||
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
|
||||
@@ -76,6 +79,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [zoomLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
|
||||
saveViewSettings(envConfig, bookKey, 'invertImgColorInDark', invertImgColorInDark, true, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [invertImgColorInDark]);
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
@@ -135,6 +144,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
|
||||
onClick={cycleThemeMode}
|
||||
/>
|
||||
<MenuItem
|
||||
label={_('Invert Image In Dark Mode')}
|
||||
disabled={!isDarkMode}
|
||||
Icon={invertImgColorInDark ? MdCheck : undefined}
|
||||
onClick={() => setInvertImgColorInDark(!invertImgColorInDark)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FiSearch } from 'react-icons/fi';
|
||||
import { FiCopy } from 'react-icons/fi';
|
||||
import { PiHighlighterFill } from 'react-icons/pi';
|
||||
@@ -22,21 +22,15 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
|
||||
import { useNotesSync } from '../../hooks/useNotesSync';
|
||||
import {
|
||||
getPopupPosition,
|
||||
getPosition,
|
||||
getTextFromRange,
|
||||
Position,
|
||||
TextSelection,
|
||||
} from '@/utils/sel';
|
||||
import { useTextSelector } from '../../hooks/useTextSelector';
|
||||
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { findTocItemBS } from '@/utils/toc';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||
import AnnotationPopup from './AnnotationPopup';
|
||||
import WiktionaryPopup from './WiktionaryPopup';
|
||||
import WikipediaPopup from './WikipediaPopup';
|
||||
import DeepLPopup from './DeepLPopup';
|
||||
import TranslatorPopup from './TranslatorPopup';
|
||||
|
||||
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
@@ -54,15 +48,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const primaryLang = bookData.book?.primaryLanguage || 'en';
|
||||
|
||||
const isShowingPopup = useRef(false);
|
||||
const isTextSelected = useRef(false);
|
||||
const isUpToShowPopup = useRef(false);
|
||||
const isTouchstarted = useRef(false);
|
||||
const selectionStartRef = useRef<number | null>(null);
|
||||
const selectionAnchorRef = useRef<{ node: Node; offset: number } | null>(null);
|
||||
const [selection, setSelection] = useState<TextSelection | null>();
|
||||
const [selection, setSelection] = useState<TextSelection | null>(null);
|
||||
const [showAnnotPopup, setShowAnnotPopup] = useState(false);
|
||||
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
|
||||
const [showWikipediaPopup, setShowWikipediaPopup] = useState(false);
|
||||
@@ -86,142 +73,41 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const dictPopupWidth = Math.min(480, maxWidth);
|
||||
const dictPopupHeight = Math.min(300, maxHeight);
|
||||
const transPopupWidth = Math.min(480, maxWidth);
|
||||
const transPopupHeight = Math.min(360, maxHeight);
|
||||
const transPopupHeight = Math.min(265, maxHeight);
|
||||
const annotPopupWidth = Math.min(useResponsiveSize(300), maxWidth);
|
||||
const annotPopupHeight = useResponsiveSize(44);
|
||||
const androidSelectionHandlerHeight = 0;
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
setSelection(null);
|
||||
setShowAnnotPopup(false);
|
||||
setShowWiktionaryPopup(false);
|
||||
setShowWikipediaPopup(false);
|
||||
setShowDeepLPopup(false);
|
||||
};
|
||||
|
||||
const handleDismissPopupAndSelection = () => {
|
||||
handleDismissPopup();
|
||||
view?.deselect();
|
||||
};
|
||||
|
||||
const { handleScroll, handlePointerup, handleSelectionchange, handleShowPopup, handleUpToPopup } =
|
||||
useTextSelector(bookKey, setSelection, handleDismissPopup);
|
||||
|
||||
const onLoad = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const { doc, index } = detail;
|
||||
|
||||
const isValidSelection = (sel: Selection) => {
|
||||
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
|
||||
};
|
||||
const transformCtx = {
|
||||
bookKey,
|
||||
viewSettings: getViewSettings(bookKey)!,
|
||||
content: '',
|
||||
transformers: ['punctuation'],
|
||||
reversePunctuationTransform: true,
|
||||
};
|
||||
const getAnnotationText = (range: Range) => {
|
||||
return getTextFromRange(range, primaryLang.startsWith('ja') ? ['rt'] : []);
|
||||
};
|
||||
const makeSelection = async (sel: Selection, rebuildRange = false) => {
|
||||
isTextSelected.current = true;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (rebuildRange) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
transformCtx['content'] = getAnnotationText(range);
|
||||
setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index });
|
||||
};
|
||||
// FIXME: extremely hacky way to dismiss system selection tools on iOS
|
||||
const makeSelectionOnIOS = async (sel: Selection) => {
|
||||
isTextSelected.current = true;
|
||||
const range = sel.getRangeAt(0);
|
||||
setTimeout(() => {
|
||||
sel.removeAllRanges();
|
||||
setTimeout(async () => {
|
||||
if (!isTextSelected.current) return;
|
||||
sel.addRange(range);
|
||||
transformCtx['content'] = getAnnotationText(range);
|
||||
setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index });
|
||||
}, 40);
|
||||
}, 0);
|
||||
};
|
||||
const handleSelectstart = () => {
|
||||
selectionAnchorRef.current = null;
|
||||
};
|
||||
const handleSelectionchange = () => {
|
||||
// Available on iOS, Android and Desktop, fired when the selection is changed
|
||||
// Ideally the popup only shows when the selection is done,
|
||||
// but on Android no proper events are fired to notify selection done or I didn't find it,
|
||||
// we make the popup show when the selection is changed
|
||||
if (osPlatform === 'ios' || appService?.isIOSApp) return;
|
||||
|
||||
const sel = doc.getSelection();
|
||||
if (isValidSelection(sel)) {
|
||||
if (osPlatform === 'android' && isTouchstarted.current) {
|
||||
if (!selectionAnchorRef.current) {
|
||||
const range = sel.getRangeAt(0);
|
||||
selectionAnchorRef.current = {
|
||||
node: range.startContainer,
|
||||
offset: range.startOffset,
|
||||
};
|
||||
}
|
||||
if (selectionAnchorRef.current) {
|
||||
const currentRange = sel.getRangeAt(0);
|
||||
if (
|
||||
currentRange.startContainer !== selectionAnchorRef.current.node ||
|
||||
currentRange.startOffset !== selectionAnchorRef.current.offset
|
||||
) {
|
||||
const newRange = doc.createRange();
|
||||
newRange.setStart(selectionAnchorRef.current.node, selectionAnchorRef.current.offset);
|
||||
newRange.setEnd(currentRange.endContainer, currentRange.endOffset);
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
}
|
||||
}
|
||||
makeSelection(sel, false);
|
||||
}
|
||||
} else if (!isUpToShowPopup.current) {
|
||||
isTextSelected.current = false;
|
||||
setShowAnnotPopup(false);
|
||||
setShowWiktionaryPopup(false);
|
||||
setShowWikipediaPopup(false);
|
||||
setShowDeepLPopup(false);
|
||||
}
|
||||
};
|
||||
const handlePointerup = () => {
|
||||
// Available on iOS and Desktop, fired when release the long press
|
||||
// Note that on Android, pointerup event is fired after an additional touch event
|
||||
const sel = doc.getSelection();
|
||||
if (isValidSelection(sel)) {
|
||||
if (osPlatform === 'ios' || appService?.isIOSApp) {
|
||||
makeSelectionOnIOS(sel);
|
||||
} else {
|
||||
makeSelection(sel, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleTouchstart = () => {
|
||||
// Available on iOS and Android for the initial touch event
|
||||
isTouchstarted.current = true;
|
||||
};
|
||||
const handleTouchmove = () => {
|
||||
// Available on iOS, on Android not fired
|
||||
// To make the popup not to follow the selection
|
||||
setShowAnnotPopup(false);
|
||||
};
|
||||
const handleTouchend = () => {
|
||||
// Available on iOS, on Android fired after an additional touch event
|
||||
isTouchstarted.current = false;
|
||||
};
|
||||
const handleScroll = () => {
|
||||
// Prevent the container from scrolling when text is selected in paginated mode
|
||||
// This is a workaround for the issue #873
|
||||
// TODO: support text selection across pages
|
||||
if (
|
||||
!viewSettings?.scrolled &&
|
||||
isTextSelected.current &&
|
||||
view?.renderer?.containerPosition &&
|
||||
selectionStartRef.current
|
||||
) {
|
||||
view.renderer.containerPosition = selectionStartRef.current;
|
||||
}
|
||||
};
|
||||
if (bookData.book?.format !== 'PDF') {
|
||||
view?.renderer?.addEventListener('scroll', handleScroll);
|
||||
detail.doc?.addEventListener('pointerup', handlePointerup);
|
||||
detail.doc?.addEventListener('touchstart', handleTouchstart);
|
||||
detail.doc?.addEventListener('touchmove', handleTouchmove);
|
||||
detail.doc?.addEventListener('touchend', handleTouchend);
|
||||
detail.doc?.addEventListener('selectstart', handleSelectstart);
|
||||
detail.doc?.addEventListener('selectionchange', handleSelectionchange);
|
||||
detail.doc?.addEventListener('pointerup', () => handlePointerup(doc, index));
|
||||
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index));
|
||||
|
||||
// Disable the default context menu on mobile devices,
|
||||
// although it should but doesn't work on iOS
|
||||
@@ -269,60 +155,22 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const annotation = annotations.find((annotation) => annotation.cfi === cfi);
|
||||
if (!annotation) return;
|
||||
const selection = { key: bookKey, annotated: true, text: annotation.text ?? '', range, index };
|
||||
isUpToShowPopup.current = true;
|
||||
setSelectedStyle(annotation.style!);
|
||||
setSelectedColor(annotation.color!);
|
||||
setSelection(selection);
|
||||
handleUpToPopup();
|
||||
};
|
||||
|
||||
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation });
|
||||
|
||||
const handleDismissPopup = () => {
|
||||
setSelection(null);
|
||||
setShowAnnotPopup(false);
|
||||
setShowWiktionaryPopup(false);
|
||||
setShowWikipediaPopup(false);
|
||||
setShowDeepLPopup(false);
|
||||
isShowingPopup.current = false;
|
||||
};
|
||||
|
||||
const handleDismissPopupAndSelection = () => {
|
||||
handleDismissPopup();
|
||||
view?.deselect();
|
||||
isTextSelected.current = false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isTextSelected.current && !selectionStartRef.current) {
|
||||
selectionStartRef.current = view?.renderer?.start || null;
|
||||
} else if (!isTextSelected.current) {
|
||||
selectionStartRef.current = null;
|
||||
selectionAnchorRef.current = null;
|
||||
}
|
||||
handleShowPopup(showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isTextSelected.current]);
|
||||
}, [showAnnotPopup, showWiktionaryPopup, showWikipediaPopup, showDeepLPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSingleClick = (): boolean => {
|
||||
if (isUpToShowPopup.current) {
|
||||
isUpToShowPopup.current = false;
|
||||
return true;
|
||||
}
|
||||
if (isTextSelected.current) {
|
||||
handleDismissPopupAndSelection();
|
||||
return true;
|
||||
}
|
||||
if (showAnnotPopup || isShowingPopup.current) {
|
||||
handleDismissPopup();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
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
|
||||
@@ -342,7 +190,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
viewSettings.vertical ? annotPopupWidth : annotPopupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
if (isTextSelected.current && annotPopupPos.dir === 'down' && osPlatform === 'android') {
|
||||
if (annotPopupPos.dir === 'down' && osPlatform === 'android') {
|
||||
triangPos.point.y += androidSelectionHandlerHeight;
|
||||
annotPopupPos.point.y += androidSelectionHandlerHeight;
|
||||
}
|
||||
@@ -361,12 +209,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
popupPadding,
|
||||
);
|
||||
if (triangPos.point.x == 0 || triangPos.point.y == 0) return;
|
||||
setShowAnnotPopup(true);
|
||||
setAnnotPopupPosition(annotPopupPos);
|
||||
setDictPopupPosition(dictPopupPos);
|
||||
setTranslatorPopupPosition(transPopupPos);
|
||||
setTrianglePosition(triangPos);
|
||||
isShowingPopup.current = true;
|
||||
handleShowAnnotPopup();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selection, bookKey]);
|
||||
@@ -393,6 +240,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress]);
|
||||
|
||||
const handleShowAnnotPopup = () => {
|
||||
setShowAnnotPopup(true);
|
||||
setShowDeepLPopup(false);
|
||||
setShowWiktionaryPopup(false);
|
||||
setShowWikipediaPopup(false);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!selection || !selection.text) return;
|
||||
eventDispatcher.dispatch('toast', {
|
||||
@@ -645,7 +499,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
/>
|
||||
)}
|
||||
{showDeepLPopup && trianglePosition && translatorPopupPosition && (
|
||||
<DeepLPopup
|
||||
<TranslatorPopup
|
||||
text={selection?.text as string}
|
||||
position={translatorPopupPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Popup from '@/components/Popup';
|
||||
import { Position } from '@/utils/sel';
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
const LANGUAGES = {
|
||||
AUTO: 'Auto Detect',
|
||||
EN: 'English',
|
||||
DE: 'German',
|
||||
FR: 'French',
|
||||
ES: 'Spanish',
|
||||
IT: 'Italian',
|
||||
EL: 'Greek',
|
||||
PT: 'Portuguese',
|
||||
NL: 'Dutch',
|
||||
PL: 'Polish',
|
||||
UK: 'Ukrainian',
|
||||
RU: 'Russian',
|
||||
AR: 'Arabic',
|
||||
TR: 'Turkish',
|
||||
ID: 'Indonesian',
|
||||
KO: 'Korean',
|
||||
JA: 'Japanese',
|
||||
'ZH-HANS': 'Chinese (Simplified)',
|
||||
'ZH-HANT': 'Chinese (Traditional)',
|
||||
};
|
||||
|
||||
const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate';
|
||||
|
||||
interface DeepLPopupProps {
|
||||
text: string;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
popupWidth: number;
|
||||
popupHeight: number;
|
||||
}
|
||||
|
||||
const DeepLPopup: React.FC<DeepLPopupProps> = ({
|
||||
text,
|
||||
position,
|
||||
trianglePosition,
|
||||
popupWidth,
|
||||
popupHeight,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { token } = useAuth();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const [sourceLang, setSourceLang] = useState('AUTO');
|
||||
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
|
||||
const [translation, setTranslation] = useState<string | null>(null);
|
||||
const [detectedSourceLang, setDetectedSourceLang] = useState<keyof typeof LANGUAGES | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSourceLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSourceLang(event.target.value);
|
||||
};
|
||||
|
||||
const handleTargetLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
settings.globalReadSettings.translateTargetLang = event.target.value;
|
||||
setSettings(settings);
|
||||
setTargetLang(event.target.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTranslation = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setTranslation(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(DEEPL_API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token ?? ''}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: [text],
|
||||
target_lang: targetLang.toUpperCase(),
|
||||
source_lang: sourceLang === 'AUTO' ? undefined : sourceLang.toUpperCase(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch translation');
|
||||
}
|
||||
const data = await response.json();
|
||||
const translatedText = data.translations[0]?.text;
|
||||
const detectedSource = data.translations[0]?.detected_source_language;
|
||||
|
||||
if (!translatedText) {
|
||||
throw new Error('No translation found');
|
||||
}
|
||||
|
||||
if (sourceLang === 'AUTO' && detectedSource) {
|
||||
setDetectedSourceLang(detectedSource);
|
||||
}
|
||||
|
||||
setTranslation(translatedText);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (!token) {
|
||||
setError(_('Unable to fetch the translation. Please log in first and try again.'));
|
||||
} else {
|
||||
setError(_('Unable to fetch the translation. Try again later.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTranslation();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [text, token, sourceLang, targetLang]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Popup
|
||||
trianglePosition={trianglePosition}
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
className='select-text'
|
||||
>
|
||||
<div className='text-neutral-content relative h-[50%] overflow-y-auto border-b border-neutral-400/75 p-4 font-sans'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<h1 className='text-base font-semibold'>{_('Original Text')}</h1>
|
||||
<select
|
||||
value={sourceLang}
|
||||
onChange={handleSourceLangChange}
|
||||
className='select text-neutral-content h-8 min-h-8 rounded-md border-none bg-neutral-200/50 text-sm focus:outline-none focus:ring-0'
|
||||
>
|
||||
{Object.entries(LANGUAGES).map(([code, name]) => {
|
||||
return (
|
||||
<option key={code} value={code}>
|
||||
{detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
|
||||
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} ` + _('(detected)')
|
||||
: name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<p className='text-base'>{text}</p>
|
||||
</div>
|
||||
|
||||
<div className='text-neutral-content relative h-[50%] overflow-y-auto p-4 font-sans'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<h2 className='text-base font-semibold'>{_('Translated Text')}</h2>
|
||||
<select
|
||||
value={targetLang}
|
||||
onChange={handleTargetLangChange}
|
||||
className='select text-neutral-content h-8 min-h-8 rounded-md border-none bg-neutral-200/50 text-sm focus:outline-none focus:ring-0'
|
||||
>
|
||||
{Object.entries(LANGUAGES)
|
||||
.filter(([code]) => code !== 'AUTO')
|
||||
.map(([code, name]) => (
|
||||
<option key={code} value={code}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className='text-base italic text-gray-500'>{_('Loading...')}</p>
|
||||
) : error ? (
|
||||
<p className='text-base text-red-600'>{error}</p>
|
||||
) : (
|
||||
<div>
|
||||
<p className='text-base'>{translation || 'No translation available.'}</p>
|
||||
<div className='pt-4 text-sm opacity-60'>Translated by DeepL.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeepLPopup;
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Popup from '@/components/Popup';
|
||||
import { Position } from '@/utils/sel';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTranslator } from '@/hooks/useTranslator';
|
||||
import { TRANSLATED_LANGS } from '@/services/constants';
|
||||
import { UseTranslatorOptions } from '@/services/translators';
|
||||
import { localeToLang } from '@/utils/lang';
|
||||
import Select from '@/components/Select';
|
||||
|
||||
const notSupportedLangs = ['hi', 'vi'];
|
||||
|
||||
const generateTranslatorLangs = () => {
|
||||
const langs = { ...TRANSLATED_LANGS };
|
||||
const result: Record<string, string> = {};
|
||||
for (const [code, name] of Object.entries(langs)) {
|
||||
if (notSupportedLangs.includes(code)) continue;
|
||||
const newCode = localeToLang(code).toUpperCase();
|
||||
result[newCode] = name;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const TRANSLATOR_LANGS = generateTranslatorLangs();
|
||||
|
||||
interface TranslatorPopupProps {
|
||||
text: string;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
popupWidth: number;
|
||||
popupHeight: number;
|
||||
}
|
||||
|
||||
interface TranslatorType {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
text,
|
||||
position,
|
||||
trianglePosition,
|
||||
popupWidth,
|
||||
popupHeight,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { token } = useAuth();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const [providers, setProviders] = useState<TranslatorType[]>([]);
|
||||
const [sourceLang, setSourceLang] = useState('AUTO');
|
||||
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
|
||||
const [provider, setProvider] = useState(settings.globalReadSettings.translationProvider);
|
||||
const [translation, setTranslation] = useState<string | null>(null);
|
||||
const [detectedSourceLang, setDetectedSourceLang] = useState<
|
||||
keyof typeof TRANSLATOR_LANGS | null
|
||||
>(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { translate, translators } = useTranslator({
|
||||
provider,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
} as UseTranslatorOptions);
|
||||
|
||||
const handleSourceLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSourceLang(event.target.value);
|
||||
};
|
||||
|
||||
const handleTargetLangChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
settings.globalReadSettings.translateTargetLang = event.target.value;
|
||||
setSettings(settings);
|
||||
setTargetLang(event.target.value);
|
||||
};
|
||||
|
||||
const handleProviderChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedTranslator = translators.find((t) => t.name === event.target.value);
|
||||
if (selectedTranslator) {
|
||||
settings.globalReadSettings.translationProvider = selectedTranslator.name;
|
||||
setSettings(settings);
|
||||
setProvider(selectedTranslator.name);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const availableProviders = translators.map((t) => {
|
||||
return { name: t.name, label: t.label };
|
||||
});
|
||||
setProviders(availableProviders);
|
||||
}, [translators]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const fetchTranslation = async () => {
|
||||
setError(null);
|
||||
setTranslation(null);
|
||||
|
||||
try {
|
||||
const input = text.replaceAll('\n', ' ').trim();
|
||||
const result = await translate([input]);
|
||||
const translatedText = result[0];
|
||||
const detectedSource = null;
|
||||
|
||||
if (!translatedText) {
|
||||
throw new Error('No translation found');
|
||||
}
|
||||
|
||||
setTranslation(translatedText);
|
||||
if (sourceLang === 'AUTO' && detectedSource) {
|
||||
setDetectedSourceLang(detectedSource);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (!token) {
|
||||
setError(_('Unable to fetch the translation. Please log in first and try again.'));
|
||||
} else {
|
||||
setError(_('Unable to fetch the translation. Try again later.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTranslation();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [text, token, sourceLang, targetLang, provider]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Popup
|
||||
trianglePosition={trianglePosition}
|
||||
width={popupWidth}
|
||||
minHeight={popupHeight}
|
||||
maxHeight={720}
|
||||
position={position}
|
||||
className='grid h-full select-text grid-rows-[1fr,auto,1fr] bg-gray-600 text-white'
|
||||
triangleClassName='text-gray-600'
|
||||
>
|
||||
<div className='overflow-y-auto p-4 font-sans'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<h1 className='text-sm font-normal'>{_('Original Text')}</h1>
|
||||
<Select
|
||||
value={sourceLang}
|
||||
onChange={handleSourceLangChange}
|
||||
options={[
|
||||
{ value: 'AUTO', label: _('Auto Detect') },
|
||||
...Object.entries(TRANSLATOR_LANGS)
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([code, name]) => {
|
||||
const label =
|
||||
detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
|
||||
? `${TRANSLATOR_LANGS[detectedSourceLang] || detectedSourceLang} ` +
|
||||
_('(detected)')
|
||||
: name;
|
||||
return { value: code, label };
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p className='text-base text-white/90'>{text}</p>
|
||||
</div>
|
||||
|
||||
<div className='mx-4 flex-shrink-0 border-t border-gray-500/30'></div>
|
||||
|
||||
<div className='overflow-y-auto px-4 pb-8 pt-4 font-sans'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<h2 className='text-sm font-normal'>{_('Translated Text')}</h2>
|
||||
<Select
|
||||
value={targetLang}
|
||||
onChange={handleTargetLangChange}
|
||||
options={Object.entries(TRANSLATOR_LANGS)
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([code, name]) => ({ value: code, label: name }))}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className='text-base italic text-gray-500'>{_('Loading...')}</p>
|
||||
) : (
|
||||
<div>
|
||||
{error ? (
|
||||
<p className='text-base text-red-600'>{error}</p>
|
||||
) : (
|
||||
<p className='text-base text-white/90'>
|
||||
{translation || _('No translation available.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='absolute bottom-0 flex h-8 w-full items-center justify-between bg-gray-600 px-4'>
|
||||
{provider && !loading && (
|
||||
<div className='text-xs opacity-60'>
|
||||
{error
|
||||
? ''
|
||||
: _('Translated by {{provider}}.', {
|
||||
provider: providers.find((p) => p.name === provider)?.label,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className='ml-auto'>
|
||||
<Select
|
||||
value={provider}
|
||||
onChange={handleProviderChange}
|
||||
options={providers.map(({ name: value, label }) => ({ value, label }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TranslatorPopup;
|
||||
@@ -16,11 +16,11 @@ const NotebookHeader: React.FC<{
|
||||
const iconSize14 = useResponsiveSize(14);
|
||||
return (
|
||||
<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'>
|
||||
<div className='absolute inset-0 z-[-1] flex items-center justify-center space-x-2'>
|
||||
<LuNotebookPen />
|
||||
<div className='notebook-title hidden text-sm font-medium sm:flex'>{_('Notebook')}</div>
|
||||
</div>
|
||||
<div className='z-10 flex items-center gap-x-4'>
|
||||
<div className='flex w-full items-center gap-x-4'>
|
||||
<button
|
||||
onClick={handleTogglePin}
|
||||
className={clsx(
|
||||
@@ -36,6 +36,8 @@ const NotebookHeader: React.FC<{
|
||||
>
|
||||
<MdArrowBackIosNew />
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex items-center justify-end gap-x-4'>
|
||||
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
|
||||
<FiSearch />
|
||||
</button>
|
||||
|
||||
@@ -42,6 +42,7 @@ const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange }) => {
|
||||
<input
|
||||
type='text'
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
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'
|
||||
/>
|
||||
|
||||
@@ -13,24 +13,38 @@ import {
|
||||
themes,
|
||||
} from '@/styles/themes';
|
||||
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 { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import ThemeEditor from './ThemeEditor';
|
||||
|
||||
const ColorPanel: React.FC<{ bookKey: string }> = ({}) => {
|
||||
const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { themeMode, themeColor, isDarkMode, setThemeMode, setThemeColor, saveCustomTheme } =
|
||||
useThemeStore();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
|
||||
viewSettings.invertImgColorInDark,
|
||||
);
|
||||
|
||||
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(() => {
|
||||
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
|
||||
saveViewSettings(envConfig, bookKey, 'invertImgColorInDark', invertImgColorInDark);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [invertImgColorInDark]);
|
||||
|
||||
useEffect(() => {
|
||||
const customThemes = settings.globalReadSettings.customThemes ?? [];
|
||||
setCustomThemes(
|
||||
@@ -112,6 +126,17 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({}) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Invert Image In Dark Mode')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={invertImgColorInDark}
|
||||
disabled={!isDarkMode}
|
||||
onChange={() => setInvertImgColorInDark(!invertImgColorInDark)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import NumberInput from './NumberInput';
|
||||
|
||||
const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled!);
|
||||
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
|
||||
const [scrollingOverlap, setScrollingOverlap] = useState(viewSettings.scrollingOverlap!);
|
||||
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip!);
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrolledMode === viewSettings.scrolled) return;
|
||||
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(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isContinuousScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollingOverlap === viewSettings.scrollingOverlap) return;
|
||||
saveViewSettings(envConfig, bookKey, 'scrollingOverlap', scrollingOverlap, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scrollingOverlap]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'volumeKeysToFlip', volumeKeysToFlip, false, false);
|
||||
if (appService?.isMobileApp) {
|
||||
if (volumeKeysToFlip) {
|
||||
acquireVolumeKeyInterception();
|
||||
} else {
|
||||
releaseVolumeKeyInterception();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [volumeKeysToFlip]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'disableClick', isDisableClick, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableClick]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'swapClickArea', swapClickArea, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [swapClickArea]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'animated', animated, false, false);
|
||||
if (animated) {
|
||||
getView(bookKey)?.renderer.setAttribute('animated', '');
|
||||
} else {
|
||||
getView(bookKey)?.renderer.removeAttribute('animated');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animated]);
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Scroll')}</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=''>{_('Scrolled Mode')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isScrolledMode}
|
||||
onChange={() => setScrolledMode(!isScrolledMode)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Continuous Scroll')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isContinuousScroll}
|
||||
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
|
||||
/>
|
||||
</div>
|
||||
<NumberInput
|
||||
label={_('Overlap Pixels')}
|
||||
value={scrollingOverlap}
|
||||
onChange={setScrollingOverlap}
|
||||
disabled={!viewSettings.scrolled}
|
||||
min={0}
|
||||
max={200}
|
||||
step={10}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Click')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Clicks for Page Flip')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={!isDisableClick}
|
||||
onChange={() => setIsDisableClick(!isDisableClick)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Swap Clicks Area')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={swapClickArea}
|
||||
disabled={isDisableClick}
|
||||
onChange={() => setSwapClickArea(!swapClickArea)}
|
||||
/>
|
||||
</div>
|
||||
{appService?.isMobileApp && (
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Volume Keys for Page Flip')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={volumeKeysToFlip}
|
||||
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
|
||||
/>
|
||||
</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-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Paging Animation')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={animated}
|
||||
onChange={() => setAnimated(!animated)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlPanel;
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -21,10 +22,13 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl'
|
||||
className={clsx(
|
||||
'dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl',
|
||||
'text-base sm:text-sm',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className='hover:bg-base-200 text-base-content flex w-full items-center justify-between rounded-md p-2'
|
||||
className='hover:bg-base-200 text-base-content flex w-full items-center justify-between rounded-md p-1'
|
||||
onClick={handleToggleGlobal}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
|
||||
@@ -8,17 +8,30 @@ interface DropDownProps {
|
||||
selected: { option: string; label: string };
|
||||
options: { option: string; label: string }[];
|
||||
onSelect: (option: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
listClassName?: string;
|
||||
}
|
||||
|
||||
const DropDown: React.FC<DropDownProps> = ({ selected, options, onSelect }) => {
|
||||
const DropDown: React.FC<DropDownProps> = ({
|
||||
selected,
|
||||
options,
|
||||
onSelect,
|
||||
className,
|
||||
listClassName,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const defaultIconSize = useDefaultIconSize();
|
||||
|
||||
return (
|
||||
<div className='dropdown dropdown-bottom'>
|
||||
<div className={clsx('dropdown dropdown-bottom', className)}>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
|
||||
className={clsx(
|
||||
'btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case',
|
||||
disabled && 'btn-disabled',
|
||||
)}
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
>
|
||||
<span>{selected.label}</span>
|
||||
@@ -29,6 +42,7 @@ const DropDown: React.FC<DropDownProps> = ({ selected, options, onSelect }) => {
|
||||
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',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
{options.map(({ option, label }) => (
|
||||
|
||||
@@ -226,6 +226,16 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className=''>{_('Override Book Font')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideFont}
|
||||
onChange={() => setOverrideFont(!overrideFont)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Font Size')}</h2>
|
||||
<div className='card border-base-200 border shadow'>
|
||||
@@ -288,16 +298,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onSelect={setDefaultCJKFont}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Override Book Font')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideFont}
|
||||
onChange={() => setOverrideFont(!overrideFont)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import clsx from 'clsx';
|
||||
import i18n from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { getTranslators } from '@/services/translators';
|
||||
import { TRANSLATED_LANGS } from '@/services/constants';
|
||||
import DropDown from './DropDown';
|
||||
|
||||
const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
|
||||
const [translationProvider, setTranslationProvider] = useState(viewSettings.translationProvider!);
|
||||
const [translateTargetLang, setTranslateTargetLang] = useState(viewSettings.translateTargetLang!);
|
||||
|
||||
const getCurrentUILangOption = () => {
|
||||
const uiLanguage = viewSettings.uiLanguage;
|
||||
return {
|
||||
option: uiLanguage,
|
||||
label:
|
||||
uiLanguage === ''
|
||||
? _('Auto')
|
||||
: TRANSLATED_LANGS[uiLanguage as keyof typeof TRANSLATED_LANGS],
|
||||
};
|
||||
};
|
||||
|
||||
const getLangOptions = () => {
|
||||
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: _('System Language') });
|
||||
return options;
|
||||
};
|
||||
|
||||
const handleSelectUILang = (option: string) => {
|
||||
saveViewSettings(envConfig, bookKey, 'uiLanguage', option, false, false);
|
||||
i18n.changeLanguage(option ? option : navigator.language);
|
||||
};
|
||||
|
||||
const getTranslationProviderOptions = () => {
|
||||
const translators = getTranslators();
|
||||
const availableProviders = translators.map((t) => {
|
||||
return { option: t.name, label: t.label };
|
||||
});
|
||||
return availableProviders;
|
||||
};
|
||||
|
||||
const getCurrentTranslationProviderOption = () => {
|
||||
const option = translationProvider;
|
||||
const availableProviders = getTranslationProviderOptions();
|
||||
return availableProviders.find((p) => p.option === option) || availableProviders[0]!;
|
||||
};
|
||||
|
||||
const handleSelectTranslationProvider = (option: string) => {
|
||||
setTranslationProvider(option);
|
||||
saveViewSettings(envConfig, bookKey, 'translationProvider', option, false, false);
|
||||
viewSettings.translationProvider = option;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
};
|
||||
|
||||
const getCurrentTargetLangOption = () => {
|
||||
const option = translateTargetLang;
|
||||
const availableOptions = getLangOptions();
|
||||
return availableOptions.find((o) => o.option === option) || availableOptions[0]!;
|
||||
};
|
||||
|
||||
const handleSelectTargetLang = (option: string) => {
|
||||
setTranslateTargetLang(option);
|
||||
saveViewSettings(envConfig, bookKey, 'translateTargetLang', option, false, false);
|
||||
viewSettings.translateTargetLang = option;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (translationEnabled === viewSettings.translationEnabled) return;
|
||||
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
|
||||
viewSettings.translationEnabled = translationEnabled;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [translationEnabled]);
|
||||
|
||||
return (
|
||||
<div className={clsx('my-4 w-full space-y-6')}>
|
||||
<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=''>{_('Interface Language')}</span>
|
||||
<DropDown
|
||||
options={getLangOptions()}
|
||||
selected={getCurrentUILangOption()}
|
||||
onSelect={handleSelectUILang}
|
||||
className='dropdown-bottom'
|
||||
listClassName='!max-h-60'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Translation')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-base-200'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Enable Translation')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={translationEnabled}
|
||||
onChange={() => setTranslationEnabled(!translationEnabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Translation Service')}</span>
|
||||
<DropDown
|
||||
selected={getCurrentTranslationProviderOption()}
|
||||
options={getTranslationProviderOptions()}
|
||||
onSelect={handleSelectTranslationProvider}
|
||||
disabled={!translationEnabled}
|
||||
className='dropdown-top'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Translate To')}</span>
|
||||
<DropDown
|
||||
options={getLangOptions()}
|
||||
selected={getCurrentTargetLangOption()}
|
||||
onSelect={handleSelectTargetLang}
|
||||
disabled={!translationEnabled}
|
||||
className='dropdown-top'
|
||||
listClassName='!max-h-60'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LangPanel;
|
||||
@@ -40,7 +40,6 @@ 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!);
|
||||
@@ -163,22 +162,11 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}, [writingMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (overrideLayout === viewSettings.overrideLayout) return;
|
||||
saveViewSettings(envConfig, bookKey, 'overrideLayout', overrideLayout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrolledMode === viewSettings.scrolled) return;
|
||||
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.doubleBorder) return;
|
||||
if (doubleBorder && viewSettings.vertical) {
|
||||
@@ -259,58 +247,53 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
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 className='flex items-center justify-between'>
|
||||
<h2 className=''>{_('Override Book Layout')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideLayout}
|
||||
onChange={() => setOverrideLayout(!overrideLayout)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mightBeRTLBook && (
|
||||
<div className='w-full'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Writing Mode')}</h2>
|
||||
<div className='flex gap-4'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Default')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('auto')}
|
||||
>
|
||||
<MdOutlineAutoMode />
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Writing Mode')}</h2>
|
||||
<div className='flex gap-4'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Default')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('auto')}
|
||||
>
|
||||
<MdOutlineAutoMode />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Horizontal Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-tb')}
|
||||
>
|
||||
<MdOutlineTextRotationNone />
|
||||
</button>
|
||||
</div>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Horizontal Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-tb')}
|
||||
>
|
||||
<MdOutlineTextRotationNone />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Vertical Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('vertical-rl')}
|
||||
>
|
||||
<MdTextRotateVertical />
|
||||
</button>
|
||||
</div>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Vertical Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('vertical-rl')}
|
||||
>
|
||||
<MdTextRotateVertical />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('RTL Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-rl')}
|
||||
>
|
||||
<TbTextDirectionRtl />
|
||||
</button>
|
||||
</div>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('RTL Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-rl')}
|
||||
>
|
||||
<TbTextDirectionRtl />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -360,7 +343,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onChange={setParagraphMargin}
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.5}
|
||||
step={0.2}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Line Spacing')}
|
||||
@@ -414,15 +397,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onChange={() => setHyphenation(!hyphenation)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Override Book Layout')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideLayout}
|
||||
onChange={() => setOverrideLayout(!overrideLayout)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import i18n from 'i18next';
|
||||
import { IoPhoneLandscapeOutline, IoPhonePortraitOutline } from 'react-icons/io5';
|
||||
import { MdOutlineScreenRotation } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
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 { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
|
||||
const [volumeKeysToFlip, setVolumeKeysToFlip] = useState(viewSettings.volumeKeysToFlip!);
|
||||
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
|
||||
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
|
||||
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
|
||||
const [screenOrientation, setScreenOrientation] = useState(viewSettings.screenOrientation!);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inputFocusInAndroid, setInputFocusInAndroid] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
@@ -103,67 +93,6 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
saveViewSettings(envConfig, bookKey, 'uiLanguage', option, false, false);
|
||||
i18n.changeLanguage(option ? option : navigator.language);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'animated', animated, false, false);
|
||||
if (animated) {
|
||||
getView(bookKey)?.renderer.setAttribute('animated', '');
|
||||
} else {
|
||||
getView(bookKey)?.renderer.removeAttribute('animated');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animated]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'disableClick', isDisableClick, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableClick]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'swapClickArea', swapClickArea, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [swapClickArea]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'volumeKeysToFlip', volumeKeysToFlip, false, false);
|
||||
if (appService?.isMobileApp) {
|
||||
if (volumeKeysToFlip) {
|
||||
acquireVolumeKeyInterception();
|
||||
} else {
|
||||
releaseVolumeKeyInterception();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [volumeKeysToFlip]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isContinuousScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'screenOrientation', screenOrientation, false, false);
|
||||
if (appService?.isMobileApp) {
|
||||
@@ -179,39 +108,6 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
inputFocusInAndroid && 'h-[50%] overflow-y-auto pb-[200px]',
|
||||
)}
|
||||
>
|
||||
<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-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Paging Animation')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={animated}
|
||||
onChange={() => setAnimated(!animated)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appService?.isMobileApp && (
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Screen')}</h2>
|
||||
@@ -253,53 +149,6 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Continuous Scroll')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isContinuousScroll}
|
||||
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
|
||||
/>
|
||||
</div>
|
||||
{appService?.isMobileApp && (
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Volume Keys for Page Flip')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={volumeKeysToFlip}
|
||||
onChange={() => setVolumeKeysToFlip(!volumeKeysToFlip)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Clicks for Page Flip')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={!isDisableClick}
|
||||
onChange={() => setIsDisableClick(!isDisableClick)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Swap Clicks Area')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={swapClickArea}
|
||||
disabled={isDisableClick}
|
||||
onChange={() => setSwapClickArea(!swapClickArea)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Custom CSS')}</h2>
|
||||
<div
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { RiFontSize } from 'react-icons/ri';
|
||||
import { RiDashboardLine } from 'react-icons/ri';
|
||||
import { RiDashboardLine, RiTranslate } from 'react-icons/ri';
|
||||
import { VscSymbolColor } from 'react-icons/vsc';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
import { LiaHandPointerSolid } from 'react-icons/lia';
|
||||
import { IoAccessibilityOutline } from 'react-icons/io5';
|
||||
import { MdArrowBackIosNew, MdArrowForwardIos } from 'react-icons/md';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
@@ -16,9 +18,11 @@ import ColorPanel from './ColorPanel';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import Dialog from '@/components/Dialog';
|
||||
import DialogMenu from './DialogMenu';
|
||||
import ControlPanel from './ControlPanel';
|
||||
import LangPanel from './LangPanel';
|
||||
import MiscPanel from './MiscPanel';
|
||||
|
||||
type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Misc';
|
||||
type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Control' | 'Language' | 'Custom';
|
||||
|
||||
type TabConfig = {
|
||||
tab: SettingsPanelType;
|
||||
@@ -28,10 +32,10 @@ type TabConfig = {
|
||||
|
||||
const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
|
||||
const [activePanel, setActivePanel] = useState<SettingsPanelType>(
|
||||
(localStorage.getItem('lastConfigPanel') || 'Font') as SettingsPanelType,
|
||||
);
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showAllTabLabels, setShowAllTabLabels] = useState(false);
|
||||
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
|
||||
const tabConfig = [
|
||||
@@ -51,12 +55,30 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
label: _('Color'),
|
||||
},
|
||||
{
|
||||
tab: 'Misc',
|
||||
tab: 'Control',
|
||||
icon: LiaHandPointerSolid,
|
||||
label: _('Behavior'),
|
||||
},
|
||||
{
|
||||
tab: 'Language',
|
||||
icon: RiTranslate,
|
||||
label: _('Language'),
|
||||
},
|
||||
{
|
||||
tab: 'Custom',
|
||||
icon: IoAccessibilityOutline,
|
||||
label: _('Misc'),
|
||||
label: _('Custom'),
|
||||
},
|
||||
] as TabConfig[];
|
||||
|
||||
const [activePanel, setActivePanel] = useState<SettingsPanelType>(() => {
|
||||
const lastPanel = localStorage.getItem('lastConfigPanel');
|
||||
if (lastPanel && tabConfig.some((tab) => tab.tab === lastPanel)) {
|
||||
return lastPanel as SettingsPanelType;
|
||||
}
|
||||
return 'Font' as SettingsPanelType;
|
||||
});
|
||||
|
||||
const handleSetActivePanel = (tab: SettingsPanelType) => {
|
||||
setActivePanel(tab);
|
||||
localStorage.setItem('lastConfigPanel', tab);
|
||||
@@ -66,16 +88,61 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
setFontLayoutSettingsDialogOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = tabsRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const checkButtonWidths = () => {
|
||||
const threshold = (container.clientWidth - 64) * 0.22;
|
||||
const hideLabel = Array.from(container.querySelectorAll('button')).some((button) => {
|
||||
const labelSpan = button.querySelector('span');
|
||||
const labelText = labelSpan?.textContent || '';
|
||||
const clone = button.cloneNode(true) as HTMLButtonElement;
|
||||
clone.style.position = 'absolute';
|
||||
clone.style.visibility = 'hidden';
|
||||
clone.style.width = 'auto';
|
||||
const cloneSpan = clone.querySelector('span');
|
||||
if (cloneSpan) {
|
||||
cloneSpan.classList.remove('hidden');
|
||||
cloneSpan.textContent = labelText;
|
||||
}
|
||||
document.body.appendChild(clone);
|
||||
const fullWidth = clone.scrollWidth;
|
||||
document.body.removeChild(clone);
|
||||
return fullWidth > threshold;
|
||||
});
|
||||
setShowAllTabLabels(!hideLabel);
|
||||
};
|
||||
|
||||
checkButtonWidths();
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkButtonWidths);
|
||||
resizeObserver.observe(container);
|
||||
const mutationObserver = new MutationObserver(checkButtonWidths);
|
||||
mutationObserver.observe(container, {
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
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'>
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
className='modal-open'
|
||||
boxClassName={clsx('sm:min-w-[520px]', appService?.isMobile && 'sm:max-w-[90%] sm:w-3/4')}
|
||||
snapHeight={appService?.isMobile ? 0.7 : undefined}
|
||||
header={
|
||||
<div className='flex w-full flex-col items-center'>
|
||||
<div className='tab-title flex pb-2 text-base font-semibold sm:hidden'>
|
||||
{tabConfig.find((tab) => tab.tab === activePanel)?.label || ''}
|
||||
</div>
|
||||
<div className='flex w-full flex-row items-center justify-between'>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
onClick={handleClose}
|
||||
@@ -85,32 +152,45 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
>
|
||||
{isRtl ? <MdArrowForwardIos /> : <MdArrowBackIosNew />}
|
||||
</button>
|
||||
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center gap-2 pl-4'>
|
||||
<div
|
||||
ref={tabsRef}
|
||||
className={clsx('dialog-tabs ms-1 flex h-10 w-full items-center gap-1 sm:ms-0')}
|
||||
>
|
||||
{tabConfig.map(({ tab, icon: Icon, label }) => (
|
||||
<button
|
||||
key={tab}
|
||||
data-tab={tab}
|
||||
className={clsx(
|
||||
'btn btn-ghost text-base-content btn-sm',
|
||||
'btn btn-ghost text-base-content btn-sm gap-1 px-2',
|
||||
activePanel === tab ? 'btn-active' : '',
|
||||
)}
|
||||
onClick={() => handleSetActivePanel(tab)}
|
||||
>
|
||||
<Icon className='mr-0' />
|
||||
{window.innerWidth >= 500 ? label : ''}
|
||||
<span
|
||||
className={clsx(
|
||||
window.innerWidth < 640 && 'hidden',
|
||||
!(showAllTabLabels || activePanel === tab) && 'hidden',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex h-full items-center justify-end gap-x-2'>
|
||||
<Dropdown
|
||||
className='dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 flex items-center justify-center'
|
||||
toggleButton={<PiDotsThreeVerticalBold />}
|
||||
>
|
||||
<DialogMenu />
|
||||
</Dropdown>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className={'bg-base-300/65 btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 sm:flex'}
|
||||
className={
|
||||
'bg-base-300/65 btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 p-0 sm:flex'
|
||||
}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@@ -126,14 +206,16 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activePanel === 'Font' && <FontPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Layout' && <LayoutPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Color' && <ColorPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Misc' && <MiscPanel bookKey={bookKey} />}
|
||||
</Dialog>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activePanel === 'Font' && <FontPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Layout' && <LayoutPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Color' && <ColorPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Control' && <ControlPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Language' && <LangPanel bookKey={bookKey} />}
|
||||
{activePanel === 'Custom' && <MiscPanel bookKey={bookKey} />}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -59,14 +59,15 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
|
||||
{_(
|
||||
"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
|
||||
{'\n\n'}
|
||||
<span
|
||||
className='mt-4 cursor-pointer italic'
|
||||
style={{
|
||||
color: primaryColor,
|
||||
}}
|
||||
>
|
||||
{_("(from 'As You Like It', Act II)")}
|
||||
</p>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,7 @@ const TOCView: React.FC<{
|
||||
toc: TOCItem[];
|
||||
}> = ({ bookKey, toc }) => {
|
||||
const { getProgress } = useReaderStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { sideBarBookKey, isSideBarVisible } = useSidebarStore();
|
||||
const progress = getProgress(bookKey);
|
||||
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||
@@ -146,9 +146,6 @@ const TOCView: React.FC<{
|
||||
}
|
||||
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
|
||||
}
|
||||
if (currentHref) {
|
||||
expandParents(toc, currentHref);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -165,13 +162,19 @@ const TOCView: React.FC<{
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewRef]);
|
||||
}, [viewRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!progress || eventDispatcher.dispatchSync('tts-is-speaking')) return;
|
||||
if (sideBarBookKey !== bookKey) return;
|
||||
if (!isSideBarVisible) return;
|
||||
const { sectionHref: currentHref } = progress;
|
||||
if (currentHref) {
|
||||
expandParents(toc, currentHref);
|
||||
}
|
||||
scrollToProgress(progress);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [toc, progress, sideBarBookKey]);
|
||||
}, [toc, progress, sideBarBookKey, isSideBarVisible]);
|
||||
|
||||
return (
|
||||
<div className='rounded pt-2'>
|
||||
|
||||
@@ -38,6 +38,7 @@ const TTSControl = () => {
|
||||
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
|
||||
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const popupPadding = useResponsiveSize(POPUP_PADDING);
|
||||
const maxWidth = window.innerWidth - 2 * popupPadding;
|
||||
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
|
||||
@@ -139,7 +140,6 @@ const TTSControl = () => {
|
||||
|
||||
ttsController.setLang(lang);
|
||||
ttsController.setRate(viewSettings.ttsRate);
|
||||
ttsController.setVoice(viewSettings.ttsVoice);
|
||||
ttsController.speak(ssml);
|
||||
ttsControllerRef.current = ttsController;
|
||||
}
|
||||
@@ -235,15 +235,15 @@ const TTSControl = () => {
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleSetVoice = useCallback(
|
||||
throttle(async (voice: string) => {
|
||||
throttle(async (voice: string, lang: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
if (ttsController.state === 'playing') {
|
||||
await ttsController.stop();
|
||||
await ttsController.setVoice(voice);
|
||||
await ttsController.setVoice(voice, lang);
|
||||
await ttsController.start();
|
||||
} else {
|
||||
await ttsController.setVoice(voice);
|
||||
await ttsController.setVoice(voice, lang);
|
||||
}
|
||||
}
|
||||
}, 3000),
|
||||
@@ -286,16 +286,18 @@ const TTSControl = () => {
|
||||
const updatePanelPosition = () => {
|
||||
if (iconRef.current) {
|
||||
const rect = iconRef.current.getBoundingClientRect();
|
||||
const windowRect = document.documentElement.getBoundingClientRect();
|
||||
const parentRect =
|
||||
iconRef.current.parentElement?.getBoundingClientRect() ||
|
||||
document.documentElement.getBoundingClientRect();
|
||||
|
||||
const trianglePos = {
|
||||
dir: 'up',
|
||||
point: { x: rect.left + rect.width / 2, y: rect.top - 12 },
|
||||
point: { x: rect.left + rect.width / 2 - parentRect.left, y: rect.top - 12 },
|
||||
} as Position;
|
||||
|
||||
const popupPos = getPopupPosition(
|
||||
trianglePos,
|
||||
windowRect,
|
||||
parentRect,
|
||||
popupWidth,
|
||||
popupHeight,
|
||||
popupPadding,
|
||||
@@ -308,6 +310,10 @@ const TTSControl = () => {
|
||||
|
||||
const togglePopup = () => {
|
||||
updatePanelPosition();
|
||||
if (!showPanel && ttsControllerRef.current) {
|
||||
const speakingLang = ttsControllerRef.current.getSpeakingLang() || ttsLang;
|
||||
setTtsLang(speakingLang);
|
||||
}
|
||||
setShowPanel((prev) => !prev);
|
||||
};
|
||||
|
||||
@@ -315,8 +321,23 @@ const TTSControl = () => {
|
||||
setShowPanel(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!iconRef.current || !showPanel) return;
|
||||
const parentElement = iconRef.current.parentElement;
|
||||
if (!parentElement) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updatePanelPosition();
|
||||
});
|
||||
resizeObserver.observe(parentElement);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showPanel]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
{showPanel && (
|
||||
<div
|
||||
className='fixed inset-0'
|
||||
@@ -328,7 +349,8 @@ const TTSControl = () => {
|
||||
<div
|
||||
ref={iconRef}
|
||||
className={clsx(
|
||||
'fixed right-6 h-12 w-12',
|
||||
'absolute h-12 w-12',
|
||||
viewSettings?.rtl ? 'left-6' : 'right-6',
|
||||
appService?.hasSafeAreaInset
|
||||
? 'bottom-[calc(env(safe-area-inset-bottom)+70px)]'
|
||||
: 'bottom-[70px] sm:bottom-14',
|
||||
@@ -343,7 +365,7 @@ const TTSControl = () => {
|
||||
height={popupHeight}
|
||||
position={panelPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
className='bg-base-200 absolute flex shadow-lg'
|
||||
className='bg-base-200 flex shadow-lg'
|
||||
>
|
||||
<TTSPanel
|
||||
bookKey={bookKey}
|
||||
@@ -362,7 +384,7 @@ const TTSControl = () => {
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ type TTSPanelProps = {
|
||||
onForward: () => void;
|
||||
onSetRate: (rate: number) => void;
|
||||
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
|
||||
onSetVoice: (voice: string) => void;
|
||||
onSetVoice: (voice: string, lang: string) => void;
|
||||
onGetVoiceId: () => string;
|
||||
onSelectTimeout: (value: number) => void;
|
||||
};
|
||||
@@ -144,8 +144,8 @@ const TTSPanel = ({
|
||||
saveSettings(envConfig, settings);
|
||||
};
|
||||
|
||||
const handleSelectVoice = (voice: string) => {
|
||||
onSetVoice(voice);
|
||||
const handleSelectVoice = (voice: string, lang: string) => {
|
||||
onSetVoice(voice, lang);
|
||||
setSelectedVoice(voice);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewSettings.ttsVoice = voice;
|
||||
@@ -282,7 +282,7 @@ const TTSPanel = ({
|
||||
{voices.map((voice, index) => (
|
||||
<li
|
||||
key={`${index}-${voice.id}`}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id)}
|
||||
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
|
||||
>
|
||||
<div className='flex items-center px-2'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getStyles } from '@/utils/style';
|
||||
import { tauriHandleToggleFullScreen, tauriQuitApp } from '@/utils/window';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
|
||||
import { viewPagination } from './usePagination';
|
||||
|
||||
interface UseBookShortcutsProps {
|
||||
sideBarBookKey: string | null;
|
||||
@@ -41,11 +42,13 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
|
||||
};
|
||||
|
||||
const goLeft = () => {
|
||||
getView(sideBarBookKey)?.goLeft();
|
||||
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
||||
viewPagination(getView(sideBarBookKey), viewSettings, 'left');
|
||||
};
|
||||
|
||||
const goRight = () => {
|
||||
getView(sideBarBookKey)?.goRight();
|
||||
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
||||
viewPagination(getView(sideBarBookKey), viewSettings, 'right');
|
||||
};
|
||||
|
||||
const goPrev = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { ScrollSource } from './usePagination';
|
||||
|
||||
export const useMouseEvent = (
|
||||
@@ -9,20 +9,23 @@ export const useMouseEvent = (
|
||||
handleContinuousScroll: (source: ScrollSource, delta: number, threshold: number) => void,
|
||||
) => {
|
||||
const { hoveredBookKey } = useReaderStore();
|
||||
const throttledScroll = throttle(handleContinuousScroll, 500, {
|
||||
emitLast: false,
|
||||
});
|
||||
const debounceScroll = debounce(handleContinuousScroll, 500);
|
||||
const debounceFlip = debounce(handlePageFlip, 100);
|
||||
const handleMouseEvent = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (msg instanceof MessageEvent) {
|
||||
if (msg.data && msg.data.bookKey === bookKey) {
|
||||
if (msg.data.type === 'iframe-wheel') {
|
||||
throttledScroll('mouse', -msg.data.deltaY, 0);
|
||||
debounceScroll('mouse', -msg.data.deltaY, 0);
|
||||
}
|
||||
if (msg.data.type === 'iframe-wheel') {
|
||||
debounceFlip(msg);
|
||||
} else {
|
||||
handlePageFlip(msg);
|
||||
}
|
||||
handlePageFlip(msg);
|
||||
}
|
||||
} else if (msg.type === 'wheel') {
|
||||
const event = msg as React.WheelEvent<HTMLDivElement>;
|
||||
throttledScroll('mouse', -event.deltaY, 0);
|
||||
debounceScroll('mouse', -event.deltaY, 0);
|
||||
} else {
|
||||
handlePageFlip(msg);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { ViewSettings } from '@/types/book';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
@@ -9,6 +10,28 @@ import { tauriGetWindowLogicalPosition } from '@/utils/window';
|
||||
|
||||
export type ScrollSource = 'touch' | 'mouse';
|
||||
|
||||
export const viewPagination = (
|
||||
view: FoliateView | null,
|
||||
viewSettings: ViewSettings | null | undefined,
|
||||
side: 'left' | 'right',
|
||||
) => {
|
||||
if (!view || !viewSettings) return;
|
||||
const renderer = view.renderer;
|
||||
if (renderer.scrolled) {
|
||||
if (view.book.dir === 'rtl') {
|
||||
side = side === 'left' ? 'right' : 'left';
|
||||
}
|
||||
const { size } = renderer;
|
||||
const showHeader = viewSettings.showHeader && viewSettings.showBarsOnScroll;
|
||||
const showFooter = viewSettings.showFooter && viewSettings.showBarsOnScroll;
|
||||
const scrollingOverlap = viewSettings.scrollingOverlap;
|
||||
const distance = size - scrollingOverlap - (showHeader ? 44 : 0) - (showFooter ? 44 : 0);
|
||||
return side === 'left' ? view.prev(distance) : view.next(distance);
|
||||
} else {
|
||||
return side === 'left' ? view.goLeft() : view.goRight();
|
||||
}
|
||||
};
|
||||
|
||||
export const usePagination = (
|
||||
bookKey: string,
|
||||
viewRef: React.MutableRefObject<FoliateView | null>,
|
||||
@@ -64,15 +87,15 @@ export const usePagination = (
|
||||
}
|
||||
if (!viewSettings.disableClick! && screenX >= viewCenterX) {
|
||||
if (viewSettings.swapClickArea) {
|
||||
viewRef.current?.goLeft();
|
||||
viewPagination(viewRef.current, viewSettings, 'left');
|
||||
} else {
|
||||
viewRef.current?.goRight();
|
||||
viewPagination(viewRef.current, viewSettings, 'right');
|
||||
}
|
||||
} else if (!viewSettings.disableClick! && screenX < viewCenterX) {
|
||||
if (viewSettings.swapClickArea) {
|
||||
viewRef.current?.goRight();
|
||||
viewPagination(viewRef.current, viewSettings, 'right');
|
||||
} else {
|
||||
viewRef.current?.goLeft();
|
||||
viewPagination(viewRef.current, viewSettings, 'left');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,9 +123,9 @@ export const usePagination = (
|
||||
if (viewSettings?.volumeKeysToFlip) {
|
||||
setHoveredBookKey('');
|
||||
if (keyName === 'VolumeUp') {
|
||||
viewRef.current?.goLeft();
|
||||
viewPagination(viewRef.current, viewSettings, 'left');
|
||||
} else if (keyName === 'VolumeDown') {
|
||||
viewRef.current?.goRight();
|
||||
viewPagination(viewRef.current, viewSettings, 'right');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -111,10 +134,11 @@ export const usePagination = (
|
||||
const width = window.innerWidth;
|
||||
const leftThreshold = width * 0.5;
|
||||
const rightThreshold = width * 0.5;
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (clientX < leftThreshold) {
|
||||
viewRef.current?.goLeft();
|
||||
viewPagination(viewRef.current, viewSettings, 'left');
|
||||
} else if (clientX > rightThreshold) {
|
||||
viewRef.current?.goRight();
|
||||
viewPagination(viewRef.current, viewSettings, 'right');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getTextFromRange, TextSelection } from '@/utils/sel';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
|
||||
export const useTextSelector = (
|
||||
bookKey: string,
|
||||
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>,
|
||||
handleDismissPopup: () => void,
|
||||
) => {
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const primaryLang = bookData.book?.primaryLanguage || 'en';
|
||||
const osPlatform = getOSPlatform();
|
||||
|
||||
const isPopuped = useRef(false);
|
||||
const isUpToPopup = useRef(false);
|
||||
const isTextSelected = useRef(false);
|
||||
const selectionPosition = useRef<number | null>(null);
|
||||
|
||||
const isValidSelection = (sel: Selection) => {
|
||||
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
|
||||
};
|
||||
|
||||
const transformCtx = {
|
||||
bookKey,
|
||||
viewSettings: getViewSettings(bookKey)!,
|
||||
content: '',
|
||||
transformers: ['punctuation'],
|
||||
reversePunctuationTransform: true,
|
||||
};
|
||||
const getAnnotationText = async (range: Range) => {
|
||||
transformCtx['content'] = getTextFromRange(range, primaryLang.startsWith('ja') ? ['rt'] : []);
|
||||
return await transformContent(transformCtx);
|
||||
};
|
||||
const makeSelection = async (sel: Selection, index: number, rebuildRange = false) => {
|
||||
isTextSelected.current = true;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (rebuildRange) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
setSelection({ key: bookKey, text: await getAnnotationText(range), range, index });
|
||||
};
|
||||
// FIXME: extremely hacky way to dismiss system selection tools on iOS
|
||||
const makeSelectionOnIOS = async (sel: Selection, index: number) => {
|
||||
isTextSelected.current = true;
|
||||
const range = sel.getRangeAt(0);
|
||||
setTimeout(() => {
|
||||
sel.removeAllRanges();
|
||||
setTimeout(async () => {
|
||||
if (!isTextSelected.current) return;
|
||||
sel.addRange(range);
|
||||
setSelection({ key: bookKey, text: await getAnnotationText(range), range, index });
|
||||
}, 40);
|
||||
}, 0);
|
||||
};
|
||||
const handleSelectionchange = (doc: Document, index: number) => {
|
||||
// Available on iOS, Android and Desktop, fired when the selection is changed
|
||||
// Ideally the popup only shows when the selection is done,
|
||||
const sel = doc.getSelection() as Selection;
|
||||
if (osPlatform === 'ios') return;
|
||||
if (!isValidSelection(sel)) {
|
||||
if (!isUpToPopup.current) {
|
||||
handleDismissPopup();
|
||||
isTextSelected.current = false;
|
||||
}
|
||||
if (isPopuped.current) {
|
||||
isUpToPopup.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// On Android no proper events are fired to notify selection done,
|
||||
// we make the popup show when the selection is changed
|
||||
if (osPlatform === 'android') {
|
||||
makeSelection(sel, index, false);
|
||||
}
|
||||
isUpToPopup.current = true;
|
||||
};
|
||||
const handlePointerup = (doc: Document, index: number) => {
|
||||
// Available on iOS and Desktop, fired at touchend or mouseup
|
||||
// Note that on Android, pointerup event is fired after an additional touch event
|
||||
const sel = doc.getSelection() as Selection;
|
||||
if (isValidSelection(sel)) {
|
||||
if (osPlatform === 'ios') {
|
||||
makeSelectionOnIOS(sel, index);
|
||||
} else {
|
||||
makeSelection(sel, index, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleScroll = () => {
|
||||
// Prevent the container from scrolling when text is selected in paginated mode
|
||||
// This is a workaround for the issue #873
|
||||
// TODO: support text selection across pages
|
||||
if (!viewSettings?.scrolled && view?.renderer?.containerPosition && selectionPosition.current) {
|
||||
view.renderer.containerPosition = selectionPosition.current;
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowPopup = (showPopup: boolean) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
if (showPopup && !isPopuped.current) {
|
||||
isUpToPopup.current = false;
|
||||
}
|
||||
isPopuped.current = showPopup;
|
||||
},
|
||||
['android', 'ios'].includes(osPlatform) ? 0 : 500,
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpToPopup = () => {
|
||||
isUpToPopup.current = true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isTextSelected.current && !selectionPosition.current) {
|
||||
selectionPosition.current = view?.renderer?.start || null;
|
||||
} else if (!isTextSelected.current) {
|
||||
selectionPosition.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isTextSelected.current]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSingleClick = (): boolean => {
|
||||
if (isUpToPopup.current) {
|
||||
isUpToPopup.current = false;
|
||||
return true;
|
||||
}
|
||||
if (isTextSelected.current) {
|
||||
handleDismissPopup();
|
||||
isTextSelected.current = false;
|
||||
view?.deselect();
|
||||
return true;
|
||||
}
|
||||
if (isPopuped.current) {
|
||||
handleDismissPopup();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
eventDispatcher.onSync('iframe-single-click', handleSingleClick);
|
||||
return () => {
|
||||
eventDispatcher.offSync('iframe-single-click', handleSingleClick);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleScroll,
|
||||
handlePointerup,
|
||||
handleSelectionchange,
|
||||
handleShowPopup,
|
||||
handleUpToPopup,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { UseTranslatorOptions } from '@/services/translators';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslator } from '@/hooks/useTranslator';
|
||||
import { walkTextNodes } from '@/utils/walk';
|
||||
import { localeToLang } from '@/utils/lang';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
|
||||
export function useTextTranslation(bookKey: string, view: FoliateView | null) {
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const enabled = useRef(viewSettings.translationEnabled);
|
||||
const [provider, setProvider] = useState(viewSettings.translationProvider);
|
||||
const [targetLang, setTargetLang] = useState(viewSettings.translateTargetLang);
|
||||
|
||||
const { translate } = useTranslator({
|
||||
provider,
|
||||
targetLang: localeToLang(targetLang || getLocale()),
|
||||
} as UseTranslatorOptions);
|
||||
|
||||
const translateRef = useRef(translate);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const translatedElements = useRef<HTMLElement[]>([]);
|
||||
const allTextNodes = useRef<HTMLElement[]>([]);
|
||||
|
||||
const toggleTranslationVisibility = (visible: boolean) => {
|
||||
translatedElements.current.forEach((element) => {
|
||||
const translationTargets = element.querySelectorAll('.translation-target');
|
||||
translationTargets.forEach((target) => {
|
||||
if (visible) {
|
||||
target.classList.remove('hidden');
|
||||
} else {
|
||||
target.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
translateRef.current = translate;
|
||||
}, [translate]);
|
||||
|
||||
const updateTranslation = () => {
|
||||
translatedElements.current.forEach((element) => {
|
||||
const translationTargets = element.querySelectorAll('.translation-target');
|
||||
translationTargets.forEach((target) => target.remove());
|
||||
});
|
||||
|
||||
translatedElements.current = [];
|
||||
if (viewSettings.translationEnabled && view) {
|
||||
recreateTranslationObserver();
|
||||
}
|
||||
};
|
||||
|
||||
const createTranslationObserver = () => {
|
||||
return new IntersectionObserver(
|
||||
(entries) => {
|
||||
let beforeIntersectedElement: HTMLElement | null = null;
|
||||
let lastIntersectedElement: HTMLElement | null = null;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) {
|
||||
if (!lastIntersectedElement) {
|
||||
beforeIntersectedElement = entry.target as HTMLElement;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const currentElement = entry.target as HTMLElement;
|
||||
translateElement(currentElement);
|
||||
lastIntersectedElement = currentElement;
|
||||
}
|
||||
if (beforeIntersectedElement) {
|
||||
translateElement(beforeIntersectedElement);
|
||||
}
|
||||
if (lastIntersectedElement) {
|
||||
preTranslateNextElements(lastIntersectedElement, 2);
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '1280px',
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const preTranslateNextElements = (currentElement: HTMLElement, count: number) => {
|
||||
if (!allTextNodes.current || count <= 0) return;
|
||||
const currentIndex = allTextNodes.current.indexOf(currentElement);
|
||||
if (currentIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextElements = allTextNodes.current.slice(currentIndex + 1, currentIndex + 1 + count);
|
||||
nextElements.forEach((element, index) => {
|
||||
setTimeout(() => {
|
||||
translateElement(element);
|
||||
}, index * 500);
|
||||
});
|
||||
};
|
||||
|
||||
const recreateTranslationObserver = () => {
|
||||
const observer = createTranslationObserver();
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = observer;
|
||||
allTextNodes.current.forEach((el) => observer.observe(el));
|
||||
};
|
||||
|
||||
const translateElement = async (el: HTMLElement) => {
|
||||
if (!enabled.current) return;
|
||||
const text = el.textContent?.trim();
|
||||
if (!text) return;
|
||||
|
||||
if (el.querySelector('.translation-target')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const translated = await translateRef.current([text]);
|
||||
const translatedText = translated[0];
|
||||
if (!translatedText || text === translatedText) return;
|
||||
|
||||
const wrapper = document.createElement('font');
|
||||
wrapper.className = `translation-target ${!enabled.current ? 'hidden' : ''}`;
|
||||
wrapper.setAttribute('translation-element-mark', '1');
|
||||
wrapper.setAttribute('lang', targetLang || getLocale());
|
||||
wrapper.appendChild(document.createElement('br'));
|
||||
|
||||
const blockWrapper = document.createElement('font');
|
||||
blockWrapper.className = 'translation-target translation-block-wrapper';
|
||||
|
||||
const inner = document.createElement('font');
|
||||
inner.className = 'translation-target target-inner target-inner-theme-none';
|
||||
inner.textContent = translatedText;
|
||||
|
||||
blockWrapper.appendChild(inner);
|
||||
wrapper.appendChild(blockWrapper);
|
||||
|
||||
el.appendChild(wrapper);
|
||||
translatedElements.current.push(el);
|
||||
} catch (err) {
|
||||
console.warn('Translation failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewSettings) return;
|
||||
|
||||
const enabledChanged = enabled.current !== viewSettings.translationEnabled;
|
||||
const providerChanged = provider !== viewSettings.translationProvider;
|
||||
const targetLangChanged = targetLang !== viewSettings.translateTargetLang;
|
||||
|
||||
if (enabledChanged) {
|
||||
enabled.current = viewSettings.translationEnabled;
|
||||
}
|
||||
|
||||
if (providerChanged) {
|
||||
setProvider(viewSettings.translationProvider);
|
||||
}
|
||||
|
||||
if (targetLangChanged) {
|
||||
setTargetLang(viewSettings.translateTargetLang);
|
||||
}
|
||||
|
||||
if (enabledChanged) {
|
||||
toggleTranslationVisibility(viewSettings.translationEnabled);
|
||||
if (enabled.current) {
|
||||
recreateTranslationObserver();
|
||||
}
|
||||
} else if (providerChanged || targetLangChanged) {
|
||||
updateTranslation();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKey, viewSettings, provider, targetLang]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!view) return;
|
||||
|
||||
const onLoad = () => {
|
||||
const observer = createTranslationObserver();
|
||||
observerRef.current = observer;
|
||||
const nodes = walkTextNodes(view);
|
||||
allTextNodes.current = nodes;
|
||||
nodes.forEach((el) => observer.observe(el));
|
||||
};
|
||||
|
||||
view.addEventListener('load', onLoad);
|
||||
return () => {
|
||||
view.removeEventListener('load', onLoad);
|
||||
observerRef.current?.disconnect();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
observerRef.current?.disconnect();
|
||||
translatedElements.current = [];
|
||||
};
|
||||
}, [view]);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import Reader from './components/Reader';
|
||||
|
||||
// This is only used for the Tauri app in the app router
|
||||
export default function Page() {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
|
||||
@@ -122,11 +122,15 @@ export const handleClick = (bookKey: string, event: MouseEvent) => {
|
||||
if (['sup', 'a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
if (element.classList.contains('js_readerFooterNote')) {
|
||||
if (
|
||||
element.classList.contains('js_readerFooterNote') ||
|
||||
element.classList.contains('zhangyue-footnote')
|
||||
) {
|
||||
eventDispatcher.dispatch('footnote-popup', {
|
||||
bookKey,
|
||||
element,
|
||||
footnote: element.getAttribute('data-wr-footernote') || '',
|
||||
footnote:
|
||||
element.getAttribute('data-wr-footernote') || element.getAttribute('zy-footnote') || '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ export const setAboutDialogVisible = (visible: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
type UpdateStatus = 'checking' | 'updating' | 'updated' | 'error';
|
||||
|
||||
export const AboutWindow = () => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const [isUpdated, setIsUpdated] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
|
||||
const [browserInfo, setBrowserInfo] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
@@ -46,46 +48,63 @@ export const AboutWindow = () => {
|
||||
}, []);
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
const update = await checkForAppUpdates(_, false);
|
||||
if (update) {
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
setIsUpdated(true);
|
||||
setUpdateStatus('checking');
|
||||
try {
|
||||
const update = await checkForAppUpdates(_, false);
|
||||
if (update) {
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
setUpdateStatus('updated');
|
||||
}
|
||||
} catch (error) {
|
||||
console.info('Error checking for updates:', error);
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setUpdateStatus(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id='about_window'
|
||||
isOpen={isOpen}
|
||||
title={_('About Readest')}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onClose={handleClose}
|
||||
boxClassName='sm:!w-96 sm:h-auto'
|
||||
>
|
||||
<div className='about-content flex h-full flex-col items-center justify-center'>
|
||||
<div className='flex flex-col items-center px-8'>
|
||||
<div className='mb-3'>
|
||||
<Image src='/icon.png' alt='App Logo' className='h-24 w-24' width={64} height={64} />
|
||||
<div className='flex flex-col items-center gap-2 px-8'>
|
||||
<div className='mb-2 mt-8'>
|
||||
<Image src='/icon.png' alt='App Logo' className='h-20 w-20' width={64} height={64} />
|
||||
</div>
|
||||
<div className='flex select-text flex-col items-center'>
|
||||
<h2 className='text-2xl font-bold'>Readest</h2>
|
||||
<h2 className='mb-2 text-2xl font-bold'>Readest</h2>
|
||||
<p className='text-neutral-content text-center text-sm'>
|
||||
{_('Version {{version}}', { version: packageJson.version })} {`(${browserInfo})`}
|
||||
</p>
|
||||
</div>
|
||||
<div className='h-5'>
|
||||
{appService?.hasUpdater && !isUpdated && (
|
||||
<span className='badge badge-primary mt-2 cursor-pointer' onClick={handleCheckUpdate}>
|
||||
{appService?.hasUpdater && !updateStatus && (
|
||||
<span className='badge badge-primary cursor-pointer p-1' onClick={handleCheckUpdate}>
|
||||
{_('Check Update')}
|
||||
</span>
|
||||
)}
|
||||
{isUpdated && (
|
||||
{updateStatus === 'updated' && (
|
||||
<p className='text-neutral-content mt-2 text-xs'>{_('Already the latest version')}</p>
|
||||
)}
|
||||
{updateStatus === 'checking' && (
|
||||
<p className='text-neutral-content mt-2 text-xs'>{_('Checking for updates...')}</p>
|
||||
)}
|
||||
{updateStatus === 'error' && (
|
||||
<p className='text-error mt-2 text-xs'>{_('Error checking for updates')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='divider py-12 sm:py-2'></div>
|
||||
<div className='divider py-16 sm:py-2'></div>
|
||||
|
||||
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
|
||||
@@ -20,7 +20,7 @@ const Alert: React.FC<{
|
||||
'min-w-[70vw] flex-col sm:min-w-[40vw] sm:flex-row',
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center space-x-2 self-start'>
|
||||
<div className='labels flex items-center space-x-2 self-start sm:space-x-4 sm:self-center'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='none'
|
||||
@@ -34,12 +34,12 @@ const Alert: React.FC<{
|
||||
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
></path>
|
||||
</svg>
|
||||
<div className=''>
|
||||
<h3 className='font-sm text-start text-base sm:text-center'>{title}</h3>
|
||||
<div className='text-xs'>{message}</div>
|
||||
<div className='flex flex-col gap-y-2'>
|
||||
<h3 className='text-start text-sm sm:text-center'>{title}</h3>
|
||||
<div className='text-start text-xs sm:text-center'>{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2 self-end sm:max-w-[20vw]'>
|
||||
<div className='buttons flex flex-wrap items-center justify-end gap-2 self-end sm:max-w-[20vw] sm:self-center'>
|
||||
<button className='btn btn-sm btn-neutral' onClick={onCancel}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import {
|
||||
formatAuthors,
|
||||
formatDate,
|
||||
formatFileSize,
|
||||
formatLanguage,
|
||||
formatPublisher,
|
||||
formatSubject,
|
||||
@@ -41,6 +42,7 @@ const BookDetailModal = ({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [bookMeta, setBookMeta] = useState<BookDoc['metadata'] | null>(null);
|
||||
const [fileSize, setFileSize] = useState<number | null>(null);
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
@@ -50,7 +52,9 @@ const BookDetailModal = ({
|
||||
const appService = await envConfig.getAppService();
|
||||
try {
|
||||
const details = await appService.fetchBookDetails(book, settings);
|
||||
const size = await appService.getBookFileSize(book);
|
||||
setBookMeta(details);
|
||||
setFileSize(size);
|
||||
} finally {
|
||||
if (loadingTimeout) clearTimeout(loadingTimeout);
|
||||
setLoading(false);
|
||||
@@ -149,13 +153,13 @@ const BookDetailModal = ({
|
||||
<div className='mb-4 grid grid-cols-2 gap-4 sm:grid-cols-3'>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Publisher:')}</span>
|
||||
<p className='text-neutral-content line-clamp-1 text-sm'>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatPublisher(bookMeta.publisher || '') || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Published:')}</span>
|
||||
<p className='text-neutral-content max-w-28 text-ellipsis text-sm'>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatDate(bookMeta.published) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -173,22 +177,20 @@ const BookDetailModal = ({
|
||||
{formatLanguage(bookMeta.language) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Identifier:')}</span>
|
||||
<p className='text-neutral-content line-clamp-1 text-sm'>
|
||||
{bookMeta.identifier || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Subjects:')}</span>
|
||||
<p className='text-neutral-content line-clamp-1 text-sm'>
|
||||
<p className='text-neutral-content line-clamp-3 text-sm'>
|
||||
{formatSubject(bookMeta.subject) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Format:')}</span>
|
||||
<p className='text-neutral-content line-clamp-1 text-sm'>
|
||||
{book.format || _('Unknown')}
|
||||
<p className='text-neutral-content text-sm'>{book.format || _('Unknown')}</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('File Size:')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatFileSize(fileSize) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,7 +213,7 @@ const BookDetailModal = ({
|
||||
>
|
||||
<Alert
|
||||
title={_('Confirm Deletion')}
|
||||
message={_('Are you sure to delete the selected books?')}
|
||||
message={_('Are you sure to delete the selected book?')}
|
||||
onCancel={() => {
|
||||
setShowDeleteAlert(false);
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
|
||||
interface ButtonProps {
|
||||
icon: React.ReactNode;
|
||||
@@ -18,6 +19,7 @@ const Button: React.FC<ButtonProps> = ({
|
||||
tooltipDirection = 'top',
|
||||
className,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -32,7 +34,8 @@ const Button: React.FC<ButtonProps> = ({
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost h-8 min-h-8 w-8 p-0',
|
||||
disabled && 'btn-disabled !bg-transparent',
|
||||
appService?.isMobileApp && 'hover:bg-transparent',
|
||||
disabled && 'btn-disabled !bg-transparent opacity-50',
|
||||
className,
|
||||
)}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
|
||||
@@ -43,7 +43,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
const { appService } = useEnv();
|
||||
const { systemUIVisible, statusBarHeight } = useThemeStore();
|
||||
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
|
||||
const [isFullHeightInMobile, setIsFullHeightInMobile] = React.useState(!snapHeight);
|
||||
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(!snapHeight);
|
||||
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
|
||||
const iconSize22 = useResponsiveSize(22);
|
||||
const isMobile = window.innerWidth < 640;
|
||||
@@ -62,6 +62,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setIsFullHeightInMobile(!snapHeight);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
if (appService?.isAndroidApp) {
|
||||
acquireBackKeyInterception();
|
||||
@@ -166,38 +167,28 @@ 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 &&
|
||||
isFullHeightInMobile &&
|
||||
'pt-[env(safe-area-inset-top)] sm:pt-0',
|
||||
boxClassName,
|
||||
)}
|
||||
style={
|
||||
snapHeight
|
||||
? {
|
||||
height: `${snapHeight * 100}%`,
|
||||
bottom: 0,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
style={{
|
||||
paddingTop:
|
||||
appService?.hasSafeAreaInset && isFullHeightInMobile
|
||||
? `max(env(safe-area-inset-top), ${systemUIVisible ? statusBarHeight : 0}px)`
|
||||
: '0px',
|
||||
...(appService?.isMobile
|
||||
? { height: snapHeight ? `${snapHeight * 100}%` : '100%', bottom: 0 }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{window.innerWidth < 640 && (
|
||||
<div
|
||||
className={clsx(
|
||||
'drag-handle flex h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center',
|
||||
'transition-padding-top duration-300 ease-out',
|
||||
)}
|
||||
style={{
|
||||
paddingTop:
|
||||
appService?.isAndroidApp && systemUIVisible && isFullHeightInMobile
|
||||
? statusBarHeight
|
||||
: 0,
|
||||
}}
|
||||
onMouseDown={handleDragStart}
|
||||
onTouchStart={handleDragStart}
|
||||
>
|
||||
<div className='bg-base-content/50 h-1 w-10 rounded-full'></div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'drag-handle h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center',
|
||||
'transition-padding-top flex duration-300 ease-out sm:hidden',
|
||||
)}
|
||||
onMouseDown={handleDragStart}
|
||||
onTouchStart={handleDragStart}
|
||||
>
|
||||
<div className='bg-base-content/50 h-1 w-10 rounded-full'></div>
|
||||
</div>
|
||||
<div className='dialog-header bg-base-100 sticky top-1 z-10 flex items-center justify-between px-4'>
|
||||
{header ? (
|
||||
header
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface MenuItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
buttonClass?: string;
|
||||
labelClass?: string;
|
||||
shortcut?: string;
|
||||
@@ -17,6 +18,7 @@ interface MenuItemProps {
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({
|
||||
label,
|
||||
description,
|
||||
buttonClass,
|
||||
labelClass,
|
||||
shortcut,
|
||||
@@ -30,40 +32,52 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
const menuButton = (
|
||||
<button
|
||||
className={clsx(
|
||||
'hover:bg-base-300 text-base-content flex h-10 w-full items-center justify-between rounded-md p-1',
|
||||
'hover:bg-base-300 text-base-content flex w-full flex-col items-center justify-center rounded-md p-1 py-[10px]',
|
||||
disabled && 'btn-disabled text-gray-400',
|
||||
buttonClass,
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className='flex min-w-0 items-center'>
|
||||
{!noIcon && (
|
||||
<span style={{ minWidth: `${iconSize}px` }}>
|
||||
{typeof Icon === 'function' ? (
|
||||
<Icon className='text-base-content' size={iconSize} />
|
||||
) : (
|
||||
Icon
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex min-w-0 items-center'>
|
||||
{!noIcon && (
|
||||
<span style={{ minWidth: `${iconSize}px` }}>
|
||||
{typeof Icon === 'function' ? (
|
||||
<Icon className='text-base-content' size={iconSize} />
|
||||
) : (
|
||||
Icon
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm', labelClass)}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{shortcut && (
|
||||
<kbd
|
||||
className={clsx(
|
||||
'border-base-300/40 bg-base-300/75 text-neutral-content hidden rounded-md border shadow-sm sm:flex',
|
||||
'shrink-0 px-1.5 py-0.5 text-xs font-medium',
|
||||
)}
|
||||
>
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex w-full'>
|
||||
{description && (
|
||||
<span
|
||||
className='mt-1 truncate text-start text-xs text-gray-500'
|
||||
style={{ minWidth: 0, paddingInlineStart: noIcon ? '0' : `${iconSize + 8}px` }}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm', labelClass)}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{shortcut && (
|
||||
<kbd
|
||||
className={clsx(
|
||||
'border-base-300/40 bg-base-300/75 text-neutral-content hidden rounded-md border shadow-sm sm:flex',
|
||||
'shrink-0 px-1.5 py-0.5 text-xs font-medium',
|
||||
)}
|
||||
>
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { ReactNode } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
interface ModalPortalProps {
|
||||
children: ReactNode;
|
||||
showOverlay?: boolean;
|
||||
}
|
||||
|
||||
const ModalPortal: React.FC<ModalPortalProps> = ({ children, showOverlay = true }) => {
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed inset-0 isolate z-50 flex items-center justify-center',
|
||||
showOverlay && 'bg-black bg-opacity-50',
|
||||
)}
|
||||
style={{ transform: 'translateZ(0)' }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalPortal;
|
||||
@@ -1,8 +1,13 @@
|
||||
import clsx from 'clsx';
|
||||
import { Position } from '@/utils/sel';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
const Popup = ({
|
||||
width,
|
||||
height,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
position,
|
||||
trianglePosition,
|
||||
children,
|
||||
@@ -11,74 +16,133 @@ const Popup = ({
|
||||
additionalStyle = {},
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
height?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
position?: Position;
|
||||
trianglePosition?: Position;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
triangleClassName?: string;
|
||||
additionalStyle?: React.CSSProperties;
|
||||
}) => (
|
||||
<div>
|
||||
<div
|
||||
id='popup-container'
|
||||
className={`bg-base-300 absolute rounded-lg font-sans shadow-xl ${className}`}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${position ? position.point.x : -999}px`,
|
||||
top: `${position ? position.point.y : -999}px`,
|
||||
...additionalStyle,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={`triangle text-base-300 absolute ${triangleClassName}`}
|
||||
style={{
|
||||
left:
|
||||
trianglePosition?.dir === 'left'
|
||||
? `${trianglePosition.point.x}px`
|
||||
: trianglePosition?.dir === 'right'
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState(position);
|
||||
const [childrenHeight, setChildrenHeight] = useState(height || minHeight || 0);
|
||||
|
||||
const popupPadding = useResponsiveSize(10);
|
||||
let availableHeight = window.innerHeight - 2 * popupPadding;
|
||||
if (trianglePosition?.dir === 'up') {
|
||||
availableHeight = trianglePosition.point.y - popupPadding;
|
||||
} else if (trianglePosition?.dir === 'down') {
|
||||
availableHeight = window.innerHeight - trianglePosition.point.y - popupPadding;
|
||||
}
|
||||
maxHeight = Math.min(maxHeight || availableHeight, availableHeight);
|
||||
if (minHeight) {
|
||||
minHeight = Math.min(minHeight, availableHeight);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const newHeight = entry.contentRect.height;
|
||||
if (newHeight !== childrenHeight) {
|
||||
setChildrenHeight(newHeight);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (!position || !trianglePosition || position.dir !== 'up') {
|
||||
setAdjustedPosition(position);
|
||||
return;
|
||||
}
|
||||
const containerHeight = childrenHeight || containerRef.current.offsetHeight;
|
||||
const newPosition = {
|
||||
...position,
|
||||
point: {
|
||||
...position.point,
|
||||
y: trianglePosition.point.y - containerHeight,
|
||||
},
|
||||
};
|
||||
setAdjustedPosition(newPosition);
|
||||
}, [position, trianglePosition, childrenHeight]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
id='popup-container'
|
||||
ref={containerRef}
|
||||
className={clsx('bg-base-300 absolute rounded-lg font-sans shadow-xl', className)}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: height ? `${height}px` : 'auto',
|
||||
minHeight: minHeight ? `${minHeight}px` : 'none',
|
||||
maxHeight: maxHeight ? `${maxHeight}px` : 'none',
|
||||
left: `${adjustedPosition ? adjustedPosition.point.x : -999}px`,
|
||||
top: `${adjustedPosition ? adjustedPosition.point.y : -999}px`,
|
||||
...additionalStyle,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={`triangle text-base-300 absolute ${triangleClassName}`}
|
||||
style={{
|
||||
left:
|
||||
trianglePosition?.dir === 'left'
|
||||
? `${trianglePosition.point.x}px`
|
||||
: `${trianglePosition ? trianglePosition.point.x : -999}px`,
|
||||
top:
|
||||
trianglePosition?.dir === 'up'
|
||||
? `${trianglePosition.point.y}px`
|
||||
: trianglePosition?.dir === 'down'
|
||||
: trianglePosition?.dir === 'right'
|
||||
? `${trianglePosition.point.x}px`
|
||||
: `${trianglePosition ? trianglePosition.point.x : -999}px`,
|
||||
top:
|
||||
trianglePosition?.dir === 'up'
|
||||
? `${trianglePosition.point.y}px`
|
||||
: `${trianglePosition ? trianglePosition.point.y : -999}px`,
|
||||
borderLeft:
|
||||
trianglePosition?.dir === 'right'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'left'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderRight:
|
||||
trianglePosition?.dir === 'left'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'right'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderTop:
|
||||
trianglePosition?.dir === 'down'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'up'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderBottom:
|
||||
trianglePosition?.dir === 'up'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'down'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
transform:
|
||||
trianglePosition?.dir === 'left' || trianglePosition?.dir === 'right'
|
||||
? 'translateY(-50%)'
|
||||
: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
: trianglePosition?.dir === 'down'
|
||||
? `${trianglePosition.point.y}px`
|
||||
: `${trianglePosition ? trianglePosition.point.y : -999}px`,
|
||||
borderLeft:
|
||||
trianglePosition?.dir === 'right'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'left'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderRight:
|
||||
trianglePosition?.dir === 'left'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'right'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderTop:
|
||||
trianglePosition?.dir === 'down'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'up'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
borderBottom:
|
||||
trianglePosition?.dir === 'up'
|
||||
? 'none'
|
||||
: trianglePosition?.dir === 'down'
|
||||
? `6px solid`
|
||||
: '6px solid transparent',
|
||||
transform:
|
||||
trianglePosition?.dir === 'left' || trianglePosition?.dir === 'right'
|
||||
? 'translateY(-50%)'
|
||||
: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Popup;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type SelectProps = {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
options: Option[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Select({ value, onChange, options, className }: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
|
||||
'bg-gray-600 text-white/75 focus:outline-none focus:ring-0',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
textAlignLast: 'end',
|
||||
}}
|
||||
>
|
||||
{options.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ const Slider: React.FC<SliderProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`slider bg-base-200 mx-auto w-full max-w-md rounded-xl ${className}`}
|
||||
className={`slider bg-base-200 mx-auto w-full rounded-xl ${className}`}
|
||||
dir={isRtl ? 'rtl' : undefined}
|
||||
>
|
||||
<div className='relative' style={{ height: `${heightPx}px` }}>
|
||||
@@ -75,7 +75,10 @@ const Slider: React.FC<SliderProps> = ({
|
||||
<div
|
||||
className='bg-base-300 absolute h-full rounded-full'
|
||||
style={{
|
||||
width: percentage > 0 ? `calc(${percentage}% + ${heightPx / 2}px)` : '0px',
|
||||
width:
|
||||
percentage > 0
|
||||
? `max(calc(${percentage}% + ${heightPx / 2}px), ${heightPx}px)`
|
||||
: '0px',
|
||||
[isRtl ? 'right' : 'left']: 0,
|
||||
}}
|
||||
></div>
|
||||
@@ -94,7 +97,7 @@ const Slider: React.FC<SliderProps> = ({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`bg-base-200 flex h-full items-center justify-center rounded-full text-sm shadow-md ${bubbleClassName}`}
|
||||
className={`bg-base-200 flex h-full items-center justify-center rounded-full text-xs shadow-md ${bubbleClassName}`}
|
||||
style={{ width: `${heightPx}px` }}
|
||||
>
|
||||
{bubbleElement || bubbleLabel}
|
||||
|
||||
@@ -7,10 +7,12 @@ import { type as osType, arch as osArch } from '@tauri-apps/plugin-os';
|
||||
import { check, Update } from '@tauri-apps/plugin-updater';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { useTranslator } from '@/hooks/useTranslator';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { tauriDownload } from '@/utils/transfer';
|
||||
import { installPackage } from '@/utils/bridge';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants';
|
||||
import packageJson from '../../package.json';
|
||||
import Dialog from '@/components/Dialog';
|
||||
@@ -58,6 +60,11 @@ interface GenericUpdate {
|
||||
|
||||
export const UpdaterContent = ({ version }: { version?: string }) => {
|
||||
const _ = useTranslation();
|
||||
const [targetLang, setTargetLang] = useState('EN');
|
||||
const { translate } = useTranslator({
|
||||
sourceLang: 'AUTO',
|
||||
targetLang,
|
||||
});
|
||||
const { appService } = useEnv();
|
||||
const searchParams = useSearchParams();
|
||||
const currentVersion = packageJson.version;
|
||||
@@ -71,6 +78,17 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloaded, setDownloaded] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const locale = getLocale();
|
||||
let userLang = locale.split('-')[0] || 'en';
|
||||
if (locale === 'zh-CN') {
|
||||
userLang = 'zh-Hans';
|
||||
} else if (locale.startsWith('zh')) {
|
||||
userLang = 'zh-Hant';
|
||||
}
|
||||
setTargetLang(userLang.toUpperCase());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDesktopUpdate = async () => {
|
||||
const update = await check();
|
||||
@@ -181,18 +199,25 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
|
||||
};
|
||||
const updateChangelogs = async (update: GenericUpdate) => {
|
||||
setNewVersion(update.version);
|
||||
const changelogs = await fetchChangelogs(currentVersion, update.version);
|
||||
if (changelogs.length > 0) {
|
||||
setChangelogs(changelogs);
|
||||
} else {
|
||||
setChangelogs([
|
||||
let changelogs = await fetchChangelogs(currentVersion, update.version);
|
||||
if (changelogs.length === 0) {
|
||||
changelogs = [
|
||||
{
|
||||
version: update.version,
|
||||
date: new Date(update.date!).toDateString(),
|
||||
notes: parseNumberedList(update.body ?? ''),
|
||||
},
|
||||
]);
|
||||
];
|
||||
}
|
||||
for (const entry of changelogs) {
|
||||
try {
|
||||
entry.notes = await translate(entry.notes, { useCache: true });
|
||||
} catch (error) {
|
||||
console.log('Failed to translate changelog:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setChangelogs(changelogs);
|
||||
};
|
||||
if (update) {
|
||||
updateChangelogs(update);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconType } from 'react-icons';
|
||||
|
||||
interface UserAvatarProps {
|
||||
@@ -17,6 +18,41 @@ const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
borderClassName,
|
||||
DefaultIcon,
|
||||
}) => {
|
||||
const [cachedImageUrl, setCachedImageUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
|
||||
const storageKey = `avatar_${btoa(url).replace(/[^a-zA-Z0-9]/g, '')}`;
|
||||
const cached = localStorage.getItem(storageKey);
|
||||
if (cached) {
|
||||
setCachedImageUrl(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheImage = async () => {
|
||||
try {
|
||||
const response = await fetch(url, { referrerPolicy: 'no-referrer' });
|
||||
const blob = await response.blob();
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64data = reader.result as string;
|
||||
try {
|
||||
localStorage.setItem(storageKey, base64data);
|
||||
setCachedImageUrl(base64data);
|
||||
} catch (e) {
|
||||
console.warn('Failed to cache avatar in localStorage:', e);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
} catch (error) {
|
||||
console.error('Failed to cache avatar:', error);
|
||||
}
|
||||
};
|
||||
|
||||
cacheImage();
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative flex h-full w-full items-center justify-center rounded-full'
|
||||
@@ -25,7 +61,7 @@ const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
{url ? (
|
||||
<div>
|
||||
<Image
|
||||
src={url}
|
||||
src={cachedImageUrl || url}
|
||||
alt='User Avatar'
|
||||
className={clsx('rounded-full', className, borderClassName)}
|
||||
referrerPolicy='no-referrer'
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client';
|
||||
import { ReactNode } from 'react';
|
||||
import posthog from 'posthog-js';
|
||||
import { ReactNode } from 'react';
|
||||
import { PostHogProvider } from 'posthog-js/react';
|
||||
import { TELEMETRY_OPT_OUT_KEY } from '@/utils/telemetry';
|
||||
|
||||
const shouldDisablePostHog = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return localStorage.getItem(TELEMETRY_OPT_OUT_KEY) === 'true';
|
||||
};
|
||||
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
@@ -12,6 +18,9 @@ if (
|
||||
api_host: process.env['NEXT_PUBLIC_POSTHOG_HOST'],
|
||||
person_profiles: 'always',
|
||||
});
|
||||
if (shouldDisablePostHog()) {
|
||||
posthog.opt_out_capturing();
|
||||
}
|
||||
}
|
||||
export const CSPostHogProvider = ({ children }: { children: ReactNode }) => {
|
||||
return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const checkForAppUpdates = async (
|
||||
return !!update;
|
||||
} else if (OS_TYPE === 'android') {
|
||||
try {
|
||||
const response = await fetch(READEST_UPDATER_FILE);
|
||||
const response = await fetch(READEST_UPDATER_FILE, { connectTimeout: 5000 });
|
||||
const data = await response.json();
|
||||
const isNewer = semver.gt(data.version, packageJson.version);
|
||||
if (isNewer && ('android-arm64' in data.platforms || 'android-universal' in data.platforms)) {
|
||||
@@ -56,7 +56,7 @@ export const checkForAppUpdates = async (
|
||||
return isNewer;
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch Android update info', err);
|
||||
return false;
|
||||
throw new Error('Failed to fetch Android update info');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
// use desktop size as base size
|
||||
export const useResponsiveSize = (baseSize: number) => {
|
||||
const isPhone = useMediaQuery({ maxWidth: 480 });
|
||||
const isTablet = useMediaQuery({ minWidth: 481, maxWidth: 1024 });
|
||||
if (isPhone) return baseSize * 1.25;
|
||||
if (isTablet) return baseSize * 1.15;
|
||||
if (typeof window === 'undefined') {
|
||||
return baseSize;
|
||||
}
|
||||
const pixelRatio = window.devicePixelRatio || 2.4;
|
||||
const isMobile = ['android', 'ios'].includes(getOSPlatform());
|
||||
if (isPhone && isMobile) return baseSize * (pixelRatio / 3) * 1.25;
|
||||
if (isTablet && isMobile) return baseSize * (pixelRatio / 3) * 1.25;
|
||||
return baseSize;
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useTheme = ({
|
||||
|
||||
useEffect(() => {
|
||||
updateAppTheme(appThemeColor);
|
||||
if (appService?.isMobile) {
|
||||
if (appService?.isMobileApp) {
|
||||
if (systemUIVisible) {
|
||||
showSystemUI();
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { getTranslator, getTranslators } from '@/services/translators';
|
||||
import { getFromCache, storeInCache, polish, UseTranslatorOptions } from '@/services/translators';
|
||||
|
||||
export function useTranslator({
|
||||
provider = 'deepl',
|
||||
sourceLang = 'AUTO',
|
||||
targetLang = 'EN',
|
||||
enablePolishing = true,
|
||||
}: UseTranslatorOptions = {}) {
|
||||
const { token } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [translator, setTransltor] = useState(() => getTranslator(provider));
|
||||
const [translators] = useState(() => getTranslators());
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [provider, sourceLang, targetLang]);
|
||||
|
||||
useEffect(() => {
|
||||
setTransltor(getTranslator(provider));
|
||||
}, [provider]);
|
||||
|
||||
const translate = useCallback(
|
||||
async (
|
||||
input: string[],
|
||||
options?: { source?: string; target?: string; useCache?: boolean },
|
||||
): Promise<string[]> => {
|
||||
const sourceLanguage = options?.source || sourceLang;
|
||||
const targetLanguage = options?.target || targetLang;
|
||||
const useCache = options?.useCache ?? false;
|
||||
const textsToTranslate = input;
|
||||
|
||||
if (textsToTranslate.length === 0 || textsToTranslate.every((t) => !t?.trim())) {
|
||||
return textsToTranslate;
|
||||
}
|
||||
|
||||
const textsNeedingTranslation: string[] = [];
|
||||
const indicesNeedingTranslation: number[] = [];
|
||||
|
||||
await Promise.all(
|
||||
textsToTranslate.map(async (text, index) => {
|
||||
if (!text?.trim()) return;
|
||||
|
||||
const cachedTranslation = await getFromCache(
|
||||
text,
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
provider,
|
||||
);
|
||||
if (cachedTranslation) return;
|
||||
|
||||
textsNeedingTranslation.push(text);
|
||||
indicesNeedingTranslation.push(index);
|
||||
}),
|
||||
);
|
||||
|
||||
if (textsNeedingTranslation.length === 0) {
|
||||
const results = await Promise.all(
|
||||
textsToTranslate.map((text) =>
|
||||
getFromCache(text, sourceLanguage, targetLanguage, provider).then(
|
||||
(cached) => cached || text,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const translator = translators.find((t) => t.name === provider);
|
||||
if (!translator) {
|
||||
throw new Error(`No translator found for provider: ${provider}`);
|
||||
}
|
||||
const translatedTexts = await translator.translate(
|
||||
textsNeedingTranslation,
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
token,
|
||||
useCache,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
textsNeedingTranslation.map(async (text, index) => {
|
||||
return storeInCache(
|
||||
text,
|
||||
translatedTexts[index] || '',
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
provider,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const results = [...textsToTranslate];
|
||||
indicesNeedingTranslation.forEach((originalIndex, translationIndex) => {
|
||||
results[originalIndex] = translatedTexts[translationIndex] || '';
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
results.map(async (_, index) => {
|
||||
if (!indicesNeedingTranslation.includes(index)) {
|
||||
const originalText = textsToTranslate[index];
|
||||
if (!originalText?.trim()) return;
|
||||
|
||||
const cachedTranslation = await getFromCache(
|
||||
originalText,
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
provider,
|
||||
);
|
||||
|
||||
if (cachedTranslation) {
|
||||
results[index] = cachedTranslation;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
return enablePolishing ? polish(results, targetLanguage) : results;
|
||||
} catch (err) {
|
||||
console.error('Translation error:', err);
|
||||
setLoading(false);
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[provider, sourceLang, targetLang, translator, token],
|
||||
);
|
||||
|
||||
return {
|
||||
translate,
|
||||
translator,
|
||||
translators,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ViewSettings } from '@/types/book';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
|
||||
// This hook allows you to inject custom CSS into the reader UI.
|
||||
// Note that the book content is rendered in an iframe, so UI CSS won't affect book rendering.
|
||||
export const useUICSS = (bookKey: string, viewSettings: ViewSettings) => {
|
||||
export const useUICSS = (bookKey: string) => {
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const [styleElement, setStyleElement] = useState<HTMLStyleElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -174,11 +174,12 @@ export class EdgeSpeechTTS {
|
||||
});
|
||||
|
||||
const genSSML = (lang: string, text: string, voice: string, rate: number) => {
|
||||
const cleanedText = text.replace(/^<break\b[^>]*>/i, '');
|
||||
return `
|
||||
<speak version="1.0" xml:lang="${lang}">
|
||||
<voice name="${voice}">
|
||||
<prosody rate="${rate}">
|
||||
${text}
|
||||
${cleanedText}
|
||||
</prosody>
|
||||
</voice>
|
||||
</speak>
|
||||
@@ -255,6 +256,12 @@ export class EdgeSpeechTTS {
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!audioData.byteLength) {
|
||||
reject(new Error('No audio data received.'));
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
ws.close();
|
||||
reject(new Error('WebSocket error occurred.'));
|
||||
|
||||
@@ -3,7 +3,6 @@ import Head from 'next/head';
|
||||
import Providers from '@/components/Providers';
|
||||
|
||||
import '../styles/globals.css';
|
||||
import '../styles/fonts.css';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user