Compare commits

...

53 Commits

Author SHA1 Message Date
Huang Xin 721e70d027 release: version 0.9.93 (#2486) 2025-11-20 06:33:58 +01:00
Huang Xin 3c1a7eac86 fix(library): fixed occasional freeze when navigating back to the library (#2485)
Closes #2482.
2025-11-20 06:15:49 +01:00
Huang Xin f8e21dbd4c fix(sync): fixed sync issues affecting new accounts, closes #2481 (#2483) 2025-11-20 04:28:41 +01:00
Huang Xin d16a35d26f fix(layout): constrain image height within table cells in paginated mode (#2480)
Closes #2472.
2025-11-19 18:06:39 +01:00
Huang Xin f881caf794 chore(pwa): config workbox to skip precaching next.js internal files (#2478) 2025-11-19 14:38:04 +01:00
54wedge eb6fa276de fix(txt): register style.css to content.opf (#2476)
* fix(txt): register style.css to content.opf

* refactor: code styling

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2025-11-19 13:59:04 +01:00
Huang Xin e987c8b37a chore(pwa): get rid of public headers requests (#2477) 2025-11-19 13:31:21 +01:00
Huang Xin 26d76e27ac fix(txt): more tolerant encoding detection for utf-8 (#2475) 2025-11-19 07:47:10 +01:00
Huang Xin 975549fca0 fix(font): avoid overriding monospace fonts for code blocks, closes #1805 (#2474) 2025-11-19 06:57:45 +01:00
Huang Xin 9a6bed52dd fix(sync): propagate book delete status to other devices (#2473) 2025-11-19 05:47:59 +01:00
Huang Xin efd9ad12a9 release: version 0.9.92 (#2469) 2025-11-18 15:15:27 +01:00
Huang Xin 93a71a0fd0 fix(bookshelf): ensure 'select all' only targets books in the active group (#2468) 2025-11-18 14:34:32 +01:00
Huang Xin d373d7ac28 fix(txt): handle chapter title patterns (#2467) 2025-11-18 14:09:03 +01:00
Huang Xin e0e991b599 fix(kosync): proxy kosync request only for the web app, closes #2440 (#2466) 2025-11-18 13:44:30 +01:00
Huang Xin 6d5b16ea8b fix(iap): open external app for payment (#2465) 2025-11-18 12:07:43 +01:00
Huang Xin bfac67d3f0 fix: ger rid of the AbortSignal polyfill (#2463) 2025-11-18 09:10:53 +01:00
Huang Xin 0fd316d775 fix(sync): retry sync books for previous failed sync, closes #2441 (#2462) 2025-11-17 18:52:48 +01:00
Huang Xin c42fcf9ac4 fix: save reading progress when closing app directly in reader page, closes #2346 (#2461) 2025-11-17 16:35:56 +01:00
Huang Xin 04ade02a06 fix(layout): enlarge clickable area for the close button in the reader page (#2459) 2025-11-17 15:22:05 +01:00
Huang Xin 8ee53d3367 fix(mobi): properly handle empty fragments for MOBI (#2456) 2025-11-17 05:09:09 +01:00
Huang Xin 42111945b8 fix(layout): scale table to fit within column constraints, closes #2445 (#2455) 2025-11-17 04:52:23 +01:00
Huang Xin 6fde157047 feat(bookshelf): support nested groups in the bookshelf, closes #568 (#2449) 2025-11-16 13:59:37 +01:00
Huang Xin 65ec5c9842 fix(library): avoid invalid regular expression in library search (#2439) 2025-11-16 14:07:29 +08:00
Huang Xin a6444b5b33 feat: supported updating email address for Readest account (#2437) 2025-11-12 19:03:02 +01:00
Huang Xin c7238cb04c fix(css): overriding book color for more semantic tags, closes #2433 (#2436) 2025-11-11 19:44:13 +01:00
Huang Xin 366e5fafad fix: fixed multiple windows are opened when opening with readest, closes #2429 (#2435) 2025-11-11 19:36:28 +01:00
Huang Xin fb5710c134 fix(pdf): disabled pagination with swipe gesture below minimum velocity for PDFs, closes #2428 (#2434) 2025-11-11 18:20:45 +01:00
ByteFlow c4d9652335 fix(annotator): enhance PDF context menu for translation and improve touch handling (#2430)
* fix(annotator): enhance PDF context menu for translation and improve touch handling

* feat(annotator, shortcuts): add keyboard shortcuts for selection actions

* feat(annotator): reposition popups on scroll to enhance user experience

* feat(annotator): disable currently unsupported annotator functions for PDFs

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2025-11-11 15:10:44 +01:00
Huang Xin df6027cf9f fix(eink): avoid gray color in E-Ink mode (#2427) 2025-11-09 19:17:35 +01:00
Huang Xin e3f7abf7f1 fix(tts): fixed incorrectly selected voices, closes #2386 (#2426) 2025-11-09 19:11:12 +01:00
Huang Xin c539917d7e compat(footnote): support more footnote formats (#2425) 2025-11-09 18:07:39 +01:00
Huang Xin 5eb870c978 fix(layout): don't hide horizontal scrollbar, closes #2418 (#2424) 2025-11-09 17:03:24 +01:00
Huang Xin b075c46ba3 compat(sync): compatibility for kosync server implementations that do not return timestamp (#2422) 2025-11-09 14:42:07 +01:00
Huang Xin 4b6c7776f5 fix(eink): no shade in eink mode (#2421) 2025-11-09 14:34:26 +01:00
Huang Xin 745a14195d fix(layout): fixed header bar z index (#2413) 2025-11-06 10:56:28 +01:00
Huang Xin fb0f660eaf fix(android): fixed back gesture to close config panel, closes #2406 (#2411) 2025-11-05 22:45:13 +01:00
Huang Xin 04a3030b79 fix(metadata): fixed changing cover image in Readest apps, closes #2402 (#2410) 2025-11-05 20:43:42 +01:00
Huang Xin 5a3114de48 fix(android): get brightness of the current window on Android, closes #2389 (#2409) 2025-11-05 19:36:45 +01:00
Huang Xin b808fc5954 fix: default to not override book color for PDF, closes #2397 (#2404) 2025-11-04 18:46:37 +01:00
Huang Xin 0ec4e37c13 fix(css): support style transformer for inline css (#2403)
* fix(css): support style transformer for inline css

* fix(eink): bold text for current chapter in the TOC, closes #2401
2025-11-04 18:36:46 +01:00
Huang Xin 9d301631ca fix(layout): restrict image container within column boundaries, closes #2390 (#2398) 2025-11-03 16:35:08 +01:00
Huang Xin ec7145bb01 fix(upload): sanitize filenames before uploading to cloud bucket (#2396) 2025-11-03 11:17:24 +01:00
Huang Xin f79d84b5fa fix(eink): more readablility for E-Ink mode on the library page, closes #2364 (#2395)
This also closes #2391 and closes #2394.
2025-11-03 09:23:35 +01:00
Huang Xin d00f1e0def feat(import): import files directly into the current book group (#2393) 2025-11-03 05:47:26 +01:00
Huang Xin 637a813732 fix(settings): don't show system fonts on Android, closes #2381 (#2388) 2025-11-02 09:49:25 +01:00
Huang Xin 4f33c9280b fix(layout): fine tuning of column width in scrolled mode and vertical mode, closes #2383 (#2387) 2025-11-02 09:34:57 +01:00
Huang Xin d54c752637 fix(koplugin): properly refresh access token (#2384) 2025-11-02 04:50:54 +01:00
Huang Xin 984d5d198d fix(css): avoid overriding table background color by default, closes #2377 (#2379) 2025-11-01 15:09:46 +01:00
Huang Xin 2568778a87 feat(layout): support for duokan bleed layout, closes #2374 (#2378) 2025-11-01 14:26:48 +01:00
Huang Xin 0d805a64f6 compat: polyfill AbortController/AbortSignal on older browsers (#2371) 2025-10-31 06:15:59 +01:00
Huang Xin 066d1c5b1e fix(import): resolve import failures for certain EPUB files, closes #230 (#2370)
If the publisher has already been parsed, don’t remap it from the author or contributor fields again.
This prevents failures when parsing metadata like <dc:creator opf:role="pbl" />.
2025-10-31 05:12:24 +01:00
Huang Xin cdc1950c3a fix(layout): various fixes on the book and reader styles and layouts, closes #2368 (#2369) 2025-10-30 19:34:55 +01:00
Huang Xin 7142874513 fix(ui): display exact storage capacity in the user profile (#2366) 2025-10-30 11:50:39 +01:00
116 changed files with 2030 additions and 496 deletions
+28
View File
@@ -50,6 +50,15 @@ const nextConfig = {
},
],
},
{
source: '/_next/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
];
},
};
@@ -57,6 +66,8 @@ const nextConfig = {
const withPWA = withPWAInit({
dest: 'public',
disable: isDev || appPlatform !== 'web',
cacheStartUrl: false,
dynamicStartUrl: false,
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
@@ -66,6 +77,23 @@ const withPWA = withPWAInit({
},
workboxOptions: {
disableDevLogs: true,
manifestTransforms: [
(manifestEntries) => {
const manifest = manifestEntries.filter((entry) => {
const url = entry.url;
return (
!url.includes('dynamic-css-manifest.json') &&
!url.includes('middleware-manifest.json') &&
!url.includes('react-loadable-manifest.json') &&
!url.includes('build-manifest.json') &&
!url.includes('_buildManifest.js') &&
!url.includes('_ssgManifest.js') &&
!url.includes('_headers')
);
});
return { manifest };
},
],
},
});
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.91",
"version": "0.9.93",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev --turbopack",
@@ -67,6 +67,7 @@
"@tauri-apps/plugin-shell": "~2.3.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"@zip.js/zip.js": "^2.7.53",
"abortcontroller-polyfill": "^1.7.8",
"app-store-server-api": "^0.17.1",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
-2
View File
@@ -1,2 +0,0 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable
@@ -662,5 +662,15 @@
"Quick Colors": "ألوان سريعة",
"Highlighter": "محدد النص",
"Save Book Cover": "حفظ غلاف الكتاب",
"Auto-save last book cover": "حفظ غلاف الكتاب الأخير تلقائيًا"
"Auto-save last book cover": "حفظ غلاف الكتاب الأخير تلقائيًا",
"Back": "عودة",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "تم إرسال بريد التأكيد! يرجى التحقق من عناوين بريدك الإلكتروني القديمة والجديدة لتأكيد التغيير.",
"Failed to update email": "فشل في تحديث البريد الإلكتروني",
"New Email": "البريد الإلكتروني الجديد",
"Your new email": "بريدك الإلكتروني الجديد",
"Updating email ...": "جارٍ تحديث البريد الإلكتروني ...",
"Update email": "تحديث البريد الإلكتروني",
"Current email": "البريد الإلكتروني الحالي",
"Update Email": "تحديث البريد الإلكتروني",
"All": "الكل"
}
@@ -646,5 +646,15 @@
"Quick Colors": "দ্রুত রংসমূহ",
"Highlighter": "হাইলাইটার",
"Save Book Cover": "বইয়ের মলাট সংরক্ষণ করুন",
"Auto-save last book cover": "শেষ বইয়ের মলাট স্বয়ংক্রিয়ভাবে সংরক্ষণ করুন"
"Auto-save last book cover": "শেষ বইয়ের মলাট স্বয়ংক্রিয়ভাবে সংরক্ষণ করুন",
"Back": "পেছনে",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "নিশ্চিতকরণ ইমেল পাঠানো হয়েছে! পরিবর্তন নিশ্চিত করতে আপনার পুরানো এবং নতুন ইমেল ঠিকানা পরীক্ষা করুন।",
"Failed to update email": "ইমেল আপডেট করতে ব্যর্থ",
"New Email": "নতুন ইমেল",
"Your new email": "আপনার নতুন ইমেল",
"Updating email ...": "ইমেল আপডেট হচ্ছে ...",
"Update email": "ইমেল আপডেট করুন",
"Current email": "বর্তমান ইমেল",
"Update Email": "ইমেল আপডেট করুন",
"All": "সব"
}
@@ -642,5 +642,15 @@
"Quick Colors": "མྱུར་ཚོན།",
"Highlighter": "མདོག་ཚད་ཀྱི་བསྡུར་བ།",
"Save Book Cover": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Auto-save last book cover": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།"
"Auto-save last book cover": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Back": "ལོག་བྱེད།",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "གཏན་འཁེལ་ཡིག་འཕྲིན་བཏང་ཡོད། ཡིག་འཕྲིན་རྣམས་གཉིས་ལ་བལྟས་ནས་བརྗེ་བ་དེ་གཏན་འཁེལ་བྱོས།",
"Failed to update email": "ཡིག་འཕྲིན་གསར་བསྒྱུར་ཕམ་པ།",
"New Email": "ཡིག་འཕྲིན་གསར་པ།",
"Your new email": "ཁྱེད་ཀྱི་ཡིག་འཕྲིན་གསར་པ།",
"Updating email ...": "ཡིག་འཕྲིན་གསར་བསྒྱུར་བཞིན་པ། ...",
"Update email": "ཡིག་འཕྲིན་གསར་བསྒྱུར།",
"Current email": "མིག་སྔའི་ཡིག་འཕྲིན།",
"Update Email": "ཡིག་འཕྲིན་གསར་བསྒྱུར།",
"All": "ཆེན་མོ།"
}
@@ -646,5 +646,15 @@
"Quick Colors": "Schnellfarben",
"Highlighter": "Textmarker",
"Save Book Cover": "Buchcover speichern",
"Auto-save last book cover": "Letztes Buchcover automatisch speichern"
"Auto-save last book cover": "Letztes Buchcover automatisch speichern",
"Back": "Zurück",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "Bestätigungs-E-Mail gesendet! Bitte überprüfe deine alte und neue E-Mail-Adresse, um die Änderung zu bestätigen.",
"Failed to update email": "E-Mail konnte nicht aktualisiert werden",
"New Email": "Neue E-Mail",
"Your new email": "Deine neue E-Mail-Adresse",
"Updating email ...": "E-Mail wird aktualisiert ...",
"Update email": "E-Mail aktualisieren",
"Current email": "Aktuelle E-Mail",
"Update Email": "E-Mail aktualisieren",
"All": "Alle"
}
@@ -646,5 +646,15 @@
"Quick Colors": "Γρήγορα χρώματα",
"Highlighter": "Υπογραμμιστής",
"Save Book Cover": "Αποθήκευση εξωφύλλου βιβλίου",
"Auto-save last book cover": "Αυτόματη αποθήκευση τελευταίου εξωφύλλου βιβλίου"
"Auto-save last book cover": "Αυτόματη αποθήκευση τελευταίου εξωφύλλου βιβλίου",
"Back": "Πίσω",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "Το email επιβεβαίωσης στάλθηκε! Παρακαλώ έλεγξε την παλιά και τη νέα σου διεύθυνση email για να επιβεβαιώσεις την αλλαγή.",
"Failed to update email": "Αποτυχία ενημέρωσης email",
"New Email": "Νέο email",
"Your new email": "Το νέο σου email",
"Updating email ...": "Ενημέρωση email ...",
"Update email": "Ενημέρωση email",
"Current email": "Τρέχον email",
"Update Email": "Ενημέρωση email",
"All": "Όλα"
}
@@ -650,5 +650,15 @@
"Quick Colors": "Colores Rápidos",
"Highlighter": "Resaltador",
"Save Book Cover": "Guardar Portada del Libro",
"Auto-save last book cover": "Guardar automáticamente la última portada del libro"
"Auto-save last book cover": "Guardar automáticamente la última portada del libro",
"Back": "Atrás",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "¡Correo de confirmación enviado! Por favor, revisa tus direcciones de correo electrónico antigua y nueva para confirmar el cambio.",
"Failed to update email": "Error al actualizar el correo electrónico",
"New Email": "Nuevo correo electrónico",
"Your new email": "Tu nuevo correo electrónico",
"Updating email ...": "Actualizando correo electrónico ...",
"Update email": "Actualizar correo electrónico",
"Current email": "Correo electrónico actual",
"Update Email": "Actualizar correo electrónico",
"All": "Todos"
}
@@ -646,5 +646,15 @@
"Quick Colors": "رنگ‌های سریع",
"Highlighter": "ماژیک هایلایت",
"Save Book Cover": "ذخیره جلد کتاب",
"Auto-save last book cover": "ذخیره خودکار آخرین جلد کتاب"
"Auto-save last book cover": "ذخیره خودکار آخرین جلد کتاب",
"Back": "بازگشت",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "ایمیل تأیید ارسال شد! لطفاً ایمیل قدیمی و جدید خود را بررسی کنید تا تغییر را تأیید نمایید.",
"Failed to update email": "به‌روزرسانی ایمیل ناموفق بود",
"New Email": "ایمیل جدید",
"Your new email": "ایمیل جدید شما",
"Updating email ...": "در حال به‌روزرسانی ایمیل ...",
"Update email": "به‌روزرسانی ایمیل",
"Current email": "ایمیل فعلی",
"Update Email": "به‌روزرسانی ایمیل",
"All": "همه"
}
@@ -650,5 +650,15 @@
"Quick Colors": "Couleurs Rapides",
"Highlighter": "Surligneur",
"Save Book Cover": "Enregistrer la Couverture du Livre",
"Auto-save last book cover": "Enregistrer automatiquement la dernière couverture du livre"
"Auto-save last book cover": "Enregistrer automatiquement la dernière couverture du livre",
"Back": "Retour",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "E-mail de confirmation envoyé ! Veuillez vérifier vos anciennes et nouvelles adresses e-mail pour confirmer le changement.",
"Failed to update email": "Échec de la mise à jour de le-mail",
"New Email": "Nouvel e-mail",
"Your new email": "Votre nouvel e-mail",
"Updating email ...": "Mise à jour de le-mail ...",
"Update email": "Mettre à jour le-mail",
"Current email": "E-mail actuel",
"Update Email": "Mettre à jour le-mail",
"All": "Tous"
}
@@ -646,5 +646,15 @@
"Quick Colors": "त्वरित रंग",
"Highlighter": "हाइलाइटर",
"Save Book Cover": "बुक कवर सहेजें",
"Auto-save last book cover": "अंतिम पुस्तक कवर को स्वचालित रूप से सहेजें"
"Auto-save last book cover": "अंतिम पुस्तक कवर को स्वचालित रूप से सहेजें",
"Back": "वापस",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "पुष्टिकरण ईमेल भेज दिया गया है! कृपया परिवर्तन की पुष्टि करने के लिए अपना पुराना और नया ईमेल पता जांचें।",
"Failed to update email": "ईमेल अपडेट करने में विफल",
"New Email": "नया ईमेल",
"Your new email": "आपका नया ईमेल",
"Updating email ...": "ईमेल अपडेट किया जा रहा है ...",
"Update email": "ईमेल अपडेट करें",
"Current email": "वर्तमान ईमेल",
"Update Email": "ईमेल अपडेट करें",
"All": "सभी"
}
@@ -642,5 +642,15 @@
"Quick Colors": "Warna Cepat",
"Highlighter": "Penyorot",
"Save Book Cover": "Simpan Sampul Buku",
"Auto-save last book cover": "Simpan otomatis sampul buku terakhir"
"Auto-save last book cover": "Simpan otomatis sampul buku terakhir",
"Back": "Kembali",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "Email konfirmasi telah dikirim! Silakan periksa alamat email lama dan baru Anda untuk mengonfirmasi perubahan.",
"Failed to update email": "Gagal memperbarui email",
"New Email": "Email baru",
"Your new email": "Email baru Anda",
"Updating email ...": "Memperbarui email ...",
"Update email": "Perbarui email",
"Current email": "Email saat ini",
"Update Email": "Perbarui email",
"All": "Semua"
}
@@ -650,5 +650,15 @@
"Quick Colors": "Colori Veloci",
"Highlighter": "Evidenziatore",
"Save Book Cover": "Salva Copertina del Libro",
"Auto-save last book cover": "Salva automaticamente l'ultima copertina del libro"
"Auto-save last book cover": "Salva automaticamente l'ultima copertina del libro",
"Back": "Indietro",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "Email di conferma inviata! Controlla il tuo vecchio e nuovo indirizzo email per confermare la modifica.",
"Failed to update email": "Aggiornamento dell'email non riuscito",
"New Email": "Nuova email",
"Your new email": "La tua nuova email",
"Updating email ...": "Aggiornamento dell'email ...",
"Update email": "Aggiorna email",
"Current email": "Email attuale",
"Update Email": "Aggiorna email",
"All": "Tutti"
}
@@ -642,5 +642,15 @@
"Quick Colors": "クイックカラー",
"Highlighter": "蛍光ペン",
"Save Book Cover": "本のカバーを保存",
"Auto-save last book cover": "最後の本のカバーを自動保存"
"Auto-save last book cover": "最後の本のカバーを自動保存",
"Back": "戻る",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "確認メールを送信しました。変更を確認するために、古いメールアドレスと新しいメールアドレスを確認してください。",
"Failed to update email": "メールの更新に失敗しました",
"New Email": "新しいメールアドレス",
"Your new email": "あなたの新しいメールアドレス",
"Updating email ...": "メールを更新しています ...",
"Update email": "メールを更新",
"Current email": "現在のメールアドレス",
"Update Email": "メールを更新",
"All": "すべて"
}
@@ -642,5 +642,15 @@
"Quick Colors": "빠른 색상",
"Highlighter": "형광펜",
"Save Book Cover": "책 표지 저장",
"Auto-save last book cover": "마지막 책 표지 자동 저장"
"Auto-save last book cover": "마지막 책 표지 자동 저장",
"Back": "뒤로",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "확인 이메일이 전송되었습니다! 변경을 확인하려면 이전 이메일 주소와 새 이메일 주소를 확인하세요.",
"Failed to update email": "이메일 업데이트 실패",
"New Email": "새 이메일",
"Your new email": "새 이메일 주소",
"Updating email ...": "이메일 업데이트 중...",
"Update email": "이메일 업데이트",
"Current email": "현재 이메일",
"Update Email": "이메일 업데이트",
"All": "전체"
}
@@ -646,5 +646,15 @@
"Quick Colors": "Snelle kleuren",
"Highlighter": "Marker",
"Save Book Cover": "Opslaan boekomslag",
"Auto-save last book cover": "Automatisch opslaan laatste boekomslag"
"Auto-save last book cover": "Automatisch opslaan laatste boekomslag",
"Back": "Terug",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "Bevestigingsmail verzonden! Controleer je oude en nieuwe e-mailadres om de wijziging te bevestigen.",
"Failed to update email": "E-mail bijwerken mislukt",
"New Email": "Nieuw e-mailadres",
"Your new email": "Je nieuwe e-mailadres",
"Updating email ...": "E-mail wordt bijgewerkt...",
"Update email": "E-mail bijwerken",
"Current email": "Huidig e-mailadres",
"Update Email": "E-mail bijwerken",
"All": "Alles"
}
@@ -654,5 +654,15 @@
"Quick Colors": "Szybkie kolory",
"Highlighter": "Zakreślacz",
"Save Book Cover": "Zapisz okładkę książki",
"Auto-save last book cover": "Automatyczne zapisywanie ostatniej okładki książki"
"Auto-save last book cover": "Automatyczne zapisywanie ostatniej okładki książki",
"Back": "Wstecz",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "E-mail potwierdzający został wysłany! Sprawdź swój stary i nowy adres e-mail, aby potwierdzić zmianę.",
"Failed to update email": "Nie udało się zaktualizować e-maila",
"New Email": "Nowy e-mail",
"Your new email": "Twój nowy adres e-mail",
"Updating email ...": "Aktualizowanie e-maila...",
"Update email": "Zaktualizuj e-mail",
"Current email": "Obecny e-mail",
"Update Email": "Zaktualizuj e-mail",
"All": "Wszystkie"
}
@@ -650,5 +650,15 @@
"Quick Colors": "Cores rápidas",
"Highlighter": "Marcador",
"Save Book Cover": "Salvar Capa do Livro",
"Auto-save last book cover": "Salvar automaticamente a última capa do livro"
"Auto-save last book cover": "Salvar automaticamente a última capa do livro",
"Back": "Voltar",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "E-mail de confirmação enviado! Verifique os seus endereços de e-mail antigo e novo para confirmar a alteração.",
"Failed to update email": "Falha ao atualizar o e-mail",
"New Email": "Novo e-mail",
"Your new email": "O seu novo e-mail",
"Updating email ...": "A atualizar o e-mail...",
"Update email": "Atualizar e-mail",
"Current email": "E-mail atual",
"Update Email": "Atualizar e-mail",
"All": "Todos"
}
@@ -654,5 +654,15 @@
"Quick Colors": "Быстрые цвета",
"Highlighter": "Маркер",
"Save Book Cover": "Сохранить обложку книги",
"Auto-save last book cover": "Автоматически сохранять последнюю обложку книги"
"Auto-save last book cover": "Автоматически сохранять последнюю обложку книги",
"Back": "Назад",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "Письмо с подтверждением отправлено! Пожалуйста, проверьте старый и новый адреса электронной почты, чтобы подтвердить изменение.",
"Failed to update email": "Не удалось обновить электронную почту",
"New Email": "Новый адрес электронной почты",
"Your new email": "Ваш новый адрес электронной почты",
"Updating email ...": "Обновление электронной почты ...",
"Update email": "Обновить электронную почту",
"Current email": "Текущий адрес электронной почты",
"Update Email": "Обновить электронную почту",
"All": "Все"
}
@@ -646,5 +646,15 @@
"Quick Colors": "ක්ෂණික වර්ණ",
"Highlighter": "ඉස්මතු කරන්නා",
"Save Book Cover": "පොත් ආවරණය සුරකින්න",
"Auto-save last book cover": "අවසන් පොත් ආවරණය ස්වයංක්‍රීයව සුරකින්න"
"Auto-save last book cover": "අවසන් පොත් ආවරණය ස්වයංක්‍රීයව සුරකින්න",
"Back": "ආපසු",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "තහවුරු කිරීමේ විද්‍යුත් තැපැල් පණිවිඩය යවන්නා! වෙනස්කම තහවුරු කිරීම සඳහා ඔබගේ පැරණි සහ නව විද්‍යුත් තැපැල් ලිපින පරීක්ෂා කරන්න.",
"Failed to update email": "විද්‍යුත් තැපැල් යාවත්කාලීන කිරීමට අසමත් විය",
"New Email": "නව විද්‍යුත් තැපැල්",
"Your new email": "ඔබේ නව විද්‍යුත් තැපැල්",
"Updating email ...": "විද්‍යුත් තැපැල් යාවත්කාලීන කරමින් ...",
"Update email": "විද්‍යුත් තැපැල් යාවත්කාලීන කරන්න",
"Current email": "වත්මන් විද්‍යුත් තැපැල්",
"Update Email": "විද්‍යුත් තැපැල් යාවත්කාලීන කරන්න",
"All": "සියල්ල"
}
@@ -646,5 +646,15 @@
"Quick Colors": "Snabba färger",
"Highlighter": "Markeringspenna",
"Save Book Cover": "Spara bokomslag",
"Auto-save last book cover": "Automatiskt spara senaste bokomslag"
"Auto-save last book cover": "Automatiskt spara senaste bokomslag",
"Back": "Tillbaka",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "Bekräftelsemail skickat! Kontrollera din gamla och nya e-postadress för att bekräfta ändringen.",
"Failed to update email": "Misslyckades med att uppdatera e-post",
"New Email": "Ny e-post",
"Your new email": "Din nya e-post",
"Updating email ...": "Uppdaterar e-post ...",
"Update email": "Uppdatera e-post",
"Current email": "Nuvarande e-post",
"Update Email": "Uppdatera e-post",
"All": "Alla"
}
@@ -646,5 +646,15 @@
"Quick Colors": "விரைவு நிறங்கள்",
"Highlighter": "ஹைலைட்டர்",
"Save Book Cover": "புத்தகக் கோப்பைச் சேமிக்கவும்",
"Auto-save last book cover": "அවසන් புத்தகக் கோப்பு காலியாக உள்ளது"
"Auto-save last book cover": "அවසන් புத்தகக் கோப்பு காலியாக உள்ளது",
"Back": "பின் செல்ல",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "உறுதிப்பத்திர மின்னஞ்சல் அனுப்பப்பட்டுள்ளது! மாற்றத்தை உறுதிப்படுத்த உங்கள் பழைய மற்றும் புதிய மின்னஞ்சல் முகவரிகளை சரிபார்க்கவும்.",
"Failed to update email": "மின்னஞ்சல் புதுப்பிப்பில் தோல்வி",
"New Email": "புதிய மின்னஞ்சல்",
"Your new email": "உங்கள் புதிய மின்னஞ்சல்",
"Updating email ...": "மின்னஞ்சல் புதுப்பிக்கப்படுகிறது ...",
"Update email": "மின்னஞ்சல் புதுப்பிக்கவும்",
"Current email": "தற்போதைய மின்னஞ்சல்",
"Update Email": "மின்னஞ்சல் புதுப்பிக்கவும்",
"All": "அனைத்து"
}
@@ -642,5 +642,15 @@
"Quick Colors": "สีด่วน",
"Highlighter": "ปากกาเน้นข้อความ",
"Save Book Cover": "บันทึกปกหนังสือ",
"Auto-save last book cover": "บันทึกปกหนังสือสุดท้ายโดยอัตโนมัติ"
"Auto-save last book cover": "บันทึกปกหนังสือสุดท้ายโดยอัตโนมัติ",
"Back": "กลับ",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "ส่งอีเมลยืนยันแล้ว! กรุณาตรวจสอบอีเมลเก่าและใหม่ของคุณเพื่อยืนยันการเปลี่ยนแปลง",
"Failed to update email": "ไม่สามารถอัปเดตอีเมลได้",
"New Email": "อีเมลใหม่",
"Your new email": "อีเมลใหม่ของคุณ",
"Updating email ...": "กำลังอัปเดตอีเมล ...",
"Update email": "อัปเดตอีเมล",
"Current email": "อีเมลปัจจุบัน",
"Update Email": "อัปเดตอีเมล",
"All": "ทั้งหมด"
}
@@ -646,5 +646,15 @@
"Quick Colors": "Hızlı Renkler",
"Highlighter": "Vurgulayıcı",
"Save Book Cover": "Kitap Kapağını Kaydet",
"Auto-save last book cover": "Son kitap kapağını otomatik olarak kaydet"
"Auto-save last book cover": "Son kitap kapağını otomatik olarak kaydet",
"Back": "Geri",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "Onay e-postası gönderildi! Değişikliği onaylamak için eski ve yeni e-posta adreslerinizi kontrol edin.",
"Failed to update email": "E-posta güncellenemedi",
"New Email": "Yeni e-posta",
"Your new email": "Yeni e-posta adresiniz",
"Updating email ...": "E-posta güncelleniyor ...",
"Update email": "E-postayı güncelle",
"Current email": "Mevcut e-posta",
"Update Email": "E-postayı güncelle",
"All": "Tümü"
}
@@ -654,5 +654,15 @@
"Quick Colors": "Швидкі кольори",
"Highlighter": "Маркер",
"Save Book Cover": "Зберегти обкладинку книги",
"Auto-save last book cover": "Автоматично зберегти останню обкладинку книги"
"Auto-save last book cover": "Автоматично зберегти останню обкладинку книги",
"Back": "Назад",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "Лист підтвердження надіслано! Будь ласка, перевірте стару та нову електронну адресу, щоб підтвердити зміну.",
"Failed to update email": "Не вдалося оновити електронну пошту",
"New Email": "Нова електронна пошта",
"Your new email": "Ваша нова електронна адреса",
"Updating email ...": "Оновлення електронної пошти ...",
"Update email": "Оновити електронну пошту",
"Current email": "Поточна електронна пошта",
"Update Email": "Оновити електронну пошту",
"All": "Всі"
}
@@ -642,5 +642,15 @@
"Quick Colors": "Màu nhanh",
"Highlighter": "Bút đánh dấu",
"Save Book Cover": "Lưu bìa sách",
"Auto-save last book cover": "Tự động lưu bìa sách cuối cùng"
"Auto-save last book cover": "Tự động lưu bìa sách cuối cùng",
"Back": "Quay lại",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "Email xác nhận đã được gửi! Vui lòng kiểm tra địa chỉ email cũ và mới của bạn để xác nhận thay đổi.",
"Failed to update email": "Cập nhật email thất bại",
"New Email": "Email mới",
"Your new email": "Email mới của bạn",
"Updating email ...": "Đang cập nhật email ...",
"Update email": "Cập nhật email",
"Current email": "Email hiện tại",
"Update Email": "Cập nhật email",
"All": "Tất cả"
}
@@ -642,5 +642,15 @@
"Quick Colors": "快速颜色",
"Highlighter": "荧光笔",
"Save Book Cover": "保存书籍封面",
"Auto-save last book cover": "自动保存最后一本书的封面"
"Auto-save last book cover": "自动保存最后一本书的封面",
"Back": "返回",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "确认邮件已发送!请检查您的旧邮箱和新邮箱以确认更改。",
"Failed to update email": "更新邮箱失败",
"New Email": "新邮箱",
"Your new email": "您的新邮箱",
"Updating email ...": "正在更新邮箱 ...",
"Update email": "更新邮箱",
"Current email": "当前邮箱",
"Update Email": "更新邮箱",
"All": "全部"
}
@@ -642,5 +642,15 @@
"Quick Colors": "快速顏色",
"Highlighter": "螢光筆",
"Save Book Cover": "保存書籍封面",
"Auto-save last book cover": "自動保存最後一本書的封面"
"Auto-save last book cover": "自動保存最後一本書的封面",
"Back": "返回",
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "確認郵件已發送!請檢查您的舊電子郵件和新電子郵件以確認更改。",
"Failed to update email": "更新電子郵件失敗",
"New Email": "新電子郵件",
"Your new email": "您的新電子郵件",
"Updating email ...": "正在更新電子郵件 ...",
"Update email": "更新電子郵件",
"Current email": "當前電子郵件",
"Update Email": "更新電子郵件",
"All": "全部"
}
+23
View File
@@ -1,5 +1,28 @@
{
"releases": {
"0.9.93": {
"date": "2025-11-20",
"notes": [
"Sync: Improved reliability for new accounts so your books sync correctly from the start",
"Library: Fixed an occasional issue where returning to the library could get stuck",
"Layout: Improved image display in paginated mode so images won't exceed the available height",
"TXT: Improved performance and stability when opening and parsing TXT files",
"Fonts: Fixed an issue where monospace fonts were not applied correctly in code blocks"
]
},
"0.9.92": {
"date": "2025-11-18",
"notes": [
"Bookshelf: Added support for nested groups to organize your books more flexibly",
"Sync: Improved reliability by automatically retrying failed book syncs",
"Sync: Improved token refresh so login sessions stay active longer on KOReader",
"E-Ink: Enhanced readability on the library page for E-Ink devices",
"UI: Storage capacity is now displayed with precise values in the user profile",
"Reader: You can now import files directly into the currently selected book group",
"Account: You can now update the email address linked to your Readest account",
"Footnotes: Added compatibility for more footnote formats"
]
},
"0.9.91": {
"date": "2025-10-29",
"notes": [
@@ -104,6 +104,12 @@
},
{
"url": "https://*:*"
},
{
"url": "http://*"
},
{
"url": "https://*"
}
]
},
@@ -134,6 +140,14 @@
"oauth:allow-cancel",
"sign-in-with-apple:default",
"opener:default",
{
"identifier": "opener:allow-open-url",
"allow": [
{
"url": "alipays:*"
}
]
},
"haptics:allow-vibrate",
"haptics:allow-impact-feedback",
"haptics:allow-notification-feedback",
@@ -75,6 +75,11 @@ class SetScreenBrightnessRequestArgs {
var brightness: Double? = null // 0.0 to 1.0
}
@InvokeArg
class OpenExternalUrlArgs {
var url: String? = null
}
@InvokeArg
class FetchProductsRequestArgs {
val productIds: List<String>? = null
@@ -457,12 +462,19 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
fun get_screen_brightness(invoke: Invoke) {
val ret = JSObject()
try {
val brightness = Settings.System.getInt(
activity.contentResolver,
Settings.System.SCREEN_BRIGHTNESS
)
val normalizedBrightness = brightness / 255.0
ret.put("brightness", normalizedBrightness)
val window = activity.window
val layoutParams = window.attributes
val brightness = layoutParams.screenBrightness
if (brightness >= 0.0f) {
ret.put("brightness", brightness.toDouble())
} else {
val systemBrightness = Settings.System.getInt(
activity.contentResolver,
Settings.System.SCREEN_BRIGHTNESS
)
ret.put("brightness", systemBrightness / 255.0)
}
} catch (e: Exception) {
ret.put("error", e.message)
ret.put("brightness", -1.0)
@@ -653,4 +665,21 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
}
}
@Command
fun open_external_url(invoke: Invoke) {
val args = invoke.parseArgs(OpenExternalUrlArgs::class.java)
val url = args.url ?: ""
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
val ret = JSObject()
ret.put("success", true)
invoke.resolve(ret)
} catch (e: Exception) {
invoke.reject("Failed to open URL: ${e.message}")
}
}
}
@@ -18,6 +18,7 @@ const COMMANDS: &[&str] = &[
"get_screen_brightness",
"set_screen_brightness",
"get_external_sdcard_path",
"open_external_url",
"request_manage_storage_permission",
"check_permissions",
"request_permissions",
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-open-external-url"
description = "Enables the open_external_url command without any pre-configured scope."
commands.allow = ["open_external_url"]
[[permission]]
identifier = "deny-open-external-url"
description = "Denies the open_external_url command without any pre-configured scope."
commands.deny = ["open_external_url"]
@@ -23,6 +23,7 @@ Default permissions for the plugin
- `allow-get-screen-brightness`
- `allow-set-screen-brightness`
- `allow-get-external-sdcard-path`
- `allow-open-external-url`
- `allow-request-manage-storage-permission`
- `allow-check-permissions`
- `allow-request-permissions`
@@ -535,6 +536,32 @@ Denies the lock_screen_orientation command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-open-external-url`
</td>
<td>
Enables the open_external_url command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-open-external-url`
</td>
<td>
Denies the open_external_url command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-request-permissions`
</td>
@@ -20,6 +20,7 @@ permissions = [
"allow-get-screen-brightness",
"allow-set-screen-brightness",
"allow-get-external-sdcard-path",
"allow-open-external-url",
"allow-request-manage-storage-permission",
"allow-check-permissions",
"allow-request-permissions",
@@ -522,6 +522,18 @@
"const": "deny-lock-screen-orientation",
"markdownDescription": "Denies the lock_screen_orientation command without any pre-configured scope."
},
{
"description": "Enables the open_external_url command without any pre-configured scope.",
"type": "string",
"const": "allow-open-external-url",
"markdownDescription": "Enables the open_external_url command without any pre-configured scope."
},
{
"description": "Denies the open_external_url command without any pre-configured scope.",
"type": "string",
"const": "deny-open-external-url",
"markdownDescription": "Denies the open_external_url command without any pre-configured scope."
},
{
"description": "Enables the request-permissions command without any pre-configured scope.",
"type": "string",
@@ -607,10 +619,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
}
]
}
@@ -149,6 +149,14 @@ pub(crate) async fn get_external_sdcard_path<R: Runtime>(
app.native_bridge().get_external_sdcard_path()
}
#[command]
pub(crate) async fn open_external_url<R: Runtime>(
app: AppHandle<R>,
payload: OpenExternalUrlRequest,
) -> Result<OpenExternalUrlResponse> {
app.native_bridge().open_external_url(payload)
}
#[command]
pub(crate) async fn request_manage_storage_permission<R: Runtime>(
app: AppHandle<R>,
@@ -122,6 +122,13 @@ impl<R: Runtime> NativeBridge<R> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn open_external_url(
&self,
_payload: OpenExternalUrlRequest,
) -> crate::Result<OpenExternalUrlResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn request_manage_storage_permission(
&self,
) -> crate::Result<RequestManageStoragePermissionResponse> {
@@ -56,6 +56,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::get_screen_brightness,
commands::set_screen_brightness,
commands::get_external_sdcard_path,
commands::open_external_url,
commands::request_manage_storage_permission,
])
.setup(|app, api| {
@@ -197,6 +197,17 @@ impl<R: Runtime> NativeBridge<R> {
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn open_external_url(
&self,
payload: OpenExternalUrlRequest,
) -> crate::Result<OpenExternalUrlResponse> {
self.0
.run_mobile_plugin("open_external_url", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn request_manage_storage_permission(
&self,
@@ -202,3 +202,16 @@ pub struct GetExternalSDCardPathResponse {
pub struct RequestManageStoragePermissionResponse {
pub manage_storage: String, // "granted", "denied", or "prompt"
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenExternalUrlRequest {
pub url: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenExternalUrlResponse {
pub success: bool,
pub error: Option<String>,
}
+52 -1
View File
@@ -21,7 +21,11 @@ use tauri_plugin_fs::FsExt;
mod macos;
mod transfer_file;
use tauri::{command, Emitter, WebviewUrl, WebviewWindowBuilder, Window};
#[cfg(target_os = "android")]
use tauri_plugin_native_bridge::{NativeBridgeExt, OpenExternalUrlRequest};
use tauri_plugin_oauth::start;
#[cfg(not(target_os = "android"))]
use tauri_plugin_opener::OpenerExt;
use transfer_file::{download_file, upload_file};
#[cfg(desktop)]
@@ -257,9 +261,56 @@ pub fn run() {
eprintln!("Failed to initialize tauri_plugin_log: {e}");
};
let app_handle = app.handle().clone();
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.background_throttling(BackgroundThrottlingPolicy::Disabled)
.background_color(tauri::window::Color(50, 49, 48, 255));
.background_color(tauri::window::Color(50, 49, 48, 255))
.initialization_script(
r#"
window.addEventListener('DOMContentLoaded', function() {
const isTauriLocal = window.location.protocol === 'tauri:' ||
window.location.hostname === 'tauri.localhost';
const needsSafeArea = !isTauriLocal;
if (needsSafeArea && !document.getElementById('safe-area-style')) {
const style = document.createElement('style');
style.id = 'safe-area-style';
style.textContent = `
body {
padding-top: env(safe-area-inset-top) !important;
padding-bottom: env(safe-area-inset-bottom) !important;
padding-left: env(safe-area-inset-left) !important;
padding-right: env(safe-area-inset-right) !important;
}
`;
document.head.appendChild(style);
}
});
"#,
)
.on_navigation(move |url| {
if url.scheme() == "alipays" || url.scheme() == "alipay" {
let url_str = url.as_str().to_string();
#[cfg(target_os = "android")]
{
let handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
match handle
.native_bridge()
.open_external_url(OpenExternalUrlRequest { url: url_str })
{
Ok(result) => println!("Result: {:?}", result),
Err(e) => eprintln!("Error: {:?}", e),
}
});
}
#[cfg(not(target_os = "android"))]
{
let _ = app_handle.opener().open_url(url_str, None::<&str>);
}
return false;
}
true
});
#[cfg(desktop)]
let win_builder = win_builder.inner_size(800.0, 600.0).resizable(true);
@@ -56,6 +56,31 @@ export default function ResetPasswordPage() {
providers={[]}
localization={getAuthLocalization()}
/>
<button
onClick={() => router.back()}
className={`mt-6 flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm transition-colors ${
isDarkMode
? 'border-gray-600 text-gray-300 hover:bg-gray-800'
: 'border-gray-300 text-gray-700 hover:bg-gray-100'
}`}
>
<svg
xmlns='http://www.w3.org/2000/svg'
className='h-4 w-4'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M15 19l-7-7 7-7'
/>
</svg>
{_('Back')}
</button>
</div>
</div>
);
@@ -0,0 +1,123 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { supabase } from '@/utils/supabase';
export default function UpdateEmailPage() {
const _ = useTranslation();
const router = useRouter();
const { user } = useAuth();
const { isDarkMode } = useThemeStore();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (!user) {
router.push('/login');
}
}, [user, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage('');
setError('');
try {
const { error: updateError } = await supabase.auth.updateUser({
email: email,
});
if (updateError) throw updateError;
setMessage(
_(
'Confirmation email sent! Please check your old and new email addresses to confirm the change.',
),
);
setEmail('');
} catch (err: any) {
setError(err.message || _('Failed to update email'));
} finally {
setLoading(false);
}
};
return (
<div className='flex min-h-screen items-center justify-center'>
<div className='w-full max-w-md p-8'>
<div className={`rounded-md p-8`}>
<form onSubmit={handleSubmit} className='space-y-6'>
<div className='space-y-1'>
<label
htmlFor='email'
className={`block text-sm font-normal ${isDarkMode ? 'text-gray-300' : 'text-gray-400'}`}
>
{_('New Email')}
</label>
<input
id='email'
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={_('Your new email')}
required
disabled={loading}
className={`w-full rounded-md border bg-transparent px-4 py-2.5 focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50 ${isDarkMode ? 'text-gray-300' : 'text-gray-400'}`}
/>
</div>
{error && <div className={`text-sm text-red-500`}>{error}</div>}
{message && <div className={`text-base-content text-sm`}>{message}</div>}
<button
type='submit'
disabled={loading || !email}
className={`w-full rounded-md bg-green-400 px-4 py-2.5 font-medium text-white transition-colors hover:bg-green-500 disabled:cursor-not-allowed`}
>
{loading ? _('Updating email ...') : _('Update email')}
</button>
<button
onClick={() => router.back()}
className={`flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm transition-colors ${
isDarkMode
? 'border-gray-600 text-gray-300 hover:bg-gray-800'
: 'border-gray-300 text-gray-700 hover:bg-gray-100'
}`}
>
<svg
xmlns='http://www.w3.org/2000/svg'
className='h-4 w-4'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M15 19l-7-7 7-7'
/>
</svg>
{_('Back')}
</button>
</form>
{user?.email && (
<div className={`mt-6 text-center text-sm text-gray-300`}>
{_('Current email')}: {user.email}
</div>
)}
</div>
</div>
</div>
);
}
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
import { Book, BooksGroup } from '@/types/book';
import { Book } from '@/types/book';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
@@ -66,8 +66,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [showGroupingModal, setShowGroupingModal] = useState(false);
const [queryTerm, setQueryTerm] = useState<string | null>(null);
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const [groupId, setGroupId] = useState(searchParams?.get('group') || '');
const [viewMode, setViewMode] = useState(searchParams?.get('view') || settings.libraryViewMode);
const [sortBy, setSortBy] = useState(searchParams?.get('sort') || settings.librarySortBy);
const [sortOrder, setSortOrder] = useState(
@@ -78,6 +78,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
const { getGroupName } = useLibraryStore();
const bookFilter = useMemo(() => createBookFilter(queryTerm), [queryTerm]);
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
@@ -87,9 +88,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
return queryTerm ? libraryBooks.filter((book) => bookFilter(book)) : libraryBooks;
}, [libraryBooks, queryTerm, bookFilter]);
const allBookshelfItems = useMemo(() => {
return generateBookshelfItems(filteredBooks);
}, [filteredBooks]);
const currentBookshelfItems = useMemo(() => {
const groupName = getGroupName(groupId) || '';
if (groupId && !groupName) {
return [];
}
const items = generateBookshelfItems(filteredBooks, groupName);
return items;
}, [filteredBooks, groupId, getGroupName]);
const autofocusRef = useAutoFocus<HTMLDivElement>();
@@ -113,12 +119,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}, [importBookUrl, appService]);
useEffect(() => {
if (navBooksGroup) {
setCurrentBookshelf(navBooksGroup.books);
} else {
setCurrentBookshelf(allBookshelfItems);
}
}, [allBookshelfItems, navBooksGroup, setCurrentBookshelf]);
setCurrentBookshelf(currentBookshelfItems);
}, [currentBookshelfItems, setCurrentBookshelf]);
useEffect(() => {
const group = searchParams?.get('group') || '';
@@ -162,23 +164,19 @@ const Bookshelf: React.FC<BookshelfProps> = ({
params.delete('order');
params.delete('view');
}
setGroupId(group);
if (group) {
const booksGroup = allBookshelfItems.find(
(item) => 'name' in item && item.id === group,
) as BooksGroup;
if (booksGroup) {
setNavBooksGroup(booksGroup);
if (currentBookshelfItems.length > 0) {
params.set('group', group);
} else {
params.delete('group');
navigateToLibrary(router, `${params.toString()}`);
}
} else {
setNavBooksGroup(null);
params.delete('group');
navigateToLibrary(router, `${params.toString()}`);
}
}, [router, settings, searchParams, allBookshelfItems, showGroupingModal]);
}, [router, settings, searchParams, currentBookshelfItems, showGroupingModal]);
const toggleSelection = useCallback(
(id: string) => {
@@ -252,7 +250,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const sortedBookshelfItems = useMemo(() => {
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
return currentBookshelfItems.sort((a, b) => {
if (sortBy === 'updated') {
return (
@@ -268,13 +265,15 @@ const Bookshelf: React.FC<BookshelfProps> = ({
return 0;
}
});
}, [sortOrder, sortBy, uiLanguage, navBooksGroup, allBookshelfItems, bookSorter]);
}, [sortOrder, sortBy, uiLanguage, currentBookshelfItems, bookSorter]);
useEffect(() => {
if (isSelectMode) {
setShowSelectModeActions(true);
if (isSelectAll) {
setSelectedBooks(filteredBooks.map((item) => item.hash));
setSelectedBooks(
currentBookshelfItems.map((item) => ('hash' in item ? item.hash : item.id)),
);
} else if (isSelectNone) {
setSelectedBooks([]);
}
@@ -283,7 +282,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
setShowSelectModeActions(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSelectMode, isSelectAll, isSelectNone, filteredBooks]);
}, [isSelectMode, isSelectAll, isSelectNone, currentBookshelfItems]);
useEffect(() => {
eventDispatcher.on('delete-books', handleDeleteBooksIntent);
@@ -330,8 +329,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
/>
))}
{viewMode === 'grid' && !navBooksGroup && allBookshelfItems.length > 0 && (
<div className={clsx('mx-0 my-4 sm:mx-4')}>
{viewMode === 'grid' && currentBookshelfItems.length > 0 && (
<div className={clsx('mx-0 my-2 sm:mx-4 sm:my-4')}>
<button
aria-label={_('Import Books')}
className={clsx(
@@ -363,7 +362,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<div
className={clsx(
'flex items-center justify-center shadow-lg',
'bg-gray-600 text-xs text-white',
'bg-base-300 text-base-content text-xs',
'mx-auto w-fit space-x-6 rounded-lg p-4',
)}
>
@@ -406,7 +405,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
!selectedBooks.length && 'btn-disabled opacity-50',
)}
>
<MdDelete className='fill-red-500' />
<MdDelete className='text-red-500' />
<div className='text-red-500'>{_('Delete')}</div>
</button>
<button
@@ -424,6 +423,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<GroupingModal
libraryBooks={libraryBooks}
selectedBooks={selectedBooks}
parentGroupName={getGroupName(groupId) || ''}
onCancel={() => {
setShowGroupingModal(false);
setShowSelectModeActions(true);
@@ -15,26 +15,47 @@ 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';
import { Book, BookGroupType, BooksGroup } from '@/types/book';
import { Book, BooksGroup } from '@/types/book';
import { md5Fingerprint } from '@/utils/md5';
import BookItem from './BookItem';
import GroupItem from './GroupItem';
export type BookshelfItem = Book | BooksGroup;
export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] => {
export const generateBookshelfItems = (
books: Book[],
parentGroupName: string,
): (Book | BooksGroup)[] => {
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
if (book.deletedAt) return acc;
if (parentGroupName && (!book.groupName || !book.groupName.startsWith(parentGroupName)))
return acc;
book.groupId = book.groupId || BOOK_UNGROUPED_ID;
book.groupName = book.groupName || BOOK_UNGROUPED_NAME;
const groupIndex = acc.findIndex((group) => group.id === book.groupId);
const booksGroup = acc[acc.findIndex((group) => group.id === book.groupId)];
const slashIndex = book.groupName.indexOf('/', parentGroupName.length + 1);
const leafGroupName = book.groupName.substring(
parentGroupName ? parentGroupName.length + 1 : 0,
slashIndex > 0 ? slashIndex : undefined,
);
const fullGroupName = parentGroupName
? `${parentGroupName}${leafGroupName ? `/${leafGroupName}` : ''}`
: leafGroupName;
const groupIndex = acc.findIndex(
(group) =>
group.name === fullGroupName ||
(parentGroupName && group.name === parentGroupName) ||
(leafGroupName === BOOK_UNGROUPED_NAME && group.name === BOOK_UNGROUPED_NAME),
);
const booksGroup = acc[groupIndex];
if (booksGroup) {
booksGroup.books.push(book);
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
booksGroup.updatedAt = Math.max(booksGroup.updatedAt, book.updatedAt);
} else {
let groupName = fullGroupName;
acc.push({
id: book.groupId,
name: book.groupName,
id: groupName === parentGroupName ? BOOK_UNGROUPED_ID : md5Fingerprint(groupName),
name: groupName === parentGroupName ? BOOK_UNGROUPED_NAME : groupName,
displayName: leafGroupName,
books: [book],
updatedAt: book.updatedAt,
});
@@ -45,29 +66,14 @@ export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] =>
group.books.sort((a, b) => b.updatedAt - a.updatedAt);
});
const ungroupedBooks: Book[] =
groups.find((group) => group.name === BOOK_UNGROUPED_NAME)?.books || [];
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== BOOK_UNGROUPED_NAME);
groups.find((group) => group.name === BOOK_UNGROUPED_NAME || group.name === parentGroupName)
?.books || [];
const groupedBooks: BooksGroup[] = groups.filter(
(group) => group.name !== BOOK_UNGROUPED_NAME && group.name !== parentGroupName,
);
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
};
export const generateGroupsList = (items: Book[]): BookGroupType[] => {
return items
.sort((a, b) => b.updatedAt - a.updatedAt)
.reduce((acc: BookGroupType[], item: Book) => {
if (item.deletedAt) return acc;
if (
item.groupId &&
item.groupName &&
item.groupId !== BOOK_UNGROUPED_ID &&
item.groupName !== BOOK_UNGROUPED_NAME &&
!acc.find((group) => group.id === item.groupId)
) {
acc.push({ id: item.groupId, name: item.groupName });
}
return acc;
}, []) as BookGroupType[];
};
interface BookshelfItemProps {
mode: LibraryViewModeType;
item: BookshelfItem;
@@ -328,7 +334,8 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
<div
className={clsx(
'visible-focus-inset-2 group',
mode === 'grid' && 'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-4 sm:px-4',
mode === 'grid' &&
'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-2 sm:px-4 sm:py-4',
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
appService?.isMobileApp && 'no-context-menu',
pressing && mode === 'grid' ? 'scale-95' : 'scale-100',
@@ -187,7 +187,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
</div>
{mode === 'list' && (
<div className='text-base-content/75 w-28 min-w-24 max-w-40 overflow-hidden text-ellipsis text-base font-semibold'>
{group.name}
{group.displayName}
</div>
)}
{groupSelected && (
@@ -207,7 +207,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
<div className={clsx('flex w-full flex-col pt-2')}>
<div className='min-w-0 flex-1'>
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-xs font-semibold'>
{group.name}
{group.displayName}
</h4>
</div>
<div className='placeholder' style={{ height: `${iconSize15}px` }}></div>
@@ -1,20 +1,22 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { MdCheck } from 'react-icons/md';
import { MdCheck, MdChevronRight } from 'react-icons/md';
import { HiOutlineFolder, HiOutlineFolderAdd, HiOutlineFolderRemove } from 'react-icons/hi';
import { IoMdArrowBack } from 'react-icons/io';
import { Book, BookGroupType } from '@/types/book';
import { isMd5, md5Fingerprint } from '@/utils/md5';
import { isMd5 } from '@/utils/md5';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { generateGroupsList } from './BookshelfItem';
import { getBreadcrumbs } from '../utils/libraryUtils';
interface GroupingModalProps {
libraryBooks: Book[];
selectedBooks: string[];
parentGroupName: string;
onCancel: () => void;
onConfirm: () => void;
}
@@ -22,30 +24,65 @@ interface GroupingModalProps {
const GroupingModal: React.FC<GroupingModalProps> = ({
libraryBooks,
selectedBooks,
parentGroupName,
onCancel,
onConfirm,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { setLibrary } = useLibraryStore();
const groupsList = generateGroupsList(libraryBooks);
const { setLibrary, addGroup, getGroups, getGroupsByParent, getParentPath, refreshGroups } =
useLibraryStore();
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
const [showInput, setShowInput] = useState(false);
const [editGroupName, setEditGroupName] = useState(_('Untitled Group'));
const [editGroupName, setEditGroupName] = useState('');
const [selectedGroup, setSelectedGroup] = useState<BookGroupType | null>(null);
const [newGroups, setNewGroups] = useState<BookGroupType[]>([]);
const [allGroups, setAllGroups] = useState<BookGroupType[]>(groupsList);
const editorRef = useRef<HTMLInputElement>(null);
const [newGroup, setNewGroup] = useState<BookGroupType | null>(null);
const editorRef = useRef<HTMLInputElement>(null);
const iconSize = useResponsiveSize(16);
const allGroups = getGroups();
const currentGroups = getGroupsByParent(currentPath);
const currentGroupsList =
newGroup &&
!currentGroups.some((g) => g.id === newGroup.id) &&
!currentGroups.some((g) => newGroup.name.startsWith(g.name))
? [newGroup, ...currentGroups]
: currentGroups;
const isSelectedBooksHasGroup =
selectedBooks.some((hash) => !isMd5(hash)) ||
selectedBooks
.map((hash) => libraryBooks.find((book) => book.hash === hash)?.groupId)
.some((group) => group && group !== BOOK_UNGROUPED_NAME);
const generateNextUntitledGroupName = () => {
const baseName = _('Untitled Group');
const basePattern = parentGroupName
? `${parentGroupName}/${baseName}`
: currentPath
? `${currentPath}/${baseName}`
: baseName;
const escapedPattern = basePattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const untitledGroupPattern = new RegExp(`^${escapedPattern}\\s*(\\d+)?$`);
const untitledGroupNumbers = allGroups
.map((group) => {
const match = group.name.match(untitledGroupPattern);
return match ? parseInt(match[1] || '1', 10) : null;
})
.filter((num) => num !== null) as number[];
const nextNumber = untitledGroupNumbers.length > 0 ? Math.max(...untitledGroupNumbers) + 1 : 1;
return `${basePattern} ${nextNumber}`;
};
const handleCreateGroup = () => {
const nextName = generateNextUntitledGroupName();
setEditGroupName(nextName);
setShowInput(true);
};
@@ -65,42 +102,26 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
}
}
});
setLibrary(libraryBooks);
setLibrary([...libraryBooks]);
appService?.saveLibraryBooks(libraryBooks);
onConfirm();
};
const handleConfirmCreateGroup = () => {
const groupName = editGroupName.trim();
let groupName = editGroupName.trim();
if (groupName) {
const newGroup = { id: md5Fingerprint(groupName), name: groupName };
const existingGroupIndex = newGroups.findIndex((group) => group.name === groupName);
if (existingGroupIndex > -1) {
newGroups.splice(existingGroupIndex, 1);
if (currentPath && !groupName.startsWith(currentPath + '/')) {
groupName = `${currentPath}/${groupName}`;
}
newGroups.push(newGroup);
setSelectedGroup(newGroup);
setNewGroups(newGroups);
for (const newGroup of newGroups) {
const existingGroupIndex = groupsList.findIndex((group) => group.id === newGroup.id);
if (existingGroupIndex > -1) {
groupsList.splice(existingGroupIndex, 1);
}
groupsList.unshift(newGroup);
}
setAllGroups(groupsList);
const untitledGroupPattern = new RegExp(`^${_('Untitled Group')}\\s*(\\d+)?$`);
const untitledGroupNumbers = groupsList
.map((group) => {
const match = group.name.match(untitledGroupPattern);
return match ? parseInt(match[1] || '0', 10) : null;
})
.filter((num) => num !== null);
const nextNumber =
untitledGroupNumbers.length > 0 ? Math.max(...untitledGroupNumbers) + 1 : 1;
setEditGroupName(`${_('Untitled Group')} ${nextNumber}`);
const newGroup = addGroup(groupName);
setNewGroup(newGroup);
setSelectedGroup(newGroup);
setShowInput(false);
const parentGroup = getParentPath(groupName);
if (parentGroup) {
setCurrentPath(parentGroup);
}
}
};
@@ -108,6 +129,19 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
setSelectedGroup((prevGroup) => (prevGroup?.id === group.id ? null : group));
};
const handleNavigateToGroup = (group: BookGroupType) => {
setCurrentPath(group.name);
};
const handleNavigateBack = () => {
const parent = currentPath ? getParentPath(currentPath) : undefined;
setCurrentPath(parent);
};
const handleNavigateToPath = (path: string | undefined) => {
setCurrentPath(path);
};
const handleConfirmGrouping = () => {
selectedBooks.forEach((id) => {
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
@@ -118,26 +152,43 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
}
}
});
setLibrary(libraryBooks);
setLibrary([...libraryBooks]);
appService?.saveLibraryBooks(libraryBooks);
onConfirm();
};
const getDisplayName = (fullPath: string) => {
const segments = fullPath.split('/');
return segments[segments.length - 1];
};
useEffect(() => {
if (editorRef.current) {
editorRef.current.select();
}
}, [showInput]);
useEffect(() => {
refreshGroups();
}, [refreshGroups]);
useEffect(() => {
const groupIds = selectedBooks
.map((id) => libraryBooks.find((book) => book.hash === id || book.groupId === id)?.groupId)
.filter((groupId) => groupId);
if (Array.from(new Set(groupIds)).length === 1) {
setSelectedGroup(groupsList.find((group) => group.id === groupIds[0]) || null);
setTimeout(() => {
const allGroups = getGroups();
const group = allGroups.find((group) => group.id === groupIds[0]);
setSelectedGroup(group || null);
if (group && !currentPath) {
const parent = getParentPath(group.name);
setCurrentPath(parent);
}
}, 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBooks]);
}, []);
return (
<div className='fixed inset-0 flex items-center justify-center'>
@@ -148,6 +199,8 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
)}
>
<h2 className='text-center text-lg font-bold'>{_('Group Books')}</h2>
{/* Action buttons */}
<div className={clsx('mt-4 grid grid-cols-1 gap-2 text-base md:grid-cols-2')}>
{isSelectedBooksHasGroup && (
<button
@@ -155,7 +208,7 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
className='flex items-center space-x-2 p-2 text-blue-500'
>
<HiOutlineFolderRemove size={iconSize} />
<span>{_('Remove From Group')}</span>
<span className='truncate'>{_('Remove From Group')}</span>
</button>
)}
<button
@@ -163,65 +216,118 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
className='flex items-center space-x-2 p-2 text-blue-500'
>
<HiOutlineFolderAdd size={iconSize} />
<span>{_('Create New Group')}</span>
<span className='truncate'>{_('Create New Group')}</span>
</button>
</div>
{/* Create group input */}
{showInput && (
<div className='mt-4 flex items-center gap-2'>
<input
type='text'
ref={editorRef}
value={editGroupName}
onChange={(e) => setEditGroupName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmCreateGroup();
e.stopPropagation();
}}
className='input input-ghost w-full border-0 px-2 text-base !outline-none sm:text-sm'
/>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={() => handleConfirmCreateGroup()}
>
<div className='pr-1 align-bottom text-base text-blue-500 sm:text-sm'>
{_('Save')}
</div>
</button>
<div className='mt-4 space-y-2'>
<div className='flex items-center gap-2'>
<input
type='text'
ref={editorRef}
value={editGroupName}
onChange={(e) => setEditGroupName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmCreateGroup();
if (e.key === 'Escape') setShowInput(false);
e.stopPropagation();
}}
className='input input-ghost w-full border-0 px-2 text-base !outline-none sm:text-sm'
/>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
editGroupName ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={() => handleConfirmCreateGroup()}
>
<div className='pr-1 align-bottom text-base text-blue-500 sm:text-sm'>
{_('Save')}
</div>
</button>
</div>
</div>
)}
<ul className='groups-list mt-4 grid grid-cols-2 gap-2'>
{allGroups.map((group, index) => (
{/* Breadcrumb navigation */}
{currentPath && (
<div className='mt-4 flex flex-wrap items-center gap-2 text-base'>
<button
key={index}
className={clsx(
'hover:bg-base-300 text-base-content flex w-full',
'items-center justify-between rounded-md px-2 py-2',
)}
onClick={() => handleToggleSelectGroup(group)}
onClick={handleNavigateBack}
className='hover:bg-base-300 flex items-center gap-1 rounded px-2 py-1'
>
<div className='flex min-w-0 items-center'>
<span style={{ minWidth: `${iconSize}px` }}>
<HiOutlineFolder size={iconSize} />
</span>
<span
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm')}
style={{ minWidth: 0 }}
>
{group.name}
</span>
</div>
<span className='text-neutral-content flex shrink-0 text-sm'>
{selectedGroup && selectedGroup.id == group.id && (
<MdCheck className='fill-blue-500' size={iconSize} />
)}
</span>
<IoMdArrowBack size={iconSize} />
</button>
))}
<button
onClick={() => handleNavigateToPath(undefined)}
className='hover:bg-base-300 rounded px-2 py-1'
>
{_('All')}
</button>
{getBreadcrumbs(currentPath).map((crumb, index, array) => {
const isLast = index === array.length - 1;
return (
<React.Fragment key={index}>
<MdChevronRight size={iconSize} className='text-neutral-content' />
{isLast ? (
<span className='truncate rounded px-2 py-1'>{crumb.name}</span>
) : (
<button
onClick={() => handleNavigateToPath(crumb.path)}
className='hover:bg-base-300 truncate rounded px-2 py-1'
>
{crumb.name}
</button>
)}
</React.Fragment>
);
})}
</div>
)}
{/* Groups list */}
<ul className='groups-list mt-4 grid grid-cols-2 gap-2 overflow-x-hidden'>
{currentGroupsList.map((group, index) => {
const displayName = getDisplayName(group.name);
const hasChildren = allGroups.some((g) => g.name.startsWith(group.name + '/'));
return (
<div key={index} className='flex min-w-0 gap-1'>
<button
className={clsx(
'hover:bg-base-300 text-base-content flex min-w-0 max-w-[90%] flex-1',
'items-center justify-between gap-2 rounded-md px-2 py-2',
)}
onClick={() => handleToggleSelectGroup(group)}
>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<span className='shrink-0'>
<HiOutlineFolder size={iconSize} />
</span>
<span className='min-w-0 truncate text-base sm:text-sm'>{displayName}</span>
</div>
<span className='text-neutral-content flex shrink-0 text-sm'>
{selectedGroup && selectedGroup.id === group.id && (
<MdCheck className='fill-blue-500' size={iconSize} />
)}
</span>
</button>
{hasChildren && (
<button
onClick={() => handleNavigateToGroup(group)}
className='hover:bg-base-300 flex shrink-0 items-center rounded-md px-1'
>
<MdChevronRight size={iconSize} />
</button>
)}
</div>
);
})}
</ul>
{/* Footer actions */}
<div className='mt-6 flex justify-end gap-x-8 p-2'>
<button onClick={onCancel} className='flex items-center'>
{_('Cancel')}
@@ -3,18 +3,18 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
import { PiSelectionAllDuotone } from 'react-icons/pi';
import { PiSelectionAll, PiSelectionAllFill } from 'react-icons/pi';
import { PiDotsThreeCircle } from 'react-icons/pi';
import { MdOutlineMenu, MdArrowBackIosNew } from 'react-icons/md';
import { MdOutlineMenu } from 'react-icons/md';
import { IoMdCloseCircle } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { navigateToLibrary } from '@/utils/nav';
import { debounce } from '@/utils/debounce';
import useShortcuts from '@/hooks/useShortcuts';
import WindowButtons from '@/components/WindowButtons';
@@ -44,6 +44,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { currentBookshelf } = useLibraryStore();
const {
@@ -55,9 +56,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
} = useTrafficLightStore();
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const viewSettings = settings.globalViewSettings;
const headerRef = useRef<HTMLDivElement>(null);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
const { safeAreaInsets: insets } = useThemeStore();
useShortcuts({
@@ -97,7 +98,6 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
}, [appService?.hasTrafficLight]);
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const isInGroupView = !!searchParams?.get('group');
const currentBooksCount = currentBookshelf.reduce(
(acc, item) => acc + ('books' in item ? item.books.length : 1),
0,
@@ -121,20 +121,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
>
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
{isInGroupView && (
<button
onClick={() => {
navigateToLibrary(router);
}}
className='ml-[-6px] mr-4 flex h-7 min-h-7 w-7 items-center p-0'
>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Go Back')}>
<MdArrowBackIosNew size={iconSize20} />
</div>
</button>
)}
<div className='relative flex h-9 w-full items-center sm:h-7'>
<span className='absolute left-3 text-gray-500'>
<span className='text-base-content/50 absolute left-3'>
<FaSearch className='h-4 w-4' />
</span>
<input
@@ -150,13 +138,17 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/45 h-9 w-full pl-10 pr-10 sm:h-7',
'input rounded-badge h-9 w-full pl-10 pr-10 sm:h-7',
viewSettings?.isEink
? 'border-1 border-base-content focus:border-base-content'
: 'bg-base-300/45 border-none',
'font-sans text-sm font-light',
'border-none focus:outline-none focus:ring-0',
'placeholder:text-base-content/50',
'focus:outline-none focus:ring-0',
)}
/>
</div>
<div className='absolute right-4 flex items-center space-x-2 text-gray-500 sm:space-x-4'>
<div className='text-base-content/50 absolute right-4 flex items-center space-x-2 sm:space-x-4'>
{searchQuery && (
<button
type='button'
@@ -164,7 +156,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
setSearchQuery('');
debouncedUpdateQueryParam('');
}}
className='pe-1 text-gray-400 hover:text-gray-600'
className='text-base-content/40 hover:text-base-content/60 pe-1'
aria-label={_('Clear Search')}
>
<IoMdCloseCircle className='h-4 w-4' />
@@ -189,10 +181,11 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
title={_('Select Books')}
className='h-6'
>
<PiSelectionAllDuotone
role='button'
className={`h-6 w-6 ${isSelectMode ? 'fill-gray-400' : 'fill-gray-500'}`}
/>
{isSelectMode ? (
<PiSelectionAllFill role='button' className='text-base-content/60 h-6 w-6' />
) : (
<PiSelectionAll role='button' className='text-base-content/60 h-6 w-6' />
)}
</button>
)}
</div>
@@ -18,7 +18,10 @@ export const useBooksSync = () => {
if (!user) return {};
const library = useLibraryStore.getState().library;
const newBooks = library.filter(
(book) => lastSyncedAtBooks < book.updatedAt || lastSyncedAtBooks < (book.deletedAt ?? 0),
(book) =>
!book.syncedAt ||
lastSyncedAtBooks < book.updatedAt ||
lastSyncedAtBooks < (book.deletedAt ?? 0),
);
return {
books: newBooks,
@@ -50,7 +53,7 @@ export const useBooksSync = () => {
}
},
SYNC_BOOKS_INTERVAL_SEC * 1000,
{ emitLast: false },
{ emitLast: true },
),
[syncBooks],
);
@@ -67,7 +70,9 @@ export const useBooksSync = () => {
const pushLibrary = useCallback(async () => {
if (!user) return;
const newBooks = getNewBooks();
await syncBooks(newBooks?.books, 'push');
if (newBooks.lastSyncedAt) {
await syncBooks(newBooks?.books, 'push');
}
}, [user, syncBooks, getNewBooks]);
useEffect(() => {
@@ -94,9 +99,9 @@ export const useBooksSync = () => {
oldBook.coverImageUrl = await appService?.generateCoverImageUrl(oldBook);
}
const mergedBook =
matchingBook.updatedAt > oldBook.updatedAt
? { ...oldBook, ...matchingBook }
: { ...matchingBook, ...oldBook };
matchingBook.updatedAt >= oldBook.updatedAt
? { ...oldBook, ...matchingBook, syncedAt: Date.now() }
: { ...matchingBook, ...oldBook, syncedAt: Date.now() };
return mergedBook;
}
return oldBook;
@@ -117,6 +122,7 @@ export const useBooksSync = () => {
const processNewBook = async (newBook: Book) => {
newBook.coverImageUrl = await appService?.generateCoverImageUrl(newBook);
newBook.syncedAt = Date.now();
updatedLibrary.push(newBook);
};
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
import { eventDispatcher } from '@/utils/event';
import { SelectedFile } from '@/hooks/useFileSelector';
import { isTauriAppPlatform } from '@/services/environment';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { useTranslation } from '@/hooks/useTranslation';
import { BOOK_ACCEPT_FORMATS } from '@/services/constants';
import { useSearchParams } from 'next/navigation';
export const useDragDropImport = () => {
const _ = useTranslation();
const searchParams = useSearchParams();
const group = searchParams?.get('group') || '';
const { appService } = useEnv();
const [isDragging, setIsDragging] = useState(false);
const handleDroppedFiles = async (files: File[] | string[]) => {
if (files.length === 0) return;
const supportedFiles = files.filter((file) => {
let fileExt;
if (typeof file === 'string') {
fileExt = file.split('.').pop()?.toLowerCase();
} else {
fileExt = file.name.split('.').pop()?.toLowerCase();
}
return BOOK_ACCEPT_FORMATS.includes(`.${fileExt}`);
});
if (supportedFiles.length === 0) {
eventDispatcher.dispatch('toast', {
message: _('No supported files found. Supported formats: {{formats}}', {
formats: BOOK_ACCEPT_FORMATS,
}),
type: 'error',
});
return;
}
if (appService?.hasHaptics) {
impactFeedback('medium');
}
const selectedFiles = supportedFiles.map(
(file) =>
({
file: typeof file === 'string' ? undefined : file,
path: typeof file === 'string' ? file : undefined,
}) as SelectedFile,
);
eventDispatcher.dispatch('import-book-files', { files: selectedFiles, groupId: group });
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
const files = Array.from(event.dataTransfer.files);
handleDroppedFiles(files);
}
};
useEffect(() => {
const libraryPage = document.querySelector('.library-page');
if (!appService?.isMobile) {
libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener);
}
if (isTauriAppPlatform()) {
const unlisten = getCurrentWebview().onDragDropEvent((event) => {
if (event.payload.type === 'over') {
setIsDragging(true);
} else if (event.payload.type === 'drop') {
setIsDragging(false);
handleDroppedFiles(event.payload.paths);
} else {
setIsDragging(false);
}
});
return () => {
unlisten.then((fn) => fn());
};
}
return () => {
if (!appService?.isMobile) {
libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [group]);
return { isDragging };
};
+106 -106
View File
@@ -2,6 +2,7 @@
import clsx from 'clsx';
import * as React from 'react';
import { MdChevronRight } from 'react-icons/md';
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
@@ -9,7 +10,7 @@ import 'overlayscrollbars/overlayscrollbars.css';
import { Book } from '@/types/book';
import { AppService, DeleteAction } from '@/types/system';
import { navigateToLogin, navigateToReader } from '@/utils/nav';
import { navigateToLibrary, navigateToLogin, navigateToReader } from '@/utils/nav';
import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
@@ -18,7 +19,6 @@ import { getFilename } from '@/utils/path';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
import { BOOK_ACCEPT_FORMATS } from '@/services/constants';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
import { getCurrentWebview } from '@tauri-apps/api/webview';
@@ -28,6 +28,7 @@ import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import { useTheme } from '@/hooks/useTheme';
import { useUICSS } from '@/hooks/useUICSS';
@@ -49,7 +50,9 @@ import { AboutWindow } from '@/components/AboutWindow';
import { BookDetailModal } from '@/components/metadata';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { MigrateDataWindow } from './components/MigrateDataWindow';
import { useDragDropImport } from './hooks/useDragDropImport';
import { Toast } from '@/components/Toast';
import { getBreadcrumbs } from './utils/libraryUtils';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
@@ -72,6 +75,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
syncProgress,
updateBook,
setLibrary,
getGroupId,
getGroupName,
refreshGroups,
checkOpenWithBooks,
checkLastOpenBooks,
setCheckOpenWithBooks,
@@ -88,13 +94,15 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const [isSelectAll, setIsSelectAll] = useState(false);
const [isSelectNone, setIsSelectNone] = useState(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [currentGroupPath, setCurrentGroupPath] = useState<string | undefined>(undefined);
const [booksTransferProgress, setBooksTransferProgress] = useState<{
[key: string]: number | null;
}>({});
const [pendingNavigationBookIds, setPendingNavigationBookIds] = useState<string[] | null>(null);
const [isDragging, setIsDragging] = useState(false);
const isInitiating = useRef(false);
const iconSize = useResponsiveSize(18);
const viewSettings = settings.globalViewSettings;
const demoBooks = useDemoBooks();
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
const containerRef: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
@@ -106,6 +114,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useOpenWithBooks();
const { pullLibrary, pushLibrary } = useBooksSync();
const { isDragging } = useDragDropImport();
usePullToRefresh(containerRef, pullLibrary);
useScreenWakeLock(settings.screenWakeLock);
@@ -134,6 +143,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
},
});
useEffect(() => {
sessionStorage.setItem('lastLibraryParams', searchParams?.toString() || '');
}, [searchParams]);
useEffect(() => {
const doCheckAppUpdates = async () => {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
@@ -155,64 +168,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
}, [appService]);
const handleDropedFiles = async (files: File[] | string[]) => {
if (files.length === 0) return;
const supportedFiles = files.filter((file) => {
let fileExt;
if (typeof file === 'string') {
fileExt = file.split('.').pop()?.toLowerCase();
} else {
fileExt = file.name.split('.').pop()?.toLowerCase();
}
return BOOK_ACCEPT_FORMATS.includes(`.${fileExt}`);
});
if (supportedFiles.length === 0) {
eventDispatcher.dispatch('toast', {
message: _('No supported files found. Supported formats: {{formats}}', {
formats: BOOK_ACCEPT_FORMATS,
}),
type: 'error',
});
return;
}
if (appService?.hasHaptics) {
impactFeedback('medium');
}
const selectedFiles = supportedFiles.map(
(file) =>
({
file: typeof file === 'string' ? undefined : file,
path: typeof file === 'string' ? file : undefined,
}) as SelectedFile,
);
await importBooks(selectedFiles);
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
const files = Array.from(event.dataTransfer.files);
handleDropedFiles(files);
}
};
const handleRefreshLibrary = useCallback(async () => {
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
@@ -235,48 +190,30 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
return;
}, [appService, handleRefreshLibrary]);
useEffect(() => {
const libraryPage = document.querySelector('.library-page');
if (!appService?.isMobile) {
libraryPage?.addEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.addEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.addEventListener('drop', handleDrop as unknown as EventListener);
}
if (isTauriAppPlatform()) {
const unlisten = getCurrentWebview().onDragDropEvent((event) => {
if (event.payload.type === 'over') {
setIsDragging(true);
} else if (event.payload.type === 'drop') {
setIsDragging(false);
handleDropedFiles(event.payload.paths);
} else {
setIsDragging(false);
}
});
return () => {
unlisten.then((fn) => fn());
};
}
return () => {
if (!appService?.isMobile) {
libraryPage?.removeEventListener('dragover', handleDragOver as unknown as EventListener);
libraryPage?.removeEventListener('dragleave', handleDragLeave as unknown as EventListener);
libraryPage?.removeEventListener('drop', handleDrop as unknown as EventListener);
}
};
const handleImportBookFiles = useCallback(async (event: CustomEvent) => {
const selectedFiles: SelectedFile[] = event.detail.files;
const groupId: string = event.detail.groupId || '';
if (selectedFiles.length === 0) return;
await importBooks(selectedFiles, groupId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageRef.current]);
}, []);
useEffect(() => {
eventDispatcher.on('import-book-files', handleImportBookFiles);
return () => {
eventDispatcher.off('import-book-files', handleImportBookFiles);
};
}, [handleImportBookFiles]);
useEffect(() => {
refreshGroups();
if (!libraryBooks.some((book) => !book.deletedAt)) {
handleSetSelectMode(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libraryBooks]);
const processOpenWithFiles = React.useCallback(
const processOpenWithFiles = useCallback(
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
const settings = await appService.loadSettings();
const bookIds: string[] = [];
@@ -398,6 +335,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
useEffect(() => {
const group = searchParams?.get('group') || '';
const groupName = getGroupName(group);
setCurrentGroupPath(groupName);
}, [libraryBooks, searchParams, getGroupName]);
useEffect(() => {
if (demoBooks.length > 0 && libraryLoaded) {
const newLibrary = [...libraryBooks];
@@ -415,7 +358,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [demoBooks, libraryLoaded]);
const importBooks = async (files: SelectedFile[]) => {
const importBooks = async (files: SelectedFile[], groupId?: string) => {
setLoading(true);
const { library } = useLibraryStore.getState();
const failedImports: Array<{ filename: string; errorMessage: string }> = [];
@@ -433,6 +376,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
if (!file) return;
try {
const book = await appService?.importBook(file, library);
if (book && groupId) {
book.groupId = groupId;
book.groupName = getGroupName(groupId);
await updateBook(envConfig, book);
}
if (user && book && !book.uploadedAt && settings.autoUpload) {
console.log('Uploading book:', book.title);
handleBookUpload(book, false);
@@ -634,7 +582,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
console.log('Importing books...');
selectFiles({ type: 'books', multiple: true }).then((result) => {
if (result.files.length === 0 || result.error) return;
importBooks(result.files);
const groupId = searchParams?.get('group') || '';
importBooks(result.files, groupId);
});
};
@@ -661,6 +610,22 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setShowDetailsBook(book);
};
const handleNavigateToPath = (path: string | undefined) => {
const group = path ? getGroupId(path) : '';
const params = new URLSearchParams(searchParams?.toString());
if (group) {
params.set('group', group);
} else {
params.delete('group');
}
setIsSelectAll(false);
setIsSelectNone(false);
navigateToLibrary(router, `${params.toString()}`);
setTimeout(() => {
setCurrentGroupPath(path);
}, 300);
};
if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) {
return <div className={clsx('h-[100vh]', !appService?.isLinuxApp && 'bg-base-200')} />;
}
@@ -672,12 +637,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
ref={pageRef}
aria-label='Your Library'
className={clsx(
'library-page bg-base-200 text-base-content flex h-[100vh] select-none flex-col overflow-hidden',
'library-page text-base-content flex h-[100vh] select-none flex-col overflow-hidden',
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)}
>
<div
className='top-0 z-40 w-full'
className='relative top-0 z-40 w-full'
role='banner'
tabIndex={-1}
aria-label={_('Library Header')}
@@ -690,12 +656,54 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
/>
<progress
className={clsx(
'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
isSyncing ? 'opacity-100' : 'opacity-0',
)}
value={syncProgress * 100}
max='100'
/>
</div>
{(loading || isSyncing) && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)}
{currentGroupPath && (
<div
className={`transition-all duration-300 ease-in-out ${
currentGroupPath ? 'opacity-100' : 'max-h-0 opacity-0'
}`}
>
<div className='flex flex-wrap items-center gap-y-1 px-4 text-base'>
<button
onClick={() => handleNavigateToPath(undefined)}
className='hover:bg-base-300 text-base-content/85 rounded px-2 py-1'
>
{_('All')}
</button>
{getBreadcrumbs(currentGroupPath).map((crumb, index, array) => {
const isLast = index === array.length - 1;
return (
<React.Fragment key={index}>
<MdChevronRight size={iconSize} className='text-neutral-content' />
{isLast ? (
<span className='truncate rounded px-2 py-1'>{crumb.name}</span>
) : (
<button
onClick={() => handleNavigateToPath(crumb.path)}
className='hover:bg-base-300 text-base-content/85 truncate rounded px-2 py-1'
>
{crumb.name}
</button>
)}
</React.Fragment>
);
})}
</div>
</div>
)}
{showBookshelf &&
(libraryBooks.some((book) => !book.deletedAt) ? (
<OverlayScrollbarsComponent
@@ -722,14 +730,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
paddingLeft: `${insets.left}px`,
}}
>
<progress
className={clsx(
'progress progress-success absolute left-0 right-0 top-[2px] z-30 h-1 transition-opacity duration-200',
isSyncing ? 'opacity-100' : 'opacity-0',
)}
value={syncProgress * 100}
max='100'
></progress>
<DropIndicator />
<Bookshelf
libraryBooks={libraryBooks}
@@ -4,7 +4,22 @@ import { formatAuthors, formatTitle } from '@/utils/book';
export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
if (!queryTerm) return true;
if (item.deletedAt) return false;
const searchTerm = new RegExp(queryTerm, 'i');
let searchTerm: RegExp;
try {
searchTerm = new RegExp(queryTerm, 'i');
} catch {
const lowerQuery = queryTerm.toLowerCase();
const title = formatTitle(item.title).toLowerCase();
const authors = formatAuthors(item.author).toLowerCase();
return (
title.includes(lowerQuery) ||
authors.includes(lowerQuery) ||
item.format.toLowerCase().includes(lowerQuery) ||
(item.groupName && item.groupName.toLowerCase().includes(lowerQuery)) ||
(item.metadata?.description && item.metadata.description.toLowerCase().includes(lowerQuery))
);
}
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return (
@@ -36,3 +51,12 @@ export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
};
export const getBreadcrumbs = (currentPath: string) => {
if (!currentPath) return [];
const segments = currentPath.split('/');
return segments.map((segment, index) => ({
name: segment,
path: segments.slice(0, index + 1).join('/'),
}));
};
@@ -23,6 +23,8 @@ import { useKOSync } from '../hooks/useKOSync';
import {
applyFixedlayoutStyles,
applyImageStyle,
applyScrollModeClass,
applyTableStyle,
applyThemeModeClass,
applyTranslationStyle,
getStyles,
@@ -129,13 +131,22 @@ const FoliateViewer: React.FC<{
if (viewSettings && detail.type === 'text/css')
return transformStylesheet(width, height, data);
if (viewSettings && bookData && detail.type === 'application/xhtml+xml') {
const ctx = {
const ctx: TransformContext = {
bookKey,
viewSettings,
width,
height,
primaryLanguage: bookData.book?.primaryLanguage,
content: data,
transformers: ['punctuation', 'footnote', 'whitespace', 'language', 'sanitizer'],
} as TransformContext;
transformers: [
'style',
'punctuation',
'footnote',
'whitespace',
'language',
'sanitizer',
],
};
return Promise.resolve(transformContent(ctx));
}
return data;
@@ -181,7 +192,9 @@ const FoliateViewer: React.FC<{
}
applyImageStyle(detail.doc);
applyTableStyle(detail.doc);
applyThemeModeClass(detail.doc, isDarkMode);
applyScrollModeClass(detail.doc, viewSettings.scrolled || false);
keepTextAlignment(detail.doc);
removeTabIndex(detail.doc);
@@ -403,10 +416,17 @@ const FoliateViewer: React.FC<{
applyFixedlayoutStyles(doc, viewSettings);
}
applyThemeModeClass(doc, isDarkMode);
applyScrollModeClass(doc, viewSettings.scrolled || false);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode, isDarkMode, viewSettings?.overrideColor, viewSettings?.invertImgColorInDark]);
}, [
themeCode,
isDarkMode,
viewSettings?.scrolled,
viewSettings?.overrideColor,
viewSettings?.invertImgColorInDark,
]);
useEffect(() => {
const mountCustomFonts = async () => {
@@ -107,6 +107,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<div
role='none'
className={clsx('absolute top-0 z-10 h-11 w-full')}
onClick={() => setHoveredBookKey(bookKey)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
@@ -60,6 +60,8 @@ const HintInfo: React.FC<SectionInfoProps> = ({
};
}, [hintMessage]);
if (!hintMessage) return null;
return (
<>
<div
@@ -33,7 +33,7 @@ const KOSyncConflictResolver: React.FC<KOSyncConflictResolverProps> = ({
onClick={onResolveWithLocal}
>
<span>{_('Local Progress')}</span>
<span className='text-xs font-normal normal-case text-gray-500'>
<span className='text-base-content/50 text-xs font-normal normal-case'>
{details.local.preview}
</span>
</button>
@@ -73,7 +73,8 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
return (
<div
className={clsx(
'progressinfo absolute bottom-0 flex items-center justify-between font-sans',
'progressinfo absolute flex items-center justify-between font-sans',
'pointer-events-none bottom-0',
isEink ? 'text-sm font-normal' : 'text-neutral-content text-xs font-extralight',
isVertical ? 'writing-vertical-rl' : 'w-full',
isScrolled && !isVertical && 'bg-base-100',
@@ -131,14 +131,18 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
clearViewState(bookKey);
};
const navigateBackToLibrary = () => {
navigateToLibrary(router, '', undefined, true);
};
const saveSettingsAndGoToLibrary = () => {
saveSettings(envConfig, settings);
navigateToLibrary(router);
navigateBackToLibrary();
};
const handleCloseBooks = throttle(async () => {
const settings = useSettingsStore.getState().settings;
await Promise.all(bookKeys.map((key) => saveConfigAndCloseBook(key)));
await Promise.all(bookKeys.map(async (key) => await saveConfigAndCloseBook(key)));
await saveSettings(envConfig, settings);
}, 200);
@@ -147,12 +151,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
if (isTauriAppPlatform()) {
const currentWindow = getCurrentWindow();
if (currentWindow.label === 'main') {
navigateToLibrary(router);
navigateBackToLibrary();
} else {
currentWindow.close();
}
} else {
navigateToLibrary(router);
navigateBackToLibrary();
}
};
@@ -166,6 +170,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const openWithFiles = (await parseOpenWithFiles()) || [];
if (appService?.hasWindow) {
if (openWithFiles.length > 0) {
tauriHandleOnCloseWindow(handleCloseBooks);
return await tauriHandleClose();
}
const currentWindow = getCurrentWindow();
@@ -31,7 +31,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { hoveredBookKey } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const topInset = Math.max(
gridInsets.top,
@@ -56,8 +56,8 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'top-0 h-[44px]',
isScrolled && !isVertical && 'bg-base-100',
)}
role='banner'
aria-label={section ? _('Section Title') + `: ${section}` : ''}
role='none'
onClick={() => setHoveredBookKey(bookKey)}
style={
isVertical
? {
@@ -76,7 +76,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
}
>
<span
aria-hidden='true'
aria-label={section ? _('Section Title') + `: ${section}` : ''}
className={clsx(
'text-center',
isVertical ? '' : 'line-clamp-1',
@@ -10,7 +10,12 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface AnnotationPopupProps {
dir: 'ltr' | 'rtl';
isVertical: boolean;
buttons: Array<{ tooltipText: string; Icon: React.ElementType; onClick: () => void }>;
buttons: Array<{
tooltipText: string;
Icon: React.ElementType;
onClick: () => void;
disabled?: boolean;
}>;
position: Position;
trianglePosition: Position;
highlightOptionsVisible: boolean;
@@ -65,6 +70,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
/>
))}
</div>
@@ -32,6 +32,7 @@ import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import TranslatorPopup from './TranslatorPopup';
import useShortcuts from '@/hooks/useShortcuts';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -79,6 +80,57 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const annotPopupHeight = useResponsiveSize(44);
const androidSelectionHandlerHeight = 0;
// Reposition popups on scroll without dismissing them
const repositionPopups = useCallback(() => {
if (!selection || !selection.text) return;
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
if (!gridFrame) return;
const rect = gridFrame.getBoundingClientRect();
const triangPos = getPosition(selection.range, rect, popupPadding, viewSettings.vertical);
const annotPopupPos = getPopupPosition(
triangPos,
rect,
viewSettings.vertical ? annotPopupHeight : annotPopupWidth,
viewSettings.vertical ? annotPopupWidth : annotPopupHeight,
popupPadding,
);
if (annotPopupPos.dir === 'down' && osPlatform === 'android') {
triangPos.point.y += androidSelectionHandlerHeight;
annotPopupPos.point.y += androidSelectionHandlerHeight;
}
const dictPopupPos = getPopupPosition(
triangPos,
rect,
dictPopupWidth,
dictPopupHeight,
popupPadding,
);
const transPopupPos = getPopupPosition(
triangPos,
rect,
transPopupWidth,
transPopupHeight,
popupPadding,
);
if (triangPos.point.x == 0 || triangPos.point.y == 0) return;
setAnnotPopupPosition(annotPopupPos);
setDictPopupPosition(dictPopupPos);
setTranslatorPopupPosition(transPopupPos);
setTrianglePosition(triangPos);
}, [
selection,
bookKey,
osPlatform,
popupPadding,
viewSettings.vertical,
annotPopupHeight,
annotPopupWidth,
dictPopupWidth,
dictPopupHeight,
transPopupWidth,
transPopupHeight,
]);
useEffect(() => {
setSelectedStyle(settings.globalReadSettings.highlightStyle);
}, [settings.globalReadSettings.highlightStyle]);
@@ -120,28 +172,59 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleTouchmove = () => {
// Available on iOS, on Android not fired
// To make the popup not to follow the selection
// To make the popup not follow the selection while dragging
setShowAnnotPopup(false);
};
if (bookData.book?.format !== 'PDF') {
view?.renderer?.addEventListener('scroll', handleScroll);
detail.doc?.addEventListener('touchstart', handleTouchStart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchEnd);
detail.doc?.addEventListener('pointerup', (ev: PointerEvent) =>
handlePointerup(doc, index, ev),
);
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index));
// Disable the default context menu on mobile devices,
// although it should but doesn't work on iOS
if (appService?.isMobile) {
detail.doc?.addEventListener('contextmenu', (event: Event) => {
event.preventDefault();
event.stopPropagation();
return false;
});
}
// Attach generic selection listeners for all formats, including PDF.
// For PDF we only guarantee Copy & Translate; highlight/annotate may be limited by CFI support.
view?.renderer?.addEventListener('scroll', handleScroll);
// Reposition popups on scroll to keep them in view
view?.renderer?.addEventListener('scroll', () => {
repositionPopups();
});
detail.doc?.addEventListener('touchstart', handleTouchStart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchEnd);
detail.doc?.addEventListener('pointerup', (ev: PointerEvent) =>
handlePointerup(doc, index, ev),
);
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index));
// For PDF selections, enable right-click context menu to directly open translator popup.
if (bookData.book?.format === 'PDF') {
detail.doc?.addEventListener('contextmenu', (e: Event) => {
try {
const sel = doc.getSelection?.();
if (sel && !sel.isCollapsed) {
const range = sel.getRangeAt(0);
const text = sel.toString();
if (text.trim()) {
setSelection({ key: bookKey, text, range, index });
// Show translation popup preferentially for PDF right-click
setShowAnnotPopup(false);
setShowDeepLPopup(true);
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
}
}
} catch (err) {
console.warn('PDF context menu translation failed:', err);
}
// Prevent native menu to keep experience consistent
e.preventDefault();
e.stopPropagation();
return false;
});
}
// Disable the default context menu on mobile devices (selection handles suffice)
if (appService?.isMobile) {
detail.doc?.addEventListener('contextmenu', (event: Event) => {
event.preventDefault();
event.stopPropagation();
return false;
});
}
};
@@ -194,6 +277,29 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAnnotPopup, showWiktionaryPopup, showWikipediaPopup, showDeepLPopup]);
// When popups are visible, update their positions on scroll events
useEffect(() => {
const view = getView(bookKey);
if (!view?.renderer) return;
const onScroll = () => {
if (showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup) {
repositionPopups();
}
};
view.renderer.addEventListener('scroll', onScroll);
return () => {
view.renderer.removeEventListener('scroll', onScroll);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
bookKey,
showAnnotPopup,
showWiktionaryPopup,
showWikipediaPopup,
showDeepLPopup,
repositionPopups,
]);
useEffect(() => {
eventDispatcher.on('export-annotations', handleExportMarkdown);
return () => {
@@ -401,6 +507,28 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
};
// Keyboard shortcuts: trigger actions only if there's an active selection and popup hidden
useShortcuts(
{
onTranslateSelection: () => {
if (selection?.text) {
handleTranslation();
}
},
onDictionarySelection: () => {
if (selection?.text) {
handleDictionary();
}
},
onWikipediaSelection: () => {
if (selection?.text) {
handleWikipedia();
}
},
},
[selection?.text],
);
const handleExportMarkdown = (event: CustomEvent) => {
const { bookKey: exportBookKey } = event.detail;
if (bookKey !== exportBookKey) return;
@@ -493,13 +621,29 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
tooltipText: selectionAnnotated ? _('Delete Highlight') : _('Highlight'),
Icon: selectionAnnotated ? RiDeleteBinLine : PiHighlighterFill,
onClick: handleHighlight,
disabled: bookData.book?.format === 'PDF',
},
{
tooltipText: _('Annotate'),
Icon: BsPencilSquare,
onClick: handleAnnotate,
disabled: bookData.book?.format === 'PDF',
},
{
tooltipText: _('Search'),
Icon: FiSearch,
onClick: handleSearch,
disabled: bookData.book?.format === 'PDF',
},
{ tooltipText: _('Annotate'), Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: _('Search'), Icon: FiSearch, onClick: handleSearch },
{ tooltipText: _('Dictionary'), Icon: TbHexagonLetterD, onClick: handleDictionary },
{ tooltipText: _('Wikipedia'), Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Translate'), Icon: BsTranslate, onClick: handleTranslation },
{ tooltipText: _('Speak'), Icon: FaHeadphones, onClick: handleSpeakText },
{
tooltipText: _('Speak'),
Icon: FaHeadphones,
onClick: handleSpeakText,
disabled: bookData.book?.format === 'PDF',
},
];
return (
@@ -1,13 +1,21 @@
import clsx from 'clsx';
import React, { useState } from 'react';
interface PopupButtonProps {
showTooltip: boolean;
tooltipText: string;
disabled?: boolean;
Icon: React.ElementType;
onClick: () => void;
}
const PopupButton: React.FC<PopupButtonProps> = ({ showTooltip, tooltipText, Icon, onClick }) => {
const PopupButton: React.FC<PopupButtonProps> = ({
showTooltip,
tooltipText,
disabled,
Icon,
onClick,
}) => {
const [buttonClicked, setButtonClicked] = useState(false);
const handleClick = () => {
setButtonClicked(true);
@@ -20,7 +28,11 @@ const PopupButton: React.FC<PopupButtonProps> = ({ showTooltip, tooltipText, Ico
>
<button
onClick={handleClick}
className='flex h-8 min-h-8 w-8 items-center justify-center p-0'
className={clsx(
'flex h-8 min-h-8 w-8 items-center justify-center p-0',
disabled ? 'cursor-not-allowed opacity-50' : 'rounded-md hover:bg-gray-500',
)}
disabled={disabled}
>
<Icon />
</button>
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { PiSun, PiMoon } from 'react-icons/pi';
import { TbSunMoon } from 'react-icons/tb';
import { useEnv } from '@/context/EnvContext';
@@ -30,7 +30,7 @@ export const ColorPanel: React.FC<ColorPanelProps> = ({ actionTab, bottomOffset
const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore();
const { themeMode, themeColor, isDarkMode, setThemeMode, setThemeColor } = useThemeStore();
const [screenBrightnessValue, setScreenBrightnessValue] = React.useState(
const [screenBrightnessValue, setScreenBrightnessValue] = useState(
settings.screenBrightness >= 0 ? settings.screenBrightness : SCREEN_BRIGHTNESS_LIMITS.DEFAULT,
);
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { FaHeadphones } from 'react-icons/fa6';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
@@ -18,7 +18,7 @@ const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
onSpeakText,
}) => {
const _ = useTranslation();
const { getView, getViewState, getViewSettings } = useReaderStore();
const { hoveredBookKey, getView, getViewState, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const viewState = getViewState(bookKey);
const viewSettings = getViewSettings(bookKey);
@@ -27,6 +27,16 @@ const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
progressValid ? progressFraction * 100 : 0,
);
const rangeInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (hoveredBookKey !== bookKey) {
if (rangeInputRef.current && document.activeElement === rangeInputRef.current) {
rangeInputRef.current.blur();
}
}
}, [hoveredBookKey, bookKey]);
useEffect(() => {
if (progressValid) {
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -93,6 +103,7 @@ const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
</span>
)}
<input
ref={rangeInputRef}
type='range'
className='text-base-content mx-2 min-w-0 flex-1'
min={0}
@@ -1,10 +1,11 @@
import clsx from 'clsx';
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useDeviceControlStore } from '@/store/deviceStore';
import { eventDispatcher } from '@/utils/event';
import { FooterBarProps, NavigationHandlers, FooterBarChildProps } from './types';
import { debounce } from '@/utils/debounce';
@@ -23,13 +24,15 @@ const FooterBar: React.FC<FooterBarProps> = ({
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { getConfig, setConfig } = useBookDataStore();
const { getConfig, setConfig, getBookData } = useBookDataStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { getView, getViewState, getProgress, getViewSettings } = useReaderStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const view = getView(bookKey);
const config = getConfig(bookKey);
const bookData = getBookData(bookKey);
const viewState = getViewState(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
@@ -147,6 +150,40 @@ const FooterBar: React.FC<FooterBarProps> = ({
],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent | CustomEvent) => {
if (event instanceof CustomEvent) {
if (event.detail.keyName === 'Back') {
setHoveredBookKey('');
return true;
}
} else {
if (event.key === 'Escape') {
setHoveredBookKey('');
}
event.stopPropagation();
}
return false;
},
[setHoveredBookKey],
);
useEffect(() => {
if (!appService?.isAndroidApp) return;
if (hoveredBookKey) {
acquireBackKeyInterception();
eventDispatcher.onSync('native-key-down', handleKeyDown);
}
return () => {
if (hoveredBookKey) {
releaseBackKeyInterception();
eventDispatcher.offSync('native-key-down', handleKeyDown);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey]);
const commonProps: FooterBarChildProps = {
bookKey,
gridInsets,
@@ -173,6 +210,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
: 'pointer-events-none translate-y-full opacity-0 sm:translate-y-0',
);
const needHorizontalScroll =
(viewSettings?.vertical && viewSettings?.scrolled) ||
(bookData?.isFixedLayout && viewSettings?.zoomLevel && viewSettings.zoomLevel > 100);
return (
<>
{/* Hover trigger area */}
@@ -180,8 +221,9 @@ const FooterBar: React.FC<FooterBarProps> = ({
role='none'
className={clsx(
'absolute bottom-0 left-0 z-10 flex h-[52px] w-full',
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
needHorizontalScroll && 'sm:!bottom-3 sm:!h-7',
)}
onClick={() => setHoveredBookKey(bookKey)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
@@ -122,7 +122,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
<div className='relative px-3 py-2'>
<div className='bg-base-100 flex h-8 items-center rounded-lg'>
<div className='pl-3'>
<FaSearch size={iconSize16} className='text-gray-500' />
<FaSearch size={iconSize16} className='text-base-content/50' />
</div>
<input
@@ -141,7 +141,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
onClick={handleClearSearch}
className='btn btn-ghost h-8 min-h-8 w-8 rounded-none rounded-r-lg p-0'
>
<FaTimes size={iconSize12} className='text-gray-500' />
<FaTimes size={iconSize12} className='text-base-content/50' />
</button>
</div>
)}
@@ -191,7 +191,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
<div className='relative p-2'>
<div className='bg-base-100 flex h-8 items-center rounded-lg'>
<div className='pl-3'>
<FaSearch size={iconSize16} className='text-gray-500' />
<FaSearch size={iconSize16} className='text-base-content/50' />
</div>
<input
@@ -213,7 +213,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
)}
menuClassName={window.innerWidth < 640 ? 'no-triangle mt-1' : 'dropdown-center mt-3'}
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 rounded-none rounded-r-lg'
toggleButton={<FaChevronDown size={iconSize12} className='text-gray-500' />}
toggleButton={<FaChevronDown size={iconSize12} className='text-base-content/50' />}
>
<SearchOptions
searchConfig={searchConfig}
@@ -68,7 +68,7 @@ const TOCItemView = React.memo<{
className={clsx(
'flex w-full cursor-pointer items-center rounded-md py-4 sm:py-2',
isActive
? 'sm:bg-base-300/65 sm:hover:bg-base-300/75 sm:text-base-content text-blue-500'
? 'text-bold-in-eink sm:bg-base-300/65 sm:hover:bg-base-300/75 sm:text-base-content text-blue-500'
: 'sm:hover:bg-base-300/75',
)}
style={{
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { debounce } from '@/utils/debounce';
@@ -54,6 +54,7 @@ interface IframeTouch {
}
interface IframeTouchEvent {
timeStamp: number;
targetTouches: IframeTouch[];
}
@@ -65,22 +66,27 @@ export const useTouchEvent = (
const { getBookData } = useBookDataStore();
const { hoveredBookKey, setHoveredBookKey, getViewSettings } = useReaderStore();
let touchStart: IframeTouch | null = null;
let touchEnd: IframeTouch | null = null;
const touchStartRef = useRef<IframeTouch | null>(null);
const touchEndRef = useRef<IframeTouch | null>(null);
const touchStartTimeRef = useRef<number | null>(null);
const touchEndTimeRef = useRef<number | null>(null);
const onTouchStart = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
touchEnd = null;
const touch = e.targetTouches[0];
if (!touch) return;
touchStart = touch;
touchStartRef.current = touch;
touchStartTimeRef.current = 'timeStamp' in e ? e.timeStamp : Date.now();
};
const onTouchMove = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
if (!touchStart) return;
if (!touchStartRef.current) return;
const touch = e.targetTouches[0];
if (touch) {
touchEnd = touch;
touchEndRef.current = touch;
touchEndTimeRef.current = 'timeStamp' in e ? e.timeStamp : Date.now();
}
const { current: touchStart } = touchStartRef;
const { current: touchEnd } = touchEndRef;
if (hoveredBookKey && touchEnd) {
const viewSettings = getViewSettings(bookKey)!;
const deltaY = touchEnd.screenY - touchStart.screenY;
@@ -96,19 +102,25 @@ export const useTouchEvent = (
};
const onTouchEnd = (e: IframeTouchEvent | React.TouchEvent<HTMLDivElement>) => {
if (!touchStart) return;
if (!touchStartRef.current) return;
const touch = e.targetTouches[0];
if (touch) {
touchEnd = touch;
touchEndRef.current = touch;
touchEndTimeRef.current = 'timeStamp' in e ? e.timeStamp : Date.now();
}
const windowWidth = window.innerWidth;
const { current: touchStart } = touchStartRef;
const { current: touchEnd } = touchEndRef;
const { current: touchStartTime } = touchStartTimeRef;
const { current: touchEndTime } = touchEndTimeRef;
if (touchEnd) {
const viewSettings = getViewSettings(bookKey)!;
const bookData = getBookData(bookKey)!;
const deltaY = touchEnd.screenY - touchStart.screenY;
const deltaX = touchEnd.screenX - touchStart.screenX;
const deltaT = touchEndTime && touchStartTime ? touchEndTime - touchStartTime : 0;
// also check for deltaX to prevent swipe page turn from triggering the toggle
if (
deltaY < -10 &&
@@ -133,6 +145,7 @@ export const useTouchEvent = (
detail: {
deltaX,
deltaY,
deltaT,
startX: touchStart.screenX,
startY: touchStart.screenY,
endX: touchEnd.screenX,
@@ -143,8 +156,8 @@ export const useTouchEvent = (
handleContinuousScroll('touch', deltaY, 30);
}
touchStart = null;
touchEnd = null;
touchStartRef.current = null;
touchEndRef.current = null;
};
const handleTouch = (msg: MessageEvent) => {
@@ -213,13 +213,16 @@ export const useKOSync = (bookKey: string) => {
setSyncState('checking');
const remoteProgress = await kosyncClient.getProgress(book);
if (!remoteProgress || !remoteProgress.progress || !remoteProgress.timestamp) {
if (!remoteProgress || !remoteProgress.progress) {
setSyncState('synced');
return;
}
const localTimestamp = bookData?.config?.updatedAt || book.updatedAt;
const remoteIsNewer = remoteProgress.timestamp * 1000 > localTimestamp;
const remoteTimestamp = remoteProgress.timestamp
? remoteProgress.timestamp * 1000
: Date.now();
const remoteIsNewer = remoteTimestamp > localTimestamp;
if (strategy === 'receive' || (strategy === 'silent' && remoteIsNewer)) {
applyRemoteProgress(book, bookDoc, remoteProgress);
setSyncState('synced');
@@ -174,8 +174,9 @@ export const usePagination = (
bookData.isFixedLayout &&
viewSettings!.zoomLevel <= 100
) {
const { deltaX, deltaY } = msg.detail;
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 30) {
const { deltaX, deltaY, deltaT } = msg.detail;
const vx = Math.abs(deltaX / deltaT);
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 30 && vx > 0.2) {
if (deltaX > 0) {
viewPagination(viewRef.current, viewSettings, 'left');
} else {
@@ -128,9 +128,11 @@ export const useTextSelector = (
isTouchStarted.current = false;
};
const handleScroll = () => {
// Prevent the container from scrolling when text is selected in paginated mode
// FIXME: this is a workaround for issue #873
// TODO: support text selection across pages
// If a popup (annotation/dictionary/translation) is open, don't dismiss it while scrolling.
if (isPopuped.current) {
return;
}
// Otherwise, behave as before: dismiss selection UI and keep container position if needed.
handleDismissPopup();
const viewSettings = getViewSettings(bookKey);
if (
@@ -126,7 +126,10 @@ export const handleClick = (
bookKey,
element,
footnote:
element.getAttribute('data-wr-footernote') || element.getAttribute('zy-footnote') || '',
element.getAttribute('data-wr-footernote') ||
element.getAttribute('zy-footnote') ||
element.getAttribute('alt') ||
'',
});
return;
}
@@ -178,6 +181,7 @@ const handleTouchEv = (bookKey: string, event: TouchEvent, type: string) => {
{
type: type,
bookKey,
timeStamp: Date.now(),
targetTouches: touches,
},
'*',
@@ -49,6 +49,7 @@ interface AccountActionsProps {
userPlan: UserPlan;
onLogout: () => void;
onResetPassword: () => void;
onUpdateEmail: () => void;
onConfirmDelete: () => void;
onRestorePurchase?: () => void;
onManageSubscription?: () => void;
@@ -58,6 +59,7 @@ const AccountActions: React.FC<AccountActionsProps> = ({
userPlan,
onLogout,
onResetPassword,
onUpdateEmail,
onConfirmDelete,
onRestorePurchase,
onManageSubscription,
@@ -84,7 +86,7 @@ const AccountActions: React.FC<AccountActionsProps> = ({
setShowConfirmDelete(false);
}}
/>
<div className='flex flex-col gap-4 md:flex-row md:justify-center'>
<div className='flex flex-col gap-4 md:grid md:grid-cols-2 lg:grid-cols-3'>
{appService?.hasIAP ? (
<button
onClick={onRestorePurchase}
@@ -108,6 +110,12 @@ const AccountActions: React.FC<AccountActionsProps> = ({
>
{_('Reset Password')}
</button>
<button
onClick={onUpdateEmail}
className='w-full rounded-lg bg-gray-200 px-6 py-3 font-medium text-gray-800 transition-colors hover:bg-gray-300 md:w-auto'
>
{_('Update Email')}
</button>
<button
onClick={onLogout}
className='w-full rounded-lg bg-gray-200 px-6 py-3 font-medium text-gray-800 transition-colors hover:bg-gray-300 md:w-auto'
+3 -1
View File
@@ -68,7 +68,8 @@ const ProfilePage = () => {
useTheme({ systemUIVisible: false });
const { quotas, userProfilePlan = 'free' } = useQuotaStats();
const { handleLogout, handleResetPassword, handleConfirmDelete } = useUserActions();
const { handleLogout, handleResetPassword, handleUpdateEmail, handleConfirmDelete } =
useUserActions();
const { availablePlans } = useAvailablePlans({
hasIAP: appService?.hasIAP || false,
@@ -277,6 +278,7 @@ const ProfilePage = () => {
userPlan={userProfilePlan}
onLogout={handleLogout}
onResetPassword={handleResetPassword}
onUpdateEmail={handleUpdateEmail}
onConfirmDelete={handleDeleteWithMessage}
onRestorePurchase={handleIAPRestorePurchase}
onManageSubscription={handleManageSubscription}
+5 -1
View File
@@ -83,7 +83,11 @@ const Popup = ({
<div
id='popup-container'
ref={containerRef}
className={clsx('bg-base-300 absolute rounded-lg font-sans shadow-xl', className)}
className={clsx(
'bg-base-300 absolute rounded-lg font-sans',
trianglePosition?.dir !== 'up' && 'shadow-xl',
className,
)}
style={{
width: `${width}px`,
height: height ? `${height}px` : 'auto',
@@ -1,5 +1,6 @@
'use client';
import '@/utils/polyfill';
import i18n from '@/i18n/i18n';
import { useEffect } from 'react';
import { IconContext } from 'react-icons';
@@ -59,8 +60,8 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
setScreenBrightness(brightness / 100);
}
applyBackgroundTexture(envConfig, globalViewSettings);
if (appService.isAndroidApp) {
applyEinkMode(globalViewSettings.isEink);
if (globalViewSettings.isEink) {
applyEinkMode(true);
}
});
}
+1 -1
View File
@@ -71,7 +71,7 @@ export const Toast = () => {
'max-h-[50vh] min-w-32 overflow-y-auto',
'text-center font-sans text-base font-normal sm:text-sm',
toastType === 'info'
? 'max-w-[80vw]'
? 'max-w-[80vw] overflow-x-hidden'
: 'min-w-[60vw] max-w-[80vw] whitespace-normal break-words sm:min-w-40 sm:max-w-80',
messageClass,
)}
@@ -151,9 +151,10 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
const selectedFile = result.files[0]!;
if (selectedFile.path && appService) {
const filePath = selectedFile.path;
const imageUrl = await appService.getCachedImageUrl(filePath);
onFieldChange('coverImageFile', filePath);
onFieldChange('coverImageUrl', await appService.getCachedImageUrl(filePath));
setNewCoverImageUrl(filePath);
onFieldChange('coverImageUrl', imageUrl);
setNewCoverImageUrl(imageUrl);
} else if (selectedFile.file) {
const coverImageBlobUrl = URL.createObjectURL(selectedFile.file);
onFieldChange('coverImageBlobUrl', coverImageBlobUrl);
@@ -164,7 +164,7 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
<div>
<span className='font-bold'>{_('Description')}</span>
<p
className='text-neutral-content prose prose-sm text-sm'
className='text-neutral-content prose prose-sm max-w-full text-sm'
dangerouslySetInnerHTML={{
__html: metadata.description || _('No description available'),
}}
@@ -57,24 +57,28 @@ export const useMetadataEdit = (metadata: BookMetadata | null) => {
return;
}
const newMeta = { ...editedMeta } as { [key: string]: unknown };
switch (field) {
case 'subject':
newMeta['subject'] = value ? value.split(/,|;||、/).map((s) => s.trim()) : [];
break;
default:
newMeta[field] = value;
}
setEditedMeta((prevMeta) => {
const newMeta = { ...prevMeta } as { [key: string]: unknown };
switch (field) {
case 'subject':
newMeta['subject'] = value ? value.split(/,|;||、/).map((s) => s.trim()) : [];
break;
default:
newMeta[field] = value;
}
return newMeta as BookMetadata;
});
setEditedMeta(newMeta as BookMetadata);
if (value !== undefined) {
handleFieldValidation(field, value);
}
if (fieldSources[field]) {
const newSources = { ...fieldSources };
delete newSources[field];
setFieldSources(newSources);
setFieldSources((prevSources) => {
const newSources = { ...prevSources };
delete newSources[field];
return newSources;
});
}
};
@@ -269,12 +269,12 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
</div>
</div>
{appService?.isMobileApp && (
{(appService?.isMobileApp || appService?.appPlatform === 'web') && (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Device')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
{appService?.isAndroidApp && (
{(appService?.isAndroidApp || appService?.appPlatform === 'web') && (
<div className='config-item'>
<span className=''>{_('E-Ink Mode')}</span>
<input
@@ -285,15 +285,17 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
/>
</div>
)}
<div className='config-item'>
<span className=''>{_('Auto Screen Brightness')}</span>
<input
type='checkbox'
className='toggle'
checked={autoScreenBrightness}
onChange={() => setAutoScreenBrightness(!autoScreenBrightness)}
/>
</div>
{appService?.isMobileApp && (
<div className='config-item'>
<span className=''>{_('Auto Screen Brightness')}</span>
<input
type='checkbox'
className='toggle'
checked={autoScreenBrightness}
onChange={() => setAutoScreenBrightness(!autoScreenBrightness)}
/>
</div>
)}
</div>
</div>
</div>
@@ -3,7 +3,6 @@ import React, { useEffect, useState } from 'react';
import { MdSettings } from 'react-icons/md';
import {
ANDROID_FONTS,
CJK_EXCLUDE_PATTENS,
CJK_FONTS_PATTENS,
CJK_SANS_SERIF_FONTS,
@@ -130,7 +129,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
defaultSysFonts = IOS_FONTS;
break;
case 'android':
defaultSysFonts = ANDROID_FONTS;
defaultSysFonts = [];
break;
default:
break;
@@ -204,7 +203,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
}, [viewSettings.serifFont, viewSettings.sansSerifFont, viewSettings.monospaceFont]);
useEffect(() => {
if (isTauriAppPlatform()) {
if (isTauriAppPlatform() && appService && !appService.isAndroidApp) {
getSysFontsList().then((res) => {
if (res.error || Object.keys(res.fonts).length === 0) {
console.error('Failed to get system fonts list:', res.error);
@@ -227,7 +226,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
setSysFonts([...new Set(processedFonts)].sort((a, b) => a.localeCompare(b)));
});
}
}, []);
}, [appService]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'defaultFont', defaultFont);
@@ -582,19 +582,19 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
label={viewSettings.vertical ? _('Maximum Column Height') : _('Maximum Column Width')}
value={maxInlineSize}
onChange={setMaxInlineSize}
disabled={maxColumnCount === 1 || viewSettings.scrolled}
min={400}
disabled={false}
min={200}
max={9999}
step={100}
step={50}
/>
<NumberInput
label={viewSettings.vertical ? _('Maximum Column Width') : _('Maximum Column Height')}
value={maxBlockSize}
onChange={setMaxBlockSize}
disabled={maxColumnCount === 1 || viewSettings.scrolled}
disabled={false}
min={400}
max={9999}
step={100}
step={50}
/>
</div>
</div>
@@ -7,6 +7,10 @@ const DEFAULT_SHORTCUTS = {
onToggleSelectMode: ['shift+s'],
onToggleBookmark: ['ctrl+d', 'cmd+d'],
onToggleTTS: ['t'],
// Selection actions (lowercase key without modifiers when selection active)
onTranslateSelection: ['ctrl+z', 'ctrl+shift+t'],
onDictionarySelection: ['ctrl+x', 'ctrl+shift+d'],
onWikipediaSelection: ['ctrl+w', 'ctrl+shift+w'],
onOpenFontLayoutSettings: ['shift+f', 'ctrl+,', 'cmd+,'],
onOpenBooks: ['ctrl+o', 'cmd+o'],
onReloadPage: ['shift+r'],
+12 -3
View File
@@ -4,7 +4,7 @@ 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 { getCurrentWindow, getAllWindows } from '@tauri-apps/api/window';
import { isTauriAppPlatform } from '@/services/environment';
import { navigateToLibrary, showLibraryWindow } from '@/utils/nav';
@@ -19,7 +19,14 @@ export function useOpenWithBooks() {
const { setCheckOpenWithBooks } = useLibraryStore();
const listenedOpenWithBooks = useRef(false);
const handleOpenWithFileUrl = (url: string) => {
const isFirstWindow = async () => {
const allWindows = await getAllWindows();
const currentWindow = getCurrentWindow();
const sortedWindows = allWindows.sort((a, b) => a.label.localeCompare(b.label));
return sortedWindows[0]?.label === currentWindow.label;
};
const handleOpenWithFileUrl = async (url: string) => {
console.log('Handle Open with URL:', url);
let filePath = url;
if (filePath.startsWith('file://')) {
@@ -28,7 +35,9 @@ export function useOpenWithBooks() {
if (!/^(https?:|data:|blob:)/i.test(filePath)) {
const settings = useSettingsStore.getState().settings;
if (appService?.hasWindow && settings.openBookInNewWindow) {
showLibraryWindow(appService, [filePath]);
if (await isFirstWindow()) {
showLibraryWindow(appService, [filePath]);
}
} else {
window.OPEN_WITH_FILES = [filePath];
setCheckOpenWithBooks(true);
@@ -65,7 +65,7 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
const headerbar = document.querySelector('.titlebar');
const pullIndicator = document.createElement('div');
const headerBottom = headerbar?.getBoundingClientRect().bottom || 0;
pullIndicator.style.top = `${headerBottom + 20}px`;
pullIndicator.style.top = `${headerBottom + 30}px`;
pullIndicator.className = 'pull-indicator text-gray-500';
pullIndicator.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
+1 -1
View File
@@ -21,7 +21,7 @@ export const useQuotaStats = (briefName = false) => {
percentage: Math.round((storagPlan.usage / storagPlan.quota) * 100),
}),
used: parseFloat((storagPlan.usage / 1024 / 1024 / (inGB ? 1024 : 1)).toFixed(2)),
total: Math.round(storagPlan.quota / 1024 / 1024 / (inGB ? 1024 : 1)),
total: Math.round((storagPlan.quota / 1024 / 1024 / (inGB ? 1024 : 1)) * 10) / 10,
unit: inGB ? 'GB' : 'MB',
};
const translationPlan = getTranslationPlanData(token);
+3 -2
View File
@@ -108,8 +108,9 @@ export function useSync(bookKey?: string) {
const result = await syncClient.pullChanges(since, type, bookId, metaHash);
setSyncResult({ ...syncResult, [type]: result[type] });
const records = result[type];
if (!records?.length) return;
const maxTime = computeMaxTimestamp(records);
if (since > 0 && !records?.length) return;
// For since = 0, we set lastSyncedAt to now if no records returned
const maxTime = records?.length ? computeMaxTimestamp(records) : Date.now();
setLastSyncedAt(maxTime);
// due to closures in React hooks the settings might be stale
+1 -1
View File
@@ -46,7 +46,7 @@ export const useTheme = ({
const handleSystemUIVisibility = useCallback(() => {
if (!appService?.isMobileApp) return;
const visible = systemUIVisible && !systemUIAlwaysHidden;
const visible = !!(systemUIVisible && !systemUIAlwaysHidden);
if (visible) {
showSystemUI();
} else {
+6 -1
View File
@@ -4,7 +4,7 @@ import { useAuth } from '@/context/AuthContext';
import { deleteUser } from '@/libs/user';
import { eventDispatcher } from '@/utils/event';
import { saveSysSettings } from '@/helpers/settings';
import { navigateToLibrary, navigateToResetPassword } from '@/utils/nav';
import { navigateToLibrary, navigateToResetPassword, navigateToUpdatePassword } from '@/utils/nav';
export const useUserActions = () => {
const router = useRouter();
@@ -21,6 +21,10 @@ export const useUserActions = () => {
navigateToResetPassword(router);
};
const handleUpdateEmail = () => {
navigateToUpdatePassword(router);
};
const handleConfirmDelete = async (errorMessage: string) => {
try {
await deleteUser();
@@ -36,6 +40,7 @@ export const useUserActions = () => {
return {
handleLogout,
handleUpdateEmail,
handleResetPassword,
handleConfirmDelete,
};
-30
View File
@@ -2,36 +2,6 @@ import { BookFormat } from '@/types/book';
import { Contributor, Identifier, LanguageMap } from '@/utils/book';
import * as epubcfi from 'foliate-js/epubcfi.js';
// A groupBy polyfill for foliate-js
Object.groupBy ??= (iterable, callbackfn) => {
const obj = Object.create(null);
let i = 0;
for (const value of iterable) {
const key = callbackfn(value, i++);
if (key in obj) {
obj[key].push(value);
} else {
obj[key] = [value];
}
}
return obj;
};
Map.groupBy ??= (iterable, callbackfn) => {
const map = new Map();
let i = 0;
for (const value of iterable) {
const key = callbackfn(value, i++),
list = map.get(key);
if (list) {
list.push(value);
} else {
map.set(key, [value]);
}
}
return map;
};
export const CFI = epubcfi;
export type DocumentFile = File;
+1 -1
View File
@@ -408,7 +408,7 @@ export abstract class BaseAppService implements AppService {
await f.close();
}
return book;
return existingBook || book;
} catch (error) {
throw error;
}
+1 -1
View File
@@ -195,7 +195,7 @@ export const DEFAULT_CJK_VIEW_SETTINGS: Partial<ViewSettings> = {
};
export const DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS: Partial<ViewSettings> = {
overrideColor: true,
overrideColor: false,
};
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
@@ -45,7 +45,7 @@ export class KOSyncClient {
headers.set('X-Auth-Key', this.config.userkey);
}
if (this.isLanServer) {
if (this.isLanServer || isTauriAppPlatform()) {
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const directUrl = `${this.config.serverUrl}${endpoint}`;
@@ -4,11 +4,13 @@ import { languageTransformer } from './language';
import { punctuationTransformer } from './punctuation';
import { whitespaceTransformer } from './whitespace';
import { sanitizerTransformer } from './sanitizer';
import { styleTransformer } from './style';
export const availableTransformers: Transformer[] = [
punctuationTransformer,
footnoteTransformer,
languageTransformer,
styleTransformer,
whitespaceTransformer,
sanitizerTransformer,
// Add more transformers here
@@ -17,8 +17,17 @@ export const sanitizerTransformer: Transformer = {
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
ADD_TAGS: ['link', 'meta'],
ADD_ATTR: (attributeName: string) => {
const attrWhitelist = [
'xmlns',
'http-equiv',
'content',
'charset',
'link',
'vlink',
'imt-state', // custom attribute generated by some translators
];
return (
['xmlns', 'http-equiv', 'content', 'charset', 'link', 'vlink'].includes(attributeName) ||
attrWhitelist.includes(attributeName) ||
attributeName.startsWith('xml:') ||
attributeName.startsWith('xmlns:') ||
attributeName.startsWith('epub:')
@@ -0,0 +1,23 @@
import { transformStylesheet } from '@/utils/style';
import type { Transformer } from './types';
export const styleTransformer: Transformer = {
name: 'style',
transform: async (ctx) => {
let result = ctx.content;
const styleMatches = [...result.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)];
for (const match of styleMatches) {
const [full, css] = match;
const transformed = await transformStylesheet(
ctx.width || window.innerWidth,
ctx.height || window.innerHeight,
css!,
);
result = result.replace(full, `<style>${transformed}</style>`);
}
return result;
},
};

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