forked from akai/readest
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7371efe1d1 | |||
| 3cb37bca5d | |||
| b9fa204eda | |||
| cf66665096 | |||
| 759779a98d | |||
| 68409db8a0 | |||
| 46f0e8e7f6 | |||
| 6260168caa | |||
| a8430a7be1 | |||
| 7bfaa38746 | |||
| c5b2feda48 | |||
| 59f875f590 | |||
| 8efad90932 | |||
| 5c23642ac0 | |||
| ee6500cecb | |||
| f004d7b3ea | |||
| f7d53508a6 | |||
| 53e7f2526c | |||
| 8f0e2d1642 | |||
| c807f0ccc8 | |||
| 6ba15319a8 | |||
| 3ea3a50baa | |||
| ccd467ebcf | |||
| fe25c3e995 | |||
| d4af2d3ef5 | |||
| 6ff1edb67c | |||
| 3d2847e427 | |||
| ff4d8af054 | |||
| 612302f42f | |||
| a26607a154 | |||
| 939c7049ed | |||
| 0ead07e704 | |||
| f4908c4536 | |||
| 37951c2ebf | |||
| 915f222e08 | |||
| 9446df1b9c | |||
| bfcf89b093 | |||
| b0cf087d78 | |||
| 5863586766 | |||
| 99182a1f9e | |||
| 2599f40dfb | |||
| aa66ba54c5 | |||
| 4f0ef01a17 |
@@ -130,7 +130,7 @@ jobs:
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('Cargo.lock') }}
|
||||
key: ${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android'
|
||||
@@ -152,6 +152,8 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.0.11902837
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
rm -rf src-tauri/gen/android
|
||||
@@ -179,6 +181,37 @@ jobs:
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk --clobber
|
||||
echo "Uploading $arm64_apk to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk --clobber
|
||||
echo "Uploading signatures to GitHub release"
|
||||
pnpm tauri signer sign $universial_apk
|
||||
pnpm tauri signer sign $arm64_apk
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber
|
||||
|
||||
- name: download and update latest.json for Android release
|
||||
if: matrix.config.release == 'android'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
|
||||
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
universial_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_universal.apk"
|
||||
arm64_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_arm64.apk"
|
||||
|
||||
universial_sig=$(cat Readest_${version}_universal.apk.sig)
|
||||
arm64_sig=$(cat Readest_${version}_arm64.apk.sig)
|
||||
|
||||
jq --arg url "$universial_apk_url" \
|
||||
--arg sig "$universial_sig" \
|
||||
'.platforms["android-universal"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
|
||||
|
||||
jq --arg url "$arm64_apk_url" \
|
||||
--arg sig "$arm64_sig" \
|
||||
'.platforms["android-arm64"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
|
||||
|
||||
echo "Uploading updated latest.json to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
if: matrix.config.release != 'android'
|
||||
@@ -199,6 +232,13 @@ jobs:
|
||||
releaseBody: ${{ needs.get-release.outputs.release_note }}
|
||||
args: ${{ matrix.config.args || '' }}
|
||||
|
||||
- name: upload release notes to GitHub release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
echo "Uploading release notes to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} apps/readest-app/release-notes.json --clobber
|
||||
|
||||
- name: upload portable binaries (Windows only)
|
||||
if: matrix.config.os == 'windows-latest'
|
||||
env:
|
||||
|
||||
Generated
+41
@@ -12,11 +12,17 @@ checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
|
||||
name = "Readest"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"block",
|
||||
"cocoa",
|
||||
"font-enumeration",
|
||||
"futures-util",
|
||||
"log",
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
"objc2 0.6.0",
|
||||
"objc2-authentication-services",
|
||||
"objc2-foundation 0.3.0",
|
||||
"objc_id",
|
||||
"rand 0.8.5",
|
||||
"read-progress-stream",
|
||||
"reqwest",
|
||||
@@ -2997,6 +3003,17 @@ dependencies = [
|
||||
"malloc_buf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-foundation"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
|
||||
dependencies = [
|
||||
"block",
|
||||
"objc",
|
||||
"objc_id",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-sys"
|
||||
version = "0.3.5"
|
||||
@@ -3042,6 +3059,21 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-authentication-services"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8963a73216bfe9565fe78e86fcc908b65232468d195b95346d2489110a60fca"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"block2 0.6.0",
|
||||
"objc2 0.6.0",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.0",
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.0"
|
||||
@@ -3233,6 +3265,15 @@ dependencies = [
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc_id"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
|
||||
dependencies = [
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.7"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#downloads">Downloads</a> •
|
||||
<a href="#getting-started">Getting Started</a> •
|
||||
<a href="#contributors">Contributors</a> •
|
||||
<a href="#troubleshooting">Troubleshooting</a> •
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
|
||||
@@ -62,7 +63,6 @@
|
||||
|
||||
| **Feature** | **Description** | **Priority** |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🛠 |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
|
||||
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
|
||||
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
|
||||
@@ -199,6 +199,39 @@ nix develop ./ops#android # enter a dev shell for the android app
|
||||
|
||||
Please check the [wiki][link-gh-wiki] of this project for more information on development.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 1. Readest Won’t Launch on Windows (Missing Edge WebView2 Runtime)
|
||||
|
||||
**Symptom**
|
||||
|
||||
- When you double-click readest.exe, nothing happens. No window appears, and Task Manager does not show the process.
|
||||
- This can affect both the standard installer and the portable version.
|
||||
|
||||
**Cause**
|
||||
|
||||
- Microsoft Edge WebView2 Runtime is either missing, outdated, or improperly installed on your system. Readest depends on WebView2 to render the interface on Windows.
|
||||
|
||||
**How to Fix**
|
||||
|
||||
1. Check if WebView2 is installed
|
||||
- Open “Add or Remove Programs” (a.k.a. Apps & features) on Windows. Look for “Microsoft Edge WebView2 Runtime.”
|
||||
2. Install or Update WebView2
|
||||
- Download the WebView2 Runtime directly from Microsoft: [link](https://developer.microsoft.com/en-us/microsoft-edge/webview2?form=MA13LH).
|
||||
- If you prefer an offline installer, download the offline package and run it as an Administrator.
|
||||
3. Re-run Readest
|
||||
- After installing/updating WebView2, launch readest.exe again.
|
||||
- If you still encounter problems, reboot your PC and try again.
|
||||
|
||||
**Additional Tips**
|
||||
|
||||
- If reinstalling once doesn’t work, uninstall Edge WebView2 completely, then reinstall it with Administrator privileges.
|
||||
- Verify your Windows installation has the latest updates from Microsoft.
|
||||
|
||||
**Still Stuck?**
|
||||
|
||||
- See Issue [readest/readest#358](https://github.com/readest/readest/issues/358) for further details, or head over to our [Discord][link-discord] server and open a support discussion with detailed logs of your environment and the steps you’ve taken.
|
||||
|
||||
## Contributors
|
||||
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
|
||||
@@ -219,9 +252,10 @@ The following libraries and frameworks are used in this software:
|
||||
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
|
||||
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
|
||||
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
|
||||
- [daisyUI](https://github.com/saadeghi/daisyui), which is MIT licensed.
|
||||
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
|
||||
- [react](https://github.com/facebook/react), which is MIT licensed.
|
||||
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
|
||||
- [react](https://github.com/facebook/react), which is MIT licensed.
|
||||
- [tauri](https://github.com/tauri-apps/tauri), which is MIT licensed.
|
||||
|
||||
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
|
||||
|
||||
@@ -32,7 +32,7 @@ yarn-error.log*
|
||||
/private_keys
|
||||
|
||||
# plists
|
||||
*.plist
|
||||
Entitlements*.plist
|
||||
|
||||
# local confs
|
||||
tauri.*.conf.json
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.30",
|
||||
"version": "0.9.36",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -74,6 +74,7 @@
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"semver": "^7.7.1",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"zustand": "5.0.1"
|
||||
},
|
||||
@@ -86,6 +87,7 @@
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-color": "^3.0.13",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cpx2": "^8.0.0",
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "تطبيق على جميع الكتب",
|
||||
"Apply to This Book": "تطبيق على هذا الكتاب",
|
||||
"Unable to fetch the translation. Try again later.": "تعذر جلب الترجمة. حاول مرة أخرى لاحقًا.",
|
||||
"Update Now!": "قم بالتحديث الآن!",
|
||||
"Update": "تحديث",
|
||||
"Check Update": "التحقق من التحديث",
|
||||
"Already the latest version": "الإصدار الحالي هو الإصدار الأحدث بالفعل",
|
||||
"Book Details": "تفاصيل الكتاب",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "تم النسخ إلى الحافظة",
|
||||
"Export Annotations": "تصدير التعليقات",
|
||||
"Auto Import on File Open": "استيراد تلقائي عند فتح الملف",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "تبديل منطقة التقليب بالنقر",
|
||||
"No chapters detected.": "لم يتم التعرف على فصول.",
|
||||
"Failed to parse the EPUB file.": "فشل في تحليل ملف EPUB.",
|
||||
"This book format is not supported.": "هذا تنسيق الكتاب غير مدعوم.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "تعذر جلب الترجمة. يرجى تسجيل الدخول أولاً ثم المحاولة مرة أخرى.",
|
||||
"Group": "تجميع"
|
||||
"Group": "تجميع",
|
||||
"Always on Top": "دائمًا في المقدمة",
|
||||
"No Timeout": "لا مهلة",
|
||||
"{{value}} minute": "{{value}} دقيقة",
|
||||
"{{value}} minutes": "{{value}} دقائق",
|
||||
"{{value}} hour": "{{value}} ساعة",
|
||||
"{{value}} hours": "{{value}} ساعات",
|
||||
"CJK Font": "خط CJK",
|
||||
"Clear Search": "مسح البحث",
|
||||
"Header & Footer": "الرأس والتذييل",
|
||||
"Apply also in Scrolled Mode": "تطبيق أيضًا في وضع التمرير",
|
||||
"A new version of Readest is Available!": "إصدار جديد من ريديست متاح!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "إصدار ريديست {{newVersion}} متاح (الإصدار المثبت {{currentVersion}}).",
|
||||
"Download and install now?": "هل ترغب في تنزيل وتثبيت الآن؟",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "جارٍ تنزيل {{downloaded}} من {{contentLength}}",
|
||||
"Download finished": "اكتمل التنزيل",
|
||||
"DOWNLOAD & INSTALL": "تنزيل وتثبيت",
|
||||
"Changelog:": "سجل التغييرات:",
|
||||
"Software Update": "تحديث البرنامج",
|
||||
"Title": "العنوان",
|
||||
"Date Read": "تاريخ القراءة",
|
||||
"Date Added": "تاريخ الإضافة",
|
||||
"Format": "التنسيق",
|
||||
"Ascending": "تصاعدي",
|
||||
"Descending": "تنازلي",
|
||||
"Sort by...": "ترتيب حسب...",
|
||||
"Added:": "تاريخ الإضافة:",
|
||||
"Format:": "التنسيق:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Auf alle Bücher anwenden",
|
||||
"Apply to This Book": "Auf dieses Buch anwenden",
|
||||
"Unable to fetch the translation. Try again later.": "Übersetzung konnte nicht abgerufen werden. Versuchen Sie es später erneut.",
|
||||
"Update Now!": "Jetzt aktualisieren!",
|
||||
"Update": "Aktualisieren",
|
||||
"Check Update": "Aktualisierung prüfen",
|
||||
"Already the latest version": "Bereits die neueste Version",
|
||||
"Book Details": "Buchdetails",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "In die Zwischenablage kopiert",
|
||||
"Export Annotations": "Anmerkungen exportieren",
|
||||
"Auto Import on File Open": "Automatischer Import beim Öffnen einer Datei",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Klick-zu-Blättern-Bereich tauschen",
|
||||
"No chapters detected.": "Keine Kapitel erkannt.",
|
||||
"Failed to parse the EPUB file.": "Fehler beim Parsen der EPUB-Datei.",
|
||||
"This book format is not supported.": "Dieses Buchformat wird nicht unterstützt.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Übersetzung konnte nicht abgerufen werden. Bitte zuerst anmelden und erneut versuchen.",
|
||||
"Group": "Gruppieren"
|
||||
"Group": "Gruppieren",
|
||||
"Always on Top": "Immer im Vordergrund",
|
||||
"No Timeout": "Kein Timeout",
|
||||
"{{value}} minute": "{{value}} Minute",
|
||||
"{{value}} minutes": "{{value}} Minuten",
|
||||
"{{value}} hour": "{{value}} Stunde",
|
||||
"{{value}} hours": "{{value}} Stunden",
|
||||
"CJK Font": "CJK-Schriftart",
|
||||
"Clear Search": "Suche löschen",
|
||||
"Header & Footer": "Kopf- & Fußzeile",
|
||||
"Apply also in Scrolled Mode": "Auch im Scroll-Modus anwenden",
|
||||
"A new version of Readest is Available!": "Eine neue Version von Readest ist verfügbar!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} ist verfügbar (installierte Version {{currentVersion}}).",
|
||||
"Download and install now?": "Jetzt herunterladen und installieren?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Herunterladen {{downloaded}} von {{contentLength}}",
|
||||
"Download finished": "Download abgeschlossen",
|
||||
"DOWNLOAD & INSTALL": "HERUNTERLADEN & INSTALLIEREN",
|
||||
"Changelog:": "Änderungsprotokoll:",
|
||||
"Software Update": "Software-Update",
|
||||
"Title": "Titel",
|
||||
"Date Read": "Datum gelesen",
|
||||
"Date Added": "Datum hinzugefügt",
|
||||
"Format": "Format",
|
||||
"Ascending": "Aufsteigend",
|
||||
"Descending": "Absteigend",
|
||||
"Sort by...": "Sortieren nach...",
|
||||
"Added:": "Hinzugefügt:",
|
||||
"Format:": "Format:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Εφαρμογή σε όλα τα βιβλία",
|
||||
"Apply to This Book": "Εφαρμογή σε αυτό το βιβλίο",
|
||||
"Unable to fetch the translation. Try again later.": "Αδυναμία λήψης της μετάφρασης. Δοκιμάστε ξανά αργότερα.",
|
||||
"Update Now!": "Ενημέρωση τώρα!",
|
||||
"Update": "Ενημέρωση",
|
||||
"Check Update": "Έλεγχος ενημέρωσης",
|
||||
"Already the latest version": "Ήδη η τελευταία έκδοση",
|
||||
"Book Details": "Λεπτομέρειες βιβλίου",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "Αντιγράφηκε στο πρόχειρο",
|
||||
"Export Annotations": "Εξαγωγή σχολιασμών",
|
||||
"Auto Import on File Open": "Αυτόματη εισαγωγή κατά το άνοιγμα αρχείου",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Ανταλλαγή περιοχής κλικ για γύρισμα σελίδας",
|
||||
"No chapters detected.": "Δεν εντοπίστηκαν κεφάλαια.",
|
||||
"Failed to parse the EPUB file.": "Αποτυχία ανάλυσης του αρχείου EPUB.",
|
||||
"This book format is not supported.": "Αυτή η μορφή βιβλίου δεν υποστηρίζεται.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Αδυναμία λήψης της μετάφρασης. Παρακαλώ συνδεθείτε πρώτα και δοκιμάστε ξανά.",
|
||||
"Group": "Ομάδα"
|
||||
"Group": "Ομάδα",
|
||||
"Always on Top": "Πάντα στην κορυφή",
|
||||
"No Timeout": "Χωρίς χρονικό όριο",
|
||||
"{{value}} minute": "{{value}} λεπτό",
|
||||
"{{value}} minutes": "{{value}} λεπτά",
|
||||
"{{value}} hour": "{{value}} ώρα",
|
||||
"{{value}} hours": "{{value}} ώρες",
|
||||
"CJK Font": "Γραμματοσειρά CJK",
|
||||
"Clear Search": "Καθαρισμός αναζήτησης",
|
||||
"Header & Footer": "Κεφαλίδα & Υποσέλιδο",
|
||||
"Apply also in Scrolled Mode": "Εφαρμογή και σε λειτουργία κύλισης",
|
||||
"A new version of Readest is Available!": "Μια νέα έκδοση του Readest είναι διαθέσιμη!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Η έκδοση Readest {{newVersion}} είναι διαθέσιμη (έκδοση που είναι εγκατεστημένη {{currentVersion}}).",
|
||||
"Download and install now?": "Θέλετε να την κατεβάσετε και να την εγκαταστήσετε τώρα;",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Κατεβάζω {{downloaded}} από {{contentLength}}",
|
||||
"Download finished": "Η λήψη ολοκληρώθηκε",
|
||||
"DOWNLOAD & INSTALL": "ΛΗΨΗ & ΕΓΚΑΤΑΣΤΑΣΗ",
|
||||
"Changelog:": "Αλλαγές:",
|
||||
"Software Update": "Ενημέρωση λογισμικού",
|
||||
"Title": "Τίτλος",
|
||||
"Date Read": "Ημερομηνία ανάγνωσης",
|
||||
"Date Added": "Ημερομηνία προσθήκης",
|
||||
"Format": "Μορφή",
|
||||
"Ascending": "Αύξουσα",
|
||||
"Descending": "Φθίνουσα",
|
||||
"Sort by...": "Ταξινόμηση κατά...",
|
||||
"Added:": "Προστέθηκε:",
|
||||
"Format:": "Μορφή:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai"
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Aplicar a todos los libros",
|
||||
"Apply to This Book": "Aplicar a este libro",
|
||||
"Unable to fetch the translation. Try again later.": "No se puede obtener la traducción. Inténtalo de nuevo más tarde.",
|
||||
"Update Now!": "¡Actualizar ahora!",
|
||||
"Update": "Actualizar",
|
||||
"Check Update": "Comprobar actualización",
|
||||
"Already the latest version": "Ya es la última versión",
|
||||
"Book Details": "Detalles del libro",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "Copiado al portapapeles",
|
||||
"Export Annotations": "Exportar anotaciones",
|
||||
"Auto Import on File Open": "Importación automática al abrir archivo",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Intercambiar área de clic para voltear",
|
||||
"No chapters detected.": "No se detectaron capítulos.",
|
||||
"Failed to parse the EPUB file.": "Error al analizar el archivo EPUB.",
|
||||
"This book format is not supported.": "Este formato de libro no es compatible.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "No se puede obtener la traducción. Por favor, inicia sesión primero e inténtalo de nuevo.",
|
||||
"Group": "Grupo"
|
||||
"Group": "Grupo",
|
||||
"Always on Top": "Siempre en la parte superior",
|
||||
"No Timeout": "Sin tiempo de espera",
|
||||
"{{value}} minute": "{{value}} minuto",
|
||||
"{{value}} minutes": "{{value}} minutos",
|
||||
"{{value}} hour": "{{value}} hora",
|
||||
"{{value}} hours": "{{value}} horas",
|
||||
"CJK Font": "Fuente CJK",
|
||||
"Clear Search": "Limpiar búsqueda",
|
||||
"Header & Footer": "Encabezado y pie de página",
|
||||
"Apply also in Scrolled Mode": "Aplicar también en modo desplazamiento",
|
||||
"A new version of Readest is Available!": "¡Una nueva versión de Readest está disponible!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} está disponible (versión instalada {{currentVersion}}).",
|
||||
"Download and install now?": "Descargar e instalar ahora?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Descargando {{downloaded}} de {{contentLength}}",
|
||||
"Download finished": "Descarga finalizada",
|
||||
"DOWNLOAD & INSTALL": "DESCARGAR E INSTALAR",
|
||||
"Changelog:": "Notas de la versión:",
|
||||
"Software Update": "Actualización de software",
|
||||
"Title": "Título",
|
||||
"Date Read": "Fecha de lectura",
|
||||
"Date Added": "Fecha añadida",
|
||||
"Format": "Formato",
|
||||
"Ascending": "Ascendente",
|
||||
"Descending": "Descendente",
|
||||
"Sort by...": "Ordenar por...",
|
||||
"Added:": "Agregado:",
|
||||
"Format:": "Formato:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Appliquer à tous les livres",
|
||||
"Apply to This Book": "Appliquer à ce livre",
|
||||
"Unable to fetch the translation. Try again later.": "Impossible de récupérer la traduction. Réessayez plus tard.",
|
||||
"Update Now!": "Mettre à jour maintenant !",
|
||||
"Update": "Mettre à jour",
|
||||
"Check Update": "Vérifier la mise à jour",
|
||||
"Already the latest version": "Déjà la dernière version",
|
||||
"Book Details": "Détails du livre",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "Copié dans le presse-papiers",
|
||||
"Export Annotations": "Exporter les annotations",
|
||||
"Auto Import on File Open": "Importation automatique à l'ouverture du fichier",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Échanger la zone de clic pour tourner",
|
||||
"No chapters detected.": "Aucun chapitre détecté.",
|
||||
"Failed to parse the EPUB file.": "Échec de l'analyse du fichier EPUB.",
|
||||
"This book format is not supported.": "Ce format de livre n'est pas pris en charge.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Impossible de récupérer la traduction. Veuillez d'abord vous connecter et réessayer.",
|
||||
"Group": "Groupe"
|
||||
"Group": "Groupe",
|
||||
"Always on Top": "Toujours au-dessus",
|
||||
"No Timeout": "Aucun délai d'attente",
|
||||
"{{value}} minute": "{{value}} minute",
|
||||
"{{value}} minutes": "{{value}} minutes",
|
||||
"{{value}} hour": "{{value}} heure",
|
||||
"{{value}} hours": "{{value}} heures",
|
||||
"CJK Font": "Police CJK",
|
||||
"Clear Search": "Effacer la recherche",
|
||||
"Header & Footer": "En-tête et pied de page",
|
||||
"Apply also in Scrolled Mode": "Appliquer également en mode défilement",
|
||||
"A new version of Readest is Available!": "Avis de mise à jour",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} est disponible (version installée {{currentVersion}}).",
|
||||
"Download and install now?": "Télécharger et installer maintenant ?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Téléchargement {{downloaded}} sur {{contentLength}}",
|
||||
"Download finished": "Téléchargement terminé",
|
||||
"DOWNLOAD & INSTALL": "TÉLÉCHARGER ET INSTALLER",
|
||||
"Changelog:": "Journal des modifications :",
|
||||
"Software Update": "Mise à jour logicielle",
|
||||
"Title": "Titre",
|
||||
"Date Read": "Date de lecture",
|
||||
"Date Added": "Date ajoutée",
|
||||
"Format": "Format",
|
||||
"Ascending": "Ascendant",
|
||||
"Descending": "Descendant",
|
||||
"Sort by...": "Trier par...",
|
||||
"Added:": "Ajouté :",
|
||||
"Format:": "Format :",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "सभी पुस्तकों पर लागू करें",
|
||||
"Apply to This Book": "इस पुस्तक पर लागू करें",
|
||||
"Unable to fetch the translation. Try again later.": "अनुवाद प्राप्त करने में असमर्थ। बाद में पुनः प्रयास करें।",
|
||||
"Update Now!": "अभी अपडेट करें!",
|
||||
"Update": "अपडेट करें",
|
||||
"Check Update": "अपडेट जांचें",
|
||||
"Already the latest version": "पहले से ही नवीनतम संस्करण",
|
||||
"Book Details": "पुस्तक विवरण",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "क्लिपबोर्ड पर कॉपी किया गया",
|
||||
"Export Annotations": "टिप्पणियाँ निर्यात करें",
|
||||
"Auto Import on File Open": "फ़ाइल खोलने पर स्वचालित आयात",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "क्लिक-टू-फ्लिप क्षेत्र स्वैप करें",
|
||||
"No chapters detected.": "कोई अध्याय नहीं मिला।",
|
||||
"Failed to parse the EPUB file.": "EPUB फ़ाइल को पार्स करने में विफल।",
|
||||
"This book format is not supported.": "यह पुस्तक प्रारूप समर्थित नहीं है।",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "अनुवाद प्राप्त करने में असमर्थ। कृपया पहले लॉग इन करें और पुनः प्रयास करें।",
|
||||
"Group": "समूह"
|
||||
"Group": "समूह",
|
||||
"Always on Top": "सर्वोच्च पर हमेशा",
|
||||
"No Timeout": "कोई समय समाप्त नहीं",
|
||||
"{{value}} minute": "{{value}} मिनट",
|
||||
"{{value}} minutes": "{{value}} मिनट",
|
||||
"{{value}} hour": "{{value}} घंटा",
|
||||
"{{value}} hours": "{{value}} घंटे",
|
||||
"CJK Font": "CJK फ़ॉन्ट",
|
||||
"Clear Search": "खोज साफ करें",
|
||||
"Header & Footer": "हेडर और फ़ुटर",
|
||||
"Apply also in Scrolled Mode": "स्क्रॉल मोड में भी लागू करें",
|
||||
"A new version of Readest is Available!": "Readest का एक नया संस्करण उपलब्ध है!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} उपलब्ध है (स्थापित संस्करण {{currentVersion}})।",
|
||||
"Download and install now?": "अब डाउनलोड और इंस्टॉल करें?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "डाउनलोड कर रहा है {{downloaded}} का {{contentLength}}",
|
||||
"Download finished": "डाउनलोड पूरा हुआ",
|
||||
"DOWNLOAD & INSTALL": "डाउनलोड और इंस्टॉल करें",
|
||||
"Changelog:": "चेंजलॉग:",
|
||||
"Software Update": "सॉफ़्टवेयर अपडेट",
|
||||
"Title": "शीर्षक",
|
||||
"Date Read": "पढ़ने की तारीख",
|
||||
"Date Added": "जोड़ा गया दिनांक",
|
||||
"Format": "फॉर्मेट",
|
||||
"Ascending": "आरोही",
|
||||
"Descending": "अवरोही",
|
||||
"Sort by...": "द्वारा क्रमबद्ध करें...",
|
||||
"Added:": "जोड़ा गया:",
|
||||
"Format:": "फॉर्मेट:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Terapkan ke semua buku",
|
||||
"Apply to This Book": "Terapkan ke buku ini",
|
||||
"Unable to fetch the translation. Try again later.": "Tidak dapat mengambil terjemahan. Coba lagi nanti.",
|
||||
"Update Now!": "Perbarui Sekarang!",
|
||||
"Update": "Perbarui",
|
||||
"Check Update": "Periksa pembaruan",
|
||||
"Already the latest version": "Sudah versi terbaru",
|
||||
"Book Details": "Detail Buku",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "Disalin ke papan klip",
|
||||
"Export Annotations": "Ekspor Anotasi",
|
||||
"Auto Import on File Open": "Impor Otomatis saat File Dibuka",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Tukar Area Klik untuk Membalik",
|
||||
"No chapters detected.": "Tidak ada bab yang terdeteksi.",
|
||||
"Failed to parse the EPUB file.": "Gagal mengurai file EPUB.",
|
||||
"This book format is not supported.": "Format buku ini tidak didukung.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Tidak dapat mengambil terjemahan. Silakan masuk terlebih dahulu dan coba lagi.",
|
||||
"Group": "Grup"
|
||||
"Group": "Grup",
|
||||
"Always on Top": "Selalu di Atas",
|
||||
"No Timeout": "Tidak Ada Waktu Habis",
|
||||
"{{value}} minute": "{{value}} menit",
|
||||
"{{value}} minutes": "{{value}} menit",
|
||||
"{{value}} hour": "{{value}} jam",
|
||||
"{{value}} hours": "{{value}} jam",
|
||||
"CJK Font": "Font CJK",
|
||||
"Clear Search": "Hapus Pencarian",
|
||||
"Header & Footer": "Header & Footer",
|
||||
"Apply also in Scrolled Mode": "Terapkan juga di Mode Gulir",
|
||||
"A new version of Readest is Available!": "Versi baru Readest Tersedia!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} tersedia (versi terinstal {{currentVersion}}).",
|
||||
"Download and install now?": "Unduh dan instal sekarang?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Mengunduh {{downloaded}} dari {{contentLength}}",
|
||||
"Download finished": "Unduhan selesai",
|
||||
"DOWNLOAD & INSTALL": "UNDUH & INSTAL",
|
||||
"Changelog:": "Catatan Perubahan:",
|
||||
"Software Update": "Pembaruan Perangkat Lunak",
|
||||
"Title": "Judul",
|
||||
"Date Read": "Tanggal Dibaca",
|
||||
"Date Added": "Tanggal Ditambahkan",
|
||||
"Format": "Format",
|
||||
"Ascending": "Naik",
|
||||
"Descending": "Turun",
|
||||
"Sort by...": "Urutkan berdasarkan...",
|
||||
"Added:": "Ditambahkan:",
|
||||
"Format:": "Format:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Applica a tutti i libri",
|
||||
"Apply to This Book": "Applica a questo libro",
|
||||
"Unable to fetch the translation. Try again later.": "Impossibile recuperare la traduzione. Riprova più tardi.",
|
||||
"Update Now!": "Aggiorna ora!",
|
||||
"Update": "Aggiorna",
|
||||
"Check Update": "Controlla aggiornamento",
|
||||
"Already the latest version": "Già l'ultima versione",
|
||||
"Book Details": "Dettagli libro",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "Copiato negli appunti",
|
||||
"Export Annotations": "Esporta annotazioni",
|
||||
"Auto Import on File Open": "Importazione automatica all'apertura del file",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Scambia area click per voltare pagina",
|
||||
"No chapters detected.": "Nessun capitolo rilevato.",
|
||||
"Failed to parse the EPUB file.": "Impossibile analizzare il file EPUB.",
|
||||
"This book format is not supported.": "Questo formato di libro non è supportato.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Impossibile recuperare la traduzione. Accedi prima e riprova.",
|
||||
"Group": "Gruppo"
|
||||
"Group": "Gruppo",
|
||||
"Always on Top": "Sempre in primo piano",
|
||||
"No Timeout": "Nessun timeout",
|
||||
"{{value}} minute": "{{value}} minuto",
|
||||
"{{value}} minutes": "{{value}} minuti",
|
||||
"{{value}} hour": "{{value}} ora",
|
||||
"{{value}} hours": "{{value}} ore",
|
||||
"CJK Font": "Font CJK",
|
||||
"Clear Search": "Cancella ricerca",
|
||||
"Header & Footer": "Intestazione e piè di pagina",
|
||||
"Apply also in Scrolled Mode": "Applica anche in modalità scorrimento",
|
||||
"A new version of Readest is Available!": "È disponibile una nuova versione di Readest!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} è disponibile (versione installata {{currentVersion}}).",
|
||||
"Download and install now?": "Scarica e installa ora?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Scaricando {{downloaded}} di {{contentLength}}",
|
||||
"Download finished": "Download completato",
|
||||
"DOWNLOAD & INSTALL": "SCARICA E INSTALLA",
|
||||
"Changelog:": "Note di rilascio:",
|
||||
"Software Update": "Aggiornamento software",
|
||||
"Title": "Titolo",
|
||||
"Date Read": "Data di lettura",
|
||||
"Date Added": "Data aggiunta",
|
||||
"Format": "Formato",
|
||||
"Ascending": "Crescente",
|
||||
"Descending": "Decrescente",
|
||||
"Sort by...": "Ordina per...",
|
||||
"Added:": "Aggiunto:",
|
||||
"Format:": "Formato:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "すべての書籍に適用",
|
||||
"Apply to This Book": "この書籍に適用",
|
||||
"Unable to fetch the translation. Try again later.": "翻訳を取得できません。後で再試行してください。",
|
||||
"Update Now!": "今すぐ更新!",
|
||||
"Update": "更新",
|
||||
"Check Update": "更新を確認",
|
||||
"Already the latest version": "すでに最新バージョン",
|
||||
"Book Details": "書籍の詳細",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "クリップボードにコピー",
|
||||
"Export Annotations": "注釈をエクスポート",
|
||||
"Auto Import on File Open": "ファイルを開くと自動インポート",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "クリックめくりエリアを入れ替え",
|
||||
"No chapters detected.": "章が検出されません。",
|
||||
"Failed to parse the EPUB file.": "EPUBファイルの解析に失敗しました。",
|
||||
"This book format is not supported.": "この書籍形式はサポートされていません。",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "翻訳を取得できません。まずログインして再試行してください。",
|
||||
"Group": "グループ"
|
||||
"Group": "グループ",
|
||||
"Always on Top": "常に最前面",
|
||||
"No Timeout": "タイムアウトなし",
|
||||
"{{value}} minute": "{{value}}分",
|
||||
"{{value}} minutes": "{{value}}分",
|
||||
"{{value}} hour": "{{value}}時間",
|
||||
"{{value}} hours": "{{value}}時間",
|
||||
"CJK Font": "CJKフォント",
|
||||
"Clear Search": "検索をクリア",
|
||||
"Header & Footer": "ヘッダーとフッター",
|
||||
"Apply also in Scrolled Mode": "スクロールモードでも適用",
|
||||
"A new version of Readest is Available!": "新しいバージョンのReadestが利用可能です!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}}が利用可能です(インストールされたバージョン{{currentVersion}})。",
|
||||
"Download and install now?": "今すぐダウンロードしてインストールしますか?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "ダウンロード中 {{downloaded}} / {{contentLength}}",
|
||||
"Download finished": "ダウンロード完了",
|
||||
"DOWNLOAD & INSTALL": "更新",
|
||||
"Changelog:": "変更ログ:",
|
||||
"Software Update": "ソフトウェアの更新",
|
||||
"Title": "タイトル",
|
||||
"Date Read": "読書日",
|
||||
"Date Added": "追加日",
|
||||
"Format": "形式",
|
||||
"Ascending": "昇順",
|
||||
"Descending": "降順",
|
||||
"Sort by...": "並べ替え...",
|
||||
"Added:": "追加日:",
|
||||
"Format:": "形式:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "모든 책에 적용",
|
||||
"Apply to This Book": "이 책에 적용",
|
||||
"Unable to fetch the translation. Try again later.": "번역을 가져올 수 없습니다. 나중에 다시 시도하세요.",
|
||||
"Update Now!": "지금 업데이트!",
|
||||
"Update": "업데이트",
|
||||
"Check Update": "업데이트 확인",
|
||||
"Already the latest version": "이미 최신 버전",
|
||||
"Book Details": "책 세부 정보",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "클립보드에 복사",
|
||||
"Export Annotations": "주석 내보내기",
|
||||
"Auto Import on File Open": "파일 열 때 자동 가져오기",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "클릭-뒤집기 영역 교환",
|
||||
"No chapters detected.": "장 발견되지 않음.",
|
||||
"Failed to parse the EPUB file.": "EPUB 파일 구문 분석 실패.",
|
||||
"This book format is not supported.": "이 책 형식은 지원되지 않습니다.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "번역을 가져올 수 없습니다. 먼저 로그인하고 다시 시도하세요.",
|
||||
"Group": "그룹"
|
||||
"Group": "그룹",
|
||||
"Always on Top": "항상 위에",
|
||||
"No Timeout": "타임아웃 없음",
|
||||
"{{value}} minute": "{{value}} 분",
|
||||
"{{value}} minutes": "{{value}} 분",
|
||||
"{{value}} hour": "{{value}} 시간",
|
||||
"{{value}} hours": "{{value}} 시간",
|
||||
"CJK Font": "CJK 글꼴",
|
||||
"Clear Search": "검색 지우기",
|
||||
"Header & Footer": "헤더 및 푸터",
|
||||
"Apply also in Scrolled Mode": "스크롤 모드에서도 적용",
|
||||
"A new version of Readest is Available!": "Readest의 새 버전이 있습니다!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}}가 사용 가능 (설치된 버전 {{currentVersion}}).",
|
||||
"Download and install now?": "지금 다운로드하고 설치하시겠습니까?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "다운로드 중 {{downloaded}} / {{contentLength}}",
|
||||
"Download finished": "다운로드 완료",
|
||||
"DOWNLOAD & INSTALL": "다운로드 및 설치",
|
||||
"Changelog:": "변경 로그:",
|
||||
"Software Update": "소프트웨어 업데이트",
|
||||
"Title": "제목",
|
||||
"Date Read": "읽은 날짜",
|
||||
"Date Added": "추가된 날짜",
|
||||
"Format": "형식",
|
||||
"Ascending": "오름차순",
|
||||
"Descending": "내림차순",
|
||||
"Sort by...": "정렬 기준...",
|
||||
"Added:": "추가됨:",
|
||||
"Format:": "형식:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Zastosuj do wszystkich książek",
|
||||
"Apply to This Book": "Zastosuj do tej książki",
|
||||
"Unable to fetch the translation. Try again later.": "Nie udało się pobrać tłumaczenia. Spróbuj ponownie później.",
|
||||
"Update Now!": "Zaktualizuj teraz!",
|
||||
"Update": "Zaktualizuj",
|
||||
"Check Update": "Sprawdź aktualizację",
|
||||
"Already the latest version": "Już najnowsza wersja",
|
||||
"Book Details": "Szczegóły książki",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "Skopiowano do schowka",
|
||||
"Export Annotations": "Eksportuj adnotacje",
|
||||
"Auto Import on File Open": "Automatyczne importowanie przy otwieraniu pliku",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Zamień obszar kliknięcia na przewracanie",
|
||||
"No chapters detected.": "Nie wykryto rozdziałów.",
|
||||
"Failed to parse the EPUB file.": "Nie udało się przeanalizować pliku EPUB.",
|
||||
"This book format is not supported.": "Ten format książki nie jest obsługiwany.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Nie można pobrać tłumaczenia. Proszę najpierw się zalogować i spróbować ponownie.",
|
||||
"Group": "Grupa"
|
||||
"Group": "Grupa",
|
||||
"Always on Top": "Zawsze na wierzchu",
|
||||
"No Timeout": "Brak limitu czasu",
|
||||
"{{value}} minute": "{{value}} minuta",
|
||||
"{{value}} minutes": "{{value}} minut",
|
||||
"{{value}} hour": "{{value}} godzina",
|
||||
"{{value}} hours": "{{value}} godziny",
|
||||
"CJK Font": "Czcionka CJK",
|
||||
"Clear Search": "Wyczyść wyszukiwanie",
|
||||
"Header & Footer": "Nagłówek i stopka",
|
||||
"Apply also in Scrolled Mode": "Zastosuj również w trybie przewijania",
|
||||
"A new version of Readest is Available!": "Nowa wersja Readest jest dostępna!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} jest dostępny (zainstalowana wersja {{currentVersion}}).",
|
||||
"Download and install now?": "Pobierz i zainstaluj teraz?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Pobieranie {{downloaded}} z {{contentLength}}",
|
||||
"Download finished": "Pobieranie zakończone",
|
||||
"DOWNLOAD & INSTALL": "POBIERZ I ZAINSTALUJ",
|
||||
"Changelog:": "Dziennik zmian:",
|
||||
"Software Update": "Aktualizacja oprogramowania",
|
||||
"Title": "Tytuł",
|
||||
"Date Read": "Data przeczytania",
|
||||
"Date Added": "Data dodania",
|
||||
"Format": "Format",
|
||||
"Ascending": "Rosnąco",
|
||||
"Descending": "Malejąco",
|
||||
"Sort by...": "Sortuj według...",
|
||||
"Added:": "Data dodania:",
|
||||
"Format:": "Format:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Aplicar a todos os livros",
|
||||
"Apply to This Book": "Aplicar a este livro",
|
||||
"Unable to fetch the translation. Try again later.": "Não foi possível buscar a tradução. Tente novamente mais tarde.",
|
||||
"Update Now!": "Atualizar Agora!",
|
||||
"Update": "Atualizar",
|
||||
"Check Update": "Verificar atualização",
|
||||
"Already the latest version": "Já é a versão mais recente",
|
||||
"Book Details": "Detalhes do Livro",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "Copiado para a área de transferência",
|
||||
"Export Annotations": "Exportar Anotações",
|
||||
"Auto Import on File Open": "Importação Automática ao Abrir Arquivo",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Trocar Área de Clique para Virar",
|
||||
"No chapters detected.": "Nenhum capítulo detectado.",
|
||||
"Failed to parse the EPUB file.": "Falha ao analisar o arquivo EPUB.",
|
||||
"This book format is not supported.": "Este formato de livro não é suportado.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Não foi possível buscar a tradução. Faça login primeiro e tente novamente.",
|
||||
"Group": "Grupo"
|
||||
"Group": "Grupo",
|
||||
"Always on Top": "Sempre no Topo",
|
||||
"No Timeout": "Sem Tempo Limite",
|
||||
"{{value}} minute": "{{value}} minuto",
|
||||
"{{value}} minutes": "{{value}} minutos",
|
||||
"{{value}} hour": "{{value}} hora",
|
||||
"{{value}} hours": "{{value}} horas",
|
||||
"CJK Font": "Fonte CJK",
|
||||
"Clear Search": "Limpar Pesquisa",
|
||||
"Header & Footer": "Cabeçalho e Rodapé",
|
||||
"Apply also in Scrolled Mode": "Aplicar também no Modo Rolado",
|
||||
"A new version of Readest is Available!": "Uma nova versão do Readest está disponível!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} está disponível (versão instalada {{currentVersion}}).",
|
||||
"Download and install now?": "Baixar e instalar agora?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Baixando {{downloaded}} de {{contentLength}}",
|
||||
"Download finished": "Download concluído",
|
||||
"DOWNLOAD & INSTALL": "BAIXAR E INSTALAR",
|
||||
"Changelog:": "Notas de versão:",
|
||||
"Software Update": "Atualização de Software",
|
||||
"Title": "Título",
|
||||
"Date Read": "Data de leitura",
|
||||
"Date Added": "Data de adição",
|
||||
"Format": "Formato",
|
||||
"Ascending": "Crescente",
|
||||
"Descending": "Decrescente",
|
||||
"Sort by...": "Ordenar por...",
|
||||
"Added:": "Adicionado:",
|
||||
"Format:": "Formato:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Применить ко всем книгам",
|
||||
"Apply to This Book": "Применить к этой книге",
|
||||
"Unable to fetch the translation. Try again later.": "Не удалось получить перевод. Попробуйте позже.",
|
||||
"Update Now!": "Обновить сейчас!",
|
||||
"Update": "Обновить",
|
||||
"Check Update": "Проверить обновление",
|
||||
"Already the latest version": "Уже последняя версия",
|
||||
"Book Details": "Детали книги",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "Скопировано в буфер обмена",
|
||||
"Export Annotations": "Экспортировать аннотации",
|
||||
"Auto Import on File Open": "Автоматический импорт при открытии файла",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Переключить область клика для переворота",
|
||||
"No chapters detected.": "Не обнаружено глав.",
|
||||
"Failed to parse the EPUB file.": "Не удалось разобрать файл EPUB.",
|
||||
"This book format is not supported.": "Этот формат книги не поддерживается.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Не удалось получить перевод. Пожалуйста, войдите в систему и попробуйте снова.",
|
||||
"Group": "Группа"
|
||||
"Group": "Группа",
|
||||
"Always on Top": "Всегда сверху",
|
||||
"No Timeout": "Нет таймаута",
|
||||
"{{value}} minute": "{{value}} минута",
|
||||
"{{value}} minutes": "{{value}} минуты",
|
||||
"{{value}} hour": "{{value}} час",
|
||||
"{{value}} hours": "{{value}} часа",
|
||||
"CJK Font": "CJK Шрифт",
|
||||
"Clear Search": "Очистить поиск",
|
||||
"Header & Footer": "Заголовок и нижний колонтитул",
|
||||
"Apply also in Scrolled Mode": "Применить также в прокручиваемом режиме",
|
||||
"A new version of Readest is Available!": "Доступна новая версия Readest!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} доступен (установленная версия {{currentVersion}}).",
|
||||
"Download and install now?": "Скачать и установить сейчас?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Скачано {{downloaded}} из {{contentLength}}",
|
||||
"Download finished": "Загрузка завершена",
|
||||
"DOWNLOAD & INSTALL": "СКАЧАТЬ И УСТАНОВИТЬ",
|
||||
"Changelog:": "Изменения:",
|
||||
"Software Update": "Обновление программного обеспечения",
|
||||
"Title": "Название",
|
||||
"Date Read": "Дата прочтения",
|
||||
"Date Added": "Дата добавления",
|
||||
"Format": "Формат",
|
||||
"Ascending": "По возрастанию",
|
||||
"Descending": "По убыванию",
|
||||
"Sort by...": "Сортировать по...",
|
||||
"Added:": "Добавлено:",
|
||||
"Format:": "Формат:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Tüm kitaplara uygula",
|
||||
"Apply to This Book": "Bu kitaba uygula",
|
||||
"Unable to fetch the translation. Try again later.": "Çeviri alınamıyor. Daha sonra tekrar deneyin.",
|
||||
"Update Now!": "Şimdi Güncelle!",
|
||||
"Update": "Güncelle",
|
||||
"Check Update": "Güncellemeyi kontrol et",
|
||||
"Already the latest version": "Zaten en son sürüm",
|
||||
"Book Details": "Kitap Detayları",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "Panoya kopyalandı",
|
||||
"Export Annotations": "Notları Dışa Aktar",
|
||||
"Auto Import on File Open": "Dosya Açıldığında Otomatik İçe Aktar",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Tıkla-Çevir Alanını Değiştir",
|
||||
"No chapters detected.": "Bölüm bulunamadı.",
|
||||
"Failed to parse the EPUB file.": "EPUB dosyası ayrıştırılamadı.",
|
||||
"This book format is not supported.": "Bu kitap formatı desteklenmiyor.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Çeviri alınamıyor. Lütfen önce giriş yapın ve tekrar deneyin.",
|
||||
"Group": "Grup"
|
||||
"Group": "Grup",
|
||||
"Always on Top": "Her Zaman Üstte",
|
||||
"No Timeout": "Zaman Aşımı Yok",
|
||||
"{{value}} minute": "{{value}} dakika",
|
||||
"{{value}} minutes": "{{value}} dakika",
|
||||
"{{value}} hour": "{{value}} saat",
|
||||
"{{value}} hours": "{{value}} saat",
|
||||
"CJK Font": "CJK Yazı Tipi",
|
||||
"Clear Search": "Aramayı Temizle",
|
||||
"Header & Footer": "Başlık ve Altbilgi",
|
||||
"Apply also in Scrolled Mode": "Kaydırılmış Modda da Uygula",
|
||||
"A new version of Readest is Available!": "Yeni bir Readest sürümü mevcut!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} mevcut (yüklü sürüm {{currentVersion}}).",
|
||||
"Download and install now?": "Şimdi indirip yükleyelim mi?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "İndiriliyor {{downloaded}}/{{contentLength}}",
|
||||
"Download finished": "İndirme tamamlandı",
|
||||
"DOWNLOAD & INSTALL": "İNDİR & YÜKLE",
|
||||
"Changelog:": "Değişiklik Günlüğü:",
|
||||
"Software Update": "Yazılım Güncellemesi",
|
||||
"Title": "Başlık",
|
||||
"Date Read": "Okunma Tarihi",
|
||||
"Date Added": "Eklenme Tarihi",
|
||||
"Format": "Biçim",
|
||||
"Ascending": "Artan",
|
||||
"Descending": "Azalan",
|
||||
"Sort by...": "Şuna göre sırala...",
|
||||
"Added:": "Eklenme:",
|
||||
"Format:": "Biçim:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Застосувати до всіх книг",
|
||||
"Apply to This Book": "Застосувати до цієї книги",
|
||||
"Unable to fetch the translation. Try again later.": "Не вдалося отримати переклад. Спробуйте пізніше.",
|
||||
"Update Now!": "Оновити зараз!",
|
||||
"Update": "Оновити",
|
||||
"Check Update": "Перевірити оновлення",
|
||||
"Already the latest version": "Вже остання версія",
|
||||
"Book Details": "Деталі книги",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "Скопійовано в буфер обміну",
|
||||
"Export Annotations": "Експортувати анотації",
|
||||
"Auto Import on File Open": "Автоматичний імпорт при відкритті файлу",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Поміняти місцями область кліку для перевороту",
|
||||
"No chapters detected.": "Не виявлено жодного розділу.",
|
||||
"Failed to parse the EPUB file.": "Не вдалося розібрати файл EPUB.",
|
||||
"This book format is not supported.": "Цей формат книги не підтримується.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Не вдалося отримати переклад. Будь ласка, спочатку увійдіть у систему та спробуйте ще раз.",
|
||||
"Group": "Група"
|
||||
"Group": "Група",
|
||||
"Always on Top": "Завжди зверху",
|
||||
"No Timeout": "Без тайм-ауту",
|
||||
"{{value}} minute": "{{value}} хвилина",
|
||||
"{{value}} minutes": "{{value}} хвилин",
|
||||
"{{value}} hour": "{{value}} година",
|
||||
"{{value}} hours": "{{value}} години",
|
||||
"CJK Font": "CJK шрифт",
|
||||
"Clear Search": "Очистити пошук",
|
||||
"Header & Footer": "Заголовок і нижній колонтитул",
|
||||
"Apply also in Scrolled Mode": "Застосувати також у прокрученому режимі",
|
||||
"A new version of Readest is Available!": "Доступна нова версія Readest!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} доступна (встановлена версія {{currentVersion}}).",
|
||||
"Download and install now?": "Завантажити та встановити зараз?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Завантаження {{downloaded}} з {{contentLength}}",
|
||||
"Download finished": "Завантаження завершено",
|
||||
"DOWNLOAD & INSTALL": "ЗАВАНТАЖИТИ І ВСТАНОВИТИ",
|
||||
"Changelog:": "Журнал змін:",
|
||||
"Software Update": "Оновлення програмного забезпечення",
|
||||
"Title": "Назва",
|
||||
"Date Read": "Дата прочитання",
|
||||
"Date Added": "Дата додавання",
|
||||
"Format": "Формат",
|
||||
"Ascending": "За зростанням",
|
||||
"Descending": "За спаданням",
|
||||
"Sort by...": "Сортувати за...",
|
||||
"Added:": "Додано:",
|
||||
"Format:": "Формат:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "Áp dụng cho tất cả sách",
|
||||
"Apply to This Book": "Áp dụng cho sách này",
|
||||
"Unable to fetch the translation. Try again later.": "Không thể tải dịch vụ. Vui lòng thử lại sau.",
|
||||
"Update Now!": "Cập nhật ngay!",
|
||||
"Update": "Cập nhật",
|
||||
"Check Update": "Kiểm tra cập nhật",
|
||||
"Already the latest version": "Đã là phiên bản mới nhất",
|
||||
"Book Details": "Chi tiết sách",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "Đã sao chép vào bảng ghi",
|
||||
"Export Annotations": "Xuất chú thích",
|
||||
"Auto Import on File Open": "Tự động nhập khi mở tệp",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai SC",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"Swap Click-to-Flip Area": "Hoán đổi khu vực nhấp để lật trang",
|
||||
"No chapters detected.": "Không phát hiện thấy chương nào.",
|
||||
"Failed to parse the EPUB file.": "Không thể phân tích tệp EPUB.",
|
||||
"This book format is not supported.": "Định dạng sách này không được hỗ trợ.",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Không thể tải dịch vụ. Vui lòng đăng nhập trước và thử lại.",
|
||||
"Group": "Nhóm"
|
||||
"Group": "Nhóm",
|
||||
"Always on Top": "Luôn ở trên cùng",
|
||||
"No Timeout": "Không có thời gian chờ",
|
||||
"{{value}} minute": "{{value}} phút",
|
||||
"{{value}} minutes": "{{value}} phút",
|
||||
"{{value}} hour": "{{value}} giờ",
|
||||
"{{value}} hours": "{{value}} giờ",
|
||||
"CJK Font": "Phông chữ CJK",
|
||||
"Clear Search": "Xóa tìm kiếm",
|
||||
"Header & Footer": "Đầu trang & Chân trang",
|
||||
"Apply also in Scrolled Mode": "Áp dụng cũng trong chế độ cuộn",
|
||||
"A new version of Readest is Available!": "Đã có phiên bản mới của Readest!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} đã có sẵn (phiên bản đã cài đặt {{currentVersion}}).",
|
||||
"Download and install now?": "Tải xuống và cài đặt ngay bây giờ?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Đang tải {{downloaded}} trong {{contentLength}}",
|
||||
"Download finished": "Tải xuống hoàn tất",
|
||||
"DOWNLOAD & INSTALL": "TẢI XUỐNG & CÀI ĐẶT",
|
||||
"Changelog:": "Thay đổi:",
|
||||
"Software Update": "Cập nhật phần mềm",
|
||||
"Title": "Tiêu đề",
|
||||
"Date Read": "Ngày đã đọc",
|
||||
"Date Added": "Ngày thêm",
|
||||
"Format": "Định dạng",
|
||||
"Ascending": "Tăng dần",
|
||||
"Descending": "Giảm dần",
|
||||
"Sort by...": "Sắp xếp theo...",
|
||||
"Added:": "Đã thêm:",
|
||||
"Format:": "Định dạng:",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "应用于所有书籍",
|
||||
"Apply to This Book": "应用于此书籍",
|
||||
"Unable to fetch the translation. Try again later.": "无法获取翻译,请稍后重试。",
|
||||
"Update Now!": "立即更新!",
|
||||
"Update": "更新",
|
||||
"Check Update": "检查更新",
|
||||
"Already the latest version": "已经是最新版本",
|
||||
"Book Details": "书籍详情",
|
||||
@@ -230,7 +228,7 @@
|
||||
"Maximum Column Height": "最大列高",
|
||||
"Maximum Column Width": "最大列宽",
|
||||
"Continuous Scroll": "连续滚动",
|
||||
"Fullscreen": "全屏",
|
||||
"Fullscreen": "全屏显示",
|
||||
"No supported files found. Supported formats: {{formats}}": "未找到支持的文件。支持的格式:{{formats}}",
|
||||
"Drop to Import Books": "拖放导入书籍",
|
||||
"Custom": "自定义",
|
||||
@@ -260,10 +258,39 @@
|
||||
"Export Annotations": "导出笔记",
|
||||
"Auto Import on File Open": "打开文件时自动导入",
|
||||
"LXGW WenKai GB Screen": "霞鹜文楷",
|
||||
"LXGW WenKai TC": "霞鶩文楷",
|
||||
"Swap Click-to-Flip Area": "交换点击翻页区域",
|
||||
"No chapters detected.": "未检测到章节。",
|
||||
"Failed to parse the EPUB file.": "无法解析 EPUB 文件。",
|
||||
"This book format is not supported.": "不支持此书籍格式。",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "无法获取翻译。请登录后重试。",
|
||||
"Group": "分组"
|
||||
"Group": "分组",
|
||||
"Always on Top": "窗口置顶",
|
||||
"No Timeout": "关闭定时",
|
||||
"{{value}} minute": "{{value}}分钟",
|
||||
"{{value}} minutes": "{{value}}分钟",
|
||||
"{{value}} hour": "{{value}}小时",
|
||||
"{{value}} hours": "{{value}}小时",
|
||||
"CJK Font": "中文字体",
|
||||
"Clear Search": "清除搜索",
|
||||
"Header & Footer": "页眉页脚",
|
||||
"Apply also in Scrolled Mode": "应用到滚动模式",
|
||||
"A new version of Readest is Available!": "有新版本的 Readest 可更新!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} 可更新(已安装版本 {{currentVersion}})。",
|
||||
"Download and install now?": "立即下载并安装?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "正在下载 {{downloaded}} / {{contentLength}}",
|
||||
"Download finished": "下载完成",
|
||||
"DOWNLOAD & INSTALL": "下载并安装",
|
||||
"Changelog:": "更新日志:",
|
||||
"Software Update": "软件更新",
|
||||
"Title": "书名",
|
||||
"Date Read": "阅读日期",
|
||||
"Date Added": "添加日期",
|
||||
"Format": "格式",
|
||||
"Ascending": "升序",
|
||||
"Descending": "降序",
|
||||
"Sort by...": "排序方式...",
|
||||
"Added:": "添加日期",
|
||||
"Format:": "格式",
|
||||
"GuanKiapTsingKhai-T": "原俠正楷-T"
|
||||
}
|
||||
|
||||
@@ -126,8 +126,6 @@
|
||||
"Apply to All Books": "應用於所有書籍",
|
||||
"Apply to This Book": "應用於此書籍",
|
||||
"Unable to fetch the translation. Try again later.": "無法獲取翻譯,請稍後再試。",
|
||||
"Update Now!": "立即更新!",
|
||||
"Update": "更新",
|
||||
"Check Update": "檢查更新",
|
||||
"Already the latest version": "已經是最新版本",
|
||||
"Book Details": "書籍詳情",
|
||||
@@ -259,11 +257,40 @@
|
||||
"Copied to clipboard": "已複製到剪貼板",
|
||||
"Export Annotations": "匯出筆記",
|
||||
"Auto Import on File Open": "打開文件時自動導入",
|
||||
"LXGW WenKai GB Screen": "霞鶩文楷",
|
||||
"LXGW WenKai GB Screen": "霞鶩文楷 SC",
|
||||
"LXGW WenKai TC": "霞鶩文楷 TC",
|
||||
"Swap Click-to-Flip Area": "交換點擊翻頁區域",
|
||||
"No chapters detected.": "未檢測到章節。",
|
||||
"Failed to parse the EPUB file.": "無法解析 EPUB 文件。",
|
||||
"This book format is not supported.": "不支援此書籍格式。",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "無法獲取翻譯。請先登入然後重試。",
|
||||
"Group": "分組"
|
||||
"Group": "分組",
|
||||
"Always on Top": "視窗置頂",
|
||||
"No Timeout": "關閉定時",
|
||||
"{{value}} minute": "{{value}}分鐘",
|
||||
"{{value}} minutes": "{{value}}分鐘",
|
||||
"{{value}} hour": "{{value}}小時",
|
||||
"{{value}} hours": "{{value}}小時",
|
||||
"CJK Font": "中文字體",
|
||||
"Clear Search": "清除搜索",
|
||||
"Header & Footer": "頁眉頁腳",
|
||||
"Apply also in Scrolled Mode": "滾動模式同樣適用",
|
||||
"A new version of Readest is Available!": "有新版本的 Readest 可更新!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} 可更新(已安裝版本 {{currentVersion}})。",
|
||||
"Download and install now?": "立即下載並安裝?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "正在下載 {{downloaded}} / {{contentLength}}",
|
||||
"Download finished": "下載完成",
|
||||
"DOWNLOAD & INSTALL": "下載並安裝",
|
||||
"Changelog:": "更新日誌:",
|
||||
"Software Update": "軟體更新",
|
||||
"Title": "書名",
|
||||
"Date Read": "閱讀日期",
|
||||
"Date Added": "添加日期",
|
||||
"Format": "格式",
|
||||
"Ascending": "升序",
|
||||
"Descending": "降序",
|
||||
"Sort by...": "排序方式...",
|
||||
"Added:": "添加日期",
|
||||
"Format:": "格式",
|
||||
"GuanKiapTsingKhai-T": "原俠正楷-T"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.36": {
|
||||
"date": "2025-04-20",
|
||||
"notes": [
|
||||
"📥 New download and upload buttons in the book detail dialog for easier file management",
|
||||
"📂 You can now open files directly in Readest from your file manager on Android and iOS",
|
||||
"🛠️ Fixed an issue where files with quotation marks in the filename could not be opened",
|
||||
"📖 Improved the immersive reading experience on the reader page for Android and iOS",
|
||||
"🌐 Added GuanKiapTsingKhai-T to the list of available CJK fonts"
|
||||
]
|
||||
},
|
||||
"0.9.35": {
|
||||
"date": "2025-04-15",
|
||||
"notes": [
|
||||
"🛠️ Fixed an issue where books without cover images can not be synced across devices",
|
||||
"📱 Added in-app update support for Android",
|
||||
"📚 You can now sort books by title or author in your library"
|
||||
]
|
||||
},
|
||||
"0.9.33": {
|
||||
"date": "2025-04-13",
|
||||
"notes": [
|
||||
"New: Redesigned update dialog with a progress bar during downloads for a smoother update experience.",
|
||||
"New: You can now set header and footer visibility separately for paginated and scroll modes.",
|
||||
"Fixed layout issues in the bottom configuration panel when using RTL (right-to-left) languages.",
|
||||
"Fixed TXT file import issues on Android by improving support for content provider file paths.",
|
||||
"Fixed transformed punctuations when highlighting text with vertical layout.",
|
||||
"Improved login flow on macOS by handling OAuth with the native ASWebAuthenticationSession."
|
||||
]
|
||||
},
|
||||
"0.9.32": {
|
||||
"date": "2025-04-09",
|
||||
"notes": [
|
||||
"No more resetting of font size and color when you apply custom book styles",
|
||||
"The underline and squiggly highlight decorations have been adjusted to sit neatly between text lines",
|
||||
"With more font options available, the font menu is no longer forced in-line",
|
||||
"LXGW WenKai TC is now part of our CJK font list"
|
||||
]
|
||||
},
|
||||
"0.9.31": {
|
||||
"date": "2025-04-08",
|
||||
"notes": [
|
||||
"iOS: Text-to-Speech (TTS) now works in the background",
|
||||
"Android: Import files from native file manager",
|
||||
"Desktop: Add option to keep window on top",
|
||||
"Vertical layout for highlighting tools",
|
||||
"Added options to auto-stop TTS after timeout",
|
||||
"Added book search functionality in bookshelf",
|
||||
"Improved font support for CJK users"
|
||||
]
|
||||
},
|
||||
"0.9.30": {
|
||||
"date": "2025-04-05",
|
||||
"notes": [
|
||||
|
||||
@@ -48,9 +48,15 @@ tauri-plugin-haptics = "2.2.4"
|
||||
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
rand = "0.8"
|
||||
cocoa = "0.25"
|
||||
objc = "0.2.7"
|
||||
rand = "0.8"
|
||||
objc-foundation = "0.1.1"
|
||||
objc_id = "0.1.1"
|
||||
block = "0.1.6"
|
||||
objc2 = "0.6"
|
||||
objc2-authentication-services = "0.3"
|
||||
objc2-foundation = { version = "0.3", features = ["NSError", "NSArray"] }
|
||||
|
||||
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-cli = "2"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<false/>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>UIHomeIndicatorAutoHidden</key>
|
||||
<true/>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>EPUB Document</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>org.idpf.epub-container</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>PDF Document</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.adobe.pdf</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>FB2 Document</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.readest.fb2</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>CBZ Archive</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.readest.cbz</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>MOBI Document</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>org.mobipocket.mobi</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>AZW Document</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.amazon.azw</string>
|
||||
<string>com.amazon.azw3</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Text File</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.plain-text</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>org.idpf.epub-container</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>EPUB Document</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.content</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>epub</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/epub+zip</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.readest.fb2</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>FB2 Document</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.xml</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>fb2</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/xml</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.readest.cbz</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>CBZ Archive</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.archive</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>cbz</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/x-cbz</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>org.mobipocket.mobi</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>MOBI Document</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>mobi</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/x-mobipocket-ebook</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.amazon.azw</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>AZW Document</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>azw</string>
|
||||
<string>azw3</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/vnd.amazon.ebook</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "updater"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"fs:default",
|
||||
@@ -39,12 +39,26 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-cache-read",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$CACHEDIR/**/*"
|
||||
},
|
||||
{
|
||||
"path": "/private/var/mobile/Containers/Data/Application/**/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://*.deepl.com"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/readest/*"
|
||||
},
|
||||
{
|
||||
"url": "https://*.cloudflarestorage.com"
|
||||
}
|
||||
@@ -57,12 +71,16 @@
|
||||
"core:window:allow-center",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:path:allow-resolve-directory",
|
||||
"log:default",
|
||||
"shell:default",
|
||||
"process:default",
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "desktop-capability",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "updater"],
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"permissions": [
|
||||
"updater:default",
|
||||
"cli:default"
|
||||
]
|
||||
"permissions": ["updater:default", "cli:default"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
|
||||
@@ -19,12 +20,43 @@
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter android:label="oauth">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="readest" android:host="auth-callback" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
|
||||
<data android:mimeType="application/epub+zip" />
|
||||
<data android:mimeType="application/pdf" />
|
||||
<data android:mimeType="application/fb2" />
|
||||
<data android:mimeType="application/x-fb2" />
|
||||
<data android:mimeType="application/vnd.amazon.mobi8-ebook" />
|
||||
<data android:mimeType="application/vnd.comicbook+zip" />
|
||||
<data android:mimeType="application/x-cbz" />
|
||||
<data android:mimeType="application/x-mobipocket-ebook" />
|
||||
<data android:mimeType="application/zip" />
|
||||
<data android:mimeType="text/plain" />
|
||||
|
||||
<data android:pathPattern=".*\\.epub" />
|
||||
<data android:pathPattern=".*\\.pdf" />
|
||||
<data android:pathPattern=".*\\.fb2" />
|
||||
<data android:pathPattern=".*\\.fb2.zip" />
|
||||
<data android:pathPattern=".*\\.mobi" />
|
||||
<data android:pathPattern=".*\\.azw" />
|
||||
<data android:pathPattern=".*\\.cbz" />
|
||||
<data android:pathPattern=".*\\.zip" />
|
||||
<data android:pathPattern=".*\\.txt" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="oauth">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="readest" android:host="auth-callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
-40
@@ -1,51 +1,11 @@
|
||||
package com.bilingify.readest
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowInsetsController
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import app.tauri.plugin.JSObject
|
||||
import com.readest.native_bridge.NativeBridgePlugin
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
hideSystemUI()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
|
||||
val uri = intent.data ?: return
|
||||
if (uri.scheme == "readest" && uri.host == "auth-callback") {
|
||||
val result = JSObject().apply {
|
||||
put("redirectUrl", uri.toString())
|
||||
}
|
||||
|
||||
NativeBridgePlugin.pendingInvoke?.resolve(result)
|
||||
NativeBridgePlugin.pendingInvoke = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideSystemUI() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window.decorView.windowInsetsController?.let { controller ->
|
||||
controller.hide(WindowInsets.Type.systemBars())
|
||||
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.readest" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowTranslucentStatus">false</item>
|
||||
<item name="android:windowTranslucentNavigation">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:fitsSystemWindows">false</item>
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
+150
@@ -4,6 +4,14 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowManager
|
||||
import android.view.WindowInsetsController
|
||||
import android.graphics.Color
|
||||
import android.webkit.WebView
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
@@ -24,6 +32,17 @@ class CopyURIRequestArgs {
|
||||
var dst: String? = null
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
class InstallPackageRequestArgs {
|
||||
var path: String? = null
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
class SetSystemUIVisibilityRequestArgs {
|
||||
var visible: Boolean? = false
|
||||
var darkMode: Boolean? = false
|
||||
}
|
||||
|
||||
@TauriPlugin
|
||||
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
private val implementation = NativeBridge()
|
||||
@@ -34,6 +53,40 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
var pendingInvoke: Invoke? = null
|
||||
}
|
||||
|
||||
override fun load(webView: WebView) {
|
||||
super.load(webView)
|
||||
handleIntent(activity.intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
val uri = intent?.data ?: return
|
||||
Log.e("NativeBridgePlugin", "Received intent: $uri")
|
||||
when {
|
||||
uri.scheme == "readest" && uri.host == "auth-callback" -> {
|
||||
val result = JSObject().apply {
|
||||
put("redirectUrl", uri.toString())
|
||||
}
|
||||
pendingInvoke?.resolve(result)
|
||||
pendingInvoke = null
|
||||
}
|
||||
|
||||
intent.action == Intent.ACTION_VIEW -> {
|
||||
try {
|
||||
activity.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e("NativeBridgePlugin", "Failed to take persistable URI permission: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Command
|
||||
fun auth_with_custom_tab(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(AuthRequestArgs::class.java)
|
||||
@@ -74,4 +127,101 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
invoke.resolve(ret)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun install_package(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(InstallPackageRequestArgs::class.java)
|
||||
val ret = JSObject()
|
||||
try {
|
||||
val file = File(args.path ?: "")
|
||||
if (file.exists()) {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
val apkUri = FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", file)
|
||||
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val packageManager = activity.packageManager
|
||||
val resolveInfos = packageManager.queryIntentActivities(intent, 0)
|
||||
for (resolveInfo in resolveInfos) {
|
||||
val packageName = resolveInfo.activityInfo.packageName
|
||||
activity.grantUriPermission(packageName, apkUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
ret.put("success", true)
|
||||
} else {
|
||||
ret.put("success", false)
|
||||
ret.put("error", "File does not exist")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ret.put("success", false)
|
||||
ret.put("error", e.message)
|
||||
}
|
||||
invoke.resolve(ret)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun set_system_ui_visibility(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(SetSystemUIVisibilityRequestArgs::class.java)
|
||||
val visible = args.visible ?: false
|
||||
var isDarkMode = args.darkMode ?: false
|
||||
val ret = JSObject()
|
||||
try {
|
||||
val window = activity.window
|
||||
val decorView = window.decorView
|
||||
if (!visible) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
window.attributes.layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window.setDecorFitsSystemWindows(false)
|
||||
val controller = window.insetsController
|
||||
if (controller != null) {
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
|
||||
if (isDarkMode) {
|
||||
controller.setSystemBarsAppearance(
|
||||
0,
|
||||
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
|
||||
)
|
||||
} else {
|
||||
controller.setSystemBarsAppearance(
|
||||
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS,
|
||||
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
|
||||
)
|
||||
}
|
||||
if (visible) {
|
||||
controller.show(WindowInsets.Type.statusBars())
|
||||
} else {
|
||||
controller.hide(WindowInsets.Type.systemBars())
|
||||
}
|
||||
}
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
decorView.systemUiVisibility = when {
|
||||
visible && !isDarkMode -> View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
visible -> View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
else -> View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
}
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
}
|
||||
ret.put("success", true)
|
||||
} catch (e: Exception) {
|
||||
ret.put("success", false)
|
||||
ret.put("error", e.message)
|
||||
}
|
||||
invoke.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ const COMMANDS: &[&str] = &[
|
||||
"auth_with_safari",
|
||||
"auth_with_custom_tab",
|
||||
"copy_uri_to_path",
|
||||
"use_background_audio",
|
||||
"install_package",
|
||||
"set_system_ui_visibility",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
|
||||
+56
-1
@@ -1,4 +1,6 @@
|
||||
import AuthenticationServices
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
import SwiftRs
|
||||
import Tauri
|
||||
import UIKit
|
||||
@@ -8,9 +10,38 @@ class SafariAuthRequestArgs: Decodable {
|
||||
let authUrl: String
|
||||
}
|
||||
|
||||
class UseBackgroundAudioRequestArgs: Decodable {
|
||||
let enabled: Bool
|
||||
}
|
||||
|
||||
class SetSystemUIVisibilityRequestArgs: Decodable {
|
||||
let visible: Bool
|
||||
let darkMode: Bool
|
||||
}
|
||||
|
||||
class NativeBridgePlugin: Plugin {
|
||||
private var authSession: ASWebAuthenticationSession?
|
||||
|
||||
@objc public func use_background_audio(_ invoke: Invoke) {
|
||||
do {
|
||||
let args = try invoke.parseArgs(UseBackgroundAudioRequestArgs.self)
|
||||
let enabled = args.enabled
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
if enabled {
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
try session.setActive(true)
|
||||
print("AVAudioSession activated")
|
||||
} else {
|
||||
try session.setActive(false)
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
print("AVAudioSession deactivated")
|
||||
}
|
||||
invoke.resolve()
|
||||
} catch {
|
||||
print("Failed to set up audio session:", error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func auth_with_safari(_ invoke: Invoke) throws {
|
||||
let args = try invoke.parseArgs(SafariAuthRequestArgs.self)
|
||||
let authUrl = URL(string: args.authUrl)!
|
||||
@@ -40,6 +71,30 @@ class NativeBridgePlugin: Plugin {
|
||||
let started = authSession?.start() ?? false
|
||||
Logger.info("Auth session start result: \(started)")
|
||||
}
|
||||
|
||||
@objc public func set_system_ui_visibility(_ invoke: Invoke) throws {
|
||||
let args = try invoke.parseArgs(SetSystemUIVisibilityRequestArgs.self)
|
||||
let visible = args.visible
|
||||
let darkMode = args.darkMode
|
||||
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = !visible
|
||||
UIApplication.shared.setStatusBarHidden(!visible, with: .none)
|
||||
|
||||
let windows = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap { $0.windows }
|
||||
|
||||
let keyWindow = windows.first(where: { $0.isKeyWindow }) ?? windows.first
|
||||
if let keyWindow = keyWindow {
|
||||
keyWindow.overrideUserInterfaceStyle = darkMode ? .dark : .light
|
||||
keyWindow.layoutIfNeeded()
|
||||
} else {
|
||||
print("No key window found")
|
||||
}
|
||||
}
|
||||
invoke.resolve(["success": true])
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_native_bridge")
|
||||
@@ -52,4 +107,4 @@ extension NativeBridgePlugin: ASWebAuthenticationPresentationContextProviding {
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
return UIApplication.shared.windows.first ?? UIWindow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-install-package"
|
||||
description = "Enables the install_package command without any pre-configured scope."
|
||||
commands.allow = ["install_package"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-install-package"
|
||||
description = "Denies the install_package command without any pre-configured scope."
|
||||
commands.deny = ["install_package"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-set-system-ui-visibility"
|
||||
description = "Enables the set_system_ui_visibility command without any pre-configured scope."
|
||||
commands.allow = ["set_system_ui_visibility"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-set-system-ui-visibility"
|
||||
description = "Denies the set_system_ui_visibility command without any pre-configured scope."
|
||||
commands.deny = ["set_system_ui_visibility"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-use-background-audio"
|
||||
description = "Enables the use_background_audio command without any pre-configured scope."
|
||||
commands.allow = ["use_background_audio"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-use-background-audio"
|
||||
description = "Denies the use_background_audio command without any pre-configured scope."
|
||||
commands.deny = ["use_background_audio"]
|
||||
+81
@@ -7,6 +7,9 @@ Default permissions for the plugin
|
||||
- `allow-auth-with-safari`
|
||||
- `allow-auth-with-custom-tab`
|
||||
- `allow-copy-uri-to-path`
|
||||
- `allow-use-background-audio`
|
||||
- `allow-install-package`
|
||||
- `allow-set-system-ui-visibility`
|
||||
|
||||
## Permission Table
|
||||
|
||||
@@ -92,6 +95,84 @@ Enables the copy_uri_to_path command without any pre-configured scope.
|
||||
|
||||
Denies the copy_uri_to_path command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-install-package`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the install_package command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-install-package`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the install_package command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-set-system-ui-visibility`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the set_system_ui_visibility command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-set-system-ui-visibility`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the set_system_ui_visibility command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-use-background-audio`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the use_background_audio command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-use-background-audio`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the use_background_audio command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
+8
-1
@@ -1,3 +1,10 @@
|
||||
[default]
|
||||
description = "Default permissions for the plugin"
|
||||
permissions = ["allow-auth-with-safari", "allow-auth-with-custom-tab", "allow-copy-uri-to-path"]
|
||||
permissions = [
|
||||
"allow-auth-with-safari",
|
||||
"allow-auth-with-custom-tab",
|
||||
"allow-copy-uri-to-path",
|
||||
"allow-use-background-audio",
|
||||
"allow-install-package",
|
||||
"allow-set-system-ui-visibility",
|
||||
]
|
||||
|
||||
+38
-2
@@ -331,10 +331,46 @@
|
||||
"markdownDescription": "Denies the copy_uri_to_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`",
|
||||
"description": "Enables the install_package command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-install-package",
|
||||
"markdownDescription": "Enables the install_package command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install_package command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-install-package",
|
||||
"markdownDescription": "Denies the install_package command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_system_ui_visibility command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-set-system-ui-visibility",
|
||||
"markdownDescription": "Enables the set_system_ui_visibility command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_system_ui_visibility command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-set-system-ui-visibility",
|
||||
"markdownDescription": "Denies the set_system_ui_visibility command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the use_background_audio command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-use-background-audio",
|
||||
"markdownDescription": "Enables the use_background_audio command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the use_background_audio command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-use-background-audio",
|
||||
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`"
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,3 +27,27 @@ pub(crate) async fn copy_uri_to_path<R: Runtime>(
|
||||
) -> Result<CopyURIResponse> {
|
||||
app.native_bridge().copy_uri_to_path(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn use_background_audio<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: UseBackgroundAudioRequest,
|
||||
) -> Result<()> {
|
||||
app.native_bridge().use_background_audio(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn install_package<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: InstallPackageRequest,
|
||||
) -> Result<InstallPackageResponse> {
|
||||
app.native_bridge().install_package(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn set_system_ui_visibility<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: SetSystemUIVisibilityRequest,
|
||||
) -> Result<SetSystemUIVisibilityResponse> {
|
||||
app.native_bridge().set_system_ui_visibility(payload)
|
||||
}
|
||||
|
||||
@@ -25,4 +25,22 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn copy_uri_to_path(&self, _payload: CopyURIRequest) -> crate::Result<CopyURIResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn install_package(
|
||||
&self,
|
||||
_payload: InstallPackageRequest,
|
||||
) -> crate::Result<InstallPackageResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn set_system_ui_visibility(
|
||||
&self,
|
||||
_payload: SetSystemUIVisibilityRequest,
|
||||
) -> crate::Result<SetSystemUIVisibilityResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
commands::auth_with_safari,
|
||||
commands::auth_with_custom_tab,
|
||||
commands::copy_uri_to_path,
|
||||
commands::use_background_audio,
|
||||
commands::install_package,
|
||||
commands::set_system_ui_visibility,
|
||||
])
|
||||
.setup(|app, api| {
|
||||
#[cfg(mobile)]
|
||||
|
||||
@@ -47,3 +47,33 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn use_background_audio(&self, payload: UseBackgroundAudioRequest) -> crate::Result<()> {
|
||||
self.0
|
||||
.run_mobile_plugin("use_background_audio", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn install_package(
|
||||
&self,
|
||||
payload: InstallPackageRequest,
|
||||
) -> crate::Result<InstallPackageResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("install_package", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn set_system_ui_visibility(
|
||||
&self,
|
||||
payload: SetSystemUIVisibilityRequest,
|
||||
) -> crate::Result<SetSystemUIVisibilityResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("set_system_ui_visibility", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,3 +25,36 @@ pub struct CopyURIResponse {
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UseBackgroundAudioRequest {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallPackageRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallPackageResponse {
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetSystemUIVisibilityRequest {
|
||||
pub visible: bool,
|
||||
pub dark_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetSystemUIVisibilityResponse {
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -6,13 +6,6 @@ extern crate cocoa;
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
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;
|
||||
|
||||
@@ -23,6 +16,8 @@ use tauri::{AppHandle, Listener, Manager, Url};
|
||||
#[cfg(desktop)]
|
||||
use tauri_plugin_fs::FsExt;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
mod transfer_file;
|
||||
use tauri::{command, Emitter, WebviewUrl, WebviewWindowBuilder, Window};
|
||||
use tauri_plugin_oauth::start;
|
||||
@@ -76,7 +71,10 @@ fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
|
||||
let files = files
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let file = f.to_string_lossy().replace("\\", "\\\\");
|
||||
let file = f
|
||||
.to_string_lossy()
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"");
|
||||
format!("\"{file}\"",)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -135,7 +133,11 @@ pub fn run() {
|
||||
download_file,
|
||||
upload_file,
|
||||
#[cfg(target_os = "macos")]
|
||||
set_traffic_lights,
|
||||
macos::safari_auth::auth_with_safari,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::apple_auth::start_apple_sign_in,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::traffic_light::set_traffic_lights,
|
||||
#[cfg(desktop)]
|
||||
list_fonts
|
||||
])
|
||||
@@ -170,7 +172,10 @@ pub fn run() {
|
||||
let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder.plugin(traffic_light::init());
|
||||
let builder = builder.plugin(macos::traffic_light::init());
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder.plugin(macos::safari_auth::init());
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
let builder = builder.plugin(tauri_plugin_sign_in_with_apple::init());
|
||||
@@ -199,13 +204,17 @@ pub fn run() {
|
||||
|
||||
let app_handle = app.handle().clone();
|
||||
app.listen("window-ready", move |_| {
|
||||
app_handle.get_webview_window("main").unwrap()
|
||||
.eval("window.__READEST_CLI_ACCESS = true; window.__READEST_UPDATER_ACCESS = true;")
|
||||
app_handle
|
||||
.get_webview_window("main")
|
||||
.unwrap()
|
||||
.eval("window.__READEST_CLI_ACCESS = true;")
|
||||
.expect("Failed to set cli access config");
|
||||
|
||||
set_rounded_window(&app_handle, true);
|
||||
#[cfg(target_os = "windows")]
|
||||
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
|
||||
if tauri_plugin_os::version()
|
||||
<= tauri_plugin_os::Version::from_string("10.0.19045")
|
||||
{
|
||||
set_rounded_window(&app_handle, false);
|
||||
}
|
||||
});
|
||||
@@ -226,9 +235,7 @@ pub fn run() {
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
|
||||
|
||||
#[cfg(desktop)]
|
||||
let win_builder = win_builder
|
||||
.inner_size(800.0, 600.0)
|
||||
.resizable(true);
|
||||
let win_builder = win_builder.inner_size(800.0, 600.0).resizable(true);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let win_builder = win_builder
|
||||
@@ -245,7 +252,9 @@ pub fn run() {
|
||||
.title("Readest");
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
|
||||
if tauri_plugin_os::version()
|
||||
<= tauri_plugin_os::Version::from_string("10.0.19045")
|
||||
{
|
||||
win_builder = win_builder.shadow(false);
|
||||
} else {
|
||||
win_builder = win_builder.shadow(true);
|
||||
@@ -262,7 +271,7 @@ pub fn run() {
|
||||
// win.open_devtools();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
menu::setup_macos_menu(app.handle())?;
|
||||
macos::menu::setup_macos_menu(app.handle())?;
|
||||
|
||||
app.handle().emit("window-ready", ()).unwrap();
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::ProtocolObject;
|
||||
use objc2::{
|
||||
define_class, msg_send, AllocAnyThread, DefinedClass, MainThreadMarker, MainThreadOnly,
|
||||
};
|
||||
use objc2_authentication_services::{
|
||||
ASAuthorization, ASAuthorizationAppleIDCredential, ASAuthorizationAppleIDProvider,
|
||||
ASAuthorizationController, ASAuthorizationControllerDelegate, ASAuthorizationRequest,
|
||||
ASAuthorizationScopeEmail, ASAuthorizationScopeFullName,
|
||||
};
|
||||
use objc2_foundation::{NSArray, NSError, NSObject, NSObjectProtocol, NSString};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use tauri::{command, AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppleIDAuthorizationRequest {
|
||||
pub scope: Vec<String>,
|
||||
pub nonce: Option<String>,
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppleIDAuthorizationResponse {
|
||||
pub user_identifier: Option<String>,
|
||||
pub given_name: Option<String>,
|
||||
pub family_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub authorization_code: Option<String>,
|
||||
pub identity_token: Option<String>,
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static APPLE_SIGN_IN_DELEGATE: RefCell<Option<Retained<ASAuthorizationControllerDelegateImpl>>> = RefCell::new(None);
|
||||
static AUTHORIZATION_CONTROLLER: RefCell<Option<Retained<ASAuthorizationController>>> = RefCell::new(None);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Ivars {
|
||||
app: AppHandle,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
#[unsafe(super(NSObject))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "ASAuthorizationControllerDelegateImpl"]
|
||||
#[ivars = Ivars]
|
||||
pub struct ASAuthorizationControllerDelegateImpl;
|
||||
|
||||
unsafe impl NSObjectProtocol for ASAuthorizationControllerDelegateImpl {}
|
||||
|
||||
unsafe impl ASAuthorizationControllerDelegate for ASAuthorizationControllerDelegateImpl {
|
||||
#[unsafe(method(authorizationController:didCompleteWithAuthorization:))]
|
||||
#[allow(non_snake_case)]
|
||||
unsafe fn authorizationController_didCompleteWithAuthorization(
|
||||
&self,
|
||||
_controller: &ASAuthorizationController,
|
||||
authorization: &ASAuthorization,
|
||||
) {
|
||||
if let Ok(credential) = authorization
|
||||
.credential()
|
||||
.downcast::<ASAuthorizationAppleIDCredential>()
|
||||
{
|
||||
let user_identifier = {
|
||||
let user = credential.user();
|
||||
if user.len() > 0 {
|
||||
Some(user.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let given_name = credential
|
||||
.fullName()
|
||||
.and_then(|name| name.givenName())
|
||||
.map(|nsstr| nsstr.to_string());
|
||||
let family_name = credential
|
||||
.fullName()
|
||||
.and_then(|name| name.familyName())
|
||||
.map(|nsstr| nsstr.to_string());
|
||||
let email = credential.email().map(|nsstr| nsstr.to_string());
|
||||
|
||||
let authorization_code = credential
|
||||
.authorizationCode()
|
||||
.and_then(|code_data| String::from_utf8(code_data.to_vec()).ok());
|
||||
|
||||
let identity_token = credential
|
||||
.identityToken()
|
||||
.and_then(|token_data| String::from_utf8(token_data.to_vec()).ok());
|
||||
|
||||
let state = credential.state().map(|nsstr| nsstr.to_string());
|
||||
|
||||
let resp = AppleIDAuthorizationResponse {
|
||||
user_identifier,
|
||||
given_name,
|
||||
family_name,
|
||||
email,
|
||||
authorization_code,
|
||||
identity_token,
|
||||
state,
|
||||
};
|
||||
let _ = self.ivars().app.emit("apple-sign-in-complete", json!(resp));
|
||||
log::info!("Apple authorization complete");
|
||||
} else {
|
||||
let _ = self
|
||||
.ivars()
|
||||
.app
|
||||
.emit("apple-sign-in-error", "Invalid credential type");
|
||||
log::error!("Invalid credential type received");
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(method(authorizationController:didCompleteWithError:))]
|
||||
#[allow(non_snake_case)]
|
||||
unsafe fn authorizationController_didCompleteWithError(
|
||||
&self,
|
||||
_controller: &ASAuthorizationController,
|
||||
error: &NSError,
|
||||
) {
|
||||
let code = error.code();
|
||||
let description = error.localizedDescription().to_string();
|
||||
let _ = self.ivars().app.emit(
|
||||
"apple-sign-in-error",
|
||||
json!({
|
||||
"code": code,
|
||||
"description": description,
|
||||
}),
|
||||
);
|
||||
|
||||
log::error!(
|
||||
"Authorization error: code={}, description={}",
|
||||
code,
|
||||
description
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
impl ASAuthorizationControllerDelegateImpl {
|
||||
fn new(app: AppHandle) -> Retained<Self> {
|
||||
let mtm = MainThreadMarker::new().expect("must run on main thread");
|
||||
let this = Self::alloc(mtm).set_ivars(Ivars { app });
|
||||
unsafe { msg_send![super(this), init] }
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub fn start_apple_sign_in(app: AppHandle, payload: AppleIDAuthorizationRequest) {
|
||||
unsafe {
|
||||
let provider = ASAuthorizationAppleIDProvider::new();
|
||||
let request = provider.createRequest();
|
||||
|
||||
if let Some(ref nonce) = payload.nonce {
|
||||
let ns_nonce = NSString::from_str(nonce);
|
||||
request.setNonce(Some(&ns_nonce));
|
||||
}
|
||||
|
||||
if let Some(ref state) = payload.state {
|
||||
let ns_state = NSString::from_str(state);
|
||||
request.setState(Some(&ns_state));
|
||||
}
|
||||
|
||||
let mut scopes = Vec::new();
|
||||
for scope in payload.scope.iter() {
|
||||
match scope.as_str() {
|
||||
"email" => scopes.push(ASAuthorizationScopeEmail),
|
||||
"fullName" => scopes.push(ASAuthorizationScopeFullName),
|
||||
_ => {
|
||||
log::warn!("[Apple Sign-In] Unsupported scope: {}", scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !scopes.is_empty() {
|
||||
request.setRequestedScopes(Some(&*NSArray::from_slice(&scopes)));
|
||||
}
|
||||
|
||||
let auth_request = &request as &ASAuthorizationRequest;
|
||||
let controller = ASAuthorizationController::initWithAuthorizationRequests(
|
||||
ASAuthorizationController::alloc(),
|
||||
&*NSArray::from_slice(&[auth_request]),
|
||||
);
|
||||
|
||||
let delegate = ASAuthorizationControllerDelegateImpl::new(app.clone());
|
||||
APPLE_SIGN_IN_DELEGATE.with(|cell| {
|
||||
*cell.borrow_mut() = Some(delegate.clone());
|
||||
});
|
||||
|
||||
AUTHORIZATION_CONTROLLER.with(|cell| {
|
||||
*cell.borrow_mut() = Some(controller.clone());
|
||||
});
|
||||
|
||||
controller.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
|
||||
|
||||
log::info!("Starting Apple Sign-In authorization request");
|
||||
controller.performRequests();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod apple_auth;
|
||||
pub mod menu;
|
||||
pub mod safari_auth;
|
||||
pub mod traffic_light;
|
||||
@@ -0,0 +1,255 @@
|
||||
use std::ffi::CStr;
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use block::ConcreteBlock;
|
||||
use objc::declare::ClassDecl;
|
||||
use objc::runtime::{Class, Object, Sel, BOOL, YES};
|
||||
use objc::{class, msg_send, sel, sel_impl};
|
||||
use objc_foundation::{INSString, NSString};
|
||||
use objc_id::Id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use tauri::{
|
||||
command,
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Emitter, Manager, Runtime, State, Window,
|
||||
};
|
||||
|
||||
pub struct ThreadSafeObjcPointer(*mut Object, PhantomData<*mut Object>);
|
||||
|
||||
unsafe impl Send for ThreadSafeObjcPointer {}
|
||||
unsafe impl Sync for ThreadSafeObjcPointer {}
|
||||
|
||||
impl ThreadSafeObjcPointer {
|
||||
pub fn new(ptr: *mut Object) -> Self {
|
||||
Self(ptr, PhantomData)
|
||||
}
|
||||
|
||||
pub fn as_ptr(&self) -> *mut Object {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn is_null(&self) -> bool {
|
||||
self.0.is_null()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthSession {
|
||||
auth_session: Option<ThreadSafeObjcPointer>,
|
||||
}
|
||||
|
||||
impl Default for AuthSession {
|
||||
fn default() -> Self {
|
||||
Self { auth_session: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AuthSession {
|
||||
fn drop(&mut self) {
|
||||
if let Some(session) = &self.auth_session {
|
||||
if !session.is_null() {
|
||||
unsafe {
|
||||
let _: () = msg_send![session.as_ptr(), cancel];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SafariAuthRequestArgs {
|
||||
auth_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthResult {
|
||||
redirect_url: String,
|
||||
}
|
||||
|
||||
fn setup_presentation_context_provider() -> &'static Class {
|
||||
let class_name = "TauriPresentationContextProvider";
|
||||
let mut decl = match Class::get(class_name) {
|
||||
Some(class) => return class,
|
||||
None => ClassDecl::new(class_name, class!(NSObject)).unwrap(),
|
||||
};
|
||||
|
||||
extern "C" fn presentation_anchor_for_web_auth_session(
|
||||
this: &Object,
|
||||
_sel: Sel,
|
||||
_session: *mut Object,
|
||||
) -> *mut Object {
|
||||
unsafe {
|
||||
let window: *mut Object = *this.get_ivar("window");
|
||||
window
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
decl.add_ivar::<*mut Object>("window");
|
||||
|
||||
let sel = sel!(presentationAnchorForWebAuthenticationSession:);
|
||||
decl.add_method(
|
||||
sel,
|
||||
presentation_anchor_for_web_auth_session
|
||||
as extern "C" fn(&Object, Sel, *mut Object) -> *mut Object,
|
||||
);
|
||||
}
|
||||
|
||||
decl.register()
|
||||
}
|
||||
|
||||
unsafe fn create_provider(window: *mut Object) -> *mut Object {
|
||||
let class = setup_presentation_context_provider();
|
||||
let provider: *mut Object = msg_send![class, alloc];
|
||||
let provider: *mut Object = msg_send![provider, init];
|
||||
|
||||
(*provider).set_ivar("window", window);
|
||||
|
||||
provider
|
||||
}
|
||||
|
||||
unsafe fn nsstring_to_string(ns_string: *mut Object) -> String {
|
||||
if ns_string.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let utf8_ptr: *const i8 = msg_send![ns_string, UTF8String];
|
||||
if utf8_ptr.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
CStr::from_ptr(utf8_ptr).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn auth_with_safari<R: Runtime>(
|
||||
window: Window<R>,
|
||||
state: State<'_, Arc<Mutex<AuthSession>>>,
|
||||
payload: SafariAuthRequestArgs,
|
||||
) -> Result<AuthResult, String> {
|
||||
let auth_url_str = NSString::from_str(&payload.auth_url);
|
||||
let url_class = Class::get("NSURL").unwrap();
|
||||
let auth_url: Id<Object> = unsafe {
|
||||
let url: *mut Object = msg_send![url_class, URLWithString:auth_url_str];
|
||||
if url.is_null() {
|
||||
return Err("Failed to create URL".to_string());
|
||||
}
|
||||
Id::from_ptr(url)
|
||||
};
|
||||
|
||||
let auth_session_class = match Class::get("ASWebAuthenticationSession") {
|
||||
Some(class) => class,
|
||||
None => {
|
||||
return Err(
|
||||
"ASWebAuthenticationSession class not found. This requires macOS 10.15+"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let window_clone = window.clone();
|
||||
let state_clone = Arc::clone(&*state);
|
||||
let completion_block =
|
||||
ConcreteBlock::new(move |callback_url: *mut Object, error: *mut Object| {
|
||||
let mut auth_session = match state_clone.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if !error.is_null() {
|
||||
unsafe {
|
||||
let description: *mut Object = msg_send![error, localizedDescription];
|
||||
let error_description = nsstring_to_string(description);
|
||||
log::error!("Auth session error: {}", error_description);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if !callback_url.is_null() {
|
||||
let url_string_value = unsafe {
|
||||
let abs_str: *mut Object = msg_send![callback_url, absoluteString];
|
||||
nsstring_to_string(abs_str)
|
||||
};
|
||||
|
||||
log::info!("Auth session callback URL: {}", url_string_value);
|
||||
|
||||
if let Some(session) = &auth_session.auth_session {
|
||||
unsafe {
|
||||
let _: () = msg_send![session.as_ptr(), cancel];
|
||||
}
|
||||
}
|
||||
|
||||
auth_session.auth_session = None;
|
||||
|
||||
let result = AuthResult {
|
||||
redirect_url: url_string_value,
|
||||
};
|
||||
|
||||
let _ = window_clone.emit("safari-auth-complete", result);
|
||||
}
|
||||
});
|
||||
|
||||
let completion_block = completion_block.copy();
|
||||
|
||||
let callback_scheme = NSString::from_str("readest");
|
||||
let auth_session_ptr: *mut Object = unsafe {
|
||||
let alloc: *mut Object = msg_send![auth_session_class, alloc];
|
||||
if alloc.is_null() {
|
||||
return Err("Failed to allocate ASWebAuthenticationSession".to_string());
|
||||
}
|
||||
|
||||
let init: *mut Object = msg_send![
|
||||
alloc,
|
||||
initWithURL:auth_url
|
||||
callbackURLScheme:callback_scheme
|
||||
completionHandler:&*completion_block
|
||||
];
|
||||
|
||||
if init.is_null() {
|
||||
return Err("Failed to initialize ASWebAuthenticationSession".to_string());
|
||||
}
|
||||
init
|
||||
};
|
||||
|
||||
unsafe {
|
||||
match window.ns_window() {
|
||||
Ok(ns_window) => {
|
||||
let ns_window = ns_window as *mut Object;
|
||||
let provider = create_provider(ns_window);
|
||||
|
||||
let _: () = msg_send![auth_session_ptr, setPresentationContextProvider:provider];
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to get NSWindow for presentation context: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let started: BOOL = unsafe { msg_send![auth_session_ptr, start] };
|
||||
|
||||
log::info!("Auth session start result: {}", started == YES);
|
||||
|
||||
if started != YES {
|
||||
return Err("Failed to start authentication session".to_string());
|
||||
}
|
||||
|
||||
let mut auth_session_guard = state.lock().unwrap();
|
||||
auth_session_guard.auth_session = Some(ThreadSafeObjcPointer::new(auth_session_ptr));
|
||||
|
||||
Ok(AuthResult {
|
||||
redirect_url: "pending".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("safari_auth")
|
||||
.setup(|app, _| {
|
||||
app.manage(Arc::new(Mutex::new(AuthSession::default())));
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![auth_with_safari])
|
||||
.build()
|
||||
}
|
||||
+16
-17
@@ -30,7 +30,6 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[command]
|
||||
pub fn set_traffic_lights(window: Window, visible: bool, x: f64, y: f64) {
|
||||
unsafe {
|
||||
@@ -47,7 +46,6 @@ pub fn set_traffic_lights(window: Window, visible: bool, x: f64, y: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
@@ -83,13 +81,11 @@ fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, visible: bool,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[derive(Debug)]
|
||||
struct WindowState<R: Runtime> {
|
||||
window: Window<R>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
use cocoa::appkit::NSWindow;
|
||||
use cocoa::base::{id, BOOL};
|
||||
@@ -148,13 +144,14 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
.expect("NS window should exist on state to handle resize")
|
||||
as id;
|
||||
|
||||
#[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,
|
||||
);
|
||||
if state.window.label() == "main" {
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
@@ -281,12 +278,14 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
.expect("Failed to emit event");
|
||||
|
||||
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,
|
||||
);
|
||||
if state.window.label() == "main" {
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
TRAFFIC_LIGHTS_VISIBLE,
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
@@ -17,15 +17,20 @@
|
||||
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
|
||||
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://fontsapi.zeoseven.com",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://fontsapi.zeoseven.com",
|
||||
"frame-src": "'self' blob: asset: http://asset.localhost",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com"
|
||||
},
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": {
|
||||
"allow": ["$RESOURCE/**", "$APPDATA/**/*", "$TEMP/**/*"],
|
||||
"allow": [
|
||||
"$RESOURCE/**",
|
||||
"$APPDATA/**/*",
|
||||
"$TEMP/**/*",
|
||||
"/private/var/mobile/Containers/Data/Application/**/*"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
const router = useRouter();
|
||||
useTheme();
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IoArrowBack } from 'react-icons/io5';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -43,6 +44,7 @@ interface ProviderLoginProp {
|
||||
|
||||
const WEB_AUTH_CALLBACK = `${READEST_WEB_BASE_URL}/auth/callback`;
|
||||
const DEEPLINK_CALLBACK = 'readest://auth-callback';
|
||||
const USE_APPLE_SIGN_IN = process.env['NEXT_PUBLIC_USE_APPLE_SIGN_IN'] === 'true';
|
||||
|
||||
const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Icon, label }) => {
|
||||
return (
|
||||
@@ -73,14 +75,19 @@ export default function AuthPage() {
|
||||
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
const getTauriRedirectTo = (isOAuth: boolean) => {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobile) {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) {
|
||||
if (appService?.isMobile) {
|
||||
return isOAuth ? DEEPLINK_CALLBACK : WEB_AUTH_CALLBACK;
|
||||
}
|
||||
return DEEPLINK_CALLBACK;
|
||||
}
|
||||
return `http://localhost:${port}`; // only for development env on Desktop
|
||||
// For development env on Desktop, use a custom OAuth callback server
|
||||
// it's possible to register a custom URL scheme for the app
|
||||
// but this is not supported by macOS, so we use a local server instead
|
||||
return `http://localhost:${port}`;
|
||||
};
|
||||
|
||||
const getWebRedirectTo = () => {
|
||||
@@ -97,15 +104,19 @@ export default function AuthPage() {
|
||||
const request = {
|
||||
scope: ['fullName', 'email'] as Scope[],
|
||||
};
|
||||
const appleAuthResponse = await getAppleIdAuth(request);
|
||||
if (appleAuthResponse.identityToken) {
|
||||
const { error } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'apple',
|
||||
token: appleAuthResponse.identityToken,
|
||||
});
|
||||
if (error) {
|
||||
console.error('Authentication error:', error);
|
||||
if (appService?.isIOSApp || USE_APPLE_SIGN_IN) {
|
||||
const appleAuthResponse = await getAppleIdAuth(request);
|
||||
if (appleAuthResponse.identityToken) {
|
||||
const { error } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'apple',
|
||||
token: appleAuthResponse.identityToken,
|
||||
});
|
||||
if (error) {
|
||||
console.error('Authentication error:', error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('Sign in with Apple on this platform is not supported yet');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,7 +139,7 @@ export default function AuthPage() {
|
||||
}
|
||||
// Open the OAuth URL in a ASWebAuthenticationSession on iOS to comply with Apple's guidelines
|
||||
// for other platforms, open the OAuth URL in the default browser
|
||||
if (appService?.isIOSApp) {
|
||||
if (appService?.isIOSApp || appService?.isMacOSApp) {
|
||||
const res = await authWithSafari({ authUrl: data.url });
|
||||
if (res) {
|
||||
handleOAuthUrl(res.redirectUrl);
|
||||
@@ -161,7 +172,7 @@ export default function AuthPage() {
|
||||
|
||||
const startTauriOAuth = async () => {
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobile) {
|
||||
if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
currentWindow.listen('single-instance', ({ event, payload }) => {
|
||||
@@ -345,7 +356,7 @@ export default function AuthPage() {
|
||||
/>
|
||||
<ProviderLogin
|
||||
provider='apple'
|
||||
handleSignIn={appService?.isIOSApp ? tauriSignInApple : tauriSignIn}
|
||||
handleSignIn={appService?.isIOSApp || USE_APPLE_SIGN_IN ? tauriSignInApple : tauriSignIn}
|
||||
Icon={FaApple}
|
||||
label={_('Sign in with Apple')}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { type as osType } from '@tauri-apps/plugin-os';
|
||||
|
||||
export type Scope = 'fullName' | 'email';
|
||||
export interface AppleIDAuthorizationRequest {
|
||||
@@ -23,12 +25,46 @@ export interface AppleIDAuthorizationResponse {
|
||||
export async function getAppleIdAuth(
|
||||
request: AppleIDAuthorizationRequest,
|
||||
): Promise<AppleIDAuthorizationResponse> {
|
||||
const result = await invoke<AppleIDAuthorizationResponse>(
|
||||
'plugin:sign-in-with-apple|get_apple_id_credential',
|
||||
{
|
||||
payload: request,
|
||||
},
|
||||
);
|
||||
const OS_TYPE = osType();
|
||||
if (OS_TYPE === 'ios') {
|
||||
const result = await invoke<AppleIDAuthorizationResponse>(
|
||||
'plugin:sign-in-with-apple|get_apple_id_credential',
|
||||
{
|
||||
payload: request,
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
return result;
|
||||
} else if (OS_TYPE === 'macos') {
|
||||
return new Promise<AppleIDAuthorizationResponse>(async (resolve, reject) => {
|
||||
const unlistenComplete = await listen<AppleIDAuthorizationResponse>(
|
||||
'apple-sign-in-complete',
|
||||
({ payload }) => {
|
||||
cleanup();
|
||||
resolve(payload);
|
||||
},
|
||||
);
|
||||
|
||||
const unlistenError = await listen<string>('apple-sign-in-error', ({ payload }) => {
|
||||
cleanup();
|
||||
reject(
|
||||
typeof payload === 'string' ? new Error(payload) : new Error('Apple sign‑in failed'),
|
||||
);
|
||||
});
|
||||
|
||||
function cleanup() {
|
||||
unlistenComplete();
|
||||
unlistenError();
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('start_apple_sign_in', { payload: request });
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('Unsupported platform');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { type as osType } from '@tauri-apps/plugin-os';
|
||||
|
||||
export interface AuthRequest {
|
||||
authUrl: string;
|
||||
@@ -9,11 +11,33 @@ export interface AuthResponse {
|
||||
}
|
||||
|
||||
export async function authWithSafari(request: AuthRequest): Promise<AuthResponse> {
|
||||
const result = await invoke<AuthResponse>('plugin:native-bridge|auth_with_safari', {
|
||||
payload: request,
|
||||
});
|
||||
const OS_TYPE = osType();
|
||||
if (OS_TYPE === 'ios') {
|
||||
const result = await invoke<AuthResponse>('plugin:native-bridge|auth_with_safari', {
|
||||
payload: request,
|
||||
});
|
||||
return result;
|
||||
} else if (OS_TYPE === 'macos') {
|
||||
return new Promise<AuthResponse>(async (resolve, reject) => {
|
||||
const unlistenComplete = await listen<AuthResponse>('safari-auth-complete', ({ payload }) => {
|
||||
cleanup();
|
||||
resolve(payload);
|
||||
});
|
||||
|
||||
return result;
|
||||
function cleanup() {
|
||||
unlistenComplete();
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke<AuthResponse>('auth_with_safari', { payload: request });
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('Unsupported OS type');
|
||||
}
|
||||
}
|
||||
|
||||
export async function authWithCustomTab(request: AuthRequest): Promise<AuthResponse> {
|
||||
|
||||
@@ -7,9 +7,11 @@ import { LuFolderPlus } from 'react-icons/lu';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
import { isMd5 } from '@/utils/md5';
|
||||
|
||||
import Alert from '@/components/Alert';
|
||||
@@ -44,13 +46,17 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
|
||||
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [showGroupingModal, setShowGroupingModal] = useState(false);
|
||||
const [queryTerm, setQueryTerm] = useState<string | null>(null);
|
||||
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
const [sortBy, setSortBy] = useState(searchParams?.get('sort') || 'updated');
|
||||
const [sortOrder, setSortOrder] = useState(searchParams?.get('order') || 'desc');
|
||||
const isImportingBook = useRef(false);
|
||||
|
||||
const { setLibrary } = useLibraryStore();
|
||||
@@ -86,18 +92,48 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
const group = searchParams?.get('group') || '';
|
||||
const query = searchParams?.get('q') || '';
|
||||
const sort = searchParams?.get('sort') || settings.librarySortBy;
|
||||
const order = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
if (query) {
|
||||
params.set('q', query);
|
||||
setQueryTerm(query);
|
||||
} else {
|
||||
params.delete('q');
|
||||
setQueryTerm(null);
|
||||
}
|
||||
if (sort) {
|
||||
params.set('sort', sort);
|
||||
setSortBy(sort);
|
||||
} else {
|
||||
params.delete('sort');
|
||||
}
|
||||
if (order) {
|
||||
params.set('order', order);
|
||||
setSortOrder(order);
|
||||
} else {
|
||||
params.delete('order');
|
||||
}
|
||||
if (sort === 'updated' && order === 'desc') {
|
||||
params.delete('sort');
|
||||
params.delete('order');
|
||||
}
|
||||
if (group) {
|
||||
const booksGroup = allBookshelfItems.find(
|
||||
(item) => 'name' in item && item.id === group,
|
||||
) as BooksGroup;
|
||||
if (booksGroup) {
|
||||
setNavBooksGroup(booksGroup);
|
||||
params.set('group', group);
|
||||
} else {
|
||||
navigateToLibrary(router);
|
||||
params.delete('group');
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}
|
||||
} else {
|
||||
setNavBooksGroup(null);
|
||||
navigateToLibrary(router);
|
||||
params.delete('group');
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, libraryBooks, showGroupingModal]);
|
||||
@@ -137,6 +173,57 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
};
|
||||
|
||||
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
|
||||
const bookFilter = (item: Book, queryTerm: string) => {
|
||||
if (item.deletedAt) return false;
|
||||
const searchTerm = new RegExp(queryTerm, 'i');
|
||||
const title = formatTitle(item.title);
|
||||
const authors = formatAuthors(item.author);
|
||||
return searchTerm.test(title) || searchTerm.test(authors);
|
||||
};
|
||||
const bookSorter = (a: Book, b: Book) => {
|
||||
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
|
||||
switch (sortBy) {
|
||||
case 'title':
|
||||
const aTitle = formatTitle(a.title);
|
||||
const bTitle = formatTitle(b.title);
|
||||
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
|
||||
case 'author':
|
||||
const aAuthors = formatAuthors(a.author);
|
||||
const bAuthors = formatAuthors(b.author);
|
||||
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
|
||||
case 'updated':
|
||||
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
case 'created':
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
case 'format':
|
||||
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
|
||||
default:
|
||||
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
}
|
||||
};
|
||||
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
|
||||
const filteredBookshelfItems = currentBookshelfItems
|
||||
.filter((item) => {
|
||||
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
|
||||
else if (queryTerm) return bookFilter(item, queryTerm);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
|
||||
if (sortBy === 'updated') {
|
||||
return (
|
||||
(new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * sortOrderMultiplier
|
||||
);
|
||||
} else if ('name' in a || 'name' in b) {
|
||||
const aName = 'name' in a ? a.name : formatTitle(a.title);
|
||||
const bName = 'name' in b ? b.name : formatTitle(b.title);
|
||||
return aName.localeCompare(bName, uiLanguage || navigator.language) * sortOrderMultiplier;
|
||||
} else if (!('name' in a || 'name' in b)) {
|
||||
return bookSorter(a, b) * sortOrderMultiplier;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='bookshelf'>
|
||||
@@ -146,7 +233,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
|
||||
)}
|
||||
>
|
||||
{currentBookshelfItems.map((item, index) => (
|
||||
{filteredBookshelfItems.map((item, index) => (
|
||||
<BookshelfItem
|
||||
key={`library-item-${index}`}
|
||||
item={item}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
@@ -94,6 +94,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
@@ -133,7 +134,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
if (isSelectMode) {
|
||||
toggleSelection(group.id);
|
||||
} else {
|
||||
navigateToLibrary(router, `group=${group.id}`);
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set('group', group.id);
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { PiSelectionAllDuotone } from 'react-icons/pi';
|
||||
import { MdOutlineMenu } from 'react-icons/md';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
import { PiDotsThreeCircle } from 'react-icons/pi';
|
||||
import { MdOutlineMenu, MdArrowBackIosNew } from 'react-icons/md';
|
||||
import { IoMdCloseCircle } from 'react-icons/io';
|
||||
|
||||
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 { throttle } from '@/utils/throttle';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
import ImportMenu from './ImportMenu';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import SortMenu from './SortMenu';
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
isSelectMode: boolean;
|
||||
@@ -40,15 +43,36 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
setTrafficLightVisibility,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
|
||||
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const iconSize20 = useResponsiveSize(20);
|
||||
|
||||
useShortcuts({
|
||||
onToggleSelectMode,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const throttledUpdateQueryParam = useCallback(
|
||||
throttle((value: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
if (value) {
|
||||
params.set('q', value);
|
||||
} else {
|
||||
params.delete('q');
|
||||
}
|
||||
router.push(`?${params.toString()}`);
|
||||
}, 1000),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newQuery = e.target.value;
|
||||
setSearchQuery(newQuery);
|
||||
throttledUpdateQueryParam(newQuery);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
@@ -68,10 +92,12 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
|
||||
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
|
||||
'titlebar bg-base-200 z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
|
||||
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
|
||||
)}
|
||||
style={{
|
||||
marginTop: appService?.hasSafeAreaInset ? 'max(env(safe-area-inset-top), 24px)' : '',
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
|
||||
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
|
||||
@@ -93,7 +119,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
</span>
|
||||
<input
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
placeholder={_('Search Books...')}
|
||||
onChange={handleSearchChange}
|
||||
spellCheck='false'
|
||||
className={clsx(
|
||||
'input rounded-badge bg-base-300/50 h-9 w-full pl-10 pr-10 sm:h-7',
|
||||
@@ -103,6 +131,19 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className='absolute right-4 flex items-center space-x-2 text-gray-500 sm:space-x-4'>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
throttledUpdateQueryParam('');
|
||||
}}
|
||||
className='text-gray-400 hover:text-gray-600'
|
||||
aria-label={_('Clear Search')}
|
||||
>
|
||||
<IoMdCloseCircle className='h-4 w-4' />
|
||||
</button>
|
||||
)}
|
||||
<span className='bg-base-content/50 mx-2 h-4 w-[0.5px]'></span>
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center'
|
||||
@@ -136,7 +177,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<MdOutlineMenu size={iconSize16} />}
|
||||
toggleButton={<PiDotsThreeCircle size={iconSize18} />}
|
||||
>
|
||||
<SortMenu />
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<MdOutlineMenu size={iconSize18} />}
|
||||
>
|
||||
<SettingsMenu />
|
||||
</Dropdown>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PiUserCircleCheck } from 'react-icons/pi';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { hasUpdater, isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -14,16 +14,16 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getStoragePlanData } from '@/utils/access';
|
||||
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
|
||||
import { tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { QuotaType } from '@/types/user';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Quota from '@/components/Quota';
|
||||
|
||||
interface BookMenuProps {
|
||||
interface SettingsMenuProps {
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
@@ -32,6 +32,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
|
||||
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
|
||||
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
|
||||
const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState(
|
||||
settings.autoImportBooksOnOpen,
|
||||
@@ -66,6 +67,15 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const toggleAlwaysOnTop = () => {
|
||||
settings.alwaysOnTop = !settings.alwaysOnTop;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsAlwaysOnTop(settings.alwaysOnTop);
|
||||
tauriHandleSetAlwaysOnTop(settings.alwaysOnTop);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const toggleAutoUploadBooks = () => {
|
||||
settings.autoUpload = !settings.autoUpload;
|
||||
setSettings(settings);
|
||||
@@ -122,7 +132,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-3 w-72 shadow-2xl'
|
||||
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 w-72 shadow-2xl'
|
||||
>
|
||||
{user ? (
|
||||
<MenuItem
|
||||
@@ -167,20 +177,27 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
onClick={toggleAutoImportBooksOnOpen}
|
||||
/>
|
||||
)}
|
||||
{hasUpdater() && (
|
||||
{appService?.hasUpdater && (
|
||||
<MenuItem
|
||||
label={_('Check Updates on Start')}
|
||||
icon={isAutoCheckUpdates ? <MdCheck className='text-base-content' /> : undefined}
|
||||
onClick={toggleAutoCheckUpdates}
|
||||
/>
|
||||
)}
|
||||
<hr className='border-base-200 my-1' />
|
||||
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
|
||||
{appService?.hasWindow && (
|
||||
<MenuItem
|
||||
label={_('Always on Top')}
|
||||
icon={isAlwaysOnTop ? <MdCheck className='text-base-content' /> : undefined}
|
||||
onClick={toggleAlwaysOnTop}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
label={_('Keep Screen Awake')}
|
||||
icon={isScreenWakeLock ? <MdCheck className='text-base-content' /> : undefined}
|
||||
onClick={toggleScreenWakeLock}
|
||||
/>
|
||||
<hr className='border-base-200 my-1' />
|
||||
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
|
||||
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { LibrarySortByType } from '@/types/settings';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface SortMenuProps {
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
|
||||
const sortBy = settings.librarySortBy;
|
||||
const isAscending = settings.librarySortAscending;
|
||||
|
||||
const sortByOptions = [
|
||||
{ label: _('Title'), value: 'title' },
|
||||
{ label: _('Author'), value: 'author' },
|
||||
{ label: _('Format'), value: 'format' },
|
||||
{ label: _('Date Read'), value: 'updated' },
|
||||
{ label: _('Date Added'), value: 'created' },
|
||||
];
|
||||
|
||||
const sortingOptions = [
|
||||
{ label: _('Ascending'), value: true },
|
||||
{ label: _('Descending'), value: false },
|
||||
];
|
||||
|
||||
const handleSetSortBy = (value: LibrarySortByType) => {
|
||||
settings.librarySortBy = value;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsDropdownOpen?.(false);
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set('sort', value);
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSetSortAscending = (value: boolean) => {
|
||||
settings.librarySortAscending = value;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsDropdownOpen?.(false);
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set('order', value ? 'asc' : 'desc');
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
|
||||
>
|
||||
<MenuItem
|
||||
label={_('Sort by...')}
|
||||
buttonClass='h-8'
|
||||
labelClass='text-sm sm:text-xs'
|
||||
disabled
|
||||
/>
|
||||
{sortByOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
buttonClass='h-8'
|
||||
icon={sortBy === option.value ? <MdCheck className='text-base-content' /> : undefined}
|
||||
onClick={() => handleSetSortBy(option.value as LibrarySortByType)}
|
||||
/>
|
||||
))}
|
||||
<hr className='border-base-200 my-1' />
|
||||
{sortingOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value.toString()}
|
||||
label={option.label}
|
||||
buttonClass='h-8'
|
||||
icon={
|
||||
isAscending === option.value ? <MdCheck className='text-base-content' /> : undefined
|
||||
}
|
||||
onClick={() => handleSetSortAscending(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortMenu;
|
||||
@@ -98,11 +98,12 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
|
||||
if (newBook.uploadedAt && !newBook.deletedAt) {
|
||||
try {
|
||||
await appService?.downloadBook(newBook, true);
|
||||
} catch {
|
||||
console.error('Failed to download book:', newBook);
|
||||
} finally {
|
||||
newBook.coverImageUrl = await appService?.generateCoverImageUrl(newBook);
|
||||
updatedLibrary.push(newBook);
|
||||
setLibrary(updatedLibrary);
|
||||
} catch {
|
||||
console.error('Failed to download book:', newBook);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ interface DemoBooks {
|
||||
|
||||
export const useDemoBooks = () => {
|
||||
const { envConfig } = useEnv();
|
||||
const userLang = getUserLang() as keyof typeof libraries;
|
||||
const [books, setBooks] = useState<Book[]>([]);
|
||||
const isLoading = useRef(false);
|
||||
|
||||
@@ -27,6 +26,7 @@ export const useDemoBooks = () => {
|
||||
if (isLoading.current) return;
|
||||
isLoading.current = true;
|
||||
|
||||
const userLang = getUserLang() as keyof typeof libraries;
|
||||
const fetchDemoBooks = async () => {
|
||||
try {
|
||||
const appService = await envConfig.getAppService();
|
||||
|
||||
@@ -12,8 +12,8 @@ import { getFilename, listFormater } from '@/utils/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { ProgressPayload } from '@/utils/transfer';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { parseOpenWithFiles } from '@/helpers/cli';
|
||||
import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
|
||||
import { parseOpenWithFiles } from '@/helpers/openWith';
|
||||
import { isTauriAppPlatform } 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';
|
||||
@@ -21,7 +21,6 @@ import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -31,9 +30,10 @@ 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 { tauriHandleSetAlwaysOnTop, tauriQuitApp } from '@/utils/window';
|
||||
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import LibraryHeader from './components/LibraryHeader';
|
||||
@@ -59,8 +59,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
setCheckOpenWithBooks,
|
||||
} = useLibraryStore();
|
||||
const _ = useTranslation();
|
||||
useTheme();
|
||||
const { updateAppTheme } = useThemeStore();
|
||||
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
@@ -93,17 +92,15 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateAppTheme('base-200');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const doCheckAppUpdates = async () => {
|
||||
if (hasUpdater() && settings.autoCheckUpdates) {
|
||||
if (appService?.hasUpdater && settings.autoCheckUpdates) {
|
||||
await checkForAppUpdates(_);
|
||||
}
|
||||
};
|
||||
if (settings.alwaysOnTop) {
|
||||
tauriHandleSetAlwaysOnTop(settings.alwaysOnTop);
|
||||
}
|
||||
doCheckAppUpdates();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings]);
|
||||
@@ -161,9 +158,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
|
||||
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 (!appService?.isMobile) {
|
||||
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) => {
|
||||
@@ -182,9 +181,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}
|
||||
|
||||
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);
|
||||
if (!appService?.isMobile) {
|
||||
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]);
|
||||
@@ -196,7 +197,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
for (const file of openWithFiles) {
|
||||
console.log('Open with book:', file);
|
||||
try {
|
||||
const temp = !settings.autoImportBooksOnOpen;
|
||||
const temp = appService.isMobile ? false : !settings.autoImportBooksOnOpen;
|
||||
const book = await appService.importBook(file, libraryBooks, true, true, false, temp);
|
||||
if (book) {
|
||||
bookIds.push(book.hash);
|
||||
@@ -334,7 +335,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const selectFilesTauri = async () => {
|
||||
const exts = appService?.isAndroidApp ? [] : SUPPORTED_FILE_EXTS;
|
||||
const files = (await appService?.selectFiles(_('Select Books'), exts)) || [];
|
||||
return files.filter((file) => SUPPORTED_FILE_EXTS.some((ext) => file.endsWith(`.${ext}`)));
|
||||
// Cannot filter out files on Android since some content providers may not return the file name
|
||||
return files;
|
||||
};
|
||||
|
||||
const selectFilesWeb = () => {
|
||||
@@ -524,11 +526,16 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'scroll-container drop-zone mt-[48px] flex-grow overflow-y-auto px-4 sm:px-2',
|
||||
appService?.hasSafeAreaInset && 'mt-[calc(52px+env(safe-area-inset-top))]',
|
||||
'scroll-container drop-zone flex-grow overflow-y-auto px-4 sm:px-2',
|
||||
appService?.hasSafeAreaInset && 'pt-[52px]',
|
||||
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
|
||||
isDragging && 'drag-over',
|
||||
)}
|
||||
style={{
|
||||
marginTop: appService?.hasSafeAreaInset
|
||||
? 'max(env(safe-area-inset-top), 24px)'
|
||||
: '48px',
|
||||
}}
|
||||
>
|
||||
<DropIndicator />
|
||||
<Bookshelf
|
||||
@@ -566,9 +573,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
isOpen={!!showDetailsBook}
|
||||
book={showDetailsBook}
|
||||
onClose={() => setShowDetailsBook(null)}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete}
|
||||
/>
|
||||
)}
|
||||
<AboutWindow />
|
||||
{appService?.isAndroidApp && <UpdaterWindow />}
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ interface BooksGridProps {
|
||||
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
const { appService } = useEnv();
|
||||
const { getConfig, getBookData } = useBookDataStore();
|
||||
const { getProgress, getViewState, getViewSettings } = useReaderStore();
|
||||
const { getProgress, getViewState, getViewSettings, hoveredBookKey } = useReaderStore();
|
||||
const { isSideBarVisible, sideBarBookKey } = useSidebarStore();
|
||||
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight);
|
||||
@@ -42,10 +42,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'grid h-full flex-grow',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
)}
|
||||
className={clsx('grid h-full flex-grow')}
|
||||
style={{
|
||||
gridTemplateColumns: gridTemplate.columns,
|
||||
gridTemplateRows: gridTemplate.rows,
|
||||
@@ -63,6 +60,10 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
const isBookmarked = getViewState(bookKey)?.ribbonVisible;
|
||||
const horizontalGapPercent = viewSettings.gapPercent;
|
||||
const verticalMarginPixels = viewSettings.marginPx;
|
||||
const scrolled = viewSettings.scrolled;
|
||||
const showBarsOnScroll = viewSettings.showBarsOnScroll;
|
||||
const showHeader = viewSettings.showHeader && (scrolled ? showBarsOnScroll : true);
|
||||
const showFooter = viewSettings.showFooter && (scrolled ? showBarsOnScroll : true);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -70,10 +71,14 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
key={bookKey}
|
||||
className={clsx(
|
||||
'relative h-full w-full overflow-hidden',
|
||||
index === 0 &&
|
||||
!viewSettings?.scrolled &&
|
||||
appService?.hasSafeAreaInset &&
|
||||
'pt-[env(safe-area-inset-top)]',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
{isBookmarked && <Ribbon width={`${horizontalGapPercent}%`} />}
|
||||
{isBookmarked && !hoveredBookKey && <Ribbon width={`${horizontalGapPercent}%`} />}
|
||||
<HeaderBar
|
||||
bookKey={bookKey}
|
||||
bookTitle={book.title}
|
||||
@@ -85,32 +90,36 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
|
||||
{viewSettings.vertical && viewSettings.scrolled && (
|
||||
<>
|
||||
<div
|
||||
className='bg-base-100 absolute left-0 top-0 h-full'
|
||||
style={{
|
||||
width: `calc(${horizontalGapPercent}%)`,
|
||||
height: `calc(100% - ${verticalMarginPixels}px)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='bg-base-100 absolute right-0 top-0 h-full'
|
||||
style={{
|
||||
width: `calc(${horizontalGapPercent}%)`,
|
||||
height: `calc(100% - ${verticalMarginPixels}px)`,
|
||||
}}
|
||||
/>
|
||||
{(showFooter || viewSettings.doubleBorder) && (
|
||||
<div
|
||||
className='bg-base-100 absolute left-0 top-0 h-full'
|
||||
style={{
|
||||
width: `calc(${horizontalGapPercent}%)`,
|
||||
height: `calc(100% - ${verticalMarginPixels}px)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(showHeader || viewSettings.doubleBorder) && (
|
||||
<div
|
||||
className='bg-base-100 absolute right-0 top-0 h-full'
|
||||
style={{
|
||||
width: `calc(${horizontalGapPercent}%)`,
|
||||
height: `calc(100% - ${verticalMarginPixels}px)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{viewSettings.vertical && viewSettings.doubleBorder && (
|
||||
<DoubleBorder
|
||||
showHeader={viewSettings.showHeader}
|
||||
showFooter={viewSettings.showFooter}
|
||||
showHeader={showHeader}
|
||||
showFooter={showFooter}
|
||||
borderColor={viewSettings.borderColor}
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
/>
|
||||
)}
|
||||
{viewSettings.showHeader && (
|
||||
{showHeader && (
|
||||
<SectionInfo
|
||||
section={sectionLabel}
|
||||
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
|
||||
@@ -127,7 +136,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
horizontalGap={horizontalGapPercent}
|
||||
verticalMargin={verticalMarginPixels}
|
||||
/>
|
||||
{viewSettings.showFooter && (
|
||||
{showFooter && (
|
||||
<PageInfoView
|
||||
bookFormat={book.format}
|
||||
section={section}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
handleTouchEnd,
|
||||
} from '../utils/iframeEventHandlers';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
|
||||
const FoliateViewer: React.FC<{
|
||||
@@ -58,13 +59,14 @@ const FoliateViewer: React.FC<{
|
||||
const { detail } = event as CustomEvent;
|
||||
detail.data = Promise.resolve(detail.data)
|
||||
.then((data) => {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
if (detail.type === 'text/css') return transformStylesheet(data);
|
||||
if (detail.type === 'application/xhtml+xml') {
|
||||
if (viewSettings && detail.type === 'application/xhtml+xml') {
|
||||
const ctx = {
|
||||
bookKey,
|
||||
viewSettings,
|
||||
content: data,
|
||||
transformers: ['punctuation'],
|
||||
};
|
||||
return Promise.resolve(transformContent(ctx));
|
||||
}
|
||||
@@ -84,7 +86,11 @@ const FoliateViewer: React.FC<{
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewSettings.vertical =
|
||||
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
|
||||
viewSettings.rtl = writingDir?.rtl || viewSettings.writingMode.includes('rl') || false;
|
||||
viewSettings.rtl =
|
||||
writingDir?.rtl ||
|
||||
getDirFromUILanguage() === 'rtl' ||
|
||||
viewSettings.writingMode.includes('rl') ||
|
||||
false;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
|
||||
mountAdditionalFonts(detail.doc);
|
||||
|
||||
@@ -149,7 +149,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
className={clsx(
|
||||
'footer-bar shadow-xs absolute bottom-0 z-50 flex w-full flex-col',
|
||||
'sm:h-[52px] sm:justify-center',
|
||||
'sm:bg-base-100 border-base-300/50 border sm:border-none',
|
||||
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
|
||||
'transition-[opacity,transform] duration-300',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
|
||||
@@ -42,7 +42,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const { hoveredBookKey, setHoveredBookKey, bookKeys } = useReaderStore();
|
||||
const { bookKeys, hoveredBookKey, systemUIVisible, setHoveredBookKey } = useReaderStore();
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
|
||||
@@ -70,56 +70,88 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSideBarVisible]);
|
||||
|
||||
const isVisible = hoveredBookKey === bookKey || isDropdownOpen;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
`header-bar absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
|
||||
isTrafficLightVisible && isTopLeft && !isSideBarVisible ? 'pl-16' : 'pl-4',
|
||||
`shadow-xs bg-base-100 transition-opacity duration-300`,
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-top-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
hoveredBookKey === bookKey || isDropdownOpen ? `visible` : `opacity-0`,
|
||||
isDropdownOpen && 'header-bar-pinned',
|
||||
'bg-base-100 absolute top-0 w-full',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
)}
|
||||
onMouseEnter={() => setHoveredBookKey(bookKey)}
|
||||
onMouseLeave={() => setHoveredBookKey('')}
|
||||
>
|
||||
<div className='sidebar-bookmark-toggler bg-base-100 z-20 flex h-full items-center gap-x-4'>
|
||||
<div className='hidden sm:flex'>
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
<div
|
||||
className={clsx('absolute top-0 z-10 hidden h-11 w-full sm:flex')}
|
||||
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-100 absolute left-0 right-0 top-0 z-10 h-[env(safe-area-inset-top)]',
|
||||
isVisible ? 'visible' : 'hidden',
|
||||
)}
|
||||
style={{
|
||||
height: systemUIVisible ? 'max(env(safe-area-inset-top), 24px)' : '',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
`header-bar bg-base-100 absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
|
||||
`shadow-xs transition-[opacity,margin-top] duration-300`,
|
||||
isTrafficLightVisible && isTopLeft && !isSideBarVisible ? 'pl-16' : 'pl-4',
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-top-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
isVisible ? 'pointer-events-auto visible' : 'pointer-events-none opacity-0',
|
||||
isDropdownOpen && 'header-bar-pinned',
|
||||
)}
|
||||
style={{
|
||||
marginTop: systemUIVisible
|
||||
? 'max(env(safe-area-inset-top), 24px)'
|
||||
: 'env(safe-area-inset-top)',
|
||||
}}
|
||||
onMouseLeave={() => !appService?.isMobile && setHoveredBookKey('')}
|
||||
>
|
||||
<div className='sidebar-bookmark-toggler z-20 flex h-full items-center gap-x-4'>
|
||||
<div className='hidden sm:flex'>
|
||||
<SidebarToggler bookKey={bookKey} />
|
||||
</div>
|
||||
<BookmarkToggler bookKey={bookKey} />
|
||||
</div>
|
||||
<BookmarkToggler bookKey={bookKey} />
|
||||
</div>
|
||||
|
||||
<div className='header-title z-15 pointer-events-none absolute inset-0 hidden items-center justify-center sm:flex'>
|
||||
<h2 className='line-clamp-1 max-w-[50%] text-center text-xs font-semibold'>{bookTitle}</h2>
|
||||
</div>
|
||||
<div className='header-title z-15 bg-base-100 pointer-events-none absolute inset-0 hidden items-center justify-center sm:flex'>
|
||||
<h2 className='line-clamp-1 max-w-[50%] text-center text-xs font-semibold'>
|
||||
{bookTitle}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4'>
|
||||
<SettingsToggler />
|
||||
<NotebookToggler bookKey={bookKey} />
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiDotsThreeVerticalBold size={iconSize16} />}
|
||||
onToggle={handleToggleDropdown}
|
||||
>
|
||||
<ViewMenu bookKey={bookKey} onSetSettingsDialogOpen={onSetSettingsDialogOpen} />
|
||||
</Dropdown>
|
||||
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4'>
|
||||
<SettingsToggler />
|
||||
<NotebookToggler bookKey={bookKey} />
|
||||
<Dropdown
|
||||
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiDotsThreeVerticalBold size={iconSize16} />}
|
||||
onToggle={handleToggleDropdown}
|
||||
>
|
||||
<ViewMenu bookKey={bookKey} onSetSettingsDialogOpen={onSetSettingsDialogOpen} />
|
||||
</Dropdown>
|
||||
|
||||
<WindowButtons
|
||||
className='window-buttons flex h-full items-center'
|
||||
headerRef={headerRef}
|
||||
showMinimize={
|
||||
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
|
||||
}
|
||||
showMaximize={
|
||||
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
|
||||
}
|
||||
onClose={() => onCloseBook(bookKey)}
|
||||
/>
|
||||
<WindowButtons
|
||||
className='window-buttons flex h-full items-center'
|
||||
headerRef={headerRef}
|
||||
showMinimize={
|
||||
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
|
||||
}
|
||||
showMaximize={
|
||||
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
|
||||
}
|
||||
onClose={() => {
|
||||
setHoveredBookKey(null);
|
||||
onCloseBook(bookKey);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,26 +45,35 @@ const HintInfo: React.FC<SectionInfoProps> = ({
|
||||
}, [hintMessage]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'hintinfo absolute flex items-center justify-end overflow-hidden',
|
||||
hintMessage ? 'bg-base-100' : 'bg-transparent',
|
||||
isVertical ? 'writing-vertical-rl max-h-[50%]' : 'top-0 h-[44px] max-w-[50%]',
|
||||
)}
|
||||
style={
|
||||
isVertical
|
||||
? {
|
||||
bottom: `${verticalMargin * 1.5}px`,
|
||||
left: `calc(100% - ${horizontalGap}%)`,
|
||||
width: showDoubleBorder ? '30px' : `${horizontalGap}%`,
|
||||
}
|
||||
: { insetInlineEnd: `${horizontalGap}%` }
|
||||
}
|
||||
>
|
||||
<h2 className={clsx('text-neutral-content text-center font-sans text-xs font-light')}>
|
||||
{hintMessage || ''}
|
||||
</h2>
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute left-0 right-0 top-0 z-10 h-[env(safe-area-inset-top)]',
|
||||
hintMessage ? 'bg-base-100' : 'bg-transparent',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'hintinfo absolute flex items-center justify-end overflow-hidden ps-2',
|
||||
'mt-[env(safe-area-inset-top)]',
|
||||
hintMessage ? 'bg-base-100' : 'bg-transparent',
|
||||
isVertical ? 'writing-vertical-rl max-h-[50%]' : 'top-0 h-[44px] max-w-[50%]',
|
||||
)}
|
||||
style={
|
||||
isVertical
|
||||
? {
|
||||
bottom: `${verticalMargin * 1.5}px`,
|
||||
left: `calc(100% - ${horizontalGap}%)`,
|
||||
width: showDoubleBorder ? '30px' : `${horizontalGap}%`,
|
||||
}
|
||||
: { insetInlineEnd: `${horizontalGap}%` }
|
||||
}
|
||||
>
|
||||
<h2 className={clsx('text-neutral-content text-center font-sans text-xs font-light')}>
|
||||
{hintMessage || ''}
|
||||
</h2>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ const PageInfoView: React.FC<PageInfoProps> = ({
|
||||
}
|
||||
: {
|
||||
insetInlineEnd: `${horizontalGap}%`,
|
||||
paddingBottom: appService?.hasSafeAreaInset ? 'env(safe-area-inset-bottom)' : 0,
|
||||
paddingBottom: appService?.hasSafeAreaInset
|
||||
? 'calc(env(safe-area-inset-bottom)*0.67)'
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
@@ -7,27 +7,30 @@ import { useEffect, Suspense, useRef } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { setSystemUIVisibility } from '@/utils/bridge';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import ReaderContent from './ReaderContent';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
|
||||
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
const { hoveredBookKey, showSystemUI, dismissSystemUI } = useReaderStore();
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const { getVisibleLibrary, setLibrary } = useLibraryStore();
|
||||
const isInitiating = useRef(false);
|
||||
|
||||
const { updateAppTheme } = useThemeStore();
|
||||
useTheme();
|
||||
useTheme({ systemUIVisible: false, appThemeColor: 'base-100' });
|
||||
useScreenWakeLock(settings.screenWakeLock);
|
||||
|
||||
useEffect(() => {
|
||||
updateAppTheme('base-100');
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
const initLibrary = async () => {
|
||||
@@ -41,6 +44,18 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.isMobile) return;
|
||||
const systemUIVisible = !!hoveredBookKey;
|
||||
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
|
||||
if (systemUIVisible) {
|
||||
showSystemUI();
|
||||
} else {
|
||||
dismissSystemUI();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hoveredBookKey]);
|
||||
|
||||
return (
|
||||
getVisibleLibrary().length > 0 &&
|
||||
settings.globalReadSettings && (
|
||||
@@ -53,6 +68,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
<Suspense>
|
||||
<ReaderContent ids={ids} settings={settings} />
|
||||
<AboutWindow />
|
||||
{appService?.isAndroidApp && <UpdaterWindow />}
|
||||
<Toast />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { Book } from '@/types/book';
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { parseOpenWithFiles } from '@/helpers/cli';
|
||||
import { parseOpenWithFiles } from '@/helpers/openWith';
|
||||
import { tauriHandleClose, tauriHandleOnCloseWindow } from '@/utils/window';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
@@ -135,7 +135,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
dismissBook(bookKey);
|
||||
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
|
||||
const openWithFiles = (await parseOpenWithFiles()) || [];
|
||||
if (openWithFiles.length > 0) {
|
||||
if (openWithFiles.length > 0 && !appService?.isMobile) {
|
||||
tauriHandleClose();
|
||||
} else {
|
||||
saveSettingsAndGoToLibrary();
|
||||
|
||||
@@ -5,22 +5,24 @@ interface RibbonProps {
|
||||
width: string;
|
||||
}
|
||||
|
||||
const Ribbon: React.FC<RibbonProps> = ({ width }) => {
|
||||
const Ribbon: React.FC<RibbonProps> = ({}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx('absolute inset-0 z-10 flex h-11 justify-center')}
|
||||
style={{ width: width }}
|
||||
className={clsx(
|
||||
'fixed inset-0 z-10 flex w-8 justify-center sm:w-6',
|
||||
'h-[calc(env(safe-area-inset-top)+44px)]',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
stroke='currentColor'
|
||||
fill='currentColor'
|
||||
strokeWidth='0'
|
||||
version='1'
|
||||
viewBox='0 0 20 45'
|
||||
enableBackground='new 0 0 20 45'
|
||||
width='100%'
|
||||
height='100%'
|
||||
preserveAspectRatio='none'
|
||||
viewBox='0 0 100 100'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
shapeRendering='geometricPrecision'
|
||||
imageRendering='optimizeQuality'
|
||||
>
|
||||
<path fill='#F44336' d='M 20 45 L 10 35 L 0 45 L 0 0 L 20 0'></path>
|
||||
<polygon fill='#F44336' points='100 100, 50 78, 0 100, 0 0, 100 0' />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,32 +19,41 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
|
||||
verticalMargin,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'sectioninfo absolute flex items-center overflow-hidden',
|
||||
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'top-0 h-[44px]',
|
||||
isScrolled && !isVertical && 'bg-base-100',
|
||||
)}
|
||||
style={
|
||||
isVertical
|
||||
? {
|
||||
top: `${verticalMargin * 1.5}px`,
|
||||
left: `calc(100% - ${horizontalGap}%)`,
|
||||
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
|
||||
height: `calc(100% - ${verticalMargin * 2}px)`,
|
||||
}
|
||||
: { insetInlineStart: `${horizontalGap}%`, width: `calc(100% - ${horizontalGap * 2}%)` }
|
||||
}
|
||||
>
|
||||
<h2
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'text-neutral-content text-center font-sans text-xs font-light',
|
||||
isVertical ? '' : 'line-clamp-1',
|
||||
'absolute left-0 right-0 top-0 z-10 h-[env(safe-area-inset-top)]',
|
||||
isScrolled && !isVertical && 'bg-base-100',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'sectioninfo absolute flex items-center overflow-hidden',
|
||||
'mt-[env(safe-area-inset-top)]',
|
||||
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'top-0 h-[44px]',
|
||||
isScrolled && !isVertical && 'bg-base-100',
|
||||
)}
|
||||
style={
|
||||
isVertical
|
||||
? {
|
||||
top: `${verticalMargin * 1.5}px`,
|
||||
left: `calc(100% - ${horizontalGap}%)`,
|
||||
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
|
||||
height: `calc(100% - ${verticalMargin * 2}px)`,
|
||||
}
|
||||
: { insetInlineStart: `${horizontalGap}%`, width: `calc(100% - ${horizontalGap * 2}%)` }
|
||||
}
|
||||
>
|
||||
{section || ''}
|
||||
</h2>
|
||||
</div>
|
||||
<h2
|
||||
className={clsx(
|
||||
'text-neutral-content text-center font-sans text-xs font-light',
|
||||
isVertical ? '' : 'line-clamp-1',
|
||||
)}
|
||||
>
|
||||
{section || ''}
|
||||
</h2>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import Popup from '@/components/Popup';
|
||||
import PopupButton from './PopupButton';
|
||||
@@ -8,6 +9,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface AnnotationPopupProps {
|
||||
dir: 'ltr' | 'rtl';
|
||||
isVertical: boolean;
|
||||
buttons: Array<{ tooltipText: string; Icon: React.ElementType; onClick: () => void }>;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
@@ -24,6 +26,7 @@ const OPTIONS_PADDING_PIX = 16;
|
||||
|
||||
const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
dir,
|
||||
isVertical,
|
||||
buttons,
|
||||
position,
|
||||
trianglePosition,
|
||||
@@ -39,17 +42,20 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
return (
|
||||
<div dir={dir}>
|
||||
<Popup
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
width={isVertical ? popupHeight : popupWidth}
|
||||
height={isVertical ? popupWidth : popupHeight}
|
||||
position={position}
|
||||
trianglePosition={trianglePosition}
|
||||
className='selection-popup bg-gray-600 px-4 text-white'
|
||||
className='selection-popup bg-gray-600 text-white'
|
||||
triangleClassName='text-gray-600'
|
||||
>
|
||||
<div
|
||||
className='selection-buttons flex items-center justify-between'
|
||||
className={clsx(
|
||||
'selection-buttons flex items-center justify-between p-2',
|
||||
isVertical ? 'flex-col' : 'flex-row',
|
||||
)}
|
||||
style={{
|
||||
height: popupHeight,
|
||||
height: isVertical ? popupWidth : popupHeight,
|
||||
}}
|
||||
>
|
||||
{buttons.map((button, index) => (
|
||||
@@ -65,15 +71,27 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
</Popup>
|
||||
{highlightOptionsVisible && (
|
||||
<HighlightOptions
|
||||
isVertical={isVertical}
|
||||
style={{
|
||||
width: `${popupWidth}px`,
|
||||
height: `${popupHeight}px`,
|
||||
left: `${position.point.x}px`,
|
||||
top: `${
|
||||
position.point.y +
|
||||
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
|
||||
(trianglePosition.dir === 'up' ? -1 : 1)
|
||||
}px`,
|
||||
width: `${isVertical ? popupHeight : popupWidth}px`,
|
||||
height: `${isVertical ? popupWidth : popupHeight}px`,
|
||||
...(isVertical
|
||||
? {
|
||||
left: `${
|
||||
position.point.x +
|
||||
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
|
||||
(trianglePosition.dir === 'left' ? -1 : 1)
|
||||
}px`,
|
||||
top: `${position.point.y}px`,
|
||||
}
|
||||
: {
|
||||
left: `${position.point.x}px`,
|
||||
top: `${
|
||||
position.point.y +
|
||||
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
|
||||
(trianglePosition.dir === 'up' ? -1 : 1)
|
||||
}px`,
|
||||
}),
|
||||
}}
|
||||
selectedStyle={selectedStyle}
|
||||
selectedColor={selectedColor}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useNotesSync } from '../../hooks/useNotesSync';
|
||||
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { findTocItemBS } from '@/utils/toc';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||
import AnnotationPopup from './AnnotationPopup';
|
||||
import WiktionaryPopup from './WiktionaryPopup';
|
||||
@@ -88,25 +89,34 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const isValidSelection = (sel: Selection) => {
|
||||
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
|
||||
};
|
||||
const makeSelection = (sel: Selection, rebuildRange = false) => {
|
||||
const transformCtx = {
|
||||
bookKey,
|
||||
viewSettings: getViewSettings(bookKey)!,
|
||||
content: '',
|
||||
transformers: ['punctuation'],
|
||||
reversePunctuationTransform: true,
|
||||
};
|
||||
const makeSelection = async (sel: Selection, rebuildRange = false) => {
|
||||
isTextSelected.current = true;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (rebuildRange) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
setSelection({ key: bookKey, text: sel.toString(), range, index });
|
||||
transformCtx['content'] = sel.toString();
|
||||
setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index });
|
||||
};
|
||||
// FIXME: extremely hacky way to dismiss system selection tools on iOS
|
||||
const makeSelectionOnIOS = (sel: Selection) => {
|
||||
const makeSelectionOnIOS = async (sel: Selection) => {
|
||||
isTextSelected.current = true;
|
||||
const range = sel.getRangeAt(0);
|
||||
setTimeout(() => {
|
||||
sel.removeAllRanges();
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
if (!isTextSelected.current) return;
|
||||
sel.addRange(range);
|
||||
setSelection({ key: bookKey, text: range.toString(), range, index });
|
||||
transformCtx['content'] = range.toString();
|
||||
setSelection({ key: bookKey, text: await transformContent(transformCtx), range, index });
|
||||
}, 40);
|
||||
}, 0);
|
||||
};
|
||||
@@ -175,6 +185,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
};
|
||||
|
||||
const onDrawAnnotation = (event: Event) => {
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const { draw, annotation, doc, range } = detail;
|
||||
const { style, color } = annotation as BookNote;
|
||||
@@ -185,8 +196,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
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: hexColor });
|
||||
const { writingMode, lineHeight, fontSize } = defaultView.getComputedStyle(el);
|
||||
const lineHeightValue =
|
||||
parseFloat(lineHeight) || viewSettings.lineHeight * viewSettings.defaultFontSize;
|
||||
const fontSizeValue = parseFloat(fontSize) || viewSettings.defaultFontSize;
|
||||
const strokeWidth = 2;
|
||||
const padding = viewSettings.vertical
|
||||
? (lineHeightValue - fontSizeValue - strokeWidth) / 2
|
||||
: strokeWidth;
|
||||
draw(Overlayer[style as keyof typeof Overlayer], { writingMode, color: hexColor, padding });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -259,8 +277,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const annotPopupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
annotPopupWidth,
|
||||
annotPopupHeight,
|
||||
viewSettings.vertical ? annotPopupHeight : annotPopupWidth,
|
||||
viewSettings.vertical ? annotPopupWidth : annotPopupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
if (isTextSelected.current && annotPopupPos.dir === 'down' && osPlatform === 'android') {
|
||||
@@ -577,6 +595,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
{showAnnotPopup && trianglePosition && annotPopupPosition && (
|
||||
<AnnotationPopup
|
||||
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
|
||||
isVertical={viewSettings.vertical}
|
||||
buttons={buttons}
|
||||
position={annotPopupPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
|
||||
@@ -9,6 +9,7 @@ const styles = ['highlight', 'underline', 'squiggly'] as HighlightStyle[];
|
||||
const colors = ['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[];
|
||||
|
||||
interface HighlightOptionsProps {
|
||||
isVertical: boolean;
|
||||
style: React.CSSProperties;
|
||||
selectedStyle: HighlightStyle;
|
||||
selectedColor: HighlightColor;
|
||||
@@ -17,6 +18,7 @@ interface HighlightOptionsProps {
|
||||
|
||||
const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
||||
style,
|
||||
isVertical,
|
||||
selectedStyle: _selectedStyle,
|
||||
selectedColor: _selectedColor,
|
||||
onHandleHighlight,
|
||||
@@ -44,13 +46,22 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
||||
onHandleHighlight(true);
|
||||
};
|
||||
return (
|
||||
<div className='highlight-options absolute flex items-center justify-between' style={style}>
|
||||
<div className='flex gap-2' style={{ height: size28 }}>
|
||||
<div
|
||||
className={clsx(
|
||||
'highlight-options absolute flex items-center justify-between',
|
||||
isVertical ? 'flex-col' : 'flex-row',
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={clsx('flex gap-2', isVertical ? 'flex-col' : 'flex-row')}
|
||||
style={isVertical ? { width: size28 } : { height: size28 }}
|
||||
>
|
||||
{styles.map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => handleSelectStyle(style)}
|
||||
className={`flex items-center justify-center rounded-full bg-gray-700 p-0`}
|
||||
className='flex items-center justify-center rounded-full bg-gray-700 p-0'
|
||||
style={{ width: size28, height: size28, minHeight: size28 }}
|
||||
>
|
||||
<div
|
||||
@@ -78,8 +89,11 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='flex items-center justify-center gap-2 rounded-3xl bg-gray-700 px-2'
|
||||
style={{ height: size28 }}
|
||||
className={clsx(
|
||||
'flex items-center justify-center gap-2 rounded-3xl bg-gray-700',
|
||||
isVertical ? 'flex-col py-2' : 'flex-row px-2',
|
||||
)}
|
||||
style={isVertical ? { width: size28 } : { height: size28 }}
|
||||
>
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
|
||||
@@ -20,7 +20,7 @@ const PopupButton: React.FC<PopupButtonProps> = ({ showTooltip, tooltipText, Ico
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className='my-2 flex h-8 min-h-8 w-8 items-center justify-center p-0'
|
||||
className='flex h-8 min-h-8 w-8 items-center justify-center p-0'
|
||||
>
|
||||
<Icon />
|
||||
</button>
|
||||
|
||||
@@ -41,7 +41,10 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
||||
</button>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className='dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-[-32px] z-[1] mt-4 w-44 shadow sm:right-0'
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-[-32px] z-[1] mt-4 w-44 shadow sm:right-0',
|
||||
moreOptions?.length ? '' : 'inline max-h-80 overflow-y-scroll',
|
||||
)}
|
||||
>
|
||||
{options.map(({ option, label }) => (
|
||||
<li key={option} onClick={() => onSelect(option)}>
|
||||
|
||||
@@ -3,6 +3,10 @@ import React, { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
ANDROID_FONTS,
|
||||
CJK_FONTS_PATTENS,
|
||||
CJK_NAMES_PATTENS,
|
||||
CJK_SANS_SERIF_FONTS,
|
||||
CJK_SERIF_FONTS,
|
||||
IOS_FONTS,
|
||||
LINUX_FONTS,
|
||||
MACOS_FONTS,
|
||||
@@ -14,7 +18,7 @@ import {
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { getOSPlatform, isCJKEnv } from '@/utils/misc';
|
||||
import { getSysFontsList } from '@/utils/font';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
@@ -63,8 +67,9 @@ const FontFace = ({
|
||||
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const view = getView(bookKey)!;
|
||||
|
||||
const fontFamilyOptions = [
|
||||
{
|
||||
@@ -103,10 +108,16 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [minFontSize, setMinFontSize] = useState(viewSettings.minimumFontSize!);
|
||||
const [overrideFont, setOverrideFont] = useState(viewSettings.overrideFont!);
|
||||
const [defaultFont, setDefaultFont] = useState(viewSettings.defaultFont!);
|
||||
const [defaultCJKFont, setDefaultCJKFont] = useState(viewSettings.defaultCJKFont!);
|
||||
const [serifFont, setSerifFont] = useState(viewSettings.serifFont!);
|
||||
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
|
||||
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
|
||||
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
|
||||
const [CJKFonts] = useState<string[]>(() => {
|
||||
return Array.from(new Set([...sysFonts, ...CJK_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]))
|
||||
.filter((font) => CJK_FONTS_PATTENS.test(font) || CJK_NAMES_PATTENS.test(font))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isTauriAppPlatform() && appService?.hasSysFontsList) {
|
||||
@@ -122,6 +133,11 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultFont]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'defaultCJKFont', defaultCJKFont);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultCJKFont]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'defaultFontSize', defaultFontSize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -224,6 +240,17 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(isCJKEnv() || view?.language.isCJK) && (
|
||||
<FontFace
|
||||
className='config-item-top'
|
||||
family='serif'
|
||||
label={_('CJK Font')}
|
||||
options={CJKFonts}
|
||||
selected={defaultCJKFont}
|
||||
onSelect={setDefaultCJKFont}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Override Book Font')}</span>
|
||||
<input
|
||||
@@ -245,7 +272,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
className='config-item-top'
|
||||
family='serif'
|
||||
label={_('Serif Font')}
|
||||
options={SERIF_FONTS}
|
||||
options={[...SERIF_FONTS, ...CJK_SERIF_FONTS]}
|
||||
moreOptions={sysFonts}
|
||||
selected={serifFont}
|
||||
onSelect={setSerifFont}
|
||||
@@ -253,7 +280,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<FontFace
|
||||
family='sans-serif'
|
||||
label={_('Sans-Serif Font')}
|
||||
options={SANS_SERIF_FONTS}
|
||||
options={[...SANS_SERIF_FONTS, ...CJK_SANS_SERIF_FONTS]}
|
||||
moreOptions={sysFonts}
|
||||
selected={sansSerifFont}
|
||||
onSelect={setSansSerifFont}
|
||||
|
||||
@@ -43,6 +43,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [borderColor, setBorderColor] = useState(viewSettings.borderColor!);
|
||||
const [showHeader, setShowHeader] = useState(viewSettings.showHeader!);
|
||||
const [showFooter, setShowFooter] = useState(viewSettings.showFooter!);
|
||||
const [showBarsOnScroll, setShowBarsOnScroll] = useState(viewSettings.showBarsOnScroll!);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'paragraphMargin', paragraphMargin);
|
||||
@@ -196,6 +197,11 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'showBarsOnScroll', showBarsOnScroll, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showBarsOnScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showFooter === viewSettings.showFooter) return;
|
||||
if (showFooter && !viewSettings.vertical) {
|
||||
@@ -391,24 +397,6 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<h2 className='mb-2 font-medium'>{_('Page')}</h2>
|
||||
<div className='card bg-base-100 border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Header')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showHeader}
|
||||
onChange={() => setShowHeader(!showHeader)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Footer')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showFooter}
|
||||
onChange={() => setShowFooter(!showFooter)}
|
||||
/>
|
||||
</div>
|
||||
<NumberInput
|
||||
label={_('Vertical Margins (px)')}
|
||||
value={marginPx}
|
||||
@@ -456,6 +444,41 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Header & Footer')}</h2>
|
||||
<div className='card bg-base-100 border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Header')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showHeader}
|
||||
onChange={() => setShowHeader(!showHeader)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Show Footer')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showFooter}
|
||||
onChange={() => setShowFooter(!showFooter)}
|
||||
/>
|
||||
</div>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Apply also in Scrolled Mode')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={showBarsOnScroll}
|
||||
onChange={() => setShowBarsOnScroll(!showBarsOnScroll)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,8 +8,8 @@ import { RiDashboardLine } from 'react-icons/ri';
|
||||
import { VscSymbolColor } from 'react-icons/vsc';
|
||||
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
|
||||
import { IoAccessibilityOutline } from 'react-icons/io5';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
|
||||
import { MdArrowBackIosNew, MdArrowForwardIos } from 'react-icons/md';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
import FontPanel from './FontPanel';
|
||||
import LayoutPanel from './LayoutPanel';
|
||||
import ColorPanel from './ColorPanel';
|
||||
@@ -28,6 +28,7 @@ type TabConfig = {
|
||||
|
||||
const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
|
||||
const [activePanel, setActivePanel] = useState<SettingsPanelType>(
|
||||
(localStorage.getItem('lastConfigPanel') || 'Font') as SettingsPanelType,
|
||||
);
|
||||
@@ -79,10 +80,10 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
tabIndex={-1}
|
||||
onClick={handleClose}
|
||||
className={
|
||||
'btn btn-ghost btn-circle flex h-6 min-h-6 w-6 hover:bg-transparent focus:outline-none sm:hidden'
|
||||
'btn btn-ghost btn-circle flex h-8 min-h-8 w-8 hover:bg-transparent focus:outline-none sm:hidden'
|
||||
}
|
||||
>
|
||||
<MdArrowBackIosNew />
|
||||
{isRtl ? <MdArrowForwardIos /> : <MdArrowBackIosNew />}
|
||||
</button>
|
||||
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center gap-2 pl-4'>
|
||||
{tabConfig.map(({ tab, icon: Icon, label }) => (
|
||||
|
||||
@@ -42,13 +42,13 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
|
||||
<div className='mb-2 mt-4'>
|
||||
<label className='mb-1 block text-sm font-medium'>{label}</label>
|
||||
<div
|
||||
className='border-base-300 overflow-hidden rounded border p-3'
|
||||
className='border-base-300 overflow-hidden rounded border p-2'
|
||||
style={{
|
||||
backgroundColor: backgroundColor,
|
||||
color: textColor,
|
||||
}}
|
||||
>
|
||||
<p className='mb-2 whitespace-pre-line text-sm'>
|
||||
<p className='mb-2 whitespace-pre-line text-xs'>
|
||||
{_(
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare",
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { eventDispatcher } from '@/utils/event';
|
||||
import { parseSSMLLang } from '@/utils/ssml';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { invokeUseBackgroundAudio } from '@/utils/bridge';
|
||||
import Popup from '@/components/Popup';
|
||||
import TTSPanel from './TTSPanel';
|
||||
import TTSIcon from './TTSIcon';
|
||||
@@ -33,21 +34,43 @@ const TTSControl = () => {
|
||||
const [panelPosition, setPanelPosition] = useState<Position>();
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position>();
|
||||
|
||||
const popupWidth = useResponsiveSize(POPUP_WIDTH);
|
||||
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
|
||||
const [timeoutOption, setTimeoutOption] = useState(0);
|
||||
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
|
||||
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const popupPadding = useResponsiveSize(POPUP_PADDING);
|
||||
const maxWidth = window.innerWidth - 2 * popupPadding;
|
||||
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
|
||||
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
|
||||
|
||||
const iconRef = useRef<HTMLDivElement>(null);
|
||||
const ttsControllerRef = useRef<TTSController | null>(null);
|
||||
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// this enables WebAudio to play even when the mute toggle switch is ON
|
||||
const unblockAudio = () => {
|
||||
const audio = document.createElement('audio');
|
||||
audio.setAttribute('x-webkit-airplay', 'deny');
|
||||
audio.preload = 'auto';
|
||||
audio.loop = true;
|
||||
audio.src = SILENCE_DATA;
|
||||
audio.play();
|
||||
if (unblockerAudioRef.current) return;
|
||||
unblockerAudioRef.current = document.createElement('audio');
|
||||
unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny');
|
||||
unblockerAudioRef.current.preload = 'auto';
|
||||
unblockerAudioRef.current.loop = true;
|
||||
unblockerAudioRef.current.src = SILENCE_DATA;
|
||||
unblockerAudioRef.current.play();
|
||||
};
|
||||
|
||||
const releaseUnblockAudio = () => {
|
||||
if (!unblockerAudioRef.current) return;
|
||||
try {
|
||||
unblockerAudioRef.current.pause();
|
||||
unblockerAudioRef.current.currentTime = 0;
|
||||
unblockerAudioRef.current.removeAttribute('src');
|
||||
unblockerAudioRef.current.src = '';
|
||||
unblockerAudioRef.current.load();
|
||||
unblockerAudioRef.current = null;
|
||||
console.log('Unblock audio released');
|
||||
} catch (err) {
|
||||
console.warn('Error releasing unblock audio:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,6 +117,9 @@ const TTSControl = () => {
|
||||
setShowIndicator(true);
|
||||
|
||||
try {
|
||||
if (appService?.isIOSApp) {
|
||||
await invokeUseBackgroundAudio({ enabled: true });
|
||||
}
|
||||
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
|
||||
unblockAudio();
|
||||
}
|
||||
@@ -121,11 +147,8 @@ const TTSControl = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTTSStop = async (event: CustomEvent) => {
|
||||
const { bookKey: stopBookKey } = event.detail;
|
||||
if (bookKey === stopBookKey) {
|
||||
handleStop();
|
||||
}
|
||||
const handleTTSStop = async () => {
|
||||
handleStop();
|
||||
};
|
||||
|
||||
const handleQueryIsSpeaking = () => {
|
||||
@@ -177,6 +200,12 @@ const TTSControl = () => {
|
||||
setShowPanel(false);
|
||||
setShowIndicator(false);
|
||||
}
|
||||
if (appService?.isIOSApp) {
|
||||
await invokeUseBackgroundAudio({ enabled: false });
|
||||
}
|
||||
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
|
||||
releaseUnblockAudio();
|
||||
}
|
||||
};
|
||||
|
||||
// rate range: 0.5 - 3, 1.0 is normal speed
|
||||
@@ -230,6 +259,23 @@ const TTSControl = () => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleSelectTimeout = (value: number) => {
|
||||
setTimeoutOption(value);
|
||||
if (timeoutFunc) {
|
||||
clearTimeout(timeoutFunc);
|
||||
}
|
||||
if (value > 0) {
|
||||
setTimeoutFunc(
|
||||
setTimeout(() => {
|
||||
handleStop();
|
||||
}, value * 1000),
|
||||
);
|
||||
setTimeoutTimestamp(Date.now() + value * 1000);
|
||||
} else {
|
||||
setTimeoutTimestamp(0);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePanelPosition = () => {
|
||||
if (iconRef.current) {
|
||||
const rect = iconRef.current.getBoundingClientRect();
|
||||
@@ -296,14 +342,16 @@ const TTSControl = () => {
|
||||
bookKey={bookKey}
|
||||
ttsLang={ttsLang}
|
||||
isPlaying={isPlaying}
|
||||
timeoutOption={timeoutOption}
|
||||
timeoutTimestamp={timeoutTimestamp}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
onBackward={handleBackward}
|
||||
onForward={handleForward}
|
||||
onStop={handleStop}
|
||||
onSetRate={handleSetRate}
|
||||
onGetVoices={handleGetVoices}
|
||||
onSetVoice={handleSetVoice}
|
||||
onGetVoiceId={handleGetVoiceId}
|
||||
onSelectTimeout={handleSelectTimeout}
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, ChangeEvent, useEffect } from 'react';
|
||||
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdStop } from 'react-icons/md';
|
||||
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdAlarm } from 'react-icons/md';
|
||||
import { RiVoiceAiFill } from 'react-icons/ri';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { TTSVoice } from '@/services/tts';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { TranslationFunc, useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
@@ -14,28 +14,104 @@ type TTSPanelProps = {
|
||||
bookKey: string;
|
||||
ttsLang: string;
|
||||
isPlaying: boolean;
|
||||
timeoutOption: number;
|
||||
timeoutTimestamp: number;
|
||||
onTogglePlay: () => void;
|
||||
onBackward: () => void;
|
||||
onForward: () => void;
|
||||
onStop: () => void;
|
||||
onSetRate: (rate: number) => void;
|
||||
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
|
||||
onSetVoice: (voice: string) => void;
|
||||
onGetVoiceId: () => string;
|
||||
onSelectTimeout: (value: number) => void;
|
||||
};
|
||||
|
||||
const getTTSTimeoutOptions = (_: TranslationFunc) => {
|
||||
return [
|
||||
{
|
||||
label: _('No Timeout'),
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} minute', { value: 1 }),
|
||||
value: 60,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} minutes', { value: 3 }),
|
||||
value: 180,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} minutes', { value: 5 }),
|
||||
value: 300,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} minutes', { value: 10 }),
|
||||
value: 600,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} minutes', { value: 20 }),
|
||||
value: 1200,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} minutes', { value: 30 }),
|
||||
value: 1800,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} minutes', { value: 45 }),
|
||||
value: 2700,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} hour', { value: 1 }),
|
||||
value: 3600,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} hours', { value: 2 }),
|
||||
value: 7200,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} hours', { value: 3 }),
|
||||
value: 10800,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} hours', { value: 4 }),
|
||||
value: 14400,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} hours', { value: 6 }),
|
||||
value: 21600,
|
||||
},
|
||||
{
|
||||
label: _('{{value}} hours', { value: 8 }),
|
||||
value: 28800,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getCountdownTime = (timeout: number) => {
|
||||
const now = Date.now();
|
||||
if (timeout > now) {
|
||||
const remainingTime = Math.floor((timeout - now) / 1000);
|
||||
const minutes = Math.floor(remainingTime / 3600) * 60 + Math.floor((remainingTime % 3600) / 60);
|
||||
const seconds = remainingTime % 60;
|
||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const TTSPanel = ({
|
||||
bookKey,
|
||||
ttsLang,
|
||||
isPlaying,
|
||||
timeoutOption,
|
||||
timeoutTimestamp,
|
||||
onTogglePlay,
|
||||
onBackward,
|
||||
onForward,
|
||||
onStop,
|
||||
onSetRate,
|
||||
onGetVoices,
|
||||
onSetVoice,
|
||||
onGetVoiceId,
|
||||
onSelectTimeout,
|
||||
}: TTSPanelProps) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
@@ -47,6 +123,10 @@ const TTSPanel = ({
|
||||
const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0);
|
||||
const [selectedVoice, setSelectedVoice] = useState(viewSettings?.ttsVoice ?? '');
|
||||
|
||||
const [timeoutCountdown, setTimeoutCountdown] = useState(() => {
|
||||
return getCountdownTime(timeoutTimestamp);
|
||||
});
|
||||
|
||||
const defaultIconSize = useDefaultIconSize();
|
||||
const iconSize32 = useResponsiveSize(32);
|
||||
const iconSize48 = useResponsiveSize(48);
|
||||
@@ -72,6 +152,23 @@ const TTSPanel = ({
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
};
|
||||
|
||||
const updateTimeout = (timeout: number) => {
|
||||
const now = Date.now();
|
||||
if (timeout > 0 && timeout < now) {
|
||||
onSelectTimeout(0);
|
||||
setTimeoutCountdown('');
|
||||
} else if (timeout > 0) {
|
||||
setTimeoutCountdown(getCountdownTime(timeout));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
updateTimeout(timeoutTimestamp);
|
||||
}, 1000);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeoutTimestamp, timeoutCountdown]);
|
||||
|
||||
useEffect(() => {
|
||||
const voiceId = onGetVoiceId();
|
||||
setSelectedVoice(voiceId);
|
||||
@@ -87,6 +184,8 @@ const TTSPanel = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ttsLang]);
|
||||
|
||||
const timeoutOptions = getTTSTimeoutOptions(_);
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl p-4'>
|
||||
<div className='flex w-full flex-col items-center gap-0.5'>
|
||||
@@ -132,9 +231,40 @@ const TTSPanel = ({
|
||||
<button onClick={onForward} className='rounded-full p-1'>
|
||||
<MdFastForward size={iconSize32} />
|
||||
</button>
|
||||
<button onClick={onStop} className='rounded-full p-1'>
|
||||
<MdStop size={iconSize32} />
|
||||
</button>
|
||||
<div className='dropdown dropdown-top'>
|
||||
<button className='flex flex-col items-center justify-center rounded-full p-1'>
|
||||
<MdAlarm size={iconSize32} />
|
||||
{timeoutCountdown && (
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute bottom-0 left-1/2 w-12 translate-x-[-50%] translate-y-[80%] px-1',
|
||||
'bg-primary/80 text-base-100 rounded-full text-center text-xs',
|
||||
)}
|
||||
>
|
||||
{timeoutCountdown}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
|
||||
'mt-4 inline max-h-96 w-[200px] overflow-y-scroll',
|
||||
)}
|
||||
>
|
||||
{timeoutOptions.map((option, index) => (
|
||||
<li key={`${index}-${option.value}`} onClick={() => onSelectTimeout(option.value)}>
|
||||
<div className='flex items-center px-2'>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
{timeoutOption === option.value && <MdCheck className='text-base-content' />}
|
||||
</span>
|
||||
<span className={clsx('text-base sm:text-sm')}>{option.label}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='dropdown dropdown-top'>
|
||||
<button tabIndex={0} className='rounded-full p-1'>
|
||||
<RiVoiceAiFill size={iconSize32} />
|
||||
|
||||
@@ -31,7 +31,10 @@ export const useClickEvent = (
|
||||
if (appService?.isMobile) {
|
||||
windowStartX = 0;
|
||||
} else {
|
||||
const windowPosition = await tauriGetWindowLogicalPosition();
|
||||
const windowPosition = (await tauriGetWindowLogicalPosition()) as {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
windowStartX = windowPosition.x;
|
||||
}
|
||||
} else {
|
||||
@@ -43,12 +46,16 @@ export const useClickEvent = (
|
||||
if (!consumed) {
|
||||
const centerStartX = viewStartX + viewRect.width * 0.375;
|
||||
const centerEndX = viewStartX + viewRect.width * 0.625;
|
||||
if (screenX >= centerStartX && screenX <= centerEndX) {
|
||||
if (
|
||||
viewSettings.disableClick! ||
|
||||
(screenX >= centerStartX && screenX <= centerEndX)
|
||||
) {
|
||||
// toggle visibility of the header bar and the footer bar
|
||||
setHoveredBookKey(hoveredBookKey ? null : bookKey);
|
||||
} else {
|
||||
if (hoveredBookKey) {
|
||||
setHoveredBookKey(null);
|
||||
return;
|
||||
}
|
||||
if (!viewSettings.disableClick! && screenX >= viewCenterX) {
|
||||
if (viewSettings.swapClickArea) {
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { hasUpdater } from '@/services/environment';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import Reader from './components/Reader';
|
||||
|
||||
export default function Page() {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
useOpenWithBooks();
|
||||
|
||||
useEffect(() => {
|
||||
const doCheckAppUpdates = async () => {
|
||||
if (hasUpdater() && settings.autoCheckUpdates) {
|
||||
if (appService?.hasUpdater && settings.autoCheckUpdates) {
|
||||
await checkForAppUpdates(_);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { UpdaterContent } from '@/components/UpdaterWindow';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import Spinner from '@/components/Spinner';
|
||||
|
||||
const UpdaterPage = () => {
|
||||
useTheme();
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='px-12 py-4'>
|
||||
<UpdaterContent />
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdaterPage;
|
||||
@@ -34,7 +34,7 @@ const ProfilePage = () => {
|
||||
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useTheme();
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import packageJson from '../../package.json';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { hasUpdater } from '@/services/environment';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { parseWebViewVersion } from '@/utils/ua';
|
||||
import Dialog from './Dialog';
|
||||
import Link from './Link';
|
||||
|
||||
export const setAboutDialogVisible = (visible: boolean) => {
|
||||
const dialog = document.getElementById('about_window');
|
||||
@@ -17,34 +19,45 @@ export const setAboutDialogVisible = (visible: boolean) => {
|
||||
|
||||
export const AboutWindow = () => {
|
||||
const _ = useTranslation();
|
||||
const [isUpdated, setIsUpdated] = React.useState(false);
|
||||
const { appService } = useEnv();
|
||||
const [isUpdated, setIsUpdated] = useState(false);
|
||||
const [browserInfo, setBrowserInfo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setBrowserInfo(parseWebViewVersion(appService));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
const update = await checkForAppUpdates(_, false);
|
||||
if (!update) {
|
||||
if (update) {
|
||||
setAboutDialogVisible(false);
|
||||
} else {
|
||||
setIsUpdated(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
id='about_window'
|
||||
isOpen={false}
|
||||
title={_('About Readest')}
|
||||
onClose={() => setAboutDialogVisible(false)}
|
||||
boxClassName='sm:!w-96 sm:h-auto'
|
||||
>
|
||||
<div className='about-content flex h-full flex-col items-center justify-center'>
|
||||
<div className='flex flex-col items-center px-8'>
|
||||
<div className='mb-4'>
|
||||
<Image src='/icon.png' alt='App Logo' className='h-24 w-24' width={64} height={64} />
|
||||
</div>
|
||||
<Dialog
|
||||
id='about_window'
|
||||
isOpen={false}
|
||||
title={_('About Readest')}
|
||||
onClose={() => setAboutDialogVisible(false)}
|
||||
boxClassName='sm:!w-96 sm:h-auto'
|
||||
>
|
||||
<div className='about-content flex h-full flex-col items-center justify-center'>
|
||||
<div className='flex flex-col items-center px-8'>
|
||||
<div className='mb-3'>
|
||||
<Image src='/icon.png' alt='App Logo' className='h-24 w-24' width={64} height={64} />
|
||||
</div>
|
||||
<div className='flex select-text flex-col items-center'>
|
||||
<h2 className='text-2xl font-bold'>Readest</h2>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{_('Version {{version}}', { version: packageJson.version })}
|
||||
{_('Version {{version}}', { version: packageJson.version })} {`(${browserInfo})`}
|
||||
</p>
|
||||
{hasUpdater() && !isUpdated && (
|
||||
</div>
|
||||
<div className='h-5'>
|
||||
{appService?.hasUpdater && !isUpdated && (
|
||||
<span className='badge badge-primary mt-2 cursor-pointer' onClick={handleCheckUpdate}>
|
||||
{_('Check Update')}
|
||||
</span>
|
||||
@@ -53,41 +66,34 @@ export const AboutWindow = () => {
|
||||
<p className='text-neutral-content mt-2 text-xs'>{_('Already the latest version')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='divider py-12 sm:py-2'></div>
|
||||
|
||||
<div className='flex flex-col items-center px-4 text-center'>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
|
||||
</p>
|
||||
<p className='text-neutral-content mt-2 text-xs'>
|
||||
This software is licensed under the{' '}
|
||||
<a
|
||||
href='https://www.gnu.org/licenses/agpl-3.0.html'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
GNU Affero General Public License v3.0
|
||||
</a>
|
||||
. You are free to use, modify, and distribute this software under the terms of the
|
||||
AGPL v3 license. Please see the license for more details.
|
||||
</p>
|
||||
<p className='text-neutral-content my-2 text-xs'>
|
||||
Source code is available at{' '}
|
||||
<a
|
||||
href='https://github.com/readest/readest'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
<div className='divider py-12 sm:py-2'></div>
|
||||
|
||||
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
|
||||
</p>
|
||||
<p className='text-neutral-content mt-2 text-xs'>
|
||||
This software is licensed under the{' '}
|
||||
<Link
|
||||
href='https://www.gnu.org/licenses/agpl-3.0.html'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
GNU Affero General Public License v3.0
|
||||
</Link>
|
||||
. You are free to use, modify, and distribute this software under the terms of the AGPL
|
||||
v3 license. Please see the license for more details.
|
||||
</p>
|
||||
<p className='text-neutral-content my-2 text-xs'>
|
||||
Source code is available at{' '}
|
||||
<Link href='https://github.com/readest/readest' className='text-blue-500 underline'>
|
||||
GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ const Alert: React.FC<{
|
||||
<div className='text-xs'>{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center justify-center gap-2'>
|
||||
<div className='flex max-w-[20vw] flex-wrap items-center justify-center gap-2'>
|
||||
<button className='btn btn-sm' onClick={onCancel}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { MdDelete, MdCloudDownload, MdCloudUpload } from 'react-icons/md';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import {
|
||||
formatAuthors,
|
||||
formatDate,
|
||||
@@ -24,16 +24,25 @@ interface BookDetailModalProps {
|
||||
book: Book;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleBookDownload?: (book: Book) => void;
|
||||
handleBookUpload?: (book: Book) => void;
|
||||
handleBookDelete?: (book: Book) => void;
|
||||
}
|
||||
|
||||
const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
const BookDetailModal = ({
|
||||
book,
|
||||
isOpen,
|
||||
onClose,
|
||||
handleBookDownload,
|
||||
handleBookUpload,
|
||||
handleBookDelete,
|
||||
}: BookDetailModalProps) => {
|
||||
const _ = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [bookMeta, setBookMeta] = useState<BookDoc['metadata'] | null>(null);
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
|
||||
useEffect(() => {
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 300);
|
||||
@@ -61,10 +70,25 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
await appService?.deleteBook(book, !!book.uploadedAt);
|
||||
await updateBook(envConfig, book);
|
||||
handleClose();
|
||||
setShowDeleteAlert(false);
|
||||
if (handleBookDelete) {
|
||||
handleBookDelete(book);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedownload = async () => {
|
||||
handleClose();
|
||||
if (handleBookDownload) {
|
||||
handleBookDownload(book);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReupload = async () => {
|
||||
handleClose();
|
||||
if (handleBookUpload) {
|
||||
handleBookUpload(book);
|
||||
}
|
||||
};
|
||||
|
||||
if (!bookMeta)
|
||||
@@ -83,13 +107,13 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
bgClassName='sm:bg-black/50'
|
||||
boxClassName='sm:min-w-[480px] sm:h-auto'
|
||||
boxClassName='sm:min-w-[480px] sm:max-w-[480px] sm:h-auto'
|
||||
contentClassName='!px-6 !py-2'
|
||||
>
|
||||
<div className='flex w-full select-text items-center justify-center'>
|
||||
<div className='relative w-full rounded-lg'>
|
||||
<div className='mb-10 flex h-40 items-start'>
|
||||
<div className='book-cover relative mr-10 aspect-[28/41] h-40 items-end shadow-lg'>
|
||||
<div className='mb-10 me-4 flex h-32 items-start'>
|
||||
<div className='book-cover relative mr-10 aspect-[28/41] h-32 items-end shadow-lg'>
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
alt={formatTitle(book.title)}
|
||||
@@ -112,45 +136,35 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='title-author flex h-40 flex-col justify-between'>
|
||||
<div className='title-author flex h-32 flex-col justify-between'>
|
||||
<div>
|
||||
<p className='text-base-content mb-2 line-clamp-2 break-all text-2xl font-bold'>
|
||||
<p className='text-base-content mb-2 line-clamp-2 break-all text-lg font-bold'>
|
||||
{formatTitle(book.title) || _('Untitled')}
|
||||
</p>
|
||||
<p className='text-neutral-content line-clamp-1'>
|
||||
{formatAuthors(book.author, bookMeta.language) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
{window.innerWidth >= 400 && (
|
||||
<div className='flex flex-wrap items-center gap-x-4 gap-y-2 py-2'>
|
||||
<button
|
||||
className='btn rounded-xl bg-red-600 px-4 text-white hover:bg-red-700'
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{_('Delete')}
|
||||
<div className='flex flex-wrap items-center gap-x-4'>
|
||||
{handleBookDelete && (
|
||||
<button onClick={handleDelete}>
|
||||
<MdDelete className='fill-red-500' />
|
||||
</button>
|
||||
<button className='btn btn-disabled bg-primary/25 hover:bg-primary/85 rounded-xl px-4 text-white'>
|
||||
{_('More Info')}
|
||||
)}
|
||||
{book.uploadedAt && handleBookDownload && (
|
||||
<button onClick={handleRedownload}>
|
||||
<MdCloudDownload className='fill-base-content' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{book.downloadedAt && handleBookUpload && (
|
||||
<button onClick={handleReupload}>
|
||||
<MdCloudUpload className='fill-base-content' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{window.innerWidth < 400 && (
|
||||
<div className='flex flex-wrap items-center gap-x-4 gap-y-2 py-2'>
|
||||
<button
|
||||
className='btn rounded bg-red-600 text-white hover:bg-red-700'
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{_('Delete')}
|
||||
</button>
|
||||
<button className='btn btn-disabled bg-primary/25 hover:bg-primary/85 rounded px-4 text-white'>
|
||||
{_('More Info')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='text-base-content my-4'>
|
||||
<div className='mb-4 grid grid-cols-2 gap-4 sm:grid-cols-3'>
|
||||
<div className='overflow-hidden'>
|
||||
@@ -167,11 +181,12 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Updated:')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
{formatDate(book.lastUpdated) || ''}
|
||||
</p>
|
||||
<p className='text-neutral-content text-sm'>{formatDate(book.updatedAt) || ''}</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Added:')}</span>
|
||||
<p className='text-neutral-content text-sm'>{formatDate(book.createdAt) || ''}</p>
|
||||
</div>
|
||||
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Language:')}</span>
|
||||
<p className='text-neutral-content text-sm'>
|
||||
@@ -190,6 +205,12 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
{formatSubject(bookMeta.subject) || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='overflow-hidden'>
|
||||
<span className='font-bold'>{_('Format:')}</span>
|
||||
<p className='text-neutral-content line-clamp-1 text-sm'>
|
||||
{book.format || _('Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import { MdArrowBackIosNew, MdArrowForwardIos } 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';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
const SNAP_THRESHOLD = 0.2;
|
||||
@@ -38,6 +39,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const [isFullHeightInMobile, setIsFullHeightInMobile] = React.useState(!snapHeight);
|
||||
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
|
||||
const iconSize22 = useResponsiveSize(22);
|
||||
const isMobile = window.innerWidth < 640;
|
||||
|
||||
@@ -133,6 +135,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
'modal sm:min-w-90 z-50 h-full w-full !items-start !bg-transparent sm:w-full sm:!items-center',
|
||||
className,
|
||||
)}
|
||||
dir={isRtl ? 'rtl' : undefined}
|
||||
>
|
||||
<div
|
||||
className={clsx('overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20', bgClassName)}
|
||||
@@ -177,10 +180,14 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
tabIndex={-1}
|
||||
onClick={onClose}
|
||||
className={
|
||||
'btn btn-ghost btn-circle flex h-6 min-h-6 w-6 hover:bg-transparent focus:outline-none sm:hidden'
|
||||
'btn btn-ghost btn-circle flex h-8 min-h-8 w-8 hover:bg-transparent focus:outline-none sm:hidden'
|
||||
}
|
||||
>
|
||||
<MdArrowBackIosNew size={iconSize22} />
|
||||
{isRtl ? (
|
||||
<MdArrowForwardIos size={iconSize22} />
|
||||
) : (
|
||||
<MdArrowBackIosNew size={iconSize22} />
|
||||
)}
|
||||
</button>
|
||||
<div className='z-15 pointer-events-none absolute inset-0 flex h-11 items-center justify-center'>
|
||||
<span className='line-clamp-1 text-center font-bold'>{title ?? ''}</span>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
|
||||
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href: string;
|
||||
}
|
||||
|
||||
const Link: React.FC<LinkProps> = ({ href, children, ...props }) => {
|
||||
const handleClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (isTauriAppPlatform()) {
|
||||
e.preventDefault();
|
||||
await openUrl(href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer' onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default Link;
|
||||
@@ -4,6 +4,7 @@ import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface MenuItemProps {
|
||||
label: string;
|
||||
buttonClass?: string;
|
||||
labelClass?: string;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
@@ -15,6 +16,7 @@ interface MenuItemProps {
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({
|
||||
label,
|
||||
buttonClass,
|
||||
labelClass,
|
||||
shortcut,
|
||||
disabled,
|
||||
@@ -29,6 +31,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
className={clsx(
|
||||
'hover:bg-base-300 text-base-content flex h-10 w-full items-center justify-between rounded-md p-2',
|
||||
disabled && 'btn-disabled text-gray-400',
|
||||
buttonClass,
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface SliderProps {
|
||||
min?: number;
|
||||
@@ -34,6 +34,8 @@ const Slider: React.FC<SliderProps> = ({
|
||||
onChange,
|
||||
}) => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [isRtl, setIsRtl] = useState(false);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent) => {
|
||||
const newValue = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
@@ -43,6 +45,17 @@ const Slider: React.FC<SliderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let node: HTMLElement | null = sliderRef.current;
|
||||
while (node) {
|
||||
if (node.getAttribute('dir') === 'rtl') {
|
||||
setIsRtl(true);
|
||||
break;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
@@ -50,22 +63,33 @@ const Slider: React.FC<SliderProps> = ({
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className={`slider bg-base-200 mx-auto w-full max-w-md rounded-xl ${className}`}>
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`slider bg-base-200 mx-auto w-full max-w-md rounded-xl ${className}`}
|
||||
dir={isRtl ? 'rtl' : undefined}
|
||||
>
|
||||
<div className='relative' style={{ height: `${heightPx}px` }}>
|
||||
{/* Background track */}
|
||||
<div className='bg-base-300/40 absolute h-full w-full rounded-full'></div>
|
||||
{/* Filled portion */}
|
||||
<div
|
||||
className='bg-base-300 absolute h-full rounded-full'
|
||||
style={{ width: percentage > 0 ? `calc(${percentage}% + ${heightPx / 2}px)` : '0px' }}
|
||||
style={{
|
||||
width: percentage > 0 ? `calc(${percentage}% + ${heightPx / 2}px)` : '0px',
|
||||
[isRtl ? 'right' : 'left']: 0,
|
||||
}}
|
||||
></div>
|
||||
{/* Min/Max labels */}
|
||||
<div className='absolute inset-0 flex items-center justify-between px-4 text-sm'>
|
||||
<span className={`ml-2 ${minClassName}`}>{minLabel}</span>
|
||||
<span className={`mr-2 ${maxClassName}`}>{maxLabel}</span>
|
||||
</div>
|
||||
{/* Thumb bubble */}
|
||||
<div
|
||||
className='pointer-events-none absolute top-0 z-10'
|
||||
style={{
|
||||
left: `max(${heightPx / 2}px, calc(${percentage}%))`,
|
||||
transform: 'translateX(calc(-50%))',
|
||||
[isRtl ? 'right' : 'left']: `max(${heightPx / 2}px, calc(${percentage}%))`,
|
||||
transform: isRtl ? 'translateX(calc(50%))' : 'translateX(calc(-50%))',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -17,10 +17,10 @@ export const Toast = () => {
|
||||
error: 'toast-error toast-top toast-end',
|
||||
};
|
||||
const alertClassMap = {
|
||||
info: 'alert-primary',
|
||||
success: 'alert-success',
|
||||
warning: 'alert-warning',
|
||||
error: 'alert-error',
|
||||
info: 'alert-primary border-base-300',
|
||||
success: 'alert-success border-0',
|
||||
warning: 'alert-warning border-0',
|
||||
error: 'alert-error border-0',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,14 +58,17 @@ export const Toast = () => {
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'alert flex max-w-80 items-center justify-center border-0',
|
||||
'alert flex items-center justify-center',
|
||||
alertClassMap[toastType.current],
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'max-h-[50vh] min-w-32 max-w-80',
|
||||
'overflow-y-auto whitespace-normal break-words text-center',
|
||||
'max-h-[50vh] min-w-32',
|
||||
'overflow-y-auto text-center',
|
||||
toastType.current === 'info'
|
||||
? 'max-w-[80vw]'
|
||||
: 'max-w-80 whitespace-normal break-words',
|
||||
messageClass.current,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import clsx from 'clsx';
|
||||
import semver from 'semver';
|
||||
import Image from 'next/image';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type as osType, arch as osArch } from '@tauri-apps/plugin-os';
|
||||
import { check, Update } from '@tauri-apps/plugin-updater';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { tauriDownload } from '@/utils/transfer';
|
||||
import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants';
|
||||
import packageJson from '../../package.json';
|
||||
import Dialog from '@/components/Dialog';
|
||||
import { installPackage } from '@/utils/bridge';
|
||||
|
||||
interface ReleaseNotes {
|
||||
releases: Record<
|
||||
string,
|
||||
{
|
||||
date: string;
|
||||
notes: string[];
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
interface Changelog {
|
||||
version: string;
|
||||
date: string;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
type DownloadEvent =
|
||||
| {
|
||||
event: 'Started';
|
||||
data: {
|
||||
contentLength?: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
event: 'Progress';
|
||||
data: {
|
||||
chunkLength: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
event: 'Finished';
|
||||
};
|
||||
|
||||
interface GenericUpdate {
|
||||
currentVersion: string;
|
||||
version: string;
|
||||
date?: string;
|
||||
body?: string;
|
||||
downloadAndInstall?(onEvent?: (progress: DownloadEvent) => void): Promise<void>;
|
||||
}
|
||||
|
||||
export const UpdaterContent = ({ version }: { version?: string }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const searchParams = useSearchParams();
|
||||
const currentVersion = packageJson.version;
|
||||
const resolvedVersion = version ?? searchParams?.get('version') ?? '';
|
||||
const [update, setUpdate] = useState<GenericUpdate | Update | null>(null);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [newVersion, setNewVersion] = useState(resolvedVersion);
|
||||
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
const [contentLength, setContentLength] = useState<number | null>(null);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloaded, setDownloaded] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDesktopUpdate = async () => {
|
||||
const update = await check();
|
||||
if (update) {
|
||||
setUpdate(update);
|
||||
}
|
||||
};
|
||||
const checkAndroidUpdate = async () => {
|
||||
const response = await fetch(READEST_UPDATER_FILE);
|
||||
const data = await response.json();
|
||||
if (semver.gt(data.version, packageJson.version)) {
|
||||
const OS_ARCH = osArch();
|
||||
const platformKey = OS_ARCH === 'aarch64' ? 'android-arm64' : 'android-universal';
|
||||
const arch = OS_ARCH === 'aarch64' ? 'arm64' : 'universal';
|
||||
const downloadUrl = data.platforms[platformKey]?.url as string;
|
||||
const cachePrefix = appService?.fs.getPrefix('Cache');
|
||||
const apkFilePath = `${cachePrefix}/Readest_${data.version}_${arch}.apk`;
|
||||
setUpdate({
|
||||
currentVersion: packageJson.version,
|
||||
version: data.version,
|
||||
date: data.date,
|
||||
body: data.notes,
|
||||
downloadAndInstall: async (onEvent) => {
|
||||
await new Promise<void>(async (resolve, reject) => {
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
await tauriDownload(downloadUrl, apkFilePath, (progress) => {
|
||||
if (!onEvent) return;
|
||||
if (!total && progress.total) {
|
||||
total = progress.total;
|
||||
onEvent({
|
||||
event: 'Started',
|
||||
data: { contentLength: total },
|
||||
});
|
||||
} else if (downloaded > 0 && progress.progress === progress.total) {
|
||||
console.log('APK downloaded to', apkFilePath);
|
||||
onEvent?.({ event: 'Finished' });
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
onEvent({
|
||||
event: 'Progress',
|
||||
data: { chunkLength: progress.progress - downloaded },
|
||||
});
|
||||
downloaded = progress.progress;
|
||||
}).catch((error) => {
|
||||
console.error('Download failed:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
const res = await installPackage({
|
||||
path: apkFilePath,
|
||||
});
|
||||
if (res.success) {
|
||||
console.log('APK installed successfully');
|
||||
} else {
|
||||
console.error('Failed to install APK:', res.error);
|
||||
}
|
||||
},
|
||||
} as GenericUpdate);
|
||||
}
|
||||
};
|
||||
const checkForUpdates = async () => {
|
||||
const OS_TYPE = osType();
|
||||
if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
|
||||
checkDesktopUpdate();
|
||||
} else if (OS_TYPE === 'android') {
|
||||
checkAndroidUpdate();
|
||||
}
|
||||
};
|
||||
checkForUpdates();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChangelogs = async (
|
||||
fromVersion: string,
|
||||
toVersion: string,
|
||||
): Promise<Changelog[]> => {
|
||||
try {
|
||||
const res = await fetch(READEST_CHANGELOG_FILE);
|
||||
const data: ReleaseNotes = await res.json();
|
||||
const releases = data.releases;
|
||||
|
||||
const entries = Object.entries(releases)
|
||||
.filter(([ver]) => semver.gt(ver, fromVersion) && semver.lte(ver, toVersion))
|
||||
.sort(([a], [b]) => semver.rcompare(a, b))
|
||||
.map(([version, info]) => ({
|
||||
version,
|
||||
date: new Date(info.date).toDateString(),
|
||||
notes: info.notes,
|
||||
}));
|
||||
|
||||
return entries;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch changelog:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const parseNumberedList = (input: string): string[] => {
|
||||
return input
|
||||
.split(/(?:^|\s)\d+\.\s/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
};
|
||||
const updateChangelogs = async (update: GenericUpdate) => {
|
||||
setNewVersion(update.version);
|
||||
const changelogs = await fetchChangelogs(currentVersion, update.version);
|
||||
if (changelogs.length > 0) {
|
||||
setChangelogs(changelogs);
|
||||
} else {
|
||||
setChangelogs([
|
||||
{
|
||||
version: update.version,
|
||||
date: new Date(update.date!).toDateString(),
|
||||
notes: parseNumberedList(update.body ?? ''),
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
if (update) {
|
||||
updateChangelogs(update);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [update]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleDownloadInstall = async () => {
|
||||
if (!update) {
|
||||
return;
|
||||
}
|
||||
let downloaded = 0;
|
||||
let contentLength = 0;
|
||||
let lastLogged = 0;
|
||||
setProgress(0);
|
||||
setIsDownloading(true);
|
||||
await update.downloadAndInstall?.((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
contentLength = event.data.contentLength!;
|
||||
setContentLength(contentLength);
|
||||
break;
|
||||
case 'Progress':
|
||||
downloaded += event.data.chunkLength;
|
||||
setDownloaded(downloaded);
|
||||
const percent = Math.floor((downloaded / contentLength) * 100);
|
||||
setProgress(percent);
|
||||
if (downloaded - lastLogged >= 1 * 1024 * 1024) {
|
||||
console.log(`downloaded ${downloaded} bytes from ${contentLength}`);
|
||||
lastLogged = downloaded;
|
||||
}
|
||||
break;
|
||||
case 'Finished':
|
||||
console.log('download finished');
|
||||
setProgress(100);
|
||||
break;
|
||||
}
|
||||
});
|
||||
console.log('package installed');
|
||||
if (!appService?.isAndroidApp && process.env.NODE_ENV === 'production') {
|
||||
await relaunch();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='bg-base-100 flex min-h-screen justify-center'>
|
||||
<div className='flex w-full max-w-2xl flex-col gap-4'>
|
||||
<div className='flex flex-col justify-center gap-4 sm:flex-row sm:items-start'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<Image src='/icon.png' alt='Logo' className='h-20 w-20' width={64} height={64} />
|
||||
</div>
|
||||
|
||||
<div className='text-base-content flex-grow text-sm'>
|
||||
<h2 className='mb-4 text-center font-bold sm:text-start'>
|
||||
{_('A new version of Readest is Available!')}
|
||||
</h2>
|
||||
<p className='mb-2'>
|
||||
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
|
||||
newVersion,
|
||||
currentVersion,
|
||||
})}
|
||||
</p>
|
||||
<p className='mb-2'>{_('Download and install now?')}</p>
|
||||
|
||||
<div className='flex w-full flex-row items-center justify-end gap-4'>
|
||||
{progress !== null && (
|
||||
<div className='flex flex-grow flex-col'>
|
||||
<progress
|
||||
className='progress my-1 h-4 w-full'
|
||||
value={progress}
|
||||
max='100'
|
||||
></progress>
|
||||
<p className='text-base-content/75 flex items-center justify-center text-sm'>
|
||||
{progress < 100
|
||||
? _('Downloading {{downloaded}} of {{contentLength}}', {
|
||||
downloaded: downloaded
|
||||
? `${Math.floor(downloaded / 1024 / 1024)} MB`
|
||||
: '0 MB',
|
||||
contentLength: contentLength
|
||||
? `${Math.floor(contentLength / 1024 / 1024)} MB`
|
||||
: '0 MB',
|
||||
})
|
||||
: _('Download finished')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='card-actions'>
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-warning text-base-100 px-6 font-bold',
|
||||
(!update || isDownloading) && 'btn-disabled',
|
||||
)}
|
||||
onClick={handleDownloadInstall}
|
||||
>
|
||||
{_('DOWNLOAD & INSTALL')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-base-content text-sm'>
|
||||
<h3 className='mb-2 font-bold'>{_('Changelog:')}</h3>
|
||||
<div className='bg-base-300 mb-4 rounded-lg px-4 pb-2 pt-4'>
|
||||
{changelogs.length > 0 ? (
|
||||
changelogs.map((entry: Changelog) => (
|
||||
<div key={entry.version} className='mb-4'>
|
||||
<h4 className='mb-2 font-bold'>
|
||||
{entry.version} ({entry.date})
|
||||
</h4>
|
||||
<ul className='list-disc space-y-1 ps-6 text-sm'>
|
||||
{entry.notes.map((note: string, i: number) => (
|
||||
<li key={i}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='flex h-56 w-full flex-col gap-4'>
|
||||
<div className='skeleton h-4 w-28'></div>
|
||||
<div className='skeleton h-4 w-full'></div>
|
||||
<div className='skeleton h-4 w-full'></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const setUpdaterWindowVisible = (visible: boolean, newVersion?: string) => {
|
||||
if (newVersion) {
|
||||
localStorage.setItem('newVersion', newVersion);
|
||||
window.dispatchEvent(new CustomEvent('new-version-detected'));
|
||||
}
|
||||
const dialog = document.getElementById('updater_window');
|
||||
if (visible) {
|
||||
(dialog as HTMLDialogElement)?.showModal();
|
||||
} else {
|
||||
(dialog as HTMLDialogElement)?.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const UpdaterWindow = () => {
|
||||
const _ = useTranslation();
|
||||
const [newVersion, setNewVersion] = useState(localStorage.getItem('newVersion') || '');
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setNewVersion(localStorage.getItem('newVersion') || '');
|
||||
};
|
||||
window.addEventListener('new-version-detected', handler);
|
||||
return () => window.removeEventListener('new-version-detected', handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id='updater_window'
|
||||
isOpen={false}
|
||||
title={_('Software Update')}
|
||||
onClose={() => setUpdaterWindowVisible(false)}
|
||||
boxClassName='sm:!w-[80%] sm:h-auto'
|
||||
>
|
||||
<UpdaterContent version={newVersion ?? undefined} />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { isWebAppPlatform, hasCli } from '@/services/environment';
|
||||
import { getCurrent } from '@tauri-apps/plugin-deep-link';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
OPEN_WITH_FILES?: string[];
|
||||
OPEN_WITH_FILES?: string[] | null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +33,35 @@ const parseCLIOpenWithFiles = async () => {
|
||||
return files;
|
||||
};
|
||||
|
||||
const parseIntentOpenWithFiles = async () => {
|
||||
const urls = await getCurrent();
|
||||
if (urls && urls.length > 0) {
|
||||
console.log('Intent Open with URL:', urls);
|
||||
return urls
|
||||
.map((url) => {
|
||||
if (url.startsWith('file://')) {
|
||||
return decodeURI(url.replace('file://', ''));
|
||||
} else if (url.startsWith('content://')) {
|
||||
return url;
|
||||
} else {
|
||||
console.info('Skip non-file URL:', url);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((url) => url !== null) as string[];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseOpenWithFiles = async () => {
|
||||
if (isWebAppPlatform()) return [];
|
||||
|
||||
let files = parseWindowOpenWithFiles();
|
||||
if (!files && hasCli()) {
|
||||
if ((!files || files.length === 0) && hasCli()) {
|
||||
files = await parseCLIOpenWithFiles();
|
||||
}
|
||||
if (!files || files.length === 0) {
|
||||
files = await parseIntentOpenWithFiles();
|
||||
}
|
||||
return files;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user