Compare commits

...

40 Commits

Author SHA1 Message Date
Huang Xin a5f18ab024 release: version 0.9.67 (#1664) 2025-07-22 16:11:00 +02:00
Huang Xin d1f5934947 library: various fixes on metadata editor and bookshelf (#1663)
* i18n: update translations

* library: various fixes on metadata editor and bookshelf

* chore: enable parallel web builds checking
2025-07-22 16:04:44 +02:00
Huang Xin f03d6f6861 feat: search in book format, group names and descriptions (#1662) 2025-07-22 11:40:14 +02:00
Huang Xin 20523b7eaf PDF: Support custom background theming for PDF files, closes #1649 (#1661) 2025-07-22 11:34:38 +02:00
Huang Xin f31c9b2a98 layout: fix hardcoded image layout in fixed layout documents (#1660) 2025-07-22 10:40:21 +02:00
Huang Xin 93071e727f linux: fixed multiple instances created in OAuth, closes #1654 (#1659) 2025-07-22 09:00:19 +02:00
Huang Xin 2d12210606 css: multiply img color in light mode when overriding book color (#1656)
This fixed image color issue for EPUBs in #1649.
2025-07-21 18:17:16 +02:00
Huang Xin 88ccc528d4 layout: hover header to show traffic light window control on macOS, closes #1645 (#1653) 2025-07-21 17:16:58 +02:00
Yurii Kostyukov 9d3fc07865 feat: Added Yandex Translator (#1652) 2025-07-21 17:13:13 +02:00
Huang Xin 6d465a40fb fix: don't use comma as separator when parsing filenames, closes #1622 (#1650) 2025-07-21 15:32:10 +02:00
Huang Xin 01c7e4c2e2 chore: fixed failed AppImage builds for Linux 2025-07-21 20:39:57 +08:00
Huang Xin 4005858242 release: version 0.9.66 (#1643) 2025-07-20 23:12:40 +02:00
Huang Xin c24f951375 fix: set xdg-mime with mime type other than scheme, closes #1621 (#1641) 2025-07-21 05:02:22 +08:00
Huang Xin dbe1efd1e9 tts: convert iso6392 language code to iso6391 to filter tts voices, closes #1627 (#1639) 2025-07-20 22:10:14 +02:00
Huang Xin 0299cce343 library: fix book not redownloaded for the redownload button in detail modal, closes #1628 (#1638) 2025-07-20 21:00:29 +02:00
Huang Xin ca9f504329 layout: fix layout for auth and user page (#1637) 2025-07-20 20:05:55 +02:00
Huang Xin df3527ceca fix: chaining file open with OS opening the highest precedence, closes #1622 (#1636) 2025-07-20 17:09:19 +02:00
Huang Xin 73d0838291 doc: run a preview build to identify compatibility issues between OpenNext, Next.js, and Supabase (#1635) 2025-07-20 11:32:00 +02:00
Huang Xin 8e3574509e chore: downgrade next.js to 15.3 (#1634)
Until https://github.com/opennextjs/opennextjs-cloudflare/issues/667 is fixed.
2025-07-20 11:12:06 +02:00
Huang Xin 9bdf375304 api: fix cors for api with new nextjs version (#1633) 2025-07-20 10:24:28 +02:00
Huang Xin effa922597 chore: bump tauri, next and zustand to latest versions (#1631) 2025-07-20 09:42:32 +02:00
Huang Xin fe484257a0 doc: add deepwiki badge and link (#1630) 2025-07-20 07:02:14 +02:00
Huang Xin cc0cd9a114 layout: fix nested toc items not expanded in very long toc list, closes #1625 (#1629) 2025-07-20 06:42:31 +02:00
Huang Xin 916d63806e chore: fix loading chunk error of optional chaining for Android WebView below 80 (#1626) 2025-07-19 20:24:30 +02:00
Huang Xin 59d0118067 chore: handle ChunkLoadError by refreshing page (#1619) 2025-07-19 06:37:13 +02:00
Huang Xin 957c6b9f32 fix: update book cover image from metadata in sidebar (#1615) 2025-07-18 19:11:02 +02:00
Huang Xin 9c5a1147fa release: version 0.9.65 (#1614) 2025-07-18 18:31:38 +02:00
Huang Xin 60ad6706ff ios: skip context menu when long-press on book cover, closes #1612 (#1613) 2025-07-18 18:22:59 +02:00
Huang Xin d9199d1182 sync: also sync book metadata (#1611) 2025-07-18 17:59:05 +02:00
Huang Xin 219edb9faf translation: fix translation not working for table of contents (#1610) 2025-07-18 11:54:26 +02:00
Huang Xin 30a0227ef3 layout: unset text indent inside of list elements (#1609) 2025-07-18 11:26:28 +02:00
Huang Xin 29b5a7cb88 tts: skip text to speech for rubys and footnote anchors, closes #1334 (#1608) 2025-07-18 11:10:12 +02:00
Huang Xin abb727870e tts: handle invalid language code and show no voices hints, closes #1579 (#1607) 2025-07-18 09:27:51 +02:00
Dave Vasilevsky af84a8b110 chore: match dev-web command to docs (#1606) 2025-07-18 06:50:41 +02:00
Huang Xin 0dea5d3094 layout: maintain the layout of anchor elements while increasing their tap target size, closes #1603 (#1605) 2025-07-18 06:26:54 +02:00
Huang Xin 527b95521e fix: replace fallback book cover with new cover image (#1604) 2025-07-18 06:03:22 +02:00
Huang Xin 396b23ddb4 css: named container classes for easier css customization, closes #1598 (#1600) 2025-07-17 20:17:51 +02:00
Huang Xin ee0d1549bd layout: add window borders on Linux, closes #1570 (#1599) 2025-07-18 01:40:12 +08:00
Huang Xin 6da6b85530 feat: support multiple reader windows on desktop, closes #259 (#1596) 2025-07-17 10:21:37 +02:00
StepanSad cde055dcb7 Update translation.json (#1595) 2025-07-17 05:03:39 +02:00
109 changed files with 2041 additions and 1071 deletions
+13 -1
View File
@@ -32,6 +32,11 @@ jobs:
build_web_app:
runs-on: ubuntu-latest
strategy:
matrix:
config:
- platform: 'web'
- platform: 'tauri'
steps:
- uses: actions/checkout@v4
with:
@@ -54,6 +59,13 @@ jobs:
pnpm install && pnpm setup-pdfjs
- name: build the web App
if: matrix.config.platform == 'web'
working-directory: apps/readest-app
run: |
pnpm build-web
pnpm build-web && pnpm check:all
- name: build the Tauri App
if: matrix.config.platform == 'tauri'
working-directory: apps/readest-app
run: |
pnpm build && pnpm check:all
+2
View File
@@ -38,3 +38,5 @@ next-env.d.ts
# Rust build
target
fastlane/report.xml
+3
View File
@@ -4,3 +4,6 @@
[submodule "packages/tauri"]
path = packages/tauri
url = https://github.com/readest/tauri.git
[submodule "packages/tauri-plugins"]
path = packages/tauri-plugins
url = https://github.com/readest/tauri-plugins-workspace.git
Generated
+366 -270
View File
File diff suppressed because it is too large Load Diff
+19 -1
View File
@@ -2,9 +2,24 @@
members = [
"apps/readest-app/src-tauri",
"packages/tauri/crates/tauri",
"packages/tauri/crates/tauri-utils",
"packages/tauri/crates/tauri-build",
"packages/tauri-plugins/plugins/deep-link",
]
resolver = "2"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tracing = "0.1"
log = "0.4"
tauri = { version = "2", default-features = false }
tauri-build = "2"
tauri-plugin = "2"
tauri-utils = "2"
serde_json = "1"
thiserror = "2"
url = "2"
[workspace.package]
authors = ["Bilingify LLC"]
homepage = "https://readest.com"
@@ -15,4 +30,7 @@ edition = "2021"
rust-version = "1.77.2"
[patch.crates-io]
tauri = { path = "packages/tauri/crates/tauri" }
tauri = { path = "packages/tauri/crates/tauri" }
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
tauri-build = { path = "packages/tauri/crates/tauri-build" }
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
+6
View File
@@ -19,6 +19,7 @@
<br>
[![Last commit][badge-last-commit]][link-gh-commits]
[![Commits][badge-commit-activity]][link-gh-pulse]
[![Ask DeepWiki][badge-deepwiki]][link-deepwiki]
</div>
@@ -56,6 +57,7 @@
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| **Translate with DeepL** | From a single sentence to the entire book—translate instantly with DeepL. | ✅ |
| **Translate with Yandex** | Instantly translate text or books using Yandex Translate. | ✅ |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
@@ -165,6 +167,8 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
pnpm tauri dev
# or start development for the Web app
pnpm dev-web
# preview with OpenNext build for the Web app
pnpm preview
```
For Android:
@@ -304,6 +308,7 @@ The following fonts are utilized in this software, either bundled within the app
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
[badge-hellogithub]: https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8a5b6ade2aee461a8bd94e59200682a7&claim_uid=eRLUbPOy2qZtDgw&theme=small
[badge-donate]: https://donate.readest.com/badge.svg
[badge-deepwiki]: https://deepwiki.com/badge.svg
[link-donate]: https://donate.readest.com/?tickers=btc%2Ceth%2Csol%2Cusdc
[link-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
@@ -316,3 +321,4 @@ The following fonts are utilized in this software, either bundled within the app
[link-parallel-read]: https://readest.com/#parallel-read
[link-koreader]: https://github.com/koreader/koreader
[link-hellogithub]: https://hellogithub.com/repository/8a5b6ade2aee461a8bd94e59200682a7
[link-deepwiki]: https://deepwiki.com/readest/readest
+20 -2
View File
@@ -1,10 +1,11 @@
import withPWAInit from '@ducanh2912/next-pwa';
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
import withBundleAnalyzer from '@next/bundle-analyzer';
const isDev = process.env['NODE_ENV'] === 'development';
const appPlatform = process.env['NEXT_PUBLIC_APP_PLATFORM'];
if (isDev) {
const { initOpenNextCloudflareForDev } = await import('@opennextjs/cloudflare');
initOpenNextCloudflareForDev();
}
@@ -25,6 +26,19 @@ const nextConfig = {
// Configure assetPrefix or else the server won't properly resolve your assets.
assetPrefix: '',
reactStrictMode: true,
serverExternalPackages: ['isows'],
transpilePackages: !isDev
? [
'i18next-browser-languagedetector',
'react-i18next',
'i18next',
'@ducanh2912/next-pwa',
'@tauri-apps',
'highlight.js',
'foliate-js',
'marked',
]
: [],
async headers() {
return [
{
@@ -55,4 +69,8 @@ const withPWA = withPWAInit({
},
});
export default withPWA(nextConfig);
const withAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
export default withPWA(withAnalyzer(nextConfig));
+20 -16
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.64",
"version": "0.9.67",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -35,7 +35,10 @@
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"check:optional-chaining": "count=$(grep -rno '\\?\\.[a-zA-Z_$]' .next/static/chunks/* out/_next/static/chunks/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Optional chaining found in output!'; exit 1; else echo '✅ No optional chaining found.'; fi",
"check:all": "pnpm check:optional-chaining",
"build-check": "pnpm build && pnpm build-web && pnpm check:all"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.735.0",
@@ -49,18 +52,18 @@
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.50.2",
"@tauri-apps/api": "2.6.0",
"@tauri-apps/plugin-cli": "^2.3.0",
"@tauri-apps/plugin-deep-link": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.2.2",
"@tauri-apps/plugin-fs": "^2.3.0",
"@tauri-apps/plugin-haptics": "^2.2.5",
"@tauri-apps/plugin-http": "^2.4.4",
"@tauri-apps/plugin-log": "^2.5.1",
"@tauri-apps/plugin-opener": "^2.3.1",
"@tauri-apps/plugin-os": "^2.2.2",
"@tauri-apps/plugin-process": "^2.2.2",
"@tauri-apps/plugin-shell": "~2.2.2",
"@tauri-apps/plugin-updater": "^2.8.1",
"@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-haptics": "^2.3.0",
"@tauri-apps/plugin-http": "^2.5.0",
"@tauri-apps/plugin-log": "^2.6.0",
"@tauri-apps/plugin-opener": "^2.4.0",
"@tauri-apps/plugin-os": "^2.3.0",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-shell": "~2.3.0",
"@tauri-apps/plugin-updater": "^2.9.0",
"@zip.js/zip.js": "^2.7.53",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
@@ -90,11 +93,12 @@
"semver": "^7.7.1",
"stripe": "^18.2.1",
"tinycolor2": "^1.6.0",
"zustand": "5.0.1"
"zustand": "5.0.6"
},
"devDependencies": {
"@next/bundle-analyzer": "^15.4.2",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.6.0",
"@tauri-apps/cli": "2.7.0",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
@@ -465,5 +465,9 @@
"Google Books": "كتب جوجل",
"Open Library": "المكتبة المفتوحة",
"Fiction, Science, History": "خيال، علوم، تاريخ",
"Select Cover Image": "اختر صورة الغلاف"
"Select Cover Image": "اختر صورة الغلاف",
"Open Book in New Window": "فتح الكتاب في نافذة جديدة",
"Voices for {{lang}}": "الأصوات لـ {{lang}}",
"Yandex Translate": "ترجمة Yandex",
"YYYY or YYYY-MM-DD": "YYYY أو YYYY-MM-DD"
}
@@ -449,5 +449,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Belletristik, Wissenschaft, Geschichte",
"Select Cover Image": "Coverbild auswählen"
"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",
"YYYY or YYYY-MM-DD": "YYYY oder YYYY-MM-DD"
}
@@ -449,5 +449,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Μυθοπλασία, Επιστήμη, Ιστορία",
"Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου"
"Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου",
"Open Book in New Window": "Άνοιγμα βιβλίου σε νέο παράθυρο",
"Voices for {{lang}}": "Φωνές για {{lang}}",
"Yandex Translate": "Μετάφραση Yandex",
"YYYY or YYYY-MM-DD": "YYYY ή YYYY-MM-DD"
}
@@ -453,5 +453,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Ficción, Ciencia, Historia",
"Select Cover Image": "Seleccionar imagen de portada"
"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",
"YYYY or YYYY-MM-DD": "YYYY o YYYY-MM-DD"
}
@@ -453,5 +453,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Fiction, Science, Histoire",
"Select Cover Image": "Sélectionner l'image de couverture"
"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",
"YYYY or YYYY-MM-DD": "YYYY ou YYYY-MM-DD"
}
@@ -449,5 +449,9 @@
"Google Books": "गूगल बुक्स",
"Open Library": "ओपन लाइब्रेरी",
"Fiction, Science, History": "काल्पनिक, विज्ञान, इतिहास",
"Select Cover Image": "कवर इमेज चुनें"
"Select Cover Image": "कवर इमेज चुनें",
"Open Book in New Window": "नई विंडो में पुस्तक खोलें",
"Voices for {{lang}}": "{{lang}} के लिए आवाज़ें",
"Yandex Translate": "यांडेक्स अनुवाद",
"YYYY or YYYY-MM-DD": "YYYY या YYYY-MM-DD"
}
@@ -445,5 +445,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Fiksi, Sains, Sejarah",
"Select Cover Image": "Pilihan Gambar Sampul"
"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",
"YYYY or YYYY-MM-DD": "YYYY atau YYYY-MM-DD"
}
@@ -453,5 +453,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Narrativa, Scienza, Storia",
"Select Cover Image": "Seleziona immagine di copertina"
"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",
"YYYY or YYYY-MM-DD": "AAAA o AAAA-MM-GG"
}
@@ -445,5 +445,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "小説、科学、歴史",
"Select Cover Image": "表紙画像を選択"
"Select Cover Image": "表紙画像を選択",
"Open Book in New Window": "新しいウィンドウで本を開く",
"Voices for {{lang}}": "{{lang}}の音声",
"Yandex Translate": "Yandex翻訳",
"YYYY or YYYY-MM-DD": "YYYYまたはYYYY-MM-DD"
}
@@ -445,5 +445,9 @@
"Google Books": "구글 북스",
"Open Library": "오픈 라이브러리",
"Fiction, Science, History": "소설, 과학, 역사",
"Select Cover Image": "표지 이미지 선택"
"Select Cover Image": "표지 이미지 선택",
"Open Book in New Window": "새 창에서 책 열기",
"Voices for {{lang}}": "{{lang}}의 음성",
"Yandex Translate": "Yandex 번역",
"YYYY or YYYY-MM-DD": "YYYY 또는 YYYY-MM-DD"
}
@@ -449,5 +449,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Fictie, Wetenschap, Geschiedenis",
"Select Cover Image": "Selecteer omslagafbeelding"
"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",
"YYYY or YYYY-MM-DD": "YYYY of YYYY-MM-DD"
}
@@ -457,5 +457,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Beletrystyka, Nauka, Historia",
"Select Cover Image": "Zaznacz obraz okładki"
"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",
"YYYY or YYYY-MM-DD": "YYYY lub YYYY-MM-DD"
}
@@ -453,5 +453,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Ficção, Ciência, História",
"Select Cover Image": "Selecionar imagem da capa"
"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",
"YYYY or YYYY-MM-DD": "YYYY ou YYYY-MM-DD"
}
@@ -457,5 +457,9 @@
"Google Books": "Google Книги",
"Open Library": "Open Library",
"Fiction, Science, History": "Художественная литература, Наука, История",
"Select Cover Image": "Выбрать изображение обложки"
"Select Cover Image": "Выбрать изображение обложки",
"Open Book in New Window": "Открыть книгу в новом окне",
"Voices for {{lang}}": "Голоса для {{lang}}",
"Yandex Translate": "Yandex Translate",
"YYYY or YYYY-MM-DD": "YYYY или YYYY-MM-DD"
}
@@ -445,5 +445,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "นิยาย, วิทยาศาสตร์, ประวัติศาสตร์",
"Select Cover Image": "เลือกภาพปก"
"Select Cover Image": "เลือกภาพปก",
"Open Book in New Window": "เปิดหนังสือในหน้าต่างใหม่",
"Voices for {{lang}}": "เสียงสำหรับ {{lang}}",
"Yandex Translate": "Yandex Translate",
"YYYY or YYYY-MM-DD": "YYYY หรือ YYYY-MM-DD"
}
@@ -449,5 +449,9 @@
"Google Books": "Google Books",
"Open Library": "Open Library",
"Fiction, Science, History": "Kurgu, Bilim, Tarih",
"Select Cover Image": "Kapak Resmi Seç"
"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",
"YYYY or YYYY-MM-DD": "YYYY veya YYYY-MM-DD"
}
@@ -1,7 +1,7 @@
{
"(detected)": "(виявлено)",
"About Readest": "Про Readest",
"Add your notes here...": "Додайте свої нотатки тут...",
"Add your notes here...": "Додайте свої нотатки сюди...",
"Animation": "Анімація",
"Annotate": "Анотація",
"Apply": "Застосувати",
@@ -395,30 +395,30 @@
"Enable": "Увімкнути",
"Upgrade to Readest Premium": "Оновити до Readest Premium",
"Show Source Text": "Показати вихідний текст",
"Cross-Platform Sync": "Міжплатформова Синхронізація",
"Cross-Platform Sync": "Міжплатформова синхронізація",
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "Безперешкодно синхронізуйте свою бібліотеку, прогрес, виділення та нотатки на всіх своїх пристроях. Більше ніколи не втратите свої книги.",
"Customizable Reading": "Користуацьке читання",
"Customizable Reading": "Користувацьке читання",
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "Персоналізуйте кожну деталь за допомогою налаштовуваних шрифтів, макетів, тем та розширених налаштувань відображення для ідеального читання.",
"AI Read Aloud": "Озвучування ШІ",
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "Насолоджуйтесь читанням без використання рук з природними звучаннями голосів, які оживляють ваші книги.",
"AI Translations": "Переклади ШІ",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Перекладайте будь-який текст миттєво за допомогою Google, Azure або DeepL. Розумійте зміст будь-якою мовою.",
"Connect with fellow readers and get help fast in our friendly community channels.": "Спілкуйтеся з іншими читачами та отримуйте швидку допомогу в наших дружніх каналах спільноти.",
"Unlimited AI Read Aloud Hours": "Необмежені Години Озвучування ШІ",
"Unlimited AI Read Aloud Hours": "Необмежені години озвучування ШІ",
"Listen without limits—convert as much text as you like into immersive audio.": "Слухайте без обмежень—перетворюйте стільки тексту, скільки хочете, в захоплююче аудіо.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Розблокуйте покращені можливості перекладу з більшим щоденним використанням та розширеними функціями.",
"DeepL Pro Access": "Доступ до DeepL Pro",
"2 GB Cloud Sync Storage": "2 ГБ Хмарного Сховища",
"2 GB Cloud Sync Storage": "2 ГБ Хмарного сховища",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Насолоджуйтеся швидшими відповідями та персональною допомогою, коли вам потрібна підтримка.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Досягнуто денну квоту перекладів. Оновіть свій план, щоб продовжити використовувати переклади ШІ.",
"Includes All Plus Plan Benefits": "Включає Всі Переваги Plus Плану",
"Early Feature Access": "Ранній Доступ до Функцій",
"Includes All Plus Plan Benefits": "Включає усі переваги Plus плану",
"Early Feature Access": "Ранній Доступ до функцій",
"Be the first to explore new features, updates, and innovations before anyone else.": "Досліджуйте нові функції, оновлення та інновації першими.",
"Advanced AI Tools": "Розширені ШІ Інструменти",
"Advanced AI Tools": "Розширені ШІ інструменти",
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Використовуйте потужні ШІ інструменти для розумного читання, перекладу та пошуку контенту.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Перекладайте до 100 000 символів щодня з найточнішим доступним рушієм перекладу.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Перекладайте до 500 000 символів щодня з найточнішим доступним рушієм перекладу.",
"10 GB Cloud Sync Storage": "10 ГБ Хмарного Сховища",
"10 GB Cloud Sync Storage": "10 ГБ Хмарного сховища",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 2 ГБ хмарного сховища.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 10 ГБ хмарного сховища.",
"Deleted cloud backup of the book: {{title}}": "Видалено хмарну резервну копію книги: {{title}}",
@@ -429,22 +429,22 @@
"Subtitle": "Підзаголовок",
"Enter book subtitle": "Введіть підзаголовок книги",
"Enter author name": "Введіть ім'я автора",
"Series": "Серія",
"Enter series name": "Введіть назву серії",
"Series Index": "Номер у серії",
"Enter series index": "Введіть номер у серії",
"Total in Series": "Всього в серії",
"Enter total books in series": "Введіть загальну кількість книг у серії",
"Enter publisher": "Введіть видавництво",
"Publication Date": "Дата публікації",
"Series": "Цикл",
"Enter series name": "Введіть назву циклу",
"Series Index": "Номер циклу",
"Enter series index": "Введіть номер циклу",
"Total in Series": "Всього у циклі",
"Enter total books in series": "Введіть загальну кількість книг у циклі",
"Enter publisher": "Введіть видавництвця",
"Publication Date": "Дата видання",
"Identifier": "Ідентифікатор",
"Enter book description": "Введіть опис книги",
"Change cover image": "Змінити зображення обкладинки",
"Replace": "Замінити",
"Unlock cover": "Розблокувати обкладинку",
"Lock cover": "Заблокувати обкладинку",
"Auto-Retrieve Metadata": "Автоматично отримати метадані",
"Auto-Retrieve": "Автоматичне отримання",
"Auto-Retrieve Metadata": "Автоматично підтягнути метадані",
"Auto-Retrieve": "Автоматичне підтягування",
"Unlock all fields": "Розблокувати всі поля",
"Unlock All": "Розблокувати все",
"Lock all fields": "Заблокувати всі поля",
@@ -454,8 +454,12 @@
"Locked": "Заблоковано",
"Select Metadata Source": "Оберіть джерело метаданих",
"Keep manual input": "Зберегти ручне введення",
"Google Books": "Google Books",
"Google Books": "Google Книги",
"Open Library": "Open Library",
"Fiction, Science, History": "Художня література, Наука, Історія",
"Select Cover Image": "Оберіть зображення обкладинки"
"Fiction, Science, History": "Художня література, Наукова фантастика, Історія",
"Select Cover Image": "Оберіть зображення обкладинки",
"Open Book in New Window": "Відкрити книгу в новому вікні",
"Voices for {{lang}}": "Голоси для {{lang}}",
"Yandex Translate": "Yandex Перекладач",
"YYYY or YYYY-MM-DD": "YYYY або YYYY-MM-DD"
}
@@ -445,5 +445,9 @@
"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"
"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",
"YYYY or YYYY-MM-DD": "YYYY hoặc YYYY-MM-DD"
}
@@ -317,7 +317,7 @@
"Quota Exceeded": "额度不足",
"{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻译字符数",
"Translation Characters": "翻译字数",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 种音",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 种音",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "出了点问题。请不要担心,我们的团队已经收到通知并正在修复中。",
"Error Details:": "错误详情:",
"Try Again": "重试",
@@ -445,5 +445,9 @@
"Google Books": "谷歌图书",
"Open Library": "开放图书馆",
"Fiction, Science, History": "小说,科学,历史",
"Select Cover Image": "选择封面图片"
"Select Cover Image": "选择封面图片",
"Open Book in New Window": "在新窗口中打开书籍",
"Voices for {{lang}}": "{{lang}} 的语音",
"Yandex Translate": "Yandex 翻译",
"YYYY or YYYY-MM-DD": "YYYY 或 YYYY-MM-DD"
}
@@ -445,5 +445,9 @@
"Google Books": "Google 圖書",
"Open Library": "開放圖書館",
"Fiction, Science, History": "小說,科學,歷史",
"Select Cover Image": "選擇封面圖片"
"Select Cover Image": "選擇封面圖片",
"Open Book in New Window": "在新視窗中打開書籍",
"Voices for {{lang}}": "{{lang}} 的語音",
"Yandex Translate": "Yandex 翻譯",
"YYYY or YYYY-MM-DD": "YYYY 或 YYYY-MM-DD"
}
+34
View File
@@ -1,5 +1,39 @@
{
"releases": {
"0.9.67": {
"date": "2025-07-22",
"notes": [
"Reader: Fixed file opening issue when filenames contain commas",
"Translation: Added Yandex Translator as a new translation provider",
"Layout: Hovering over header now reveals macOS traffic light window controls",
"Theme: Reader background color is now customizable for EPUB and PDF",
"Library: Search now includes book format, group names, and descriptions",
"Library: Various improvements to metadata editor and bookshelf management",
"Linux: Fixed social login failure on Linux systems"
]
},
"0.9.66": {
"date": "2025-07-21",
"notes": [
"Layout: Fixed nested table of contents items not expanding in long TOC lists",
"Reader: Opening files from the OS now takes highest priority",
"TTS: Improved voice filtering for some language codes",
"App: Fixed loading issues on older browsers",
"App: Fixed wrong MIME types generated on Linux"
]
},
"0.9.65": {
"date": "2025-07-19",
"notes": [
"Reader: Added support for opening multiple reader windows on desktop",
"Layout: Added visible window borders on Linux",
"TTS: Improved handling of invalid book language",
"TTS: Skipped reading footnote anchor links",
"Translation: Fixed an issue where table of contents entries were not translated",
"Sync: Edited book metadata is now included in sync across devices",
"iOS: Disabled context menu when long-pressing on book covers"
]
},
"0.9.64": {
"date": "2025-07-16",
"notes": [
+15 -15
View File
@@ -15,7 +15,7 @@ name = "readestlib"
crate-type = ["staticlib", "cdylib", "lib"]
[build-dependencies]
tauri-build = { version = "2.1.1", features = [] }
tauri-build = { version = "2", features = [] }
[dependencies]
serde_json = "1.0"
@@ -31,19 +31,19 @@ reqwest = { version = "0.12", default-features = false, features = [
"stream",
] }
tauri = { version = "2.5.1", features = [ "protocol-asset" ] }
tauri-build = "2.3.0"
tauri-plugin-log = "2.4.0"
tauri-plugin-fs = "2.2.1"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-os = "2.2.1"
tauri-plugin-http = "2.4.3"
tauri-plugin-shell = "2.2.1"
tauri-plugin-process = "2.2.1"
tauri-build = "2"
tauri-plugin-log = "2"
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
tauri-plugin-os = "2"
tauri-plugin-http = "2"
tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.6"
tauri-plugin-deep-link = "2.2.1"
tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-sign-in-with-apple = "1.0.2"
tauri-plugin-haptics = "2.2.4"
tauri-plugin-haptics = "2"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
@@ -60,6 +60,6 @@ objc2-foundation = { version = "0.3", features = ["NSError", "NSArray"] }
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2.2.3"
tauri-plugin-updater = "2.7.0"
tauri-plugin-window-state = "2.2.2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main", "updater"],
"windows": ["main", "updater", "reader-*"],
"permissions": [
"core:default",
"fs:default",
@@ -68,6 +68,9 @@
{
"url": "https://edge.microsoft.com"
},
{
"url": "https://translate.toil.cc"
},
{
"url": "https://*.microsofttranslator.com"
},
@@ -91,6 +94,7 @@
"core:window:allow-toggle-maximize",
"core:window:allow-set-fullscreen",
"core:window:allow-set-always-on-top",
"core:window:allow-destroy",
"core:webview:allow-create-webview-window",
"core:path:allow-resolve-directory",
"log:default",
@@ -9,13 +9,13 @@ exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-native-bridge"
[dependencies]
tauri = { version = "2.3.1" }
tauri = { version = "2" }
serde = "1.0"
thiserror = "2"
schemars = "0.8"
[build-dependencies]
tauri-plugin = { version = "2.0.4", features = ["build"] }
tauri-plugin = { version = "2", features = ["build"] }
schemars = "0.8"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
@@ -9,11 +9,11 @@ exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-native-tts"
[dependencies]
tauri = { version = "2.5.0" }
tauri = { version = "2" }
serde = "1.0"
thiserror = "2"
schemars = "0.8"
[build-dependencies]
tauri-plugin = { version = "2.2.0", features = ["build"] }
tauri-plugin = { version = "2", features = ["build"] }
schemars = "0.8"
+18 -6
View File
@@ -252,12 +252,24 @@ pub fn run() {
.title("");
#[cfg(all(not(target_os = "macos"), desktop))]
let win_builder = win_builder
.decorations(false)
.transparent(true)
.visible(false)
.shadow(true)
.title("Readest");
let win_builder = {
let mut builder = win_builder
.decorations(false)
.visible(false)
.shadow(true)
.title("Readest");
#[cfg(target_os = "windows")]
{
builder = builder.transparent(false);
}
#[cfg(target_os = "linux")]
{
builder = builder.transparent(true);
}
builder
};
win_builder.build().unwrap();
// let win = win_builder.build().unwrap();
@@ -144,7 +144,8 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
.expect("NS window should exist on state to handle resize")
as id;
if state.window.label() == "main" {
if state.window.label() == "main" || state.window.label().starts_with("reader")
{
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
TRAFFIC_LIGHTS_VISIBLE,
@@ -278,7 +279,8 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
.expect("Failed to emit event");
let id = state.window.ns_window().expect("Failed to emit event") as id;
if state.window.label() == "main" {
if state.window.label() == "main" || state.window.label().starts_with("reader")
{
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
TRAFFIC_LIGHTS_VISIBLE,
@@ -363,26 +365,26 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
app_box: *mut c_void = app_box,
toolbar: id = cocoa::base::nil,
super_delegate: id = current_delegate,
(windowShouldClose:) => on_window_should_close as extern fn(&Object, Sel, id) -> BOOL,
(windowWillClose:) => on_window_will_close as extern fn(&Object, Sel, id),
(windowDidResize:) => on_window_did_resize::<R> as extern fn(&Object, Sel, id),
(windowDidMove:) => on_window_did_move as extern fn(&Object, Sel, id),
(windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern fn(&Object, Sel, id),
(windowDidBecomeKey:) => on_window_did_become_key as extern fn(&Object, Sel, id),
(windowDidResignKey:) => on_window_did_resign_key as extern fn(&Object, Sel, id),
(draggingEntered:) => on_dragging_entered as extern fn(&Object, Sel, id) -> BOOL,
(prepareForDragOperation:) => on_prepare_for_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
(performDragOperation:) => on_perform_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
(concludeDragOperation:) => on_conclude_drag_operation as extern fn(&Object, Sel, id),
(draggingExited:) => on_dragging_exited as extern fn(&Object, Sel, id),
(window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
(windowDidEnterFullScreen:) => on_window_did_enter_full_screen::<R> as extern fn(&Object, Sel, id),
(windowWillEnterFullScreen:) => on_window_will_enter_full_screen::<R> as extern fn(&Object, Sel, id),
(windowDidExitFullScreen:) => on_window_did_exit_full_screen::<R> as extern fn(&Object, Sel, id),
(windowWillExitFullScreen:) => on_window_will_exit_full_screen::<R> as extern fn(&Object, Sel, id),
(windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern fn(&Object, Sel, id),
(effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern fn(&Object, Sel, id),
(effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern fn(&Object, Sel, id)
(windowShouldClose:) => on_window_should_close as extern "C" fn(&Object, Sel, id) -> BOOL,
(windowWillClose:) => on_window_will_close as extern "C" fn(&Object, Sel, id),
(windowDidResize:) => on_window_did_resize::<R> as extern "C" fn(&Object, Sel, id),
(windowDidMove:) => on_window_did_move as extern "C" fn(&Object, Sel, id),
(windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern "C" fn(&Object, Sel, id),
(windowDidBecomeKey:) => on_window_did_become_key as extern "C" fn(&Object, Sel, id),
(windowDidResignKey:) => on_window_did_resign_key as extern "C" fn(&Object, Sel, id),
(draggingEntered:) => on_dragging_entered as extern "C" fn(&Object, Sel, id) -> BOOL,
(prepareForDragOperation:) => on_prepare_for_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL,
(performDragOperation:) => on_perform_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL,
(concludeDragOperation:) => on_conclude_drag_operation as extern "C" fn(&Object, Sel, id),
(draggingExited:) => on_dragging_exited as extern "C" fn(&Object, Sel, id),
(window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern "C" fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
(windowDidEnterFullScreen:) => on_window_did_enter_full_screen::<R> as extern "C" fn(&Object, Sel, id),
(windowWillEnterFullScreen:) => on_window_will_enter_full_screen::<R> as extern "C" fn(&Object, Sel, id),
(windowDidExitFullScreen:) => on_window_did_exit_full_screen::<R> as extern "C" fn(&Object, Sel, id),
(windowWillExitFullScreen:) => on_window_will_exit_full_screen::<R> as extern "C" fn(&Object, Sel, id),
(windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern "C" fn(&Object, Sel, id),
(effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern "C" fn(&Object, Sel, id),
(effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern "C" fn(&Object, Sel, id)
}))
}
}
+1 -1
View File
@@ -15,7 +15,7 @@
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://ik.imagekit.io",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://ik.imagekit.io",
+68 -50
View File
@@ -334,64 +334,82 @@ export default function AuthPage() {
return isTauriAppPlatform() ? (
<div
className={clsx(
'fixed inset-0 z-0 flex select-none flex-col items-center overflow-y-auto',
'bg-base-100 border-base-200 border',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
'bg-base-100 inset-0 flex select-none flex-col items-center overflow-hidden',
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
appService?.isLinuxApp && 'window-border',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<div
ref={headerRef}
className={clsx(
'fixed z-10 flex w-full items-center justify-between py-2 pe-6 ps-4',
appService?.hasTrafficLight && 'pt-11',
'flex h-full w-full flex-col items-center overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
>
<button onClick={handleGoBack} className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0')}>
<IoArrowBack className='text-base-content' />
</button>
<div
ref={headerRef}
className={clsx(
'fixed z-10 flex w-full items-center justify-between py-2 pe-6 ps-4',
appService?.hasTrafficLight && 'pt-11',
)}
>
<button
onClick={handleGoBack}
className={clsx('btn btn-ghost h-12 min-h-12 w-12 p-0 sm:h-8 sm:min-h-8 sm:w-8')}
>
<IoArrowBack className='text-base-content' />
</button>
{appService?.hasWindowBar && (
<WindowButtons
headerRef={headerRef}
showMinimize={!isTrafficLightVisible}
showMaximize={!isTrafficLightVisible}
showClose={!isTrafficLightVisible}
onClose={handleGoBack}
{appService?.hasWindowBar && (
<WindowButtons
headerRef={headerRef}
showMinimize={!isTrafficLightVisible}
showMaximize={!isTrafficLightVisible}
showClose={!isTrafficLightVisible}
onClose={handleGoBack}
/>
)}
</div>
<div
className={clsx(
'z-20 flex flex-col items-center pb-8',
appService?.hasTrafficLight ? 'mt-24' : 'mt-12',
)}
style={{ maxWidth: '420px' }}
>
<ProviderLogin
provider='google'
handleSignIn={tauriSignIn}
Icon={FcGoogle}
label={_('Sign in with Google')}
/>
)}
</div>
<div
className={clsx('z-20 pb-8', appService?.hasTrafficLight ? 'mt-24' : 'mt-12')}
style={{ maxWidth: '420px' }}
>
<ProviderLogin
provider='google'
handleSignIn={tauriSignIn}
Icon={FcGoogle}
label={_('Sign in with Google')}
/>
<ProviderLogin
provider='apple'
handleSignIn={appService?.isIOSApp || USE_APPLE_SIGN_IN ? tauriSignInApple : tauriSignIn}
Icon={FaApple}
label={_('Sign in with Apple')}
/>
<ProviderLogin
provider='github'
handleSignIn={tauriSignIn}
Icon={FaGithub}
label={_('Sign in with GitHub')}
/>
<hr className='border-base-300 my-3 mt-6 w-64 border-t' />
<Auth
supabaseClient={supabase}
appearance={{ theme: ThemeSupa }}
theme={isDarkMode ? 'dark' : 'light'}
magicLink={true}
providers={[]}
redirectTo={getTauriRedirectTo(false)}
localization={getAuthLocalization()}
/>
<ProviderLogin
provider='apple'
handleSignIn={
appService?.isIOSApp || USE_APPLE_SIGN_IN ? tauriSignInApple : tauriSignIn
}
Icon={FaApple}
label={_('Sign in with Apple')}
/>
<ProviderLogin
provider='github'
handleSignIn={tauriSignIn}
Icon={FaGithub}
label={_('Sign in with GitHub')}
/>
<hr className='border-base-300 my-3 mt-6 w-64 border-t' />
<div className='w-full'>
<Auth
supabaseClient={supabase}
appearance={{ theme: ThemeSupa }}
theme={isDarkMode ? 'dark' : 'light'}
magicLink={true}
providers={[]}
redirectTo={getTauriRedirectTo(false)}
localization={getAuthLocalization()}
/>
</div>
</div>
</div>
</div>
) : (
+2
View File
@@ -5,6 +5,7 @@ import { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { parseWebViewVersion } from '@/utils/ua';
import { handleGlobalError } from '@/utils/error';
interface ErrorPageProps {
error: Error & { digest?: string };
@@ -19,6 +20,7 @@ export default function Error({ error, reset }: ErrorPageProps) {
useEffect(() => {
setBrowserInfo(parseWebViewVersion(appService));
posthog.captureException(error);
handleGlobalError(error);
}, [appService, error]);
const handleGoHome = () => {
@@ -11,7 +11,7 @@ import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
import { formatAuthors, formatTitle } from '@/utils/book';
import { isMd5 } from '@/utils/md5';
@@ -169,11 +169,17 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const openSelectedBooks = () => {
setTimeout(() => setLoading(true), 200);
navigateToReader(router, selectedBooks);
handleSetSelectMode(false);
if (appService?.hasWindow && settings.openBookInNewWindow) {
showReaderWindow(appService, selectedBooks);
} else {
setTimeout(() => setLoading(true), 200);
navigateToReader(router, selectedBooks);
}
};
const openBookDetails = () => {
handleSetSelectMode(false);
const book = libraryBooks.find((book) => book.hash === selectedBooks[0]);
if (book) {
handleShowDetailsBook(book);
@@ -216,7 +222,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const searchTerm = new RegExp(queryTerm, 'i');
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return searchTerm.test(title) || searchTerm.test(authors);
return (
searchTerm.test(title) ||
searchTerm.test(authors) ||
searchTerm.test(item.format) ||
(item.groupName && searchTerm.test(item.groupName)) ||
(item.metadata?.description && searchTerm.test(item.metadata?.description))
);
};
const bookSorter = (a: Book, b: Book) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -8,8 +9,9 @@ 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 { getOSPlatform } from '@/utils/misc';
import { getLocalBookFilename } from '@/utils/book';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
@@ -117,8 +119,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const makeBookAvailable = async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
if (await appService?.isBookAvailable(book)) {
if (!book.downloadedAt) {
if (!book.downloadedAt || !book.coverDownloadedAt) {
book.downloadedAt = Date.now();
book.coverDownloadedAt = Date.now();
updateBook(envConfig, book);
}
return true;
@@ -137,24 +140,36 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
return true;
};
const handleBookClick = async (book: Book) => {
if (isSelectMode) {
toggleSelection(book.hash);
} else {
if (!(await makeBookAvailable(book))) return;
navigateToReader(router, [book.hash]);
}
};
const handleBookClick = useCallback(
async (book: Book) => {
if (isSelectMode) {
toggleSelection(book.hash);
} else {
if (!(await makeBookAvailable(book))) return;
if (appService?.hasWindow && settings.openBookInNewWindow) {
showReaderWindow(appService, [book.hash]);
} else {
navigateToReader(router, [book.hash]);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isSelectMode, settings.openBookInNewWindow, appService],
);
const handleGroupClick = (group: BooksGroup) => {
if (isSelectMode) {
toggleSelection(group.id);
} else {
const params = new URLSearchParams(searchParams?.toString());
params.set('group', group.id);
navigateToLibrary(router, `${params.toString()}`);
}
};
const handleGroupClick = useCallback(
(group: BooksGroup) => {
if (isSelectMode) {
toggleSelection(group.id);
} else {
const params = new URLSearchParams(searchParams?.toString());
params.set('group', group.id);
navigateToLibrary(router, `${params.toString()}`);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isSelectMode, searchParams],
);
const bookContextMenuHandler = async (book: Book) => {
if (!appService?.hasContextMenu) return;
@@ -236,8 +251,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
menu.popup();
};
const { pressing, handlers } = useLongPress({
onLongPress: async () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSelectItem = useCallback(
throttle(() => {
if (!isSelectMode) {
handleSetSelectMode(true);
}
@@ -246,22 +262,56 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
} else {
toggleSelection((item as BooksGroup).id);
}
},
onTap: () => {
}, 100),
[isSelectMode],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleOpenItem = useCallback(
throttle(() => {
if (isSelectMode) {
handleSelectItem();
return;
}
if ('format' in item) {
handleBookClick(item as Book);
} else {
handleGroupClick(item as BooksGroup);
}
},
onContextMenu: () => {
}, 100),
[handleSelectItem, handleBookClick, handleGroupClick],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleContextMenu = useCallback(
throttle(() => {
if ('format' in item) {
bookContextMenuHandler(item as Book);
} else {
groupContextMenuHandler(item as BooksGroup);
}
}, 100),
[],
);
const { pressing, handlers } = useLongPress(
{
onLongPress: () => {
handleSelectItem();
},
onTap: () => {
handleOpenItem();
},
onContextMenu: () => {
if (appService?.hasContextMenu) {
handleContextMenu();
} else if (appService?.isAndroidApp) {
handleSelectItem();
}
},
},
});
[isSelectMode, handleSelectItem, handleOpenItem, handleContextMenu],
);
return (
<div className={clsx(mode === 'list' && 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
@@ -270,6 +320,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
'group',
mode === 'grid' && 'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-4 sm:px-4',
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
appService?.isMobileApp && 'no-context-menu',
pressing ? (mode === 'grid' ? 'scale-95' : 'scale-98') : 'scale-100',
)}
style={{
@@ -83,6 +83,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const toggleOpenInNewWindow = () => {
settings.openBookInNewWindow = !settings.openBookInNewWindow;
setSettings(settings);
saveSettings(envConfig, settings);
setIsDropdownOpen?.(false);
};
const toggleAlwaysOnTop = () => {
settings.alwaysOnTop = !settings.alwaysOnTop;
setSettings(settings);
@@ -219,6 +226,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
/>
)}
<hr className='border-base-200 my-1' />
{appService?.hasWindow && (
<MenuItem
label={_('Open Book in New Window')}
Icon={settings.openBookInNewWindow ? MdCheck : undefined}
onClick={toggleOpenInNewWindow}
/>
)}
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
{appService?.hasWindow && (
<MenuItem
@@ -70,7 +70,7 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
const processOldBook = async (oldBook: Book) => {
const matchingBook = syncedBooks.find((newBook) => newBook.hash === oldBook.hash);
if (matchingBook) {
if (!matchingBook.deletedAt && matchingBook.uploadedAt && !oldBook.downloadedAt) {
if (!matchingBook.deletedAt && matchingBook.uploadedAt && !oldBook.coverDownloadedAt) {
await appService?.downloadBook(oldBook, true);
}
const mergedBook =
+36 -10
View File
@@ -2,7 +2,7 @@
import clsx from 'clsx';
import * as React from 'react';
import { useState, useRef, useEffect, Suspense } from 'react';
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
import 'overlayscrollbars/overlayscrollbars.css';
@@ -194,6 +194,28 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
};
const handleRefreshLibrary = useCallback(async () => {
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
const library = await appService.loadLibraryBooks();
setSettings(settings);
setLibrary(library);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [envConfig, appService]);
useEffect(() => {
if (appService?.hasWindow) {
const currentWebview = getCurrentWebview();
const unlisten = currentWebview.listen('close-reader-window', async () => {
handleRefreshLibrary();
});
return () => {
unlisten.then((fn) => fn());
};
}
return;
}, [appService, handleRefreshLibrary]);
useEffect(() => {
const libraryPage = document.querySelector('.library-page');
if (!appService?.isMobile) {
@@ -294,7 +316,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
navigateToReader(router, bookIds);
}
}
}, [pendingNavigationBookIds, router]);
}, [pendingNavigationBookIds, appService, router]);
useEffect(() => {
if (isInitiating.current) return;
@@ -322,12 +344,15 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// Reuse the library from the store when we return from the reader
const library = libraryBooks.length > 0 ? libraryBooks : await appService.loadLibraryBooks();
setCheckOpenWithBooks(checkOpenWithBooks && (await handleOpenWithBooks(appService, library)));
setCheckLastOpenBooks(
checkLastOpenBooks &&
settings.openLastBooks &&
(await handleOpenLastBooks(appService, settings.lastOpenBooks, library)),
);
let opened = false;
if (checkOpenWithBooks) {
opened = await handleOpenWithBooks(appService, library);
}
setCheckOpenWithBooks(opened);
if (!opened && checkLastOpenBooks && settings.openLastBooks) {
opened = await handleOpenLastBooks(appService, settings.lastOpenBooks, library);
}
setCheckLastOpenBooks(opened);
setLibrary(library);
setLibraryLoaded(true);
@@ -486,9 +511,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
};
const handleBookDownload = async (book: Book) => {
const handleBookDownload = async (book: Book, redownload = false) => {
try {
await appService?.downloadBook(book, false, (progress) => {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
@@ -647,6 +672,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
className={clsx(
'library-page bg-base-200 text-base-content flex select-none flex-col overflow-hidden',
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
appService?.isLinuxApp && 'window-border',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
@@ -31,7 +31,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { getConfig, getBookData } = useBookDataStore();
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { setGridInsets, hoveredBookKey } = useReaderStore();
const { isSideBarVisible, sideBarBookKey } = useSidebarStore();
const { sideBarBookKey } = useSidebarStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const screenInsets = useSafeAreaInsets();
@@ -70,7 +70,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
return (
<div
className={clsx('relative grid h-full flex-grow')}
className={clsx('books-grid bg-base-100 relative grid h-full flex-grow')}
style={{
gridTemplateColumns: gridTemplate.columns,
gridTemplateRows: gridTemplate.rows,
@@ -106,7 +106,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
key={bookKey}
className={clsx(
'relative h-full w-full overflow-hidden',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
{isBookmarked && !hoveredBookKey && <Ribbon width={`${horizontalGapPercent}%`} />}
@@ -158,6 +158,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
)}
{showHeader && (
<SectionInfo
bookKey={bookKey}
section={sectionLabel}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isScrolled={viewSettings.scrolled}
@@ -14,6 +14,7 @@ import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import {
applyFixedlayoutStyles,
applyImageStyle,
applyTranslationStyle,
getStyles,
@@ -134,6 +135,10 @@ const FoliateViewer: React.FC<{
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
if (bookDoc.rendition?.layout === 'pre-paginated') {
applyFixedlayoutStyles(detail.doc, viewSettings);
}
applyImageStyle(detail.doc);
// Inline scripts in tauri platforms are not executed by default
@@ -312,9 +317,13 @@ const FoliateViewer: React.FC<{
if (viewRef.current && viewRef.current.renderer) {
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
if (bookDoc.rendition?.layout === 'pre-paginated') {
const docs = viewRef.current.renderer.getContents();
docs.forEach(({ doc }) => applyFixedlayoutStyles(doc, viewSettings));
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode, isDarkMode]);
}, [themeCode, isDarkMode, viewSettings?.overrideColor, viewSettings?.invertImgColorInDark]);
useEffect(() => {
if (viewRef.current && viewRef.current.renderer && viewSettings) {
@@ -40,7 +40,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
const { appService } = useEnv();
const headerRef = useRef<HTMLDivElement>(null);
const {
isTrafficLightVisible,
trafficLightInFullscreen,
setTrafficLightVisibility,
initializeTrafficLightStore,
initializeTrafficLightListeners,
@@ -62,21 +62,27 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
setTrafficLightVisibility(true);
return () => {
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [appService]);
useEffect(() => {
if (!appService?.hasTrafficLight) return;
if (isSideBarVisible) return;
setTrafficLightVisibility(isSideBarVisible);
if (hoveredBookKey === bookKey && isTopLeft) {
setTrafficLightVisibility(true, { x: 10, y: 20 });
} else if (!hoveredBookKey) {
setTrafficLightVisibility(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSideBarVisible]);
}, [appService, isSideBarVisible, hoveredBookKey]);
const isVisible = hoveredBookKey === bookKey || isDropdownOpen;
const isHeaderVisible = hoveredBookKey === bookKey || isDropdownOpen;
const trafficLightInHeader =
appService?.hasTrafficLight && !trafficLightInFullscreen && !isSideBarVisible && isTopLeft;
return (
<div
@@ -86,14 +92,15 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
}}
>
<div
className={clsx('absolute top-0 z-10 hidden h-11 w-full sm:flex')}
className={clsx('absolute top-0 z-10 h-11 w-full')}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
<div
className={clsx(
'bg-base-100 absolute left-0 right-0 top-0 z-10',
isVisible ? 'visible' : 'hidden',
appService?.hasRoundedWindow && 'rounded-window-top-right',
isHeaderVisible ? 'visible' : 'hidden',
)}
style={{
height: systemUIVisible ? `${Math.max(gridInsets.top, statusBarHeight)}px` : '0px',
@@ -104,11 +111,11 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
className={clsx(
`header-bar bg-base-100 absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
`shadow-xs transition-[opacity,margin-top] duration-300`,
isTrafficLightVisible && isTopLeft && !isSideBarVisible ? 'pl-16' : 'pl-4',
trafficLightInHeader ? 'pl-20' : 'pl-4',
appService?.hasRoundedWindow && 'rounded-window-top-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-top-left',
isHoveredAnim && 'hover-bar-anim',
isVisible ? 'pointer-events-auto visible' : 'pointer-events-none opacity-0',
isHeaderVisible ? 'pointer-events-auto visible' : 'pointer-events-none opacity-0',
isDropdownOpen && 'header-bar-pinned',
)}
style={{
@@ -148,10 +155,14 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
className='window-buttons flex h-full items-center'
headerRef={headerRef}
showMinimize={
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
bookKeys.length == 1 &&
!appService?.hasTrafficLight &&
appService?.appPlatform !== 'web'
}
showMaximize={
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
bookKeys.length == 1 &&
!appService?.hasTrafficLight &&
appService?.appPlatform !== 'web'
}
onClose={() => {
setHoveredBookKey(null);
@@ -111,8 +111,9 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
settings.globalReadSettings && (
<div
className={clsx(
`reader-page bg-base-100 text-base-content select-none`,
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window',
`reader-page text-base-content select-none overflow-hidden`,
appService?.isLinuxApp && 'window-border',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<Suspense>
@@ -5,14 +5,16 @@ import * as React from 'react';
import { useState, useRef, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { Book } from '@/types/book';
import { SystemSettings } from '@/types/settings';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { UnlistenFn } from '@tauri-apps/api/event';
import { tauriHandleClose, tauriHandleOnCloseWindow } from '@/utils/window';
import { isTauriAppPlatform } from '@/services/environment';
import { uniqueId } from '@/utils/misc';
@@ -83,7 +85,10 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
saveSettings(envConfig, settings);
}
if (isTauriAppPlatform()) tauriHandleOnCloseWindow(handleCloseBooks);
let unlistenOnCloseWindow: Promise<UnlistenFn>;
if (isTauriAppPlatform()) {
unlistenOnCloseWindow = tauriHandleOnCloseWindow(handleCloseBooks);
}
window.addEventListener('beforeunload', handleCloseBooks);
eventDispatcher.on('beforereload', handleCloseBooks);
eventDispatcher.on('quit-app', handleCloseBooks);
@@ -91,6 +96,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
window.removeEventListener('beforeunload', handleCloseBooks);
eventDispatcher.off('beforereload', handleCloseBooks);
eventDispatcher.off('quit-app', handleCloseBooks);
unlistenOnCloseWindow?.then((fn) => fn());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKeys]);
@@ -128,7 +134,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const settings = useSettingsStore.getState().settings;
await Promise.all(bookKeys.map((key) => saveConfigAndCloseBook(key)));
await saveSettings(envConfig, settings);
}, 500);
}, 200);
const handleCloseBooksToLibrary = () => {
handleCloseBooks();
@@ -143,11 +149,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
dismissBook(bookKey);
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
const openWithFiles = (await parseOpenWithFiles()) || [];
if (openWithFiles.length > 0 && !appService?.isMobile) {
tauriHandleClose();
} else {
saveSettingsAndGoToLibrary();
if (appService?.hasWindow) {
if (openWithFiles.length > 0) {
return await tauriHandleClose();
}
const currentWindow = getCurrentWindow();
if (currentWindow.label.startsWith('reader')) {
return await currentWindow.close();
}
}
saveSettingsAndGoToLibrary();
}
};
@@ -166,7 +177,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}
return (
<div className={clsx('flex', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
<div className={clsx('reader-content flex', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
<Notebook />
@@ -3,8 +3,10 @@ import React from 'react';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
interface SectionInfoProps {
bookKey: string;
section?: string;
showDoubleBorder: boolean;
isScrolled: boolean;
@@ -15,6 +17,7 @@ interface SectionInfoProps {
}
const SectionInfo: React.FC<SectionInfoProps> = ({
bookKey,
section,
showDoubleBorder,
isScrolled,
@@ -24,6 +27,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
gridInsets,
}) => {
const { appService } = useEnv();
const { hoveredBookKey } = useReaderStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const topInset = Math.max(
gridInsets.top,
@@ -68,6 +72,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
className={clsx(
'text-neutral-content text-center font-sans text-xs font-light',
isVertical ? '' : 'line-clamp-1',
!isVertical && hoveredBookKey == bookKey && 'hidden',
)}
>
{section || ''}
@@ -216,7 +216,7 @@ const Notebook: React.FC = ({}) => {
}
`}</style>
<div
className='drag-bar absolute left-0 top-0 -m-3 h-full w-0.5 cursor-col-resize p-3'
className='drag-bar absolute left-0 top-0 -m-2 h-full w-0.5 cursor-col-resize bg-transparent p-2'
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
/>
@@ -10,7 +10,7 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { formatAuthors, formatTitle } from '@/utils/book';
const BookCard = ({ book }: { book: Book }) => {
const { coverImageUrl, title, author } = book;
const { coverImageUrl, title, author, metadata } = book;
const _ = useTranslation();
const { isDarkMode } = useThemeStore();
const iconSize18 = useResponsiveSize(18);
@@ -22,7 +22,7 @@ const BookCard = ({ book }: { book: Book }) => {
return (
<div className='flex h-20 w-full items-center'>
<Image
src={coverImageUrl!}
src={metadata?.coverImageUrl || coverImageUrl!}
alt={_('Book Cover')}
width={56}
height={80}
@@ -27,7 +27,7 @@ const SidebarHeader: React.FC<{
<div
className={clsx(
'sidebar-header flex h-11 items-center justify-between pe-2',
isTrafficLightVisible ? 'pl-20' : 'ps-1.5',
isTrafficLightVisible ? 'ps-1.5 sm:ps-20' : 'ps-1.5',
)}
dir='ltr'
>
@@ -11,6 +11,7 @@ import { BookSearchConfig, BookSearchResult } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { debounce } from '@/utils/debounce';
import { isCJKStr } from '@/utils/lang';
import { createRejecttFilter } from '@/utils/node';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
@@ -117,19 +118,6 @@ const SearchBar: React.FC<SearchBarProps> = ({
return searchTerm.length >= minLength;
};
const createAcceptNode = ({ withRT = true } = {}) => {
return (node: Node): number => {
if (node.nodeType === Node.ELEMENT_NODE) {
const name = (node as Element).tagName.toLowerCase();
if (name === 'script' || name === 'style' || (!withRT && name === 'rt')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_SKIP;
}
return NodeFilter.FILTER_ACCEPT;
};
};
const handleSearch = useCallback(
async (term: string) => {
console.log('searching for:', term);
@@ -139,7 +127,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
...searchConfig,
index,
query: term,
acceptNode: createAcceptNode({ withRT: !primaryLang.startsWith('ja') }),
acceptNode: createRejecttFilter({
tags: primaryLang.startsWith('ja') ? ['rt'] : [],
}),
});
const results: BookSearchResult[] = [];
let lastProgressLogTime = 0;
@@ -259,7 +259,7 @@ const SideBar: React.FC<{
)}
<div
className={clsx(
'drag-bar absolute -right-1 top-0 -m-1 h-full w-0.5 cursor-col-resize p-1',
'drag-bar absolute right-0 top-0 -m-2 h-full w-0.5 cursor-col-resize bg-transparent p-2',
isMobile && 'hidden',
)}
onMouseDown={handleHorizontalDragStart}
@@ -81,7 +81,12 @@ const TOCView: React.FC<{
}
}, [initialize]);
useTextTranslation(bookKey, containerRef.current, false, 'translation-target-toc');
useTextTranslation(
bookKey,
containerRef.current || staticListRef.current,
false,
'translation-target-toc',
);
useEffect(() => {
const updateHeight = () => {
@@ -112,7 +117,7 @@ const TOCView: React.FC<{
resizeObserver.disconnect();
}
};
}, []);
}, [expandedItems]);
const activeHref = useMemo(() => progress?.sectionHref || null, [progress?.sectionHref]);
const flatItems = useFlattenedTOC(toc, expandedItems);
@@ -208,7 +213,11 @@ const TOCView: React.FC<{
}, [flatItems, scrollToActiveItem]);
return flatItems.length > 256 ? (
<div className='rounded' data-overlayscrollbars-initialize='' ref={containerRef}>
<div
className='virtual-list rounded pt-2'
data-overlayscrollbars-initialize=''
ref={containerRef}
>
<VirtualList
ref={vitualListRef}
outerRef={listOuterRef}
@@ -228,7 +237,7 @@ const TOCView: React.FC<{
</VirtualList>
</div>
) : (
<div className='rounded pt-2' ref={staticListRef}>
<div className='static-list rounded pt-2' ref={staticListRef}>
{flatItems.map((flatItem, index) => (
<StaticListRow
key={`static-row-${index}`}
@@ -4,6 +4,7 @@ import { MdBookmarkBorder as BookmarkIcon } from 'react-icons/md';
import { IoIosList as TOCIcon } from 'react-icons/io';
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
const TabNavigation: React.FC<{
@@ -11,12 +12,16 @@ const TabNavigation: React.FC<{
onTabChange: (tab: string) => void;
}> = ({ activeTab, onTabChange }) => {
const _ = useTranslation();
const { appService } = useEnv();
const tabs = ['toc', 'annotations', 'bookmarks'];
return (
<div
className={clsx('bottom-tab border-base-300/50 bg-base-200 relative flex w-full border-t')}
className={clsx(
'bottom-tab border-base-300/50 bg-base-200 relative flex w-full border-t',
appService?.hasRoundedWindow && 'rounded-window-bottom-left',
)}
dir='ltr'
>
<div
@@ -212,11 +212,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
await ttsController.initViewTTS();
const ssml = view.tts?.from(ttsFromRange);
if (ssml) {
let lang = parseSSMLLang(ssml) || 'en';
// We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value
if (lang === 'en' && primaryLang && primaryLang !== 'en') {
lang = primaryLang.split('-')[0]!;
}
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
setIsPlaying(true);
setTtsLang(lang);
@@ -9,6 +9,7 @@ import { useReaderStore } from '@/store/readerStore';
import { TranslationFunc, useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
import { getLanguageName } from '@/utils/lang';
type TTSPanelProps = {
bookKey: string;
@@ -178,7 +179,19 @@ const TTSPanel = ({
useEffect(() => {
const fetchVoices = async () => {
const voiceGroups = await onGetVoices(ttsLang);
setVoiceGroups(voiceGroups);
const voicesCount = voiceGroups.reduce((acc, group) => acc + group.voices.length, 0);
if (!voiceGroups || voicesCount === 0) {
console.warn('No voices found for TTSPanel');
setVoiceGroups([
{
id: 'no-voices',
name: _('Voices for {{lang}}', { lang: getLanguageName(ttsLang) }),
voices: [],
},
]);
} else {
setVoiceGroups(voiceGroups);
}
};
fetchVoices();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -289,48 +302,44 @@ const TTSPanel = ({
>
{voiceGroups.map((voiceGroup, index) => {
return (
voiceGroup.voices.length > 0 && (
<div key={voiceGroup.id} className=''>
<div className='flex items-center gap-2 px-2 py-1'>
<span
style={{ width: `${defaultIconSize}px`, height: `${defaultIconSize}px` }}
></span>
<span className='text-sm text-gray-400 sm:text-xs'>
{_('{{engine}}: {{count}} voices', {
engine: _(voiceGroup.name),
count: voiceGroup.voices.length,
})}
</span>
</div>
{voiceGroup.voices.map((voice, voiceIndex) => (
<li
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
>
<div className='flex items-center px-2'>
<span
style={{
width: `${defaultIconSize}px`,
height: `${defaultIconSize}px`,
}}
>
{selectedVoice === voice.id && (
<MdCheck className='text-base-content' />
)}
</span>
<span
className={clsx(
'text-base sm:text-sm',
voice.disabled && 'text-gray-400',
)}
>
{_(voice.name)}
</span>
</div>
</li>
))}
<div key={voiceGroup.id} className=''>
<div className='flex items-center gap-2 px-2 py-1'>
<span
style={{ width: `${defaultIconSize}px`, height: `${defaultIconSize}px` }}
></span>
<span className='text-sm text-gray-400 sm:text-xs'>
{_('{{engine}}: {{count}} voices', {
engine: _(voiceGroup.name),
count: voiceGroup.voices.length,
})}
</span>
</div>
)
{voiceGroup.voices.map((voice, voiceIndex) => (
<li
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
>
<div className='flex items-center px-2'>
<span
style={{
width: `${defaultIconSize}px`,
height: `${defaultIconSize}px`,
}}
>
{selectedVoice === voice.id && <MdCheck className='text-base-content' />}
</span>
<span
className={clsx(
'max-w-[180px] overflow-hidden text-ellipsis text-base sm:text-sm',
voice.disabled && 'text-gray-400',
)}
>
{_(voice.name)}
</span>
</div>
</li>
))}
</div>
);
})}
</ul>
@@ -22,7 +22,10 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({ onGoBack }) => {
appService?.hasTrafficLight && 'pt-11',
)}
>
<button onClick={onGoBack} className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0')}>
<button
onClick={onGoBack}
className={clsx('btn btn-ghost h-12 min-h-12 w-12 p-0 sm:h-8 sm:min-h-8 sm:w-8')}
>
<IoArrowBack className='text-base-content' />
</button>
@@ -44,13 +44,16 @@ const PlansComparison: React.FC<PlansComparisonProps> = ({
};
const handleTouchStart = (e: React.TouchEvent) => {
const touchStart = e.touches[0]!.clientX;
const touchStartX = e.touches[0]!.clientX;
const touchStartY = e.touches[0]!.clientY;
const handleTouchMove = (moveEvent: TouchEvent) => {
const touchEnd = moveEvent.touches[0]!.clientX;
const diff = touchStart - touchEnd;
const touchEndX = moveEvent.touches[0]!.clientX;
const touchEndY = moveEvent.touches[0]!.clientY;
const diffX = touchStartX - touchEndX;
const diffY = touchStartY - touchEndY;
if (Math.abs(diff) > 50) {
if (diff > 0) {
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
if (diffX > 0) {
handlePlanSwipe('left');
} else {
handlePlanSwipe('right');
+57 -49
View File
@@ -69,7 +69,7 @@ const ProfilePage = () => {
useTheme({ systemUIVisible: false });
const { quotas, userPlan } = useQuotaStats();
const { quotas, userPlan = 'free' } = useQuotaStats();
const handleGoBack = () => {
if (showEmbeddedCheckout) {
@@ -191,7 +191,7 @@ const ProfilePage = () => {
useEffect(() => {
fetch(WEB_STRIPE_PLANS_URL)
.then((res) => res.json())
.then((data) => setAvailablePlans(data));
.then((data) => setAvailablePlans(data || []));
}, []);
if (!mounted) {
@@ -219,62 +219,70 @@ const ProfilePage = () => {
return (
<div
className={clsx(
'fixed inset-0 z-0 flex select-none flex-col items-center overflow-y-auto',
'bg-base-100 border-base-200 border',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
'bg-base-100 inset-0 select-none overflow-hidden',
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
appService?.isLinuxApp && 'window-border',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<ProfileHeader onGoBack={handleGoBack} />
<div className='w-full min-w-60 max-w-4xl py-10'>
{loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
<div
className={clsx(
'flex h-full w-full flex-col items-center overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
{showEmbeddedCheckout ? (
<div className='bg-base-100 rounded-lg p-4'>
<Checkout
clientSecret={checkoutState.clientSecret}
sessionId={checkoutState.sessionId}
planName={checkoutState.planName}
onSuccess={handleCheckoutSuccess}
/>
</div>
) : (
<div className='sm:bg-base-200 overflow-hidden rounded-lg sm:p-6 sm:shadow-md'>
<div className='flex flex-col gap-y-8'>
<div className='flex flex-col gap-y-8 px-6'>
<UserInfo
avatarUrl={avatarUrl}
userFullName={userFullName}
userEmail={userEmail}
planDetails={planDetails}
/>
>
<ProfileHeader onGoBack={handleGoBack} />
<div className='w-full min-w-60 max-w-4xl py-10'>
{loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)}
{showEmbeddedCheckout ? (
<div className='bg-base-100 rounded-lg p-4'>
<Checkout
clientSecret={checkoutState.clientSecret}
sessionId={checkoutState.sessionId}
planName={checkoutState.planName}
onSuccess={handleCheckoutSuccess}
/>
</div>
) : (
<div className='sm:bg-base-200 overflow-hidden rounded-lg sm:p-6 sm:shadow-md'>
<div className='flex flex-col gap-y-8'>
<div className='flex flex-col gap-y-8 px-6'>
<UserInfo
avatarUrl={avatarUrl}
userFullName={userFullName}
userEmail={userEmail}
planDetails={planDetails}
/>
<UsageStats quotas={quotas} />
</div>
<UsageStats quotas={quotas} />
</div>
<div className='flex flex-col gap-y-8 sm:px-6'>
<PlansComparison
availablePlans={availablePlans}
userPlan={userPlan}
onSubscribe={handleSubscribe}
/>
</div>
<div className='flex flex-col gap-y-8 sm:px-6'>
<PlansComparison
availablePlans={availablePlans}
userPlan={userPlan}
onSubscribe={handleSubscribe}
/>
</div>
<div className='flex flex-col gap-y-8 px-6'>
<AccountActions
userPlan={userPlan}
onLogout={handleLogout}
onConfirmDelete={handleConfirmDelete}
onManageSubscription={handleManageSubscription}
/>
<div className='flex flex-col gap-y-8 px-6'>
<AccountActions
userPlan={userPlan}
onLogout={handleLogout}
onConfirmDelete={handleConfirmDelete}
onManageSubscription={handleManageSubscription}
/>
</div>
</div>
</div>
</div>
)}
)}
</div>
<Toast />
</div>
<Toast />
</div>
);
};
+26 -11
View File
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import Image from 'next/image';
import { useEffect, useRef } from 'react';
import { Book } from '@/types/book';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { formatAuthors, formatTitle } from '@/utils/book';
@@ -21,26 +22,40 @@ const BookCover: React.FC<BookCoverProps> = ({
imageClassName,
isPreview,
}) => {
const handleImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.target as HTMLImageElement;
img.style.display = 'none';
const coverRef = useRef<HTMLDivElement>(null);
const mainContainer = img.closest('.book-cover-container');
const fallbackDiv = mainContainer?.querySelector('.fallback-cover');
if (fallbackDiv) {
fallbackDiv.classList.remove('invisible');
const toggleImageVisibility = (showImage: boolean) => {
if (coverRef.current) {
const coverImage = coverRef.current.querySelector('.cover-image');
const fallbackCover = coverRef.current.querySelector('.fallback-cover');
if (coverImage) {
coverImage.classList.toggle('invisible', !showImage);
}
if (fallbackCover) {
fallbackCover.classList.toggle('invisible', showImage);
}
}
};
const handleImageError = () => {
toggleImageVisibility(false);
};
useEffect(() => {
toggleImageVisibility(true);
}, [book.metadata?.coverImageUrl, book.coverImageUrl]);
return (
<div className={clsx('book-cover-container relative flex h-full w-full', className)}>
<div
ref={coverRef}
className={clsx('book-cover-container relative flex h-full w-full', className)}
>
{coverFit === 'crop' ? (
<Image
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
alt={book.title}
fill={true}
className={clsx('crop-cover-img object-cover', imageClassName)}
className={clsx('cover-image crop-cover-img object-cover', imageClassName)}
onError={handleImageError}
/>
) : (
@@ -57,7 +72,7 @@ const BookCover: React.FC<BookCoverProps> = ({
height={0}
sizes='100vw'
className={clsx(
'fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
'cover-image fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
imageClassName,
)}
onError={handleImageError}
+5 -1
View File
@@ -160,7 +160,11 @@ const Dialog: React.FC<DialogProps> = ({
dir={isRtl ? 'rtl' : undefined}
>
<div
className={clsx('overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20', bgClassName)}
className={clsx(
'overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20',
appService?.hasRoundedWindow && 'rounded-window',
bgClassName,
)}
onClick={onClose}
/>
<div
@@ -114,7 +114,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
field: 'published',
label: _('Publication Date'),
value: metadata.published || '',
placeholder: 'YYYY or YYYY-MM-DD',
placeholder: _('YYYY or YYYY-MM-DD'),
},
{
field: 'language',
@@ -178,9 +178,12 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
let files;
if (isTauriAppPlatform()) {
files = (await selectImageFileTauri()) as string[];
if (files.length > 0) {
if (appService && files.length > 0) {
metadata.coverImageFile = files[0]!;
metadata.coverImageUrl = appService?.fs.getURL(files[0]!);
const tempName = 'cover-temp.png';
const cachePrefix = appService.fs.getPrefix('Cache');
await appService.fs.copyFile(files[0]!, tempName, 'Cache');
metadata.coverImageUrl = await appService.fs.getURL(`${cachePrefix}/${tempName}`);
setNewCoverImageUrl(metadata.coverImageUrl!);
}
} else {
@@ -196,7 +199,10 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
<div className='bg-base-100 relative w-full rounded-lg'>
<div className='mb-6 flex items-start gap-4'>
<div className='flex w-[30%] max-w-32 flex-col gap-2'>
<div className='aspect-[28/41] h-full shadow-md'>
<div
className='aspect-[28/41] h-full shadow-md'
onClick={!isCoverLocked ? handleSelectLocalImage : undefined}
>
<BookCover
mode='list'
book={{
@@ -222,7 +228,12 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
)}
title={_('Change cover image')}
>
<MdEdit className='h-5 w-5 sm:h-4 sm:w-4' />
<MdEdit
className={clsx(
'h-5 w-5 sm:h-4 sm:w-4',
isCoverLocked ? 'fill-base-content' : 'fill-gray-600',
)}
/>
<span className='hidden sm:inline'>{_('Replace')}</span>
</button>
@@ -364,7 +375,8 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
</button>
<button
onClick={onSave}
className='rounded-md bg-green-500 px-4 py-2 text-white hover:bg-green-600'
disabled={fieldErrors && Object.keys(fieldErrors).length > 0}
className='rounded-md bg-green-500 px-4 py-2 text-white hover:bg-green-600 disabled:opacity-50'
>
{_('Save')}
</button>
@@ -18,7 +18,7 @@ interface BookDetailModalProps {
book: Book;
isOpen: boolean;
onClose: () => void;
handleBookDownload?: (book: Book) => void;
handleBookDownload?: (book: Book, redownload?: boolean) => void;
handleBookUpload?: (book: Book) => void;
handleBookDelete?: (book: Book) => void;
handleBookDeleteCloudBackup?: (book: Book) => void;
@@ -132,7 +132,7 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
const handleRedownload = async () => {
handleClose();
if (handleBookDownload) {
handleBookDownload(book);
handleBookDownload(book, true);
}
};
@@ -100,7 +100,7 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
<div className='overflow-hidden'>
<span className='font-bold'>{_('Published')}</span>
<p className='text-neutral-content text-sm'>
{formatDate(metadata.published) || _('Unknown')}
{formatDate(metadata.published, true) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
@@ -60,7 +60,7 @@ export const useMetadataEdit = (metadata: BookMetadata | null) => {
const newMeta = { ...editedMeta } as { [key: string]: unknown };
switch (field) {
case 'subject':
newMeta['subject'] = value ? value.split(/,||、/).map((s) => s.trim()) : [];
newMeta['subject'] = value ? value.split(/,|;||、/).map((s) => s.trim()) : [];
break;
default:
newMeta[field] = value;
+3 -1
View File
@@ -13,7 +13,9 @@ interface CliArgument {
}
const parseWindowOpenWithFiles = () => {
return window.OPEN_WITH_FILES;
const params = new URLSearchParams(window.location.search);
const files = params.getAll('file');
return files.length > 0 ? files : window.OPEN_WITH_FILES;
};
const parseCLIOpenWithFiles = async () => {
+15 -10
View File
@@ -21,14 +21,17 @@ interface UseLongPressResult {
};
}
export const useLongPress = ({
onTap,
onLongPress,
onContextMenu,
onCancel,
threshold = 500,
moveThreshold = 10,
}: UseLongPressOptions): UseLongPressResult => {
export const useLongPress = (
{
onTap,
onLongPress,
onContextMenu,
onCancel,
threshold = 500,
moveThreshold = 10,
}: UseLongPressOptions,
deps: React.DependencyList,
): UseLongPressResult => {
const [pressing, setPressing] = useState(false);
const timerRef = useRef<NodeJS.Timeout>();
const startPosRef = useRef<{ x: number; y: number } | null>(null);
@@ -114,15 +117,17 @@ export const useLongPress = ({
e.stopPropagation();
onContextMenu();
}
reset();
},
[onContextMenu],
[onContextMenu, reset],
);
useEffect(() => {
return () => {
clearTimeout(timerRef.current);
};
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return {
pressing,
+15 -7
View File
@@ -1,10 +1,12 @@
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { isTauriAppPlatform } from '@/services/environment';
import { useLibraryStore } from '@/store/libraryStore';
import { navigateToLibrary } from '@/utils/nav';
import { navigateToLibrary, showLibraryWindow } from '@/utils/nav';
interface SingleInstancePayload {
args: string[];
@@ -13,6 +15,7 @@ interface SingleInstancePayload {
export function useOpenWithBooks() {
const router = useRouter();
const { appService } = useEnv();
const { setCheckOpenWithBooks } = useLibraryStore();
const listenedOpenWithBooks = useRef(false);
@@ -23,14 +26,19 @@ export function useOpenWithBooks() {
filePath = decodeURI(filePath.replace('file://', ''));
}
if (!/^(https?:|data:|blob:)/i.test(filePath)) {
window.OPEN_WITH_FILES = [filePath];
setCheckOpenWithBooks(true);
navigateToLibrary(router, `reload=${Date.now()}`);
const settings = useSettingsStore.getState().settings;
if (appService?.hasWindow && settings.openBookInNewWindow) {
showLibraryWindow(appService, [filePath]);
} else {
window.OPEN_WITH_FILES = [filePath];
setCheckOpenWithBooks(true);
navigateToLibrary(router, `reload=${Date.now()}`);
}
}
};
useEffect(() => {
if (!isTauriAppPlatform()) return;
if (!isTauriAppPlatform() || !appService) return;
if (listenedOpenWithBooks.current) return;
listenedOpenWithBooks.current = true;
@@ -54,5 +62,5 @@ export function useOpenWithBooks() {
unlistenOpenUrl.then((f) => f());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [appService]);
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const useQuotaStats = () => {
const _ = useTranslation();
const { token, user } = useAuth();
const [quotas, setQuotas] = useState<QuotaType[]>([]);
const [userPlan, setUserPlan] = useState<UserPlan>('free');
const [userPlan, setUserPlan] = useState<UserPlan | undefined>(undefined);
useEffect(() => {
if (!user || !token) return;
+4
View File
@@ -83,6 +83,10 @@ export type BookMetadata = {
export interface BookDoc {
metadata: BookMetadata;
rendition?: {
layout?: 'pre-paginated' | 'reflowable';
viewport?: { width: number; height: number };
};
dir: string;
toc?: Array<TOCItem>;
sections?: Array<SectionItem>;
+1 -1
View File
@@ -45,5 +45,5 @@ export function middleware(request: NextRequest) {
}
export const config = {
matcher: ['/api/stripe/:path*', '/api/metadata/:path*'],
matcher: ['/api/:path*', '/api/stripe/:path*', '/api/metadata/:path*'],
};
+34 -7
View File
@@ -18,7 +18,7 @@ import {
getPrimaryLanguage,
} from '@/utils/book';
import { partialMD5 } from '@/utils/md5';
import { BookDoc, DocumentLoader } from '@/libs/document';
import { BookDoc, DocumentLoader, EXTS } from '@/libs/document';
import {
DEFAULT_BOOK_LAYOUT,
DEFAULT_BOOK_STYLE,
@@ -205,6 +205,7 @@ export abstract class BaseAppService implements AppService {
hash,
format,
title: formatTitle(loadedBook.metadata.title),
sourceTitle: formatTitle(loadedBook.metadata.title),
author: formatAuthors(loadedBook.metadata.author, loadedBook.metadata.language),
primaryLanguage: getPrimaryLanguage(loadedBook.metadata.language),
createdAt: existingBook ? existingBook.createdAt : Date.now(),
@@ -215,8 +216,9 @@ export abstract class BaseAppService implements AppService {
};
// update book metadata when reimporting the same book
if (existingBook) {
existingBook.title = book.title;
existingBook.author = book.author;
existingBook.title = existingBook.title ?? book.title;
existingBook.sourceTitle = existingBook.sourceTitle ?? book.sourceTitle;
existingBook.author = existingBook.author ?? book.author;
existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage;
}
@@ -298,6 +300,7 @@ export abstract class BaseAppService implements AppService {
if (includingLocal) {
book.deletedAt = Date.now();
book.downloadedAt = null;
book.coverDownloadedAt = null;
}
if (includingUploaded) {
book.uploadedAt = null;
@@ -357,6 +360,7 @@ export abstract class BaseAppService implements AppService {
book.updatedAt = Date.now();
book.uploadedAt = Date.now();
book.downloadedAt = Date.now();
book.coverDownloadedAt = Date.now();
} else {
throw new Error('Book file not uploaded');
}
@@ -377,12 +381,19 @@ export abstract class BaseAppService implements AppService {
}
}
async downloadBook(book: Book, onlyCover = false, onProgress?: ProgressHandler): Promise<void> {
async downloadBook(
book: Book,
onlyCover = false,
redownload = false,
onProgress?: ProgressHandler,
): Promise<void> {
let bookDownloaded = false;
let bookCoverDownloaded = false;
const completedFiles = { count: 0 };
let toDownloadFpCount = 0;
const needDownCover = !(await this.fs.exists(getCoverFilename(book), 'Books'));
const needDownBook = !onlyCover && !(await this.fs.exists(getLocalBookFilename(book), 'Books'));
const needDownCover = !(await this.fs.exists(getCoverFilename(book), 'Books')) || redownload;
const needDownBook =
(!onlyCover && !(await this.fs.exists(getLocalBookFilename(book), 'Books'))) || redownload;
if (needDownCover) {
toDownloadFpCount++;
}
@@ -402,6 +413,7 @@ export abstract class BaseAppService implements AppService {
const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
completedFiles.count++;
bookCoverDownloaded = true;
}
} catch (error) {
// don't throw error here since some books may not have cover images at all
@@ -420,6 +432,9 @@ export abstract class BaseAppService implements AppService {
if (bookDownloaded || (!onlyCover && !needDownBook)) {
book.downloadedAt = Date.now();
}
if ((bookCoverDownloaded || !needDownCover) && !book.coverDownloadedAt) {
book.coverDownloadedAt = Date.now();
}
}
async isBookAvailable(book: Book): Promise<boolean> {
@@ -460,7 +475,19 @@ export abstract class BaseAppService implements AppService {
} else if (book.url) {
file = await this.fs.openFile(book.url, 'None');
} else {
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
// 0.9.64 has a bug that book.title might be modified but the filename is not updated
const bookDir = getDir(book);
const files = await this.fs.readDir(getDir(book), 'Books');
if (files.length > 0) {
const bookFile = files.find((f) => f.path.endsWith(`.${EXTS[book.format]}`));
if (bookFile) {
file = await this.fs.openFile(`${bookDir}/${bookFile.path}`, 'Books');
} else {
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
}
} else {
throw new Error(BOOK_FILE_NOT_FOUND_ERROR);
}
}
return { book, file, config: await this.loadBookConfig(book, settings) };
}
@@ -40,6 +40,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
keepLogin: false,
autoUpload: true,
alwaysOnTop: false,
openBookInNewWindow: false,
alwaysShowStatusBar: false,
autoCheckUpdates: true,
screenWakeLock: false,
@@ -220,7 +220,7 @@ export class NativeAppService extends BaseAppService {
override hasWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
override hasWindowBar = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
override hasContextMenu = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
override hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android') && !!window.IS_ROUNDED;
override hasRoundedWindow = OS_TYPE === 'linux';
override hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android';
override hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android';
override hasUpdater =
@@ -2,6 +2,7 @@ import { TranslationProvider } from '../types';
import { deeplProvider } from './deepl';
import { azureProvider } from './azure';
import { googleProvider } from './google';
import { yandexProvider } from './yandex';
function createTranslator<T extends string>(
name: T,
@@ -18,11 +19,13 @@ function createTranslator<T extends string>(
const deeplTranslator = createTranslator('deepl', deeplProvider);
const azureTranslator = createTranslator('azure', azureProvider);
const googleTranslator = createTranslator('google', googleProvider);
const yandexTranslator = createTranslator('yandex', yandexProvider);
const availableTranslators = [
deeplTranslator,
azureTranslator,
googleTranslator,
yandexTranslator,
// Add more translators here
];
@@ -0,0 +1,77 @@
import { stubTranslation as _ } from '@/utils/misc';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { normalizeToShortLang } from '@/utils/lang';
import { TranslationProvider } from '../types';
/**
* Based on https://translate.toil.cc/v2/docs API specification
*/
async function translateSingleTextForService(
text: string,
lang: string,
service: string,
): Promise<string[]> {
const fetchImpl = isTauriAppPlatform() ? tauriFetch : window.fetch;
const url = 'https://translate.toil.cc/v2/translate/';
const request = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
lang: lang,
service: service,
text: text,
})
};
const response = await fetchImpl(url, request);
if (!response.ok) {
const response_json = JSON.stringify(await response.json());
throw new Error(`${service} failed with status ${response.status}\n${text.length}\n${JSON.stringify(request)}\n${response_json}`);
}
const data = await response.json();
if (
data &&
Array.isArray(data.translations)
) {
return data.translations;
} else {
// fallback: return original texts if translation failed
return [text];
}
};
export const yandexProvider: TranslationProvider = {
name: 'yandex',
label: _('Yandex Translate'),
authRequired: false,
translate: async (texts: string[], sourceLang: string, targetLang: string): Promise<string[]> => {
if (!texts.length) return [];
/**
Possible options:
- yandexcloud: often returns 500: {"error":"The text couldn't be translated, because Forbidden"}
- yandexgpt: often better than others
- yandextranslate
- yandexbrowser
*/
const service = "yandexgpt";
// Yandex does not accept "auto" language
const source_lang = sourceLang == "AUTO" ? "en" : normalizeToShortLang(sourceLang).toLowerCase();
const target_lang = normalizeToShortLang(targetLang).toLowerCase();
const lang = `${source_lang}-${target_lang}`;
const responses = await Promise.all(texts.map(async text => {
return await translateSingleTextForService(text, lang, service)
}));
const translatedTexts = responses.flat();
return translatedTexts;
}
};
@@ -1,7 +1,7 @@
import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent } from './TTSClient';
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { parseSSMLMarks } from '@/utils/ssml';
import { TTSController } from './TTSController';
import { TTSUtils } from './TTSUtils';
import { TTSGranularity, TTSVoice, TTSVoicesGroup } from './types';
@@ -60,19 +60,14 @@ export class EdgeTTSClient implements TTSClient {
};
async *speak(ssml: string, signal: AbortSignal, preload = false) {
const { marks } = parseSSMLMarks(ssml);
let defaultLang = parseSSMLLang(ssml) || 'en';
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
defaultLang = this.#primaryLang;
}
const { marks } = parseSSMLMarks(ssml, this.#primaryLang);
if (preload) {
// preload the first 2 marks immediately and the rest in the background
const maxImmediate = 2;
for (let i = 0; i < Math.min(maxImmediate, marks.length); i++) {
const mark = marks[i]!;
const { language } = mark;
const voiceLang = language || defaultLang;
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#currentVoiceId = voiceId;
await this.#edgeTTS
@@ -86,8 +81,7 @@ export class EdgeTTSClient implements TTSClient {
for (let i = maxImmediate; i < marks.length; i++) {
const mark = marks[i]!;
try {
const { language } = mark;
const voiceLang = language || defaultLang;
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
await this.#edgeTTS.createAudio(this.getPayload(voiceLang, mark.text, voiceId));
} catch (err) {
@@ -116,8 +110,7 @@ export class EdgeTTSClient implements TTSClient {
break;
}
try {
const { language } = mark;
const voiceLang = language || defaultLang;
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#speakingLang = voiceLang;
const blob = await this.#edgeTTS.createAudio(
@@ -1,7 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { addPluginListener, PluginListener } from '@tauri-apps/api/core';
import { getUserLocale } from '@/utils/misc';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { parseSSMLMarks } from '@/utils/ssml';
import { stubTranslation as _ } from '@/utils/misc';
import { TTSClient, TTSMessageEvent } from './TTSClient';
import { TTSGranularity, TTSMark, TTSVoice, TTSVoicesGroup } from './types';
@@ -100,13 +100,12 @@ export class NativeTTSClient implements TTSClient {
return this.initialized;
}
async *speakMark(mark: TTSMark, preload: boolean, defaultLang: string, signal: AbortSignal) {
async *speakMark(mark: TTSMark, preload: boolean, signal: AbortSignal) {
if (preload) {
yield { code: 'end', message: 'Dummy preload finished' } as TTSMessageEvent;
return;
}
const { language } = mark;
const voiceLang = language || defaultLang;
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#currentVoiceId = voiceId;
this.#speakingLang = voiceLang;
@@ -179,14 +178,10 @@ export class NativeTTSClient implements TTSClient {
}
async *speak(ssml: string, signal: AbortSignal, preload: boolean = false) {
const { marks } = parseSSMLMarks(ssml);
let defaultLang = parseSSMLLang(ssml) || 'en';
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
defaultLang = this.#primaryLang;
}
const { marks } = parseSSMLMarks(ssml, this.#primaryLang);
for (const mark of marks) {
for await (const ev of this.speakMark(mark, preload, defaultLang, signal)) {
for await (const ev of this.speakMark(mark, preload, signal)) {
if (signal.aborted) {
yield { code: 'error', message: 'Aborted' } as TTSMessageEvent;
return;
@@ -3,6 +3,7 @@ import { AppService } from '@/types/system';
import { parseSSMLMarks } from '@/utils/ssml';
import { Overlayer } from 'foliate-js/overlayer.js';
import { TTSGranularity, TTSHighlightOptions, TTSMark, TTSVoice } from './types';
import { createRejecttFilter } from '@/utils/node';
import { WebSpeechClient } from './WebSpeechClient';
import { NativeTTSClient } from './NativeTTSClient';
import { EdgeTTSClient } from './EdgeTTSClient';
@@ -106,7 +107,14 @@ export class TTSController extends EventTarget {
granularity = supportedGranularities[0]!;
}
const highlightOptions: TTSHighlightOptions = { style: 'highlight', color: 'gray' };
await this.view.initTTS(granularity, this.#getHighlighter(highlightOptions));
await this.view.initTTS(
granularity,
createRejecttFilter({
tags: ['rt', 'sup'],
contents: [{ tag: 'a', content: /^\d+$/ }],
}),
this.#getHighlighter(highlightOptions),
);
}
async preloadSSML(ssml: string | undefined) {
@@ -1,6 +1,6 @@
import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent } from './TTSClient';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity, TTSMark, TTSVoice, TTSVoicesGroup } from './types';
import { WEB_SPEECH_BLACKLISTED_VOICES } from './TTSData';
import { TTSController } from './TTSController';
@@ -18,7 +18,7 @@ interface TTSBoundaryEvent {
async function* speakWithMarks(
ssml: string,
defaultLang: string,
primaryLang: string,
getRate: () => number,
getPitch: () => number,
getVoice: (lang: string) => Promise<SpeechSynthesisVoice | null>,
@@ -26,14 +26,11 @@ async function* speakWithMarks(
setSpeakingLang: (lang: string) => void,
dispatchSpeakMark: (mark: TTSMark) => void,
) {
const { marks } = parseSSMLMarks(ssml);
const { marks } = parseSSMLMarks(ssml, primaryLang);
const synth = window.speechSynthesis;
const utterance = new SpeechSynthesisUtterance();
for (const mark of marks) {
const { language } = mark;
const voiceLang = language || defaultLang;
const { language: voiceLang } = mark;
dispatchSpeakMark(mark);
utterance.text = mark.text;
utterance.rate = getRate();
@@ -149,14 +146,9 @@ export class WebSpeechClient implements TTSClient {
// no need to preload for web speech
if (preload) return;
let defaultLang = parseSSMLLang(ssml) || 'en';
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
defaultLang = this.#primaryLang;
}
for await (const ev of speakWithMarks(
ssml,
defaultLang,
this.#primaryLang,
() => this.#rate,
() => this.#pitch,
this.getWebSpeechVoiceFromLang,
+1 -1
View File
@@ -23,5 +23,5 @@ export type TTSMark = {
offset: number;
name: string;
text: string;
language?: string;
language: string;
};
@@ -151,8 +151,10 @@ const indexedDBFileSystem: FileSystem = {
async removeDir() {
// Directories are virtual in IndexedDB; no-op
},
async readDir(path: string) {
async readDir(path: string, base: BaseDir) {
const { fp } = resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<{ path: string; isDir: boolean }[]>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
@@ -162,8 +164,8 @@ const indexedDBFileSystem: FileSystem = {
const files = request.result as { path: string }[];
resolve(
files
.filter((file) => file.path.startsWith(path))
.map((file) => ({ path: file.path, isDir: false })),
.filter((file) => file.path.startsWith(fp))
.map((file) => ({ path: file.path.slice(fp.length + 1), isDir: false })),
);
};
+7 -3
View File
@@ -16,7 +16,7 @@ import { updateToc } from '@/utils/toc';
import { useSettingsStore } from './settingsStore';
import { useBookDataStore } from './bookDataStore';
import { useLibraryStore } from './libraryStore';
import { getPrimaryLanguage } from '@/utils/book';
import { formatTitle, getBaseFilename, getPrimaryLanguage } from '@/utils/book';
interface ViewState {
/* Unique key for each book view */
@@ -138,8 +138,12 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const { book: bookDoc } = await new DocumentLoader(file).open();
updateToc(bookDoc, config.viewSettings?.sortedTOC ?? false);
// Set the book's language for formerly imported books, newly imported books have this field set
book.primaryLanguage =
book.primaryLanguage ?? getPrimaryLanguage(bookDoc.metadata.language);
if (!bookDoc.metadata.title) {
bookDoc.metadata.title = getBaseFilename(file.name);
}
book.sourceTitle = formatTitle(bookDoc.metadata.title);
const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language);
book.primaryLanguage = book.primaryLanguage ?? primaryLanguage;
book.metadata = book.metadata ?? bookDoc.metadata;
useBookDataStore.setState((state) => ({
booksData: {
@@ -9,8 +9,9 @@ interface TrafficLightState {
appService?: AppService;
isTrafficLightVisible: boolean;
shouldShowTrafficLight: boolean;
trafficLightInFullscreen: boolean;
initializeTrafficLightStore: (appService: AppService) => void;
setTrafficLightVisibility: (visible: boolean) => void;
setTrafficLightVisibility: (visible: boolean, position?: { x: number; y: number }) => void;
initializeTrafficLightListeners: () => Promise<void>;
cleanupTrafficLightListeners: () => void;
unlistenEnterFullScreen?: () => void;
@@ -22,6 +23,7 @@ export const useTrafficLightStore = create<TrafficLightState>((set, get) => {
appService: undefined,
isTrafficLightVisible: false,
shouldShowTrafficLight: false,
trafficLightInFullscreen: false,
initializeTrafficLightStore: (appService: AppService) => {
set({
@@ -31,15 +33,19 @@ export const useTrafficLightStore = create<TrafficLightState>((set, get) => {
});
},
setTrafficLightVisibility: async (visible: boolean) => {
setTrafficLightVisibility: async (visible: boolean, position?: { x: number; y: number }) => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
const isFullscreen = await currentWindow.isFullscreen();
set({ isTrafficLightVisible: !isFullscreen && visible, shouldShowTrafficLight: visible });
set({
isTrafficLightVisible: !isFullscreen && visible,
shouldShowTrafficLight: visible,
trafficLightInFullscreen: isFullscreen,
});
invoke('set_traffic_lights', {
visible: visible,
x: WINDOW_CONTROL_PAD_X,
y: WINDOW_CONTROL_PAD_Y,
x: position?.x ?? WINDOW_CONTROL_PAD_X,
y: position?.y ?? WINDOW_CONTROL_PAD_Y,
});
},
@@ -48,12 +54,12 @@ export const useTrafficLightStore = create<TrafficLightState>((set, get) => {
const currentWindow = getCurrentWindow();
const unlistenEnterFullScreen = await currentWindow.listen('will-enter-fullscreen', () => {
set({ isTrafficLightVisible: false });
set({ isTrafficLightVisible: false, trafficLightInFullscreen: true });
});
const unlistenExitFullScreen = await currentWindow.listen('will-exit-fullscreen', () => {
const { shouldShowTrafficLight } = get();
set({ isTrafficLightVisible: shouldShowTrafficLight });
set({ isTrafficLightVisible: shouldShowTrafficLight, trafficLightInFullscreen: false });
});
set({ unlistenEnterFullScreen, unlistenExitFullScreen });
+29
View File
@@ -6,6 +6,7 @@
border-radius: 10px;
scrollbar-gutter: auto !important;
overscroll-behavior: none;
background-color: transparent;
--safe-area-inset-top: env(safe-area-inset-top);
--safe-area-inset-right: env(safe-area-inset-right);
@@ -97,6 +98,25 @@ foliate-view {
border-bottom-right-radius: 10px;
}
.window-border {
border-radius: 10px;
z-index: 999;
position: relative;
}
.window-border::before {
content: '';
z-index: 1000;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 1px solid theme('colors.base-300');
border-radius: 10px;
pointer-events: none;
}
.dropdown-content {
position: relative;
@apply bg-base-100;
@@ -304,3 +324,12 @@ foliate-view {
.drop-zone.drag-over .drop-indicator {
display: block;
}
.no-context-menu {
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
touch-action: manipulation;
}
+3 -1
View File
@@ -12,7 +12,8 @@ export interface Book {
filePath?: string;
hash: string;
format: BookFormat;
title: string;
title: string; // editable title from metadata
sourceTitle?: string; // parsed when the book is imported and used to locate the file
author: string;
group?: string; // deprecated in favor of groupId and groupName
groupId?: string;
@@ -26,6 +27,7 @@ export interface Book {
uploadedAt?: number | null;
downloadedAt?: number | null;
coverDownloadedAt?: number | null;
lastUpdated?: number; // deprecated in favor of updatedAt
progress?: [number, number]; // Add progress field: [current, total], 1-based page number
+2
View File
@@ -3,12 +3,14 @@ export interface DBBook {
book_hash: string;
format: string;
title: string;
source_title?: string;
author: string;
group_id?: string;
group_name?: string;
tags?: string[];
progress?: [number, number];
metadata?: string | null;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
+1
View File
@@ -27,6 +27,7 @@ export interface SystemSettings {
keepLogin: boolean;
autoUpload: boolean;
alwaysOnTop: boolean;
openBookInNewWindow: boolean;
autoCheckUpdates: boolean;
screenWakeLock: boolean;
alwaysShowStatusBar: boolean;
+6 -1
View File
@@ -59,7 +59,12 @@ export interface AppService {
): Promise<Book | null>;
deleteBook(book: Book, includingUploaded?: boolean, includingLocal?: boolean): Promise<void>;
uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void>;
downloadBook(book: Book, onlyCover?: boolean, onProgress?: ProgressHandler): Promise<void>;
downloadBook(
book: Book,
onlyCover?: boolean,
redownload?: boolean,
onProgress?: ProgressHandler,
): Promise<void>;
isBookAvailable(book: Book): Promise<boolean>;
getBookFileSize(book: Book): Promise<number | null>;
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
+5 -1
View File
@@ -20,7 +20,11 @@ export interface FoliateView extends HTMLElement {
clearSearch: () => void;
select: (target: string | number | { fraction: number }) => void;
deselect: () => void;
initTTS: (granularity?: TTSGranularity, highlight?: (range: Range) => void) => Promise<void>;
initTTS: (
granularity?: TTSGranularity,
nodeFilter?: (node: Node) => number,
highlight?: (range: Range) => void,
) => Promise<void>;
book: BookDoc;
tts: TTS | null;
language: {
+5 -4
View File
@@ -1,7 +1,7 @@
import { EXTS } from '@/libs/document';
import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book';
import { getUserLang, isContentURI, isFileURI, isValidURL, makeSafeFilename } from './misc';
import { getStorageType } from './object';
import { getStorageType } from './storage';
import { getDirFromLanguage } from './rtl';
import { SUPPORTED_LANGS } from '@/services/constants';
@@ -14,7 +14,7 @@ export const getLibraryFilename = () => {
export const getRemoteBookFilename = (book: Book) => {
// S3 storage: https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/userguide/object-keys.html
if (getStorageType() === 'r2') {
return `${book.hash}/${makeSafeFilename(book.title)}.${EXTS[book.format]}`;
return `${book.hash}/${makeSafeFilename(book.sourceTitle || book.title)}.${EXTS[book.format]}`;
} else if (getStorageType() === 's3') {
return `${book.hash}/${book.hash}.${EXTS[book.format]}`;
} else {
@@ -22,7 +22,7 @@ export const getRemoteBookFilename = (book: Book) => {
}
};
export const getLocalBookFilename = (book: Book) => {
return `${book.hash}/${makeSafeFilename(book.title)}.${EXTS[book.format]}`;
return `${book.hash}/${makeSafeFilename(book.sourceTitle || book.title)}.${EXTS[book.format]}`;
};
export const getCoverFilename = (book: Book) => {
return `${book.hash}/cover.png`;
@@ -123,7 +123,7 @@ export const getPrimaryLanguage = (lang: string | string[] | undefined) => {
return Array.isArray(lang) ? lang[0] : lang;
};
export const formatDate = (date: string | number | Date | null | undefined) => {
export const formatDate = (date: string | number | Date | null | undefined, isUTC = false) => {
if (!date) return;
const userLang = getUserLang();
try {
@@ -131,6 +131,7 @@ export const formatDate = (date: string | number | Date | null | undefined) => {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: isUTC ? 'UTC' : undefined,
});
} catch {
return;
+14
View File
@@ -0,0 +1,14 @@
export const handleGlobalError = (e: Error) => {
const isChunkError = e?.message?.includes('Loading chunk');
if (!isChunkError) {
const now = Date.now();
const lastReload = Number(sessionStorage.getItem('lastErrorReload') || '0');
if (now - lastReload > 60_000) {
sessionStorage.setItem('lastErrorReload', String(now));
window.location.reload();
} else {
console.warn('Error detected, but reload suppressed (rate limit)');
}
}
};
+25
View File
@@ -68,7 +68,32 @@ export const isSameLang = (lang1?: string | null, lang2?: string | null): boolea
return normalizedLang1 === normalizedLang2;
};
export const isValidLang = (lang?: string) => {
if (!lang) return false;
const code = lang.split('-')[0]!.toLowerCase();
return iso6392.some((l) => l.iso6391 === code || l.iso6392B === code);
};
export const code6392to6391 = (code: string): string => {
const lang = iso6392.find((l) => l.iso6392B === code);
return lang?.iso6391 || '';
};
export const getLanguageName = (code: string): string => {
const lang = normalizedLangCode(code);
const language = iso6392.find((l) => l.iso6391 === lang || l.iso6392B === lang);
return language ? language.name : lang;
};
export const inferLangFromScript = (text: string, lang: string): string => {
if (!lang || lang === 'en') {
if (/[\p{Script=Hangul}]/u.test(text)) {
return 'ko';
} else if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) {
return 'ja';
} else if (/[\p{Script=Han}]/u.test(text)) {
return 'zh';
}
}
return lang;
};
+43
View File
@@ -1,6 +1,49 @@
import { useRouter, redirect } from 'next/navigation';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { isPWA, isWebAppPlatform } from '@/services/environment';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
import { AppService } from '@/types/system';
let readerWindowsCount = 0;
const createReaderWindow = (appService: AppService, url: string) => {
const win = new WebviewWindow(`reader-${readerWindowsCount}`, {
url,
width: 800,
height: 600,
center: true,
resizable: true,
title: appService.isMacOSApp ? '' : 'Readest',
decorations: appService.isMacOSApp ? true : false,
transparent: appService.isMacOSApp ? false : true,
shadow: appService.isMacOSApp ? undefined : true,
titleBarStyle: appService.isMacOSApp ? 'overlay' : undefined,
});
win.once('tauri://created', () => {
console.log('new window created');
readerWindowsCount += 1;
});
win.once('tauri://error', (e) => {
console.error('error creating window', e);
});
win.once('tauri://destroyed', () => {
readerWindowsCount -= 1;
});
};
export const showReaderWindow = (appService: AppService, bookIds: string[]) => {
const ids = bookIds.join(BOOK_IDS_SEPARATOR);
const params = new URLSearchParams('');
params.set('ids', ids);
const url = `/reader?${params.toString()}`;
createReaderWindow(appService, url);
};
export const showLibraryWindow = (appService: AppService, filenames: string[]) => {
const params = new URLSearchParams();
filenames.forEach((filename) => params.append('file', filename));
const url = `/library?${params.toString()}`;
createReaderWindow(appService, url);
};
export const navigateToReader = (
router: ReturnType<typeof useRouter>,
+38
View File
@@ -0,0 +1,38 @@
export const createRejecttFilter = ({
tags = [],
classes = [],
attributes = [],
contents = [],
}: {
tags?: string[];
classes?: string[];
attributes?: string[];
contents?: { tag: string; content: RegExp }[];
}) => {
return (node: Node): number => {
if (node.nodeType === Node.ELEMENT_NODE) {
const name = (node as Element).tagName.toLowerCase();
if (name === 'script' || name === 'style') {
return NodeFilter.FILTER_REJECT;
}
if (tags.includes(name)) {
return NodeFilter.FILTER_REJECT;
}
if (classes.some((cls) => (node as Element).classList.contains(cls))) {
return NodeFilter.FILTER_REJECT;
}
if (attributes.some((attr) => (node as Element).hasAttribute(attr))) {
return NodeFilter.FILTER_REJECT;
}
if (
contents.some(({ tag, content }) => {
return name === tag && content.test((node as Element).textContent || '');
})
) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_SKIP;
}
return NodeFilter.FILTER_ACCEPT;
};
};
+2 -15
View File
@@ -1,21 +1,8 @@
import { s3Storage } from './s3';
import { r2Storage } from './r2';
import { getStorageType } from './storage';
type ObjectStorageType = 'r2' | 's3';
export const getStorageType = (): ObjectStorageType => {
// TODO: do not expose storage type to client
if (process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE']) {
return process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE'] as ObjectStorageType;
} else {
return 'r2';
}
};
export const getDownloadSignedUrl = async (
fileKey: string,
expiresIn: number,
) => {
export const getDownloadSignedUrl = async (fileKey: string, expiresIn: number) => {
const storageType = getStorageType();
if (storageType === 'r2') {
const bucketName = process.env['R2_BUCKET_NAME'] || '';
+2 -2
View File
@@ -183,13 +183,13 @@ export const getPopupPosition = (
return { point: popupPoint, dir: position.dir } as Position;
};
export const getTextFromRange = (range: Range, rejects: string[] = []): string => {
export const getTextFromRange = (range: Range, rejectTags: string[] = []): string => {
const clonedRange = range.cloneRange();
const fragment = clonedRange.cloneContents();
const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parent = node.parentElement;
if (rejects.includes(parent?.tagName.toLowerCase() || '')) {
if (rejectTags.includes(parent?.tagName.toLowerCase() || '')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
+25 -29
View File
@@ -1,4 +1,5 @@
import { TTSMark } from '@/services/tts/types';
import { code6392to6391, inferLangFromScript, isSameLang, isValidLang } from './lang';
const cleanTextContent = (text: string) =>
text.replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ').trimStart();
@@ -16,9 +17,30 @@ export const genSSML = (lang: string, text: string, voice: string, rate: number)
`;
};
export const parseSSMLMarks = (ssml: string) => {
const speakMatch = ssml.match(/<speak[^>]*xml:lang="([^"]+)"[^>]*>/i);
const defaultLang = speakMatch?.[1] ?? 'en';
export const parseSSMLLang = (ssml: string, primaryLang?: string): string => {
let lang = 'en';
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
if (match && match[1]) {
const parts = match[1].split('-');
lang =
parts.length > 1
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
: parts[0]!.toLowerCase();
lang = code6392to6391(lang) || lang;
if (!isValidLang(lang)) {
lang = 'en';
}
}
primaryLang = code6392to6391(primaryLang?.toLowerCase() || '') || primaryLang;
if (lang === 'en' && primaryLang && !isSameLang(lang, primaryLang)) {
lang = primaryLang.split('-')[0]!.toLowerCase();
}
return inferLangFromScript(ssml, lang);
};
export const parseSSMLMarks = (ssml: string, primaryLang?: string) => {
const defaultLang = parseSSMLLang(ssml, primaryLang) || 'en';
ssml = ssml.replace(/<speak[^>]*>/i, '').replace(/<\/speak>/i, '');
let plainText = '';
@@ -93,29 +115,3 @@ export const findSSMLMark = (charIndex: number, marks: TTSMark[]) => {
return result;
};
const inferLangFromScript = (text: string, lang: string | null): string | null => {
if (!lang || lang.startsWith('en')) {
if (/[\p{Script=Hangul}]/u.test(text)) {
return 'ko';
} else if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) {
return 'ja';
} else if (/[\p{Script=Han}]/u.test(text)) {
return 'zh';
}
}
return lang;
};
export const parseSSMLLang = (ssml: string): string | null => {
let lang = null;
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
if (match && match[1]) {
const parts = match[1].split('-');
lang =
parts.length > 1
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
: parts[0]!.toLowerCase();
}
return inferLangFromScript(ssml, lang);
};

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