forked from akai/readest
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0c249bce2 | |||
| e8a96fba95 | |||
| 92b01c40a0 | |||
| 6003eeadba | |||
| 856ed4d3b3 | |||
| 267fe58a8c | |||
| 1b00d8c41c | |||
| cb126613b1 | |||
| 515bfee2a1 | |||
| 584f3511f8 | |||
| 3ea54e6725 | |||
| 48212d892f | |||
| 977c61b914 | |||
| 66fceb2313 | |||
| 76feefff00 | |||
| 1be43ff704 | |||
| b6d4a547e5 | |||
| 5ee860f1e8 | |||
| 0b01132d88 | |||
| b98c487919 | |||
| b43b08d423 | |||
| 3ddb6e6422 | |||
| ea9146be5b | |||
| 78cb1f45e4 | |||
| e472d1cf1e | |||
| 75dc04d598 | |||
| fc4c033cf3 | |||
| dbee054838 | |||
| 45b805dcd4 | |||
| a8ac54b51c | |||
| 86f705eae9 | |||
| d390209dab | |||
| 149f94be4c | |||
| ef1a1eab98 | |||
| dc7c6e14bc | |||
| 8c31dc85f9 | |||
| 7b8d1ddc35 | |||
| 8c12399732 | |||
| 93a4b1a448 | |||
| 04b0752b22 | |||
| 5137c08204 | |||
| ec0b44fb9f | |||
| 0bf83dfe67 | |||
| b151b70cb9 | |||
| f2640359a0 | |||
| 079aeaedb0 | |||
| bfacadb964 | |||
| 2e98ed44ee | |||
| 952d2a3553 | |||
| 225aa0ca59 | |||
| 37f718b84b | |||
| db8f53a6e1 | |||
| ce345ad67f | |||
| 8e53e625a4 |
@@ -11,7 +11,7 @@ jobs:
|
||||
env:
|
||||
RUSTFLAGS: '-C target-cpu=skylake'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
submodules: 'true'
|
||||
- name: Install minimal stable with clippy and rustfmt
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
- platform: 'web'
|
||||
- platform: 'tauri'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
@@ -57,8 +57,10 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/readest-app/.next/cache
|
||||
key: nextjs-${{ runner.os }}-${{ hashFiles('apps/readest-app/package.json', 'pnpm-lock.yaml') }}
|
||||
restore-keys: nextjs-${{ runner.os }}-
|
||||
key: nextjs-${{ matrix.config.platform }}-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
nextjs-${{ matrix.config.platform }}-${{ github.sha }}-
|
||||
nextjs-${{ matrix.config.platform }}-
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
release_version: ${{ steps.get-release-notes.outputs.release_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
- name: get version
|
||||
@@ -48,6 +48,39 @@ jobs:
|
||||
core.setOutput('release_version', version);
|
||||
core.setOutput('release_note', releaseNote);
|
||||
|
||||
update-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-release
|
||||
|
||||
steps:
|
||||
- name: update release
|
||||
id: update-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
release_id: ${{ needs.get-release.outputs.release_id }}
|
||||
release_tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
release_note: ${{ needs.get-release.outputs.release_note }}
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: process.env.release_tag,
|
||||
})
|
||||
const notes = process.env.release_note.split(/(?:\d\.\s)/).filter(Boolean);
|
||||
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
|
||||
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
body: body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
build-tauri:
|
||||
needs: get-release
|
||||
permissions:
|
||||
@@ -91,7 +124,7 @@ jobs:
|
||||
runs-on: ${{ matrix.config.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: initialize git submodules
|
||||
run: git submodule update --init --recursive
|
||||
@@ -109,7 +142,7 @@ jobs:
|
||||
|
||||
- name: setup Java (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
@@ -288,41 +321,8 @@ jobs:
|
||||
echo "Uploading $bin_file to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
|
||||
|
||||
update-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-release, build-tauri]
|
||||
|
||||
steps:
|
||||
- name: update release
|
||||
id: update-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
release_id: ${{ needs.get-release.outputs.release_id }}
|
||||
release_tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
release_note: ${{ needs.get-release.outputs.release_note }}
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: process.env.release_tag,
|
||||
})
|
||||
const notes = process.env.release_note.split(/(?:\d\.\s)/).filter(Boolean);
|
||||
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
|
||||
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
body: body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
upload-to-r2:
|
||||
needs: [get-release, update-release]
|
||||
needs: [get-release, build-tauri]
|
||||
uses: ./.github/workflows/upload-to-r2.yml
|
||||
with:
|
||||
tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@v25
|
||||
|
||||
@@ -40,3 +40,6 @@ next-env.d.ts
|
||||
target
|
||||
|
||||
fastlane/report.xml
|
||||
|
||||
*.koplugin.zip
|
||||
|
||||
|
||||
Generated
+374
-376
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@ rust-version = "1.77.2"
|
||||
tauri = { path = "packages/tauri/crates/tauri" }
|
||||
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
|
||||
tauri-build = { path = "packages/tauri/crates/tauri-build" }
|
||||
tauri-plugin-os = { path = "packages/tauri-plugins/plugins/os" }
|
||||
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
|
||||
tauri-plugin-dialog = { path = "packages/tauri-plugins/plugins/dialog" }
|
||||
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.71",
|
||||
"version": "0.9.76",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -47,19 +47,19 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@opennextjs/cloudflare": "^1.6.1",
|
||||
"@opennextjs/cloudflare": "^1.6.5",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.4.0",
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
"@supabase/supabase-js": "^2.50.2",
|
||||
"@tauri-apps/api": "2.6.0",
|
||||
"@supabase/supabase-js": "^2.55.0",
|
||||
"@tauri-apps/api": "2.8.0",
|
||||
"@tauri-apps/plugin-cli": "^2.4.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.2",
|
||||
"@tauri-apps/plugin-fs": "^2.4.1",
|
||||
"@tauri-apps/plugin-haptics": "^2.3.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||
"@tauri-apps/plugin-os": "^2.3.0",
|
||||
@@ -84,7 +84,7 @@
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"next": "15.3.3",
|
||||
"next": "15.5.0",
|
||||
"overlayscrollbars": "^2.11.4",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"posthog-js": "^1.246.0",
|
||||
@@ -105,7 +105,7 @@
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^15.4.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tauri-apps/cli": "2.7.0",
|
||||
"@tauri-apps/cli": "2.8.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
@@ -137,6 +137,6 @@
|
||||
"typescript": "^5.7.2",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.26.0"
|
||||
"wrangler": "^4.31.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,6 @@
|
||||
"Google Books": "كتب جوجل",
|
||||
"Open Library": "المكتبة المفتوحة",
|
||||
"Fiction, Science, History": "خيال، علوم، تاريخ",
|
||||
"Select Cover Image": "اختر صورة الغلاف",
|
||||
"Open Book in New Window": "فتح الكتاب في نافذة جديدة",
|
||||
"Voices for {{lang}}": "الأصوات لـ {{lang}}",
|
||||
"Yandex Translate": "ترجمة Yandex",
|
||||
@@ -500,7 +499,6 @@
|
||||
"File Content (recommended)": "محتوى الملف (موصى به)",
|
||||
"File Name": "اسم الملف",
|
||||
"Device Name": "اسم الجهاز",
|
||||
"Sync Tolerance": "تحمل المزامنة",
|
||||
"Connect to your KOReader Sync server.": "الاتصال بخادم مزامنة KOReader الخاص بك.",
|
||||
"Server URL": "عنوان URL للخادم",
|
||||
"Username": "اسم المستخدم",
|
||||
@@ -519,6 +517,20 @@
|
||||
"Approximately {{percentage}}%": "تقريبًا {{percentage}}%",
|
||||
"Failed to connect": "فشل في الاتصال",
|
||||
"Sync Server Connected": "تم الاتصال بخادم المزامنة",
|
||||
"Precision: {{precision}} digits after the decimal": "الدقة: {{precision}} أرقام بعد الفاصلة",
|
||||
"Sync as {{userDisplayName}}": "المزامنة كـ {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "المزامنة كـ {{userDisplayName}}",
|
||||
"Custom Fonts": "الخطوط المخصصة",
|
||||
"Cancel Delete": "إلغاء الحذف",
|
||||
"Import Font": "استيراد خط",
|
||||
"Delete Font": "حذف الخط",
|
||||
"Tips": "نصائح",
|
||||
"Custom fonts can be selected from the Font Face menu": "يمكن اختيار الخطوط المخصصة من قائمة نوع الخط",
|
||||
"Manage Custom Fonts": "إدارة الخطوط المخصصة",
|
||||
"Select Files": "حدد الملفات",
|
||||
"Select Image": "حدد صورة",
|
||||
"Select Video": "حدد فيديو",
|
||||
"Select Audio": "حدد صوت",
|
||||
"Select Fonts": "حدد الخطوط",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "تنسيقات الخط المدعومة: .ttf، .otf، .woff، .woff2",
|
||||
"Push Progress": "تقدم الدفع",
|
||||
"Pull Progress": "تقدم السحب"
|
||||
}
|
||||
|
||||
@@ -438,7 +438,6 @@
|
||||
"Google Books": "Google དཔེ་མཛོད།",
|
||||
"Open Library": "སྒོ་འབྱེད་དཔེ་ཁང་།",
|
||||
"Fiction, Science, History": "སྒྲུང་གཏམ། ཚན་རིག ལོ་རྒྱུས།",
|
||||
"Select Cover Image": "སྟོང་ངོས་ཀྱི་པར་རིས་འདེམས་པ།",
|
||||
"Open Book in New Window": "སྒེའུ་ཁུང་གསར་པའི་ནང་དཔེ་དེབ་ཁ་ཕྱེ་བ།",
|
||||
"Voices for {{lang}}": "{{lang}} གྱི་སྒྲ་སྐད།",
|
||||
"Yandex Translate": "Yandex སྒྱུར་བཅོས།",
|
||||
@@ -482,8 +481,6 @@
|
||||
"File Content (recommended)": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
|
||||
"File Name": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
|
||||
"Device Name": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
|
||||
"Sync Tolerance": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
|
||||
"Precision: {{precision}} digits after the decimal": "ཡིག་སྣེ་འབྱུང་ཁུངས།",
|
||||
"Connect to your KOReader Sync server.": "ཁྱེད་ཀྱིས KOReader སྤྲི་དོན་སང་བཞིན་འདེམས་པ།",
|
||||
"Server URL": "སང་བཞིན་འདེམས་པའི URL",
|
||||
"Username": "ཁྱེད་ཀྱིས་འབྱུང་ཁུངས་འདེམས་པ།",
|
||||
@@ -498,7 +495,22 @@
|
||||
"Remote Progress": "བརྒྱབ་འབྱུང་ཁུངས།",
|
||||
"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": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
|
||||
"Page {{page}} of {{total}} ({{percentage}}%)": "ཤོག་ {{page}} དང་ {{total}} ({{percentage}}%)",
|
||||
"Current position": "ད་ལྟ་བཞིན་འདེམས་པ།",
|
||||
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "ད་ལྟ་བཞིན་འདེམས་པ།",
|
||||
"Approximately {{percentage}}%": "ད་ལྟ་བཞིན་འདེམས་པ།"
|
||||
"Current position": "ད་ལྟའི་གནས་ས།",
|
||||
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "ཕྲན་བུ་ཤོག་ངོས་ {{page}} / {{total}} ({{percentage}}%)",
|
||||
"Approximately {{percentage}}%": "ཕྲན་བུ་ {{percentage}}%",
|
||||
"Custom Fonts": "རང་བཞིན་ཡིག་གཟུགས།",
|
||||
"Cancel Delete": "སུབ་པ་འདོར་བ།",
|
||||
"Import Font": "ཡིག་གཟུགས་འདྲེན་འཇུག",
|
||||
"Delete Font": "ཡིག་གཟུགས་སུབ་པ།",
|
||||
"Tips": "གསལ་འདེབས།",
|
||||
"Custom fonts can be selected from the Font Face menu": "རང་བཞིན་ཡིག་གཟུགས་ནི་ཡིག་གཟུགས་ངོ་བོའི་ཟ་འབྲིང་ནས་འདེམས་ཆོག",
|
||||
"Manage Custom Fonts": "རང་བཞིན་ཡིག་གཟུགས་འཛིན་སྐྱོང་།",
|
||||
"Select Files": "ཡིག་ཆ་འདེམས་པ།",
|
||||
"Select Image": "རི་མོ་འདེམས་པ།",
|
||||
"Select Video": "བརྙན་ཟློས་འདེམས་པ།",
|
||||
"Select Audio": "སྒྲ་ཟློས་འདེམས་པ།",
|
||||
"Select Fonts": "ཡིག་གཟུགས་འདེམས་པ།",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "ཡིག་གཟུགས་འདེམས་པ་བྱས་ནས་བསྐྱར་བརྗེ་བ། ཡིག་གཟུགས་ཀྱི་སྒྲ་སྐད། .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "འདེགས་པའི་འཕེལ་རིམ།",
|
||||
"Pull Progress": "འདྲུད་པའི་འཕེལ་རིམ།"
|
||||
}
|
||||
|
||||
@@ -443,7 +443,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Belletristik, Wissenschaft, Geschichte",
|
||||
"Select Cover Image": "Coverbild auswählen",
|
||||
"Open Book in New Window": "Buch in neuem Fenster öffnen",
|
||||
"Voices for {{lang}}": "Stimmen für {{lang}}",
|
||||
"Yandex Translate": "Yandex Übersetzer",
|
||||
@@ -484,7 +483,6 @@
|
||||
"File Content (recommended)": "Dateiinhalte (empfohlen)",
|
||||
"File Name": "Dateiname",
|
||||
"Device Name": "Gerätename",
|
||||
"Sync Tolerance": "Sync-Toleranz",
|
||||
"Connect to your KOReader Sync server.": "Mit Ihrem KOReader Sync-Server verbinden.",
|
||||
"Server URL": "Server-URL",
|
||||
"Username": "Benutzername",
|
||||
@@ -503,6 +501,20 @@
|
||||
"Approximately {{percentage}}%": "Ungefähr {{percentage}}%",
|
||||
"Failed to connect": "Verbindung fehlgeschlagen",
|
||||
"Sync Server Connected": "Sync-Server verbunden",
|
||||
"Precision: {{precision}} digits after the decimal": "Präzision: {{precision}} Nachkommastellen",
|
||||
"Sync as {{userDisplayName}}": "Sync als {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Sync als {{userDisplayName}}",
|
||||
"Custom Fonts": "Benutzerdefinierte Schriftarten",
|
||||
"Cancel Delete": "Löschen abbrechen",
|
||||
"Import Font": "Schriftart importieren",
|
||||
"Delete Font": "Schriftart löschen",
|
||||
"Tips": "Tipps",
|
||||
"Custom fonts can be selected from the Font Face menu": "Benutzerdefinierte Schriftarten können aus dem Schriftart-Menü ausgewählt werden",
|
||||
"Manage Custom Fonts": "Benutzerdefinierte Schriftarten verwalten",
|
||||
"Select Files": "Dateien auswählen",
|
||||
"Select Image": "Bild auswählen",
|
||||
"Select Video": "Video auswählen",
|
||||
"Select Audio": "Audio auswählen",
|
||||
"Select Fonts": "Schriftarten auswählen",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Unterstützte Schriftartenformate: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "Fortschritt senden",
|
||||
"Pull Progress": "Fortschritt empfangen"
|
||||
}
|
||||
|
||||
@@ -443,7 +443,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Μυθοπλασία, Επιστήμη, Ιστορία",
|
||||
"Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου",
|
||||
"Open Book in New Window": "Άνοιγμα βιβλίου σε νέο παράθυρο",
|
||||
"Voices for {{lang}}": "Φωνές για {{lang}}",
|
||||
"Yandex Translate": "Μετάφραση Yandex",
|
||||
@@ -484,7 +483,6 @@
|
||||
"File Content (recommended)": "Περιεχόμενο αρχείου (συνιστάται)",
|
||||
"File Name": "Όνομα αρχείου",
|
||||
"Device Name": "Όνομα συσκευής",
|
||||
"Sync Tolerance": "Ανοχή συγχρονισμού",
|
||||
"Connect to your KOReader Sync server.": "Συνδεθείτε στον διακομιστή συγχρονισμού KOReader.",
|
||||
"Server URL": "Διεύθυνση URL διακομιστή",
|
||||
"Username": "Όνομα χρήστη",
|
||||
@@ -503,6 +501,20 @@
|
||||
"Approximately {{percentage}}%": "Περίπου {{percentage}}%",
|
||||
"Failed to connect": "Αποτυχία σύνδεσης",
|
||||
"Sync Server Connected": "Ο διακομιστής συγχρονισμού είναι συνδεδεμένος",
|
||||
"Precision: {{precision}} digits after the decimal": "Ακρίβεια: {{precision}} ψηφία μετά την υποδιαστολή",
|
||||
"Sync as {{userDisplayName}}": "Συγχρονισμός ως {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Συγχρονισμός ως {{userDisplayName}}",
|
||||
"Custom Fonts": "Προσαρμοσμένες Γραμματοσειρές",
|
||||
"Cancel Delete": "Ακύρωση Διαγραφής",
|
||||
"Import Font": "Εισαγωγή Γραμματοσειράς",
|
||||
"Delete Font": "Διαγραφή Γραμματοσειράς",
|
||||
"Tips": "Συμβουλές",
|
||||
"Custom fonts can be selected from the Font Face menu": "Οι προσαρμοσμένες γραμματοσειρές μπορούν να επιλεγούν από το μενού Γραμματοσειράς",
|
||||
"Manage Custom Fonts": "Διαχείριση Προσαρμοσμένων Γραμματοσειρών",
|
||||
"Select Files": "Επιλογή Αρχείων",
|
||||
"Select Image": "Επιλογή Εικόνας",
|
||||
"Select Video": "Επιλογή Βίντεο",
|
||||
"Select Audio": "Επιλογή Ήχου",
|
||||
"Select Fonts": "Επιλογή Γραμματοσειρών",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Υποστηριζόμενες μορφές γραμματοσειρών: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "Αποστολή προόδου",
|
||||
"Pull Progress": "Λήψη προόδου"
|
||||
}
|
||||
|
||||
@@ -88,7 +88,6 @@
|
||||
"File Content (recommended)": "Contenido del archivo (recomendado)",
|
||||
"File Name": "Nombre del archivo",
|
||||
"Device Name": "Nombre del dispositivo",
|
||||
"Sync Tolerance": "Tolerancia de sincronización",
|
||||
"Reading Progress Synced": "Progreso de lectura sincronizado",
|
||||
"Sync Conflict": "Conflicto de sincronización",
|
||||
"Sync reading progress from \"{{deviceName}}\"?": "¿Quieres sincronizar el progreso de lectura desde el dispositivo \"{{deviceName}}\"?",
|
||||
@@ -476,7 +475,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Ficción, Ciencia, Historia",
|
||||
"Select Cover Image": "Seleccionar imagen de portada",
|
||||
"Open Book in New Window": "Abre el libro en una nueva ventana",
|
||||
"Voices for {{lang}}": "Voces para {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
@@ -507,6 +505,20 @@
|
||||
"Remove from Device Only": "Eliminar solo del dispositivo",
|
||||
"Failed to connect": "Error al conectar",
|
||||
"Sync Server Connected": "Servidor de sincronización conectado",
|
||||
"Precision: {{precision}} digits after the decimal": "Precisión: {{precision}} dígitos después del decimal",
|
||||
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}",
|
||||
"Custom Fonts": "Fuentes Personalizadas",
|
||||
"Cancel Delete": "Cancelar Eliminación",
|
||||
"Import Font": "Importar Fuente",
|
||||
"Delete Font": "Eliminar Fuente",
|
||||
"Tips": "Consejos",
|
||||
"Custom fonts can be selected from the Font Face menu": "Las fuentes personalizadas se pueden seleccionar desde el menú de Tipografía",
|
||||
"Manage Custom Fonts": "Administrar Fuentes Personalizadas",
|
||||
"Select Files": "Seleccionar Archivos",
|
||||
"Select Image": "Seleccionar Imagen",
|
||||
"Select Video": "Seleccionar Video",
|
||||
"Select Audio": "Seleccionar Audio",
|
||||
"Select Fonts": "Seleccionar Fuentes",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formatos de fuente compatibles: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "Empujar progreso",
|
||||
"Pull Progress": "Tirar progreso"
|
||||
}
|
||||
|
||||
@@ -447,7 +447,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Fiction, Science, Histoire",
|
||||
"Select Cover Image": "Sélectionner l'image de couverture",
|
||||
"Open Book in New Window": "Ouvrir dans une nouvelle fenêtre",
|
||||
"Voices for {{lang}}": "Voix pour {{lang}}",
|
||||
"Yandex Translate": "Yandex Traduction",
|
||||
@@ -488,7 +487,6 @@
|
||||
"File Content (recommended)": "Contenu du fichier (recommandé)",
|
||||
"File Name": "Nom du fichier",
|
||||
"Device Name": "Nom de l'appareil",
|
||||
"Sync Tolerance": "Tolérance de synchronisation",
|
||||
"Connect to your KOReader Sync server.": "Connectez-vous à votre serveur de synchronisation KOReader.",
|
||||
"Server URL": "URL du serveur",
|
||||
"Username": "Nom d'utilisateur",
|
||||
@@ -507,6 +505,20 @@
|
||||
"Approximately {{percentage}}%": "Environ {{percentage}}%",
|
||||
"Failed to connect": "Échec de la connexion",
|
||||
"Sync Server Connected": "Serveur de synchronisation connecté",
|
||||
"Precision: {{precision}} digits after the decimal": "Précision : {{precision}} chiffres après la virgule",
|
||||
"Sync as {{userDisplayName}}": "Synchroniser en tant que {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Synchroniser en tant que {{userDisplayName}}",
|
||||
"Custom Fonts": "Polices Personnalisées",
|
||||
"Cancel Delete": "Annuler la Suppression",
|
||||
"Import Font": "Importer une Police",
|
||||
"Delete Font": "Supprimer la Police",
|
||||
"Tips": "Conseils",
|
||||
"Custom fonts can be selected from the Font Face menu": "Les polices personnalisées peuvent être sélectionnées depuis le menu Police",
|
||||
"Manage Custom Fonts": "Gérer les Polices Personnalisées",
|
||||
"Select Files": "Sélectionner des Fichiers",
|
||||
"Select Image": "Sélectionner une Image",
|
||||
"Select Video": "Sélectionner une Vidéo",
|
||||
"Select Audio": "Sélectionner un Audio",
|
||||
"Select Fonts": "Sélectionner des Polices",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formats de police pris en charge : .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "Pousser la Progression",
|
||||
"Pull Progress": "Tirer la Progression"
|
||||
}
|
||||
|
||||
@@ -443,7 +443,6 @@
|
||||
"Google Books": "गूगल बुक्स",
|
||||
"Open Library": "ओपन लाइब्रेरी",
|
||||
"Fiction, Science, History": "काल्पनिक, विज्ञान, इतिहास",
|
||||
"Select Cover Image": "कवर इमेज चुनें",
|
||||
"Open Book in New Window": "नई विंडो में पुस्तक खोलें",
|
||||
"Voices for {{lang}}": "{{lang}} के लिए आवाज़ें",
|
||||
"Yandex Translate": "यांडेक्स अनुवाद",
|
||||
@@ -484,7 +483,6 @@
|
||||
"File Content (recommended)": "फाइल सामग्री (अनुशंसित)",
|
||||
"File Name": "फाइल नाम",
|
||||
"Device Name": "डिवाइस नाम",
|
||||
"Sync Tolerance": "सिंक सहिष्णुता",
|
||||
"Connect to your KOReader Sync server.": "अपने KOReader सिंक सर्वर से कनेक्ट करें।",
|
||||
"Server URL": "सर्वर यूआरएल",
|
||||
"Username": "उपयोगकर्ता नाम",
|
||||
@@ -503,6 +501,20 @@
|
||||
"Approximately {{percentage}}%": "लगभग {{percentage}}%",
|
||||
"Failed to connect": "कनेक्ट करने में विफल",
|
||||
"Sync Server Connected": "सिंक सर्वर कनेक्टेड",
|
||||
"Precision: {{precision}} digits after the decimal": "सटीकता: {{precision}} दशमलव के बाद अंक",
|
||||
"Sync as {{userDisplayName}}": "सिंक के रूप में {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "सिंक के रूप में {{userDisplayName}}",
|
||||
"Custom Fonts": "कस्टम फ़ॉन्ट",
|
||||
"Cancel Delete": "डिलीट रद्द करें",
|
||||
"Import Font": "फ़ॉन्ट आयात करें",
|
||||
"Delete Font": "फ़ॉन्ट डिलीट करें",
|
||||
"Tips": "सुझाव",
|
||||
"Custom fonts can be selected from the Font Face menu": "कस्टम फ़ॉन्ट को फ़ॉन्ट फेस मेनू से चुना जा सकता है",
|
||||
"Manage Custom Fonts": "कस्टम फ़ॉन्ट प्रबंधन",
|
||||
"Select Files": "फ़ाइलें चुनें",
|
||||
"Select Image": "छवि चुनें",
|
||||
"Select Video": "वीडियो चुनें",
|
||||
"Select Audio": "ऑडियो चुनें",
|
||||
"Select Fonts": "फ़ॉन्ट चुनें",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "समर्थित फ़ॉन्ट प्रारूप: .ttf, .otf, .woff, .woff2",
|
||||
"Push progress": "पुश प्रगति",
|
||||
"Pull progress": "पुल प्रगति"
|
||||
}
|
||||
|
||||
@@ -439,7 +439,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Fiksi, Sains, Sejarah",
|
||||
"Select Cover Image": "Pilihan Gambar Sampul",
|
||||
"Open Book in New Window": "Buka Buku di Jendela Baru",
|
||||
"Voices for {{lang}}": "Suara untuk {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
@@ -480,7 +479,6 @@
|
||||
"File Content (recommended)": "Konten File (disarankan)",
|
||||
"File Name": "Nama File",
|
||||
"Device Name": "Nama Perangkat",
|
||||
"Sync Tolerance": "Toleransi Sinkronisasi",
|
||||
"Connect to your KOReader Sync server.": "Hubungkan ke server Sinkronisasi KOReader Anda.",
|
||||
"Server URL": "URL Server",
|
||||
"Username": "Nama Pengguna",
|
||||
@@ -499,6 +497,20 @@
|
||||
"Approximately {{percentage}}%": "Sekitar {{percentage}}%",
|
||||
"Failed to connect": "Gagal terhubung",
|
||||
"Sync Server Connected": "Server sinkronisasi terhubung",
|
||||
"Precision: {{precision}} digits after the decimal": "Presisi: {{precision}} digit setelah desimal",
|
||||
"Sync as {{userDisplayName}}": "Sinkronkan sebagai {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Sinkronkan sebagai {{userDisplayName}}",
|
||||
"Custom Fonts": "Font Kustom",
|
||||
"Cancel Delete": "Batal Hapus",
|
||||
"Import Font": "Impor Font",
|
||||
"Delete Font": "Hapus Font",
|
||||
"Tips": "Tips",
|
||||
"Custom fonts can be selected from the Font Face menu": "Font kustom dapat dipilih dari menu Font Face",
|
||||
"Manage Custom Fonts": "Kelola Font Kustom",
|
||||
"Select Files": "Pilih File",
|
||||
"Select Image": "Pilih Gambar",
|
||||
"Select Video": "Pilih Video",
|
||||
"Select Audio": "Pilih Audio",
|
||||
"Select Fonts": "Pilih Font",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Format font yang didukung: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "Dorong Progres",
|
||||
"Pull Progress": "Tarik Progres"
|
||||
}
|
||||
|
||||
@@ -447,7 +447,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Narrativa, Scienza, Storia",
|
||||
"Select Cover Image": "Seleziona immagine di copertina",
|
||||
"Open Book in New Window": "Apri libro in una nuova finestra",
|
||||
"Voices for {{lang}}": "Voci per {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
@@ -488,7 +487,6 @@
|
||||
"File Content (recommended)": "Contenuto del File (consigliato)",
|
||||
"File Name": "Nome del File",
|
||||
"Device Name": "Nome del Dispositivo",
|
||||
"Sync Tolerance": "Tolleranza di Sincronizzazione",
|
||||
"Connect to your KOReader Sync server.": "Connettiti al tuo server KOReader Sync.",
|
||||
"Server URL": "URL del Server",
|
||||
"Username": "Nome Utente",
|
||||
@@ -507,6 +505,20 @@
|
||||
"Approximately {{percentage}}%": "Circa {{percentage}}%",
|
||||
"Failed to connect": "Impossibile connettersi",
|
||||
"Sync Server Connected": "Server di sincronizzazione connesso",
|
||||
"Precision: {{precision}} digits after the decimal": "Precisione: {{precision}} cifre dopo la virgola",
|
||||
"Sync as {{userDisplayName}}": "Sincronizza come {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Sincronizza come {{userDisplayName}}",
|
||||
"Custom Fonts": "Font Personalizzati",
|
||||
"Cancel Delete": "Annulla Eliminazione",
|
||||
"Import Font": "Importa Font",
|
||||
"Delete Font": "Elimina Font",
|
||||
"Tips": "Suggerimenti",
|
||||
"Custom fonts can be selected from the Font Face menu": "I font personalizzati possono essere selezionati dal menu Carattere",
|
||||
"Manage Custom Fonts": "Gestisci Font Personalizzati",
|
||||
"Select Files": "Seleziona File",
|
||||
"Select Image": "Seleziona Immagine",
|
||||
"Select Video": "Seleziona Video",
|
||||
"Select Audio": "Seleziona Audio",
|
||||
"Select Fonts": "Seleziona Font",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formati di font supportati: .ttf, .otf, .woff, .woff2",
|
||||
"Push progress": "Progresso push",
|
||||
"Pull progress": "Progresso pull"
|
||||
}
|
||||
|
||||
@@ -439,7 +439,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "小説、科学、歴史",
|
||||
"Select Cover Image": "表紙画像を選択",
|
||||
"Open Book in New Window": "新しいウィンドウで本を開く",
|
||||
"Voices for {{lang}}": "{{lang}}の音声",
|
||||
"Yandex Translate": "Yandex翻訳",
|
||||
@@ -480,7 +479,6 @@
|
||||
"File Content (recommended)": "ファイル内容(推奨)",
|
||||
"File Name": "ファイル名",
|
||||
"Device Name": "デバイス名",
|
||||
"Sync Tolerance": "同期許容範囲",
|
||||
"Connect to your KOReader Sync server.": "KOReader Syncサーバーに接続します。",
|
||||
"Server URL": "サーバーURL",
|
||||
"Username": "ユーザー名",
|
||||
@@ -499,6 +497,20 @@
|
||||
"Approximately {{percentage}}%": "おおよそ {{percentage}}%",
|
||||
"Failed to connect": "接続に失敗しました",
|
||||
"Sync Server Connected": "同期サーバーに接続されました",
|
||||
"Precision: {{precision}} digits after the decimal": "精度: {{precision}} 桁の小数点以下",
|
||||
"Sync as {{userDisplayName}}": "{{userDisplayName}} として同期"
|
||||
"Sync as {{userDisplayName}}": "{{userDisplayName}} として同期",
|
||||
"Custom Fonts": "カスタムフォント",
|
||||
"Cancel Delete": "削除をキャンセル",
|
||||
"Import Font": "フォントをインポート",
|
||||
"Delete Font": "フォントを削除",
|
||||
"Tips": "ヒント",
|
||||
"Custom fonts can be selected from the Font Face menu": "カスタムフォントは書体メニューから選択できます",
|
||||
"Manage Custom Fonts": "カスタムフォント管理",
|
||||
"Select Files": "ファイルを選択",
|
||||
"Select Image": "画像を選択",
|
||||
"Select Video": "動画を選択",
|
||||
"Select Audio": "音声を選択",
|
||||
"Select Fonts": "フォントを選択",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "サポートされているフォント形式:.ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "進捗をプッシュ",
|
||||
"Pull Progress": "進捗をプル"
|
||||
}
|
||||
|
||||
@@ -439,7 +439,6 @@
|
||||
"Google Books": "구글 북스",
|
||||
"Open Library": "오픈 라이브러리",
|
||||
"Fiction, Science, History": "소설, 과학, 역사",
|
||||
"Select Cover Image": "표지 이미지 선택",
|
||||
"Open Book in New Window": "새 창에서 책 열기",
|
||||
"Voices for {{lang}}": "{{lang}}의 음성",
|
||||
"Yandex Translate": "Yandex 번역",
|
||||
@@ -480,7 +479,6 @@
|
||||
"File Content (recommended)": "파일 내용 (권장)",
|
||||
"File Name": "파일 이름",
|
||||
"Device Name": "장치 이름",
|
||||
"Sync Tolerance": "동기화 허용 오차",
|
||||
"Connect to your KOReader Sync server.": "KOReader Sync 서버에 연결합니다.",
|
||||
"Server URL": "서버 URL",
|
||||
"Username": "사용자 이름",
|
||||
@@ -499,6 +497,20 @@
|
||||
"Approximately {{percentage}}%": "대략 {{percentage}}%",
|
||||
"Failed to connect": "연결 실패",
|
||||
"Sync Server Connected": "동기화 서버에 연결됨",
|
||||
"Precision: {{precision}} digits after the decimal": "정밀도: {{precision}} 자리의 소수점 이하",
|
||||
"Sync as {{userDisplayName}}": "{{userDisplayName}}로 동기화"
|
||||
"Sync as {{userDisplayName}}": "{{userDisplayName}}로 동기화",
|
||||
"Custom Fonts": "사용자 정의 글꼴",
|
||||
"Cancel Delete": "삭제 취소",
|
||||
"Import Font": "글꼴 가져오기",
|
||||
"Delete Font": "글꼴 삭제",
|
||||
"Tips": "팁",
|
||||
"Custom fonts can be selected from the Font Face menu": "사용자 정의 글꼴은 글꼴 메뉴에서 선택할 수 있습니다",
|
||||
"Manage Custom Fonts": "사용자 정의 글꼴 관리",
|
||||
"Select Files": "파일 선택",
|
||||
"Select Image": "이미지 선택",
|
||||
"Select Video": "동영상 선택",
|
||||
"Select Audio": "오디오 선택",
|
||||
"Select Fonts": "글꼴 선택",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "지원되는 글꼴 형식: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "진행 상황 푸시",
|
||||
"Pull Progress": "진행 상황 끌어오기"
|
||||
}
|
||||
|
||||
@@ -443,7 +443,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Fictie, Wetenschap, Geschiedenis",
|
||||
"Select Cover Image": "Selecteer omslagafbeelding",
|
||||
"Open Book in New Window": "Open boek in nieuw venster",
|
||||
"Voices for {{lang}}": "Stemmen voor {{lang}}",
|
||||
"Yandex Translate": "Yandex Vertalen",
|
||||
@@ -484,7 +483,6 @@
|
||||
"File Content (recommended)": "Bestandsinhoud (aanbevolen)",
|
||||
"File Name": "Bestandsnaam",
|
||||
"Device Name": "Apparaatnaam",
|
||||
"Sync Tolerance": "Synchronisatietolerantie",
|
||||
"Connect to your KOReader Sync server.": "Verbind met je KOReader Sync-server.",
|
||||
"Server URL": "Server-URL",
|
||||
"Username": "Gebruikersnaam",
|
||||
@@ -503,6 +501,20 @@
|
||||
"Approximately {{percentage}}%": "Ongeveer {{percentage}}%",
|
||||
"Failed to connect": "Verbinding mislukt",
|
||||
"Sync Server Connected": "Synchronisatie server verbonden",
|
||||
"Precision: {{precision}} digits after the decimal": "Precisie: {{precision}} cijfers na de komma",
|
||||
"Sync as {{userDisplayName}}": "Synchroniseren als {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Synchroniseren als {{userDisplayName}}",
|
||||
"Custom Fonts": "Aangepaste Lettertypen",
|
||||
"Cancel Delete": "Verwijderen Annuleren",
|
||||
"Import Font": "Lettertype Importeren",
|
||||
"Delete Font": "Lettertype Verwijderen",
|
||||
"Tips": "Tips",
|
||||
"Custom fonts can be selected from the Font Face menu": "Aangepaste lettertypen kunnen worden geselecteerd vanuit het Lettertype menu",
|
||||
"Manage Custom Fonts": "Aangepaste Lettertypen Beheren",
|
||||
"Select Files": "Bestanden Selecteren",
|
||||
"Select Image": "Afbeelding Selecteren",
|
||||
"Select Video": "Video Selecteren",
|
||||
"Select Audio": "Audio Selecteren",
|
||||
"Select Fonts": "Lettertypen Selecteren",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Ondersteunde lettertypeformaten: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "Voortgang pushen",
|
||||
"Pull Progress": "Voortgang pullen"
|
||||
}
|
||||
|
||||
@@ -451,7 +451,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Beletrystyka, Nauka, Historia",
|
||||
"Select Cover Image": "Zaznacz obraz okładki",
|
||||
"Open Book in New Window": "Otwórz książkę w nowym oknie",
|
||||
"Voices for {{lang}}": "{{lang}} Głosy",
|
||||
"Yandex Translate": "Yandex Tłumacz",
|
||||
@@ -492,7 +491,6 @@
|
||||
"File Content (recommended)": "Zawartość pliku (zalecane)",
|
||||
"File Name": "Nazwa pliku",
|
||||
"Device Name": "Nazwa urządzenia",
|
||||
"Sync Tolerance": "Tolerancja synchronizacji",
|
||||
"Connect to your KOReader Sync server.": "Połącz z serwerem synchronizacji KOReader.",
|
||||
"Server URL": "Adres URL serwera",
|
||||
"Username": "Nazwa użytkownika",
|
||||
@@ -511,6 +509,20 @@
|
||||
"Approximately {{percentage}}%": "Około {{percentage}}%",
|
||||
"Failed to connect": "Nie udało się połączyć",
|
||||
"Sync Server Connected": "Serwer synchronizacji połączony",
|
||||
"Precision: {{precision}} digits after the decimal": "Precyzja: {{precision}} cyfr po przecinku",
|
||||
"Sync as {{userDisplayName}}": "Synchronizuj jako {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Synchronizuj jako {{userDisplayName}}",
|
||||
"Custom Fonts": "Niestandardowe Czcionki",
|
||||
"Cancel Delete": "Anuluj Usuwanie",
|
||||
"Import Font": "Importuj Czcionkę",
|
||||
"Delete Font": "Usuń Czcionkę",
|
||||
"Tips": "Wskazówki",
|
||||
"Custom fonts can be selected from the Font Face menu": "Niestandardowe czcionki można wybrać z menu Czcionka",
|
||||
"Manage Custom Fonts": "Zarządzaj Niestandardowymi Czcionkami",
|
||||
"Select Files": "Wybierz Pliki",
|
||||
"Select Image": "Wybierz Obraz",
|
||||
"Select Video": "Wybierz Wideo",
|
||||
"Select Audio": "Wybierz Audio",
|
||||
"Select Fonts": "Wybierz Czcionki",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Obsługiwane formaty czcionek: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "Wyślij postęp",
|
||||
"Pull Progress": "Pobierz postęp"
|
||||
}
|
||||
|
||||
@@ -447,7 +447,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Ficção, Ciência, História",
|
||||
"Select Cover Image": "Selecionar imagem da capa",
|
||||
"Open Book in New Window": "Abrir livro em nova janela",
|
||||
"Voices for {{lang}}": "Vozes para {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
@@ -488,7 +487,6 @@
|
||||
"File Content (recommended)": "Conteúdo do Arquivo (recomendado)",
|
||||
"File Name": "Nome do Arquivo",
|
||||
"Device Name": "Nome do Dispositivo",
|
||||
"Sync Tolerance": "Tolerância de Sincronização",
|
||||
"Connect to your KOReader Sync server.": "Conectar ao seu servidor KOReader Sync.",
|
||||
"Server URL": "URL do Servidor",
|
||||
"Username": "Nome de Usuário",
|
||||
@@ -507,6 +505,20 @@
|
||||
"Approximately {{percentage}}%": "Aproximadamente {{percentage}}%",
|
||||
"Failed to connect": "Falha ao conectar",
|
||||
"Sync Server Connected": "Servidor de Sincronização Conectado",
|
||||
"Precision: {{precision}} digits after the decimal": "Precisão: {{precision}} dígitos após a vírgula",
|
||||
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Sincronizar como {{userDisplayName}}",
|
||||
"Custom Fonts": "Fontes Personalizadas",
|
||||
"Cancel Delete": "Cancelar Exclusão",
|
||||
"Import Font": "Importar Fonte",
|
||||
"Delete Font": "Excluir Fonte",
|
||||
"Tips": "Dicas",
|
||||
"Custom fonts can be selected from the Font Face menu": "Fontes personalizadas podem ser selecionadas no menu Fonte",
|
||||
"Manage Custom Fonts": "Gerenciar Fontes Personalizadas",
|
||||
"Select Files": "Selecionar Arquivos",
|
||||
"Select Image": "Selecionar Imagem",
|
||||
"Select Video": "Selecionar Vídeo",
|
||||
"Select Audio": "Selecionar Áudio",
|
||||
"Select Fonts": "Selecionar Fontes",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Formatos de fonte suportados: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "Enviar Progresso",
|
||||
"Pull Progress": "Obter Progresso"
|
||||
}
|
||||
|
||||
@@ -451,7 +451,6 @@
|
||||
"Google Books": "Google Книги",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Художественная литература, Наука, История",
|
||||
"Select Cover Image": "Выбрать изображение обложки",
|
||||
"Open Book in New Window": "Открыть книгу в новом окне",
|
||||
"Voices for {{lang}}": "Голоса для {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
@@ -492,7 +491,6 @@
|
||||
"File Content (recommended)": "Содержимое файла (рекомендуется)",
|
||||
"File Name": "Имя файла",
|
||||
"Device Name": "Имя устройства",
|
||||
"Sync Tolerance": "Допуск синхронизации",
|
||||
"Connect to your KOReader Sync server.": "Подключитесь к вашему серверу синхронизации KOReader.",
|
||||
"Server URL": "URL сервера",
|
||||
"Username": "Имя пользователя",
|
||||
@@ -511,6 +509,20 @@
|
||||
"Approximately {{percentage}}%": "Приблизительно {{percentage}}%",
|
||||
"Failed to connect": "Не удалось подключиться",
|
||||
"Sync Server Connected": "Сервер синхронизации подключен",
|
||||
"Precision: {{precision}} digits after the decimal": "Точность: {{precision}} знаков после запятой",
|
||||
"Sync as {{userDisplayName}}": "Синхронизировать как {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Синхронизировать как {{userDisplayName}}",
|
||||
"Custom Fonts": "Пользовательские Шрифты",
|
||||
"Cancel Delete": "Отменить Удаление",
|
||||
"Import Font": "Импортировать Шрифт",
|
||||
"Delete Font": "Удалить Шрифт",
|
||||
"Tips": "Советы",
|
||||
"Custom fonts can be selected from the Font Face menu": "Пользовательские шрифты можно выбрать в меню Шрифт",
|
||||
"Manage Custom Fonts": "Управление Пользовательскими Шрифтами",
|
||||
"Select Files": "Выбрать Файлы",
|
||||
"Select Image": "Выбрать Изображение",
|
||||
"Select Video": "Выбрать Видео",
|
||||
"Select Audio": "Выбрать Аудио",
|
||||
"Select Fonts": "Выбрать Шрифты",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Поддерживаемые форматы шрифтов: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "Отправить прогресс",
|
||||
"Pull Progress": "Получить прогресс"
|
||||
}
|
||||
|
||||
@@ -439,7 +439,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "นิยาย, วิทยาศาสตร์, ประวัติศาสตร์",
|
||||
"Select Cover Image": "เลือกภาพปก",
|
||||
"Open Book in New Window": "เปิดหนังสือในหน้าต่างใหม่",
|
||||
"Voices for {{lang}}": "เสียงสำหรับ {{lang}}",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
@@ -480,7 +479,6 @@
|
||||
"File Content (recommended)": "เนื้อหาไฟล์ (แนะนำ)",
|
||||
"File Name": "ชื่อไฟล์",
|
||||
"Device Name": "ชื่ออุปกรณ์",
|
||||
"Sync Tolerance": "ความทนทานต่อการซิงค์",
|
||||
"Connect to your KOReader Sync server.": "เชื่อมต่อกับเซิร์ฟเวอร์ KOReader Sync ของคุณ",
|
||||
"Server URL": "URL ของเซิร์ฟเวอร์",
|
||||
"Username": "ชื่อผู้ใช้",
|
||||
@@ -499,6 +497,20 @@
|
||||
"Approximately {{percentage}}%": "ประมาณ {{percentage}}%",
|
||||
"Failed to connect": "ไม่สามารถเชื่อมต่อได้",
|
||||
"Sync Server Connected": "เซิร์ฟเวอร์ซิงค์เชื่อมต่อแล้ว",
|
||||
"Precision: {{precision}} digits after the decimal": "ความแม่นยำ: {{precision}} หลักหลังจุดทศนิยม",
|
||||
"Sync as {{userDisplayName}}": "ซิงค์เป็น {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "ซิงค์เป็น {{userDisplayName}}",
|
||||
"Custom Fonts": "ฟอนต์กำหนดเอง",
|
||||
"Cancel Delete": "ยกเลิกการลบ",
|
||||
"Import Font": "นำเข้าฟอนต์",
|
||||
"Delete Font": "ลบฟอนต์",
|
||||
"Tips": "เคล็ดลับ",
|
||||
"Custom fonts can be selected from the Font Face menu": "ฟอนต์กำหนดเองสามารถเลือกได้จากเมนูฟอนต์",
|
||||
"Manage Custom Fonts": "จัดการฟอนต์กำหนดเอง",
|
||||
"Select Files": "เลือกไฟล์",
|
||||
"Select Image": "เลือกรูปภาพ",
|
||||
"Select Video": "เลือกวิดีโอ",
|
||||
"Select Audio": "เลือกเสียง",
|
||||
"Select Fonts": "เลือกฟอนต์",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "รูปแบบฟอนต์ที่รองรับ: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "ส่งความคืบหน้า",
|
||||
"Pull Progress": "ดึงความคืบหน้า"
|
||||
}
|
||||
|
||||
@@ -443,7 +443,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Kurgu, Bilim, Tarih",
|
||||
"Select Cover Image": "Kapak Resmi Seç",
|
||||
"Open Book in New Window": "Kitabı Yeni Pencerede Aç",
|
||||
"Voices for {{lang}}": "{{lang}} için Sesler",
|
||||
"Yandex Translate": "Yandex Çeviri",
|
||||
@@ -484,7 +483,6 @@
|
||||
"File Content (recommended)": "Dosya İçeriği (önerilen)",
|
||||
"File Name": "Dosya Adı",
|
||||
"Device Name": "Cihaz Adı",
|
||||
"Sync Tolerance": "Senkronizasyon Toleransı",
|
||||
"Connect to your KOReader Sync server.": "KOReader Senkronizasyon sunucunuza bağlanın.",
|
||||
"Server URL": "Sunucu URL'si",
|
||||
"Username": "Kullanıcı Adı",
|
||||
@@ -503,6 +501,20 @@
|
||||
"Approximately {{percentage}}%": "Yaklaşık {{percentage}}%",
|
||||
"Failed to connect": "Bağlanma başarısız oldu",
|
||||
"Sync Server Connected": "Senkronizasyon Sunucusu Bağlandı",
|
||||
"Precision: {{precision}} digits after the decimal": "Hassasiyet: {{precision}} ondalık basamaktan sonra",
|
||||
"Sync as {{userDisplayName}}": "Senkronize et {{userDisplayName}} olarak"
|
||||
"Sync as {{userDisplayName}}": "Senkronize et {{userDisplayName}} olarak",
|
||||
"Custom Fonts": "Özel Yazı Tipleri",
|
||||
"Cancel Delete": "Silmeyi İptal Et",
|
||||
"Import Font": "Yazı Tipi İçe Aktar",
|
||||
"Delete Font": "Yazı Tipini Sil",
|
||||
"Tips": "İpuçları",
|
||||
"Custom fonts can be selected from the Font Face menu": "Özel yazı tipleri Yazı Tipi menüsünden seçilebilir",
|
||||
"Manage Custom Fonts": "Özel Yazı Tiplerini Yönet",
|
||||
"Select Files": "Dosyaları Seç",
|
||||
"Select Image": "Resim Seç",
|
||||
"Select Video": "Video Seç",
|
||||
"Select Audio": "Ses Seç",
|
||||
"Select Fonts": "Yazı Tiplerini Seç",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Desteklenen yazı tipi formatları: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "İlerlemeyi Gönder",
|
||||
"Pull Progress": "İlerlemeyi Al"
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"Slow": "Повільно",
|
||||
"Solarized": "Сонячний",
|
||||
"Speak": "Озвучити",
|
||||
"Subjects": "Теми",
|
||||
"Subjects": "Жанри",
|
||||
"System Fonts": "Системні шрифти",
|
||||
"Theme Color": "Колір теми",
|
||||
"Theme Mode": "Режим теми",
|
||||
@@ -451,7 +451,6 @@
|
||||
"Google Books": "Google Книги",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Художня література, Наукова фантастика, Історія",
|
||||
"Select Cover Image": "Оберіть зображення обкладинки",
|
||||
"Open Book in New Window": "Відкрити книгу в новому вікні",
|
||||
"Voices for {{lang}}": "Голоси для {{lang}}",
|
||||
"Yandex Translate": "Yandex Перекладач (росія)",
|
||||
@@ -469,48 +468,61 @@
|
||||
"Disable Double Click": "Вимкнути подвійне натискання",
|
||||
"TTS not supported for this document": "TTS не підтримується в цьому документі",
|
||||
"Reset Password": "Скинути пароль",
|
||||
"Show Reading Progress": "Показати прогрес читання",
|
||||
"Reading Progress Style": "Стиль прогресу читання",
|
||||
"Show Reading Progress": "Показати проґрес читання",
|
||||
"Reading Progress Style": "Стиль проґресу читання",
|
||||
"Page Number": "Номер сторінки",
|
||||
"Percentage": "Процент",
|
||||
"Percentage": "Відсотки",
|
||||
"Deleted local copy of the book: {{title}}": "Видалено локальну копію книги: {{title}}",
|
||||
"Failed to delete cloud backup of the book: {{title}}": "Не вдалося видалити резервну копію книги в хмарі: {{title}}",
|
||||
"Failed to delete local copy of the book: {{title}}": "Не вдалося видалити локальну копію книги: {{title}}",
|
||||
"Are you sure to delete the local copy of the selected book?": "Ви впевнені, що хочете видалити локальну копію вибраної книги?",
|
||||
"Remove from Cloud & Device": "Видалити з хмари та пристрою",
|
||||
"Remove from Cloud Only": "Видалити тільки з хмари",
|
||||
"Remove from Device Only": "Видалити тільки з пристрою",
|
||||
"Disconnected": "Відключено",
|
||||
"Remove from Cloud Only": "Видалити тільки із хмари",
|
||||
"Remove from Device Only": "Видалити тільки із пристрою",
|
||||
"Disconnected": "Від'єднано",
|
||||
"KOReader Sync Settings": "Налаштування синхронізації KOReader",
|
||||
"Sync Strategy": "Стратегія синхронізації",
|
||||
"Ask on conflict": "Запитувати при конфлікті",
|
||||
"Always use latest": "Завжди використовувати останню версію",
|
||||
"Always use latest": "Завжди використовувати найновішу",
|
||||
"Send changes only": "Відправити тільки зміни",
|
||||
"Receive changes only": "Отримати тільки зміни",
|
||||
"Disabled": "Вимкнено",
|
||||
"Checksum Method": "Метод контрольної суми",
|
||||
"Checksum Method": "Метод контролю суми",
|
||||
"File Content (recommended)": "Вміст файлу (рекомендується)",
|
||||
"File Name": "Ім'я файлу",
|
||||
"Device Name": "Ім'я пристрою",
|
||||
"Sync Tolerance": "Допустима похибка синхронізації",
|
||||
"Connect to your KOReader Sync server.": "Підключіться до свого сервера синхронізації KOReader.",
|
||||
"Server URL": "URL сервера",
|
||||
"Username": "Ім'я користувача",
|
||||
"Your Username": "Ваше ім'я користувача",
|
||||
"Password": "Пароль",
|
||||
"Connect": "Підключитися",
|
||||
"Connect": "Під'єднатися",
|
||||
"KOReader Sync": "Синхронізація KOReader",
|
||||
"Sync Conflict": "Конфлікт синхронізації",
|
||||
"Sync reading progress from \"{{deviceName}}\"?": "Синхронізувати прогрес читання з \"{{deviceName}}\"?",
|
||||
"Sync reading progress from \"{{deviceName}}\"?": "Синхронізувати прогрес читання із \"{{deviceName}}\"?",
|
||||
"another device": "інший пристрій",
|
||||
"Local Progress": "Локальний прогрес",
|
||||
"Remote Progress": "Віддалений прогрес",
|
||||
"Remote Progress": "Прогрес у хмарі",
|
||||
"Page {{page}} of {{total}} ({{percentage}}%)": "Сторінка {{page}} з {{total}} ({{percentage}}%)",
|
||||
"Current position": "Поточна позиція",
|
||||
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизно сторінка {{page}} з {{total}} ({{percentage}}%)",
|
||||
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Приблизно сторінка {{page}} із {{total}} ({{percentage}}%)",
|
||||
"Approximately {{percentage}}%": "Приблизно {{percentage}}%",
|
||||
"Failed to connect": "Не вдалося підключитися",
|
||||
"Sync Server Connected": "Сервер синхронізації підключено",
|
||||
"Precision: {{precision}} digits after the decimal": "Точність: {{precision}} знаків після коми",
|
||||
"Sync as {{userDisplayName}}": "Синхронізувати як {{userDisplayName}}"
|
||||
"Failed to connect": "Не вдалося під'єднатися",
|
||||
"Sync Server Connected": "Сервер синхронізації під'єднано",
|
||||
"Sync as {{userDisplayName}}": "Синхронізувати як {{userDisplayName}}",
|
||||
"Custom Fonts": "Користувацькі Шрифти",
|
||||
"Cancel Delete": "Скасувати Видалення",
|
||||
"Import Font": "Імпортувати Шрифт",
|
||||
"Delete Font": "Видалити Шрифт",
|
||||
"Tips": "Поради",
|
||||
"Custom fonts can be selected from the Font Face menu": "Користувацькі шрифти можна вибрати з меню Шрифт",
|
||||
"Manage Custom Fonts": "Керування Користувацькими Шрифтами",
|
||||
"Select Files": "Вибрати Файли",
|
||||
"Select Image": "Вибрати Зображення",
|
||||
"Select Video": "Вибрати Відео",
|
||||
"Select Audio": "Вибрати Аудіо",
|
||||
"Select Fonts": "Вибрати Шрифти",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Підтримувані формати шрифтів: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "Надіслати прогрес",
|
||||
"Pull Progress": "Отримати прогрес"
|
||||
}
|
||||
|
||||
@@ -439,7 +439,6 @@
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Fiction, Science, History": "Tiểu thuyết, Khoa học, Lịch sử",
|
||||
"Select Cover Image": "Chọn ảnh bìa",
|
||||
"Open Book in New Window": "Mở Sách trong Cửa sổ Mới",
|
||||
"Voices for {{lang}}": "Giọng nói cho {{lang}}",
|
||||
"Yandex Translate": "Yandex Dịch",
|
||||
@@ -480,7 +479,6 @@
|
||||
"File Content (recommended)": "Nội dung tệp (được khuyến nghị)",
|
||||
"File Name": "Tên tệp",
|
||||
"Device Name": "Tên thiết bị",
|
||||
"Sync Tolerance": "Độ dung sai đồng bộ",
|
||||
"Connect to your KOReader Sync server.": "Kết nối với máy chủ đồng bộ KOReader của bạn.",
|
||||
"Server URL": "URL máy chủ",
|
||||
"Username": "Tên người dùng",
|
||||
@@ -499,6 +497,20 @@
|
||||
"Approximately {{percentage}}%": "Khoảng {{percentage}}%",
|
||||
"Failed to connect": "Không thể kết nối",
|
||||
"Sync Server Connected": "Máy chủ đồng bộ đã kết nối",
|
||||
"Precision: {{precision}} digits after the decimal": "Độ chính xác: {{precision}} chữ số sau dấu thập phân",
|
||||
"Sync as {{userDisplayName}}": "Đồng bộ dưới tên {{userDisplayName}}"
|
||||
"Sync as {{userDisplayName}}": "Đồng bộ dưới tên {{userDisplayName}}",
|
||||
"Custom Fonts": "Phông Chữ Tùy Chỉnh",
|
||||
"Cancel Delete": "Hủy Xóa",
|
||||
"Import Font": "Nhập Phông Chữ",
|
||||
"Delete Font": "Xóa Phông Chữ",
|
||||
"Tips": "Mẹo",
|
||||
"Custom fonts can be selected from the Font Face menu": "Phông chữ tùy chỉnh có thể được chọn từ menu Phông Chữ",
|
||||
"Manage Custom Fonts": "Quản Lý Phông Chữ Tùy Chỉnh",
|
||||
"Select Files": "Chọn Tệp",
|
||||
"Select Image": "Chọn Hình Ảnh",
|
||||
"Select Video": "Chọn Video",
|
||||
"Select Audio": "Chọn Âm Thanh",
|
||||
"Select Fonts": "Chọn Phông Chữ",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Định dạng phông chữ được hỗ trợ: .ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "Đẩy tiến độ",
|
||||
"Pull Progress": "Kéo tiến độ"
|
||||
}
|
||||
|
||||
@@ -439,7 +439,6 @@
|
||||
"Google Books": "谷歌图书",
|
||||
"Open Library": "开放图书馆",
|
||||
"Fiction, Science, History": "小说,科学,历史",
|
||||
"Select Cover Image": "选择封面图片",
|
||||
"Open Book in New Window": "在新窗口中打开书籍",
|
||||
"Voices for {{lang}}": "{{lang}} 的语音",
|
||||
"Yandex Translate": "Yandex 翻译",
|
||||
@@ -480,7 +479,6 @@
|
||||
"File Content (recommended)": "文件内容(推荐)",
|
||||
"File Name": "文件名",
|
||||
"Device Name": "设备名称",
|
||||
"Sync Tolerance": "同步容忍度",
|
||||
"Connect to your KOReader Sync server.": "连接到您的 KOReader 同步服务器。",
|
||||
"Server URL": "服务器 URL",
|
||||
"Username": "用户名",
|
||||
@@ -499,6 +497,20 @@
|
||||
"Approximately {{percentage}}%": "大约 {{percentage}}%",
|
||||
"Failed to connect": "连接失败",
|
||||
"Sync Server Connected": "同步服务器已连接",
|
||||
"Precision: {{precision}} digits after the decimal": "精度:{{precision}} 位小数",
|
||||
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步"
|
||||
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步",
|
||||
"Custom Fonts": "自定义字体",
|
||||
"Cancel Delete": "取消删除",
|
||||
"Import Font": "导入字体",
|
||||
"Delete Font": "删除字体",
|
||||
"Tips": "提示",
|
||||
"Custom fonts can be selected from the Font Face menu": "可以从字形菜单中选择自定义字体",
|
||||
"Manage Custom Fonts": "管理自定义字体",
|
||||
"Select Files": "选择文件",
|
||||
"Select Image": "选择图片",
|
||||
"Select Video": "选择视频",
|
||||
"Select Audio": "选择音频",
|
||||
"Select Fonts": "选择字体",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "支持的字体格式:.ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "推送阅读进度",
|
||||
"Pull Progress": "拉取阅读进度"
|
||||
}
|
||||
|
||||
@@ -439,7 +439,6 @@
|
||||
"Google Books": "Google 圖書",
|
||||
"Open Library": "開放圖書館",
|
||||
"Fiction, Science, History": "小說,科學,歷史",
|
||||
"Select Cover Image": "選擇封面圖片",
|
||||
"Open Book in New Window": "在新視窗中打開書籍",
|
||||
"Voices for {{lang}}": "{{lang}} 的語音",
|
||||
"Yandex Translate": "Yandex 翻譯",
|
||||
@@ -480,7 +479,6 @@
|
||||
"File Content (recommended)": "文件內容(推薦)",
|
||||
"File Name": "文件名",
|
||||
"Device Name": "設備名稱",
|
||||
"Sync Tolerance": "同步容忍度",
|
||||
"Connect to your KOReader Sync server.": "連接到您的 KOReader 同步伺服器。",
|
||||
"Server URL": "伺服器 URL",
|
||||
"Username": "用戶名",
|
||||
@@ -499,6 +497,20 @@
|
||||
"Approximately {{percentage}}%": "大約 {{percentage}}%",
|
||||
"Failed to connect": "連接失敗",
|
||||
"Sync Server Connected": "同步伺服器已連接",
|
||||
"Precision: {{precision}} digits after the decimal": "精度: {{precision}} 位小數",
|
||||
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步"
|
||||
"Sync as {{userDisplayName}}": "以 {{userDisplayName}} 身份同步",
|
||||
"Custom Fonts": "自訂字型",
|
||||
"Cancel Delete": "取消刪除",
|
||||
"Import Font": "匯入字型",
|
||||
"Delete Font": "刪除字型",
|
||||
"Tips": "提示",
|
||||
"Custom fonts can be selected from the Font Face menu": "可以從字型選單中選擇自訂字型",
|
||||
"Manage Custom Fonts": "管理自訂字型",
|
||||
"Select Files": "選擇檔案",
|
||||
"Select Image": "選擇圖片",
|
||||
"Select Video": "選擇影片",
|
||||
"Select Audio": "選擇音訊",
|
||||
"Select Fonts": "選擇字型",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "支援的字型格式:.ttf, .otf, .woff, .woff2",
|
||||
"Push Progress": "推送閱讀進度",
|
||||
"Pull Progress": "拉取閱讀進度"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.76": {
|
||||
"date": "2025-08-30",
|
||||
"notes": [
|
||||
"Sync: Moved KOReader sync controls into the reader sidebar with a manual push/pull option",
|
||||
"Sync: Improved tolerance and reliability for KOReader sync operations",
|
||||
"Sync: Added support for more KOReader server implementations",
|
||||
"Fonts: Added support for importing multiple styles within a font family",
|
||||
"Reader: Fixed an issue where footnotes are empty in some EPUB files",
|
||||
"Theme: Applied theme background color for PDFs on macOS",
|
||||
"Reader: Added support for importing AZW3 files on Android",
|
||||
"Reader: Reduced screen flash when launching the app and opening books",
|
||||
"Layout: Fixed an issue where the grouping modal would not appear on Linux"
|
||||
]
|
||||
},
|
||||
"0.9.75": {
|
||||
"date": "2025-08-22",
|
||||
"notes": [
|
||||
"Fonts: Added support for importing local TTF and ODF fonts",
|
||||
"TTS: Reduced the pause between sentences when using Edge TTS",
|
||||
"TTS: Fixed an issue where voices list could not be opened on older versions of iOS",
|
||||
"TOC: Fixed an issue where nested TOC items would not collapse properly in some books",
|
||||
"Translator: Fixed an issue where translations in the popup did not refresh correctly",
|
||||
"Library: Added option to sort authors with last name first",
|
||||
"Layout: Fixed mismatch in close button position between the reader and library pages",
|
||||
"Layout: Book title now remains centered in the available space",
|
||||
"Layout: Fixed footer bar layout in landscape mode"
|
||||
]
|
||||
},
|
||||
"0.9.72": {
|
||||
"date": "2025-08-18",
|
||||
"notes": [
|
||||
"Fixed Edge TTS voice playback for EPUBs",
|
||||
"Reduced accidental page flips when toggling toolbars",
|
||||
"Fixed images with background colors not displaying correctly in some books",
|
||||
"Added support for custom KOReader Sync Servers on your local network (LAN)"
|
||||
]
|
||||
},
|
||||
"0.9.71": {
|
||||
"date": "2025-08-13",
|
||||
"notes": [
|
||||
"Sync: Added two ways to sync reading progress with Koreader devices",
|
||||
"Sync: Added two ways to sync reading progress with KOReader devices",
|
||||
"EPUB: Applied monospace font settings",
|
||||
"EPUB: Fixed initial text alignment for some EPUB files",
|
||||
"PDF: Fixed issue opening large PDF files on Android",
|
||||
|
||||
@@ -41,7 +41,7 @@ tauri-plugin-log = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-http = { version = "2", features = ["dangerous-settings"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-oauth = "2"
|
||||
|
||||
@@ -87,6 +87,12 @@
|
||||
},
|
||||
{
|
||||
"url": "https://translate.googleapis.com"
|
||||
},
|
||||
{
|
||||
"url": "http://*:*"
|
||||
},
|
||||
{
|
||||
"url": "https://*:*"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -30,27 +30,55 @@
|
||||
|
||||
<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/mobi"/>
|
||||
<data android:mimeType="application/azw"/>
|
||||
<data android:mimeType="application/azw3"/>
|
||||
<data android:mimeType="application/x-mobipocket-ebook" />
|
||||
<data android:mimeType="application/vnd.amazon.ebook" />
|
||||
<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:mimeType="application/octet-stream"/>
|
||||
<data android:mimeType="application/x-font-ttf"/>
|
||||
<data android:mimeType="application/x-font-otf"/>
|
||||
</intent-filter>
|
||||
|
||||
<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>
|
||||
<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="*/*" />
|
||||
<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=".*\\.azw3" />
|
||||
<data android:pathPattern=".*\\.cbz" />
|
||||
<data android:pathPattern=".*\\.zip" />
|
||||
<data android:pathPattern=".*\\.txt" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="*/*"/>
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="*/*"/>
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="oauth">
|
||||
@@ -59,6 +87,7 @@
|
||||
<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" />
|
||||
@@ -66,7 +95,7 @@
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<intent-filter android:autoVerify="true" >
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<color android:color="#FF323130" />
|
||||
</item>
|
||||
<item android:gravity="center" android:width="120dp" android:height="120dp">
|
||||
<bitmap android:src="@drawable/splash_icon" />
|
||||
</item>
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
@@ -2,9 +2,7 @@
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.readest" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="android:windowSplashScreenBackground">@android:color/transparent</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon" tools:targetApi="31">@null</item>
|
||||
|
||||
<item name="android:windowBackground">@drawable/splash_background</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowTranslucentStatus">false</item>
|
||||
|
||||
+37
-1
@@ -198,6 +198,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
@Suppress("DEPRECATION")
|
||||
window.setDecorFitsSystemWindows(false)
|
||||
val controller = window.insetsController
|
||||
if (controller != null) {
|
||||
@@ -228,7 +229,7 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val compatController = WindowCompat.getInsetsController(window, decorView)
|
||||
compatController?.let {
|
||||
compatController.let {
|
||||
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
if (!isDarkMode) {
|
||||
it.isAppearanceLightStatusBars = true
|
||||
@@ -259,7 +260,9 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
}.reduce { acc, flag -> acc or flag }
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
@Suppress("DEPRECATION")
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
ret.put("success", true)
|
||||
} catch (e: Exception) {
|
||||
@@ -366,4 +369,37 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@Command
|
||||
fun get_safe_area_insets(invoke: Invoke) {
|
||||
val ret = JSObject()
|
||||
try {
|
||||
val rootView = activity.findViewById<View>(android.R.id.content)
|
||||
val windowInsets = androidx.core.view.ViewCompat.getRootWindowInsets(rootView)
|
||||
|
||||
if (windowInsets != null) {
|
||||
val insets = windowInsets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or
|
||||
WindowInsetsCompat.Type.displayCutout()
|
||||
)
|
||||
val density = activity.resources.displayMetrics.density
|
||||
ret.put("top", insets.top / density)
|
||||
ret.put("right", insets.right / density)
|
||||
ret.put("bottom", insets.bottom / density)
|
||||
ret.put("left", insets.left / density)
|
||||
} else {
|
||||
ret.put("top", 0)
|
||||
ret.put("right", 0)
|
||||
ret.put("bottom", 0)
|
||||
ret.put("left", 0)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ret.put("error", e.message)
|
||||
ret.put("top", 0)
|
||||
ret.put("right", 0)
|
||||
ret.put("bottom", 0)
|
||||
ret.put("left", 0)
|
||||
}
|
||||
invoke.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const COMMANDS: &[&str] = &[
|
||||
"iap_purchase_product",
|
||||
"iap_restore_purchases",
|
||||
"get_system_color_scheme",
|
||||
"get_safe_area_insets",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-get-safe-area-insets"
|
||||
description = "Enables the get_safe_area_insets command without any pre-configured scope."
|
||||
commands.allow = ["get_safe_area_insets"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-get-safe-area-insets"
|
||||
description = "Denies the get_safe_area_insets command without any pre-configured scope."
|
||||
commands.deny = ["get_safe_area_insets"]
|
||||
+27
@@ -19,6 +19,7 @@ Default permissions for the plugin
|
||||
- `allow-iap-purchase-product`
|
||||
- `allow-iap-restore-purchases`
|
||||
- `allow-get-system-color-scheme`
|
||||
- `allow-get-safe-area-insets`
|
||||
|
||||
## Permission Table
|
||||
|
||||
@@ -110,6 +111,32 @@ Denies the copy_uri_to_path command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-get-safe-area-insets`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the get_safe_area_insets command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-get-safe-area-insets`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the get_safe_area_insets command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-get-status-bar-height`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -16,4 +16,5 @@ permissions = [
|
||||
"allow-iap-purchase-product",
|
||||
"allow-iap-restore-purchases",
|
||||
"allow-get-system-color-scheme",
|
||||
"allow-get-safe-area-insets",
|
||||
]
|
||||
|
||||
+14
-2
@@ -330,6 +330,18 @@
|
||||
"const": "deny-copy-uri-to-path",
|
||||
"markdownDescription": "Denies the copy_uri_to_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_safe_area_insets command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-get-safe-area-insets",
|
||||
"markdownDescription": "Enables the get_safe_area_insets command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_safe_area_insets command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-get-safe-area-insets",
|
||||
"markdownDescription": "Denies the get_safe_area_insets command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_status_bar_height command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -475,10 +487,10 @@
|
||||
"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`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`",
|
||||
"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`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`",
|
||||
"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`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`"
|
||||
"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`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -119,3 +119,10 @@ pub(crate) async fn get_system_color_scheme<R: Runtime>(
|
||||
) -> Result<GetSystemColorSchemeResponse> {
|
||||
app.native_bridge().get_system_color_scheme()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn get_safe_area_insets<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
) -> Result<GetSafeAreaInsetsResponse> {
|
||||
app.native_bridge().get_safe_area_insets()
|
||||
}
|
||||
|
||||
@@ -102,4 +102,8 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn get_system_color_scheme(&self) -> crate::Result<GetSystemColorSchemeResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
commands::iap_purchase_product,
|
||||
commands::iap_restore_purchases,
|
||||
commands::get_system_color_scheme,
|
||||
commands::get_safe_area_insets,
|
||||
])
|
||||
.setup(|app, api| {
|
||||
#[cfg(mobile)]
|
||||
|
||||
@@ -161,3 +161,11 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn get_safe_area_insets(&self) -> crate::Result<GetSafeAreaInsetsResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("get_safe_area_insets", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,3 +158,12 @@ pub struct IAPRestorePurchasesResponse {
|
||||
pub struct GetSystemColorSchemeResponse {
|
||||
pub color_scheme: String, // "light" or "dark"
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetSafeAreaInsetsResponse {
|
||||
pub top: f64,
|
||||
pub bottom: f64,
|
||||
pub left: f64,
|
||||
pub right: f64,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ extern crate cocoa;
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
use tauri::utils::config::BackgroundThrottlingPolicy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::TitleBarStyle;
|
||||
|
||||
@@ -240,7 +241,9 @@ pub fn run() {
|
||||
eprintln!("Failed to initialize tauri_plugin_log: {e}");
|
||||
};
|
||||
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
.background_throttling(BackgroundThrottlingPolicy::Disabled)
|
||||
.background_color(tauri::window::Color(50, 49, 48, 255));
|
||||
|
||||
#[cfg(desktop)]
|
||||
let win_builder = win_builder.inner_size(800.0, 600.0).resizable(true);
|
||||
|
||||
@@ -86,11 +86,18 @@
|
||||
},
|
||||
{
|
||||
"name": "azw",
|
||||
"ext": ["azw", "azw3"],
|
||||
"ext": ["azw"],
|
||||
"description": "AZW file",
|
||||
"mimeType": "application/vnd.amazon.ebook",
|
||||
"role": "Viewer"
|
||||
},
|
||||
{
|
||||
"name": "azw3",
|
||||
"ext": ["azw3"],
|
||||
"description": "AZW3 file",
|
||||
"mimeType": "application/vnd.amazon.mobi8-ebook",
|
||||
"role": "Viewer"
|
||||
},
|
||||
{
|
||||
"name": "fb2",
|
||||
"ext": ["fb2"],
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function AuthPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { isDarkMode } = useThemeStore();
|
||||
const { isDarkMode, safeAreaInsets } = useThemeStore();
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [port, setPort] = useState<number | null>(null);
|
||||
@@ -351,10 +351,10 @@ export default function AuthPage() {
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-full w-full flex-col items-center overflow-y-auto',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
)}
|
||||
className={clsx('flex h-full w-full flex-col items-center overflow-y-auto')}
|
||||
style={{
|
||||
paddingTop: `${safeAreaInsets?.top || 0}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={headerRef}
|
||||
|
||||
@@ -37,7 +37,6 @@ export const viewport = {
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: 'cover',
|
||||
themeColor: 'white',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -45,6 +44,17 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<html>
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
const themeMode = localStorage.getItem('themeMode');
|
||||
const themeColor = localStorage.getItem('themeColor');
|
||||
if (themeMode && themeColor) {
|
||||
document.documentElement.setAttribute('data-theme', \`\${themeColor}-\${themeMode}\`);
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<meta
|
||||
name='viewport'
|
||||
content='minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, user-scalable=no, viewport-fit=cover'
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PiPlus } from 'react-icons/pi';
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -53,6 +54,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const searchParams = useSearchParams();
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
@@ -239,8 +241,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const bTitle = formatTitle(b.title);
|
||||
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
|
||||
case 'author':
|
||||
const aAuthors = formatAuthors(a.author);
|
||||
const bAuthors = formatAuthors(b.author);
|
||||
const aAuthors = formatAuthors(a.author, a?.primaryLanguage || 'en', true);
|
||||
const bAuthors = formatAuthors(b.author, b?.primaryLanguage || 'en', true);
|
||||
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
|
||||
case 'updated':
|
||||
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
@@ -346,7 +348,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
<div className='fixed bottom-0 left-0 right-0 z-40 pb-[calc(env(safe-area-inset-bottom)+16px)]'>
|
||||
<div
|
||||
className='fixed bottom-0 left-0 right-0 z-40'
|
||||
style={{
|
||||
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
|
||||
}}
|
||||
>
|
||||
{isSelectMode && showSelectModeActions && (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -425,10 +432,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
)}
|
||||
{showDeleteAlert && (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
|
||||
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
|
||||
)}
|
||||
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
|
||||
style={{
|
||||
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
title={_('Confirm Deletion')}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { Menu, MenuItem } from '@tauri-apps/api/menu';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { getLocalBookFilename } from '@/utils/book';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
@@ -192,7 +191,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const showBookInFinderMenuItem = await MenuItem.new({
|
||||
text: _(fileRevealLabel),
|
||||
action: async () => {
|
||||
const folder = `${settings.localBooksDir}/${getLocalBookFilename(book)}`;
|
||||
const folder = `${settings.localBooksDir}/${book.hash}`;
|
||||
revealItemInDir(folder);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
@@ -59,7 +58,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const iconSize20 = useResponsiveSize(20);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { safeAreaInsets: insets } = useThemeStore();
|
||||
|
||||
useShortcuts({
|
||||
onToggleSelectMode,
|
||||
@@ -95,7 +94,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [appService?.hasTrafficLight]);
|
||||
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
const isInGroupView = !!searchParams?.get('group');
|
||||
@@ -110,7 +109,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
<div
|
||||
ref={headerRef}
|
||||
className={clsx(
|
||||
'titlebar bg-base-200 z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
|
||||
'titlebar bg-base-200 z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px]',
|
||||
windowButtonVisible ? 'sm:pr-4' : 'sm:pr-6',
|
||||
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -8,7 +8,6 @@ import { TbSunMoon } from 'react-icons/tb';
|
||||
import { BiMoon, BiSun } from 'react-icons/bi';
|
||||
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { setKOSyncSettingsWindowVisible } from './KOSyncSettings';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@@ -159,11 +158,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsTelemetryEnabled(settings.telemetryEnabled);
|
||||
};
|
||||
|
||||
const showKoSyncSettingsWindow = () => {
|
||||
setKOSyncSettingsWindowVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleUpgrade = () => {
|
||||
navigateToProfile(router);
|
||||
setIsDropdownOpen?.(false);
|
||||
@@ -274,8 +268,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
onClick={cycleThemeMode}
|
||||
/>
|
||||
<hr className='border-base-200 my-1' />
|
||||
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
{user && userPlan === 'free' && !appService?.isIOSApp && (
|
||||
<MenuItem label={_('Upgrade to Readest Premium')} onClick={handleUpgrade} />
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { Book } from '@/types/book';
|
||||
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
|
||||
|
||||
export interface UseBooksSyncProps {
|
||||
onSyncStart?: () => void;
|
||||
@@ -17,12 +17,11 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
|
||||
const { appService } = useEnv();
|
||||
const { library, setLibrary } = useLibraryStore();
|
||||
const { syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
|
||||
const syncBooksPullingRef = useRef(false);
|
||||
|
||||
const pullLibrary = async () => {
|
||||
const pullLibrary = useCallback(async () => {
|
||||
if (!user) return;
|
||||
syncBooks([], 'pull');
|
||||
};
|
||||
await syncBooks([], 'pull');
|
||||
}, [user, syncBooks]);
|
||||
|
||||
const pushLibrary = async () => {
|
||||
if (!user) return;
|
||||
@@ -32,15 +31,13 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (syncBooksPullingRef.current) return;
|
||||
syncBooksPullingRef.current = true;
|
||||
|
||||
pullLibrary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [user]);
|
||||
|
||||
const getNewBooks = () => {
|
||||
if (!user) return [];
|
||||
const library = useLibraryStore.getState().library;
|
||||
const newBooks = library.filter(
|
||||
(book) => lastSyncedAtBooks < book.updatedAt || lastSyncedAtBooks < (book.deletedAt ?? 0),
|
||||
);
|
||||
@@ -53,14 +50,14 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
|
||||
const newBooks = getNewBooks();
|
||||
syncBooks(newBooks, 'both');
|
||||
}, SYNC_BOOKS_INTERVAL_SEC * 1000),
|
||||
[library, lastSyncedAtBooks],
|
||||
[syncBooks],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
handleAutoSync();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [library]);
|
||||
}, [library, handleAutoSync]);
|
||||
|
||||
const updateLibrary = async () => {
|
||||
if (!syncedBooks?.length) return;
|
||||
|
||||
@@ -10,25 +10,21 @@ import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService, DeleteAction } from '@/types/system';
|
||||
import { navigateToLogin, navigateToReader } from '@/utils/nav';
|
||||
import {
|
||||
formatAuthors,
|
||||
formatTitle,
|
||||
getFilename,
|
||||
getPrimaryLanguage,
|
||||
listFormater,
|
||||
} from '@/utils/book';
|
||||
import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { ProgressPayload } from '@/utils/transfer';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { getFilename } from '@/utils/path';
|
||||
import { parseOpenWithFiles } from '@/helpers/openWith';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
|
||||
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
|
||||
import { BOOK_ACCEPT_FORMATS } from '@/services/constants';
|
||||
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -37,9 +33,9 @@ import { useTheme } from '@/hooks/useTheme';
|
||||
import { useUICSS } from '@/hooks/useUICSS';
|
||||
import { useDemoBooks } from './hooks/useDemoBooks';
|
||||
import { useBooksSync } from './hooks/useBooksSync';
|
||||
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import {
|
||||
tauriHandleSetAlwaysOnTop,
|
||||
@@ -48,7 +44,6 @@ import {
|
||||
} from '@/utils/window';
|
||||
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { KOSyncSettingsWindow } from './components/KOSyncSettings';
|
||||
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
import { BookMetadata } from '@/libs/document';
|
||||
import { BookDetailModal } from '@/components/metadata';
|
||||
@@ -78,7 +73,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
setCheckLastOpenBooks,
|
||||
} = useLibraryStore();
|
||||
const _ = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { selectFiles } = useFileSelector(appService, _);
|
||||
const { safeAreaInsets: insets } = useThemeStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
@@ -153,12 +149,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
} else {
|
||||
fileExt = file.name.split('.').pop()?.toLowerCase();
|
||||
}
|
||||
return FILE_ACCEPT_FORMATS.includes(`.${fileExt}`);
|
||||
return BOOK_ACCEPT_FORMATS.includes(`.${fileExt}`);
|
||||
});
|
||||
if (supportedFiles.length === 0) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('No supported files found. Supported formats: {{formats}}', {
|
||||
formats: FILE_ACCEPT_FORMATS,
|
||||
formats: BOOK_ACCEPT_FORMATS,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
@@ -169,7 +165,14 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
impactFeedback('medium');
|
||||
}
|
||||
|
||||
await importBooks(supportedFiles);
|
||||
const selectedFiles = supportedFiles.map(
|
||||
(file) =>
|
||||
({
|
||||
file: typeof file === 'string' ? undefined : file,
|
||||
path: typeof file === 'string' ? file : undefined,
|
||||
}) as SelectedFile,
|
||||
);
|
||||
await importBooks(selectedFiles);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
|
||||
@@ -397,7 +400,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [demoBooks, libraryLoaded]);
|
||||
|
||||
const importBooks = async (files: (string | File)[]) => {
|
||||
const importBooks = async (files: SelectedFile[]) => {
|
||||
setLoading(true);
|
||||
const failedFiles = [];
|
||||
const errorMap: [string, string][] = [
|
||||
@@ -406,7 +409,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
['Unsupported format.', _('This book format is not supported.')],
|
||||
];
|
||||
const { library } = useLibraryStore.getState();
|
||||
for (const file of files) {
|
||||
for (const selectedFile of files) {
|
||||
const file = selectedFile.file || selectedFile.path;
|
||||
if (!file) continue;
|
||||
try {
|
||||
const book = await appService?.importBook(file, library);
|
||||
setLibrary([...library]);
|
||||
@@ -436,32 +441,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const selectFilesTauri = async () => {
|
||||
const exts = appService?.isIOSApp ? [] : SUPPORTED_FILE_EXTS;
|
||||
const files = (await appService?.selectFiles(_('Select Books'), exts)) || [];
|
||||
if (appService?.isIOSApp) {
|
||||
return files.filter((file) => {
|
||||
const fileExt = file.split('.').pop()?.toLowerCase() || 'unknown';
|
||||
return SUPPORTED_FILE_EXTS.includes(fileExt);
|
||||
});
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const selectFilesWeb = () => {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = FILE_ACCEPT_FORMATS;
|
||||
fileInput.multiple = true;
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = () => {
|
||||
resolve(fileInput.files);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateBookTransferProgress = throttle((bookHash: string, progress: ProgressPayload) => {
|
||||
if (progress.total === 0) return;
|
||||
const progressPct = (progress.progress / progress.total) * 100;
|
||||
@@ -610,14 +589,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const handleImportBooks = async () => {
|
||||
setIsSelectMode(false);
|
||||
console.log('Importing books...');
|
||||
let files;
|
||||
|
||||
if (isTauriAppPlatform()) {
|
||||
files = (await selectFilesTauri()) as string[];
|
||||
} else {
|
||||
files = (await selectFilesWeb()) as File[];
|
||||
}
|
||||
importBooks(files);
|
||||
selectFiles({ type: 'books', multiple: true }).then((result) => {
|
||||
if (result.files.length === 0 || result.error) return;
|
||||
importBooks(result.files);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetSelectMode = (selectMode: boolean) => {
|
||||
@@ -643,19 +618,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
setShowDetailsBook(book);
|
||||
};
|
||||
|
||||
if (!appService || !insets) {
|
||||
return null;
|
||||
if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) {
|
||||
return <div className='bg-base-200 h-[100vh]' />;
|
||||
}
|
||||
|
||||
if (checkOpenWithBooks || checkLastOpenBooks) {
|
||||
return (
|
||||
loading && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -682,11 +649,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
{libraryLoaded &&
|
||||
{showBookshelf &&
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<OverlayScrollbarsComponent
|
||||
defer
|
||||
ref={osRef}
|
||||
className='flex-grow'
|
||||
options={{ scrollbars: { autoHide: 'scroll' } }}
|
||||
events={{
|
||||
initialized: (instance) => {
|
||||
@@ -754,7 +722,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
/>
|
||||
)}
|
||||
<AboutWindow />
|
||||
<KOSyncSettingsWindow />
|
||||
<UpdaterWindow />
|
||||
<Toast />
|
||||
</div>
|
||||
@@ -763,13 +730,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
|
||||
const LibraryPage = () => {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<div className='bg-base-200 h-[100vh]' />}>
|
||||
<LibraryPageWithSearchParams />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import LibraryPage from './library/page';
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/library');
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
return <LibraryPage />;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import clsx from 'clsx';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
|
||||
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
|
||||
import { getViewInsets } from '@/utils/insets';
|
||||
import FoliateViewer from './FoliateViewer';
|
||||
@@ -34,7 +34,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
|
||||
const screenInsets = useSafeAreaInsets();
|
||||
const { safeAreaInsets: screenInsets } = useThemeStore();
|
||||
const aspectRatio = window.innerWidth / window.innerHeight;
|
||||
const gridTemplate = getGridTemplate(bookKeys.length, aspectRatio);
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useCustomFontStore } from '@/store/customFontStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
@@ -21,7 +23,7 @@ import {
|
||||
getStyles,
|
||||
transformStylesheet,
|
||||
} from '@/utils/style';
|
||||
import { mountAdditionalFonts } from '@/utils/font';
|
||||
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
|
||||
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
|
||||
import { useUICSS } from '@/hooks/useUICSS';
|
||||
import {
|
||||
@@ -43,6 +45,7 @@ import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { useTextTranslation } from '../hooks/useTextTranslation';
|
||||
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
|
||||
import { getViewInsets } from '@/utils/insets';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import ConfirmSyncDialog from './ConfirmSyncDialog';
|
||||
|
||||
declare global {
|
||||
@@ -57,12 +60,14 @@ const FoliateViewer: React.FC<{
|
||||
config: BookConfig;
|
||||
contentInsets: Insets;
|
||||
}> = ({ bookKey, bookDoc, config, contentInsets: insets }) => {
|
||||
const { appService, envConfig } = useEnv();
|
||||
const { themeCode, isDarkMode } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { loadCustomFonts, getLoadedFonts } = useCustomFontStore();
|
||||
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { appService } = useEnv();
|
||||
const { themeCode, isDarkMode } = useThemeStore();
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
|
||||
const viewRef = useRef<FoliateView | null>(null);
|
||||
@@ -70,6 +75,8 @@ const FoliateViewer: React.FC<{
|
||||
const isViewCreated = useRef(false);
|
||||
const doubleClickDisabled = useRef(!!viewSettings?.disableDoubleClick);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const docLoaded = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setToastMessage(''), 2000);
|
||||
@@ -79,12 +86,8 @@ const FoliateViewer: React.FC<{
|
||||
useUICSS(bookKey);
|
||||
useProgressSync(bookKey);
|
||||
useProgressAutoSave(bookKey);
|
||||
const {
|
||||
syncState,
|
||||
conflictDetails,
|
||||
resolveConflictWithLocal,
|
||||
resolveConflictWithRemote,
|
||||
} = useKOSync(bookKey);
|
||||
const { syncState, conflictDetails, resolveConflictWithLocal, resolveConflictWithRemote } =
|
||||
useKOSync(bookKey);
|
||||
useTextTranslation(bookKey, viewRef.current);
|
||||
|
||||
const progressRelocateHandler = (event: Event) => {
|
||||
@@ -127,6 +130,8 @@ const FoliateViewer: React.FC<{
|
||||
};
|
||||
|
||||
const docLoadHandler = (event: Event) => {
|
||||
setLoading(false);
|
||||
docLoaded.current = true;
|
||||
const detail = (event as CustomEvent).detail;
|
||||
console.log('doc index loaded:', detail.index);
|
||||
if (detail.doc) {
|
||||
@@ -144,6 +149,10 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
|
||||
|
||||
getLoadedFonts().forEach((font) => {
|
||||
mountCustomFont(detail.doc, font);
|
||||
});
|
||||
|
||||
if (bookDoc.rendition?.layout === 'pre-paginated') {
|
||||
applyFixedlayoutStyles(detail.doc, viewSettings);
|
||||
}
|
||||
@@ -224,6 +233,8 @@ const FoliateViewer: React.FC<{
|
||||
if (isViewCreated.current) return;
|
||||
isViewCreated.current = true;
|
||||
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
|
||||
const openBook = async () => {
|
||||
console.log('Opening book', bookKey);
|
||||
await import('foliate-js/view.js');
|
||||
@@ -335,6 +346,21 @@ const FoliateViewer: React.FC<{
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [themeCode, isDarkMode, viewSettings?.overrideColor, viewSettings?.invertImgColorInDark]);
|
||||
|
||||
useEffect(() => {
|
||||
const mountCustomFonts = async () => {
|
||||
await loadCustomFonts(envConfig);
|
||||
getLoadedFonts().forEach((font) => {
|
||||
mountCustomFont(document, font);
|
||||
const docs = viewRef.current?.renderer.getContents();
|
||||
docs?.forEach(({ doc }) => mountCustomFont(doc, font));
|
||||
});
|
||||
};
|
||||
if (settings.customFonts) {
|
||||
mountCustomFonts();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings.customFonts, envConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && viewRef.current.renderer) {
|
||||
doubleClickDisabled.current = !!viewSettings?.disableDoubleClick;
|
||||
@@ -364,6 +390,7 @@ const FoliateViewer: React.FC<{
|
||||
{...mouseHandlers}
|
||||
{...touchHandlers}
|
||||
/>
|
||||
{!docLoaded.current && loading && <Spinner loading={true} />}
|
||||
{syncState === 'conflict' && conflictDetails && (
|
||||
<ConfirmSyncDialog
|
||||
details={conflictDetails}
|
||||
|
||||
@@ -187,7 +187,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
'sm:h-[52px] sm:justify-center',
|
||||
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
|
||||
'transition-[opacity,transform] duration-300',
|
||||
appService?.isMobile ? 'fixed' : 'absolute',
|
||||
// See: https://github.com/readest/readest/issues/1716
|
||||
appService?.isAndroidApp && window.innerWidth < 640 ? 'fixed' : 'absolute',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
@@ -366,7 +367,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
</span>
|
||||
<input
|
||||
type='range'
|
||||
className='text-base-content mx-2 w-full'
|
||||
className='text-base-content mx-2 min-w-0 flex-1'
|
||||
min={0}
|
||||
max={100}
|
||||
value={progressValid ? progressFraction * 100 : 0}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { getFootnoteStyles, getStyles, getThemeCode } from '@/utils/style';
|
||||
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
|
||||
import { FootnoteHandler } from 'foliate-js/footnotes.js';
|
||||
import { mountAdditionalFonts } from '@/utils/font';
|
||||
import { mountAdditionalFonts } from '@/styles/fonts';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { FoliateView } from '@/types/view';
|
||||
import { isCJKLang } from '@/utils/lang';
|
||||
|
||||
@@ -40,6 +40,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
const { appService } = useEnv();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
trafficLightInFullscreen,
|
||||
setTrafficLightVisibility,
|
||||
initializeTrafficLightStore,
|
||||
@@ -52,6 +53,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
|
||||
const handleToggleDropdown = (isOpen: boolean) => {
|
||||
setIsDropdownOpen(isOpen);
|
||||
if (!isOpen) setHoveredBookKey('');
|
||||
@@ -133,9 +136,19 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
<TranslationToggler bookKey={bookKey} />
|
||||
</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}
|
||||
<div
|
||||
className={clsx(
|
||||
'header-title z-15 bg-base-100 pointer-events-none hidden flex-1 items-center justify-center sm:flex',
|
||||
!windowButtonVisible && 'absolute inset-0',
|
||||
)}
|
||||
>
|
||||
<h2
|
||||
className={clsx(
|
||||
'line-clamp-1 text-center text-xs font-semibold',
|
||||
!windowButtonVisible && 'max-w-[50%]',
|
||||
)}
|
||||
>
|
||||
{bookTitle} {bookTitle} {bookTitle} {bookTitle}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -154,16 +167,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
<WindowButtons
|
||||
className='window-buttons flex h-full items-center'
|
||||
headerRef={headerRef}
|
||||
showMinimize={
|
||||
bookKeys.length == 1 &&
|
||||
!appService?.hasTrafficLight &&
|
||||
appService?.appPlatform !== 'web'
|
||||
}
|
||||
showMaximize={
|
||||
bookKeys.length == 1 &&
|
||||
!appService?.hasTrafficLight &&
|
||||
appService?.appPlatform !== 'web'
|
||||
}
|
||||
showMinimize={bookKeys.length == 1 && windowButtonVisible}
|
||||
showMaximize={bookKeys.length == 1 && windowButtonVisible}
|
||||
onClose={() => {
|
||||
setHoveredBookKey(null);
|
||||
onCloseBook(bookKey);
|
||||
|
||||
+83
-124
@@ -1,21 +1,21 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { md5 } from 'js-md5';
|
||||
import clsx from 'clsx';
|
||||
import { type as osType } from '@tauri-apps/plugin-os';
|
||||
import Dialog from '@/components/Dialog';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { KOSyncClient } from '@/services/sync/KOSyncClient';
|
||||
import { KoreaderSyncChecksumMethod, KoreaderSyncStrategy } from '@/types/settings';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { KOSyncChecksumMethod, KOSyncStrategy } from '@/types/settings';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type SelectProps = {
|
||||
@@ -43,8 +43,8 @@ const StyledSelect: React.FC<SelectProps> = ({
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{options.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{options.map(({ value, label, disabled = false }) => (
|
||||
<option key={value} value={value} disabled={disabled}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
@@ -68,21 +68,23 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [url, setUrl] = useState(settings.koreaderSyncServerUrl || '');
|
||||
const [username, setUsername] = useState(settings.koreaderSyncUsername || '');
|
||||
const [url, setUrl] = useState(settings.kosync.serverUrl || '');
|
||||
const [username, setUsername] = useState(settings.kosync.username || '');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState('');
|
||||
const [deviceName, setDeviceName] = useState('');
|
||||
const [osName, setOsName] = useState('');
|
||||
|
||||
const [toleranceSliderValue, setToleranceSliderValue] = useState(() => {
|
||||
const tolerance = settings.koreaderSyncPercentageTolerance;
|
||||
return tolerance && tolerance > 0 ? Math.round(-Math.log10(tolerance)) : 4;
|
||||
});
|
||||
|
||||
// Get the OS name once
|
||||
useEffect(() => {
|
||||
const formatOsName = (name: string): string => {
|
||||
if (!name) return '';
|
||||
if (name.toLowerCase() === 'macos') return 'macOS';
|
||||
if (name.toLowerCase() === 'ios') return 'iOS';
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
};
|
||||
|
||||
const getOsName = async () => {
|
||||
let name = '';
|
||||
if (appService?.appPlatform === 'tauri') {
|
||||
@@ -93,20 +95,17 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
name = platform;
|
||||
}
|
||||
}
|
||||
setOsName(name ? name.charAt(0).toUpperCase() + name.slice(1) : '');
|
||||
setOsName(formatOsName(name));
|
||||
};
|
||||
getOsName();
|
||||
}, [appService]);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultName = osName ? `Readest (${osName})` : 'Readest';
|
||||
setDeviceName(settings.koreaderSyncDeviceName || defaultName);
|
||||
}, [settings.koreaderSyncDeviceName, osName]);
|
||||
setDeviceName(settings.kosync.deviceName || defaultName);
|
||||
}, [settings.kosync.deviceName, osName]);
|
||||
|
||||
const isConfigured = useMemo(
|
||||
() => !!settings.koreaderSyncUserkey,
|
||||
[settings.koreaderSyncUserkey],
|
||||
);
|
||||
const isConfigured = useMemo(() => !!settings.kosync.userkey, [settings.kosync.userkey]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedSaveDeviceName = useCallback(
|
||||
@@ -128,15 +127,10 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
const handleCustomEvent = (event: CustomEvent) => {
|
||||
setIsOpen(event.detail.visible);
|
||||
if (event.detail.visible) {
|
||||
setUrl(settings.koreaderSyncServerUrl || '');
|
||||
setUsername(settings.koreaderSyncUsername || '');
|
||||
setUrl(settings.kosync.serverUrl || '');
|
||||
setUsername(settings.kosync.username || '');
|
||||
setPassword('');
|
||||
setConnectionStatus('');
|
||||
// Sync the slider with the current settings when opening
|
||||
const tolerance = settings.koreaderSyncPercentageTolerance;
|
||||
setToleranceSliderValue(
|
||||
tolerance && tolerance > 0 ? Math.round(-Math.log10(tolerance)) : 4,
|
||||
);
|
||||
}
|
||||
};
|
||||
const el = document.getElementById('kosync_settings_window');
|
||||
@@ -144,40 +138,26 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
return () => {
|
||||
el?.removeEventListener('setKOSyncSettingsVisibility', handleCustomEvent as EventListener);
|
||||
};
|
||||
}, [
|
||||
settings.koreaderSyncServerUrl,
|
||||
settings.koreaderSyncUsername,
|
||||
settings.koreaderSyncPercentageTolerance,
|
||||
]);
|
||||
}, [settings.kosync.serverUrl, settings.kosync.username]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setIsConnecting(true);
|
||||
|
||||
let deviceId = settings.koreaderSyncDeviceId;
|
||||
if (!deviceId) {
|
||||
deviceId = uuidv4().replace(/-/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
const client = new KOSyncClient(
|
||||
url,
|
||||
const config = {
|
||||
...settings.kosync,
|
||||
serverUrl: url,
|
||||
username,
|
||||
md5(password),
|
||||
settings.koreaderSyncChecksumMethod,
|
||||
deviceId,
|
||||
userkey: md5(password),
|
||||
deviceName,
|
||||
);
|
||||
enabled: true,
|
||||
};
|
||||
const client = new KOSyncClient(config);
|
||||
const result = await client.connect(username, password);
|
||||
|
||||
if (result.success) {
|
||||
const newSettings = {
|
||||
...settings,
|
||||
koreaderSyncServerUrl: url,
|
||||
koreaderSyncUsername: username,
|
||||
koreaderSyncUserkey: md5(password),
|
||||
koreaderSyncDeviceId: deviceId,
|
||||
koreaderSyncDeviceName: deviceName,
|
||||
koreaderSyncStrategy:
|
||||
settings.koreaderSyncStrategy === 'disabled' ? 'prompt' : settings.koreaderSyncStrategy,
|
||||
kosync: config,
|
||||
};
|
||||
setSettings(newSettings);
|
||||
await saveSettings(envConfig, newSettings);
|
||||
@@ -193,11 +173,12 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
const newSettings = {
|
||||
...settings,
|
||||
koreaderSyncStrategy: 'disabled' as KoreaderSyncStrategy,
|
||||
koreaderSyncUserkey: '',
|
||||
const kosync = {
|
||||
...settings.kosync,
|
||||
userkey: '',
|
||||
enabled: false,
|
||||
};
|
||||
const newSettings = { ...settings, kosync };
|
||||
setSettings(newSettings);
|
||||
await saveSettings(envConfig, newSettings);
|
||||
setUsername('');
|
||||
@@ -205,26 +186,23 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleStrategyChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newStrategy = e.target.value as KoreaderSyncStrategy;
|
||||
const newSettings = { ...settings, koreaderSyncStrategy: newStrategy };
|
||||
const kosync = {
|
||||
...settings.kosync,
|
||||
strategy: e.target.value as KOSyncStrategy,
|
||||
};
|
||||
|
||||
const newSettings = { ...settings, kosync };
|
||||
setSettings(newSettings);
|
||||
await saveSettings(envConfig, newSettings);
|
||||
};
|
||||
|
||||
const handleChecksumMethodChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newMethod = e.target.value as KoreaderSyncChecksumMethod;
|
||||
const newSettings = { ...settings, koreaderSyncChecksumMethod: newMethod };
|
||||
setSettings(newSettings);
|
||||
await saveSettings(envConfig, newSettings);
|
||||
};
|
||||
const kosync = {
|
||||
...settings.kosync,
|
||||
checksumMethod: e.target.value as KOSyncChecksumMethod,
|
||||
};
|
||||
|
||||
const handleToleranceChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const sliderValue = parseInt(e.target.value, 10);
|
||||
setToleranceSliderValue(sliderValue);
|
||||
// Calculate the actual tolerance from the slider value (e.g., 4 -> 0.0001)
|
||||
const newTolerance = Math.pow(10, -sliderValue);
|
||||
|
||||
const newSettings = { ...settings, koreaderSyncPercentageTolerance: newTolerance };
|
||||
const newSettings = { ...settings, kosync };
|
||||
setSettings(newSettings);
|
||||
await saveSettings(envConfig, newSettings);
|
||||
};
|
||||
@@ -236,7 +214,6 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={_('KOReader Sync Settings')}
|
||||
boxClassName='sm:!min-w-[520px] sm:h-auto'
|
||||
bgClassName='!bg-black/60'
|
||||
>
|
||||
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
|
||||
{isConfigured ? (
|
||||
@@ -244,18 +221,16 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
<div className='text-center'>
|
||||
<p className='text-base-content/80 text-sm'>
|
||||
{_('Sync as {{userDisplayName}}', {
|
||||
userDisplayName: settings.koreaderSyncUsername,
|
||||
userDisplayName: settings.kosync.username,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex h-14 items-center justify-between'>
|
||||
<span className='text-base-content/80'>
|
||||
{_('Sync Server Connected', { username: settings.koreaderSyncUsername })}
|
||||
</span>
|
||||
<span className='text-base-content/80'>{_('Sync Server Connected')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={settings.koreaderSyncStrategy !== 'disabled'}
|
||||
checked={settings.kosync.enabled}
|
||||
onChange={() => handleDisconnect()}
|
||||
/>
|
||||
</div>
|
||||
@@ -264,14 +239,13 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
<span className='label-text font-medium'>{_('Sync Strategy')}</span>
|
||||
</label>
|
||||
<StyledSelect
|
||||
value={settings.koreaderSyncStrategy}
|
||||
value={settings.kosync.strategy}
|
||||
onChange={handleStrategyChange}
|
||||
options={[
|
||||
{ value: 'prompt', label: _('Ask on conflict') },
|
||||
{ value: 'silent', label: _('Always use latest') },
|
||||
{ value: 'send', label: _('Send changes only') },
|
||||
{ value: 'receive', label: _('Receive changes only') },
|
||||
{ value: 'disable', label: _('Disabled') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -280,11 +254,11 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
<span className='label-text font-medium'>{_('Checksum Method')}</span>
|
||||
</label>
|
||||
<StyledSelect
|
||||
value={settings.koreaderSyncChecksumMethod}
|
||||
value={settings.kosync.checksumMethod}
|
||||
onChange={handleChecksumMethodChange}
|
||||
options={[
|
||||
{ value: 'binary', label: _('File Content (recommended)') },
|
||||
{ value: 'filename', label: _('File Name') },
|
||||
{ value: 'filename', label: _('File Name'), disabled: true },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -300,27 +274,6 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
onChange={handleDeviceNameChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Hidden to avoid confusing users with technical details */}
|
||||
{false && (
|
||||
<div className='form-control w-full'>
|
||||
<label className='label py-1'>
|
||||
<span className='label-text font-medium'>{_('Sync Tolerance')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='range'
|
||||
min='0'
|
||||
max='15'
|
||||
value={toleranceSliderValue}
|
||||
onChange={handleToleranceChange}
|
||||
className='range range-primary'
|
||||
/>
|
||||
<div className='text-base-content/70 mt-2 text-center text-xs'>
|
||||
{_('Precision: {{precision}} digits after the decimal', {
|
||||
precision: toleranceSliderValue,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -335,34 +288,40 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
type='text'
|
||||
placeholder='https://koreader.sync.server'
|
||||
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
|
||||
spellCheck='false'
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label py-1'>
|
||||
<span className='label-text font-medium'>{_('Username')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={_('Your Username')}
|
||||
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label py-1'>
|
||||
<span className='label-text font-medium'>{_('Password')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder={_('Your Password')}
|
||||
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<form className='flex flex-col gap-4'>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label py-1'>
|
||||
<span className='label-text font-medium'>{_('Username')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={_('Your Username')}
|
||||
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
|
||||
spellCheck='false'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete='username'
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label py-1'>
|
||||
<span className='label-text font-medium'>{_('Password')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder={_('Your Password')}
|
||||
className='input input-bordered h-12 w-full focus:outline-none focus:ring-0'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete='current-password'
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<button
|
||||
className='btn btn-primary mt-2 h-12 min-h-12 w-full'
|
||||
onClick={handleConnect}
|
||||
@@ -16,11 +16,12 @@ import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { interceptWindowOpen } from '@/utils/open';
|
||||
import { mountAdditionalFonts } from '@/utils/font';
|
||||
import { mountAdditionalFonts } from '@/styles/fonts';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
import { KOSyncSettingsWindow } from './KOSyncSettings';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import { initDayjs } from '@/utils/time';
|
||||
@@ -106,24 +107,25 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hoveredBookKey]);
|
||||
|
||||
return (
|
||||
libraryLoaded &&
|
||||
settings.globalReadSettings && (
|
||||
<div
|
||||
className={clsx(
|
||||
`reader-page text-base-content select-none overflow-hidden`,
|
||||
appService?.isLinuxApp && 'window-border',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
<Suspense>
|
||||
<ReaderContent ids={ids} settings={settings} />
|
||||
<AboutWindow />
|
||||
<UpdaterWindow />
|
||||
<Toast />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
return libraryLoaded && settings.globalReadSettings ? (
|
||||
<div
|
||||
className={clsx(
|
||||
`reader-page bg-base-100 text-base-content select-none overflow-hidden`,
|
||||
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
|
||||
appService?.isLinuxApp && 'window-border',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<div className='h-[100vh]'></div>}>
|
||||
<ReaderContent ids={ids} settings={settings} />
|
||||
<AboutWindow />
|
||||
<UpdaterWindow />
|
||||
<KOSyncSettingsWindow />
|
||||
<Toast />
|
||||
</Suspense>
|
||||
</div>
|
||||
) : (
|
||||
<div className='bg-base-100 h-[100vh]'></div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
if (isPrimary && book && config) {
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
eventDispatcher.dispatch('sync-book-progress', { bookKey });
|
||||
eventDispatcher.dispatch('flush-koreader-sync', { bookKey });
|
||||
eventDispatcher.dispatch('flush-kosync', { bookKey });
|
||||
await saveConfig(envConfig, bookKey, config, settings);
|
||||
}
|
||||
};
|
||||
@@ -142,7 +142,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
|
||||
const handleCloseBooksToLibrary = () => {
|
||||
handleCloseBooks();
|
||||
navigateToLibrary(router);
|
||||
if (isTauriAppPlatform()) {
|
||||
const currentWindow = getCurrentWindow();
|
||||
if (currentWindow.label === 'main') {
|
||||
navigateToLibrary(router);
|
||||
} else {
|
||||
currentWindow.close();
|
||||
}
|
||||
} else {
|
||||
navigateToLibrary(router);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseBook = async (bookKey: string) => {
|
||||
@@ -170,7 +179,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
const bookData = getBookData(bookKeys[0]!);
|
||||
const viewSettings = getViewSettings(bookKeys[0]!);
|
||||
if (!bookData || !bookData.book || !bookData.bookDoc || !viewSettings) {
|
||||
setTimeout(() => setLoading(true), 300);
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
return (
|
||||
loading && (
|
||||
<div className={clsx('hero hero-content', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
|
||||
interface RibbonProps {
|
||||
width: string;
|
||||
}
|
||||
|
||||
const Ribbon: React.FC<RibbonProps> = ({}) => {
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'ribbon absolute inset-0 z-10 flex w-8 justify-center sm:w-6',
|
||||
'h-[calc(env(safe-area-inset-top)+44px)]',
|
||||
)}
|
||||
className={clsx('ribbon absolute inset-0 z-10 flex w-8 justify-center sm:w-6')}
|
||||
style={{
|
||||
height: `${(safeAreaInsets?.top || 0) + 44}px`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width='100%'
|
||||
|
||||
@@ -129,7 +129,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
|
||||
fetchTranslation();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [text, token, sourceLang, targetLang, provider]);
|
||||
}, [text, token, sourceLang, targetLang, provider, translate]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -176,9 +176,12 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
className='bg-gray-600 text-white/75'
|
||||
value={targetLang}
|
||||
onChange={handleTargetLangChange}
|
||||
options={Object.entries(translatorLangs)
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([code, name]) => ({ value: code, label: name }))}
|
||||
options={[
|
||||
{ value: '', label: _('System Language') },
|
||||
...Object.entries(translatorLangs)
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([code, name]) => ({ value: code, label: name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
|
||||
@@ -25,7 +25,7 @@ const MAX_NOTEBOOK_WIDTH = 0.45;
|
||||
|
||||
const Notebook: React.FC = ({}) => {
|
||||
const _ = useTranslation();
|
||||
const { updateAppTheme } = useThemeStore();
|
||||
const { updateAppTheme, safeAreaInsets } = useThemeStore();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
@@ -196,7 +196,6 @@ const Notebook: React.FC = ({}) => {
|
||||
'notebook-container bg-base-200 right-0 z-20 flex min-w-60 select-none flex-col',
|
||||
'font-sans text-base font-normal sm:text-sm',
|
||||
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
|
||||
!isNotebookPinned && 'shadow-2xl',
|
||||
)}
|
||||
@@ -205,6 +204,7 @@ const Notebook: React.FC = ({}) => {
|
||||
width: `${notebookWidth}`,
|
||||
maxWidth: `${MAX_NOTEBOOK_WIDTH * 100}%`,
|
||||
position: isNotebookPinned ? 'relative' : 'absolute',
|
||||
paddingTop: `${safeAreaInsets?.top || 0}px`,
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
|
||||
@@ -187,7 +187,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className=''>{_('Override Book Color')}</h2>
|
||||
<h2 className='font-medium'>{_('Override Book Color')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
@@ -264,7 +264,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
onChange={(event) => setCodeLanguage(event.target.value as CodeLanguage)}
|
||||
options={CODE_LANGUAGES.map((lang) => ({
|
||||
value: lang,
|
||||
label: lang,
|
||||
label: lang === 'auto-detect' ? _('Auto') : lang,
|
||||
}))}
|
||||
disabled={!codeHighlighting}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { MdAdd, MdDelete } from 'react-icons/md';
|
||||
import { IoMdCloseCircleOutline } from 'react-icons/io';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCustomFontStore } from '@/store/customFontStore';
|
||||
import { useFileSelector } from '@/hooks/useFileSelector';
|
||||
import { mountCustomFont } from '@/styles/fonts';
|
||||
import { parseFontName } from '@/utils/font';
|
||||
import { getFilename } from '@/utils/path';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
|
||||
interface CustomFontsProps {
|
||||
bookKey: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService, envConfig } = useEnv();
|
||||
const {
|
||||
fonts: customFonts,
|
||||
addFont,
|
||||
loadFont,
|
||||
removeFont,
|
||||
getAvailableFonts,
|
||||
saveCustomFonts,
|
||||
} = useCustomFontStore();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const [isDeleteMode, setIsDeleteMode] = useState(false);
|
||||
|
||||
const { selectFiles } = useFileSelector(appService, _);
|
||||
|
||||
const currentDefaultFont =
|
||||
viewSettings.defaultFont.toLowerCase() === 'serif' ? 'serif' : 'sans-serif';
|
||||
|
||||
const currentFontFamily =
|
||||
currentDefaultFont === 'serif' ? viewSettings.serifFont : viewSettings.sansSerifFont;
|
||||
|
||||
const handleImportFont = () => {
|
||||
selectFiles({ type: 'fonts', multiple: true }).then(async (result) => {
|
||||
if (result.error || result.files.length === 0) return;
|
||||
if (!(await appService!.fs.exists('', 'Fonts'))) {
|
||||
await appService!.fs.createDir('', 'Fonts');
|
||||
}
|
||||
for (const selectedFile of result.files) {
|
||||
let fontPath: string;
|
||||
let fontFile: File;
|
||||
if (selectedFile.path) {
|
||||
const filePath = selectedFile.path;
|
||||
fontPath = getFilename(filePath);
|
||||
await appService!.fs.copyFile(filePath, fontPath, 'Fonts');
|
||||
fontFile = await appService!.fs.openFile(fontPath, 'Fonts');
|
||||
} else if (selectedFile.file) {
|
||||
const file = selectedFile.file;
|
||||
fontPath = getFilename(file.name);
|
||||
await appService!.fs.writeFile(fontPath, 'Fonts', file);
|
||||
fontFile = file;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
const fontName = parseFontName(await fontFile.arrayBuffer(), fontPath);
|
||||
const customFont = addFont(fontPath, {
|
||||
name: fontName.name,
|
||||
family: fontName.family,
|
||||
style: fontName.style,
|
||||
});
|
||||
if (customFont && !customFont.error) {
|
||||
const loadedFont = await loadFont(envConfig, customFont.id);
|
||||
mountCustomFont(document, loadedFont);
|
||||
}
|
||||
}
|
||||
saveCustomFonts(envConfig);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteFont = (fontId: string) => {
|
||||
const font = customFonts.find((f) => f.id === fontId);
|
||||
if (font) {
|
||||
if (removeFont(fontId)) {
|
||||
appService!.fs.removeFile(font.path, 'Fonts');
|
||||
saveCustomFonts(envConfig);
|
||||
if (getAvailableFonts().length === 0) {
|
||||
setIsDeleteMode(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFont = (fontId: string) => {
|
||||
const font = customFonts.find((f) => f.id === fontId);
|
||||
if (font) {
|
||||
if (currentDefaultFont === 'serif') {
|
||||
saveViewSettings(envConfig, bookKey, 'serifFont', font.name);
|
||||
} else {
|
||||
saveViewSettings(envConfig, bookKey, 'sansSerifFont', font.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeleteMode = () => {
|
||||
setIsDeleteMode(!isDeleteMode);
|
||||
};
|
||||
|
||||
const availableFonts = customFonts
|
||||
.filter((font) => !font.deletedAt)
|
||||
.sort((a, b) => (b.downloadedAt || 0) - (a.downloadedAt || 0));
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-6 flex h-8 items-center justify-between'>
|
||||
<div className='breadcrumbs py-1'>
|
||||
<ul>
|
||||
<li>
|
||||
<a className='font-semibold' onClick={onBack}>
|
||||
{_('Font')}
|
||||
</a>
|
||||
</li>
|
||||
<li className='font-medium'>{_('Custom Fonts')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{availableFonts.length > 0 && (
|
||||
<button
|
||||
onClick={toggleDeleteMode}
|
||||
className={`btn btn-ghost btn-sm text-base-content gap-2`}
|
||||
title={isDeleteMode ? _('Cancel Delete') : _('Delete Font')}
|
||||
>
|
||||
{isDeleteMode ? (
|
||||
<>{_('Cancel')}</>
|
||||
) : (
|
||||
<>
|
||||
<MdDelete className='h-4 w-4' />
|
||||
{_('Delete')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='card border-primary/50 hover:border-primary/75 group h-12 border-2 transition-colors'>
|
||||
<div
|
||||
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
|
||||
onClick={handleImportFont}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex items-center justify-center'>
|
||||
<MdAdd className='text-primary/85 group-hover:text-primary h-6 w-6' />
|
||||
</div>
|
||||
<div className='text-primary/85 group-hover:text-primary line-clamp-1 font-medium'>
|
||||
{_('Import Font')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableFonts.map((font) => (
|
||||
<div
|
||||
key={font.id}
|
||||
className={clsx(
|
||||
'card h-12 border shadow-sm',
|
||||
currentFontFamily === font.name
|
||||
? 'border-primary/50 bg-primary/50'
|
||||
: `border-base-200 bg-base-200 ${isDeleteMode ? '' : 'cursor-pointer'}`,
|
||||
)}
|
||||
onClick={!isDeleteMode ? () => handleSelectFont(font.id) : undefined}
|
||||
title={font.name}
|
||||
>
|
||||
<div className='card-body flex items-center justify-center p-2'>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: font.loaded ? `"${font.name}", sans-serif` : 'sans-serif',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
className='text-base-content line-clamp-1 break-all'
|
||||
>
|
||||
{font.family || font.name}
|
||||
</div>
|
||||
{isDeleteMode && (
|
||||
<button
|
||||
onClick={() => handleDeleteFont(font.id)}
|
||||
className='btn btn-ghost btn-xs absolute right-[-10px] top-[-10px] h-6 min-h-0 w-6 p-0 hover:bg-transparent'
|
||||
title={_('Delete Font')}
|
||||
>
|
||||
<IoMdCloseCircleOutline className='text-base-content/75 h-6 w-6' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='bg-base-200/30 my-8 rounded-lg p-4'>
|
||||
<div className='text-base-content/70 text-sm sm:text-xs'>
|
||||
<div className='mb-1 indent-2 font-medium'>{_('Tips')}:</div>
|
||||
<ul className='list-outside list-disc space-y-1 ps-2'>
|
||||
<li>{_('Supported font formats: .ttf, .otf, .woff, .woff2')}</li>
|
||||
<li>{_('Custom fonts can be selected from the Font Face menu')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomFonts;
|
||||
@@ -14,10 +14,16 @@ interface DialogMenuProps {
|
||||
resetLabel?: string;
|
||||
}
|
||||
|
||||
const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, resetLabel }) => {
|
||||
const DialogMenu: React.FC<DialogMenuProps> = ({
|
||||
activePanel,
|
||||
setIsDropdownOpen,
|
||||
onReset,
|
||||
resetLabel,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const iconSize = useResponsiveSize(16);
|
||||
const { isFontLayoutSettingsGlobal, setFontLayoutSettingsGlobal } = useSettingsStore();
|
||||
const { setFontPanelView, isFontLayoutSettingsGlobal, setFontLayoutSettingsGlobal } =
|
||||
useSettingsStore();
|
||||
|
||||
const handleToggleGlobal = () => {
|
||||
setFontLayoutSettingsGlobal(!isFontLayoutSettingsGlobal);
|
||||
@@ -29,6 +35,11 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, res
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleManageCustomFont = () => {
|
||||
setFontPanelView('custom-fonts');
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
@@ -49,6 +60,9 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ setIsDropdownOpen, onReset, res
|
||||
onClick={handleToggleGlobal}
|
||||
/>
|
||||
<MenuItem label={resetLabel || _('Reset Settings')} onClick={handleResetToDefaults} />
|
||||
{activePanel === 'Font' && (
|
||||
<MenuItem label={_('Manage Custom Fonts')} onClick={handleManageCustomFont} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,19 +34,18 @@ const FontItem: React.FC<FontItemProps> = ({ index, style, data }) => {
|
||||
const option = options[index]!;
|
||||
|
||||
return (
|
||||
<li
|
||||
className='px-1 sm:px-2'
|
||||
key={option.option}
|
||||
style={style}
|
||||
onClick={() => onSelect(option.option)}
|
||||
>
|
||||
<li className='px-2' key={option.option} style={style} onClick={() => onSelect(option.option)}>
|
||||
<div className='flex w-full items-center overflow-hidden !px-0 text-sm'>
|
||||
<span style={{ minWidth: `${iconSize16}px` }}>
|
||||
{selected === option.option && (
|
||||
<MdCheck className='text-base-content' size={iconSize16} />
|
||||
)}
|
||||
</span>
|
||||
<span style={{ fontFamily: onGetFontFamily(option.option, family) }}>
|
||||
<span
|
||||
className='line-clamp-1 overflow-visible break-all leading-loose'
|
||||
style={{ fontFamily: onGetFontFamily(option.option, family) }}
|
||||
title={option.label || option.option}
|
||||
>
|
||||
{option.label || option.option}
|
||||
</span>
|
||||
</div>
|
||||
@@ -105,7 +104,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
||||
>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<span
|
||||
className='text-ellipsis'
|
||||
className='line-clamp-1 overflow-visible break-all leading-loose'
|
||||
style={{
|
||||
fontFamily: onGetFontFamily(selectedOption.option, family ?? ''),
|
||||
}}
|
||||
@@ -138,7 +137,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
||||
|
||||
{/* More options with nested dropdown */}
|
||||
{moreOptions && moreOptions.length > 0 && (
|
||||
<li className='dropdown dropdown-left dropdown-top px-1 sm:px-2'>
|
||||
<li className='dropdown dropdown-left dropdown-top px-2'>
|
||||
<div className='flex items-center px-0 text-sm'>
|
||||
<span style={{ minWidth: `${iconSize}px` }}>
|
||||
<FiChevronLeft size={iconSize} />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MdSettings } from 'react-icons/md';
|
||||
|
||||
import {
|
||||
ANDROID_FONTS,
|
||||
CJK_EXCLUDE_PATTENS,
|
||||
CJK_FONTS_PATTENS,
|
||||
CJK_NAMES_PATTENS,
|
||||
CJK_SANS_SERIF_FONTS,
|
||||
CJK_SERIF_FONTS,
|
||||
IOS_FONTS,
|
||||
@@ -17,27 +17,33 @@ import {
|
||||
SERIF_FONTS,
|
||||
WINDOWS_FONTS,
|
||||
} from '@/services/constants';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useCustomFontStore } from '@/store/customFontStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { getOSPlatform, isCJKEnv } from '@/utils/misc';
|
||||
import { getSysFontsList } from '@/utils/bridge';
|
||||
import { isCJKStr } from '@/utils/lang';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { useResetViewSettings } from '../../hooks/useResetSettings';
|
||||
import { SettingsPanelPanelProp } from './SettingsDialog';
|
||||
import NumberInput from './NumberInput';
|
||||
import FontDropdown from './FontDropDown';
|
||||
import CustomFonts from './CustomFonts';
|
||||
|
||||
const genCJKFontsList = (sysFonts: 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))
|
||||
.filter((font) => CJK_FONTS_PATTENS.test(font) || isCJKStr(font))
|
||||
.filter((font) => !CJK_EXCLUDE_PATTENS.test(font))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const isSymbolicFontName = (font: string) =>
|
||||
/emoji|icons|symbol|dingbats|ornaments|webdings|wingdings|miuiex/i.test(font);
|
||||
|
||||
interface FontFaceProps {
|
||||
className?: string;
|
||||
family: string;
|
||||
@@ -83,10 +89,19 @@ const FontFace = ({
|
||||
|
||||
const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const { fontPanelView, setFontPanelView } = useSettingsStore();
|
||||
const {
|
||||
fonts: allCustomFonts,
|
||||
getAllFonts,
|
||||
getFontFamilies,
|
||||
removeFont,
|
||||
saveCustomFonts,
|
||||
} = useCustomFontStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const view = getView(bookKey)!;
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
|
||||
const fontFamilyOptions = [
|
||||
{
|
||||
@@ -130,8 +145,10 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
|
||||
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
|
||||
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
|
||||
|
||||
const [customFonts, setCustomFonts] = useState<string[]>(getFontFamilies());
|
||||
const [CJKFonts, setCJKFonts] = useState<string[]>(() => {
|
||||
return genCJKFontsList(sysFonts);
|
||||
return genCJKFontsList([...customFonts, ...sysFonts]);
|
||||
});
|
||||
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
@@ -148,19 +165,43 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
monospaceFont: setMonospaceFont,
|
||||
fontWeight: setFontWeight,
|
||||
});
|
||||
getAllFonts().forEach((font) => {
|
||||
if (removeFont(font.id)) {
|
||||
appService!.fs.removeFile(font.path, 'Fonts');
|
||||
}
|
||||
});
|
||||
saveCustomFonts(envConfig);
|
||||
};
|
||||
|
||||
const handleManageCustomFonts = () => {
|
||||
setFontPanelView('custom-fonts');
|
||||
};
|
||||
|
||||
const handleBackToMain = () => {
|
||||
setFontPanelView('main-fonts');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterReset(handleReset);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [appService]);
|
||||
|
||||
useEffect(() => {
|
||||
setCJKFonts((prev) => {
|
||||
const newFonts = genCJKFontsList(sysFonts);
|
||||
const newFonts = genCJKFontsList([...customFonts, ...sysFonts]);
|
||||
return prev.length !== newFonts.length ? newFonts : prev;
|
||||
});
|
||||
}, [sysFonts]);
|
||||
}, [customFonts, sysFonts]);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomFonts(getFontFamilies());
|
||||
}, [allCustomFonts, getFontFamilies]);
|
||||
|
||||
useEffect(() => {
|
||||
setSerifFont(viewSettings.serifFont);
|
||||
setSansSerifFont(viewSettings.sansSerifFont);
|
||||
setMonospaceFont(viewSettings.monospaceFont);
|
||||
}, [viewSettings.serifFont, viewSettings.sansSerifFont, viewSettings.monospaceFont]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTauriAppPlatform()) {
|
||||
@@ -247,10 +288,18 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
}
|
||||
};
|
||||
|
||||
if (fontPanelView === 'custom-fonts') {
|
||||
return (
|
||||
<div className='my-4 w-full'>
|
||||
<CustomFonts bookKey={bookKey} onBack={handleBackToMain} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className=''>{_('Override Book Font')}</h2>
|
||||
<h2 className='font-medium'>{_('Override Book Font')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
@@ -326,14 +375,27 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
</div>
|
||||
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-2 font-medium'>{_('Font Face')}</h2>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<h2 className='font-medium'>{_('Font Face')}</h2>
|
||||
<button
|
||||
onClick={handleManageCustomFonts}
|
||||
className='btn btn-ghost btn-xs gap-1 hover:bg-transparent'
|
||||
title={_('Manage Custom Fonts')}
|
||||
>
|
||||
<MdSettings size={iconSize18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className='card border-base-200 border shadow'>
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<FontFace
|
||||
className='config-item-top'
|
||||
family='serif'
|
||||
label={_('Serif Font')}
|
||||
options={[...SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SERIF_FONTS]}
|
||||
options={[
|
||||
...customFonts,
|
||||
...SERIF_FONTS.filter(filterNonFreeFonts),
|
||||
...CJK_SERIF_FONTS,
|
||||
]}
|
||||
moreOptions={sysFonts}
|
||||
selected={serifFont}
|
||||
onSelect={setSerifFont}
|
||||
@@ -341,7 +403,11 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
<FontFace
|
||||
family='sans-serif'
|
||||
label={_('Sans-Serif Font')}
|
||||
options={[...SANS_SERIF_FONTS.filter(filterNonFreeFonts), ...CJK_SANS_SERIF_FONTS]}
|
||||
options={[
|
||||
...customFonts,
|
||||
...SANS_SERIF_FONTS.filter(filterNonFreeFonts),
|
||||
...CJK_SANS_SERIF_FONTS,
|
||||
]}
|
||||
moreOptions={sysFonts}
|
||||
selected={sansSerifFont}
|
||||
onSelect={setSansSerifFont}
|
||||
@@ -350,7 +416,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
className='config-item-bottom'
|
||||
family='monospace'
|
||||
label={_('Monospace Font')}
|
||||
options={MONOSPACE_FONTS}
|
||||
options={[...customFonts, ...MONOSPACE_FONTS]}
|
||||
moreOptions={sysFonts}
|
||||
selected={monospaceFont}
|
||||
onSelect={setMonospaceFont}
|
||||
|
||||
@@ -370,7 +370,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
|
||||
return (
|
||||
<div className='my-4 w-full space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className=''>{_('Override Book Layout')}</h2>
|
||||
<h2 className='font-medium'>{_('Override Book Layout')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
|
||||
@@ -40,7 +40,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showAllTabLabels, setShowAllTabLabels] = useState(false);
|
||||
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
const { setFontPanelView, setFontLayoutSettingsDialogOpen } = useSettingsStore();
|
||||
|
||||
const tabConfig = [
|
||||
{
|
||||
@@ -85,6 +85,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
|
||||
const handleSetActivePanel = (tab: SettingsPanelType) => {
|
||||
setActivePanel(tab);
|
||||
setFontPanelView('main-fonts');
|
||||
localStorage.setItem('lastConfigPanel', tab);
|
||||
};
|
||||
|
||||
@@ -115,6 +116,8 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFontPanelView('main-fonts');
|
||||
|
||||
const container = tabsRef.current;
|
||||
if (!container) return;
|
||||
|
||||
@@ -154,7 +157,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [setFontPanelView]);
|
||||
|
||||
const currentPanel = tabConfig.find((tab) => tab.tab === activePanel);
|
||||
|
||||
@@ -163,6 +166,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
className='modal-open'
|
||||
bgClassName='sm:!bg-black/20'
|
||||
boxClassName={clsx('sm:min-w-[520px]', appService?.isMobile && 'sm:max-w-[90%] sm:w-3/4')}
|
||||
snapHeight={appService?.isMobile ? 0.7 : undefined}
|
||||
header={
|
||||
|
||||
@@ -8,10 +8,12 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
|
||||
import useBooksManager from '../../hooks/useBooksManager';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
@@ -22,6 +24,7 @@ interface BookMenuProps {
|
||||
|
||||
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const { settings } = useSettingsStore();
|
||||
const { bookKeys, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getVisibleLibrary } = useLibraryStore();
|
||||
const { openParallelView } = useBooksManager();
|
||||
@@ -69,6 +72,18 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
unsetParallel(bookKeys);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const showKoSyncSettingsWindow = () => {
|
||||
setKOSyncSettingsWindowVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const handlePullKOSync = () => {
|
||||
eventDispatcher.dispatch('pull-kosync', { bookKey: sideBarBookKey });
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const handlePushKOSync = () => {
|
||||
eventDispatcher.dispatch('push-kosync', { bookKey: sideBarBookKey });
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const isWebApp = isWebAppPlatform();
|
||||
|
||||
@@ -93,6 +108,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
<ul className='max-h-60 overflow-y-auto'>
|
||||
{getVisibleLibrary()
|
||||
.filter((book) => book.format !== 'PDF' && book.format !== 'CBZ')
|
||||
.filter((book) => !!book.downloadedAt)
|
||||
.slice(0, 20)
|
||||
.map((book) => (
|
||||
<MenuItem
|
||||
@@ -117,6 +133,14 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
</ul>
|
||||
</MenuItem>
|
||||
<hr className='border-base-200 my-1' />
|
||||
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
|
||||
{settings.kosync.enabled && (
|
||||
<>
|
||||
<MenuItem label={_('Push Progress')} onClick={handlePushKOSync} />
|
||||
<MenuItem label={_('Pull Progress')} onClick={handlePullKOSync} />
|
||||
</>
|
||||
)}
|
||||
<hr className='border-base-200 my-1' />
|
||||
<MenuItem label={_('Export Annotations')} onClick={handleExportAnnotations} />
|
||||
<MenuItem
|
||||
label={_('Sort TOC by Page')}
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
@@ -15,7 +15,7 @@ const SidebarContent: React.FC<{
|
||||
bookDoc: BookDoc;
|
||||
sideBarBookKey: string;
|
||||
}> = ({ bookDoc, sideBarBookKey }) => {
|
||||
const { appService } = useEnv();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
const config = getConfig(sideBarBookKey);
|
||||
const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc');
|
||||
@@ -75,10 +75,10 @@ const SidebarContent: React.FC<{
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-shrink-0',
|
||||
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom)/2)]',
|
||||
)}
|
||||
className='flex-shrink-0'
|
||||
style={{
|
||||
paddingBottom: `${(safeAreaInsets?.bottom || 0) / 2}px`,
|
||||
}}
|
||||
>
|
||||
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { GiBookshelf } from 'react-icons/gi';
|
||||
import { FiSearch } from 'react-icons/fi';
|
||||
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
@@ -18,11 +18,30 @@ const SidebarHeader: React.FC<{
|
||||
onTogglePin: () => void;
|
||||
onToggleSearchBar: () => void;
|
||||
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
const { appService } = useEnv();
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
setTrafficLightVisibility,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
const iconSize14 = useResponsiveSize(14);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const iconSize22 = useResponsiveSize(22);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
setTrafficLightVisibility(true);
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService?.hasTrafficLight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -29,7 +29,7 @@ const SideBar: React.FC<{
|
||||
onGoToLibrary: () => void;
|
||||
}> = ({ onGoToLibrary }) => {
|
||||
const { appService } = useEnv();
|
||||
const { updateAppTheme } = useThemeStore();
|
||||
const { updateAppTheme, safeAreaInsets } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
@@ -186,7 +186,6 @@ const SideBar: React.FC<{
|
||||
'sidebar-container bg-base-200 z-20 flex min-w-60 select-none flex-col',
|
||||
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
|
||||
'transition-[padding-top] duration-300',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
|
||||
!isSideBarPinned && 'shadow-2xl',
|
||||
)}
|
||||
@@ -195,6 +194,7 @@ const SideBar: React.FC<{
|
||||
width: `${sideBarWidth}`,
|
||||
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
|
||||
position: isSideBarPinned ? 'relative' : 'absolute',
|
||||
paddingTop: `${safeAreaInsets?.top || 0}px`,
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
|
||||
@@ -13,12 +13,17 @@ import { getContentMd5 } from '@/utils/misc';
|
||||
import { useTextTranslation } from '../../hooks/useTextTranslation';
|
||||
import { FlatTOCItem, StaticListRow, VirtualListRow } from './TOCItem';
|
||||
|
||||
const getItemIdentifier = (item: TOCItem) => {
|
||||
const href = item.href || '';
|
||||
return `toc-item-${item.id}-${href}`;
|
||||
};
|
||||
|
||||
const useFlattenedTOC = (toc: TOCItem[], expandedItems: Set<string>) => {
|
||||
return useMemo(() => {
|
||||
const flattenTOC = (items: TOCItem[], depth = 0): FlatTOCItem[] => {
|
||||
const result: FlatTOCItem[] = [];
|
||||
items.forEach((item, index) => {
|
||||
const isExpanded = expandedItems.has(item.href || '');
|
||||
const isExpanded = expandedItems.has(getItemIdentifier(item));
|
||||
result.push({ item, depth, index, isExpanded });
|
||||
if (item.subitems && isExpanded) {
|
||||
result.push(...flattenTOC(item.subitems, depth + 1));
|
||||
@@ -126,13 +131,13 @@ const TOCView: React.FC<{
|
||||
}, [flatItems, activeHref]);
|
||||
|
||||
const handleToggleExpand = useCallback((item: TOCItem) => {
|
||||
const href = item.href || '';
|
||||
const itemId = getItemIdentifier(item);
|
||||
setExpandedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(href)) {
|
||||
newSet.delete(href);
|
||||
if (newSet.has(itemId)) {
|
||||
newSet.delete(itemId);
|
||||
} else {
|
||||
newSet.add(href);
|
||||
newSet.add(itemId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
@@ -149,9 +154,10 @@ const TOCView: React.FC<{
|
||||
);
|
||||
|
||||
const expandParents = useCallback((toc: TOCItem[], href: string) => {
|
||||
const parentPath = findParentPath(toc, href).map((item) => item.href);
|
||||
const parentHrefs = parentPath.filter(Boolean) as string[];
|
||||
setExpandedItems(new Set(parentHrefs));
|
||||
const parentItems = findParentPath(toc, href)
|
||||
.map((item) => getItemIdentifier(item))
|
||||
.filter(Boolean);
|
||||
setExpandedItems(new Set(parentItems));
|
||||
}, []);
|
||||
|
||||
const scrollToActiveItem = useCallback(() => {
|
||||
@@ -176,7 +182,8 @@ const TOCView: React.FC<{
|
||||
(activeItem as HTMLElement).setAttribute('aria-current', 'page');
|
||||
}
|
||||
}
|
||||
}, [activeHref, flatItems]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeHref]);
|
||||
|
||||
const virtualItemSize = useMemo(() => {
|
||||
return window.innerWidth >= 640 && !viewSettings?.translationEnabled ? 37 : 57;
|
||||
@@ -210,7 +217,7 @@ const TOCView: React.FC<{
|
||||
setTimeout(scrollToActiveItem, appService?.isAndroidApp ? 300 : 0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flatItems, scrollToActiveItem]);
|
||||
}, [scrollToActiveItem]);
|
||||
|
||||
return flatItems.length > 256 ? (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -27,6 +28,7 @@ interface TTSControlProps {
|
||||
const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView, getProgress, getViewSettings } = useReaderStore();
|
||||
const { setViewSettings, setTTSEnabled } = useReaderStore();
|
||||
@@ -429,10 +431,13 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
|
||||
className={clsx(
|
||||
'absolute h-12 w-12',
|
||||
viewSettings?.rtl ? 'left-6' : 'right-6',
|
||||
appService?.hasSafeAreaInset
|
||||
? 'bottom-[calc(env(safe-area-inset-bottom)+70px)]'
|
||||
: 'bottom-[70px] sm:bottom-14',
|
||||
!appService?.hasSafeAreaInset && 'bottom-[70px] sm:bottom-14',
|
||||
)}
|
||||
style={{
|
||||
bottom: appService?.hasSafeAreaInset
|
||||
? `${(safeAreaInsets?.bottom || 0) + 70}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
|
||||
</div>
|
||||
|
||||
@@ -13,14 +13,18 @@ const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative h-full w-full',
|
||||
'relative h-full w-full rounded-full',
|
||||
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
|
||||
)}
|
||||
style={{
|
||||
maskImage: 'radial-gradient(circle, white 100%, transparent 100%)',
|
||||
WebkitMaskImage: 'radial-gradient(circle, white 100%, transparent 100%)',
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
|
||||
<div
|
||||
className='absolute -inset-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
|
||||
className='absolute -inset-full rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
|
||||
style={{
|
||||
animation: isPlaying && ttsInited ? 'moveGradient 2s alternate infinite' : 'none',
|
||||
}}
|
||||
|
||||
@@ -248,6 +248,7 @@ const TTSPanel = ({
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='flex flex-col items-center justify-center rounded-full p-1'
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
>
|
||||
<MdAlarm size={iconSize32} />
|
||||
{timeoutCountdown && (
|
||||
@@ -290,7 +291,11 @@ const TTSPanel = ({
|
||||
</div>
|
||||
|
||||
<div className='dropdown dropdown-top'>
|
||||
<button tabIndex={0} className='rounded-full p-1'>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className='rounded-full p-1'
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
>
|
||||
<RiVoiceAiFill size={iconSize32} />
|
||||
</button>
|
||||
<ul
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { md5 } from 'js-md5';
|
||||
import { type as osType } from '@tauri-apps/plugin-os';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { KOSyncClient, KoSyncProgress } from '@/services/sync/KOSyncClient';
|
||||
import { Book, FIXED_LAYOUT_FORMATS } from '@/types/book';
|
||||
import { Book, BookProgress, FIXED_LAYOUT_FORMATS } from '@/types/book';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getCFIFromXPointer, XCFI } from '@/utils/xcfi';
|
||||
import { getCFIFromXPointer, normalizeProgressXPointer, XCFI } from '@/utils/xcfi';
|
||||
|
||||
type SyncState = 'idle' | 'checking' | 'conflict' | 'synced' | 'error';
|
||||
|
||||
@@ -30,386 +28,262 @@ export interface SyncDetails {
|
||||
|
||||
export const useKOSync = (bookKey: string) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getProgress, getView } = useReaderStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { appService } = useEnv();
|
||||
const progress = getProgress(bookKey);
|
||||
|
||||
const [kosyncClient, setKOSyncClient] = useState<KOSyncClient | null>(null);
|
||||
const [syncState, setSyncState] = useState<SyncState>('idle');
|
||||
const [conflictDetails, setConflictDetails] = useState<SyncDetails | null>(null);
|
||||
const [errorMessage] = useState<string | null>(null);
|
||||
const hasPulledOnce = useRef(false);
|
||||
|
||||
const syncCompletedForKey = useRef<string | null>(null);
|
||||
const lastPushedCfiRef = useRef<string | null>(null);
|
||||
const bookData = getBookData(bookKey);
|
||||
const book = bookData?.book;
|
||||
const bookDoc = bookData?.bookDoc;
|
||||
const progress = getProgress(bookKey);
|
||||
|
||||
useEffect(() => {
|
||||
lastPushedCfiRef.current = null;
|
||||
syncCompletedForKey.current = null;
|
||||
setSyncState('idle');
|
||||
}, [bookKey]);
|
||||
if (!settings.kosync.username || !settings.kosync.userkey) {
|
||||
setKOSyncClient(null);
|
||||
return;
|
||||
}
|
||||
const client = new KOSyncClient(settings.kosync);
|
||||
setKOSyncClient(client);
|
||||
}, [settings]);
|
||||
|
||||
const mapProgressToServerFormat = useCallback(() => {
|
||||
const currentProgress = getProgress(bookKey);
|
||||
const currentBook = getBookData(bookKey)?.book;
|
||||
if (!currentProgress || !currentBook) return null;
|
||||
const generateKOProgress = useCallback(() => {
|
||||
const progress = getProgress(bookKey);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!progress || !book) return null;
|
||||
|
||||
let progressStr: string;
|
||||
let koProgress = '';
|
||||
let percentage: number;
|
||||
|
||||
if (FIXED_LAYOUT_FORMATS.has(currentBook.format)) {
|
||||
const page = (currentProgress.section?.current ?? 0) + 1;
|
||||
const totalPages = currentProgress.section?.total ?? 0;
|
||||
progressStr = page.toString();
|
||||
percentage = totalPages > 0 ? page / totalPages : 0;
|
||||
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
|
||||
const page = progress.section?.current ?? 0;
|
||||
const totalPages = progress.section?.total ?? 0;
|
||||
koProgress = page.toString();
|
||||
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
|
||||
} else {
|
||||
progressStr = currentProgress.location;
|
||||
const view = getView(bookKey);
|
||||
|
||||
if (view && progressStr) {
|
||||
try {
|
||||
const content = view.renderer.getContents()[0];
|
||||
if (content) {
|
||||
const { doc, index: spineIndex } = content;
|
||||
const converter = new XCFI(doc, spineIndex || 0);
|
||||
const xpointerResult = converter.cfiToXPointer(progressStr);
|
||||
|
||||
progressStr = xpointerResult.xpointer;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to convert CFI to XPointer. Progress will be sent as percentage only.',
|
||||
error,
|
||||
);
|
||||
const cfi = progress.location;
|
||||
if (!view || !cfi) return null;
|
||||
try {
|
||||
const content = view.renderer.getContents()[0];
|
||||
if (content) {
|
||||
const { doc, index: spineIndex } = content;
|
||||
const converter = new XCFI(doc, spineIndex || 0);
|
||||
const xpointerResult = converter.cfiToXPointer(cfi);
|
||||
koProgress = normalizeProgressXPointer(xpointerResult.xpointer);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to convert CFI to XPointer', error);
|
||||
}
|
||||
|
||||
const page = currentProgress.pageinfo?.current ?? 0;
|
||||
const totalPages = currentProgress.pageinfo?.total ?? 0;
|
||||
const page = progress.pageinfo?.current ?? 0;
|
||||
const totalPages = progress.pageinfo?.total ?? 0;
|
||||
percentage = totalPages > 0 ? (page + 1) / totalPages : 0;
|
||||
}
|
||||
|
||||
return { progressStr, percentage };
|
||||
return { koProgress, percentage };
|
||||
}, [bookKey, getProgress, getBookData, getView]);
|
||||
|
||||
const applyRemoteProgress = async (book: Book, bookDoc: BookDoc, remote: KoSyncProgress) => {
|
||||
const view = getView(bookKey);
|
||||
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
|
||||
const pageToGo = parseInt(remote.progress!, 10);
|
||||
if (isNaN(pageToGo)) return;
|
||||
view?.select(pageToGo - 1);
|
||||
} else {
|
||||
if (!remote.progress?.startsWith('/body')) return;
|
||||
try {
|
||||
const content = view?.renderer.getContents()[0];
|
||||
const cfi = await getCFIFromXPointer(
|
||||
remote.progress!,
|
||||
content?.doc,
|
||||
content?.index,
|
||||
bookDoc,
|
||||
);
|
||||
view?.goTo(cfi);
|
||||
} catch (error) {
|
||||
console.error('Failed to convert XPointer to CFI', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
eventDispatcher.dispatch('toast', { message: _('Reading Progress Synced'), type: 'info' });
|
||||
};
|
||||
|
||||
const promptedSync = async (
|
||||
book: Book,
|
||||
bookDoc: BookDoc,
|
||||
local: BookProgress,
|
||||
remote: KoSyncProgress,
|
||||
) => {
|
||||
let localPreview = '';
|
||||
let remotePreview = '';
|
||||
const remotePercentage = remote.percentage || 0;
|
||||
|
||||
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
|
||||
const localPageInfo = local.section;
|
||||
const localPercentage =
|
||||
localPageInfo && localPageInfo.total > 0
|
||||
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
|
||||
: 0;
|
||||
localPreview = localPageInfo
|
||||
? _('Page {{page}} of {{total}} ({{percentage}}%)', {
|
||||
page: localPageInfo.current + 1,
|
||||
total: localPageInfo.total,
|
||||
percentage: localPercentage,
|
||||
})
|
||||
: _('Current position');
|
||||
|
||||
const remotePage = parseInt(remote.progress!, 10);
|
||||
if (!isNaN(remotePage) && remotePercentage > 0) {
|
||||
const localTotalPages = localPageInfo?.total ?? 0;
|
||||
const remoteTotalPages = Math.round(remotePage / remotePercentage);
|
||||
const pagesMatch = Math.abs(localTotalPages - remoteTotalPages) <= 1;
|
||||
|
||||
if (pagesMatch) {
|
||||
remotePreview = _('Page {{page}} of {{total}} ({{percentage}}%)', {
|
||||
page: remotePage,
|
||||
total: remoteTotalPages,
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
});
|
||||
} else {
|
||||
remotePreview = _('Approximately page {{page}} of {{total}} ({{percentage}}%)', {
|
||||
page: remotePage,
|
||||
total: remoteTotalPages,
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
remotePreview = _('Approximately {{percentage}}%', {
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const localPageInfo = local.pageinfo;
|
||||
const localPercentage =
|
||||
localPageInfo && localPageInfo.total > 0
|
||||
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
|
||||
: 0;
|
||||
localPreview = `${local.sectionLabel} (${localPercentage}%)`;
|
||||
|
||||
remotePreview = _('Approximately {{percentage}}%', {
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
});
|
||||
}
|
||||
|
||||
setConflictDetails({
|
||||
book,
|
||||
bookDoc,
|
||||
local: { cfi: local.location, preview: localPreview },
|
||||
remote: { ...remote, preview: remotePreview },
|
||||
});
|
||||
};
|
||||
|
||||
const pushProgress = useMemo(
|
||||
() =>
|
||||
debounce(async () => {
|
||||
const { settings: currentSettings } = useSettingsStore.getState();
|
||||
if (!bookKey || !appService || !kosyncClient || !hasPulledOnce.current) return;
|
||||
const { settings } = useSettingsStore.getState();
|
||||
if (['receive', 'disable'].includes(settings.kosync.strategy)) return;
|
||||
|
||||
const currentBook = getBookData(bookKey)?.book;
|
||||
const progress = generateKOProgress();
|
||||
if (!currentBook || !progress || !progress.koProgress) return;
|
||||
|
||||
const { koreaderSyncUsername, koreaderSyncUserkey, koreaderSyncStrategy } = currentSettings;
|
||||
if (
|
||||
!koreaderSyncUsername ||
|
||||
!koreaderSyncUserkey ||
|
||||
['receive', 'disable'].includes(koreaderSyncStrategy) ||
|
||||
!currentBook
|
||||
)
|
||||
return;
|
||||
|
||||
const getDocumentDigest = (bookToDigest: Book): string => {
|
||||
if (currentSettings.koreaderSyncChecksumMethod === 'filename') {
|
||||
const filename = bookToDigest.sourceTitle || bookToDigest.title;
|
||||
const normalizedPath = filename.replace(/\\/g, '/');
|
||||
return md5(
|
||||
normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || normalizedPath,
|
||||
);
|
||||
}
|
||||
return bookToDigest.hash;
|
||||
};
|
||||
|
||||
const getDeviceName = async () => {
|
||||
if (currentSettings.koreaderSyncDeviceName) return currentSettings.koreaderSyncDeviceName;
|
||||
if (appService?.appPlatform === 'tauri') {
|
||||
const name = await osType();
|
||||
return `Readest (${name.charAt(0).toUpperCase() + name.slice(1)})`;
|
||||
}
|
||||
return 'Readest';
|
||||
};
|
||||
|
||||
const digest = getDocumentDigest(currentBook);
|
||||
const progressData = mapProgressToServerFormat();
|
||||
if (!digest || !progressData) return;
|
||||
|
||||
if (progressData.progressStr === lastPushedCfiRef.current) return;
|
||||
|
||||
const deviceName = await getDeviceName();
|
||||
const client = new KOSyncClient(
|
||||
currentSettings.koreaderSyncServerUrl,
|
||||
currentSettings.koreaderSyncUsername,
|
||||
currentSettings.koreaderSyncUserkey,
|
||||
currentSettings.koreaderSyncChecksumMethod,
|
||||
currentSettings.koreaderSyncDeviceId,
|
||||
deviceName,
|
||||
);
|
||||
|
||||
await client.updateProgress(currentBook, progressData.progressStr, progressData.percentage);
|
||||
lastPushedCfiRef.current = progressData.progressStr;
|
||||
await kosyncClient.updateProgress(currentBook, progress.koProgress, progress.percentage);
|
||||
}, 5000),
|
||||
[bookKey, appService, getBookData, mapProgressToServerFormat],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[bookKey, appService, kosyncClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFlush = (event: CustomEvent) => {
|
||||
const { bookKey: syncBookKey } = event.detail;
|
||||
if (syncBookKey === bookKey) {
|
||||
pushProgress.flush();
|
||||
}
|
||||
};
|
||||
eventDispatcher.on('flush-koreader-sync', handleFlush);
|
||||
return () => {
|
||||
eventDispatcher.off('flush-koreader-sync', handleFlush);
|
||||
pushProgress.flush();
|
||||
};
|
||||
}, [bookKey, pushProgress]);
|
||||
const pullProgress = useCallback(
|
||||
async () => {
|
||||
if (!progress?.location || !appService || !kosyncClient) return;
|
||||
|
||||
useEffect(() => {
|
||||
const performInitialSync = async () => {
|
||||
const { koreaderSyncUsername, koreaderSyncUserkey, koreaderSyncStrategy } = settings;
|
||||
if (
|
||||
!book ||
|
||||
!bookDoc ||
|
||||
!progress ||
|
||||
!koreaderSyncUsername ||
|
||||
!koreaderSyncUserkey ||
|
||||
koreaderSyncStrategy === 'disabled'
|
||||
)
|
||||
return;
|
||||
const bookData = getBookData(bookKey);
|
||||
const book = bookData?.book;
|
||||
const bookDoc = bookData?.bookDoc;
|
||||
if (!book || !bookDoc) return;
|
||||
|
||||
if (koreaderSyncStrategy === 'send') {
|
||||
syncCompletedForKey.current = bookKey;
|
||||
const { strategy, enabled } = settings.kosync;
|
||||
if (!enabled) return;
|
||||
|
||||
hasPulledOnce.current = true;
|
||||
if (strategy === 'send') {
|
||||
setSyncState('synced');
|
||||
return;
|
||||
}
|
||||
|
||||
setSyncState('checking');
|
||||
|
||||
const getDeviceName = async () => {
|
||||
if (settings.koreaderSyncDeviceName) return settings.koreaderSyncDeviceName;
|
||||
if (appService?.appPlatform === 'tauri') {
|
||||
const name = await osType();
|
||||
return `Readest (${name.charAt(0).toUpperCase() + name.slice(1)})`;
|
||||
}
|
||||
return 'Readest';
|
||||
};
|
||||
|
||||
const deviceName = await getDeviceName();
|
||||
const client = new KOSyncClient(
|
||||
settings.koreaderSyncServerUrl,
|
||||
settings.koreaderSyncUsername,
|
||||
settings.koreaderSyncUserkey,
|
||||
settings.koreaderSyncChecksumMethod,
|
||||
settings.koreaderSyncDeviceId,
|
||||
deviceName,
|
||||
);
|
||||
const remote = await client.getProgress(book);
|
||||
lastPushedCfiRef.current = progress.location;
|
||||
|
||||
if (!remote?.progress || !remote?.timestamp) {
|
||||
syncCompletedForKey.current = bookKey;
|
||||
const remoteProgress = await kosyncClient.getProgress(book);
|
||||
if (!remoteProgress || !remoteProgress.progress || !remoteProgress.timestamp) {
|
||||
setSyncState('synced');
|
||||
if (settings.koreaderSyncStrategy !== 'receive') {
|
||||
pushProgress();
|
||||
pushProgress.flush();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const localTimestamp = bookData?.config?.updatedAt || book.updatedAt;
|
||||
const remoteIsNewer = remote.timestamp * 1000 > localTimestamp;
|
||||
|
||||
const localIdentifier = FIXED_LAYOUT_FORMATS.has(book.format)
|
||||
? progress.section?.current.toString()
|
||||
: progress.location;
|
||||
const isLocalCFI = localIdentifier?.startsWith('epubcfi');
|
||||
|
||||
const remoteIdentifier = FIXED_LAYOUT_FORMATS.has(book.format)
|
||||
? (parseInt(remote.progress, 10) - 1).toString()
|
||||
: remote.progress.startsWith('epubcfi')
|
||||
? remote.progress
|
||||
: null;
|
||||
const isRemoteCFI = remoteIdentifier?.startsWith('epubcfi');
|
||||
|
||||
let isProgressIdentical = false;
|
||||
if (isLocalCFI && isRemoteCFI) {
|
||||
isProgressIdentical = localIdentifier === remoteIdentifier;
|
||||
}
|
||||
|
||||
if (!isProgressIdentical) {
|
||||
const localPercentage = mapProgressToServerFormat()?.percentage ?? 0;
|
||||
const remotePercentage = remote.percentage;
|
||||
|
||||
if (remotePercentage !== undefined && remotePercentage !== null) {
|
||||
const tolerance = settings.koreaderSyncPercentageTolerance;
|
||||
const percentageDifference = Math.abs(localPercentage - remotePercentage);
|
||||
isProgressIdentical = percentageDifference < tolerance;
|
||||
}
|
||||
}
|
||||
|
||||
if (isProgressIdentical) {
|
||||
lastPushedCfiRef.current = localIdentifier;
|
||||
syncCompletedForKey.current = bookKey;
|
||||
const remoteIsNewer = remoteProgress.timestamp * 1000 > localTimestamp;
|
||||
if (strategy === 'receive' || (strategy === 'silent' && remoteIsNewer)) {
|
||||
applyRemoteProgress(book, bookDoc, remoteProgress);
|
||||
setSyncState('synced');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
settings.koreaderSyncStrategy === 'receive' ||
|
||||
(settings.koreaderSyncStrategy === 'silent' && remoteIsNewer)
|
||||
) {
|
||||
const applyRemoteProgress = async () => {
|
||||
const view = getView(bookKey);
|
||||
if (view && remote.progress) {
|
||||
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
|
||||
const pageToGo = parseInt(remote.progress, 10);
|
||||
if (!isNaN(pageToGo)) view.select(pageToGo - 1);
|
||||
} else {
|
||||
const isXPointer = remote.progress.startsWith('/body');
|
||||
if (isXPointer) {
|
||||
try {
|
||||
const content = view.renderer.getContents()[0];
|
||||
if (content) {
|
||||
const { doc, index } = content;
|
||||
const cfi = await getCFIFromXPointer(remote.progress, doc, index || 0, bookDoc);
|
||||
view.goTo(cfi);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Reading Progress Synced'),
|
||||
type: 'info',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to convert XPointer to CFI, falling back to percentage.',
|
||||
error,
|
||||
);
|
||||
if (remote.percentage !== undefined && remote.percentage !== null) {
|
||||
view.goToFraction(remote.percentage);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (remote.percentage !== undefined && remote.percentage !== null) {
|
||||
view.goToFraction(remote.percentage);
|
||||
}
|
||||
}
|
||||
}
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Reading Progress Synced'),
|
||||
type: 'info',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
applyRemoteProgress();
|
||||
syncCompletedForKey.current = bookKey;
|
||||
setSyncState('synced');
|
||||
} else if (settings.koreaderSyncStrategy === 'prompt') {
|
||||
let localPreview = '';
|
||||
let remotePreview = '';
|
||||
const remotePercentage = remote.percentage || 0;
|
||||
|
||||
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
|
||||
const localPageInfo = progress.section;
|
||||
const localPercentage =
|
||||
localPageInfo && localPageInfo.total > 0
|
||||
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
|
||||
: 0;
|
||||
localPreview = localPageInfo
|
||||
? _('Page {{page}} of {{total}} ({{percentage}}%)', {
|
||||
page: localPageInfo.current + 1,
|
||||
total: localPageInfo.total,
|
||||
percentage: localPercentage,
|
||||
})
|
||||
: _('Current position');
|
||||
|
||||
const remotePage = parseInt(remote.progress, 10);
|
||||
if (!isNaN(remotePage) && remotePercentage > 0) {
|
||||
const localTotalPages = localPageInfo?.total ?? 0;
|
||||
const remoteTotalPages = Math.round(remotePage / remotePercentage);
|
||||
const pagesMatch = Math.abs(localTotalPages - remoteTotalPages) <= 1;
|
||||
|
||||
if (pagesMatch) {
|
||||
remotePreview = _('Page {{page}} of {{total}} ({{percentage}}%)', {
|
||||
page: remotePage,
|
||||
total: remoteTotalPages,
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
});
|
||||
} else {
|
||||
remotePreview = _('Approximately page {{page}} of {{total}} ({{percentage}}%)', {
|
||||
page: remotePage,
|
||||
total: remoteTotalPages,
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
remotePreview = _('Approximately {{percentage}}%', {
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const localPageInfo = progress.pageinfo;
|
||||
const localPercentage =
|
||||
localPageInfo && localPageInfo.total > 0
|
||||
? Math.round(((localPageInfo.current + 1) / localPageInfo.total) * 100)
|
||||
: 0;
|
||||
localPreview = `${progress.sectionLabel} (${localPercentage}%)`;
|
||||
|
||||
remotePreview = _('Approximately {{percentage}}%', {
|
||||
percentage: Math.round(remotePercentage * 100),
|
||||
});
|
||||
}
|
||||
|
||||
setConflictDetails({
|
||||
book,
|
||||
bookDoc,
|
||||
local: { cfi: progress.location, preview: localPreview },
|
||||
remote: { ...remote, preview: remotePreview, percentage: remote.percentage },
|
||||
});
|
||||
} else if (strategy === 'prompt') {
|
||||
promptedSync(book, bookDoc, progress, remoteProgress);
|
||||
setSyncState('conflict');
|
||||
} else {
|
||||
syncCompletedForKey.current = bookKey;
|
||||
setSyncState('synced');
|
||||
}
|
||||
};
|
||||
|
||||
if (bookKey && book && progress && syncCompletedForKey.current !== bookKey) {
|
||||
syncCompletedForKey.current = bookKey;
|
||||
performInitialSync();
|
||||
}
|
||||
}, [
|
||||
bookKey,
|
||||
book,
|
||||
bookDoc,
|
||||
progress,
|
||||
settings,
|
||||
appService,
|
||||
getBookData,
|
||||
getProgress,
|
||||
getView,
|
||||
mapProgressToServerFormat,
|
||||
pushProgress,
|
||||
_,
|
||||
bookData?.config?.updatedAt,
|
||||
]);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[bookKey, appService, kosyncClient, settings.kosync, progress],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePushProgress = (event: CustomEvent) => {
|
||||
if (event.detail.bookKey !== bookKey) return;
|
||||
pushProgress();
|
||||
};
|
||||
const handleFlush = (event: CustomEvent) => {
|
||||
if (event.detail.bookKey !== bookKey) return;
|
||||
pushProgress.flush();
|
||||
};
|
||||
eventDispatcher.on('push-kosync', handlePushProgress);
|
||||
eventDispatcher.on('flush-kosync', handleFlush);
|
||||
return () => {
|
||||
eventDispatcher.off('push-kosync', handlePushProgress);
|
||||
eventDispatcher.off('flush-kosync', handleFlush);
|
||||
pushProgress.flush();
|
||||
};
|
||||
}, [bookKey, pushProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePullProgress = (event: CustomEvent) => {
|
||||
if (event.detail.bookKey !== bookKey) return;
|
||||
pullProgress();
|
||||
};
|
||||
eventDispatcher.on('pull-kosync', handlePullProgress);
|
||||
return () => {
|
||||
eventDispatcher.off('pull-kosync', handlePullProgress);
|
||||
};
|
||||
}, [bookKey, pullProgress]);
|
||||
|
||||
// Pull: pull progress once when the book is opened
|
||||
useEffect(() => {
|
||||
if (!appService || !kosyncClient || !progress?.location) return;
|
||||
if (hasPulledOnce.current) return;
|
||||
|
||||
pullProgress();
|
||||
}, [appService, kosyncClient, progress?.location, pushProgress, pullProgress]);
|
||||
|
||||
// Push: auto-push progress when progress changes with a debounce
|
||||
useEffect(() => {
|
||||
if (syncState === 'synced' && progress) {
|
||||
if (
|
||||
settings.koreaderSyncStrategy !== 'receive' &&
|
||||
settings.koreaderSyncStrategy !== 'disabled'
|
||||
) {
|
||||
const { strategy, enabled } = settings.kosync;
|
||||
if (strategy !== 'receive' && enabled) {
|
||||
pushProgress();
|
||||
}
|
||||
}
|
||||
}, [progress, syncState, settings.koreaderSyncStrategy, pushProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pushProgress.flush();
|
||||
};
|
||||
}, [pushProgress]);
|
||||
}, [progress, syncState, settings.kosync, pushProgress]);
|
||||
|
||||
const resolveConflictWithLocal = () => {
|
||||
pushProgress();
|
||||
@@ -421,50 +295,12 @@ export const useKOSync = (bookKey: string) => {
|
||||
const resolveConflictWithRemote = async () => {
|
||||
const view = getView(bookKey);
|
||||
const remote = conflictDetails?.remote;
|
||||
const currentBook = conflictDetails?.book;
|
||||
const book = conflictDetails?.book;
|
||||
const bookDoc = conflictDetails?.bookDoc;
|
||||
|
||||
if (view && remote?.progress && currentBook) {
|
||||
if (FIXED_LAYOUT_FORMATS.has(currentBook.format)) {
|
||||
const localTotalPages = getProgress(bookKey)?.section?.total ?? 0;
|
||||
const remotePage = parseInt(remote.progress, 10);
|
||||
const remotePercentage = remote.percentage || 0;
|
||||
const remoteTotalPages =
|
||||
remotePercentage > 0 ? Math.round(remotePage / remotePercentage) : 0;
|
||||
if (!book || !bookDoc || !remote || !remote.progress || !view) return;
|
||||
|
||||
if (!isNaN(remotePage) && Math.abs(localTotalPages - remoteTotalPages) <= 1) {
|
||||
console.log('Going to remote page:', remotePage);
|
||||
view.select(remotePage - 1);
|
||||
} else if (remote.percentage !== undefined && remote.percentage !== null) {
|
||||
console.log('Going to remote percentage:', remote.percentage);
|
||||
view.goToFraction(remote.percentage);
|
||||
}
|
||||
} else {
|
||||
const isXPointer = remote.progress.startsWith('/body');
|
||||
const isCFI = remote.progress.startsWith('epubcfi');
|
||||
|
||||
if (isXPointer) {
|
||||
try {
|
||||
const content = view.renderer.getContents()[0];
|
||||
if (content) {
|
||||
const { doc, index } = content;
|
||||
const cfi = await getCFIFromXPointer(remote.progress, doc, index || 0, bookDoc);
|
||||
view.goTo(cfi);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to convert XPointer to CFI, falling back to percentage.', error);
|
||||
if (remote.percentage !== undefined && remote.percentage !== null) {
|
||||
view.goToFraction(remote.percentage);
|
||||
}
|
||||
}
|
||||
} else if (isCFI) {
|
||||
view.goTo(remote.progress);
|
||||
} else if (remote.percentage !== undefined && remote.percentage !== null) {
|
||||
view.goToFraction(remote.percentage);
|
||||
}
|
||||
}
|
||||
eventDispatcher.dispatch('toast', { message: _('Reading Progress Synced'), type: 'info' });
|
||||
}
|
||||
applyRemoteProgress(book, bookDoc, remote);
|
||||
setSyncState('synced');
|
||||
setConflictDetails(null);
|
||||
};
|
||||
@@ -473,8 +309,9 @@ export const useKOSync = (bookKey: string) => {
|
||||
syncState,
|
||||
conflictDetails,
|
||||
errorMessage,
|
||||
pushProgress,
|
||||
pullProgress,
|
||||
resolveConflictWithLocal,
|
||||
resolveConflictWithRemote,
|
||||
pushProgress,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,14 +33,14 @@ export const useNotesSync = (bookKey: string) => {
|
||||
const newNotes = getNewNotes();
|
||||
syncNotes(newNotes, bookHash, 'both');
|
||||
}, SYNC_NOTES_INTERVAL_SEC * 1000),
|
||||
[lastSyncedAtNotes],
|
||||
[syncNotes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.location || !user) return;
|
||||
handleAutoSync();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
}, [config, handleAutoSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const processNewNote = (note: BookNote) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CFI } from '@/libs/document';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
|
||||
import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
|
||||
import { getCFIFromXPointer, getXPointerFromCFI, normalizeProgressXPointer } from '@/utils/xcfi';
|
||||
|
||||
export const useProgressSync = (bookKey: string) => {
|
||||
const _ = useTranslation();
|
||||
@@ -56,10 +56,10 @@ export const useProgressSync = (bookKey: string) => {
|
||||
if (content && !FIXED_LAYOUT_FORMATS.has(book.format)) {
|
||||
const { doc, index } = content;
|
||||
const xpointerResult = await getXPointerFromCFI(config.location!, doc, index || 0);
|
||||
config.xpointer = xpointerResult.xpointer;
|
||||
config.xpointer = normalizeProgressXPointer(xpointerResult.xpointer);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to convert CFI to XPointer', error);
|
||||
console.warn('Failed to convert CFI to XPointer', error);
|
||||
}
|
||||
pushConfig(bookKey, config);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useQuotaStats } from '@/hooks/useQuotaStats';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -58,6 +59,7 @@ const ProfilePage = () => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user, logout } = useAuth();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [availablePlans, setAvailablePlans] = useState<AvailablePlan[]>([]);
|
||||
@@ -345,10 +347,10 @@ const ProfilePage = () => {
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex h-full w-full flex-col items-center overflow-y-auto',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
)}
|
||||
className={clsx('flex h-full w-full flex-col items-center overflow-y-auto')}
|
||||
style={{
|
||||
paddingTop: `${safeAreaInsets?.top || 0}px`,
|
||||
}}
|
||||
>
|
||||
<ProfileHeader onGoBack={handleGoBack} />
|
||||
<div className='w-full min-w-60 max-w-4xl py-10'>
|
||||
|
||||
@@ -41,7 +41,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const { systemUIVisible, statusBarHeight } = useThemeStore();
|
||||
const { systemUIVisible, statusBarHeight, safeAreaInsets } = useThemeStore();
|
||||
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
|
||||
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(!snapHeight);
|
||||
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
|
||||
@@ -161,7 +161,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20',
|
||||
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/50',
|
||||
appService?.hasRoundedWindow && 'rounded-window',
|
||||
bgClassName,
|
||||
)}
|
||||
@@ -179,7 +179,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
style={{
|
||||
paddingTop:
|
||||
appService?.hasSafeAreaInset && isFullHeightInMobile
|
||||
? `max(env(safe-area-inset-top), ${systemUIVisible ? statusBarHeight : 0}px)`
|
||||
? `${Math.max(safeAreaInsets?.top || 0, systemUIVisible ? statusBarHeight : 0)}px`
|
||||
: '0px',
|
||||
...(isMobile ? { height: snapHeight ? `${snapHeight * 100}%` : '100%', bottom: 0 } : {}),
|
||||
}}
|
||||
|
||||
@@ -11,7 +11,7 @@ const ModalPortal: React.FC<ModalPortalProps> = ({ children, showOverlay = true
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed inset-0 isolate z-50 flex items-center justify-center',
|
||||
'fixed inset-0 isolate z-[100] flex items-center justify-center',
|
||||
showOverlay && 'bg-black bg-opacity-50',
|
||||
)}
|
||||
style={{ transform: 'translateZ(0)' }}
|
||||
|
||||
@@ -6,12 +6,14 @@ import { AuthProvider } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { CSPostHogProvider } from '@/context/PHContext';
|
||||
import { SyncProvider } from '@/context/SyncContext';
|
||||
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
|
||||
import { initSystemThemeListener } from '@/store/themeStore';
|
||||
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
|
||||
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
|
||||
|
||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
const { appService } = useEnv();
|
||||
const iconSize = useDefaultIconSize();
|
||||
useSafeAreaInsets(); // Initialize safe area insets
|
||||
|
||||
useEffect(() => {
|
||||
if (appService) {
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function Select({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
|
||||
'select h-8 min-h-8 rounded-md border-none text-sm',
|
||||
'bg-base-200 focus:outline-none focus:ring-0',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
const Spinner: React.FC<{
|
||||
loading: boolean;
|
||||
}> = ({ loading }) => {
|
||||
const _ = useTranslation();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
if (!loading) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute left-1/2 -translate-x-1/2 transform text-center',
|
||||
'top-4 pt-[calc(env(safe-area-inset-top)+64px)]',
|
||||
)}
|
||||
className={clsx('absolute left-1/2 top-4 -translate-x-1/2 transform text-center')}
|
||||
style={{
|
||||
paddingTop: `${(safeAreaInsets?.top || 0) + 64}px`,
|
||||
}}
|
||||
role='status'
|
||||
>
|
||||
<span className='loading loading-dots loading-lg'></span>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
export type ToastType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export const Toast = () => {
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const toastType = useRef<ToastType>('info');
|
||||
const toastTimeout = useRef(5000);
|
||||
@@ -52,9 +54,12 @@ export const Toast = () => {
|
||||
className={clsx(
|
||||
'toast toast-center toast-middle z-50 w-auto max-w-screen-sm',
|
||||
toastClassMap[toastType.current],
|
||||
toastClassMap[toastType.current].includes('toast-top') &&
|
||||
'top-[calc(44px+env(safe-area-inset-top))]',
|
||||
)}
|
||||
style={{
|
||||
top: toastClassMap[toastType.current].includes('toast-top')
|
||||
? `${(safeAreaInsets?.top || 0) + 44}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -7,9 +7,8 @@ import { BookMetadata } from '@/libs/document';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { flattenContributors, formatAuthors, formatPublisher, formatTitle } from '@/utils/book';
|
||||
import { useFileSelector } from '@/hooks/useFileSelector';
|
||||
import { FormField } from './FormField';
|
||||
import { IMAGE_ACCEPT_FORMATS, SUPPORTED_IMAGE_EXTS } from '@/services/constants';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import BookCover from '@/components/BookCover';
|
||||
|
||||
interface BookDetailEditProps {
|
||||
@@ -47,6 +46,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { selectFiles } = useFileSelector(appService, _);
|
||||
|
||||
const hasLockedFields = Object.values(lockedFields).some((locked) => locked);
|
||||
const allFieldsLocked = Object.values(lockedFields).every((locked) => locked);
|
||||
@@ -148,51 +148,23 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
|
||||
},
|
||||
];
|
||||
|
||||
const selectImageFileWeb = () => {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = IMAGE_ACCEPT_FORMATS;
|
||||
fileInput.multiple = false;
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = () => {
|
||||
resolve(fileInput.files);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const selectImageFileTauri = async () => {
|
||||
const exts = appService?.isMobileApp ? [] : SUPPORTED_IMAGE_EXTS;
|
||||
const files = (await appService?.selectFiles(_('Select Cover Image'), exts)) || [];
|
||||
if (appService?.isIOSApp) {
|
||||
return files.filter((file) => {
|
||||
const fileExt = file.split('.').pop()?.toLowerCase() || 'unknown';
|
||||
return SUPPORTED_IMAGE_EXTS.includes(fileExt);
|
||||
});
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const handleSelectLocalImage = async () => {
|
||||
let files;
|
||||
if (isTauriAppPlatform()) {
|
||||
files = (await selectImageFileTauri()) as string[];
|
||||
if (appService && files.length > 0) {
|
||||
metadata.coverImageFile = files[0]!;
|
||||
selectFiles({ type: 'covers', multiple: false }).then(async (result) => {
|
||||
if (result.error || result.files.length === 0) return;
|
||||
const selectedFile = result.files[0]!;
|
||||
if (selectedFile.path && appService) {
|
||||
const filePath = selectedFile.path;
|
||||
metadata.coverImageFile = filePath;
|
||||
const tempName = `cover-${Date.now()}.png`;
|
||||
const cachePrefix = await appService.fs.getPrefix('Cache');
|
||||
await appService.fs.copyFile(files[0]!, tempName, 'Cache');
|
||||
await appService.fs.copyFile(filePath, tempName, 'Cache');
|
||||
metadata.coverImageUrl = await appService.fs.getURL(`${cachePrefix}/${tempName}`);
|
||||
setNewCoverImageUrl(metadata.coverImageUrl!);
|
||||
}
|
||||
} else {
|
||||
files = (await selectImageFileWeb()) as File[];
|
||||
if (files.length > 0) {
|
||||
metadata.coverImageBlobUrl = URL.createObjectURL(files[0]!);
|
||||
} else if (selectedFile.file) {
|
||||
metadata.coverImageBlobUrl = URL.createObjectURL(selectedFile.file);
|
||||
setNewCoverImageUrl(metadata.coverImageBlobUrl!);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Book } from '@/types/book';
|
||||
import { BookMetadata } from '@/libs/document';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useMetadataEdit } from './useMetadataEdit';
|
||||
@@ -45,6 +46,7 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
|
||||
handleBookMetadataUpdate,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeDeleteAction, setActiveDeleteAction] = useState<DeleteAction | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -187,7 +189,6 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
|
||||
title={editMode ? _('Edit Metadata') : _('Book Details')}
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
bgClassName='sm:bg-black/50'
|
||||
boxClassName={clsx(
|
||||
editMode ? 'sm:min-w-[600px] sm:max-w-[600px]' : 'sm:min-w-[480px] sm:max-w-[480px]',
|
||||
'sm:h-auto sm:max-h-[90%]',
|
||||
@@ -240,10 +241,10 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
|
||||
|
||||
{activeDeleteAction && currentDeleteConfig && (
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
|
||||
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
|
||||
)}
|
||||
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
|
||||
style={{
|
||||
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
title={currentDeleteConfig.title}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { AppService } from '@/types/system';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { basename } from '@tauri-apps/api/path';
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants';
|
||||
|
||||
export interface FileSelectorOptions {
|
||||
type: SelectionType;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
extensions?: string[];
|
||||
dialogTitle?: string;
|
||||
}
|
||||
|
||||
export interface SelectedFile {
|
||||
// For Web file
|
||||
file?: File;
|
||||
|
||||
// For Tauri file
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface FileSelectionResult {
|
||||
files: SelectedFile[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const selectFileWeb = (options: FileSelectorOptions): Promise<File[]> => {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = options.accept || '*/*';
|
||||
fileInput.multiple = options.multiple || false;
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = () => {
|
||||
resolve(Array.from(fileInput.files || []));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const selectFileTauri = async (
|
||||
options: FileSelectorOptions,
|
||||
appService: AppService,
|
||||
_: (key: string) => string,
|
||||
): Promise<string[]> => {
|
||||
const noFilter = appService?.isIOSApp || (appService?.isAndroidApp && options.type === 'books');
|
||||
const exts = noFilter ? [] : options.extensions || [];
|
||||
const title = options.dialogTitle || _('Select Files');
|
||||
let files = (await appService?.selectFiles(_(title), exts)) || [];
|
||||
|
||||
if (noFilter && options.extensions) {
|
||||
files = await Promise.all(
|
||||
files.map(async (file: string) => {
|
||||
let processedFile = file;
|
||||
if (appService?.isAndroidApp && file.startsWith('content://')) {
|
||||
processedFile = await basename(file);
|
||||
}
|
||||
const fileExt = processedFile.split('.').pop()?.toLowerCase() || 'unknown';
|
||||
const extensions = options.extensions!;
|
||||
const shouldInclude = extensions.includes(fileExt) || extensions.includes('*');
|
||||
return shouldInclude ? file : null;
|
||||
}),
|
||||
).then((results) => results.filter((file) => file !== null));
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const processWebFiles = (files: File[]): SelectedFile[] => {
|
||||
return files.map((file) => ({
|
||||
file,
|
||||
}));
|
||||
};
|
||||
|
||||
const processTauriFiles = (files: string[]): SelectedFile[] => {
|
||||
return files.map((path) => ({
|
||||
path,
|
||||
}));
|
||||
};
|
||||
|
||||
export const useFileSelector = (appService: AppService | null, _: (key: string) => string) => {
|
||||
const selectFiles = async (options: FileSelectorOptions = { type: 'generic' }) => {
|
||||
options = { ...FILE_SELECTION_PRESETS[options.type], ...options };
|
||||
if (!appService) {
|
||||
return { files: [] as SelectedFile[], error: 'App service is not available' };
|
||||
}
|
||||
try {
|
||||
if (isTauriAppPlatform()) {
|
||||
const filePaths = await selectFileTauri(options, appService, _);
|
||||
const files = await processTauriFiles(filePaths);
|
||||
return { files };
|
||||
} else {
|
||||
const webFiles = await selectFileWeb(options);
|
||||
const files = processWebFiles(webFiles);
|
||||
return { files };
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
files: [],
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
};
|
||||
return {
|
||||
selectFiles,
|
||||
};
|
||||
};
|
||||
|
||||
export const FILE_SELECTION_PRESETS = {
|
||||
generic: {
|
||||
accept: '*/*',
|
||||
extensions: ['*'],
|
||||
dialogTitle: _('Select Files'),
|
||||
},
|
||||
images: {
|
||||
accept: 'image/*',
|
||||
extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
|
||||
dialogTitle: _('Select Image'),
|
||||
},
|
||||
videos: {
|
||||
accept: 'video/*',
|
||||
extensions: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'],
|
||||
dialogTitle: _('Select Video'),
|
||||
},
|
||||
audio: {
|
||||
accept: 'audio/*',
|
||||
extensions: ['mp3', 'wav', 'ogg', 'flac', 'm4a'],
|
||||
dialogTitle: _('Select Audio'),
|
||||
},
|
||||
books: {
|
||||
accept: BOOK_ACCEPT_FORMATS,
|
||||
extensions: SUPPORTED_BOOK_EXTS,
|
||||
dialogTitle: _('Select Books'),
|
||||
},
|
||||
fonts: {
|
||||
accept: '.ttf, .otf, .woff, .woff2',
|
||||
extensions: ['ttf', 'otf', 'woff', 'woff2'],
|
||||
dialogTitle: _('Select Fonts'),
|
||||
},
|
||||
covers: {
|
||||
accept: '.png, .jpg, .jpeg, .gif',
|
||||
extensions: ['png', 'jpg', 'jpeg', 'gif'],
|
||||
dialogTitle: _('Select Image'),
|
||||
},
|
||||
};
|
||||
|
||||
export type SelectionType = keyof typeof FILE_SELECTION_PRESETS;
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { getSafeAreaInsets } from '@/utils/bridge';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export const useSafeAreaInsets = () => {
|
||||
const { appService } = useEnv();
|
||||
const [updated, setUpdated] = useState(false);
|
||||
const [insets, setInsets] = useState({
|
||||
top: 0,
|
||||
@@ -9,27 +13,59 @@ export const useSafeAreaInsets = () => {
|
||||
left: 0,
|
||||
});
|
||||
|
||||
const updateSafeAreaInsets = useCallback(() => {
|
||||
const { updateSafeAreaInsets } = useThemeStore();
|
||||
|
||||
const onUpdateInsets = useCallback(() => {
|
||||
if (!appService) return;
|
||||
|
||||
if (!appService.hasSafeAreaInset) {
|
||||
updateSafeAreaInsets(insets);
|
||||
setUpdated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const hasCustomProperties = rootStyles.getPropertyValue('--safe-area-inset-top');
|
||||
if (hasCustomProperties) {
|
||||
setInsets({
|
||||
const isWebView139 = /Chrome\/139/.test(navigator.userAgent);
|
||||
// safe-area-inset-* values in css are always 0px in some versions of webview 139
|
||||
// due to https://issues.chromium.org/issues/40699457
|
||||
if (appService.isAndroidApp && isWebView139) {
|
||||
getSafeAreaInsets().then((response) => {
|
||||
if (response.error) {
|
||||
console.error('Error getting safe area insets from native bridge:', response.error);
|
||||
} else {
|
||||
const insets = {
|
||||
top: response.top,
|
||||
right: response.right,
|
||||
bottom: response.bottom,
|
||||
left: response.left,
|
||||
};
|
||||
setInsets(insets);
|
||||
updateSafeAreaInsets(insets);
|
||||
setUpdated(true);
|
||||
}
|
||||
});
|
||||
} else if (hasCustomProperties) {
|
||||
const insets = {
|
||||
top: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-top')) || 0,
|
||||
right: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-right')) || 0,
|
||||
bottom: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-bottom')) || 0,
|
||||
left: parseFloat(rootStyles.getPropertyValue('--safe-area-inset-left')) || 0,
|
||||
});
|
||||
};
|
||||
setInsets(insets);
|
||||
updateSafeAreaInsets(insets);
|
||||
setUpdated(true);
|
||||
}
|
||||
setUpdated(true);
|
||||
}, []);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService]);
|
||||
|
||||
useEffect(() => {
|
||||
updateSafeAreaInsets();
|
||||
window.addEventListener('resize', updateSafeAreaInsets);
|
||||
onUpdateInsets();
|
||||
window.addEventListener('resize', onUpdateInsets);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateSafeAreaInsets);
|
||||
window.removeEventListener('resize', onUpdateInsets);
|
||||
};
|
||||
}, [updateSafeAreaInsets]);
|
||||
}, [onUpdateInsets]);
|
||||
|
||||
return updated ? insets : null;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSyncContext } from '@/context/SyncContext';
|
||||
import { SyncData, SyncOp, SyncResult, SyncType } from '@/libs/sync';
|
||||
@@ -47,7 +47,7 @@ export function useSync(bookKey?: string) {
|
||||
const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState<number>(0);
|
||||
const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState<number>(0);
|
||||
const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState<number>(0);
|
||||
const lastSyncedAtInited = useRef(false);
|
||||
const [lastSyncedAtInited, setLastSyncedAtInited] = useState(false);
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
// null means unsynced, empty array means synced no changes
|
||||
@@ -63,17 +63,19 @@ export function useSync(bookKey?: string) {
|
||||
const { syncClient } = useSyncContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings || !config) return;
|
||||
if (lastSyncedAtInited.current) return;
|
||||
lastSyncedAtInited.current = true;
|
||||
if (!settings.version) return;
|
||||
if (bookKey && !config?.location) return;
|
||||
if (lastSyncedAtInited) return;
|
||||
|
||||
const lastSyncedBooksAt = settings.lastSyncedAtBooks ?? 0;
|
||||
const lastSyncedConfigsAt = config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0;
|
||||
const lastSyncedNotesAt = config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0;
|
||||
setLastSyncedAtBooks(lastSyncedBooksAt > 0 ? lastSyncedBooksAt - SEVEN_DAYS_IN_MS : 0);
|
||||
setLastSyncedAtBooks(lastSyncedBooksAt > 0 ? lastSyncedBooksAt : 0);
|
||||
setLastSyncedAtConfigs(lastSyncedConfigsAt > 0 ? lastSyncedConfigsAt - SEVEN_DAYS_IN_MS : 0);
|
||||
setLastSyncedAtNotes(lastSyncedNotesAt > 0 ? lastSyncedNotesAt - SEVEN_DAYS_IN_MS : 0);
|
||||
}, [settings, config]);
|
||||
setLastSyncedAtInited(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKey, settings, config]);
|
||||
|
||||
// bookId is for configs and notes only, if bookId is provided, only pull changes for that book
|
||||
// and update the lastSyncedAt for that book in the book config
|
||||
@@ -158,38 +160,60 @@ export function useSync(bookKey?: string) {
|
||||
}
|
||||
};
|
||||
|
||||
const syncBooks = async (books?: Book[], op: SyncOp = 'both') => {
|
||||
if ((op === 'push' || op === 'both') && books?.length) {
|
||||
await pushChanges({ books });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks);
|
||||
}
|
||||
};
|
||||
const syncBooks = useCallback(
|
||||
async (books?: Book[], op: SyncOp = 'both') => {
|
||||
if (!lastSyncedAtInited) return;
|
||||
if ((op === 'push' || op === 'both') && books?.length) {
|
||||
await pushChanges({ books });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[lastSyncedAtInited, lastSyncedAtBooks],
|
||||
);
|
||||
|
||||
const syncConfigs = async (bookConfigs?: BookConfig[], bookId?: string, op: SyncOp = 'both') => {
|
||||
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
|
||||
await pushChanges({ configs: bookConfigs });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges(
|
||||
'configs',
|
||||
lastSyncedAtConfigs,
|
||||
setLastSyncedAtConfigs,
|
||||
setSyncingConfigs,
|
||||
bookId,
|
||||
);
|
||||
}
|
||||
};
|
||||
const syncConfigs = useCallback(
|
||||
async (bookConfigs?: BookConfig[], bookId?: string, op: SyncOp = 'both') => {
|
||||
if (!bookId && !lastSyncedAtInited) return;
|
||||
if ((op === 'push' || op === 'both') && bookConfigs?.length) {
|
||||
await pushChanges({ configs: bookConfigs });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges(
|
||||
'configs',
|
||||
lastSyncedAtConfigs,
|
||||
setLastSyncedAtConfigs,
|
||||
setSyncingConfigs,
|
||||
bookId,
|
||||
);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[lastSyncedAtInited, lastSyncedAtConfigs],
|
||||
);
|
||||
|
||||
const syncNotes = async (bookNotes?: BookNote[], bookId?: string, op: SyncOp = 'both') => {
|
||||
if ((op === 'push' || op === 'both') && bookNotes?.length) {
|
||||
await pushChanges({ notes: bookNotes });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges('notes', lastSyncedAtNotes, setLastSyncedAtNotes, setSyncingNotes, bookId);
|
||||
}
|
||||
};
|
||||
const syncNotes = useCallback(
|
||||
async (bookNotes?: BookNote[], bookId?: string, op: SyncOp = 'both') => {
|
||||
if (!lastSyncedAtInited) return;
|
||||
if ((op === 'push' || op === 'both') && bookNotes?.length) {
|
||||
await pushChanges({ notes: bookNotes });
|
||||
}
|
||||
if (op === 'pull' || op === 'both') {
|
||||
await pullChanges(
|
||||
'notes',
|
||||
lastSyncedAtNotes,
|
||||
setLastSyncedAtNotes,
|
||||
setSyncingNotes,
|
||||
bookId,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[lastSyncedAtInited, lastSyncedAtNotes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncing && syncResult) {
|
||||
|
||||
@@ -38,7 +38,7 @@ export const useTheme = ({
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [appService?.isAndroidApp]);
|
||||
|
||||
const handleSystemUIVisibility = useCallback(() => {
|
||||
if (!appService?.isMobileApp) return;
|
||||
@@ -94,7 +94,7 @@ export const useTheme = ({
|
||||
applyCustomTheme(customTheme);
|
||||
});
|
||||
localStorage.setItem('customThemes', JSON.stringify(customThemes));
|
||||
}, [settings]);
|
||||
}, [settings.globalReadSettings?.customThemes]);
|
||||
|
||||
useEffect(() => {
|
||||
const colorScheme = isDarkMode ? 'dark' : 'light';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user