forked from akai/readest
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfa0468d24 | |||
| 4869b756ea | |||
| 693c79c9b7 | |||
| e846875973 | |||
| b1f2941247 | |||
| 6688c813e6 | |||
| 664dc45fcc | |||
| 27fa753486 | |||
| 71e392544c | |||
| 7f24081205 | |||
| 89f1c43984 | |||
| f841004e7a | |||
| 0922f557bb | |||
| 0b72ab156d | |||
| dfc6cc9c8a | |||
| 83b2908266 | |||
| c25f2a7abc | |||
| de1e396f6e | |||
| 80a3861816 | |||
| fd8eeb07c2 | |||
| 93d1fe6946 | |||
| 3c52a9e0cb | |||
| 3056a29860 | |||
| bd6738e54c | |||
| 8c3f49de25 | |||
| 0f44fff7cd | |||
| 77bba7bd0c | |||
| d30d1c17ae | |||
| 088c7d2c49 | |||
| d0e6f3ad50 | |||
| 8a83389164 | |||
| 04d07856f5 | |||
| cc8d0ea457 | |||
| 29e6af7768 | |||
| bc70d07749 | |||
| 2db83b8569 | |||
| 2f29295c6c | |||
| cab757257e | |||
| fc8c9546b6 | |||
| 3cc41b7b71 | |||
| e65380aaf5 | |||
| 24b68ac046 | |||
| e0591ce41c | |||
| 7be6c07344 | |||
| f8ea7fc463 |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Share an idea or suggestion
|
||||
title: 'FR: [a handful of words describing the FR]'
|
||||
title: 'FR: describing your feature request'
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[submodule "packages/foliate-js"]
|
||||
path = packages/foliate-js
|
||||
url = https://github.com/chrox/foliate-js.git
|
||||
url = https://github.com/readest/foliate-js.git
|
||||
[submodule "packages/tauri"]
|
||||
path = packages/tauri
|
||||
url = https://github.com/chrox/tauri.git
|
||||
url = https://github.com/tauri-apps/tauri.git
|
||||
|
||||
Generated
+675
-651
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@
|
||||
|
||||
<div align="center">
|
||||
<a href="https://readest.com" target="_blank">
|
||||
<img src="./data/screenshots/landing_preview.png" alt="Readest Banner" width="100%" />
|
||||
<img src="./data/screenshots/landing_all_platforms.png" alt="Readest Banner" width="100%" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
@@ -94,10 +94,9 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
|
||||
The Readest app is available for download! 🥳 🚀
|
||||
|
||||
- macOS : Search for "Readest" on the [macOS App Store][link-macos-appstore].
|
||||
- Windows / Linux: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- macOS / iOS / iPadOS : Search for "Readest" on the [App Store][link-appstore], also available on TestFlight for beta test (send your Apple ID to readestapp@gmail.com to request access).
|
||||
- Windows / Linux / Android: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- Web: Visit [https://web.readest.com][link-web-readest].
|
||||
- iOS / Android: coming soon 👀
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -149,8 +148,9 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
|
||||
### 4. Build for Development
|
||||
|
||||
```bash
|
||||
# Start development for the Tauri app
|
||||
pnpm tauri dev
|
||||
# or start development for the Web version only
|
||||
# or start development for the Web app
|
||||
pnpm dev-web
|
||||
```
|
||||
|
||||
@@ -205,7 +205,7 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
|
||||
|
||||
<a href="https://github.com/readest/readest/graphs/contributors">
|
||||
<p align="left">
|
||||
<img width="300" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
|
||||
<img width="500" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
|
||||
</p>
|
||||
</a>
|
||||
|
||||
@@ -241,7 +241,7 @@ The following fonts are utilized in this software, either bundled within the app
|
||||
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
|
||||
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
|
||||
[badge-hellogithub]: https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8a5b6ade2aee461a8bd94e59200682a7&claim_uid=eRLUbPOy2qZtDgw&theme=small
|
||||
[link-macos-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
|
||||
[link-web-readest]: https://web.readest.com
|
||||
[link-gh-releases]: https://github.com/readest/readest/releases
|
||||
|
||||
@@ -4,6 +4,8 @@ NEXT_PUBLIC_POSTHOG_HOST=YOUR_POSTHOG_HOST
|
||||
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
|
||||
|
||||
SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
|
||||
|
||||
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
|
||||
DEEPL_FREE_API_KEYS=YOUR_DEEPL_FREE_API_KEYS
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.17",
|
||||
"version": "0.9.21",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -42,19 +42,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.2.0",
|
||||
"@tauri-apps/api": "2.3.0",
|
||||
"@tauri-apps/plugin-cli": "^2.2.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-fs": "^2.2.0",
|
||||
"@tauri-apps/plugin-haptics": "^2.2.3",
|
||||
"@tauri-apps/plugin-http": "^2.3.0",
|
||||
"@tauri-apps/plugin-log": "^2.2.1",
|
||||
"@tauri-apps/plugin-opener": "^2.2.5",
|
||||
"@tauri-apps/plugin-log": "^2.2.3",
|
||||
"@tauri-apps/plugin-opener": "^2.2.6",
|
||||
"@tauri-apps/plugin-os": "^2.2.0",
|
||||
"@tauri-apps/plugin-process": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "~2.2.0",
|
||||
"@tauri-apps/plugin-updater": "^2.4.0",
|
||||
"@tauri-apps/plugin-updater": "^2.5.1",
|
||||
"@zip.js/zip.js": "^2.7.53",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
@@ -76,7 +76,7 @@
|
||||
"zustand": "5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.2.7",
|
||||
"@tauri-apps/cli": "2.3.1",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/cssbeautify": "^0.3.5",
|
||||
"@types/node": "^22.10.1",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "مطابقة حالة الأحرف",
|
||||
"Match Diacritics": "مطابقة التشكيل",
|
||||
"Match Whole Words": "مطابقة الكلمات الكاملة",
|
||||
"Maximum Block Size": "أقصى مساحة رأسية",
|
||||
"Maximum Inline Size": "أقصى مساحة أفقية",
|
||||
"Maximum Number of Columns": "العدد الأقصى للأعمدة",
|
||||
"Minimum Font Size": "الحد الأدنى لحجم الخط",
|
||||
"Misc": "أخرى",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "التحقق من الرمز",
|
||||
"Sign in with Google": "تسجيل الدخول عبر Google",
|
||||
"Sign in with Apple": "تسجيل الدخول عبر Apple",
|
||||
"Sign in with GitHub": "تسجيل الدخول عبر GitHub"
|
||||
"Sign in with GitHub": "تسجيل الدخول عبر GitHub",
|
||||
"Account": "الحساب",
|
||||
"Cloud Storage": "التخزين السحابي",
|
||||
"Failed to delete user. Please try again later.": "فشل حذف المستخدم. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"Unlimited Offline/Online Reading": "قراءة غير محدودة بدون اتصال/متصلة بالإنترنت",
|
||||
"Unlimited Cloud Sync Devices": "أجهزة مزامنة سحابية غير محدودة",
|
||||
"Essential Text-to-Speech Voices": "أصوات أساسية لتحويل النص إلى كلام",
|
||||
"DeepL Free Access": "وصول مجاني إلى DeepL",
|
||||
"Community Support": "دعم المجتمع",
|
||||
"500 MB Cloud Sync Space": "مساحة مزامنة سحابية 500 ميجابايت",
|
||||
"Includes All Free Tier Benefits": "يتضمن جميع مزايا الفئة المجانية",
|
||||
"AI Summaries": "ملخصات الذكاء الاصطناعي",
|
||||
"AI Translations": "ترجمات الذكاء الاصطناعي",
|
||||
"Priority Support": "دعم ذو أولوية",
|
||||
"DeepL Pro Access": "وصول إلى DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "أصوات موسعة لتحويل النص إلى كلام",
|
||||
"2000 MB Cloud Sync Space": "مساحة مزامنة سحابية 2000 ميجابايت",
|
||||
"Includes All Plus Tier Benefits": "يتضمن جميع مزايا فئة Plus",
|
||||
"Unlimited AI Summaries": "ملخصات ذكاء اصطناعي غير محدودة",
|
||||
"Unlimited AI Translations": "ترجمات ذكاء اصطناعي غير محدودة",
|
||||
"Unlimited DeepL Pro Access": "وصول غير محدود إلى DeepL Pro",
|
||||
"Advanced AI Tools": "أدوات ذكاء اصطناعي متقدمة",
|
||||
"Early Feature Access": "وصول مبكر للميزات",
|
||||
"10 GB Cloud Sync Space": "مساحة مزامنة سحابية 10 جيجابايت",
|
||||
"Loading profile...": "جارٍ تحميل الملف الشخصي...",
|
||||
"Features": "الميزات",
|
||||
"Delete Account": "حذف الحساب",
|
||||
"Delete Your Account?": "حذف حسابك؟",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "لا يمكن التراجع عن هذا الإجراء. سيتم حذف جميع بياناتك في السحابة بشكل دائم.",
|
||||
"Delete Permanently": "حذف نهائي",
|
||||
"Free Tier": "الفئة المجانية",
|
||||
"Plus Tier": "فئة Plus",
|
||||
"Pro Tier": "فئة Pro",
|
||||
"RTL Direction": "الاتجاه من اليمين إلى اليسار",
|
||||
"Maximum Column Height": "الارتفاع الأقصى للعمود",
|
||||
"Maximum Column Width": "العرض الأقصى للعمود",
|
||||
"Continuous Scroll": "التمرير المستمر",
|
||||
"Fullscreen": "ملء الشاشة",
|
||||
"No supported files found. Supported formats: {{formats}}": "لم يتم العثور على ملفات مدعومة. الصيغ المدعومة: {{formats}}",
|
||||
"Drop to Import Books": "اسحب لاستيراد الكتب"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Groß-/Kleinschreibung beachten",
|
||||
"Match Diacritics": "Akzente beachten",
|
||||
"Match Whole Words": "Ganze Wörter",
|
||||
"Maximum Block Size": "Maximale Blockgröße",
|
||||
"Maximum Inline Size": "Maximale Zeilengröße",
|
||||
"Maximum Number of Columns": "Maximale Spaltenanzahl",
|
||||
"Minimum Font Size": "Minimale Schriftgröße",
|
||||
"Misc": "Sonstiges",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Token überprüfen",
|
||||
"Sign in with Google": "Mit Google anmelden",
|
||||
"Sign in with Apple": "Mit Apple anmelden",
|
||||
"Sign in with GitHub": "Mit GitHub anmelden"
|
||||
"Sign in with GitHub": "Mit GitHub anmelden",
|
||||
"Account": "Konto",
|
||||
"Cloud Storage": "Cloud-Speicher",
|
||||
"Failed to delete user. Please try again later.": "Benutzer konnte nicht gelöscht werden. Bitte versuchen Sie es später erneut.",
|
||||
"Unlimited Offline/Online Reading": "Unbegrenztes Offline/Online Lesen",
|
||||
"Unlimited Cloud Sync Devices": "Unbegrenzte Cloud-Synchronisierungsgeräte",
|
||||
"Essential Text-to-Speech Voices": "Grundlegende Text-zu-Sprache-Stimmen",
|
||||
"DeepL Free Access": "Kostenloser DeepL-Zugang",
|
||||
"Community Support": "Community-Support",
|
||||
"500 MB Cloud Sync Space": "500 MB Cloud-Synchronisierungsspeicher",
|
||||
"Includes All Free Tier Benefits": "Beinhaltet alle Vorteile der kostenlosen Stufe",
|
||||
"AI Summaries": "KI-Zusammenfassungen",
|
||||
"AI Translations": "KI-Übersetzungen",
|
||||
"Priority Support": "Bevorzugter Support",
|
||||
"DeepL Pro Access": "DeepL Pro-Zugang",
|
||||
"Expanded Text-to-Speech Voices": "Erweiterte Text-zu-Sprache-Stimmen",
|
||||
"2000 MB Cloud Sync Space": "2000 MB Cloud-Synchronisierungsspeicher",
|
||||
"Includes All Plus Tier Benefits": "Beinhaltet alle Vorteile der Plus-Stufe",
|
||||
"Unlimited AI Summaries": "Unbegrenzte KI-Zusammenfassungen",
|
||||
"Unlimited AI Translations": "Unbegrenzte KI-Übersetzungen",
|
||||
"Unlimited DeepL Pro Access": "Unbegrenzter DeepL Pro-Zugang",
|
||||
"Advanced AI Tools": "Fortgeschrittene KI-Tools",
|
||||
"Early Feature Access": "Früher Zugang zu neuen Funktionen",
|
||||
"10 GB Cloud Sync Space": "10 GB Cloud-Synchronisierungsspeicher",
|
||||
"Loading profile...": "Profil wird geladen...",
|
||||
"Features": "Funktionen",
|
||||
"Delete Account": "Konto löschen",
|
||||
"Delete Your Account?": "Ihr Konto löschen?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Diese Aktion kann nicht rückgängig gemacht werden. Alle Ihre Daten in der Cloud werden dauerhaft gelöscht.",
|
||||
"Delete Permanently": "Dauerhaft löschen",
|
||||
"Free Tier": "Kostenlose Stufe",
|
||||
"Plus Tier": "Plus-Stufe",
|
||||
"Pro Tier": "Pro-Stufe",
|
||||
"RTL Direction": "RTL-Richtung",
|
||||
"Maximum Column Height": "Maximale Spaltenhöhe",
|
||||
"Maximum Column Width": "Maximale Spaltenbreite",
|
||||
"Continuous Scroll": "Kontinuierliches Scrollen",
|
||||
"Fullscreen": "Vollbild",
|
||||
"No supported files found. Supported formats: {{formats}}": "Keine unterstützten Dateien gefunden. Unterstützte Formate: {{formats}}",
|
||||
"Drop to Import Books": "Zum Importieren von Büchern ablegen"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Ταίριασμα πεζών-κεφαλαίων",
|
||||
"Match Diacritics": "Ταίριασμα διακριτικών σημείων",
|
||||
"Match Whole Words": "Ταίριασμα ολόκληρων λέξεων",
|
||||
"Maximum Block Size": "Μέγιστο μέγεθος μπλοκ",
|
||||
"Maximum Inline Size": "Μέγιστο μέγεθος γραμμής",
|
||||
"Maximum Number of Columns": "Μέγιστος αριθμός στηλών",
|
||||
"Minimum Font Size": "Ελάχιστο μέγεθος γραμματοσειράς",
|
||||
"Misc": "Διάφορα",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Επαλήθευση κωδικού",
|
||||
"Sign in with Google": "Σύνδεση με Google",
|
||||
"Sign in with Apple": "Σύνδεση με Apple",
|
||||
"Sign in with GitHub": "Σύνδεση με GitHub"
|
||||
"Sign in with GitHub": "Σύνδεση με GitHub",
|
||||
"Account": "Λογαριασμός",
|
||||
"Cloud Storage": "Αποθηκευτικός χώρος Cloud",
|
||||
"Failed to delete user. Please try again later.": "Αποτυχία διαγραφής χρήστη. Παρακαλώ δοκιμάστε ξανά αργότερα.",
|
||||
"Unlimited Offline/Online Reading": "Απεριόριστη ανάγνωση εκτός/εντός σύνδεσης",
|
||||
"Unlimited Cloud Sync Devices": "Απεριόριστες συσκευές συγχρονισμού Cloud",
|
||||
"Essential Text-to-Speech Voices": "Βασικές φωνές μετατροπής κειμένου σε ομιλία",
|
||||
"DeepL Free Access": "Δωρεάν πρόσβαση DeepL",
|
||||
"Community Support": "Υποστήριξη κοινότητας",
|
||||
"500 MB Cloud Sync Space": "500 MB χώρος συγχρονισμού Cloud",
|
||||
"Includes All Free Tier Benefits": "Περιλαμβάνει όλα τα οφέλη της δωρεάν βαθμίδας",
|
||||
"AI Summaries": "Περιλήψεις AI",
|
||||
"AI Translations": "Μεταφράσεις AI",
|
||||
"Priority Support": "Υποστήριξη προτεραιότητας",
|
||||
"DeepL Pro Access": "Πρόσβαση DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Διευρυμένες φωνές μετατροπής κειμένου σε ομιλία",
|
||||
"2000 MB Cloud Sync Space": "2000 MB χώρος συγχρονισμού Cloud",
|
||||
"Includes All Plus Tier Benefits": "Περιλαμβάνει όλα τα οφέλη της βαθμίδας Plus",
|
||||
"Unlimited AI Summaries": "Απεριόριστες περιλήψεις AI",
|
||||
"Unlimited AI Translations": "Απεριόριστες μεταφράσεις AI",
|
||||
"Unlimited DeepL Pro Access": "Απεριόριστη πρόσβαση DeepL Pro",
|
||||
"Advanced AI Tools": "Προηγμένα εργαλεία AI",
|
||||
"Early Feature Access": "Πρώιμη πρόσβαση σε λειτουργίες",
|
||||
"10 GB Cloud Sync Space": "10 GB χώρος συγχρονισμού Cloud",
|
||||
"Loading profile...": "Φόρτωση προφίλ...",
|
||||
"Features": "Χαρακτηριστικά",
|
||||
"Delete Account": "Διαγραφή λογαριασμού",
|
||||
"Delete Your Account?": "Διαγραφή του λογαριασμού σας;",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. Όλα τα δεδομένα σας στο cloud θα διαγραφούν μόνιμα.",
|
||||
"Delete Permanently": "Μόνιμη διαγραφή",
|
||||
"Free Tier": "Δωρεάν βαθμίδα",
|
||||
"Plus Tier": "Βαθμίδα Plus",
|
||||
"Pro Tier": "Βαθμίδα Pro",
|
||||
"RTL Direction": "Κατεύθυνση RTL",
|
||||
"Maximum Column Height": "Μέγιστο ύψος στήλης",
|
||||
"Maximum Column Width": "Μέγιστο πλάτος στήλης",
|
||||
"Continuous Scroll": "Συνεχής κύλιση",
|
||||
"Fullscreen": "Πλήρης οθόνη",
|
||||
"No supported files found. Supported formats: {{formats}}": "Δεν βρέθηκαν υποστηριζόμενα αρχεία. Υποστηριζόμενες μορφές: {{formats}}",
|
||||
"Drop to Import Books": "Ρίξτε για εισαγωγή βιβλίων"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Coincidir mayúsculas y minúsculas",
|
||||
"Match Diacritics": "Coincidir diacríticos",
|
||||
"Match Whole Words": "Coincidir palabras completas",
|
||||
"Maximum Block Size": "Tamaño máximo de bloque",
|
||||
"Maximum Inline Size": "Tamaño máximo en línea",
|
||||
"Maximum Number of Columns": "Número máximo de columnas",
|
||||
"Minimum Font Size": "Tamaño de fuente mínimo",
|
||||
"Misc": "Varios",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Verificar código",
|
||||
"Sign in with Google": "Iniciar sesión con Google",
|
||||
"Sign in with Apple": "Iniciar sesión con Apple",
|
||||
"Sign in with GitHub": "Iniciar sesión con GitHub"
|
||||
"Sign in with GitHub": "Iniciar sesión con GitHub",
|
||||
"Account": "Cuenta",
|
||||
"Cloud Storage": "Almacenamiento en la nube",
|
||||
"Failed to delete user. Please try again later.": "Error al eliminar usuario. Por favor, inténtelo de nuevo más tarde.",
|
||||
"Unlimited Offline/Online Reading": "Lectura sin límites en línea/sin conexión",
|
||||
"Unlimited Cloud Sync Devices": "Sincronización ilimitada de dispositivos en la nube",
|
||||
"Essential Text-to-Speech Voices": "Voces esenciales de texto a voz",
|
||||
"DeepL Free Access": "Acceso gratuito a DeepL",
|
||||
"Community Support": "Soporte comunitario",
|
||||
"500 MB Cloud Sync Space": "500 MB de espacio de sincronización en la nube",
|
||||
"Includes All Free Tier Benefits": "Incluye todos los beneficios del nivel gratuito",
|
||||
"AI Summaries": "Resúmenes con IA",
|
||||
"AI Translations": "Traducciones con IA",
|
||||
"Priority Support": "Soporte prioritario",
|
||||
"DeepL Pro Access": "Acceso a DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Voces ampliadas de texto a voz",
|
||||
"2000 MB Cloud Sync Space": "2000 MB de espacio de sincronización en la nube",
|
||||
"Includes All Plus Tier Benefits": "Incluye todos los beneficios del nivel Plus",
|
||||
"Unlimited AI Summaries": "Resúmenes ilimitados con IA",
|
||||
"Unlimited AI Translations": "Traducciones ilimitadas con IA",
|
||||
"Unlimited DeepL Pro Access": "Acceso ilimitado a DeepL Pro",
|
||||
"Advanced AI Tools": "Herramientas avanzadas de IA",
|
||||
"Early Feature Access": "Acceso anticipado a funciones",
|
||||
"10 GB Cloud Sync Space": "10 GB de espacio de sincronización en la nube",
|
||||
"Loading profile...": "Cargando perfil...",
|
||||
"Features": "Características",
|
||||
"Delete Account": "Eliminar cuenta",
|
||||
"Delete Your Account?": "¿Eliminar tu cuenta?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Esta acción no se puede deshacer. Todos tus datos en la nube se eliminarán permanentemente.",
|
||||
"Delete Permanently": "Eliminar permanentemente",
|
||||
"Free Tier": "Nivel gratuito",
|
||||
"Plus Tier": "Nivel Plus",
|
||||
"Pro Tier": "Nivel Pro",
|
||||
"RTL Direction": "Dirección RTL",
|
||||
"Maximum Column Height": "Altura máxima de columna",
|
||||
"Maximum Column Width": "Ancho máximo de columna",
|
||||
"Continuous Scroll": "Desplazamiento continuo",
|
||||
"Fullscreen": "Pantalla completa",
|
||||
"No supported files found. Supported formats: {{formats}}": "No se encontraron archivos compatibles. Formatos compatibles: {{formats}}",
|
||||
"Drop to Import Books": "Soltar para importar libros"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Respecter la casse",
|
||||
"Match Diacritics": "Respecter les accents",
|
||||
"Match Whole Words": "Mots entiers",
|
||||
"Maximum Block Size": "Taille maximale du bloc",
|
||||
"Maximum Inline Size": "Taille maximale en ligne",
|
||||
"Maximum Number of Columns": "Nombre maximal de colonnes",
|
||||
"Minimum Font Size": "Taille minimale de police",
|
||||
"Misc": "Divers",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Vérifier le code",
|
||||
"Sign in with Google": "Se connecter avec Google",
|
||||
"Sign in with Apple": "Se connecter avec Apple",
|
||||
"Sign in with GitHub": "Se connecter avec GitHub"
|
||||
"Sign in with GitHub": "Se connecter avec GitHub",
|
||||
"Account": "Compte",
|
||||
"Cloud Storage": "Stockage cloud",
|
||||
"Failed to delete user. Please try again later.": "Échec de la suppression de l'utilisateur. Veuillez réessayer plus tard.",
|
||||
"Unlimited Offline/Online Reading": "Lecture illimitée hors ligne/en ligne",
|
||||
"Unlimited Cloud Sync Devices": "Synchronisation cloud sur appareils illimitée",
|
||||
"Essential Text-to-Speech Voices": "Voix de synthèse vocale essentielles",
|
||||
"DeepL Free Access": "Accès gratuit à DeepL",
|
||||
"Community Support": "Support communautaire",
|
||||
"500 MB Cloud Sync Space": "500 Mo d'espace de synchronisation cloud",
|
||||
"Includes All Free Tier Benefits": "Inclut tous les avantages du niveau gratuit",
|
||||
"AI Summaries": "Résumés par IA",
|
||||
"AI Translations": "Traductions par IA",
|
||||
"Priority Support": "Support prioritaire",
|
||||
"DeepL Pro Access": "Accès à DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Voix de synthèse vocale étendues",
|
||||
"2000 MB Cloud Sync Space": "2000 Mo d'espace de synchronisation cloud",
|
||||
"Includes All Plus Tier Benefits": "Inclut tous les avantages du niveau Plus",
|
||||
"Unlimited AI Summaries": "Résumés par IA illimités",
|
||||
"Unlimited AI Translations": "Traductions par IA illimitées",
|
||||
"Unlimited DeepL Pro Access": "Accès illimité à DeepL Pro",
|
||||
"Advanced AI Tools": "Outils IA avancés",
|
||||
"Early Feature Access": "Accès anticipé aux fonctionnalités",
|
||||
"10 GB Cloud Sync Space": "10 Go d'espace de synchronisation cloud",
|
||||
"Loading profile...": "Chargement du profil...",
|
||||
"Features": "Fonctionnalités",
|
||||
"Delete Account": "Supprimer le compte",
|
||||
"Delete Your Account?": "Supprimer votre compte ?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Cette action est irréversible. Toutes vos données dans le cloud seront définitivement supprimées.",
|
||||
"Delete Permanently": "Supprimer définitivement",
|
||||
"Free Tier": "Niveau gratuit",
|
||||
"Plus Tier": "Niveau Plus",
|
||||
"Pro Tier": "Niveau Pro",
|
||||
"RTL Direction": "Direction RTL",
|
||||
"Maximum Column Height": "Hauteur maximale de colonne",
|
||||
"Maximum Column Width": "Largeur maximale de colonne",
|
||||
"Continuous Scroll": "Défilement continu",
|
||||
"Fullscreen": "Plein écran",
|
||||
"No supported files found. Supported formats: {{formats}}": "Aucun fichier pris en charge trouvé. Formats pris en charge : {{formats}}",
|
||||
"Drop to Import Books": "Déposez pour importer des livres"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "केस मैच करें",
|
||||
"Match Diacritics": "डायाक्रिटिक्स मैच करें",
|
||||
"Match Whole Words": "पूरे शब्द मैच करें",
|
||||
"Maximum Block Size": "अधिकतम ब्लॉक साइज़",
|
||||
"Maximum Inline Size": "अधिकतम इनलाइन साइज़",
|
||||
"Maximum Number of Columns": "कॉलम की अधिकतम संख्या",
|
||||
"Minimum Font Size": "न्यूनतम फ़ॉन्ट साइज़",
|
||||
"Misc": "विविध",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "टोकन सत्यापित करें",
|
||||
"Sign in with Google": "Google के साथ साइन इन करें",
|
||||
"Sign in with Apple": "Apple के साथ साइन इन करें",
|
||||
"Sign in with GitHub": "GitHub के साथ साइन इन करें"
|
||||
"Sign in with GitHub": "GitHub के साथ साइन इन करें",
|
||||
"Account": "खाता",
|
||||
"Cloud Storage": "क्लाउड स्टोरेज",
|
||||
"Failed to delete user. Please try again later.": "उपयोगकर्ता को हटाने में विफल। कृपया बाद में पुनः प्रयास करें।",
|
||||
"Unlimited Offline/Online Reading": "असीमित ऑफलाइन/ऑनलाइन पढ़ना",
|
||||
"Unlimited Cloud Sync Devices": "असीमित क्लाउड सिंक उपकरण",
|
||||
"Essential Text-to-Speech Voices": "आवश्यक टेक्स्ट-टू-स्पीच आवाज़ें",
|
||||
"DeepL Free Access": "DeepL मुफ्त पहुंच",
|
||||
"Community Support": "सामुदायिक सहायता",
|
||||
"500 MB Cloud Sync Space": "500 MB क्लाउड सिंक स्पेस",
|
||||
"Includes All Free Tier Benefits": "सभी फ्री टियर लाभ शामिल हैं",
|
||||
"AI Summaries": "AI सारांश",
|
||||
"AI Translations": "AI अनुवाद",
|
||||
"Priority Support": "प्राथमिकता सहायता",
|
||||
"DeepL Pro Access": "DeepL प्रो पहुंच",
|
||||
"Expanded Text-to-Speech Voices": "विस्तारित टेक्स्ट-टू-स्पीच आवाज़ें",
|
||||
"2000 MB Cloud Sync Space": "2000 MB क्लाउड सिंक स्पेस",
|
||||
"Includes All Plus Tier Benefits": "सभी प्लस टियर लाभ शामिल हैं",
|
||||
"Unlimited AI Summaries": "असीमित AI सारांश",
|
||||
"Unlimited AI Translations": "असीमित AI अनुवाद",
|
||||
"Unlimited DeepL Pro Access": "असीमित DeepL प्रो पहुंच",
|
||||
"Advanced AI Tools": "उन्नत AI टूल्स",
|
||||
"Early Feature Access": "प्रारंभिक फीचर पहुंच",
|
||||
"10 GB Cloud Sync Space": "10 GB क्लाउड सिंक स्पेस",
|
||||
"Loading profile...": "प्रोफ़ाइल लोड हो रहा है...",
|
||||
"Features": "विशेषताएं",
|
||||
"Delete Account": "खाता हटाएं",
|
||||
"Delete Your Account?": "अपना खाता हटाएं?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "यह क्रिया पूर्ववत नहीं की जा सकती। क्लाउड में आपका सभी डेटा स्थायी रूप से हटा दिया जाएगा।",
|
||||
"Delete Permanently": "स्थायी रूप से हटाएं",
|
||||
"Free Tier": "फ्री टियर",
|
||||
"Plus Tier": "प्लस टियर",
|
||||
"Pro Tier": "प्रो टियर",
|
||||
"RTL Direction": "RTL दिशा",
|
||||
"Maximum Column Height": "कॉलम की अधिकतम ऊँचाई",
|
||||
"Maximum Column Width": "कॉलम की अधिकतम चौड़ाई",
|
||||
"Continuous Scroll": "निरंतर स्क्रॉल",
|
||||
"Fullscreen": "पूर्णस्क्रीन",
|
||||
"No supported files found. Supported formats: {{formats}}": "कोई समर्थित फ़ाइलें नहीं मिलीं। समर्थित प्रारूप: {{formats}}",
|
||||
"Drop to Import Books": "पुस्तकें आयात करने के लिए छोड़ें"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Cocokkan Huruf Besar/Kecil",
|
||||
"Match Diacritics": "Cocokkan Diakritik",
|
||||
"Match Whole Words": "Cocokkan Kata Utuh",
|
||||
"Maximum Block Size": "Ukuran Blok Maksimum",
|
||||
"Maximum Inline Size": "Ukuran Sebaris Maksimum",
|
||||
"Maximum Number of Columns": "Jumlah Kolom Maksimum",
|
||||
"Minimum Font Size": "Ukuran Font Minimum",
|
||||
"Misc": "Lain-lain",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Verifikasi token",
|
||||
"Sign in with Google": "Masuk dengan Google",
|
||||
"Sign in with Apple": "Masuk dengan Apple",
|
||||
"Sign in with GitHub": "Masuk dengan GitHub"
|
||||
"Sign in with GitHub": "Masuk dengan GitHub",
|
||||
"Account": "Akun",
|
||||
"Cloud Storage": "Penyimpanan Cloud",
|
||||
"Failed to delete user. Please try again later.": "Gagal menghapus pengguna. Silakan coba lagi nanti.",
|
||||
"Unlimited Offline/Online Reading": "Membaca Offline/Online Tanpa Batas",
|
||||
"Unlimited Cloud Sync Devices": "Perangkat Sinkronisasi Cloud Tanpa Batas",
|
||||
"Essential Text-to-Speech Voices": "Suara Text-to-Speech Esensial",
|
||||
"DeepL Free Access": "Akses DeepL Gratis",
|
||||
"Community Support": "Dukungan Komunitas",
|
||||
"500 MB Cloud Sync Space": "Ruang Sinkronisasi Cloud 500 MB",
|
||||
"Includes All Free Tier Benefits": "Termasuk Semua Manfaat Tingkat Gratis",
|
||||
"AI Summaries": "Ringkasan AI",
|
||||
"AI Translations": "Terjemahan AI",
|
||||
"Priority Support": "Dukungan Prioritas",
|
||||
"DeepL Pro Access": "Akses DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Suara Text-to-Speech yang Diperluas",
|
||||
"2000 MB Cloud Sync Space": "Ruang Sinkronisasi Cloud 2000 MB",
|
||||
"Includes All Plus Tier Benefits": "Termasuk Semua Manfaat Tingkat Plus",
|
||||
"Unlimited AI Summaries": "Ringkasan AI Tanpa Batas",
|
||||
"Unlimited AI Translations": "Terjemahan AI Tanpa Batas",
|
||||
"Unlimited DeepL Pro Access": "Akses DeepL Pro Tanpa Batas",
|
||||
"Advanced AI Tools": "Alat AI Lanjutan",
|
||||
"Early Feature Access": "Akses Fitur Awal",
|
||||
"10 GB Cloud Sync Space": "Ruang Sinkronisasi Cloud 10 GB",
|
||||
"Loading profile...": "Memuat profil...",
|
||||
"Features": "Fitur",
|
||||
"Delete Account": "Hapus Akun",
|
||||
"Delete Your Account?": "Hapus Akun Anda?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Tindakan ini tidak dapat dibatalkan. Semua data Anda di cloud akan dihapus secara permanen.",
|
||||
"Delete Permanently": "Hapus Secara Permanen",
|
||||
"Free Tier": "Tingkat Gratis",
|
||||
"Plus Tier": "Tingkat Plus",
|
||||
"Pro Tier": "Tingkat Pro",
|
||||
"RTL Direction": "Arah RTL",
|
||||
"Maximum Column Height": "Tinggi Kolom Maksimum",
|
||||
"Maximum Column Width": "Lebar Kolom Maksimum",
|
||||
"Continuous Scroll": "Gulir Terus",
|
||||
"Fullscreen": "Layar Penuh",
|
||||
"No supported files found. Supported formats: {{formats}}": "Tidak ada file yang didukung ditemukan. Format yang didukung: {{formats}}",
|
||||
"Drop to Import Books": "Jatuhkan untuk Mengimpor Buku"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Maiuscole/minuscole",
|
||||
"Match Diacritics": "Corrispondenza diacritici",
|
||||
"Match Whole Words": "Parole intere",
|
||||
"Maximum Block Size": "Dimensione massima blocco",
|
||||
"Maximum Inline Size": "Dimensione massima in linea",
|
||||
"Maximum Number of Columns": "Numero massimo di colonne",
|
||||
"Minimum Font Size": "Dimensione minima font",
|
||||
"Misc": "Varie",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Verifica token",
|
||||
"Sign in with Google": "Accedi con Google",
|
||||
"Sign in with Apple": "Accedi con Apple",
|
||||
"Sign in with GitHub": "Accedi con GitHub"
|
||||
"Sign in with GitHub": "Accedi con GitHub",
|
||||
"Account": "Account",
|
||||
"Cloud Storage": "Archiviazione Cloud",
|
||||
"Failed to delete user. Please try again later.": "Impossibile eliminare l'utente. Riprova più tardi.",
|
||||
"Unlimited Offline/Online Reading": "Lettura offline/online illimitata",
|
||||
"Unlimited Cloud Sync Devices": "Sincronizzazione cloud su dispositivi illimitati",
|
||||
"Essential Text-to-Speech Voices": "Voci essenziali di sintesi vocale",
|
||||
"DeepL Free Access": "Accesso gratuito a DeepL",
|
||||
"Community Support": "Supporto della community",
|
||||
"500 MB Cloud Sync Space": "500 MB di spazio di sincronizzazione cloud",
|
||||
"Includes All Free Tier Benefits": "Include tutti i vantaggi del piano gratuito",
|
||||
"AI Summaries": "Riassunti AI",
|
||||
"AI Translations": "Traduzioni AI",
|
||||
"Priority Support": "Supporto prioritario",
|
||||
"DeepL Pro Access": "Accesso a DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Voci di sintesi vocale ampliate",
|
||||
"2000 MB Cloud Sync Space": "2000 MB di spazio di sincronizzazione cloud",
|
||||
"Includes All Plus Tier Benefits": "Include tutti i vantaggi del piano Plus",
|
||||
"Unlimited AI Summaries": "Riassunti AI illimitati",
|
||||
"Unlimited AI Translations": "Traduzioni AI illimitate",
|
||||
"Unlimited DeepL Pro Access": "Accesso illimitato a DeepL Pro",
|
||||
"Advanced AI Tools": "Strumenti AI avanzati",
|
||||
"Early Feature Access": "Accesso anticipato alle funzionalità",
|
||||
"10 GB Cloud Sync Space": "10 GB di spazio di sincronizzazione cloud",
|
||||
"Loading profile...": "Caricamento profilo...",
|
||||
"Features": "Funzionalità",
|
||||
"Delete Account": "Elimina account",
|
||||
"Delete Your Account?": "Eliminare il tuo account?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Questa azione non può essere annullata. Tutti i tuoi dati nel cloud verranno eliminati definitivamente.",
|
||||
"Delete Permanently": "Elimina definitivamente",
|
||||
"Free Tier": "Piano gratuito",
|
||||
"Plus Tier": "Piano Plus",
|
||||
"Pro Tier": "Piano Pro",
|
||||
"RTL Direction": "Direzione RTL",
|
||||
"Maximum Column Height": "Altezza massima colonna",
|
||||
"Maximum Column Width": "Larghezza massima colonna",
|
||||
"Continuous Scroll": "Scorrimento continuo",
|
||||
"Fullscreen": "Schermo intero",
|
||||
"No supported files found. Supported formats: {{formats}}": "Nessun file supportato trovato. Formati supportati: {{formats}}",
|
||||
"Drop to Import Books": "Rilascia per importare libri"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "大文字・小文字を区別",
|
||||
"Match Diacritics": "アクセント記号を区別",
|
||||
"Match Whole Words": "単語全体を一致",
|
||||
"Maximum Block Size": "最大ブロックサイズ",
|
||||
"Maximum Inline Size": "最大インラインサイズ",
|
||||
"Maximum Number of Columns": "最大列数",
|
||||
"Minimum Font Size": "最小フォントサイズ",
|
||||
"Misc": "その他",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "トークンを確認",
|
||||
"Sign in with Google": "Googleでサインイン",
|
||||
"Sign in with Apple": "Appleでサインイン",
|
||||
"Sign in with GitHub": "GitHubでサインイン"
|
||||
"Sign in with GitHub": "GitHubでサインイン",
|
||||
"Account": "アカウント",
|
||||
"Cloud Storage": "クラウドストレージ",
|
||||
"Failed to delete user. Please try again later.": "ユーザーの削除に失敗しました。後ほど再試行してください。",
|
||||
"Unlimited Offline/Online Reading": "無制限のオフライン/オンライン読書",
|
||||
"Unlimited Cloud Sync Devices": "無制限のクラウド同期デバイス",
|
||||
"Essential Text-to-Speech Voices": "基本的な音声合成ボイス",
|
||||
"DeepL Free Access": "DeepL無料アクセス",
|
||||
"Community Support": "コミュニティサポート",
|
||||
"500 MB Cloud Sync Space": "500 MBのクラウド同期スペース",
|
||||
"Includes All Free Tier Benefits": "無料プランの全特典を含む",
|
||||
"AI Summaries": "AI要約",
|
||||
"AI Translations": "AI翻訳",
|
||||
"Priority Support": "優先サポート",
|
||||
"DeepL Pro Access": "DeepL Proアクセス",
|
||||
"Expanded Text-to-Speech Voices": "拡張された音声合成ボイス",
|
||||
"2000 MB Cloud Sync Space": "2000 MBのクラウド同期スペース",
|
||||
"Includes All Plus Tier Benefits": "Plusプランの全特典を含む",
|
||||
"Unlimited AI Summaries": "無制限のAI要約",
|
||||
"Unlimited AI Translations": "無制限のAI翻訳",
|
||||
"Unlimited DeepL Pro Access": "無制限のDeepL Proアクセス",
|
||||
"Advanced AI Tools": "高度なAIツール",
|
||||
"Early Feature Access": "新機能への早期アクセス",
|
||||
"10 GB Cloud Sync Space": "10 GBのクラウド同期スペース",
|
||||
"Loading profile...": "プロフィール読み込み中...",
|
||||
"Features": "機能",
|
||||
"Delete Account": "アカウント削除",
|
||||
"Delete Your Account?": "アカウントを削除しますか?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "この操作は元に戻せません。クラウド上のすべてのデータが完全に削除されます。",
|
||||
"Delete Permanently": "完全に削除",
|
||||
"Free Tier": "無料プラン",
|
||||
"Plus Tier": "Plusプラン",
|
||||
"Pro Tier": "Proプラン",
|
||||
"RTL Direction": "RTL方向",
|
||||
"Maximum Column Height": "最大列高",
|
||||
"Maximum Column Width": "最大列幅",
|
||||
"Continuous Scroll": "連続スクロール",
|
||||
"Fullscreen": "全画面",
|
||||
"No supported files found. Supported formats: {{formats}}": "サポートされているファイルが見つかりません。サポートされている形式:{{formats}}",
|
||||
"Drop to Import Books": "書籍をインポートするにはドロップ"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "대소문자 구분",
|
||||
"Match Diacritics": "발음 구별 부호 구분",
|
||||
"Match Whole Words": "전체 단어 일치",
|
||||
"Maximum Block Size": "최대 블록 크기",
|
||||
"Maximum Inline Size": "최대 인라인 크기",
|
||||
"Maximum Number of Columns": "최대 열 수",
|
||||
"Minimum Font Size": "최소 글꼴 크기",
|
||||
"Misc": "기타",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "토큰 확인",
|
||||
"Sign in with Google": "Google로 로그인",
|
||||
"Sign in with Apple": "Apple로 로그인",
|
||||
"Sign in with GitHub": "GitHub로 로그인"
|
||||
"Sign in with GitHub": "GitHub로 로그인",
|
||||
"Account": "계정",
|
||||
"Cloud Storage": "클라우드 스토리지",
|
||||
"Failed to delete user. Please try again later.": "사용자 삭제에 실패했습니다. 나중에 다시 시도해 주세요.",
|
||||
"Unlimited Offline/Online Reading": "무제한 오프라인/온라인 읽기",
|
||||
"Unlimited Cloud Sync Devices": "무제한 클라우드 동기화 기기",
|
||||
"Essential Text-to-Speech Voices": "필수 텍스트 음성 변환 목소리",
|
||||
"DeepL Free Access": "DeepL 무료 접근",
|
||||
"Community Support": "커뮤니티 지원",
|
||||
"500 MB Cloud Sync Space": "500 MB 클라우드 동기화 공간",
|
||||
"Includes All Free Tier Benefits": "무료 등급의 모든 혜택 포함",
|
||||
"AI Summaries": "AI 요약",
|
||||
"AI Translations": "AI 번역",
|
||||
"Priority Support": "우선 지원",
|
||||
"DeepL Pro Access": "DeepL 프로 접근",
|
||||
"Expanded Text-to-Speech Voices": "확장된 텍스트 음성 변환 목소리",
|
||||
"2000 MB Cloud Sync Space": "2000 MB 클라우드 동기화 공간",
|
||||
"Includes All Plus Tier Benefits": "플러스 등급의 모든 혜택 포함",
|
||||
"Unlimited AI Summaries": "무제한 AI 요약",
|
||||
"Unlimited AI Translations": "무제한 AI 번역",
|
||||
"Unlimited DeepL Pro Access": "무제한 DeepL 프로 접근",
|
||||
"Advanced AI Tools": "고급 AI 도구",
|
||||
"Early Feature Access": "신기능 조기 접근",
|
||||
"10 GB Cloud Sync Space": "10 GB 클라우드 동기화 공간",
|
||||
"Loading profile...": "프로필 로딩 중...",
|
||||
"Features": "기능",
|
||||
"Delete Account": "계정 삭제",
|
||||
"Delete Your Account?": "계정을 삭제하시겠습니까?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "이 작업은 취소할 수 없습니다. 클라우드에 있는 모든 데이터가 영구적으로 삭제됩니다.",
|
||||
"Delete Permanently": "영구 삭제",
|
||||
"Free Tier": "무료 등급",
|
||||
"Plus Tier": "플러스 등급",
|
||||
"Pro Tier": "프로 등급",
|
||||
"RTL Direction": "RTL 방향",
|
||||
"Maximum Column Height": "최대 열 높이",
|
||||
"Maximum Column Width": "최대 열 너비",
|
||||
"Continuous Scroll": "연속 스크롤",
|
||||
"Fullscreen": "전체 화면",
|
||||
"No supported files found. Supported formats: {{formats}}": "지원되는 파일이 없습니다. 지원되는 형식: {{formats}}",
|
||||
"Drop to Import Books": "책 가져오기 위해 드롭"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Uwzględnij wielkość liter",
|
||||
"Match Diacritics": "Uwzględnij znaki diakrytyczne",
|
||||
"Match Whole Words": "Uwzględnij całe słowa",
|
||||
"Maximum Block Size": "Maksymalny rozmiar bloku",
|
||||
"Maximum Inline Size": "Maksymalny rozmiar linii",
|
||||
"Maximum Number of Columns": "Maksymalna liczba kolumn",
|
||||
"Minimum Font Size": "Minimalny rozmiar czcionki",
|
||||
"Misc": "Różne",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Weryfikacja tokena",
|
||||
"Sign in with Google": "Zaloguj się za pomocą Google",
|
||||
"Sign in with Apple": "Zaloguj się za pomocą Apple",
|
||||
"Sign in with GitHub": "Zaloguj się za pomocą GitHub"
|
||||
"Sign in with GitHub": "Zaloguj się za pomocą GitHub",
|
||||
"Account": "Konto",
|
||||
"Cloud Storage": "Przestrzeń w chmurze",
|
||||
"Failed to delete user. Please try again later.": "Nie udało się usunąć użytkownika. Spróbuj ponownie później.",
|
||||
"Unlimited Offline/Online Reading": "Nieograniczone czytanie offline/online",
|
||||
"Unlimited Cloud Sync Devices": "Nieograniczona liczba urządzeń synchronizowanych w chmurze",
|
||||
"Essential Text-to-Speech Voices": "Podstawowe głosy zamiany tekstu na mowę",
|
||||
"DeepL Free Access": "Darmowy dostęp do DeepL",
|
||||
"Community Support": "Wsparcie społeczności",
|
||||
"500 MB Cloud Sync Space": "500 MB przestrzeni synchronizacji w chmurze",
|
||||
"Includes All Free Tier Benefits": "Zawiera wszystkie korzyści darmowego planu",
|
||||
"AI Summaries": "Podsumowania AI",
|
||||
"AI Translations": "Tłumaczenia AI",
|
||||
"Priority Support": "Priorytetowe wsparcie",
|
||||
"DeepL Pro Access": "Dostęp do DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Rozszerzone głosy zamiany tekstu na mowę",
|
||||
"2000 MB Cloud Sync Space": "2000 MB przestrzeni synchronizacji w chmurze",
|
||||
"Includes All Plus Tier Benefits": "Zawiera wszystkie korzyści planu Plus",
|
||||
"Unlimited AI Summaries": "Nieograniczone podsumowania AI",
|
||||
"Unlimited AI Translations": "Nieograniczone tłumaczenia AI",
|
||||
"Unlimited DeepL Pro Access": "Nieograniczony dostęp do DeepL Pro",
|
||||
"Advanced AI Tools": "Zaawansowane narzędzia AI",
|
||||
"Early Feature Access": "Wczesny dostęp do nowych funkcji",
|
||||
"10 GB Cloud Sync Space": "10 GB przestrzeni synchronizacji w chmurze",
|
||||
"Loading profile...": "Ładowanie profilu...",
|
||||
"Features": "Funkcje",
|
||||
"Delete Account": "Usuń konto",
|
||||
"Delete Your Account?": "Usunąć Twoje konto?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Tej operacji nie można cofnąć. Wszystkie Twoje dane w chmurze zostaną trwale usunięte.",
|
||||
"Delete Permanently": "Usuń trwale",
|
||||
"Free Tier": "Plan darmowy",
|
||||
"Plus Tier": "Plan Plus",
|
||||
"Pro Tier": "Plan Pro",
|
||||
"RTL Direction": "Kierunek RTL",
|
||||
"Maximum Column Height": "Maksymalna wysokość kolumny",
|
||||
"Maximum Column Width": "Maksymalna szerokość kolumny",
|
||||
"Continuous Scroll": "Przewijanie ciągłe",
|
||||
"Fullscreen": "Pełny ekran",
|
||||
"No supported files found. Supported formats: {{formats}}": "Nie znaleziono obsługiwanych plików. Obsługiwane formaty: {{formats}}",
|
||||
"Drop to Import Books": "Upuść, aby zaimportować książki"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Diferenciar Maiúsculas e Minúsculas",
|
||||
"Match Diacritics": "Corresponder Acentos",
|
||||
"Match Whole Words": "Corresponder Palavras Inteiras",
|
||||
"Maximum Block Size": "Tamanho Máximo do Bloco",
|
||||
"Maximum Inline Size": "Tamanho Máximo em Linha",
|
||||
"Maximum Number of Columns": "Número Máximo de Colunas",
|
||||
"Minimum Font Size": "Tamanho Mínimo da Fonte",
|
||||
"Misc": "Diversos",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Verificar token",
|
||||
"Sign in with Google": "Entrar com o Google",
|
||||
"Sign in with Apple": "Entrar com o Apple",
|
||||
"Sign in with GitHub": "Entrar com o GitHub"
|
||||
"Sign in with GitHub": "Entrar com o GitHub",
|
||||
"Account": "Conta",
|
||||
"Cloud Storage": "Armazenamento na nuvem",
|
||||
"Failed to delete user. Please try again later.": "Falha ao excluir usuário. Por favor, tente novamente mais tarde.",
|
||||
"Unlimited Offline/Online Reading": "Leitura offline/online ilimitada",
|
||||
"Unlimited Cloud Sync Devices": "Sincronização ilimitada de dispositivos na nuvem",
|
||||
"Essential Text-to-Speech Voices": "Vozes essenciais de texto para fala",
|
||||
"DeepL Free Access": "Acesso gratuito ao DeepL",
|
||||
"Community Support": "Suporte da comunidade",
|
||||
"500 MB Cloud Sync Space": "500 MB de espaço de sincronização na nuvem",
|
||||
"Includes All Free Tier Benefits": "Inclui todos os benefícios do plano gratuito",
|
||||
"AI Summaries": "Resumos de IA",
|
||||
"AI Translations": "Traduções de IA",
|
||||
"Priority Support": "Suporte prioritário",
|
||||
"DeepL Pro Access": "Acesso ao DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Vozes ampliadas de texto para fala",
|
||||
"2000 MB Cloud Sync Space": "2000 MB de espaço de sincronização na nuvem",
|
||||
"Includes All Plus Tier Benefits": "Inclui todos os benefícios do plano Plus",
|
||||
"Unlimited AI Summaries": "Resumos de IA ilimitados",
|
||||
"Unlimited AI Translations": "Traduções de IA ilimitadas",
|
||||
"Unlimited DeepL Pro Access": "Acesso ilimitado ao DeepL Pro",
|
||||
"Advanced AI Tools": "Ferramentas avançadas de IA",
|
||||
"Early Feature Access": "Acesso antecipado a recursos",
|
||||
"10 GB Cloud Sync Space": "10 GB de espaço de sincronização na nuvem",
|
||||
"Loading profile...": "Carregando perfil...",
|
||||
"Features": "Recursos",
|
||||
"Delete Account": "Excluir conta",
|
||||
"Delete Your Account?": "Excluir sua conta?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Esta ação não pode ser desfeita. Todos os seus dados na nuvem serão excluídos permanentemente.",
|
||||
"Delete Permanently": "Excluir permanentemente",
|
||||
"Free Tier": "Plano gratuito",
|
||||
"Plus Tier": "Plano Plus",
|
||||
"Pro Tier": "Plano Pro",
|
||||
"RTL Direction": "Direção RTL",
|
||||
"Maximum Column Height": "Altura Máxima da Coluna",
|
||||
"Maximum Column Width": "Largura Máxima da Coluna",
|
||||
"Continuous Scroll": "Rolagem Contínua",
|
||||
"Fullscreen": "Tela Cheia",
|
||||
"No supported files found. Supported formats: {{formats}}": "Nenhum arquivo suportado encontrado. Formatos suportados: {{formats}}",
|
||||
"Drop to Import Books": "Arraste para Importar Livros"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Учитывать регистр",
|
||||
"Match Diacritics": "Учитывать диакритические знаки",
|
||||
"Match Whole Words": "Только целые слова",
|
||||
"Maximum Block Size": "Максимальный размер блока",
|
||||
"Maximum Inline Size": "Максимальный размер строки",
|
||||
"Maximum Number of Columns": "Максимальное количество колонок",
|
||||
"Minimum Font Size": "Минимальный размер шрифта",
|
||||
"Misc": "Разное",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Проверить токен",
|
||||
"Sign in with Google": "Войти через Google",
|
||||
"Sign in with Apple": "Войти через Apple",
|
||||
"Sign in with GitHub": "Войти через GitHub"
|
||||
"Sign in with GitHub": "Войти через GitHub",
|
||||
"Account": "Аккаунт",
|
||||
"Cloud Storage": "Облачное хранилище",
|
||||
"Failed to delete user. Please try again later.": "Не удалось удалить пользователя. Пожалуйста, повторите попытку позже.",
|
||||
"Unlimited Offline/Online Reading": "Безлимитное чтение офлайн/онлайн",
|
||||
"Unlimited Cloud Sync Devices": "Неограниченное количество устройств для облачной синхронизации",
|
||||
"Essential Text-to-Speech Voices": "Основные голоса для преобразования текста в речь",
|
||||
"DeepL Free Access": "Бесплатный доступ к DeepL",
|
||||
"Community Support": "Поддержка сообщества",
|
||||
"500 MB Cloud Sync Space": "500 МБ облачного пространства для синхронизации",
|
||||
"Includes All Free Tier Benefits": "Включает все преимущества бесплатного тарифа",
|
||||
"AI Summaries": "AI-резюме",
|
||||
"AI Translations": "AI-переводы",
|
||||
"Priority Support": "Приоритетная поддержка",
|
||||
"DeepL Pro Access": "Доступ к DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Расширенный набор голосов для преобразования текста в речь",
|
||||
"2000 MB Cloud Sync Space": "2000 МБ облачного пространства для синхронизации",
|
||||
"Includes All Plus Tier Benefits": "Включает все преимущества тарифа Plus",
|
||||
"Unlimited AI Summaries": "Безлимитные AI-резюме",
|
||||
"Unlimited AI Translations": "Безлимитные AI-переводы",
|
||||
"Unlimited DeepL Pro Access": "Неограниченный доступ к DeepL Pro",
|
||||
"Advanced AI Tools": "Продвинутые AI-инструменты",
|
||||
"Early Feature Access": "Ранний доступ к новым функциям",
|
||||
"10 GB Cloud Sync Space": "10 ГБ облачного пространства для синхронизации",
|
||||
"Loading profile...": "Загрузка профиля...",
|
||||
"Features": "Функции",
|
||||
"Delete Account": "Удалить аккаунт",
|
||||
"Delete Your Account?": "Удалить ваш аккаунт?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Это действие нельзя отменить. Все ваши данные в облаке будут удалены безвозвратно.",
|
||||
"Delete Permanently": "Удалить навсегда",
|
||||
"Free Tier": "Бесплатный тариф",
|
||||
"Plus Tier": "Тариф Plus",
|
||||
"Pro Tier": "Тариф Pro",
|
||||
"RTL Direction": "Направление RTL",
|
||||
"Maximum Column Height": "Максимальная высота колонки",
|
||||
"Maximum Column Width": "Максимальная ширина колонки",
|
||||
"Continuous Scroll": "Непрерывная прокрутка",
|
||||
"Fullscreen": "Полноэкранный режим",
|
||||
"No supported files found. Supported formats: {{formats}}": "Поддерживаемые файлы не найдены. Поддерживаемые форматы: {{formats}}",
|
||||
"Drop to Import Books": "Перетащите сюда книги для импорта"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Büyük/Küçük Harf Eşleştir",
|
||||
"Match Diacritics": "Aksan İşaretlerini Eşleştir",
|
||||
"Match Whole Words": "Tam Kelimeleri Eşleştir",
|
||||
"Maximum Block Size": "Maksimum Blok Boyutu",
|
||||
"Maximum Inline Size": "Maksimum Satır İçi Boyut",
|
||||
"Maximum Number of Columns": "Maksimum Sütun Sayısı",
|
||||
"Minimum Font Size": "Minimum Yazı Boyutu",
|
||||
"Misc": "Diğer",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Token'ı doğrula",
|
||||
"Sign in with Google": "Google ile giriş yap",
|
||||
"Sign in with Apple": "Apple ile giriş yap",
|
||||
"Sign in with GitHub": "GitHub ile giriş yap"
|
||||
"Sign in with GitHub": "GitHub ile giriş yap",
|
||||
"Account": "Hesap",
|
||||
"Cloud Storage": "Bulut Depolama",
|
||||
"Failed to delete user. Please try again later.": "Kullanıcı silinemedi. Lütfen daha sonra tekrar deneyin.",
|
||||
"Unlimited Offline/Online Reading": "Sınırsız Çevrimdışı/Çevrimiçi Okuma",
|
||||
"Unlimited Cloud Sync Devices": "Sınırsız Bulut Senkronizasyon Cihazları",
|
||||
"Essential Text-to-Speech Voices": "Temel Metinden Sese Dönüştürme Sesleri",
|
||||
"DeepL Free Access": "DeepL Ücretsiz Erişim",
|
||||
"Community Support": "Topluluk Desteği",
|
||||
"500 MB Cloud Sync Space": "500 MB Bulut Senkronizasyon Alanı",
|
||||
"Includes All Free Tier Benefits": "Tüm Ücretsiz Paket Avantajlarını İçerir",
|
||||
"AI Summaries": "Yapay Zeka Özetleri",
|
||||
"AI Translations": "Yapay Zeka Çevirileri",
|
||||
"Priority Support": "Öncelikli Destek",
|
||||
"DeepL Pro Access": "DeepL Pro Erişimi",
|
||||
"Expanded Text-to-Speech Voices": "Genişletilmiş Metinden Sese Dönüştürme Sesleri",
|
||||
"2000 MB Cloud Sync Space": "2000 MB Bulut Senkronizasyon Alanı",
|
||||
"Includes All Plus Tier Benefits": "Tüm Plus Paket Avantajlarını İçerir",
|
||||
"Unlimited AI Summaries": "Sınırsız Yapay Zeka Özetleri",
|
||||
"Unlimited AI Translations": "Sınırsız Yapay Zeka Çevirileri",
|
||||
"Unlimited DeepL Pro Access": "Sınırsız DeepL Pro Erişimi",
|
||||
"Advanced AI Tools": "Gelişmiş Yapay Zeka Araçları",
|
||||
"Early Feature Access": "Erken Özellik Erişimi",
|
||||
"10 GB Cloud Sync Space": "10 GB Bulut Senkronizasyon Alanı",
|
||||
"Loading profile...": "Profil yükleniyor...",
|
||||
"Features": "Özellikler",
|
||||
"Delete Account": "Hesabı Sil",
|
||||
"Delete Your Account?": "Hesabınızı Silmek İstiyor musunuz?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Bu işlem geri alınamaz. Buluttaki tüm verileriniz kalıcı olarak silinecektir.",
|
||||
"Delete Permanently": "Kalıcı Olarak Sil",
|
||||
"Free Tier": "Ücretsiz Paket",
|
||||
"Plus Tier": "Plus Paket",
|
||||
"Pro Tier": "Pro Paket",
|
||||
"RTL Direction": "RTL Yönü",
|
||||
"Maximum Column Height": "Maksimum Sütun Yüksekliği",
|
||||
"Maximum Column Width": "Maksimum Sütun Genişliği",
|
||||
"Continuous Scroll": "Sürekli Kaydırma",
|
||||
"Fullscreen": "Tam Ekran",
|
||||
"No supported files found. Supported formats: {{formats}}": "Desteklenen dosya bulunamadı. Desteklenen formatlar: {{formats}}",
|
||||
"Drop to Import Books": "Kitapları İçe Aktarmak İçin Bırak"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Враховувати регістр",
|
||||
"Match Diacritics": "Враховувати діакритичні знаки",
|
||||
"Match Whole Words": "Шукати цілі слова",
|
||||
"Maximum Block Size": "Максимальний розмір блоку",
|
||||
"Maximum Inline Size": "Максимальний розмір рядка",
|
||||
"Maximum Number of Columns": "Максимальна кількість колонок",
|
||||
"Minimum Font Size": "Мінімальний розмір шрифту",
|
||||
"Misc": "Різне",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Перевірити токен",
|
||||
"Sign in with Google": "Увійти через Google",
|
||||
"Sign in with Apple": "Увійти через Apple",
|
||||
"Sign in with GitHub": "Увійти через GitHub"
|
||||
"Sign in with GitHub": "Увійти через GitHub",
|
||||
"Account": "Обліковий запис",
|
||||
"Cloud Storage": "Хмарне сховище",
|
||||
"Failed to delete user. Please try again later.": "Не вдалося видалити користувача. Будь ласка, спробуйте пізніше.",
|
||||
"Unlimited Offline/Online Reading": "Необмежене читання офлайн/онлайн",
|
||||
"Unlimited Cloud Sync Devices": "Необмежена кількість пристроїв для хмарної синхронізації",
|
||||
"Essential Text-to-Speech Voices": "Основні голоси для перетворення тексту в мовлення",
|
||||
"DeepL Free Access": "Безкоштовний доступ до DeepL",
|
||||
"Community Support": "Підтримка спільноти",
|
||||
"500 MB Cloud Sync Space": "500 МБ простору для хмарної синхронізації",
|
||||
"Includes All Free Tier Benefits": "Включає всі переваги безкоштовного тарифу",
|
||||
"AI Summaries": "AI-резюме",
|
||||
"AI Translations": "AI-переклади",
|
||||
"Priority Support": "Пріоритетна підтримка",
|
||||
"DeepL Pro Access": "Доступ до DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Розширений набір голосів для перетворення тексту в мовлення",
|
||||
"2000 MB Cloud Sync Space": "2000 МБ простору для хмарної синхронізації",
|
||||
"Includes All Plus Tier Benefits": "Включає всі переваги тарифу Plus",
|
||||
"Unlimited AI Summaries": "Необмежені AI-резюме",
|
||||
"Unlimited AI Translations": "Необмежені AI-переклади",
|
||||
"Unlimited DeepL Pro Access": "Необмежений доступ до DeepL Pro",
|
||||
"Advanced AI Tools": "Розширені AI-інструменти",
|
||||
"Early Feature Access": "Ранній доступ до нових функцій",
|
||||
"10 GB Cloud Sync Space": "10 ГБ простору для хмарної синхронізації",
|
||||
"Loading profile...": "Завантаження профілю...",
|
||||
"Features": "Функції",
|
||||
"Delete Account": "Видалити обліковий запис",
|
||||
"Delete Your Account?": "Видалити ваш обліковий запис?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Цю дію неможливо скасувати. Всі ваші дані в хмарі будуть повністю видалені.",
|
||||
"Delete Permanently": "Видалити назавжди",
|
||||
"Free Tier": "Безкоштовний тариф",
|
||||
"Plus Tier": "Тариф Plus",
|
||||
"Pro Tier": "Тариф Pro",
|
||||
"RTL Direction": "Напрямок RTL",
|
||||
"Maximum Column Height": "Максимальна висота колонки",
|
||||
"Maximum Column Width": "Максимальна ширина колонки",
|
||||
"Continuous Scroll": "Постійна прокрутка",
|
||||
"Fullscreen": "Повноекранний",
|
||||
"No supported files found. Supported formats: {{formats}}": "Не знайдено підтримуваних файлів. Підтримувані формати: {{formats}}",
|
||||
"Drop to Import Books": "Перетягніть для імпорту книг"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "Phân biệt chữ hoa/thường",
|
||||
"Match Diacritics": "Phân biệt dấu",
|
||||
"Match Whole Words": "Khớp toàn bộ từ",
|
||||
"Maximum Block Size": "Kích thước khối tối đa",
|
||||
"Maximum Inline Size": "Kích thước nội tuyến tối đa",
|
||||
"Maximum Number of Columns": "Số cột tối đa",
|
||||
"Minimum Font Size": "Cỡ chữ tối thiểu",
|
||||
"Misc": "Khác",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "Xác minh mã token",
|
||||
"Sign in with Google": "Đăng nhập với Google",
|
||||
"Sign in with Apple": "Đăng nhập với Apple",
|
||||
"Sign in with GitHub": "Đăng nhập với GitHub"
|
||||
"Sign in with GitHub": "Đăng nhập với GitHub",
|
||||
"Account": "Tài khoản",
|
||||
"Cloud Storage": "Lưu trữ đám mây",
|
||||
"Failed to delete user. Please try again later.": "Không thể xóa người dùng. Vui lòng thử lại sau.",
|
||||
"Unlimited Offline/Online Reading": "Đọc ngoại tuyến/trực tuyến không giới hạn",
|
||||
"Unlimited Cloud Sync Devices": "Đồng bộ hóa thiết bị đám mây không giới hạn",
|
||||
"Essential Text-to-Speech Voices": "Giọng nói chuyển văn bản thành giọng nói cơ bản",
|
||||
"DeepL Free Access": "Truy cập DeepL miễn phí",
|
||||
"Community Support": "Hỗ trợ cộng đồng",
|
||||
"500 MB Cloud Sync Space": "500 MB không gian đồng bộ hóa đám mây",
|
||||
"Includes All Free Tier Benefits": "Bao gồm tất cả lợi ích của gói miễn phí",
|
||||
"AI Summaries": "Tóm tắt AI",
|
||||
"AI Translations": "Dịch thuật AI",
|
||||
"Priority Support": "Hỗ trợ ưu tiên",
|
||||
"DeepL Pro Access": "Truy cập DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Giọng nói chuyển văn bản thành giọng nói mở rộng",
|
||||
"2000 MB Cloud Sync Space": "2000 MB không gian đồng bộ hóa đám mây",
|
||||
"Includes All Plus Tier Benefits": "Bao gồm tất cả lợi ích của gói Plus",
|
||||
"Unlimited AI Summaries": "Tóm tắt AI không giới hạn",
|
||||
"Unlimited AI Translations": "Dịch thuật AI không giới hạn",
|
||||
"Unlimited DeepL Pro Access": "Truy cập DeepL Pro không giới hạn",
|
||||
"Advanced AI Tools": "Công cụ AI nâng cao",
|
||||
"Early Feature Access": "Truy cập tính năng sớm",
|
||||
"10 GB Cloud Sync Space": "10 GB không gian đồng bộ hóa đám mây",
|
||||
"Loading profile...": "Đang tải hồ sơ...",
|
||||
"Features": "Tính năng",
|
||||
"Delete Account": "Xóa tài khoản",
|
||||
"Delete Your Account?": "Xóa tài khoản của bạn?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Hành động này không thể hoàn tác. Tất cả dữ liệu của bạn trên đám mây sẽ bị xóa vĩnh viễn.",
|
||||
"Delete Permanently": "Xóa vĩnh viễn",
|
||||
"Free Tier": "Gói miễn phí",
|
||||
"Plus Tier": "Gói Plus",
|
||||
"Pro Tier": "Gói Pro",
|
||||
"RTL Direction": "Hướng RTL",
|
||||
"Maximum Column Height": "Chiều cao cột tối đa",
|
||||
"Maximum Column Width": "Chiều rộng cột tối đa",
|
||||
"Continuous Scroll": "Cuộn liên tục",
|
||||
"Fullscreen": "Toàn màn hình",
|
||||
"No supported files found. Supported formats: {{formats}}": "Không tìm thấy tệp được hỗ trợ. Định dạng được hỗ trợ: {{formats}}",
|
||||
"Drop to Import Books": "Kéo và thả để nhập sách"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "匹配大小写",
|
||||
"Match Diacritics": "匹配重音符号",
|
||||
"Match Whole Words": "匹配整个单词",
|
||||
"Maximum Block Size": "内容最大宽度",
|
||||
"Maximum Inline Size": "每栏最大宽度",
|
||||
"Maximum Number of Columns": "分栏数",
|
||||
"Minimum Font Size": "最小字号",
|
||||
"Misc": "杂项",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "验证令牌",
|
||||
"Sign in with Google": "使用 Google 登录",
|
||||
"Sign in with Apple": "使用 Apple 登录",
|
||||
"Sign in with GitHub": "使用 GitHub 登录"
|
||||
"Sign in with GitHub": "使用 GitHub 登录",
|
||||
"Account": "账户",
|
||||
"Cloud Storage": "云存储",
|
||||
"Failed to delete user. Please try again later.": "删除用户失败。请稍后重试。",
|
||||
"Unlimited Offline/Online Reading": "无限离线和在线阅读",
|
||||
"Unlimited Cloud Sync Devices": "无限云同步设备个数",
|
||||
"Essential Text-to-Speech Voices": "基础 TTS 语音包",
|
||||
"DeepL Free Access": "免费版 DeepL 翻译",
|
||||
"Community Support": "社区支持",
|
||||
"500 MB Cloud Sync Space": "500 MB 云同步空间",
|
||||
"Includes All Free Tier Benefits": "包含所有免费版权益",
|
||||
"AI Summaries": "AI 摘要",
|
||||
"AI Translations": "AI 翻译",
|
||||
"Priority Support": "优先支持",
|
||||
"DeepL Pro Access": "专业版 DeepL 翻译",
|
||||
"Expanded Text-to-Speech Voices": "更多文本转语音声音",
|
||||
"2000 MB Cloud Sync Space": "2000 MB 云同步空间",
|
||||
"Includes All Plus Tier Benefits": "包含所有增强版权益",
|
||||
"Unlimited AI Summaries": "无限使用 AI 摘要",
|
||||
"Unlimited AI Translations": "无限使用 AI 翻译",
|
||||
"Unlimited DeepL Pro Access": "无限使用专业版 DeepL 翻译",
|
||||
"Advanced AI Tools": "高级 AI 工具",
|
||||
"Early Feature Access": "抢先体验新功能",
|
||||
"10 GB Cloud Sync Space": "10 GB 云同步空间",
|
||||
"Loading profile...": "加载个人资料中...",
|
||||
"Features": "功能特性",
|
||||
"Delete Account": "删除账户",
|
||||
"Delete Your Account?": "删除您的账户?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "此操作无法撤销。您在云端的所有数据将被永久删除。",
|
||||
"Delete Permanently": "永久删除",
|
||||
"Free Tier": "免费版",
|
||||
"Plus Tier": "增强版",
|
||||
"Pro Tier": "专业版",
|
||||
"RTL Direction": "从右向左",
|
||||
"Maximum Column Height": "最大列高",
|
||||
"Maximum Column Width": "最大列宽",
|
||||
"Continuous Scroll": "连续滚动",
|
||||
"Fullscreen": "全屏",
|
||||
"No supported files found. Supported formats: {{formats}}": "未找到支持的文件。支持的格式:{{formats}}",
|
||||
"Drop to Import Books": "拖放导入书籍"
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"Match Case": "匹配大小寫",
|
||||
"Match Diacritics": "匹配重音符號",
|
||||
"Match Whole Words": "匹配整個單詞",
|
||||
"Maximum Block Size": "內容最大寬度",
|
||||
"Maximum Inline Size": "每欄最大寬度",
|
||||
"Maximum Number of Columns": "分欄數",
|
||||
"Minimum Font Size": "最小字號",
|
||||
"Misc": "雜項",
|
||||
@@ -198,5 +196,44 @@
|
||||
"Verify token": "驗證令牌",
|
||||
"Sign in with Google": "使用 Google 登入",
|
||||
"Sign in with Apple": "使用 Apple 登入",
|
||||
"Sign in with GitHub": "使用 GitHub 登入"
|
||||
"Sign in with GitHub": "使用 GitHub 登入",
|
||||
"Account": "帳戶",
|
||||
"Cloud Storage": "雲端儲存空間",
|
||||
"Failed to delete user. Please try again later.": "刪除使用者失敗。請稍後再試。",
|
||||
"Unlimited Offline/Online Reading": "無限離線/線上閱讀",
|
||||
"Unlimited Cloud Sync Devices": "無限雲端同步裝置",
|
||||
"Essential Text-to-Speech Voices": "基本文字轉語音聲音",
|
||||
"DeepL Free Access": "DeepL免費存取",
|
||||
"Community Support": "社群支援",
|
||||
"500 MB Cloud Sync Space": "500 MB雲端同步空間",
|
||||
"Includes All Free Tier Benefits": "包含所有免費方案權益",
|
||||
"AI Summaries": "AI摘要",
|
||||
"AI Translations": "AI翻譯",
|
||||
"Priority Support": "優先支援",
|
||||
"DeepL Pro Access": "DeepL專業版存取",
|
||||
"Expanded Text-to-Speech Voices": "擴展文字轉語音聲音",
|
||||
"2000 MB Cloud Sync Space": "2000 MB雲端同步空間",
|
||||
"Includes All Plus Tier Benefits": "包含所有Plus方案權益",
|
||||
"Unlimited AI Summaries": "無限AI摘要",
|
||||
"Unlimited AI Translations": "無限AI翻譯",
|
||||
"Unlimited DeepL Pro Access": "無限DeepL專業版存取",
|
||||
"Advanced AI Tools": "進階AI工具",
|
||||
"Early Feature Access": "搶先體驗新功能",
|
||||
"10 GB Cloud Sync Space": "10 GB雲端同步空間",
|
||||
"Loading profile...": "載入個人資料中...",
|
||||
"Features": "功能",
|
||||
"Delete Account": "刪除帳戶",
|
||||
"Delete Your Account?": "刪除您的帳戶?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "此操作無法撤銷。您在雲端的所有資料將被永久刪除。",
|
||||
"Delete Permanently": "永久刪除",
|
||||
"Free Tier": "免費方案",
|
||||
"Plus Tier": "Plus方案",
|
||||
"Pro Tier": "專業方案",
|
||||
"RTL Direction": "從右向左",
|
||||
"Maximum Column Height": "最大列高",
|
||||
"Maximum Column Width": "最大列寬",
|
||||
"Continuous Scroll": "連續滾動",
|
||||
"Fullscreen": "全螢幕",
|
||||
"No supported files found. Supported formats: {{formats}}": "未找到支援的檔案。支援的格式:{{formats}}",
|
||||
"Drop to Import Books": "拖放導入書籍"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.21": {
|
||||
"date": "2025-03-10",
|
||||
"notes": [
|
||||
"Fix column height in vertical layout on mobile",
|
||||
"Fix drag handle height not constant on mobile",
|
||||
"Add fullscreen option on desktop",
|
||||
"Add drag and drop to import books on desktop",
|
||||
"Various fixes and enhancements on updater, footerbar and note"
|
||||
]
|
||||
},
|
||||
"0.9.20": {
|
||||
"date": "2025-03-10",
|
||||
"notes": [
|
||||
"Fix column height in vertical layout on mobile",
|
||||
"Fix drag handle height not constant on mobile",
|
||||
"Add fullscreen option on desktop",
|
||||
"Add drag and drop to import books on desktop",
|
||||
"Various fixes and enhancements on updater, footerbar and note"
|
||||
]
|
||||
},
|
||||
"0.9.19": {
|
||||
"date": "2025-03-07",
|
||||
"notes": [
|
||||
"Support custom CSS for Reader UI",
|
||||
"Initial support of RTL layout for Arabic and Hebrew books",
|
||||
"Various fixes and enhancements on layout and sync"
|
||||
]
|
||||
},
|
||||
"0.9.18": {
|
||||
"date": "2025-02-26",
|
||||
"notes": [
|
||||
"Add user profile page and option to delete account in the cloud",
|
||||
"Fix TTS failed to start with invalid book language",
|
||||
"Enhancements on iOS with modals and annotation tools",
|
||||
"UX enhancements with modern highlights colors"
|
||||
]
|
||||
},
|
||||
"0.9.17": {
|
||||
"date": "2025-02-24",
|
||||
"notes": [
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"shell:default",
|
||||
"process:default",
|
||||
"process:allow-exit",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/.tauri
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.plugin.safari_auth"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("androidx.core:core-ktx:1.9.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.0")
|
||||
implementation("com.google.android.material:material:1.7.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
implementation(project(":tauri-android"))
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,31 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
}
|
||||
resolutionStrategy {
|
||||
eachPlugin {
|
||||
switch (requested.id.id) {
|
||||
case "com.android.library":
|
||||
useVersion("8.0.2")
|
||||
break
|
||||
case "org.jetbrains.kotlin.android":
|
||||
useVersion("1.8.20")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
include ':tauri-android'
|
||||
project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.plugin.safari_auth
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.plugin.safari_auth", appContext.packageName)
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.plugin.safari_auth
|
||||
|
||||
import android.util.Log
|
||||
|
||||
class Example {
|
||||
fun pong(value: String): String {
|
||||
Log.i("Pong", value)
|
||||
return value
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.plugin.safari_auth
|
||||
|
||||
import android.app.Activity
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.JSObject
|
||||
import app.tauri.plugin.Plugin
|
||||
import app.tauri.plugin.Invoke
|
||||
|
||||
@InvokeArg
|
||||
class PingArgs {
|
||||
var value: String? = null
|
||||
}
|
||||
|
||||
@TauriPlugin
|
||||
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
|
||||
private val implementation = Example()
|
||||
|
||||
@Command
|
||||
fun ping(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(PingArgs::class.java)
|
||||
|
||||
val ret = JSObject()
|
||||
ret.put("value", implementation.pong(args.value ?: "default value :("))
|
||||
invoke.resolve(ret)
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.plugin.safari_auth
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,11 @@ pub fn init<R: Runtime, C: DeserializeOwned>(
|
||||
_app: &AppHandle<R>,
|
||||
api: PluginApi<R, C>,
|
||||
) -> crate::Result<SafariAuth<R>> {
|
||||
#[cfg(target_os = "android")]
|
||||
let handle = api.register_android_plugin("com.bilingify.safari_auth", "ExamplePlugin")?;
|
||||
#[cfg(target_os = "ios")]
|
||||
let handle = api.register_ios_plugin(init_plugin_safari_auth)?;
|
||||
|
||||
Ok(SafariAuth(handle))
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ extern crate objc;
|
||||
mod menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod traffic_light;
|
||||
#[cfg(target_os = "macos")]
|
||||
use traffic_light::set_traffic_lights;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::TitleBarStyle;
|
||||
@@ -98,6 +100,8 @@ pub fn run() {
|
||||
start_server,
|
||||
download_file,
|
||||
upload_file,
|
||||
#[cfg(target_os = "macos")]
|
||||
set_traffic_lights,
|
||||
#[cfg(desktop)]
|
||||
list_fonts
|
||||
])
|
||||
@@ -106,8 +110,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_safari_auth::init());
|
||||
.plugin(tauri_plugin_fs::init());
|
||||
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
|
||||
@@ -133,6 +136,9 @@ pub fn run() {
|
||||
#[cfg(target_os = "ios")]
|
||||
let builder = builder.plugin(tauri_plugin_sign_in_with_apple::init());
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
let builder = builder.plugin(tauri_plugin_safari_auth::init());
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||
let builder = builder.plugin(tauri_plugin_haptics::init());
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use objc::{msg_send, sel, sel_impl};
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use tauri::Emitter;
|
||||
use tauri::{
|
||||
command,
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Runtime, Window,
|
||||
}; // 0.8
|
||||
Emitter, Runtime, Window,
|
||||
};
|
||||
|
||||
const WINDOW_CONTROL_PAD_X: f64 = 10.0;
|
||||
const WINDOW_CONTROL_PAD_Y: f64 = 22.0;
|
||||
static mut WINDOW_CONTROL_PAD_X: f64 = 10.0;
|
||||
static mut WINDOW_CONTROL_PAD_Y: f64 = 22.0;
|
||||
static mut TRAFFIC_LIGHTS_VISIBLE: bool = true;
|
||||
|
||||
struct UnsafeWindowHandle(*mut std::ffi::c_void);
|
||||
unsafe impl Send for UnsafeWindowHandle {}
|
||||
@@ -30,7 +31,24 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64) {
|
||||
#[command]
|
||||
pub fn set_traffic_lights(window: Window, visible: bool, x: f64, y: f64) {
|
||||
unsafe {
|
||||
TRAFFIC_LIGHTS_VISIBLE = visible;
|
||||
WINDOW_CONTROL_PAD_X = x;
|
||||
WINDOW_CONTROL_PAD_Y = y;
|
||||
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, visible: bool, x: f64, y: f64) {
|
||||
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
|
||||
use cocoa::foundation::NSRect;
|
||||
let ns_window = ns_window_handle.0 as cocoa::base::id;
|
||||
@@ -45,7 +63,10 @@ fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64)
|
||||
let close_rect: NSRect = msg_send![close, frame];
|
||||
let button_height = close_rect.size.height;
|
||||
|
||||
let title_bar_frame_height = button_height + y;
|
||||
let mut title_bar_frame_height = button_height + y;
|
||||
if !visible {
|
||||
title_bar_frame_height = 0.0;
|
||||
}
|
||||
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
||||
title_bar_rect.size.height = title_bar_frame_height;
|
||||
title_bar_rect.origin.y = NSView::frame(ns_window).size.height - title_bar_frame_height;
|
||||
@@ -77,11 +98,14 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
use std::ffi::c_void;
|
||||
|
||||
// Do the initial positioning
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
unsafe {
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure they stay in place while resizing the window.
|
||||
fn with_window_state<R: Runtime, F: FnOnce(&mut WindowState<R>) -> T, T>(
|
||||
@@ -127,6 +151,7 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
#[cfg(target_os = "macos")]
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
@@ -258,6 +283,7 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
let id = state.window.ns_window().expect("Failed to emit event") as id;
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function AuthErrorPage() {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
router.push('/auth');
|
||||
}, 5000);
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [router]);
|
||||
@@ -20,10 +20,6 @@ export default function AuthErrorPage() {
|
||||
<div className='bg-base-200/50 text-base-content hero h-screen items-center justify-center'>
|
||||
<div className='hero-content text-neutral-content text-center'>
|
||||
<div className='max-w-md'>
|
||||
<h1 className='mb-5 text-5xl font-bold'>Authentication Error</h1>
|
||||
<p className='mb-5'>
|
||||
Something went wrong during the authentication process. Please try again.
|
||||
</p>
|
||||
<p className='mb-5'>You will be redirected to the login page shortly...</p>
|
||||
<button className='btn btn-primary rounded-xl' onClick={() => router.push('/auth')}>
|
||||
Go to Login
|
||||
|
||||
@@ -12,8 +12,8 @@ import { IoArrowBack } from 'react-icons/io5';
|
||||
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
@@ -61,7 +61,7 @@ export default function AuthPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { isDarkMode } = useTheme();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [port, setPort] = useState<number | null>(null);
|
||||
const isOAuthServerRunning = useRef(false);
|
||||
@@ -129,7 +129,7 @@ export default function AuthPage() {
|
||||
};
|
||||
|
||||
const handleOAuthUrl = async (url: string) => {
|
||||
console.log('Received OAuth URL:', url);
|
||||
console.log('Handle OAuth URL:', url);
|
||||
const hashMatch = url.match(/#(.*)/);
|
||||
if (hashMatch) {
|
||||
const hash = hashMatch[1];
|
||||
@@ -332,20 +332,16 @@ export default function AuthPage() {
|
||||
Icon={FaGithub}
|
||||
label={_('Sign in with GitHub')}
|
||||
/>
|
||||
{!appService?.isIOSApp && (
|
||||
<div>
|
||||
<hr className='my-3 mt-6 w-64 border-t border-gray-200' />
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={true}
|
||||
providers={[]}
|
||||
redirectTo={getTauriRedirectTo(false)}
|
||||
localization={getAuthLocalization()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<hr className='my-3 mt-6 w-64 border-t border-gray-200' />
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={true}
|
||||
providers={[]}
|
||||
redirectTo={getTauriRedirectTo(false)}
|
||||
localization={getAuthLocalization()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -21,9 +21,9 @@ interface BookshelfProps {
|
||||
libraryBooks: Book[];
|
||||
isSelectMode: boolean;
|
||||
handleImportBooks: () => void;
|
||||
handleBookUpload: (book: Book) => void;
|
||||
handleBookDownload: (book: Book) => void;
|
||||
handleBookDelete: (book: Book) => void;
|
||||
handleBookUpload: (book: Book) => Promise<boolean>;
|
||||
handleBookDownload: (book: Book) => Promise<boolean>;
|
||||
handleBookDelete: (book: Book) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
@@ -143,7 +143,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
<div
|
||||
className={clsx(
|
||||
'transform-wrapper grid flex-1 gap-x-4 sm:gap-x-0',
|
||||
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8',
|
||||
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
|
||||
)}
|
||||
>
|
||||
{currentBookshelfItems.map((item, index) => (
|
||||
|
||||
@@ -72,9 +72,9 @@ interface BookshelfItemProps {
|
||||
transferProgress: number | null;
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
toggleSelection: (hash: string) => void;
|
||||
handleBookUpload: (book: Book) => void;
|
||||
handleBookDownload: (book: Book) => void;
|
||||
handleBookDelete: (book: Book) => void;
|
||||
handleBookUpload: (book: Book) => Promise<boolean>;
|
||||
handleBookDownload: (book: Book) => Promise<boolean>;
|
||||
handleBookDelete: (book: Book) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
}
|
||||
@@ -109,9 +109,8 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
let available = false;
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 200);
|
||||
try {
|
||||
await handleBookDownload(book);
|
||||
available = await handleBookDownload(book);
|
||||
updateBook(envConfig, book);
|
||||
available = true;
|
||||
} finally {
|
||||
if (loadingTimeout) clearTimeout(loadingTimeout);
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
@@ -10,8 +10,8 @@ import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
@@ -33,7 +33,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { appService } = useEnv();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
setTrafficLightVisibility,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize20 = useResponsiveSize(20);
|
||||
@@ -42,6 +49,18 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
onToggleSelectMode,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
setTrafficLightVisibility(true);
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
const isInGroupView = !!searchParams?.get('group');
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getStoragePlanData } from '@/utils/access';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
|
||||
import { tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { QuotaType } from '@/types/user';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Quota from '@/components/Quota';
|
||||
@@ -25,8 +26,8 @@ interface BookMenuProps {
|
||||
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig } = useEnv();
|
||||
const { token, user, logout } = useAuth();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
@@ -47,11 +48,8 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleUserLogout = () => {
|
||||
logout();
|
||||
settings.keepLogin = false;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
const handleUserProfile = () => {
|
||||
navigateToProfile(router);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
@@ -60,6 +58,11 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleFullScreen = () => {
|
||||
tauriHandleToggleFullScreen();
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const toggleAutoUploadBooks = () => {
|
||||
settings.autoUpload = !settings.autoUpload;
|
||||
setSettings(settings);
|
||||
@@ -135,8 +138,8 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
<Quota quotas={quotas} />
|
||||
<MenuItem label={_('Sign Out')} noIcon onClick={handleUserLogout} />
|
||||
<Quota quotas={quotas} className='h-10 pl-4 pr-2' />
|
||||
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
|
||||
</ul>
|
||||
</MenuItem>
|
||||
) : (
|
||||
@@ -160,6 +163,9 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
onClick={toggleScreenWakeLock}
|
||||
/>
|
||||
<hr className='border-base-200 my-1' />
|
||||
{appService?.hasRoundedWindow && (
|
||||
<MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />
|
||||
)}
|
||||
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService } from '@/types/system';
|
||||
@@ -17,17 +17,20 @@ import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
|
||||
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useDemoBooks } from './hooks/useDemoBooks';
|
||||
import { useBooksSync } from './hooks/useBooksSync';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { tauriQuitApp } from '@/utils/window';
|
||||
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
@@ -37,8 +40,14 @@ import LibraryHeader from './components/LibraryHeader';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import BookDetailModal from '@/components/BookDetailModal';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import DropIndicator from '@/components/DropIndicator';
|
||||
|
||||
const LibraryPage = () => {
|
||||
const LibraryPageWithSearchParams = () => {
|
||||
const searchParams = useSearchParams();
|
||||
return <LibraryPageContent searchParams={searchParams} />;
|
||||
};
|
||||
|
||||
const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchParams | null }) => {
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
@@ -47,10 +56,11 @@ const LibraryPage = () => {
|
||||
updateBook,
|
||||
setLibrary,
|
||||
checkOpenWithBooks,
|
||||
clearOpenWithBooks,
|
||||
setCheckOpenWithBooks,
|
||||
} = useLibraryStore();
|
||||
const _ = useTranslation();
|
||||
const { updateAppTheme } = useTheme();
|
||||
useTheme();
|
||||
const { updateAppTheme } = useThemeStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
@@ -60,8 +70,12 @@ const LibraryPage = () => {
|
||||
const [booksTransferProgress, setBooksTransferProgress] = useState<{
|
||||
[key: string]: number | null;
|
||||
}>({});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const demoBooks = useDemoBooks();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOpenWithBooks();
|
||||
|
||||
const { pullLibrary, pushLibrary } = useBooksSync({
|
||||
onSyncStart: () => setLoading(true),
|
||||
@@ -94,6 +108,87 @@ const LibraryPage = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings]);
|
||||
|
||||
const handleDropedFiles = async (files: File[] | string[]) => {
|
||||
if (files.length === 0) return;
|
||||
const supportedFiles = files.filter((file) => {
|
||||
let fileExt;
|
||||
if (typeof file === 'string') {
|
||||
fileExt = file.split('.').pop()?.toLowerCase();
|
||||
} else {
|
||||
fileExt = file.name.split('.').pop()?.toLowerCase();
|
||||
}
|
||||
return FILE_ACCEPT_FORMATS.includes(`.${fileExt}`);
|
||||
});
|
||||
if (supportedFiles.length === 0) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('No supported files found. Supported formats: {{formats}}', {
|
||||
formats: FILE_ACCEPT_FORMATS,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
|
||||
await importBooks(supportedFiles);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
handleDropedFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const libraryPage = document.querySelector('.library-page');
|
||||
libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener);
|
||||
libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener);
|
||||
libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener);
|
||||
|
||||
if (isTauriAppPlatform()) {
|
||||
const unlisten = getCurrentWebview().onDragDropEvent((event) => {
|
||||
if (event.payload.type === 'over') {
|
||||
setIsDragging(true);
|
||||
} else if (event.payload.type === 'drop') {
|
||||
setIsDragging(false);
|
||||
handleDropedFiles(event.payload.paths);
|
||||
} else {
|
||||
setIsDragging(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener);
|
||||
libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener);
|
||||
libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageRef.current]);
|
||||
|
||||
const processOpenWithFiles = React.useCallback(
|
||||
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
|
||||
const bookIds: string[] = [];
|
||||
@@ -113,7 +208,9 @@ const LibraryPage = () => {
|
||||
|
||||
console.log('Opening books:', bookIds);
|
||||
if (bookIds.length > 0) {
|
||||
navigateToReader(router, bookIds);
|
||||
setTimeout(() => {
|
||||
navigateToReader(router, bookIds);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -148,7 +245,7 @@ const LibraryPage = () => {
|
||||
if (checkOpenWithBooks && isTauriAppPlatform()) {
|
||||
await handleOpenWithBooks(appService, libraryBooks);
|
||||
} else {
|
||||
clearOpenWithBooks();
|
||||
setCheckOpenWithBooks(false);
|
||||
setLibrary(libraryBooks);
|
||||
}
|
||||
|
||||
@@ -163,7 +260,7 @@ const LibraryPage = () => {
|
||||
if (openWithFiles.length > 0) {
|
||||
await processOpenWithFiles(appService, openWithFiles, libraryBooks);
|
||||
} else {
|
||||
clearOpenWithBooks();
|
||||
setCheckOpenWithBooks(false);
|
||||
setLibrary(libraryBooks);
|
||||
}
|
||||
};
|
||||
@@ -171,10 +268,11 @@ const LibraryPage = () => {
|
||||
initLogin();
|
||||
initLibrary();
|
||||
return () => {
|
||||
clearOpenWithBooks();
|
||||
setCheckOpenWithBooks(false);
|
||||
isInitiating.current = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (demoBooks.length > 0 && libraryLoaded) {
|
||||
@@ -193,7 +291,7 @@ const LibraryPage = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [demoBooks, libraryLoaded]);
|
||||
|
||||
const importBooks = async (files: [string | File]) => {
|
||||
const importBooks = async (files: (string | File)[]) => {
|
||||
setLoading(true);
|
||||
const failedFiles = [];
|
||||
for (const file of files) {
|
||||
@@ -262,17 +360,18 @@ const LibraryPage = () => {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('Not authenticated') && settings.keepLogin) {
|
||||
navigateToLogin(router);
|
||||
return;
|
||||
return false;
|
||||
} else if (err.message.includes('Insufficient storage quota')) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Insufficient storage quota'),
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
eventDispatcher.dispatch('toast', {
|
||||
@@ -281,6 +380,7 @@ const LibraryPage = () => {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -297,6 +397,7 @@ const LibraryPage = () => {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Failed to download book: {{title}}', {
|
||||
@@ -304,6 +405,7 @@ const LibraryPage = () => {
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,6 +421,7 @@ const LibraryPage = () => {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Failed to delete book: {{title}}', {
|
||||
@@ -326,6 +429,7 @@ const LibraryPage = () => {
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -379,6 +483,7 @@ const LibraryPage = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageRef}
|
||||
className={clsx(
|
||||
'library-page bg-base-200 text-base-content flex select-none flex-col overflow-hidden',
|
||||
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
|
||||
@@ -402,11 +507,13 @@ const LibraryPage = () => {
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'scroll-container mt-12 flex-grow overflow-auto px-4 sm:px-2',
|
||||
'scroll-container drop-zone mt-12 flex-grow overflow-auto px-4 sm:px-2',
|
||||
appService?.hasSafeAreaInset && 'mt-[calc(48px+env(safe-area-inset-top))]',
|
||||
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
|
||||
isDragging && 'drag-over',
|
||||
)}
|
||||
>
|
||||
<DropIndicator />
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
@@ -420,7 +527,8 @@ const LibraryPage = () => {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero h-screen items-center justify-center'>
|
||||
<div className='hero drop-zone h-screen items-center justify-center'>
|
||||
<DropIndicator />
|
||||
<div className='hero-content text-neutral-content text-center'>
|
||||
<div className='max-w-md'>
|
||||
<h1 className='mb-5 text-5xl font-bold'>{_('Your Library')}</h1>
|
||||
@@ -449,4 +557,18 @@ const LibraryPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const LibraryPage = () => {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LibraryPageWithSearchParams />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryPage;
|
||||
|
||||
@@ -85,14 +85,20 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
<HintInfo bookKey={bookKey} gapRight={marginGap} />
|
||||
<PageInfoView
|
||||
bookFormat={book.format}
|
||||
section={section ?? null}
|
||||
pageinfo={pageinfo ?? null}
|
||||
section={section}
|
||||
pageinfo={pageinfo}
|
||||
gapRight={marginGap}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Annotator bookKey={bookKey} />
|
||||
<FooterBar bookKey={bookKey} pageinfo={pageinfo} isHoveredAnim={false} />
|
||||
<FooterBar
|
||||
bookKey={bookKey}
|
||||
bookFormat={book.format}
|
||||
section={section}
|
||||
pageinfo={pageinfo}
|
||||
isHoveredAnim={false}
|
||||
/>
|
||||
{isFontLayoutSettingsDialogOpen && <SettingsDialog bookKey={bookKey} config={config} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { BookDoc, getDirection } from '@/libs/document';
|
||||
import { BookConfig } from '@/types/book';
|
||||
import { FoliateView, wrappedFoliateView } from '@/types/view';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
|
||||
@@ -10,9 +11,8 @@ import { useProgressSync } from '../hooks/useProgressSync';
|
||||
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
|
||||
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
|
||||
import { getStyles, mountAdditionalFonts } from '@/utils/style';
|
||||
import { getBookDirFromWritingMode } from '@/utils/book';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
|
||||
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
|
||||
import { useUICSS } from '@/hooks/useUICSS';
|
||||
import {
|
||||
handleKeydown,
|
||||
handleMousedown,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
} from '../utils/iframeEventHandlers';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
|
||||
const FoliateViewer: React.FC<{
|
||||
bookKey: string;
|
||||
@@ -35,7 +36,8 @@ const FoliateViewer: React.FC<{
|
||||
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { themeCode } = useTheme();
|
||||
const { themeCode } = useThemeStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
useEffect(() => {
|
||||
@@ -43,6 +45,7 @@ const FoliateViewer: React.FC<{
|
||||
return () => clearTimeout(timer);
|
||||
}, [toastMessage]);
|
||||
|
||||
useUICSS(bookKey, viewSettings);
|
||||
useProgressSync(bookKey);
|
||||
useProgressAutoSave(bookKey);
|
||||
|
||||
@@ -58,7 +61,9 @@ const FoliateViewer: React.FC<{
|
||||
if (detail.doc) {
|
||||
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewSettings.vertical = writingDir?.vertical || false;
|
||||
viewSettings.vertical =
|
||||
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
|
||||
viewSettings.rtl = writingDir?.rtl || viewSettings.writingMode.includes('rl') || false;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (viewSettings.scrolled && shouldAutoHideScrollbar) {
|
||||
handleScrollbarAutoHide(detail.doc);
|
||||
@@ -86,7 +91,8 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
if (detail.reason === 'scroll') {
|
||||
const renderer = viewRef.current?.renderer;
|
||||
if (renderer) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (renderer && viewSettings.continuousScroll) {
|
||||
if (renderer.start <= 0) {
|
||||
viewRef.current?.prev(1);
|
||||
// sometimes viewSize has subpixel value that the end never reaches
|
||||
@@ -120,7 +126,7 @@ const FoliateViewer: React.FC<{
|
||||
useEffect(() => {
|
||||
if (viewRef.current && viewRef.current.renderer) {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewRef.current.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themeCode]);
|
||||
@@ -133,31 +139,34 @@ const FoliateViewer: React.FC<{
|
||||
console.log('Opening book', bookKey);
|
||||
await import('foliate-js/view.js');
|
||||
const view = wrappedFoliateView(document.createElement('foliate-view') as FoliateView);
|
||||
view.id = `foliate-view-${bookKey}`;
|
||||
document.body.append(view);
|
||||
containerRef.current?.appendChild(view);
|
||||
|
||||
const writingMode = viewSettings.writingMode;
|
||||
if (writingMode) {
|
||||
const settingsDir = getBookDirFromWritingMode(writingMode);
|
||||
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
|
||||
if (settingsDir !== 'auto') {
|
||||
bookDoc.dir = settingsDir;
|
||||
} else if (languageDir !== 'auto') {
|
||||
bookDoc.dir = languageDir;
|
||||
}
|
||||
}
|
||||
|
||||
await view.open(bookDoc);
|
||||
// make sure we can listen renderer events after opening book
|
||||
viewRef.current = view;
|
||||
setFoliateView(bookKey, view);
|
||||
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
|
||||
const writingMode = viewSettings.writingMode;
|
||||
if (writingMode) {
|
||||
view.book.dir = getBookDirFromWritingMode(writingMode);
|
||||
}
|
||||
view.renderer.setStyles?.(getStyles(viewSettings));
|
||||
|
||||
const isScrolled = viewSettings.scrolled!;
|
||||
const marginPx = viewSettings.marginPx!;
|
||||
const gapPercent = viewSettings.gapPercent!;
|
||||
const animated = viewSettings.animated!;
|
||||
const maxColumnCount = viewSettings.maxColumnCount!;
|
||||
const maxInlineSize =
|
||||
maxColumnCount === 1 || isScrolled
|
||||
? ONE_COLUMN_MAX_INLINE_SIZE
|
||||
: viewSettings.maxInlineSize!;
|
||||
const maxInlineSize = getMaxInlineSize(viewSettings);
|
||||
const maxBlockSize = viewSettings.maxBlockSize!;
|
||||
if (animated) {
|
||||
view.renderer.setAttribute('animated', '');
|
||||
|
||||
@@ -9,21 +9,32 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { PageInfo } from '@/types/book';
|
||||
import Button from '@/components/Button';
|
||||
|
||||
interface FooterBarProps {
|
||||
bookKey: string;
|
||||
pageinfo: { current: number; total: number } | undefined;
|
||||
bookFormat: string;
|
||||
section?: PageInfo;
|
||||
pageinfo?: PageInfo;
|
||||
isHoveredAnim: boolean;
|
||||
}
|
||||
|
||||
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
|
||||
const FooterBar: React.FC<FooterBarProps> = ({
|
||||
bookKey,
|
||||
bookFormat,
|
||||
section,
|
||||
pageinfo,
|
||||
isHoveredAnim,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { hoveredBookKey, setHoveredBookKey, getView, getProgress } = useReaderStore();
|
||||
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
|
||||
useReaderStore();
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const view = getView(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
|
||||
const handleProgressChange = (event: React.ChangeEvent) => {
|
||||
const newProgress = parseInt((event.target as HTMLInputElement).value, 10);
|
||||
@@ -56,53 +67,73 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
|
||||
}
|
||||
};
|
||||
|
||||
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
|
||||
const progressFraction = pageinfoValid ? pageinfo.current / pageinfo.total : 0;
|
||||
const isVisible = hoveredBookKey === bookKey;
|
||||
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
|
||||
const progressValid = !!progressInfo;
|
||||
const progressFraction = progressValid ? (progressInfo!.current + 1) / progressInfo!.total : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'footer-bar absolute bottom-0 z-10 flex h-12 w-full items-center gap-x-4 px-4',
|
||||
'shadow-xs bg-base-100 transition-opacity duration-300',
|
||||
appService?.hasSafeAreaInset && 'pb-[env(safe-area-inset-bottom)]',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
hoveredBookKey === bookKey ? `opacity-100` : `opacity-0`,
|
||||
)}
|
||||
onMouseEnter={() => setHoveredBookKey(bookKey)}
|
||||
onMouseLeave={() => setHoveredBookKey('')}
|
||||
>
|
||||
<div className='hidden sm:flex'>
|
||||
<Button icon={<RiArrowLeftWideLine />} onClick={handleGoPrev} tooltip={_('Go Left')} />
|
||||
<>
|
||||
<div
|
||||
className='absolute bottom-0 left-0 z-10 h-12 w-full'
|
||||
onMouseEnter={() => setHoveredBookKey(bookKey)}
|
||||
onTouchStart={() => setHoveredBookKey(bookKey)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'footer-bar absolute bottom-0 z-10 flex h-12 w-full items-center gap-x-4 px-4',
|
||||
'shadow-xs bg-base-100 transition-opacity duration-300',
|
||||
appService?.hasSafeAreaInset && 'pb-[env(safe-area-inset-bottom)]',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
viewSettings?.vertical && viewSettings?.scrolled ? 'mb-3 !h-6' : '',
|
||||
isVisible ? `pointer-events-auto opacity-100` : `pointer-events-none opacity-0`,
|
||||
)}
|
||||
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
|
||||
onMouseLeave={() => setHoveredBookKey('')}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
<div className='hidden sm:flex'>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowRightWideLine /> : <RiArrowLeftWideLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoNext : handleGoPrev}
|
||||
tooltip={viewSettings?.rtl ? _('Go Right') : _('Go Left')}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
|
||||
onClick={handleGoBack}
|
||||
tooltip={_('Go Back')}
|
||||
disabled={!view?.history.canGoBack}
|
||||
/>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
|
||||
onClick={handleGoForward}
|
||||
tooltip={_('Go Forward')}
|
||||
disabled={!view?.history.canGoForward}
|
||||
/>
|
||||
<span className='mx-2 text-center text-sm'>
|
||||
{progressValid ? `${Math.round(progressFraction * 100)}%` : ''}
|
||||
</span>
|
||||
<input
|
||||
type='range'
|
||||
className='text-base-content mx-2 w-full'
|
||||
min={0}
|
||||
max={100}
|
||||
value={progressValid ? progressFraction * 100 : 0}
|
||||
onChange={(e) => handleProgressChange(e)}
|
||||
/>
|
||||
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
|
||||
<div className='hidden sm:flex'>
|
||||
<Button
|
||||
icon={viewSettings?.rtl ? <RiArrowLeftWideLine /> : <RiArrowRightWideLine />}
|
||||
onClick={viewSettings?.rtl ? handleGoPrev : handleGoNext}
|
||||
tooltip={viewSettings?.rtl ? _('Go Left') : _('Go Right')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
icon={<RiArrowGoBackLine />}
|
||||
onClick={handleGoBack}
|
||||
tooltip={_('Go Back')}
|
||||
disabled={!view?.history.canGoBack}
|
||||
/>
|
||||
<Button
|
||||
icon={<RiArrowGoForwardLine />}
|
||||
onClick={handleGoForward}
|
||||
tooltip={_('Go Forward')}
|
||||
disabled={!view?.history.canGoForward}
|
||||
/>
|
||||
<span className='mx-2 text-center text-sm'>
|
||||
{pageinfoValid ? `${Math.round(progressFraction * 100)}%` : ''}
|
||||
</span>
|
||||
<input
|
||||
type='range'
|
||||
className='text-base-content mx-2 w-full'
|
||||
min={0}
|
||||
max={100}
|
||||
value={pageinfoValid ? progressFraction * 100 : 0}
|
||||
onChange={(e) => handleProgressChange(e)}
|
||||
/>
|
||||
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
|
||||
<div className='hidden sm:flex'>
|
||||
<Button icon={<RiArrowRightWideLine />} onClick={handleGoNext} tooltip={_('Go Right')} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { getFootnoteStyles, getStyles } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
@@ -27,7 +27,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const [popupPosition, setPopupPosition] = useState<Position | null>();
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { themeCode } = useTheme();
|
||||
const { themeCode } = useThemeStore();
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const footnoteHandler = new FootnoteHandler();
|
||||
@@ -76,7 +76,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const triangPos = getPosition(detail.a, rect, viewSettings.vertical);
|
||||
const triangPos = getPosition(detail.a, rect, popupPadding, viewSettings.vertical);
|
||||
const popupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
@@ -113,7 +113,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const triangPos = getPosition(element, rect, viewSettings.vertical);
|
||||
const triangPos = getPosition(element, rect, popupPadding, viewSettings.vertical);
|
||||
const popupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SidebarToggler from './SidebarToggler';
|
||||
@@ -34,7 +34,13 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
setTrafficLightVisibility,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const { hoveredBookKey, setHoveredBookKey, bookKeys } = useReaderStore();
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
@@ -45,6 +51,25 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
if (!isOpen) setHoveredBookKey('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
setTrafficLightVisibility(true);
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
setTrafficLightVisibility(isSideBarVisible);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSideBarVisible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={headerRef}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
|
||||
interface SectionInfoProps {
|
||||
bookKey: string;
|
||||
@@ -11,7 +11,7 @@ interface SectionInfoProps {
|
||||
|
||||
const HintInfo: React.FC<SectionInfoProps> = ({ bookKey, gapRight }) => {
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
const [hintMessage, setHintMessage] = React.useState<string | null>(null);
|
||||
const hintTimeout = useRef(2000);
|
||||
const dismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -4,8 +4,8 @@ import { PageInfo } from '@/types/book';
|
||||
|
||||
interface PageInfoProps {
|
||||
bookFormat: string;
|
||||
section: PageInfo | null;
|
||||
pageinfo: PageInfo | null;
|
||||
section?: PageInfo;
|
||||
pageinfo?: PageInfo;
|
||||
gapRight: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, Suspense, useRef } from 'react';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
@@ -16,10 +17,11 @@ import ReaderContent from './ReaderContent';
|
||||
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { library, setLibrary } = useLibraryStore();
|
||||
const { getVisibleLibrary, setLibrary } = useLibraryStore();
|
||||
const isInitiating = useRef(false);
|
||||
|
||||
const { updateAppTheme } = useTheme();
|
||||
const { updateAppTheme } = useThemeStore();
|
||||
useTheme();
|
||||
useScreenWakeLock(settings.screenWakeLock);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +40,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
library.length > 0 &&
|
||||
getVisibleLibrary().length > 0 &&
|
||||
settings.globalReadSettings && (
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
|
||||
interface SectionInfoProps {
|
||||
section?: string;
|
||||
@@ -11,7 +11,7 @@ interface SectionInfoProps {
|
||||
|
||||
const SectionInfo: React.FC<SectionInfoProps> = ({ section, gapLeft }) => {
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -5,17 +5,16 @@ import { BiMoon, BiSun } from 'react-icons/bi';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
|
||||
|
||||
import {
|
||||
MAX_ZOOM_LEVEL,
|
||||
MIN_ZOOM_LEVEL,
|
||||
ONE_COLUMN_MAX_INLINE_SIZE,
|
||||
ZOOM_STEP,
|
||||
} from '@/services/constants';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme, ThemeMode } from '@/hooks/useTheme';
|
||||
import { ThemeMode } from '@/styles/themes';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface ViewMenuProps {
|
||||
bookKey: string;
|
||||
@@ -29,19 +28,18 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
onSetSettingsDialogOpen,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { getView, getViews, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
const { themeMode, isDarkMode, themeCode, updateThemeMode } = useTheme();
|
||||
const { themeMode, themeCode, setThemeMode } = useThemeStore();
|
||||
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
|
||||
const [isInvertedColors, setInvertedColors] = useState(viewSettings!.invert);
|
||||
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
|
||||
|
||||
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
|
||||
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
|
||||
const resetZoom = () => setZoomLevel(100);
|
||||
const toggleScrolledMode = () => setScrolledMode(!isScrolledMode);
|
||||
const toggleInvertedColors = () => setInvertedColors(!isInvertedColors);
|
||||
|
||||
const openFontLayoutMenu = () => {
|
||||
setIsDropdownOpen?.(false);
|
||||
@@ -51,40 +49,39 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
const cycleThemeMode = () => {
|
||||
const nextMode: ThemeMode =
|
||||
themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
|
||||
updateThemeMode(nextMode);
|
||||
setThemeMode(nextMode);
|
||||
};
|
||||
|
||||
const handleFullScreen = () => {
|
||||
tauriHandleToggleFullScreen();
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getViews().forEach((view) => {
|
||||
view.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
|
||||
view.renderer.setStyles?.(getStyles(viewSettings!));
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themeCode]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings!.scrolled = isScrolledMode;
|
||||
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
|
||||
getView(bookKey)?.renderer.setAttribute(
|
||||
'max-inline-size',
|
||||
`${viewSettings.maxColumnCount === 1 || isScrolledMode ? ONE_COLUMN_MAX_INLINE_SIZE : viewSettings.maxInlineSize}px`,
|
||||
`${getMaxInlineSize(viewSettings)}px`,
|
||||
);
|
||||
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
|
||||
viewSettings!.scrolled = isScrolledMode;
|
||||
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!));
|
||||
setViewSettings(bookKey, viewSettings!);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isScrolledMode]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle('invert', isInvertedColors);
|
||||
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isInvertedColors]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = getView(bookKey);
|
||||
if (!view) return;
|
||||
viewSettings!.zoomLevel = zoomLevel;
|
||||
setViewSettings(bookKey, viewSettings!);
|
||||
view.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
|
||||
view.renderer.setStyles?.(getStyles(viewSettings!));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [zoomLevel]);
|
||||
|
||||
@@ -135,6 +132,9 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
|
||||
<hr className='border-base-300 my-1' />
|
||||
|
||||
{appService?.hasRoundedWindow && (
|
||||
<MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />
|
||||
)}
|
||||
<MenuItem
|
||||
label={
|
||||
themeMode === 'dark'
|
||||
@@ -146,12 +146,6 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
icon={themeMode === 'dark' ? <BiMoon /> : themeMode === 'light' ? <BiSun /> : <TbSunMoon />}
|
||||
onClick={cycleThemeMode}
|
||||
/>
|
||||
<MenuItem
|
||||
label={_('Invert Colors in Dark Mode')}
|
||||
icon={isInvertedColors ? <MdCheck className='text-base-content' /> : undefined}
|
||||
onClick={toggleInvertedColors}
|
||||
disabled={!isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HighlightColor, HighlightStyle } from '@/types/book';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface AnnotationPopupProps {
|
||||
dir: 'ltr' | 'rtl';
|
||||
buttons: Array<{ tooltipText: string; Icon: React.ElementType; onClick: () => void }>;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
@@ -22,6 +23,7 @@ const OPTIONS_HEIGHT_PIX = 28;
|
||||
const OPTIONS_PADDING_PIX = 16;
|
||||
|
||||
const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
dir,
|
||||
buttons,
|
||||
position,
|
||||
trianglePosition,
|
||||
@@ -35,7 +37,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
|
||||
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
|
||||
return (
|
||||
<div>
|
||||
<div dir={dir}>
|
||||
<Popup
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useFoliateEvents } from '../../hooks/useFoliateEvents';
|
||||
import { useNotesSync } from '../../hooks/useNotesSync';
|
||||
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||
import AnnotationPopup from './AnnotationPopup';
|
||||
import WiktionaryPopup from './WiktionaryPopup';
|
||||
import WikipediaPopup from './WikipediaPopup';
|
||||
@@ -75,7 +76,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const dictPopupHeight = Math.min(300, maxHeight);
|
||||
const transPopupWidth = Math.min(480, maxWidth);
|
||||
const transPopupHeight = Math.min(360, maxHeight);
|
||||
const annotPopupWidth = useResponsiveSize(280);
|
||||
const annotPopupWidth = useResponsiveSize(300);
|
||||
const annotPopupHeight = useResponsiveSize(44);
|
||||
const androidSelectionHandlerHeight = 8;
|
||||
|
||||
@@ -95,11 +96,26 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}
|
||||
setSelection({ key: bookKey, text: sel.toString(), range, index });
|
||||
};
|
||||
// FIXME: extremely hacky way to dismiss system selection tools on iOS
|
||||
const makeSelectionOnIOS = (sel: Selection) => {
|
||||
isTextSelected.current = true;
|
||||
const range = sel.getRangeAt(0);
|
||||
setTimeout(() => {
|
||||
sel.removeAllRanges();
|
||||
setTimeout(() => {
|
||||
if (!isTextSelected.current) return;
|
||||
sel.addRange(range);
|
||||
setSelection({ key: bookKey, text: range.toString(), range, index });
|
||||
}, 40);
|
||||
}, 0);
|
||||
};
|
||||
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) {
|
||||
@@ -118,7 +134,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// Note that on Android, pointerup event is fired after an additional touch event
|
||||
const sel = doc.getSelection();
|
||||
if (isValidSelection(sel)) {
|
||||
makeSelection(sel, true);
|
||||
if (osPlatform === 'ios' || appService?.isIOSApp) {
|
||||
makeSelectionOnIOS(sel);
|
||||
} else {
|
||||
makeSelection(sel, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleTouchstart = () => {
|
||||
@@ -157,14 +177,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const { draw, annotation, doc, range } = detail;
|
||||
const { style, color } = annotation as BookNote;
|
||||
const hexColor = color ? HIGHLIGHT_COLOR_HEX[color] : color;
|
||||
if (style === 'highlight') {
|
||||
draw(Overlayer.highlight, { color });
|
||||
draw(Overlayer.highlight, { color: hexColor });
|
||||
} else if (['underline', 'squiggly'].includes(style as string)) {
|
||||
const { defaultView } = doc;
|
||||
const node = range.startContainer;
|
||||
const el = node.nodeType === 1 ? node : node.parentElement;
|
||||
const { writingMode } = defaultView.getComputedStyle(el);
|
||||
draw(Overlayer[style as keyof typeof Overlayer], { writingMode, color });
|
||||
draw(Overlayer[style as keyof typeof Overlayer], { writingMode, color: hexColor });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,7 +252,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const triangPos = getPosition(selection.range, rect, viewSettings.vertical);
|
||||
const triangPos = getPosition(selection.range, rect, popupPadding, viewSettings.vertical);
|
||||
const annotPopupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
@@ -466,6 +487,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
)}
|
||||
{showAnnotPopup && trianglePosition && annotPopupPosition && (
|
||||
<AnnotationPopup
|
||||
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
|
||||
buttons={buttons}
|
||||
position={annotPopupPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
|
||||
@@ -108,18 +108,20 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
trianglePosition={trianglePosition}
|
||||
className='select-text overflow-y-auto'
|
||||
className='select-text'
|
||||
>
|
||||
<main className='p-2 font-sans'></main>
|
||||
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='p-4 text-sm opacity-60'>
|
||||
From <a id='link'>Wikipedia</a>, released under the{' '}
|
||||
<a href='https://en.wikipedia.org/wiki/Wikipedia:Text_of_the_Creative_Commons_Attribution-ShareAlike_4.0_International_License'>
|
||||
CC BY-SA License
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</footer>
|
||||
<div className='text-base-content flex h-full flex-col pt-2'>
|
||||
<main className='flex-grow overflow-y-auto px-2 font-sans'></main>
|
||||
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
|
||||
From <a id='link'>Wikipedia</a>, released under the{' '}
|
||||
<a href='https://en.wikipedia.org/wiki/Wikipedia:Text_of_the_Creative_Commons_Attribution-ShareAlike_4.0_International_License'>
|
||||
CC BY-SA License
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -163,15 +163,17 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
className='select-text overflow-y-auto'
|
||||
className='select-text'
|
||||
>
|
||||
<main className='p-4 font-sans' />
|
||||
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='p-4 text-sm opacity-60'>
|
||||
From <a id='link'>Wiktionary</a>, released under the{' '}
|
||||
<a href='https://creativecommons.org/licenses/by-sa/4.0/'>CC BY-SA License</a>.
|
||||
</div>
|
||||
</footer>
|
||||
<div className='flex h-full flex-col'>
|
||||
<main className='flex-grow overflow-y-auto p-4 font-sans' />
|
||||
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
|
||||
From <a id='link'>Wiktionary</a>, released under the{' '}
|
||||
<a href='https://creativecommons.org/licenses/by-sa/4.0/'>CC BY-SA License</a>.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,10 +15,10 @@ const NotebookHeader: React.FC<{
|
||||
const _ = useTranslation();
|
||||
const iconSize14 = useResponsiveSize(14);
|
||||
return (
|
||||
<div className='notebook-header relative flex h-11 items-center px-3'>
|
||||
<div className='notebook-header relative flex h-11 items-center px-3' dir='ltr'>
|
||||
<div className='absolute inset-0 flex items-center justify-center space-x-2'>
|
||||
<LuNotebookPen />
|
||||
<div className='notebook-title text-sm font-medium'>{_('Notebook')}</div>
|
||||
<div className='notebook-title hidden text-sm font-medium sm:flex'>{_('Notebook')}</div>
|
||||
</div>
|
||||
<div className='z-10 flex items-center gap-x-4'>
|
||||
<button
|
||||
|
||||
@@ -114,6 +114,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
|
||||
'inset-0 w-full rounded-none border-0 bg-transparent p-0',
|
||||
'content font-size-sm',
|
||||
)}
|
||||
dir='auto'
|
||||
ref={editorRef}
|
||||
value={note}
|
||||
rows={1}
|
||||
@@ -130,7 +131,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
|
||||
<span className='content font-size-xs inline text-gray-500'>{getAnnotationText()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end p-2'>
|
||||
<div className='flex justify-end p-2' dir='ltr'>
|
||||
<button
|
||||
className={clsx(
|
||||
'content btn btn-ghost font-size-sm hover:bg-transparent',
|
||||
|
||||
@@ -7,13 +7,14 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useDrag } from '@/hooks/useDrag';
|
||||
import { TextSelection } from '@/utils/sel';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getBookDirFromLanguage } from '@/utils/book';
|
||||
import BooknoteItem from '../sidebar/BooknoteItem';
|
||||
import NotebookHeader from './Header';
|
||||
import NoteEditor from './NoteEditor';
|
||||
@@ -23,14 +24,14 @@ const MAX_NOTEBOOK_WIDTH = 0.45;
|
||||
|
||||
const Notebook: React.FC = ({}) => {
|
||||
const _ = useTranslation();
|
||||
const { updateAppTheme } = useTheme();
|
||||
const { updateAppTheme } = useThemeStore();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { notebookWidth, isNotebookVisible, isNotebookPinned } = useNotebookStore();
|
||||
const { notebookNewAnnotation, notebookEditAnnotation, setNotebookPin } = useNotebookStore();
|
||||
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
|
||||
const { getView } = useReaderStore();
|
||||
const { getBookData, getConfig, saveConfig, updateBooknotes } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { setNotebookWidth, setNotebookVisible, toggleNotebookPin } = useNotebookStore();
|
||||
const { setNotebookNewAnnotation, setNotebookEditAnnotation } = useNotebookStore();
|
||||
|
||||
@@ -136,6 +137,14 @@ const Notebook: React.FC = ({}) => {
|
||||
|
||||
if (!sideBarBookKey) return null;
|
||||
|
||||
const bookData = getBookData(sideBarBookKey);
|
||||
const viewSettings = getViewSettings(sideBarBookKey);
|
||||
if (!bookData || !bookData.bookDoc) {
|
||||
return null;
|
||||
}
|
||||
const { bookDoc } = bookData;
|
||||
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
|
||||
|
||||
const config = getConfig(sideBarBookKey);
|
||||
const { booknotes: allNotes = [] } = config || {};
|
||||
const annotationNotes = allNotes
|
||||
@@ -159,6 +168,7 @@ const Notebook: React.FC = ({}) => {
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
|
||||
!isNotebookPinned && 'shadow-2xl',
|
||||
)}
|
||||
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
|
||||
style={{
|
||||
width: `${notebookWidth}`,
|
||||
maxWidth: `${MAX_NOTEBOOK_WIDTH * 100}%`,
|
||||
@@ -183,7 +193,7 @@ const Notebook: React.FC = ({}) => {
|
||||
handleTogglePin={handleTogglePin}
|
||||
/>
|
||||
<div className='max-h-[calc(100vh-44px)] overflow-y-auto px-3'>
|
||||
<div>
|
||||
<div dir='ltr'>
|
||||
{excerptNotes.length > 0 && (
|
||||
<p className='content font-size-base pt-1'>{_('Excerpts')}</p>
|
||||
)}
|
||||
@@ -196,7 +206,7 @@ const Notebook: React.FC = ({}) => {
|
||||
className='collapse-arrow border-base-300 bg-base-100 collapse border'
|
||||
>
|
||||
<div
|
||||
className='collapse-title font-size-sm h-9 min-h-9 p-2 pr-8 font-medium'
|
||||
className='collapse-title font-size-sm h-9 min-h-9 p-2 pe-8 font-medium'
|
||||
style={
|
||||
{
|
||||
'--top-override': '1.2rem',
|
||||
@@ -208,7 +218,7 @@ const Notebook: React.FC = ({}) => {
|
||||
</div>
|
||||
<div className='collapse-content font-size-xs select-text px-3 pb-0'>
|
||||
<p className='hyphens-auto text-justify'>{item.text}</p>
|
||||
<div className='flex justify-end'>
|
||||
<div className='flex justify-end' dir='ltr'>
|
||||
<div
|
||||
className='font-size-xs cursor-pointer align-bottom text-red-500 hover:text-red-600'
|
||||
onClick={handleEditNote.bind(null, item, true)}
|
||||
@@ -221,7 +231,7 @@ const Notebook: React.FC = ({}) => {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div>
|
||||
<div dir='ltr'>
|
||||
{(notebookNewAnnotation || annotationNotes.length > 0) && (
|
||||
<p className='content font-size-base pt-1'>{_('Notes')}</p>
|
||||
)}
|
||||
|
||||
@@ -3,24 +3,24 @@ import { MdOutlineLightMode, MdOutlineDarkMode } from 'react-icons/md';
|
||||
import { MdRadioButtonUnchecked, MdRadioButtonChecked } from 'react-icons/md';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { themes } from '@/styles/themes';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { themeMode, themeColor, themeCode, isDarkMode, updateThemeMode, updateThemeColor } =
|
||||
useTheme();
|
||||
const { themeMode, themeColor, themeCode, isDarkMode, setThemeMode, setThemeColor } =
|
||||
useThemeStore();
|
||||
const { getViews, getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const iconSize24 = useResponsiveSize(24);
|
||||
|
||||
useEffect(() => {
|
||||
getViews().forEach((view) => {
|
||||
view.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
|
||||
view.renderer.setStyles?.(getStyles(viewSettings!));
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themeCode]);
|
||||
@@ -29,11 +29,11 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Theme Mode')}</h2>
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex gap-4'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto Mode')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => updateThemeMode('auto')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setThemeMode('auto')}
|
||||
>
|
||||
<TbSunMoon />
|
||||
</button>
|
||||
@@ -41,8 +41,8 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Light Mode')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => updateThemeMode('light')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setThemeMode('light')}
|
||||
>
|
||||
<MdOutlineLightMode />
|
||||
</button>
|
||||
@@ -50,8 +50,8 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Dark Mode')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => updateThemeMode('dark')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setThemeMode('dark')}
|
||||
>
|
||||
<MdOutlineDarkMode />
|
||||
</button>
|
||||
@@ -78,7 +78,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
name='theme'
|
||||
value={name}
|
||||
checked={themeColor === name}
|
||||
onChange={() => updateThemeColor(name)}
|
||||
onChange={() => setThemeColor(name)}
|
||||
className='hidden'
|
||||
/>
|
||||
{themeColor === name ? (
|
||||
|
||||
@@ -16,10 +16,10 @@ import {
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { FONT_ENUM_SUPPORTED_OS_PLATFORMS, getSysFontsList } from '@/utils/font';
|
||||
import { getSysFontsList } from '@/utils/font';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
interface FontFaceProps {
|
||||
@@ -60,11 +60,11 @@ const FontFace = ({
|
||||
|
||||
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const { themeCode } = useTheme();
|
||||
|
||||
const fontFamilyOptions = [
|
||||
{
|
||||
@@ -109,7 +109,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTauriAppPlatform() && FONT_ENUM_SUPPORTED_OS_PLATFORMS.includes(osPlatform)) {
|
||||
if (isTauriAppPlatform() && appService?.hasSysFontsList) {
|
||||
getSysFontsList().then((fonts) => {
|
||||
setSysFonts(fonts);
|
||||
});
|
||||
@@ -124,7 +124,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.defaultFont = defaultFont;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultFont]);
|
||||
|
||||
@@ -135,7 +135,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.defaultFontSize = defaultFontSize;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultFontSize]);
|
||||
|
||||
@@ -146,7 +146,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.minimumFontSize = minFontSize;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [minFontSize]);
|
||||
|
||||
@@ -157,7 +157,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.fontWeight = fontWeight;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fontWeight]);
|
||||
|
||||
@@ -168,7 +168,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.serifFont = serifFont;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serifFont]);
|
||||
|
||||
@@ -179,7 +179,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.sansSerifFont = sansSerifFont;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sansSerifFont]);
|
||||
|
||||
@@ -190,7 +190,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.monospaceFont = monospaceFont;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [monospaceFont]);
|
||||
|
||||
@@ -201,7 +201,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.overrideFont = overrideFont;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideFont]);
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MdOutlineAutoMode } from 'react-icons/md';
|
||||
import { MdOutlineTextRotationDown, MdOutlineTextRotationNone } from 'react-icons/md';
|
||||
import { MdOutlineTextRotationNone, MdTextRotateVertical } from 'react-icons/md';
|
||||
import { TbTextDirectionRtl } from 'react-icons/tb';
|
||||
|
||||
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
|
||||
import NumberInput from './NumberInput';
|
||||
|
||||
@@ -20,7 +20,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const view = getView(bookKey);
|
||||
const bookData = getBookData(bookKey)!;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const { themeCode } = useTheme();
|
||||
|
||||
const [paragraphMargin, setParagraphMargin] = useState(viewSettings.paragraphMargin!);
|
||||
const [lineHeight, setLineHeight] = useState(viewSettings.lineHeight!);
|
||||
@@ -44,7 +43,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.paragraphMargin = paragraphMargin;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paragraphMargin]);
|
||||
|
||||
@@ -55,7 +54,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.lineHeight = lineHeight;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lineHeight]);
|
||||
|
||||
@@ -66,7 +65,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.wordSpacing = wordSpacing;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wordSpacing]);
|
||||
|
||||
@@ -77,7 +76,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.letterSpacing = letterSpacing;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [letterSpacing]);
|
||||
|
||||
@@ -88,7 +87,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.textIndent = textIndent;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [textIndent]);
|
||||
|
||||
@@ -99,7 +98,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.fullJustification = fullJustification;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fullJustification]);
|
||||
|
||||
@@ -110,7 +109,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.hyphenation = hyphenation;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hyphenation]);
|
||||
|
||||
@@ -147,10 +146,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setAttribute('max-column-count', maxColumnCount);
|
||||
view?.renderer.setAttribute(
|
||||
'max-inline-size',
|
||||
`${maxColumnCount === 1 || viewSettings.scrolled ? ONE_COLUMN_MAX_INLINE_SIZE : maxInlineSize}px`,
|
||||
);
|
||||
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [maxColumnCount]);
|
||||
|
||||
@@ -161,10 +157,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.maxInlineSize = maxInlineSize;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setAttribute(
|
||||
'max-inline-size',
|
||||
`${maxColumnCount === 1 || viewSettings.scrolled ? ONE_COLUMN_MAX_INLINE_SIZE : maxInlineSize}px`,
|
||||
);
|
||||
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [maxInlineSize]);
|
||||
|
||||
@@ -181,12 +174,19 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
useEffect(() => {
|
||||
// global settings are not supported for writing mode
|
||||
const prevWritingMode = viewSettings.writingMode;
|
||||
viewSettings.writingMode = writingMode;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (view) {
|
||||
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view.renderer.setStyles?.(getStyles(viewSettings));
|
||||
view.book.dir = getBookDirFromWritingMode(writingMode);
|
||||
}
|
||||
if (
|
||||
prevWritingMode !== writingMode &&
|
||||
(writingMode === 'horizontal-rl' || prevWritingMode === 'horizontal-rl')
|
||||
) {
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [writingMode]);
|
||||
|
||||
@@ -197,23 +197,24 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalViewSettings.overrideLayout = overrideLayout;
|
||||
setSettings(settings);
|
||||
}
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideLayout]);
|
||||
|
||||
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
|
||||
const isCJKBook = langCode === 'zh' || langCode === 'ja' || langCode === 'ko';
|
||||
const mightBeRTLBook =
|
||||
langCode === 'zh' || langCode === 'ja' || langCode === 'ko' || langCode === '';
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
{isCJKBook && (
|
||||
{mightBeRTLBook && (
|
||||
<div className='w-full'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Writing Mode')}</h2>
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex gap-4'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Default')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('auto')}
|
||||
>
|
||||
<MdOutlineAutoMode />
|
||||
@@ -222,7 +223,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Horizontal Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('horizontal-tb')}
|
||||
>
|
||||
<MdOutlineTextRotationNone />
|
||||
@@ -231,10 +232,19 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Vertical Direction')}>
|
||||
<button
|
||||
className={`btn btn-ghost btn-circle ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={() => setWritingMode('vertical-rl')}
|
||||
>
|
||||
<MdOutlineTextRotationDown />
|
||||
<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>
|
||||
@@ -256,7 +266,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
step={0.5}
|
||||
/>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Line Spacing')}
|
||||
value={lineHeight}
|
||||
onChange={setLineHeight}
|
||||
@@ -265,7 +274,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Word Spacing')}
|
||||
value={wordSpacing}
|
||||
onChange={setWordSpacing}
|
||||
@@ -274,7 +282,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
step={0.5}
|
||||
/>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Letter Spacing')}
|
||||
value={letterSpacing}
|
||||
onChange={setLetterSpacing}
|
||||
@@ -283,7 +290,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput
|
||||
className='config-item-top'
|
||||
label={_('Text Indent')}
|
||||
value={textIndent}
|
||||
onChange={setTextIndent}
|
||||
@@ -350,18 +356,20 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
max={4}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Maximum Inline Size')}
|
||||
label={viewSettings.vertical ? _('Maximum Column Height') : _('Maximum Column Width')}
|
||||
value={maxInlineSize}
|
||||
onChange={setMaxInlineSize}
|
||||
min={500}
|
||||
disabled={maxColumnCount === 1 || viewSettings.scrolled}
|
||||
min={400}
|
||||
max={9999}
|
||||
step={100}
|
||||
/>
|
||||
<NumberInput
|
||||
label={_('Maximum Block Size')}
|
||||
label={viewSettings.vertical ? _('Maximum Column Width') : _('Maximum Column Height')}
|
||||
value={maxBlockSize}
|
||||
onChange={setMaxBlockSize}
|
||||
min={500}
|
||||
disabled={maxColumnCount === 1 || viewSettings.scrolled}
|
||||
min={400}
|
||||
max={9999}
|
||||
step={100}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import cssbeautify from 'cssbeautify';
|
||||
import cssValidate from '@/utils/css';
|
||||
|
||||
@@ -13,10 +12,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
|
||||
const { getView, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const { themeCode } = useTheme();
|
||||
|
||||
const [animated, setAnimated] = useState(viewSettings.animated!);
|
||||
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
|
||||
const [isContinuousScroll, setIsContinuousScroll] = useState(viewSettings.continuousScroll!);
|
||||
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
|
||||
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -52,14 +51,14 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setDraftStylesheet(formattedCSS);
|
||||
setDraftStylesheetSaved(true);
|
||||
viewSettings.userStylesheet = formattedCSS;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.userStylesheet = formattedCSS;
|
||||
setSettings(settings);
|
||||
}
|
||||
|
||||
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
|
||||
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings));
|
||||
};
|
||||
|
||||
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
@@ -92,12 +91,22 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDisableClick]);
|
||||
|
||||
useEffect(() => {
|
||||
viewSettings.continuousScroll = isContinuousScroll;
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
if (isFontLayoutSettingsGlobal) {
|
||||
settings.globalViewSettings.continuousScroll = isContinuousScroll;
|
||||
setSettings(settings);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isContinuousScroll]);
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-y'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item config-item-top config-item-bottom'>
|
||||
<span className=''>{_('Paging Animation')}</span>
|
||||
<input
|
||||
@@ -114,8 +123,17 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
|
||||
<div className='card border-base-200 bg-base-100 border shadow'>
|
||||
<div className='divide-y'>
|
||||
<div className='config-item config-item-top config-item-bottom'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item config-item-top'>
|
||||
<span className=''>{_('Continuous Scroll')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={isContinuousScroll}
|
||||
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item config-item-bottom'>
|
||||
<span className=''>{_('Disable Click-to-Flip')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
|
||||
@@ -9,6 +9,7 @@ interface NumberInputProps {
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
disabled,
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const numberStep = step || 1;
|
||||
@@ -74,13 +76,13 @@ const NumberInput: React.FC<NumberInputProps> = ({
|
||||
/>
|
||||
<button
|
||||
onClick={decrement}
|
||||
className={`btn btn-circle btn-sm ${value === min ? 'btn-disabled !bg-opacity-5' : ''}`}
|
||||
className={`btn btn-circle btn-sm ${value <= min || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
|
||||
>
|
||||
<FiMinus className='h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
onClick={increment}
|
||||
className={`btn btn-circle btn-sm ${value === max ? 'btn-disabled !bg-opacity-5' : ''}`}
|
||||
className={`btn btn-circle btn-sm ${value >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`}
|
||||
>
|
||||
<FiPlus className='h-4 w-4' />
|
||||
</button>
|
||||
|
||||
@@ -64,7 +64,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
className='modal-open'
|
||||
boxClassName='sm:w-1/2 sm:min-w-[480px] sm:h-[65%]'
|
||||
boxClassName='sm:min-w-[520px]'
|
||||
header={
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<button
|
||||
@@ -76,12 +76,12 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
>
|
||||
<MdArrowBackIosNew />
|
||||
</button>
|
||||
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center justify-around pl-4'>
|
||||
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center pl-4 gap-2'>
|
||||
{tabConfig.map(({ tab, icon: Icon, label }) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={clsx(
|
||||
'btn btn-ghost text-base-content h-8 min-h-8',
|
||||
'btn btn-ghost text-base-content btn-sm',
|
||||
activePanel === tab ? 'btn-active' : '',
|
||||
)}
|
||||
onClick={() => setActivePanel(tab)}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { MdInfoOutline } from 'react-icons/md';
|
||||
import { Book } from '@/types/book';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
@@ -10,6 +12,7 @@ import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
const BookCard = ({ book }: { book: Book }) => {
|
||||
const { coverImageUrl, title, author } = book;
|
||||
const _ = useTranslation();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
|
||||
const showBookDetails = () => {
|
||||
@@ -23,14 +26,17 @@ const BookCard = ({ book }: { book: Book }) => {
|
||||
alt={_('Book Cover')}
|
||||
width={56}
|
||||
height={80}
|
||||
className='mr-4 aspect-auto max-h-16 w-[15%] max-w-12 rounded-sm object-cover shadow-md'
|
||||
className={clsx(
|
||||
'me-4 aspect-auto max-h-16 w-[15%] max-w-12 rounded-sm object-cover shadow-md',
|
||||
isDarkMode ? 'mix-blend-screen' : 'mix-blend-multiply',
|
||||
)}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='line-clamp-2 w-[90%] text-sm font-semibold'>{formatTitle(title)}</h4>
|
||||
<p className='text-neutral-content truncate text-xs'>{formatAuthors(author)}</p>
|
||||
<p className='truncate text-xs opacity-75'>{formatAuthors(author)}</p>
|
||||
</div>
|
||||
<button
|
||||
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
|
||||
|
||||
@@ -17,7 +17,7 @@ interface BookMenuProps {
|
||||
|
||||
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const { library } = useLibraryStore();
|
||||
const { getVisibleLibrary } = useLibraryStore();
|
||||
const { openParallelView } = useBooksManager();
|
||||
const handleParallelView = (id: string) => {
|
||||
openParallelView(id);
|
||||
@@ -48,26 +48,28 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
>
|
||||
<MenuItem label={_('Parallel Read')} noIcon>
|
||||
<ul className='max-h-60 overflow-y-auto'>
|
||||
{library.slice(0, 20).map((book) => (
|
||||
<MenuItem
|
||||
key={book.hash}
|
||||
icon={
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
width={56}
|
||||
height={80}
|
||||
className='aspect-auto max-h-8 max-w-6 rounded-sm shadow-md'
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={book.title}
|
||||
labelClass='max-w-36'
|
||||
onClick={() => handleParallelView(book.hash)}
|
||||
/>
|
||||
))}
|
||||
{getVisibleLibrary()
|
||||
.slice(0, 20)
|
||||
.map((book) => (
|
||||
<MenuItem
|
||||
key={book.hash}
|
||||
icon={
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
width={56}
|
||||
height={80}
|
||||
className='aspect-auto max-h-8 max-w-6 rounded-sm shadow-md'
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={book.title}
|
||||
labelClass='max-w-36'
|
||||
onClick={() => handleParallelView(book.hash)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</MenuItem>
|
||||
<MenuItem label={_('Reload Page')} noIcon shortcut='Shift+R' onClick={handleReloadPage} />
|
||||
|
||||
@@ -81,12 +81,16 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{item.note && <span className='content font-size-sm font-normal'>{item.note}</span>}
|
||||
{item.note && (
|
||||
<span className='content font-size-sm font-normal' dir='auto'>
|
||||
{item.note}
|
||||
</span>
|
||||
)}
|
||||
<div className='flex items-start'>
|
||||
{item.note && (
|
||||
<div className='my-1 mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
|
||||
<div className='my-1 me-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
|
||||
)}
|
||||
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'py-2')}>
|
||||
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'my-2')}>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline',
|
||||
@@ -116,7 +120,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='flex justify-end space-x-3 p-2'>
|
||||
<div className='flex justify-end space-x-3 p-2' dir='ltr'>
|
||||
{item.note && (
|
||||
<button
|
||||
className={clsx(
|
||||
|
||||
@@ -5,8 +5,8 @@ import { FiSearch } from 'react-icons/fi';
|
||||
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import BookMenu from './BookMenu';
|
||||
|
||||
@@ -18,16 +18,18 @@ const SidebarHeader: React.FC<{
|
||||
onTogglePin: () => void;
|
||||
onToggleSearchBar: () => void;
|
||||
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
const iconSize14 = useResponsiveSize(14);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const iconSize22 = useResponsiveSize(22);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'sidebar-header flex h-11 items-center justify-between pr-2',
|
||||
isTrafficLightVisible ? 'pl-20' : 'pl-1.5',
|
||||
'sidebar-header flex h-11 items-center justify-between pe-2',
|
||||
isTrafficLightVisible ? 'pl-20' : 'ps-1.5',
|
||||
)}
|
||||
dir='ltr'
|
||||
>
|
||||
<div className='flex items-center gap-x-8'>
|
||||
<button
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { FaSearch, FaChevronDown } from 'react-icons/fa';
|
||||
|
||||
@@ -7,9 +8,9 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SearchOptions from './SearchOptions';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
const MINIMUM_SEARCH_TERM_LENGTH_DEFAULT = 2;
|
||||
const MINIMUM_SEARCH_TERM_LENGTH_CJK = 1;
|
||||
@@ -172,7 +173,11 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
|
||||
<div className='bg-base-300 flex h-8 w-8 items-center rounded-r-lg'>
|
||||
<Dropdown
|
||||
className='dropdown-bottom flex justify-center'
|
||||
className={clsx(
|
||||
window.innerWidth < 640 && 'dropdown-end',
|
||||
'dropdown-bottom flex justify-center',
|
||||
)}
|
||||
menuClassName={window.innerWidth < 640 ? 'no-triangle mt-1' : 'dropdown-center mt-3'}
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 rounded-none rounded-r-lg'
|
||||
toggleButton={<FaChevronDown size={iconSize12} className='text-gray-500' />}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { BookSearchConfig } from '@/types/book';
|
||||
@@ -6,6 +7,7 @@ import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface SearchOptionsProps {
|
||||
searchConfig: BookSearchConfig;
|
||||
menuClassName?: string;
|
||||
onSearchConfigChanged: (searchConfig: BookSearchConfig) => void;
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
}
|
||||
@@ -32,6 +34,7 @@ const Option: React.FC<OptionProps> = ({ label, isActive, onClick }) => (
|
||||
|
||||
const SearchOptions: React.FC<SearchOptionsProps> = ({
|
||||
searchConfig,
|
||||
menuClassName,
|
||||
onSearchConfigChanged,
|
||||
setIsDropdownOpen,
|
||||
}) => {
|
||||
@@ -44,7 +47,10 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='book-menu dropdown-content dropdown-center border-base-200 z-20 mt-3 w-56 border shadow-2xl'
|
||||
className={clsx(
|
||||
'book-menu dropdown-content dropdown-center border-base-200 z-20 w-56 border shadow-2xl',
|
||||
menuClassName,
|
||||
)}
|
||||
>
|
||||
<Option
|
||||
label={_('Book')}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { BookSearchResult } from '@/types/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getBookDirFromLanguage } from '@/utils/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useDrag } from '@/hooks/useDrag';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import SidebarHeader from './Header';
|
||||
import SidebarContent from './Content';
|
||||
import BookCard from './BookCard';
|
||||
@@ -21,15 +23,17 @@ import useShortcuts from '@/hooks/useShortcuts';
|
||||
const MIN_SIDEBAR_WIDTH = 0.05;
|
||||
const MAX_SIDEBAR_WIDTH = 0.45;
|
||||
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
|
||||
const SideBar: React.FC<{
|
||||
onGoToLibrary: () => void;
|
||||
}> = ({ onGoToLibrary }) => {
|
||||
const { appService } = useEnv();
|
||||
const { updateAppTheme } = useTheme();
|
||||
const { updateAppTheme } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView } = useReaderStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<BookSearchResult[] | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -95,23 +99,33 @@ const SideBar: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerticalDragEnd = (velocity: number) => {
|
||||
const handleVerticalDragEnd = (data: { velocity: number; clientY: number }) => {
|
||||
const sidebar = document.querySelector('.sidebar-container') as HTMLElement;
|
||||
const overlay = document.querySelector('.overlay') as HTMLElement;
|
||||
|
||||
if (!sidebar || !overlay) return;
|
||||
|
||||
if (velocity > 0.5) {
|
||||
sidebar.style.transition = `top ${0.15 / velocity}s ease-out`;
|
||||
if (
|
||||
data.velocity > VELOCITY_THRESHOLD ||
|
||||
(data.velocity >= 0 && data.clientY >= window.innerHeight * 0.5)
|
||||
) {
|
||||
const transitionDuration = 0.15 / Math.max(data.velocity, 0.5);
|
||||
sidebar.style.transition = `top ${transitionDuration}s ease-out`;
|
||||
sidebar.style.top = '100%';
|
||||
overlay.style.transition = `opacity ${0.15 / velocity}s ease-out`;
|
||||
overlay.style.transition = `opacity ${transitionDuration}s ease-out`;
|
||||
overlay.style.opacity = '0';
|
||||
setTimeout(() => setSideBarVisible(false), 300);
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
} else {
|
||||
sidebar.style.transition = 'top 0.3s ease-out';
|
||||
sidebar.style.top = '0%';
|
||||
overlay.style.transition = 'opacity 0.3s ease-out';
|
||||
overlay.style.opacity = '0.8';
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,11 +163,13 @@ const SideBar: React.FC<{
|
||||
|
||||
if (!sideBarBookKey) return null;
|
||||
|
||||
const viewSettings = getViewSettings(sideBarBookKey);
|
||||
const bookData = getBookData(sideBarBookKey);
|
||||
if (!bookData || !bookData.book || !bookData.bookDoc) {
|
||||
return null;
|
||||
}
|
||||
const { book, bookDoc } = bookData;
|
||||
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
|
||||
|
||||
return isSideBarVisible ? (
|
||||
<>
|
||||
@@ -165,6 +181,7 @@ const SideBar: React.FC<{
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
|
||||
!isSideBarPinned && 'shadow-2xl',
|
||||
)}
|
||||
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
|
||||
style={{
|
||||
width: `${sideBarWidth}`,
|
||||
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
|
||||
|
||||
@@ -15,10 +15,11 @@ const TabNavigation: React.FC<{
|
||||
return (
|
||||
<div
|
||||
className={clsx('bottom-tab border-base-300/50 bg-base-200 relative flex w-full border-t')}
|
||||
dir='ltr'
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-300 absolute bottom-1.5 left-1 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
|
||||
'bg-base-300 absolute bottom-1.5 start-1 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
|
||||
'transform transition-transform duration-300',
|
||||
activeTab === 'toc' && 'translate-x-0',
|
||||
activeTab === 'annotations' && 'translate-x-[calc(100%+8px)]',
|
||||
|
||||
@@ -112,24 +112,24 @@ const TTSPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center justify-between space-x-2'>
|
||||
<button onClick={onBackward} className='hover:bg-base-200/75 rounded-full p-1'>
|
||||
<button onClick={onBackward} className='rounded-full p-1'>
|
||||
<MdFastRewind size={iconSize32} />
|
||||
</button>
|
||||
<button onClick={onTogglePlay} className='hover:bg-base-200/75 rounded-full p-1'>
|
||||
<button onClick={onTogglePlay} className='rounded-full p-1'>
|
||||
{isPlaying ? (
|
||||
<MdPauseCircle size={iconSize48} className='fill-primary' />
|
||||
) : (
|
||||
<MdPlayCircle size={iconSize48} className='fill-primary' />
|
||||
)}
|
||||
</button>
|
||||
<button onClick={onForward} className='hover:bg-base-200/75 rounded-full p-1'>
|
||||
<button onClick={onForward} className='rounded-full p-1'>
|
||||
<MdFastForward size={iconSize32} />
|
||||
</button>
|
||||
<button onClick={onStop} className='hover:bg-base-200/75 rounded-full p-1'>
|
||||
<button onClick={onStop} className='rounded-full p-1'>
|
||||
<MdStop size={iconSize32} />
|
||||
</button>
|
||||
<div className='dropdown dropdown-top'>
|
||||
<button tabIndex={0} className='hover:bg-base-200/75 rounded-full p-1'>
|
||||
<button tabIndex={0} className='rounded-full p-1'>
|
||||
<RiVoiceAiFill size={iconSize32} />
|
||||
</button>
|
||||
<ul
|
||||
|
||||
@@ -5,7 +5,6 @@ import useShortcuts from '@/hooks/useShortcuts';
|
||||
import useBooksManager from './useBooksManager';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { tauriQuitApp } from '@/utils/window';
|
||||
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
|
||||
@@ -21,7 +20,6 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
|
||||
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
const { toggleNotebook } = useNotebookStore();
|
||||
const { getNextBookKey } = useBooksManager();
|
||||
const { themeCode } = useTheme();
|
||||
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
||||
const fontSize = viewSettings?.defaultFontSize ?? 16;
|
||||
const lineHeight = viewSettings?.lineHeight ?? 1.6;
|
||||
@@ -100,7 +98,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
|
||||
const zoomLevel = viewSettings!.zoomLevel + ZOOM_STEP;
|
||||
viewSettings!.zoomLevel = Math.min(zoomLevel, MAX_ZOOM_LEVEL);
|
||||
setViewSettings(sideBarBookKey, viewSettings!);
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings!));
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
@@ -111,7 +109,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
|
||||
const zoomLevel = viewSettings!.zoomLevel - ZOOM_STEP;
|
||||
viewSettings!.zoomLevel = Math.max(zoomLevel, MIN_ZOOM_LEVEL);
|
||||
setViewSettings(sideBarBookKey, viewSettings!);
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings!));
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
@@ -121,7 +119,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
|
||||
const viewSettings = getViewSettings(sideBarBookKey)!;
|
||||
viewSettings!.zoomLevel = 100;
|
||||
setViewSettings(sideBarBookKey, viewSettings!);
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
|
||||
view?.renderer.setStyles?.(getStyles(viewSettings!));
|
||||
};
|
||||
|
||||
useShortcuts(
|
||||
|
||||
@@ -58,12 +58,17 @@ export const useClickEvent = (
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
|
||||
} else if (msg.data.type === 'iframe-wheel') {
|
||||
// FIXME: conlict with the touchpad scroll event on macOS
|
||||
if (viewSettings.scrolled && appService?.osPlatform === 'macos') return;
|
||||
const { deltaY } = msg.data;
|
||||
const fontSize = viewSettings?.defaultFontSize ?? 16;
|
||||
const lineHeight = viewSettings?.lineHeight ?? 1.6;
|
||||
const distance = fontSize * lineHeight * 3;
|
||||
if (deltaY > 0) {
|
||||
viewRef.current?.next(1);
|
||||
viewRef.current?.next(viewSettings.scrolled ? distance : 1);
|
||||
} else if (deltaY < 0) {
|
||||
viewRef.current?.prev(1);
|
||||
viewRef.current?.prev(viewSettings.scrolled ? distance : 1);
|
||||
}
|
||||
} else if (msg.data.type === 'iframe-mouseup') {
|
||||
if (msg.data.button === 3) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { SYNC_NOTES_INTERVAL_SEC } from '@/services/constants';
|
||||
|
||||
@@ -12,12 +13,6 @@ export const useNotesSync = (bookKey: string) => {
|
||||
const config = getConfig(bookKey);
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
syncNotes([], bookHash, 'pull');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const lastSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -57,15 +52,27 @@ export const useNotesSync = (bookKey: string) => {
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncedNotes?.length && config?.location) {
|
||||
const processNewNote = (note: BookNote) => {
|
||||
const oldNotes = config?.booknotes ?? [];
|
||||
const existingNote = oldNotes.find((oldNote) => oldNote.id === note.id);
|
||||
if (existingNote) {
|
||||
if (existingNote.updatedAt < note.updatedAt) {
|
||||
return { ...existingNote, ...note };
|
||||
} else {
|
||||
return { ...note, ...existingNote };
|
||||
}
|
||||
}
|
||||
return note;
|
||||
};
|
||||
if (syncedNotes?.length && config) {
|
||||
const newNotes = syncedNotes.filter((note) => note.bookHash === bookHash);
|
||||
if (!newNotes.length) return;
|
||||
const oldNotes = config.booknotes ?? [];
|
||||
const mergedNotes = [
|
||||
...oldNotes.filter((oldNote) => !newNotes.some((newNote) => newNote.id === oldNote.id)),
|
||||
...newNotes,
|
||||
...newNotes.map(processNewNote),
|
||||
];
|
||||
setConfig(bookKey, { ...config, booknotes: mergedNotes });
|
||||
setConfig(bookKey, { booknotes: mergedNotes });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [syncedNotes]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
|
||||
export const useProgressAutoSave = (bookKey: string) => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { envConfig } = useEnv();
|
||||
const { getConfig, saveConfig } = useBookDataStore();
|
||||
const { getProgress } = useReaderStore();
|
||||
const progress = getProgress(bookKey);
|
||||
@@ -22,9 +22,6 @@ export const useProgressAutoSave = (bookKey: string) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// FIXME: Save book config when progress changes on Android
|
||||
// until we can hook into the lifecycle of the Android app
|
||||
if (!appService?.isAndroidApp || !progress) return;
|
||||
saveBookConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress, bookKey]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
import { serializeConfig } from '@/utils/serializer';
|
||||
import { CFI } from '@/libs/document';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
|
||||
@@ -106,14 +106,9 @@ export const useProgressSync = (bookKey: string) => {
|
||||
configSynced.current = true;
|
||||
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
|
||||
if (syncedConfig) {
|
||||
const newConfig = deserializeConfig(
|
||||
JSON.stringify(syncedConfig),
|
||||
settings.globalViewSettings,
|
||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||
);
|
||||
setConfig(bookKey, { ...config, ...newConfig });
|
||||
const syncedCFI = syncedConfig.location;
|
||||
const configCFI = config?.location;
|
||||
const syncedCFI = syncedConfig.location;
|
||||
setConfig(bookKey, syncedConfig);
|
||||
if (syncedCFI && configCFI) {
|
||||
if (CFI.compare(configCFI, syncedCFI) < 0) {
|
||||
if (view) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { hasUpdater } from '@/services/environment';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import Reader from './components/Reader';
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function Page() {
|
||||
const _ = useTranslation();
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
useTheme();
|
||||
useOpenWithBooks();
|
||||
|
||||
useEffect(() => {
|
||||
const doCheckAppUpdates = async () => {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IoArrowBack } from 'react-icons/io5';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { QuotaType, UserPlan } from '@/types/user';
|
||||
import { getStoragePlanData, getUserPlan } from '@/utils/access';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import { deleteUser } from '@/libs/user';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import Quota from '@/components/Quota';
|
||||
|
||||
const ProfilePage = () => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user, logout } = useAuth();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [userPlan, setUserPlan] = useState<UserPlan>('free');
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
|
||||
|
||||
useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
|
||||
try {
|
||||
const userPlan = getUserPlan(token);
|
||||
const storagePlan = getStoragePlanData(token);
|
||||
const storageQuota = {
|
||||
name: _('Cloud Storage'),
|
||||
tooltip: _('{{percentage}}% of Cloud Storage Used.', {
|
||||
percentage: Math.round((storagePlan.usage / storagePlan.quota) * 100),
|
||||
}),
|
||||
used: Math.round(storagePlan.usage / 1024 / 1024),
|
||||
total: Math.round(storagePlan.quota / 1024 / 1024),
|
||||
unit: 'MB',
|
||||
};
|
||||
setUserPlan(userPlan);
|
||||
setQuotas([storageQuota]);
|
||||
} catch (error) {
|
||||
console.error('Error loading user plan data:', error);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const handleGoBack = () => {
|
||||
navigateToLibrary(router);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
settings.keepLogin = false;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
navigateToLibrary(router);
|
||||
};
|
||||
|
||||
const handleDeleteRequest = () => {
|
||||
setShowConfirmDelete(true);
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowConfirmDelete(false);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
await deleteUser();
|
||||
handleLogout();
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to delete user. Please try again later.'),
|
||||
});
|
||||
}
|
||||
setShowConfirmDelete(false);
|
||||
};
|
||||
|
||||
const getPlanDetails = (userPlan: UserPlan) => {
|
||||
switch (userPlan) {
|
||||
case 'free':
|
||||
return {
|
||||
name: _('Free Tier'),
|
||||
color: 'bg-gray-200 text-gray-800',
|
||||
features: [
|
||||
_('Community Support'),
|
||||
_('DeepL Free Access'),
|
||||
_('Essential Text-to-Speech Voices'),
|
||||
_('Unlimited Offline/Online Reading'),
|
||||
_('Unlimited Cloud Sync Devices'),
|
||||
_('500 MB Cloud Sync Space'),
|
||||
],
|
||||
};
|
||||
case 'plus':
|
||||
return {
|
||||
name: _('Plus Tier'),
|
||||
color: 'bg-blue-200 text-blue-800',
|
||||
features: [
|
||||
_('Includes All Free Tier Benefits'),
|
||||
_('Priority Support'),
|
||||
_('AI Summaries'),
|
||||
_('AI Translations'),
|
||||
_('DeepL Pro Access'),
|
||||
_('Expanded Text-to-Speech Voices'),
|
||||
_('2000 MB Cloud Sync Space'),
|
||||
],
|
||||
};
|
||||
case 'pro':
|
||||
return {
|
||||
name: _('Pro Tier'),
|
||||
color: 'bg-purple-200 text-purple-800',
|
||||
features: [
|
||||
_('Includes All Plus Tier Benefits'),
|
||||
_('Unlimited AI Summaries'),
|
||||
_('Unlimited AI Translations'),
|
||||
_('Unlimited DeepL Pro Access'),
|
||||
_('Advanced AI Tools'),
|
||||
_('Early Feature Access'),
|
||||
_('10 GB Cloud Sync Space'),
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (!user || !token) {
|
||||
return (
|
||||
<div className='mx-auto max-w-4xl px-4 py-8'>
|
||||
<div className='overflow-hidden rounded-lg shadow-md'>
|
||||
<div className='flex min-h-[300px] items-center justify-center p-6'>
|
||||
<div className='text-base-content animate-pulse'>{_('Loading profile...')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
|
||||
const userFullName = user?.user_metadata?.['full_name'] || '-';
|
||||
const userEmail = user?.email || '';
|
||||
const planDetails = getPlanDetails(userPlan) || getPlanDetails('free');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-6 flex justify-center',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
appService?.hasTrafficLight && 'pt-11',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className={clsx(
|
||||
'btn btn-ghost fixed left-4 h-8 min-h-8 w-8 p-0',
|
||||
appService?.hasSafeAreaInset && 'top-[calc(env(safe-area-inset-top)+16px)]',
|
||||
appService?.hasTrafficLight && 'top-11',
|
||||
)}
|
||||
>
|
||||
<IoArrowBack className='text-base-content' />
|
||||
</button>
|
||||
<div className='w-full max-w-4xl px-6 py-10'>
|
||||
<div className='bg-base-200 overflow-hidden rounded-lg p-2 shadow-md sm:p-6'>
|
||||
<div className='p-6'>
|
||||
<div className='mb-8 flex flex-col items-center gap-x-6 gap-y-4 md:flex-row md:items-start'>
|
||||
<div className='flex-shrink-0'>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={_('User avatar')}
|
||||
className='border-base-100 h-16 w-16 rounded-full border-4 md:h-24 md:w-24'
|
||||
referrerPolicy='no-referrer'
|
||||
width={128}
|
||||
height={128}
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<PiUserCircle className='h-16 w-16 md:h-24 md:w-24' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex-grow text-center md:text-left'>
|
||||
<h2 className='text-base-content text-xl font-bold md:text-2xl'>{userFullName}</h2>
|
||||
<p className='text-base-content/60'>{userEmail}</p>
|
||||
<div className='mt-3'>
|
||||
<span
|
||||
className={`inline-block rounded-full px-3 py-1 text-sm font-medium ${planDetails.color}`}
|
||||
>
|
||||
{planDetails.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-base-100 mb-8 rounded-lg p-6'>
|
||||
<h3 className='text-base-content mb-2 text-lg font-semibold'>{_('Features')}</h3>
|
||||
<div className='mt-6'>
|
||||
<ul className='text-base-content/60 grid grid-cols-1 gap-2 md:grid-cols-2'>
|
||||
{planDetails.features.map((feature, index) => (
|
||||
<li key={index} className='flex items-center'>
|
||||
<svg
|
||||
className='mr-2 h-4 w-4 text-green-500'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-base-300 mb-8 rounded-lg'>
|
||||
<div className='p-0'>
|
||||
{quotas && quotas.length > 0 ? (
|
||||
<Quota quotas={quotas} showProgress className='h-10 pl-4 pr-2' />
|
||||
) : (
|
||||
<div className='h-10 animate-pulse'></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 md:flex-row'>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className='w-full rounded-lg bg-gray-200 px-6 py-3 font-medium text-gray-800 transition-colors hover:bg-gray-300 md:w-auto'
|
||||
>
|
||||
{_('Sign Out')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteRequest}
|
||||
className='w-full rounded-lg bg-red-100 px-6 py-3 font-medium text-red-600 transition-colors hover:bg-red-200 md:w-auto'
|
||||
>
|
||||
{_('Delete Account')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConfirmDelete && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4'>
|
||||
<div className='w-full max-w-md rounded-2xl bg-white p-6'>
|
||||
<h3 className='mb-4 text-xl font-bold text-gray-800'>{_('Delete Your Account?')}</h3>
|
||||
<p className='mb-6 text-gray-600'>
|
||||
{_(
|
||||
'This action cannot be undone. All your data in the cloud will be permanently deleted.',
|
||||
)}
|
||||
</p>
|
||||
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||
<button
|
||||
onClick={handleCancelDelete}
|
||||
className='flex-1 rounded-lg bg-gray-300 px-4 py-2 font-medium text-gray-800 hover:bg-gray-400'
|
||||
>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
className='flex-1 rounded-lg bg-red-500 px-4 py-2 font-medium text-white hover:bg-red-600'
|
||||
>
|
||||
{_('Delete Permanently')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -20,7 +20,7 @@ export const AboutWindow = () => {
|
||||
const [isUpdated, setIsUpdated] = React.useState(false);
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
const update = await checkForAppUpdates(_);
|
||||
const update = await checkForAppUpdates(_, false);
|
||||
if (!update) {
|
||||
setIsUpdated(true);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
title={_('Book Details')}
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
className='!bg-[rgba(0,0,0,0.5)]'
|
||||
bgClassName='sm:bg-black/50'
|
||||
boxClassName='sm:min-w-[480px] sm:h-auto'
|
||||
contentClassName='!px-6 !py-2'
|
||||
>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useDrag } from '@/hooks/useDrag';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
||||
|
||||
const DISMISS_THRESHOLD = 100;
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
|
||||
interface DialogProps {
|
||||
@@ -16,6 +15,7 @@ interface DialogProps {
|
||||
header?: ReactNode;
|
||||
title?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
boxClassName?: string;
|
||||
contentClassName?: string;
|
||||
onClose: () => void;
|
||||
@@ -28,12 +28,12 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
header,
|
||||
title,
|
||||
className,
|
||||
bgClassName,
|
||||
boxClassName,
|
||||
contentClassName,
|
||||
onClose,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const [translateY, setTranslateY] = useState(0);
|
||||
const iconSize22 = useResponsiveSize(22);
|
||||
const isMobile = window.innerWidth < 640;
|
||||
|
||||
@@ -56,27 +56,42 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
|
||||
const modal = document.querySelector('.modal-box') as HTMLElement;
|
||||
const overlay = document.querySelector('.overlay') as HTMLElement;
|
||||
|
||||
const heightFraction = data.clientY / window.innerHeight;
|
||||
const newTop = Math.max(0.0, Math.min(1, heightFraction));
|
||||
|
||||
if (modal && overlay) {
|
||||
modal.style.transition = '';
|
||||
overlay.style.opacity = `${1 - data.clientY / window.innerHeight}`;
|
||||
modal.style.transform = `translateY(${newTop * 100}%)`;
|
||||
overlay.style.opacity = `${1 - heightFraction}`;
|
||||
}
|
||||
setTranslateY((prev) => Math.max(prev + data.deltaY, 0));
|
||||
};
|
||||
|
||||
const handleDragEnd = (velocity: number) => {
|
||||
const handleDragEnd = (data: { velocity: number; clientY: number }) => {
|
||||
const modal = document.querySelector('.modal-box') as HTMLElement;
|
||||
if (!modal) return;
|
||||
const overlay = document.querySelector('.overlay') as HTMLElement;
|
||||
if (!modal || !overlay) return;
|
||||
|
||||
if (translateY > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
||||
modal.style.transition = `transform ${0.15 / velocity}s ease-out`;
|
||||
onClose();
|
||||
setTimeout(() => setTranslateY(0), 300);
|
||||
if (
|
||||
data.velocity > VELOCITY_THRESHOLD ||
|
||||
(data.velocity >= 0 && data.clientY >= window.innerHeight * 0.5)
|
||||
) {
|
||||
const transitionDuration = 0.15 / Math.max(data.velocity, 0.5);
|
||||
modal.style.transition = `transform ${transitionDuration}s ease-out`;
|
||||
modal.style.transform = 'translateY(100%)';
|
||||
overlay.style.transition = `opacity ${transitionDuration}s ease-out`;
|
||||
overlay.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
modal.style.transform = 'translateY(0%)';
|
||||
}, 300);
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
} else {
|
||||
modal.style.transition = `transform 0.3s ease-out`;
|
||||
setTranslateY(0);
|
||||
modal.style.transform = `translateY(0%)`;
|
||||
overlay.style.opacity = '0';
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
@@ -89,23 +104,23 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
<dialog
|
||||
id={id ?? 'dialog'}
|
||||
open={isOpen}
|
||||
className={clsx('modal sm:min-w-90 z-50 h-full w-full sm:w-full', className)}
|
||||
className={clsx('modal sm:min-w-90 z-50 h-full w-full !bg-transparent sm:w-full', className)}
|
||||
>
|
||||
<div className='overlay fixed inset-0 z-10 bg-black/30 sm:bg-black/20' />
|
||||
<div className={clsx('overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20', bgClassName)} />
|
||||
<div
|
||||
className={clsx(
|
||||
'modal-box settings-content z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
|
||||
'h-full max-h-full w-full max-w-full sm:w-[65%] sm:max-w-[600px]',
|
||||
'h-full max-h-full w-full max-w-full',
|
||||
window.innerWidth < window.innerHeight
|
||||
? 'sm:h-[50%] sm:w-3/4'
|
||||
: 'sm:h-[65%] sm:w-1/2 sm:max-w-[600px]',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)] sm:pt-0',
|
||||
boxClassName,
|
||||
)}
|
||||
style={{
|
||||
transform: `translateY(${translateY}px)`,
|
||||
}}
|
||||
>
|
||||
{window.innerWidth < 640 && (
|
||||
<div
|
||||
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
|
||||
className='drag-handle flex h-10 max-h-10 min-h-10 w-full cursor-row-resize items-center justify-center'
|
||||
onMouseDown={handleDragStart}
|
||||
onTouchStart={handleDragStart}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { HiArrowDownTray } from 'react-icons/hi2';
|
||||
|
||||
const DropIndicator: React.FC = () => {
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<div className='drag-overlay'></div>
|
||||
<div className='drop-indicator'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<HiArrowDownTray className='h-12 w-12' />
|
||||
<p className='mt-2 font-medium'>{_('Drop to Import Books')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropIndicator;
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
type QuotaProps = {
|
||||
@@ -8,26 +9,48 @@ type QuotaProps = {
|
||||
total: number;
|
||||
unit: string;
|
||||
}[];
|
||||
className?: string;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
const Quota: React.FC<QuotaProps> = ({ quotas }) => {
|
||||
const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className }) => {
|
||||
return (
|
||||
<div className='w-full max-w-lg rounded-md pl-4 pr-2 text-base sm:text-sm'>
|
||||
<div>
|
||||
{quotas.map((quota) => (
|
||||
<div key={quota.name} className='flex h-10 items-center justify-between'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={quota.tooltip}>
|
||||
<div className='flex max-w-28 items-center gap-x-2'>
|
||||
<div className={clsx('text-base-content w-full space-y-2 rounded-md text-base sm:text-sm')}>
|
||||
{quotas.map((quota) => {
|
||||
const usagePercentage = (quota.used / quota.total) * 100;
|
||||
let bgColor = 'bg-green-500';
|
||||
if (usagePercentage > 80) {
|
||||
bgColor = 'bg-red-500';
|
||||
} else if (usagePercentage > 50) {
|
||||
bgColor = 'bg-yellow-500';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={quota.name}
|
||||
className={clsx(
|
||||
'relative w-full overflow-hidden rounded-md',
|
||||
showProgress && 'border-base-300 border',
|
||||
)}
|
||||
>
|
||||
{showProgress && (
|
||||
<div
|
||||
className={`absolute left-0 top-0 h-full ${bgColor}`}
|
||||
style={{ width: `${usagePercentage}%` }}
|
||||
></div>
|
||||
)}
|
||||
|
||||
<div className={clsx('relative flex items-center justify-between p-2', className)}>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={quota.tooltip}>
|
||||
<span className='truncate'>{quota.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='py-3 text-right text-xs'>
|
||||
{quota.used} / {quota.total} {quota.unit}
|
||||
<div className='text-right text-xs'>
|
||||
{quota.used} / {quota.total} {quota.unit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
@@ -38,6 +39,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
localStorage.setItem('token', access_token);
|
||||
localStorage.setItem('refresh_token', refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
posthog.identify(user.id);
|
||||
setToken(access_token);
|
||||
setUser(user);
|
||||
} else {
|
||||
|
||||
@@ -6,10 +6,11 @@ import { TranslationFunc } from '@/hooks/useTranslation';
|
||||
|
||||
const LAST_CHECK_KEY = 'lastAppUpdateCheck';
|
||||
|
||||
export const checkForAppUpdates = async (_: TranslationFunc) => {
|
||||
export const checkForAppUpdates = async (_: TranslationFunc, autoCheck = true) => {
|
||||
const lastCheck = localStorage.getItem(LAST_CHECK_KEY);
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000) return;
|
||||
if (autoCheck && lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000)
|
||||
return;
|
||||
localStorage.setItem(LAST_CHECK_KEY, now.toString());
|
||||
|
||||
console.log('Checking for updates');
|
||||
|
||||
@@ -2,7 +2,14 @@ import { useCallback, useRef } from 'react';
|
||||
|
||||
export const useDrag = (
|
||||
onDragMove: (data: { clientX: number; clientY: number; deltaX: number; deltaY: number }) => void,
|
||||
onDragEnd?: (velocity: number) => void,
|
||||
onDragEnd?: (data: {
|
||||
velocity: number;
|
||||
deltaT: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
}) => void,
|
||||
) => {
|
||||
const isDragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
@@ -52,17 +59,28 @@ export const useDrag = (
|
||||
|
||||
const handleEnd = (event: MouseEvent | TouchEvent) => {
|
||||
isDragging.current = false;
|
||||
let deltaX = 0;
|
||||
let deltaY = 0;
|
||||
let clientX = 0;
|
||||
let clientY = 0;
|
||||
const endTime = performance.now();
|
||||
const deltaT = endTime - startTime.current;
|
||||
|
||||
const distanceY =
|
||||
'touches' in event
|
||||
? event.changedTouches[0]!.clientY - startY.current
|
||||
: event.clientY - startY.current;
|
||||
const velocity = distanceY / deltaT;
|
||||
if ('touches' in event) {
|
||||
const currentTouch = event.changedTouches[0]!;
|
||||
clientX = currentTouch.clientX;
|
||||
clientY = currentTouch.clientY;
|
||||
} else {
|
||||
const evt = event as MouseEvent;
|
||||
clientX = evt.clientX;
|
||||
clientY = evt.clientY;
|
||||
}
|
||||
deltaX = clientX - startX.current;
|
||||
deltaY = clientY - startY.current;
|
||||
const velocity = deltaY / deltaT;
|
||||
|
||||
if (onDragEnd) {
|
||||
onDragEnd(velocity);
|
||||
onDragEnd({ velocity, deltaT, clientX, clientY, deltaX, deltaY });
|
||||
}
|
||||
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
|
||||
export function useOpenWithBooks() {
|
||||
const router = useRouter();
|
||||
const { setCheckOpenWithBooks } = useLibraryStore();
|
||||
const listenedOpenWithBooks = useRef(false);
|
||||
|
||||
const handleOpenWithFileUrl = (url: string) => {
|
||||
let filePath = url;
|
||||
if (filePath.startsWith('file://')) {
|
||||
filePath = decodeURI(filePath.replace('file://', ''));
|
||||
}
|
||||
if (filePath.startsWith('/')) {
|
||||
window.OPEN_WITH_FILES = [filePath];
|
||||
setCheckOpenWithBooks(true);
|
||||
navigateToLibrary(router, `reload=${Date.now()}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriAppPlatform()) return;
|
||||
if (listenedOpenWithBooks.current) return;
|
||||
listenedOpenWithBooks.current = true;
|
||||
|
||||
const listenOpenWithFiles = async () => {
|
||||
return await onOpenUrl((urls) => {
|
||||
urls.forEach((url) => {
|
||||
handleOpenWithFileUrl(url);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const unlisten = listenOpenWithFiles();
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
export const useScreenWakeLock = (lock: boolean) => {
|
||||
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
||||
const unlistenOnFocusChangedRef = useRef<Promise<() => void> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const requestWakeLock = async () => {
|
||||
@@ -20,20 +23,50 @@ export const useScreenWakeLock = (lock: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (lock) {
|
||||
requestWakeLock();
|
||||
} else if (wakeLockRef.current) {
|
||||
wakeLockRef.current.release();
|
||||
wakeLockRef.current = null;
|
||||
console.log('Wake lock released');
|
||||
}
|
||||
|
||||
return () => {
|
||||
const releaseWakeLock = () => {
|
||||
if (wakeLockRef.current) {
|
||||
wakeLockRef.current.release();
|
||||
wakeLockRef.current = null;
|
||||
console.log('Wake lock released');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
releaseWakeLock();
|
||||
} else {
|
||||
requestWakeLock();
|
||||
}
|
||||
};
|
||||
|
||||
if (lock) {
|
||||
requestWakeLock();
|
||||
} else if (wakeLockRef.current) {
|
||||
releaseWakeLock();
|
||||
}
|
||||
|
||||
if (isWebAppPlatform() && lock) {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
} else if (isTauriAppPlatform() && lock) {
|
||||
unlistenOnFocusChangedRef.current = getCurrentWindow().onFocusChanged(
|
||||
({ payload: focused }) => {
|
||||
if (focused) {
|
||||
requestWakeLock();
|
||||
} else {
|
||||
releaseWakeLock();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
releaseWakeLock();
|
||||
if (isWebAppPlatform() && lock) {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (unlistenOnFocusChangedRef.current) {
|
||||
unlistenOnFocusChangedRef.current.then((f) => f());
|
||||
}
|
||||
};
|
||||
}, [lock]);
|
||||
};
|
||||
|
||||
@@ -1,109 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ThemeCode } from '@/utils/style';
|
||||
import { themes, Palette } from '@/styles/themes';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
|
||||
export type ThemeMode = 'auto' | 'light' | 'dark';
|
||||
import { useEffect } from 'react';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
|
||||
export const useTheme = () => {
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
|
||||
if (typeof window !== 'undefined' && localStorage) {
|
||||
return (localStorage.getItem('themeMode') as ThemeMode) || 'auto';
|
||||
}
|
||||
return 'auto';
|
||||
});
|
||||
|
||||
const [themeColor, setThemeColor] = useState(() => {
|
||||
if (typeof window !== 'undefined' && localStorage) {
|
||||
return localStorage.getItem('themeColor') || 'default';
|
||||
}
|
||||
return 'default';
|
||||
});
|
||||
|
||||
const [systemIsDarkMode, setSystemIsDarkMode] = useState(() => {
|
||||
return (
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
);
|
||||
});
|
||||
|
||||
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
|
||||
|
||||
const [themeCode, setThemeCode] = useState<ThemeCode>(() => {
|
||||
let themeMode = 'auto';
|
||||
let themeColor = 'default';
|
||||
let systemIsDarkMode = false;
|
||||
if (typeof window !== 'undefined') {
|
||||
themeColor = localStorage.getItem('themeColor') || 'default';
|
||||
themeMode = localStorage.getItem('themeMode') as ThemeMode;
|
||||
systemIsDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
|
||||
const defaultTheme = themes.find((theme) => theme.name === themeColor);
|
||||
const defaultPalette = isDarkMode ? defaultTheme!.colors.dark : defaultTheme!.colors.light;
|
||||
return {
|
||||
bg: defaultPalette['base-100'],
|
||||
fg: defaultPalette['base-content'],
|
||||
primary: defaultPalette.primary,
|
||||
palette: defaultPalette,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleSystemThemeChange = () => {
|
||||
setSystemIsDarkMode(mediaQuery.matches);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleSystemThemeChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleSystemThemeChange);
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const { themeColor, isDarkMode } = useThemeStore();
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute(
|
||||
'data-theme',
|
||||
`${themeColor}-${isDarkMode ? 'dark' : 'light'}`,
|
||||
);
|
||||
}, [themeColor, isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && localStorage) {
|
||||
localStorage.setItem('themeMode', themeMode);
|
||||
localStorage.setItem('themeColor', themeColor);
|
||||
}
|
||||
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
|
||||
const theme = themes.find((t) => t.name === themeColor);
|
||||
const palette = isDarkMode ? theme!.colors.dark : theme!.colors.light;
|
||||
const bg = palette['base-100'];
|
||||
const fg = palette['base-content'];
|
||||
const primary = palette.primary;
|
||||
if (isWebAppPlatform()) {
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg);
|
||||
}
|
||||
setThemeCode({ bg, fg, primary, palette });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themeMode, themeColor, isDarkMode]);
|
||||
|
||||
const updateThemeMode = (mode: ThemeMode) => setThemeMode(mode);
|
||||
const updateThemeColor = (color: string) => setThemeColor(color);
|
||||
const updateAppTheme = (color: keyof Palette) => {
|
||||
if (isWebAppPlatform()) {
|
||||
const { palette } = themeCode;
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', palette[color]);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
themeMode,
|
||||
themeColor,
|
||||
themeCode,
|
||||
isDarkMode,
|
||||
updateThemeMode,
|
||||
updateThemeColor,
|
||||
updateAppTheme,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
|
||||
/**
|
||||
* Custom hook to get the visibility of the traffic light (window control buttons on macOS)
|
||||
* based on the fullscreen state of the application window.
|
||||
*
|
||||
* @returns {Object} An object containing:
|
||||
* - `isTrafficLightVisible` (boolean): A state indicating whether the traffic light is visible.
|
||||
*/
|
||||
const useTrafficLight = () => {
|
||||
const { appService } = useEnv();
|
||||
const [isTrafficLightVisible, setVisible] = useState(appService?.hasTrafficLight ?? false);
|
||||
let unlistenEnterFullScreen: () => void;
|
||||
let unlistenExitFullScreen: () => void;
|
||||
|
||||
const handleSwitchFullScreen = async () => {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
const isFullscreen = await currentWindow.isFullscreen();
|
||||
if (appService?.hasTrafficLight) setVisible(!isFullscreen);
|
||||
|
||||
unlistenEnterFullScreen = await currentWindow.listen('will-enter-fullscreen', () => {
|
||||
if (appService?.hasTrafficLight) setVisible(false);
|
||||
});
|
||||
|
||||
unlistenExitFullScreen = await currentWindow.listen('will-exit-fullscreen', () => {
|
||||
if (appService?.hasTrafficLight) setVisible(true);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
handleSwitchFullScreen();
|
||||
|
||||
return () => {
|
||||
unlistenEnterFullScreen?.();
|
||||
unlistenExitFullScreen?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return { isTrafficLightVisible };
|
||||
};
|
||||
|
||||
export default useTrafficLight;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ViewSettings } from '@/types/book';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// 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) => {
|
||||
const [styleElement, setStyleElement] = useState<HTMLStyleElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewSettings) return;
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
}
|
||||
|
||||
const rawCSS = viewSettings.userStylesheet;
|
||||
const newStyleEl = document.createElement('style');
|
||||
newStyleEl.textContent = rawCSS.replace('foliate-view', `#foliate-view-${bookKey}`);
|
||||
document.head.appendChild(newStyleEl);
|
||||
setStyleElement(newStyleEl);
|
||||
|
||||
return () => {
|
||||
newStyleEl.remove();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewSettings]);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getAPIBaseUrl, isWebAppPlatform } from '@/services/environment';
|
||||
import { getAccessToken, getUserID } from '@/utils/access';
|
||||
import { getUserID } from '@/utils/access';
|
||||
import { fetchWithAuth } from '@/utils/fetch';
|
||||
import {
|
||||
tauriUpload,
|
||||
tauriDownload,
|
||||
@@ -15,27 +16,6 @@ const API_ENDPOINTS = {
|
||||
delete: getAPIBaseUrl() + '/storage/delete',
|
||||
};
|
||||
|
||||
const fetchWithAuth = async (url: string, options: RequestInit) => {
|
||||
const token = await getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const headers = {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Error:', errorData.error || response.statusText);
|
||||
throw new Error(errorData.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export const createProgressHandler = (
|
||||
totalFiles: number,
|
||||
completedFilesRef: { count: number },
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { getUserID } from '@/utils/access';
|
||||
import { fetchWithAuth } from '@/utils/fetch';
|
||||
|
||||
const API_ENDPOINT = getAPIBaseUrl() + '/user/delete';
|
||||
|
||||
export const deleteUser = async () => {
|
||||
try {
|
||||
const userId = await getUserID();
|
||||
if (!userId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
await fetchWithAuth(API_ENDPOINT, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('User deletion failed:', error);
|
||||
throw new Error('User deletion failed');
|
||||
}
|
||||
};
|
||||
@@ -28,7 +28,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { user, token } = await getUserAndToken(req.headers['authorization']);
|
||||
let deeplApiUrl = DEEPL_FREE_API;
|
||||
if (user && token) {
|
||||
const userPlan = await getUserPlan(token);
|
||||
const userPlan = getUserPlan(token);
|
||||
if (userPlan !== 'free') deeplApiUrl = DEEPL_PRO_API;
|
||||
}
|
||||
const deeplAuthKey =
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user