forked from akai/readest
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efd9ad12a9 | |||
| 93a71a0fd0 | |||
| d373d7ac28 | |||
| e0e991b599 | |||
| 6d5b16ea8b | |||
| bfac67d3f0 | |||
| 0fd316d775 | |||
| c42fcf9ac4 | |||
| 04ade02a06 | |||
| 8ee53d3367 | |||
| 42111945b8 | |||
| 6fde157047 | |||
| 65ec5c9842 | |||
| a6444b5b33 | |||
| c7238cb04c | |||
| 366e5fafad | |||
| fb5710c134 | |||
| c4d9652335 | |||
| df6027cf9f | |||
| e3f7abf7f1 | |||
| c539917d7e | |||
| 5eb870c978 | |||
| b075c46ba3 | |||
| 4b6c7776f5 | |||
| 745a14195d | |||
| fb0f660eaf | |||
| 04a3030b79 | |||
| 5a3114de48 | |||
| b808fc5954 | |||
| 0ec4e37c13 | |||
| 9d301631ca | |||
| ec7145bb01 | |||
| f79d84b5fa | |||
| d00f1e0def | |||
| 637a813732 | |||
| 4f33c9280b | |||
| d54c752637 | |||
| 984d5d198d | |||
| 2568778a87 | |||
| 0d805a64f6 | |||
| 066d1c5b1e | |||
| cdc1950c3a | |||
| 7142874513 | |||
| 409c2f62be | |||
| 82922c5053 | |||
| 9c5bcb13e2 | |||
| 1d87dcdb7f | |||
| c7ba44a91e | |||
| 19b79c5b35 | |||
| 45216763e3 | |||
| 123293dc0e | |||
| 78e61060d2 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.90",
|
||||
"version": "0.9.92",
|
||||
"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",
|
||||
|
||||
@@ -651,5 +651,26 @@
|
||||
"Purchase Successful!": "تمت عملية الشراء بنجاح!",
|
||||
"lifetime": "مدى الحياة",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "شكرًا لشرائك! تم معالجة الدفع بنجاح.",
|
||||
"Order ID:": "معرف الطلب:"
|
||||
"Order ID:": "معرف الطلب:",
|
||||
"TTS Highlighting": "تمييز تحويل النص إلى كلام",
|
||||
"Style": "النمط",
|
||||
"Underline": "تسطير",
|
||||
"Strikethrough": "يتوسطه خط",
|
||||
"Squiggly": "متعرج",
|
||||
"Outline": "مخطط",
|
||||
"Save Current Color": "حفظ اللون الحالي",
|
||||
"Quick Colors": "ألوان سريعة",
|
||||
"Highlighter": "محدد النص",
|
||||
"Save 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": "الكل"
|
||||
}
|
||||
|
||||
@@ -635,5 +635,26 @@
|
||||
"Purchase Successful!": "ক্রয় সফল!",
|
||||
"lifetime": "জীবনকাল",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "আপনার ক্রয়ের জন্য ধন্যবাদ! আপনার পেমেন্ট সফলভাবে প্রক্রিয়া করা হয়েছে।",
|
||||
"Order ID:": "অর্ডার আইডি:"
|
||||
"Order ID:": "অর্ডার আইডি:",
|
||||
"TTS Highlighting": "টিটিএস হাইলাইটিং",
|
||||
"Style": "শৈলী",
|
||||
"Underline": "আন্ডারলাইন",
|
||||
"Strikethrough": "কাটা লেখা",
|
||||
"Squiggly": "ঢেউখেলানো দাগ",
|
||||
"Outline": "বাহিরের রেখা",
|
||||
"Save Current Color": "বর্তমান রঙ সংরক্ষণ করুন",
|
||||
"Quick Colors": "দ্রুত রংসমূহ",
|
||||
"Highlighter": "হাইলাইটার",
|
||||
"Save 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": "সব"
|
||||
}
|
||||
|
||||
@@ -631,5 +631,26 @@
|
||||
"Purchase Successful!": "ক্রয় সফল!",
|
||||
"lifetime": "জীবনকাল",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "আপনার ক্রয়ের জন্য ধন্যবাদ! আপনার পেমেন্ট সফলভাবে প্রক্রিয়াকৃত হয়েছে।",
|
||||
"Order ID:": "অর্ডার আইডি:"
|
||||
"Order ID:": "অর্ডার আইডি:",
|
||||
"TTS Highlighting": "ཡིག་འབྲུ་སྦྱོར་བའི་གསལ་བཤད།",
|
||||
"Style": "བཟོ་ལྟ།",
|
||||
"Underline": "འོག་ཐིག",
|
||||
"Strikethrough": "འཐེན་ཐིག",
|
||||
"Squiggly": "འཁྱོག་ཐིག",
|
||||
"Outline": "མཐའ་ཐིག",
|
||||
"Save Current Color": "མིག་སྔའི་ཚོན་གཏན་འཁེལ་བྱེད།",
|
||||
"Quick Colors": "མྱུར་ཚོན།",
|
||||
"Highlighter": "མདོག་ཚད་ཀྱི་བསྡུར་བ།",
|
||||
"Save 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": "ཆེན་མོ།"
|
||||
}
|
||||
|
||||
@@ -635,5 +635,26 @@
|
||||
"Purchase Successful!": "Kauf erfolgreich!",
|
||||
"lifetime": "lebenslang",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Danke für Ihren Kauf! Ihre Zahlung wurde erfolgreich verarbeitet.",
|
||||
"Order ID:": "Bestell-ID:"
|
||||
"Order ID:": "Bestell-ID:",
|
||||
"TTS Highlighting": "TTS-Hervorhebung",
|
||||
"Style": "Stil",
|
||||
"Underline": "Unterstreichen",
|
||||
"Strikethrough": "Durchgestrichen",
|
||||
"Squiggly": "Wellig",
|
||||
"Outline": "Umriss",
|
||||
"Save Current Color": "Aktuelle Farbe speichern",
|
||||
"Quick Colors": "Schnellfarben",
|
||||
"Highlighter": "Textmarker",
|
||||
"Save Book Cover": "Buchcover 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"
|
||||
}
|
||||
|
||||
@@ -635,5 +635,26 @@
|
||||
"Purchase Successful!": "Η αγορά ήταν επιτυχής!",
|
||||
"lifetime": "διαρκής",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Σας ευχαριστούμε για την αγορά σας! Η πληρωμή σας έχει επεξεργαστεί με επιτυχία.",
|
||||
"Order ID:": "Αριθμός παραγγελίας:"
|
||||
"Order ID:": "Αριθμός παραγγελίας:",
|
||||
"TTS Highlighting": "Επισήμανση TTS",
|
||||
"Style": "Στυλ",
|
||||
"Underline": "Υπογράμμιση",
|
||||
"Strikethrough": "Διαγραμμένο",
|
||||
"Squiggly": "Κυματιστό",
|
||||
"Outline": "Περίγραμμα",
|
||||
"Save Current Color": "Αποθήκευση τρέχουσας χρώματος",
|
||||
"Quick Colors": "Γρήγορα χρώματα",
|
||||
"Highlighter": "Υπογραμμιστής",
|
||||
"Save 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": "Όλα"
|
||||
}
|
||||
|
||||
@@ -639,5 +639,26 @@
|
||||
"Purchase Successful!": "¡Compra Exitosa!",
|
||||
"lifetime": "de por vida",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "¡Gracias por tu compra! Tu pago se ha procesado correctamente.",
|
||||
"Order ID:": "ID de pedido:"
|
||||
"Order ID:": "ID de pedido:",
|
||||
"TTS Highlighting": "TTS Resaltado",
|
||||
"Style": "Estilo",
|
||||
"Underline": "Subrayado",
|
||||
"Strikethrough": "Tachado",
|
||||
"Squiggly": "Ondulado",
|
||||
"Outline": "Contorno",
|
||||
"Save Current Color": "Guardar Color Actual",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -635,5 +635,26 @@
|
||||
"Purchase Successful!": "خرید با موفقیت انجام شد!",
|
||||
"lifetime": "مادامالعمر",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "از خرید شما متشکریم! پرداخت شما با موفقیت انجام شد.",
|
||||
"Order ID:": "شناسه سفارش:"
|
||||
"Order ID:": "شناسه سفارش:",
|
||||
"TTS Highlighting": "هایلایت TTS",
|
||||
"Style": "سبک",
|
||||
"Underline": "خط زیرین",
|
||||
"Strikethrough": "خطخورده",
|
||||
"Squiggly": "خطموجدار",
|
||||
"Outline": "خطکشی",
|
||||
"Save Current Color": "ذخیره رنگ فعلی",
|
||||
"Quick Colors": "رنگهای سریع",
|
||||
"Highlighter": "ماژیک هایلایت",
|
||||
"Save 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": "همه"
|
||||
}
|
||||
|
||||
@@ -639,5 +639,26 @@
|
||||
"Purchase Successful!": "Achat Réussi!",
|
||||
"lifetime": "à vie",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Merci pour votre achat ! Votre paiement a été traité avec succès.",
|
||||
"Order ID:": "ID de commande :"
|
||||
"Order ID:": "ID de commande :",
|
||||
"TTS Highlighting": "Surbrillance TTS",
|
||||
"Style": "Style",
|
||||
"Underline": "Souligner",
|
||||
"Strikethrough": "Barré",
|
||||
"Squiggly": "Ondulé",
|
||||
"Outline": "Contour",
|
||||
"Save Current Color": "Enregistrer la Couleur Actuelle",
|
||||
"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",
|
||||
"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 l’e-mail",
|
||||
"New Email": "Nouvel e-mail",
|
||||
"Your new email": "Votre nouvel e-mail",
|
||||
"Updating email ...": "Mise à jour de l’e-mail ...",
|
||||
"Update email": "Mettre à jour l’e-mail",
|
||||
"Current email": "E-mail actuel",
|
||||
"Update Email": "Mettre à jour l’e-mail",
|
||||
"All": "Tous"
|
||||
}
|
||||
|
||||
@@ -635,5 +635,26 @@
|
||||
"Purchase Successful!": "खरीदारी सफल!",
|
||||
"lifetime": "जीवनकाल",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "आपकी खरीदारी के लिए धन्यवाद! आपका भुगतान सफलतापूर्वक संसाधित हो गया है।",
|
||||
"Order ID:": "ऑर्डर आईडी:"
|
||||
"Order ID:": "ऑर्डर आईडी:",
|
||||
"TTS Highlighting": "TTS हाइलाइटिंग",
|
||||
"Style": "शैली",
|
||||
"Underline": "रेखांकित",
|
||||
"Strikethrough": "रेखांकित करें",
|
||||
"Squiggly": "स्क्विगली",
|
||||
"Outline": "आउटलाइन",
|
||||
"Save Current Color": "वर्तमान रंग सहेजें",
|
||||
"Quick Colors": "त्वरित रंग",
|
||||
"Highlighter": "हाइलाइटर",
|
||||
"Save 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": "सभी"
|
||||
}
|
||||
|
||||
@@ -631,5 +631,26 @@
|
||||
"Purchase Successful!": "Pembelian Berhasil!",
|
||||
"lifetime": "seumur hidup",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Terima kasih atas pembelian Anda! Pembayaran Anda telah berhasil diproses.",
|
||||
"Order ID:": "ID Pesanan:"
|
||||
"Order ID:": "ID Pesanan:",
|
||||
"TTS Highlighting": "TTS Menyorot",
|
||||
"Style": "Gaya",
|
||||
"Underline": "Garisan Bawah",
|
||||
"Strikethrough": "Garis Tengah",
|
||||
"Squiggly": "Bergelombang",
|
||||
"Outline": "Garis Besar",
|
||||
"Save Current Color": "Simpan Warna Saat Ini",
|
||||
"Quick Colors": "Warna Cepat",
|
||||
"Highlighter": "Penyorot",
|
||||
"Save Book Cover": "Simpan Sampul Buku",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -639,5 +639,26 @@
|
||||
"Purchase Successful!": "Acquisto Riuscito!",
|
||||
"lifetime": "a vita",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Grazie per il tuo acquisto! Il pagamento è stato elaborato con successo.",
|
||||
"Order ID:": "ID Ordine:"
|
||||
"Order ID:": "ID Ordine:",
|
||||
"TTS Highlighting": "Evidenziazione TTS",
|
||||
"Style": "Stile",
|
||||
"Underline": "Sottolineato",
|
||||
"Strikethrough": "Barrato",
|
||||
"Squiggly": "Ondulato",
|
||||
"Outline": "Contorno",
|
||||
"Save Current Color": "Salva Colore Corrente",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -631,5 +631,26 @@
|
||||
"Purchase Successful!": "購入成功!",
|
||||
"lifetime": "ライフタイム",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "ご購入ありがとうございます!お支払いが正常に処理されました。",
|
||||
"Order ID:": "購入ID:"
|
||||
"Order ID:": "購入ID:",
|
||||
"TTS Highlighting": "TTSハイライト",
|
||||
"Style": "スタイル",
|
||||
"Underline": "下線",
|
||||
"Strikethrough": "取り消し線",
|
||||
"Squiggly": "波線",
|
||||
"Outline": "アウトライン",
|
||||
"Save Current Color": "現在の色を保存",
|
||||
"Quick Colors": "クイックカラー",
|
||||
"Highlighter": "蛍光ペン",
|
||||
"Save 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": "すべて"
|
||||
}
|
||||
|
||||
@@ -631,5 +631,26 @@
|
||||
"Purchase Successful!": "구매 성공!",
|
||||
"lifetime": "평생",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "구매해주셔서 감사합니다! 결제가 성공적으로 처리되었습니다.",
|
||||
"Order ID:": "주문 ID:"
|
||||
"Order ID:": "주문 ID:",
|
||||
"TTS Highlighting": "TTS 하이라이팅",
|
||||
"Style": "스타일",
|
||||
"Underline": "밑줄",
|
||||
"Strikethrough": "취소선",
|
||||
"Squiggly": "물결선",
|
||||
"Outline": "윤곽선",
|
||||
"Save Current Color": "현재 색상 저장",
|
||||
"Quick Colors": "빠른 색상",
|
||||
"Highlighter": "형광펜",
|
||||
"Save 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": "전체"
|
||||
}
|
||||
|
||||
@@ -635,5 +635,26 @@
|
||||
"Purchase Successful!": "Aankoop Succesvol!",
|
||||
"lifetime": "levenslang",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Bedankt voor je aankoop! Je betaling is succesvol verwerkt.",
|
||||
"Order ID:": "Bestel-ID:"
|
||||
"Order ID:": "Bestel-ID:",
|
||||
"TTS Highlighting": "TTS-markering",
|
||||
"Style": "Stijl",
|
||||
"Underline": "Onderstrepen",
|
||||
"Strikethrough": "Doorhalen",
|
||||
"Squiggly": "Kronkelig",
|
||||
"Outline": "Omtrek",
|
||||
"Save Current Color": "Huidige kleur opslaan",
|
||||
"Quick Colors": "Snelle kleuren",
|
||||
"Highlighter": "Marker",
|
||||
"Save Book Cover": "Opslaan 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"
|
||||
}
|
||||
|
||||
@@ -643,5 +643,26 @@
|
||||
"Purchase Successful!": "Zakup Udany!",
|
||||
"lifetime": "dożywotnio",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Dziękujemy za zakup! Twoja płatność została pomyślnie przetworzona.",
|
||||
"Order ID:": "ID zamówienia:"
|
||||
"Order ID:": "ID zamówienia:",
|
||||
"TTS Highlighting": "Wyróżnianie TTS",
|
||||
"Style": "Styl",
|
||||
"Underline": "Podkreślenie",
|
||||
"Strikethrough": "Przekreślenie",
|
||||
"Squiggly": "Falista linia",
|
||||
"Outline": "Obrys",
|
||||
"Save Current Color": "Zapisz bieżący kolor",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -639,5 +639,26 @@
|
||||
"Purchase Successful!": "Compra Bem-Sucedida!",
|
||||
"lifetime": "vitalício",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Obrigado pela sua compra! Seu pagamento foi processado com sucesso.",
|
||||
"Order ID:": "ID do Pedido:"
|
||||
"Order ID:": "ID do Pedido:",
|
||||
"TTS Highlighting": "Destaque TTS",
|
||||
"Style": "Estilo",
|
||||
"Underline": "Sublinhado",
|
||||
"Strikethrough": "Tachado",
|
||||
"Squiggly": "Ondulado",
|
||||
"Outline": "Contorno",
|
||||
"Save Current Color": "Salvar cor atual",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -643,5 +643,26 @@
|
||||
"Purchase Successful!": "Покупка Успешна!",
|
||||
"lifetime": "пожизненный",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Спасибо за вашу покупку! Ваш платеж был успешно обработан.",
|
||||
"Order ID:": "ID заказа:"
|
||||
"Order ID:": "ID заказа:",
|
||||
"TTS Highlighting": "Выделение TTS",
|
||||
"Style": "Стиль",
|
||||
"Underline": "Подчёркивание",
|
||||
"Strikethrough": "Зачёркивание",
|
||||
"Squiggly": "Волнистая линия",
|
||||
"Outline": "Контур",
|
||||
"Save Current Color": "Сохранить текущий цвет",
|
||||
"Quick Colors": "Быстрые цвета",
|
||||
"Highlighter": "Маркер",
|
||||
"Save 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": "Все"
|
||||
}
|
||||
|
||||
@@ -635,5 +635,26 @@
|
||||
"Purchase Successful!": "මිලදී ගැනීම සාර්ථකයි!",
|
||||
"lifetime": "පොදුකාලීන",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "ඔබේ මිලදී ගැනීමට ස්තූතියි! ඔබේ ගෙවීම සාර්ථකව සැකසිණි.",
|
||||
"Order ID:": "ඇණවුම් හැඳුනුම්පත:"
|
||||
"Order ID:": "ඇණවුම් හැඳුනුම්පත:",
|
||||
"TTS Highlighting": "TTS උද්දීපනය",
|
||||
"Style": "රූපය",
|
||||
"Underline": "ඇටය",
|
||||
"Strikethrough": "ඇඳුම",
|
||||
"Squiggly": "වැලි රේඛාව",
|
||||
"Outline": "පරිසීමාව",
|
||||
"Save Current Color": "දැන් ඇති වර්ණය සුරකින්න",
|
||||
"Quick Colors": "ක්ෂණික වර්ණ",
|
||||
"Highlighter": "ඉස්මතු කරන්නා",
|
||||
"Save 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": "සියල්ල"
|
||||
}
|
||||
|
||||
@@ -635,5 +635,26 @@
|
||||
"Purchase Successful!": "Köp lyckades!",
|
||||
"lifetime": "livstid",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Tack för ditt köp! Din betalning har behandlats.",
|
||||
"Order ID:": "Order-ID:"
|
||||
"Order ID:": "Order-ID:",
|
||||
"TTS Highlighting": "TTS-markering",
|
||||
"Style": "Stil",
|
||||
"Underline": "Understrykning",
|
||||
"Strikethrough": "Genomstrykning",
|
||||
"Squiggly": "Vågig linje",
|
||||
"Outline": "Kontur",
|
||||
"Save Current Color": "Spara aktuell färg",
|
||||
"Quick Colors": "Snabba färger",
|
||||
"Highlighter": "Markeringspenna",
|
||||
"Save Book Cover": "Spara 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"
|
||||
}
|
||||
|
||||
@@ -635,5 +635,26 @@
|
||||
"Purchase Successful!": "கொள்முதல் வெற்றிகரமாக முடிந்தது!",
|
||||
"lifetime": "ஆயுள்திருத்தம்",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "உங்கள் வாங்குதலுக்கு நன்றி! உங்கள் கட்டணம் வெற்றிகரமாக செயல்படுத்தப்பட்டது.",
|
||||
"Order ID:": "ஆர்டர் ஐடி:"
|
||||
"Order ID:": "ஆர்டர் ஐடி:",
|
||||
"TTS Highlighting": "TTS சிறப்பித்தல்",
|
||||
"Style": "பாணி",
|
||||
"Underline": "அடிக்கோடு",
|
||||
"Strikethrough": "குறிக்கோடு",
|
||||
"Squiggly": "சுருள் கோடு",
|
||||
"Outline": "வட்டம்",
|
||||
"Save Current Color": "தற்போதைய நிறத்தை சேமிக்கவும்",
|
||||
"Quick Colors": "விரைவு நிறங்கள்",
|
||||
"Highlighter": "ஹைலைட்டர்",
|
||||
"Save 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": "அனைத்து"
|
||||
}
|
||||
|
||||
@@ -631,5 +631,26 @@
|
||||
"Purchase Successful!": "การซื้อสำเร็จ!",
|
||||
"lifetime": "ตลอดชีพ",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "ขอบคุณสำหรับการซื้อ! การชำระเงินของคุณได้รับการประมวลผลเรียบร้อยแล้ว",
|
||||
"Order ID:": "รหัสคำสั่งซื้อ:"
|
||||
"Order ID:": "รหัสคำสั่งซื้อ:",
|
||||
"TTS Highlighting": "การไฮไลต์ TTS",
|
||||
"Style": "สไตล์",
|
||||
"Underline": "ขีดเส้นใต้",
|
||||
"Strikethrough": "ขีดฆ่า",
|
||||
"Squiggly": "เส้นหยัก",
|
||||
"Outline": "เส้นขอบ",
|
||||
"Save Current Color": "บันทึกสีปัจจุบัน",
|
||||
"Quick Colors": "สีด่วน",
|
||||
"Highlighter": "ปากกาเน้นข้อความ",
|
||||
"Save 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": "ทั้งหมด"
|
||||
}
|
||||
|
||||
@@ -635,5 +635,26 @@
|
||||
"Purchase Successful!": "Satın Alma Başarılı!",
|
||||
"lifetime": "ömür boyu",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Satın alma işleminiz için teşekkürler! Ödemeniz başarıyla işlendi.",
|
||||
"Order ID:": "Sipariş Kimliği:"
|
||||
"Order ID:": "Sipariş Kimliği:",
|
||||
"TTS Highlighting": "TTS Vurgulama",
|
||||
"Style": "Stil",
|
||||
"Underline": "Altı Çizili",
|
||||
"Strikethrough": "Üstü Çizili",
|
||||
"Squiggly": "Dalgalı Çizgi",
|
||||
"Outline": "Kontur",
|
||||
"Save Current Color": "Mevcut Rengi Kaydet",
|
||||
"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",
|
||||
"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ü"
|
||||
}
|
||||
|
||||
@@ -643,5 +643,26 @@
|
||||
"Purchase Successful!": "Покупка успішна!",
|
||||
"lifetime": "довічний",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Дякуємо за вашу покупку! Ваш платіж успішно оброблено.",
|
||||
"Order ID:": "ID замовлення:"
|
||||
"Order ID:": "ID замовлення:",
|
||||
"TTS Highlighting": "Підсвічування TTS",
|
||||
"Style": "Стиль",
|
||||
"Underline": "Підкреслення",
|
||||
"Strikethrough": "Закреслення",
|
||||
"Squiggly": "Хвиляста лінія",
|
||||
"Outline": "Контур",
|
||||
"Save Current Color": "Зберегти поточний колір",
|
||||
"Quick Colors": "Швидкі кольори",
|
||||
"Highlighter": "Маркер",
|
||||
"Save 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": "Всі"
|
||||
}
|
||||
|
||||
@@ -631,5 +631,26 @@
|
||||
"Purchase Successful!": "Mua hàng thành công!",
|
||||
"lifetime": "trọn đời",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Cảm ơn bạn đã mua hàng! Thanh toán của bạn đã được xử lý thành công.",
|
||||
"Order ID:": "Mã đơn hàng:"
|
||||
"Order ID:": "Mã đơn hàng:",
|
||||
"TTS Highlighting": "Đánh dấu TTS",
|
||||
"Style": "Kiểu",
|
||||
"Underline": "Gạch chân",
|
||||
"Strikethrough": "Gạch ngang",
|
||||
"Squiggly": "Gạch ngoằn ngoèo",
|
||||
"Outline": "Đường viền",
|
||||
"Save Current Color": "Lưu màu hiện tại",
|
||||
"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",
|
||||
"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ả"
|
||||
}
|
||||
|
||||
@@ -631,5 +631,26 @@
|
||||
"Purchase Successful!": "购买成功!",
|
||||
"lifetime": "永久",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "感谢您的购买!您的付款已成功处理。",
|
||||
"Order ID:": "订单号:"
|
||||
"Order ID:": "订单号:",
|
||||
"TTS Highlighting": "TTS 高亮",
|
||||
"Style": "样式",
|
||||
"Underline": "下划线",
|
||||
"Strikethrough": "删除线",
|
||||
"Squiggly": "波浪线",
|
||||
"Outline": "轮廓",
|
||||
"Save Current Color": "保存当前颜色",
|
||||
"Quick Colors": "快速颜色",
|
||||
"Highlighter": "荧光笔",
|
||||
"Save 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": "全部"
|
||||
}
|
||||
|
||||
@@ -631,5 +631,26 @@
|
||||
"Purchase Successful!": "購買成功!",
|
||||
"lifetime": "永久",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "感謝您的購買!您的付款已成功處理。",
|
||||
"Order ID:": "訂單編號:"
|
||||
"Order ID:": "訂單編號:",
|
||||
"TTS Highlighting": "TTS 高亮",
|
||||
"Style": "樣式",
|
||||
"Underline": "底線",
|
||||
"Strikethrough": "刪除線",
|
||||
"Squiggly": "波浪線",
|
||||
"Outline": "輪廓",
|
||||
"Save Current Color": "儲存目前顏色",
|
||||
"Quick Colors": "快速顏色",
|
||||
"Highlighter": "螢光筆",
|
||||
"Save 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": "全部"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
{
|
||||
"releases": {
|
||||
"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": [
|
||||
"TTS: Fixed an issue where system voices were not working on Android",
|
||||
"TTS: Added options to customize the highlight style during read-aloud",
|
||||
"E-Ink: Disabled all shadows to improve display clarity",
|
||||
"E-Ink: Added an option to save the last book cover on Android",
|
||||
"Android: Fixed a crash that occurred when toggling 'Allow Script'",
|
||||
"Android: Adjusted launcher icon size for better compatibility across devices"
|
||||
]
|
||||
},
|
||||
"0.9.90": {
|
||||
"date": "2025-10-28",
|
||||
"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",
|
||||
|
||||
@@ -57,9 +57,15 @@ android {
|
||||
}
|
||||
}
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("signing")
|
||||
}
|
||||
proguardFiles(
|
||||
*fileTree(".") { include("**/*.pro") }
|
||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
.toList().toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
@@ -75,13 +81,13 @@ rust {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.webkit:webkit:1.6.1")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("androidx.activity:activity-ktx:1.9.3")
|
||||
implementation("com.google.android.material:material:1.8.0")
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||
}
|
||||
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@mipmap/ic_launcher_foreground"
|
||||
android:inset="22%"/>
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
+35
-6
@@ -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,7 +18,10 @@ const COMMANDS: &[&str] = &[
|
||||
"get_screen_brightness",
|
||||
"set_screen_brightness",
|
||||
"get_external_sdcard_path",
|
||||
"open_external_url",
|
||||
"request_manage_storage_permission",
|
||||
"check_permissions",
|
||||
"request_permissions",
|
||||
"checkPermissions",
|
||||
"requestPermissions",
|
||||
];
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-check-permissions"
|
||||
description = "Enables the check-permissions command without any pre-configured scope."
|
||||
commands.allow = ["check-permissions"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-check-permissions"
|
||||
description = "Denies the check-permissions command without any pre-configured scope."
|
||||
commands.deny = ["check-permissions"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-check-permissions"
|
||||
description = "Enables the check_permissions command without any pre-configured scope."
|
||||
commands.allow = ["check_permissions"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-check-permissions"
|
||||
description = "Denies the check_permissions command without any pre-configured scope."
|
||||
commands.deny = ["check_permissions"]
|
||||
+13
@@ -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"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-request-permissions"
|
||||
description = "Enables the request-permissions command without any pre-configured scope."
|
||||
commands.allow = ["request-permissions"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-request-permissions"
|
||||
description = "Denies the request-permissions command without any pre-configured scope."
|
||||
commands.deny = ["request-permissions"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-request-permissions"
|
||||
description = "Enables the request_permissions command without any pre-configured scope."
|
||||
commands.allow = ["request_permissions"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-request-permissions"
|
||||
description = "Denies the request_permissions command without any pre-configured scope."
|
||||
commands.deny = ["request_permissions"]
|
||||
+133
@@ -23,7 +23,10 @@ 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`
|
||||
- `allow-checkPermissions`
|
||||
- `allow-requestPermissions`
|
||||
|
||||
@@ -91,6 +94,32 @@ Denies the auth_with_safari command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-check-permissions`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the check-permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-check-permissions`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the check-permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-checkPermissions`
|
||||
|
||||
</td>
|
||||
@@ -117,6 +146,32 @@ Denies the checkPermissions command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-check-permissions`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the check_permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-check-permissions`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the check_permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-copy-uri-to-path`
|
||||
|
||||
</td>
|
||||
@@ -481,6 +536,58 @@ 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>
|
||||
<td>
|
||||
|
||||
Enables the request-permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-request-permissions`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the request-permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-requestPermissions`
|
||||
|
||||
</td>
|
||||
@@ -533,6 +640,32 @@ Denies the request_manage_storage_permission command without any pre-configured
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-request-permissions`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the request_permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-request-permissions`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the request_permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-set-screen-brightness`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -20,7 +20,10 @@ 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",
|
||||
"allow-checkPermissions",
|
||||
"allow-requestPermissions",
|
||||
]
|
||||
|
||||
+62
-2
@@ -318,6 +318,18 @@
|
||||
"const": "deny-auth-with-safari",
|
||||
"markdownDescription": "Denies the auth_with_safari command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check-permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-check-permissions",
|
||||
"markdownDescription": "Enables the check-permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check-permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-check-permissions",
|
||||
"markdownDescription": "Denies the check-permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the checkPermissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -330,6 +342,18 @@
|
||||
"const": "deny-checkPermissions",
|
||||
"markdownDescription": "Denies the checkPermissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the copy_uri_to_path command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -498,6 +522,30 @@
|
||||
"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",
|
||||
"const": "allow-request-permissions",
|
||||
"markdownDescription": "Enables the request-permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request-permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-request-permissions",
|
||||
"markdownDescription": "Denies the request-permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the requestPermissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -522,6 +570,18 @@
|
||||
"const": "deny-request-manage-storage-permission",
|
||||
"markdownDescription": "Denies the request_manage_storage_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-request-permissions",
|
||||
"markdownDescription": "Enables the request_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-request-permissions",
|
||||
"markdownDescription": "Denies the request_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_screen_brightness command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -559,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-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-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>,
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ const COMMANDS: &[&str] = &[
|
||||
"set_media_session_active",
|
||||
"update_media_session_state",
|
||||
"update_media_session_metadata",
|
||||
"register_listener",
|
||||
"remove_listener",
|
||||
"check_permissions",
|
||||
"request_permissions",
|
||||
"checkPermissions",
|
||||
"requestPermissions",
|
||||
"registerListener",
|
||||
"remove_listener",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-check-permissions"
|
||||
description = "Enables the check_permissions command without any pre-configured scope."
|
||||
commands.allow = ["check_permissions"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-check-permissions"
|
||||
description = "Denies the check_permissions command without any pre-configured scope."
|
||||
commands.deny = ["check_permissions"]
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-registerListener"
|
||||
description = "Enables the registerListener command without any pre-configured scope."
|
||||
commands.allow = ["registerListener"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-registerListener"
|
||||
description = "Denies the registerListener command without any pre-configured scope."
|
||||
commands.deny = ["registerListener"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-register-listener"
|
||||
description = "Enables the register_listener command without any pre-configured scope."
|
||||
commands.allow = ["register_listener"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-register-listener"
|
||||
description = "Denies the register_listener command without any pre-configured scope."
|
||||
commands.deny = ["register_listener"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-request-permissions"
|
||||
description = "Enables the request_permissions command without any pre-configured scope."
|
||||
commands.allow = ["request_permissions"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-request-permissions"
|
||||
description = "Denies the request_permissions command without any pre-configured scope."
|
||||
commands.deny = ["request_permissions"]
|
||||
+60
-6
@@ -16,10 +16,12 @@ Default permissions for the plugin
|
||||
- `allow-set-media-session-active`
|
||||
- `allow-update-media-session-state`
|
||||
- `allow-update-media-session-metadata`
|
||||
- `allow-register-listener`
|
||||
- `allow-remove-listener`
|
||||
- `allow-check-permissions`
|
||||
- `allow-request-permissions`
|
||||
- `allow-checkPermissions`
|
||||
- `allow-requestPermissions`
|
||||
- `allow-registerListener`
|
||||
- `allow-remove-listener`
|
||||
|
||||
## Permission Table
|
||||
|
||||
@@ -59,6 +61,32 @@ Denies the checkPermissions command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-check-permissions`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the check_permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:deny-check-permissions`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the check_permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-get-all-voices`
|
||||
|
||||
</td>
|
||||
@@ -137,12 +165,12 @@ Denies the pause command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-registerListener`
|
||||
`native-tts:allow-register-listener`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the registerListener command without any pre-configured scope.
|
||||
Enables the register_listener command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -150,12 +178,12 @@ Enables the registerListener command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:deny-registerListener`
|
||||
`native-tts:deny-register-listener`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the registerListener command without any pre-configured scope.
|
||||
Denies the register_listener command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -215,6 +243,32 @@ Denies the requestPermissions command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-request-permissions`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the request_permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:deny-request-permissions`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the request_permissions command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-tts:allow-resume`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -13,8 +13,10 @@ permissions = [
|
||||
"allow-set-media-session-active",
|
||||
"allow-update-media-session-state",
|
||||
"allow-update-media-session-metadata",
|
||||
"allow-register-listener",
|
||||
"allow-remove-listener",
|
||||
"allow-check-permissions",
|
||||
"allow-request-permissions",
|
||||
"allow-checkPermissions",
|
||||
"allow-requestPermissions",
|
||||
"allow-registerListener",
|
||||
"allow-remove-listener",
|
||||
]
|
||||
|
||||
+32
-8
@@ -306,6 +306,18 @@
|
||||
"const": "deny-checkPermissions",
|
||||
"markdownDescription": "Denies the checkPermissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_all_voices command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -343,16 +355,16 @@
|
||||
"markdownDescription": "Denies the pause command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the registerListener command without any pre-configured scope.",
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-registerListener",
|
||||
"markdownDescription": "Enables the registerListener command without any pre-configured scope."
|
||||
"const": "allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the registerListener command without any pre-configured scope.",
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-registerListener",
|
||||
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
|
||||
"const": "deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_listener command without any pre-configured scope.",
|
||||
@@ -378,6 +390,18 @@
|
||||
"const": "deny-requestPermissions",
|
||||
"markdownDescription": "Denies the requestPermissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-request-permissions",
|
||||
"markdownDescription": "Enables the request_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-request-permissions",
|
||||
"markdownDescription": "Denies the request_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the resume command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -487,10 +511,10 @@
|
||||
"markdownDescription": "Denies the update_media_session_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-set-media-session-active`\n- `allow-update-media-session-state`\n- `allow-update-media-session-metadata`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-registerListener`\n- `allow-remove-listener`",
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-set-media-session-active`\n- `allow-update-media-session-state`\n- `allow-update-media-session-metadata`\n- `allow-register-listener`\n- `allow-remove-listener`\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-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-set-media-session-active`\n- `allow-update-media-session-state`\n- `allow-update-media-session-metadata`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-registerListener`\n- `allow-remove-listener`"
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-set-media-session-active`\n- `allow-update-media-session-state`\n- `allow-update-media-session-metadata`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -24,7 +24,7 @@ import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oau
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { handleAuthCallback } from '@/helpers/auth';
|
||||
import { getUserPlan } from '@/utils/access';
|
||||
import { getUserProfilePlan } from '@/utils/access';
|
||||
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
|
||||
import { authWithCustomTab, authWithSafari } from './utils/nativeAuth';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
@@ -173,7 +173,7 @@ export default function AuthPage() {
|
||||
const type = params.get('type');
|
||||
if (accessToken) {
|
||||
let next = params.get('next') ?? '/';
|
||||
if (getUserPlan(accessToken) === 'free') {
|
||||
if (getUserProfilePlan(accessToken) === 'free') {
|
||||
next = '/user';
|
||||
}
|
||||
handleAuthCallback({ accessToken, refreshToken, type, next, login, navigate: router.push });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -39,7 +39,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { userPlan, quotas } = useQuotaStats(true);
|
||||
const { userProfilePlan, quotas } = useQuotaStats(true);
|
||||
const { themeMode, setThemeMode } = useThemeStore();
|
||||
const { settings, setSettingsDialogOpen } = useSettingsStore();
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
@@ -53,6 +53,9 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
);
|
||||
const [isTelemetryEnabled, setIsTelemetryEnabled] = useState(settings.telemetryEnabled);
|
||||
const [alwaysInForeground, setAlwaysInForeground] = useState(settings.alwaysInForeground);
|
||||
const [savedBookCoverForLockScreen, setSavedBookCoverForLockScreen] = useState(
|
||||
settings.savedBookCoverForLockScreen || '',
|
||||
);
|
||||
const iconSize = useResponsiveSize(16);
|
||||
|
||||
const showAboutReadest = () => {
|
||||
@@ -169,6 +172,12 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setSettingsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSetSavedBookCoverForLockScreen = () => {
|
||||
const newValue = settings.savedBookCoverForLockScreen ? '' : 'default';
|
||||
saveSysSettings(envConfig, 'savedBookCoverForLockScreen', newValue);
|
||||
setSavedBookCoverForLockScreen(newValue);
|
||||
};
|
||||
|
||||
const toggleAlwaysInForeground = async () => {
|
||||
const requestAlwaysInForeground = !settings.alwaysInForeground;
|
||||
|
||||
@@ -299,13 +308,26 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
<hr aria-hidden='true' className='border-base-200 my-1' />
|
||||
<MenuItem label={_('Advanced Settings')}>
|
||||
<ul className='flex flex-col'>
|
||||
<MenuItem label={_('Change Data Location')} noIcon onClick={handleSetRootDir} />
|
||||
<MenuItem
|
||||
label={_('Change Data Location')}
|
||||
noIcon={!appService?.isAndroidApp}
|
||||
onClick={handleSetRootDir}
|
||||
/>
|
||||
{appService?.isAndroidApp && (
|
||||
<MenuItem
|
||||
label={_('Save Book Cover')}
|
||||
tooltip={_('Auto-save last book cover')}
|
||||
description={savedBookCoverForLockScreen ? '💾 Images/last-book-cover.png' : ''}
|
||||
toggled={!!savedBookCoverForLockScreen}
|
||||
onClick={handleSetSavedBookCoverForLockScreen}
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<hr aria-hidden='true' className='border-base-200 my-1' />
|
||||
{user && userPlan === 'free' && !appService?.isIOSApp && (
|
||||
{user && userProfilePlan === 'free' && (
|
||||
<MenuItem label={_('Upgrade to Readest Premium')} onClick={handleUpgrade} />
|
||||
)}
|
||||
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -95,8 +100,8 @@ export const useBooksSync = () => {
|
||||
}
|
||||
const mergedBook =
|
||||
matchingBook.updatedAt > oldBook.updatedAt
|
||||
? { ...oldBook, ...matchingBook }
|
||||
: { ...matchingBook, ...oldBook };
|
||||
? { ...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 };
|
||||
};
|
||||
@@ -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);
|
||||
@@ -155,64 +164,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 +186,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 +331,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 +354,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 +372,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 +578,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 +606,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 +633,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 +652,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 +726,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('/'),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -85,12 +85,14 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
const config = getConfig(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const viewState = getViewState(bookKey);
|
||||
const gridInsets = calcGridInsets(index, bookKeys.length);
|
||||
const { book, bookDoc } = bookData || {};
|
||||
if (!book || !config || !bookDoc || !viewSettings) return null;
|
||||
if (!book || !config || !bookDoc || !viewSettings || !viewState) return null;
|
||||
|
||||
const { section, pageinfo, timeinfo, sectionLabel } = progress || {};
|
||||
const isBookmarked = getViewState(bookKey)?.ribbonVisible;
|
||||
const isBookmarked = viewState.ribbonVisible;
|
||||
const viewerKey = viewState.viewerKey;
|
||||
const horizontalGapPercent = viewSettings.gapPercent;
|
||||
const viewInsets = getViewInsets(viewSettings);
|
||||
const contentInsets = {
|
||||
@@ -123,6 +125,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
gridInsets={gridInsets}
|
||||
/>
|
||||
<FoliateViewer
|
||||
key={viewerKey}
|
||||
bookKey={bookKey}
|
||||
bookDoc={bookDoc}
|
||||
config={config}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useKOSync } from '../hooks/useKOSync';
|
||||
import {
|
||||
applyFixedlayoutStyles,
|
||||
applyImageStyle,
|
||||
applyTableStyle,
|
||||
applyThemeModeClass,
|
||||
applyTranslationStyle,
|
||||
getStyles,
|
||||
@@ -50,6 +51,7 @@ import { TransformContext } from '@/services/transformers/types';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { useTextTranslation } from '../hooks/useTextTranslation';
|
||||
import { useBookCoverAutoSave } from '../hooks/useAutoSaveBookCover';
|
||||
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
|
||||
import { getViewInsets } from '@/utils/insets';
|
||||
import { removeTabIndex } from '@/utils/a11y';
|
||||
@@ -101,6 +103,7 @@ const FoliateViewer: React.FC<{
|
||||
useUICSS(bookKey);
|
||||
useProgressSync(bookKey);
|
||||
useProgressAutoSave(bookKey);
|
||||
useBookCoverAutoSave(bookKey);
|
||||
const { syncState, conflictDetails, resolveWithLocal, resolveWithRemote } = useKOSync(bookKey);
|
||||
useTextTranslation(bookKey, viewRef.current);
|
||||
|
||||
@@ -127,13 +130,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;
|
||||
@@ -179,6 +191,7 @@ const FoliateViewer: React.FC<{
|
||||
}
|
||||
|
||||
applyImageStyle(detail.doc);
|
||||
applyTableStyle(detail.doc);
|
||||
applyThemeModeClass(detail.doc, isDarkMode);
|
||||
keepTextAlignment(detail.doc);
|
||||
removeTabIndex(detail.doc);
|
||||
@@ -271,7 +284,6 @@ const FoliateViewer: React.FC<{
|
||||
await import('foliate-js/view.js');
|
||||
const view = wrappedFoliateView(document.createElement('foliate-view') as FoliateView);
|
||||
view.id = `foliate-view-${bookKey}`;
|
||||
document.body.append(view);
|
||||
containerRef.current?.appendChild(view);
|
||||
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -323,7 +323,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
setShowIndicator(true);
|
||||
const ttsController = new TTSController(appService, view);
|
||||
await ttsController.init();
|
||||
await ttsController.initViewTTS();
|
||||
await ttsController.initViewTTS(viewSettings.ttsHighlightOptions);
|
||||
const ssml = view.tts?.from(ttsFromRange);
|
||||
if (ssml) {
|
||||
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
|
||||
@@ -625,6 +625,13 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
};
|
||||
}, [hoveredBookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
|
||||
if (ttsControllerRef.current && ttsHighlightOptions) {
|
||||
ttsControllerRef.current.initViewTTS(ttsHighlightOptions);
|
||||
}
|
||||
}, [viewSettings?.ttsHighlightOptions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { getCoverFilename } from '@/utils/book';
|
||||
|
||||
export const useBookCoverAutoSave = (bookKey: string) => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const saveBookCover = useCallback(
|
||||
throttle(
|
||||
() => {
|
||||
setTimeout(async () => {
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
const bookData = useBookDataStore.getState().getBookData(bookKey);
|
||||
const book = bookData?.book;
|
||||
const savedBookHash = settings.savedBookCoverForLockScreen;
|
||||
if (appService && book && savedBookHash && savedBookHash !== book?.hash) {
|
||||
const coverPath = await appService.resolveFilePath(getCoverFilename(book), 'Books');
|
||||
try {
|
||||
await appService.copyFile(coverPath, 'last-book-cover.png', 'Images');
|
||||
settings.savedBookCoverForLockScreen = book.hash;
|
||||
useSettingsStore.getState().setSettings(settings);
|
||||
useSettingsStore.getState().saveSettings(envConfig, settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-save book cover for lock screen:', error);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
},
|
||||
5000,
|
||||
{ emitLast: false },
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
saveBookCover();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
@@ -67,8 +67,9 @@ const ProfilePage = () => {
|
||||
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
const { quotas, userPlan = 'free' } = useQuotaStats();
|
||||
const { handleLogout, handleResetPassword, handleConfirmDelete } = useUserActions();
|
||||
const { quotas, userProfilePlan = 'free' } = useQuotaStats();
|
||||
const { handleLogout, handleResetPassword, handleUpdateEmail, handleConfirmDelete } =
|
||||
useUserActions();
|
||||
|
||||
const { availablePlans } = useAvailablePlans({
|
||||
hasIAP: appService?.hasIAP || false,
|
||||
@@ -218,8 +219,8 @@ const ProfilePage = () => {
|
||||
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
|
||||
const userFullName = user?.user_metadata?.['full_name'] || '-';
|
||||
const userEmail = user?.email || '';
|
||||
const planDetails =
|
||||
getPlanDetails(userPlan, availablePlans) || getPlanDetails('free', availablePlans);
|
||||
const userPlanDetails =
|
||||
getPlanDetails(userProfilePlan, availablePlans) || getPlanDetails('free', availablePlans);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -258,7 +259,7 @@ const ProfilePage = () => {
|
||||
avatarUrl={avatarUrl}
|
||||
userFullName={userFullName}
|
||||
userEmail={userEmail}
|
||||
planDetails={planDetails}
|
||||
planDetails={userPlanDetails}
|
||||
/>
|
||||
|
||||
<UsageStats quotas={quotas} />
|
||||
@@ -267,16 +268,17 @@ const ProfilePage = () => {
|
||||
<div className='flex flex-col gap-y-8 sm:px-6'>
|
||||
<PlansComparison
|
||||
availablePlans={availablePlans}
|
||||
userPlan={userPlan}
|
||||
userPlan={userProfilePlan}
|
||||
onSubscribe={appService.hasIAP ? handleIAPSubscribe : handleStripeSubscribe}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-y-8 px-6'>
|
||||
<AccountActions
|
||||
userPlan={userPlan}
|
||||
userPlan={userProfilePlan}
|
||||
onLogout={handleLogout}
|
||||
onResetPassword={handleResetPassword}
|
||||
onUpdateEmail={handleUpdateEmail}
|
||||
onConfirmDelete={handleDeleteWithMessage}
|
||||
onRestorePurchase={handleIAPRestorePurchase}
|
||||
onManageSubscription={handleManageSubscription}
|
||||
|
||||
@@ -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,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import '@/utils/polyfill';
|
||||
import i18n from '@/i18n/i18n';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { IconContext } from 'react-icons';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { CSPostHogProvider } from '@/context/PHContext';
|
||||
@@ -26,6 +28,16 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
const iconSize = useDefaultIconSize();
|
||||
useSafeAreaInsets(); // Initialize safe area insets
|
||||
|
||||
const pathname = usePathname();
|
||||
const prevPathnameRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevPathnameRef.current !== null) {
|
||||
sessionStorage.setItem('lastPath', prevPathnameRef.current);
|
||||
}
|
||||
prevPathnameRef.current = pathname;
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlerLanguageChanged = (lng: string) => {
|
||||
document.documentElement.lang = lng;
|
||||
@@ -59,8 +71,8 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
setScreenBrightness(brightness / 100);
|
||||
}
|
||||
applyBackgroundTexture(envConfig, globalViewSettings);
|
||||
if (appService.isAndroidApp) {
|
||||
applyEinkMode(globalViewSettings.isEink);
|
||||
if (globalViewSettings.isEink) {
|
||||
applyEinkMode(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user