Compare commits

...

9 Commits

83 changed files with 1606 additions and 402 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.90",
"version": "0.9.91",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev --turbopack",
@@ -651,5 +651,16 @@
"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": "حفظ غلاف الكتاب الأخير تلقائيًا"
}
@@ -635,5 +635,16 @@
"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": "শেষ বইয়ের মলাট স্বয়ংক্রিয়ভাবে সংরক্ষণ করুন"
}
@@ -631,5 +631,16 @@
"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": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།"
}
@@ -635,5 +635,16 @@
"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"
}
@@ -635,5 +635,16 @@
"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": "Αυτόματη αποθήκευση τελευταίου εξωφύλλου βιβλίου"
}
@@ -639,5 +639,16 @@
"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"
}
@@ -635,5 +635,16 @@
"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": "ذخیره خودکار آخرین جلد کتاب"
}
@@ -639,5 +639,16 @@
"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"
}
@@ -635,5 +635,16 @@
"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": "अंतिम पुस्तक कवर को स्वचालित रूप से सहेजें"
}
@@ -631,5 +631,16 @@
"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"
}
@@ -639,5 +639,16 @@
"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"
}
@@ -631,5 +631,16 @@
"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": "最後の本のカバーを自動保存"
}
@@ -631,5 +631,16 @@
"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": "마지막 책 표지 자동 저장"
}
@@ -635,5 +635,16 @@
"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"
}
@@ -643,5 +643,16 @@
"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"
}
@@ -639,5 +639,16 @@
"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"
}
@@ -643,5 +643,16 @@
"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": "Автоматически сохранять последнюю обложку книги"
}
@@ -635,5 +635,16 @@
"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": "අවසන් පොත් ආවරණය ස්වයංක්‍රීයව සුරකින්න"
}
@@ -635,5 +635,16 @@
"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"
}
@@ -635,5 +635,16 @@
"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": "அවසන් புத்தகக் கோப்பு காலியாக உள்ளது"
}
@@ -631,5 +631,16 @@
"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": "บันทึกปกหนังสือสุดท้ายโดยอัตโนมัติ"
}
@@ -635,5 +635,16 @@
"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"
}
@@ -643,5 +643,16 @@
"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": "Автоматично зберегти останню обкладинку книги"
}
@@ -631,5 +631,16 @@
"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"
}
@@ -631,5 +631,16 @@
"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": "自动保存最后一本书的封面"
}
@@ -631,5 +631,16 @@
"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": "自動保存最後一本書的封面"
}
+11
View File
@@ -1,5 +1,16 @@
{
"releases": {
"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": [
@@ -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")
@@ -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>
@@ -19,6 +19,8 @@ const COMMANDS: &[&str] = &[
"set_screen_brightness",
"get_external_sdcard_path",
"request_manage_storage_permission",
"check_permissions",
"request_permissions",
"checkPermissions",
"requestPermissions",
];
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -24,6 +24,8 @@ Default permissions for the plugin
- `allow-set-screen-brightness`
- `allow-get-external-sdcard-path`
- `allow-request-manage-storage-permission`
- `allow-check-permissions`
- `allow-request-permissions`
- `allow-checkPermissions`
- `allow-requestPermissions`
@@ -91,6 +93,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 +145,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 +535,32 @@ Denies the lock_screen_orientation command without any pre-configured scope.
<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 +613,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>
@@ -21,6 +21,8 @@ permissions = [
"allow-set-screen-brightness",
"allow-get-external-sdcard-path",
"allow-request-manage-storage-permission",
"allow-check-permissions",
"allow-request-permissions",
"allow-checkPermissions",
"allow-requestPermissions",
]
@@ -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,18 @@
"const": "deny-lock-screen-orientation",
"markdownDescription": "Denies the lock_screen_orientation 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 +558,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 +607,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-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-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
}
]
}
@@ -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() {
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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",
]
@@ -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`"
}
]
}
+2 -2
View File
@@ -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 });
@@ -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} />}
@@ -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}
@@ -50,6 +50,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 +102,7 @@ const FoliateViewer: React.FC<{
useUICSS(bookKey);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
useBookCoverAutoSave(bookKey);
const { syncState, conflictDetails, resolveWithLocal, resolveWithRemote } = useKOSync(bookKey);
useTextTranslation(bookKey, viewRef.current);
@@ -271,7 +273,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)!;
@@ -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
}, []);
};
+6 -6
View File
@@ -67,7 +67,7 @@ const ProfilePage = () => {
useTheme({ systemUIVisible: false });
const { quotas, userPlan = 'free' } = useQuotaStats();
const { quotas, userProfilePlan = 'free' } = useQuotaStats();
const { handleLogout, handleResetPassword, handleConfirmDelete } = useUserActions();
const { availablePlans } = useAvailablePlans({
@@ -218,8 +218,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 +258,7 @@ const ProfilePage = () => {
avatarUrl={avatarUrl}
userFullName={userFullName}
userEmail={userEmail}
planDetails={planDetails}
planDetails={userPlanDetails}
/>
<UsageStats quotas={quotas} />
@@ -267,14 +267,14 @@ 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}
onConfirmDelete={handleDeleteWithMessage}
@@ -1,9 +1,4 @@
import React, { useState, useEffect } from 'react';
import { MdOutlineLightMode, MdOutlineDarkMode, MdClose, MdAdd } from 'react-icons/md';
import { MdRadioButtonUnchecked, MdRadioButtonChecked } from 'react-icons/md';
import { CgColorPicker } from 'react-icons/cg';
import { TbSunMoon } from 'react-icons/tb';
import { PiPlus } from 'react-icons/pi';
import {
applyCustomTheme,
CustomTheme,
@@ -17,48 +12,55 @@ import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { useCustomTextureStore } from '@/store/customTextureStore';
import { saveViewSettings } from '@/helpers/settings';
import { CODE_LANGUAGES, CodeLanguage, manageSyntaxHighlighting } from '@/utils/highlightjs';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { useFileSelector } from '@/hooks/useFileSelector';
import { PREDEFINED_TEXTURES } from '@/styles/textures';
import { HighlightColor } from '@/types/book';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import Select from '@/components/Select';
import ThemeEditor from './ThemeEditor';
import ColorInput from './ColorInput';
import ThemeEditor from './color/ThemeEditor';
import ThemeModeSelector from './color/ThemeModeSelector';
import ThemeColorSelector from './color/ThemeColorSelector';
import BackgroundTextureSelector from './color/BackgroundTextureSelector';
import HighlightColorsEditor from './color/HighlightColorsEditor';
import TTSHighlightStyleEditor, { TTSHighlightStyle } from './color/TTSHighlightStyleEditor';
import CodeHighlightingSettings from './color/CodeHighlightingSettings';
const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
const { themeMode, themeColor, isDarkMode, setThemeMode, setThemeColor, saveCustomTheme } =
useThemeStore();
const { envConfig, appService } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { getView, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
viewSettings.invertImgColorInDark,
);
const iconSize16 = useResponsiveSize(16);
const iconSize24 = useResponsiveSize(24);
const [editTheme, setEditTheme] = useState<CustomTheme | null>(null);
const [customThemes, setCustomThemes] = useState<Theme[]>([]);
const [showCustomThemeEditor, setShowCustomThemeEditor] = useState(false);
const [overrideColor, setOverrideColor] = useState(viewSettings.overrideColor);
const [codeHighlighting, setcodeHighlighting] = useState(viewSettings.codeHighlighting);
const [codeLanguage, setCodeLanguage] = useState(viewSettings.codeLanguage);
const [selectedTextureId, setSelectedTextureId] = useState(viewSettings.backgroundTextureId);
const [backgroundOpacity, setBackgroundOpacity] = useState(viewSettings.backgroundOpacity);
const [backgroundSize, setBackgroundSize] = useState(viewSettings.backgroundSize);
const [ttsHighlightStyle, setTtsHighlightStyle] = useState(
viewSettings.ttsHighlightOptions.style,
);
const [ttsHighlightColor, setTtsHighlightColor] = useState(
viewSettings.ttsHighlightOptions.color,
);
const [customHighlightColors, setCustomHighlightColors] = useState(
settings.globalReadSettings.customHighlightColors,
);
const [customTtsHighlightColors, setCustomTtsHighlightColors] = useState(
settings.globalReadSettings.customTtsHighlightColors || [],
);
const {
textures: customTextures,
@@ -70,7 +72,6 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
saveCustomTextures,
} = useCustomTextureStore();
const resetToDefaults = useResetViewSettings();
const { selectFiles } = useFileSelector(appService, _);
const handleReset = () => {
@@ -110,7 +111,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
}, [overrideColor]);
useEffect(() => {
let update = false; // check if we need to update syntax highlighting
let update = false;
if (codeHighlighting !== viewSettings.codeHighlighting) {
saveViewSettings(envConfig, bookKey, 'codeHighlighting', codeHighlighting);
update = true;
@@ -172,7 +173,6 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const handleSaveCustomTheme = (customTheme: CustomTheme) => {
applyCustomTheme(customTheme);
saveCustomTheme(envConfig, settings, customTheme);
setSettings({ ...settings });
setThemeColor(customTheme.name);
setShowCustomThemeEditor(false);
@@ -180,7 +180,6 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const handleDeleteCustomTheme = (customTheme: CustomTheme) => {
saveCustomTheme(envConfig, settings, customTheme, true);
setSettings({ ...settings });
setThemeColor('default');
setShowCustomThemeEditor(false);
@@ -224,7 +223,35 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
saveCustomTextures(envConfig);
};
const allTextures = [...PREDEFINED_TEXTURES, ...customTextures.filter((t) => !t.deletedAt)];
const handleHighlightColorsChange = (colors: typeof customHighlightColors) => {
setCustomHighlightColors(colors);
settings.globalReadSettings.customHighlightColors = colors;
setSettings(settings);
saveSettings(envConfig, settings);
};
const handleTTSStyleChange = (style: TTSHighlightStyle) => {
setTtsHighlightStyle(style);
saveViewSettings(envConfig, bookKey, 'ttsHighlightOptions', {
style,
color: ttsHighlightColor,
});
};
const handleTTSColorChange = (color: string) => {
setTtsHighlightColor(color);
saveViewSettings(envConfig, bookKey, 'ttsHighlightOptions', {
style: ttsHighlightStyle,
color,
});
};
const handleCustomTtsColorsChange = (colors: string[]) => {
setCustomTtsHighlightColors(colors);
settings.globalReadSettings.customTtsHighlightColors = colors;
setSettings(settings);
saveSettings(envConfig, settings);
};
return (
<div className='my-4 w-full space-y-6'>
@@ -237,32 +264,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
/>
) : (
<>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Theme Mode')}</h2>
<div className='flex gap-4'>
<button
title={_('Auto Mode')}
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('auto')}
>
<TbSunMoon />
</button>
<button
title={_('Light Mode')}
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('light')}
>
<MdOutlineLightMode />
</button>
<button
title={_('Dark Mode')}
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('dark')}
>
<MdOutlineDarkMode />
</button>
</div>
</div>
<ThemeModeSelector themeMode={themeMode} onThemeModeChange={setThemeMode} />
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Invert Image In Dark Mode')}</h2>
@@ -285,226 +287,48 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
/>
</div>
<div>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
{themes.concat(customThemes).map(({ name, label, colors, isCustomizale }) => (
<button
key={name}
tabIndex={0}
onClick={() => setThemeColor(name)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
setThemeColor(name);
}
e.stopPropagation();
}}
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg p-4 shadow-md ${
themeColor === name ? 'ring-2 ring-indigo-500 ring-offset-2' : ''
}`}
style={{
backgroundColor: isDarkMode
? colors.dark['base-100']
: colors.light['base-100'],
color: isDarkMode ? colors.dark['base-content'] : colors.light['base-content'],
}}
>
<input
aria-label={_(label)}
type='radio'
name='theme'
value={name}
checked={themeColor === name}
onChange={() => setThemeColor(name)}
className='hidden'
/>
{themeColor === name ? (
<MdRadioButtonChecked size={iconSize24} />
) : (
<MdRadioButtonUnchecked size={iconSize24} />
)}
<span>{_(label)}</span>
{isCustomizale && themeColor === name && (
<button onClick={() => handleEditTheme(name)}>
<CgColorPicker size={iconSize16} className='absolute right-2 top-2' />
</button>
)}
</button>
))}
<button
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed p-4 shadow-md`}
onClick={() => setShowCustomThemeEditor(true)}
>
<PiPlus size={iconSize24} />
<span>{_('Custom')}</span>
</button>
</div>
</div>
<ThemeColorSelector
themes={themes.concat(customThemes)}
themeColor={themeColor}
isDarkMode={isDarkMode}
onThemeColorChange={setThemeColor}
onEditTheme={handleEditTheme}
onCreateTheme={() => setShowCustomThemeEditor(true)}
/>
<div>
<h2 className='mb-2 font-medium'>{_('Background Image')}</h2>
<div className='mb-4 grid grid-cols-2 gap-4'>
{allTextures.map((texture) => (
<button
key={texture.id}
onClick={() => setSelectedTextureId(texture.id)}
className={`bg-base-100 relative flex flex-col items-center justify-center rounded-lg border-2 p-4 shadow-md transition-all ${
selectedTextureId === texture.id
? 'ring-2 ring-indigo-500 ring-offset-2'
: 'border-base-300'
}`}
style={{
backgroundImage: texture.loaded
? `url("${texture.blobUrl || texture.url}")`
: 'none',
backgroundSize: 'cover',
backgroundPosition: 'top',
minHeight: '80px',
}}
>
{selectedTextureId === texture.id && (
<MdRadioButtonChecked
size={iconSize24}
className='absolute right-2 top-2 rounded-full bg-white text-indigo-500'
/>
)}
{!PREDEFINED_TEXTURES.find((t) => t.id === texture.id) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteCustomTexture(texture.id);
}}
className='absolute left-2 top-2 rounded-full bg-red-500 p-1 text-white transition-colors hover:bg-red-600'
title={_('Delete')}
>
<MdClose size={16} />
</button>
)}
</button>
))}
<BackgroundTextureSelector
predefinedTextures={PREDEFINED_TEXTURES}
customTextures={customTextures.filter((t) => !t.deletedAt)}
selectedTextureId={selectedTextureId}
backgroundOpacity={backgroundOpacity}
backgroundSize={backgroundSize}
onTextureSelect={setSelectedTextureId}
onOpacityChange={setBackgroundOpacity}
onSizeChange={setBackgroundSize}
onImportImage={handleImportImage}
onDeleteTexture={handleDeleteCustomTexture}
/>
{/* Custom Image Upload */}
<div
className={`border-base-300 relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 shadow-md transition-all`}
style={{ minHeight: '80px' }}
>
<button
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
onClick={handleImportImage}
>
<div className='flex items-center gap-2'>
<div className='flex items-center justify-center'>
<MdAdd className='text-primary/85 group-hover:text-primary h-6 w-6' />
</div>
<div className='text-primary/85 group-hover:text-primary line-clamp-1 font-medium'>
{_('Import Image')}
</div>
</div>
</button>
</div>
</div>
<HighlightColorsEditor
customHighlightColors={customHighlightColors}
onChange={handleHighlightColorsChange}
/>
{/* Background Image Settings */}
{selectedTextureId !== 'none' && (
<div className='card border-base-200 bg-base-100 space-y-4 border p-4 shadow'>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Opacity')}</span>
<div className='flex items-center gap-2'>
<input
type='range'
min='0'
max='1'
step='0.05'
value={backgroundOpacity}
onChange={(e) => setBackgroundOpacity(parseFloat(e.target.value))}
className='range range-sm w-32'
/>
<span className='w-12 text-right text-sm'>
{Math.round(backgroundOpacity * 100)}%
</span>
</div>
</div>
<TTSHighlightStyleEditor
style={ttsHighlightStyle}
color={ttsHighlightColor}
customColors={customTtsHighlightColors}
onStyleChange={handleTTSStyleChange}
onColorChange={handleTTSColorChange}
onCustomColorsChange={handleCustomTtsColorsChange}
/>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Size')}</span>
<Select
value={backgroundSize}
onChange={(e) => setBackgroundSize(e.target.value)}
options={[
{ value: 'auto', label: _('Auto') },
{ value: 'cover', label: _('Cover') },
{ value: 'contain', label: _('Contain') },
]}
/>
</div>
</div>
)}
</div>
<div>
<h2 className='mb-2 font-medium'>{_('Highlight Colors')}</h2>
<div className='card border-base-200 bg-base-100 overflow-visible border p-4 shadow'>
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
{(['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[]).map(
(color, index, array) => {
const position =
index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
return (
<div key={color} className='flex flex-col items-center gap-2'>
<div
className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'
style={{ backgroundColor: customHighlightColors[color] }}
/>
<ColorInput
label=''
value={customHighlightColors[color]}
compact={true}
pickerPosition={position}
onChange={(value: string) => {
customHighlightColors[color] = value;
setCustomHighlightColors({ ...customHighlightColors });
settings.globalReadSettings.customHighlightColors =
customHighlightColors;
setSettings(settings);
}}
/>
</div>
);
},
)}
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Code Highlighting')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
<div className='config-item'>
<span className=''>{_('Enable Highlighting')}</span>
<input
type='checkbox'
className='toggle'
checked={codeHighlighting}
onChange={() => setcodeHighlighting(!codeHighlighting)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Code Language')}</span>
<Select
value={codeLanguage}
onChange={(event) => setCodeLanguage(event.target.value as CodeLanguage)}
options={CODE_LANGUAGES.map((lang) => ({
value: lang,
label: lang === 'auto-detect' ? _('Auto') : lang,
}))}
disabled={!codeHighlighting}
/>
</div>
</div>
</div>
</div>
<CodeHighlightingSettings
codeHighlighting={codeHighlighting}
codeLanguage={codeLanguage}
onToggle={setcodeHighlighting}
onLanguageChange={setCodeLanguage}
/>
</>
)}
</div>
@@ -8,16 +8,16 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { useEinkMode } from '@/hooks/useEinkMode';
import { getStyles } from '@/utils/style';
import { saveAndReload } from '@/utils/reload';
import { getMaxInlineSize } from '@/utils/config';
import { saveSysSettings, saveViewSettings } from '@/helpers/settings';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { RELOAD_BEFORE_SAVED_TIMEOUT_MS } from '@/services/constants';
import NumberInput from './NumberInput';
const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const { getView, getViewSettings, recreateViewer } = useReaderStore();
const { getBookData } = useBookDataStore();
const { settings } = useSettingsStore();
const { applyEinkMode } = useEinkMode();
@@ -144,7 +144,9 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
useEffect(() => {
if (viewSettings.allowScript === allowScript) return;
saveViewSettings(envConfig, bookKey, 'allowScript', allowScript, true, false);
saveAndReload();
setTimeout(() => {
recreateViewer(envConfig, bookKey);
}, RELOAD_BEFORE_SAVED_TIMEOUT_MS);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowScript]);
@@ -8,9 +8,12 @@ import { useSettingsStore } from '@/store/settingsStore';
import { saveViewSettings } from '@/helpers/settings';
import { getTranslators } from '@/services/translators';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { TRANSLATED_LANGS, TRANSLATOR_LANGS } from '@/services/constants';
import {
RELOAD_BEFORE_SAVED_TIMEOUT_MS,
TRANSLATED_LANGS,
TRANSLATOR_LANGS,
} from '@/services/constants';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { saveAndReload } from '@/utils/reload';
import Select from '@/components/Select';
const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
@@ -18,7 +21,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const { token } = useAuth();
const { envConfig } = useEnv();
const { settings, applyUILanguage } = useSettingsStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getViewSettings, setViewSettings, recreateViewer } = useReaderStore();
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
const [uiLanguage, setUILanguage] = useState(viewSettings.uiLanguage);
@@ -141,10 +144,8 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
useEffect(() => {
if (translationEnabled === viewSettings.translationEnabled) return;
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
viewSettings.translationEnabled = translationEnabled;
setViewSettings(bookKey, { ...viewSettings });
if (!showTranslateSource && !translationEnabled) {
saveAndReload();
if (!showTranslateSource && translationEnabled) {
setTimeout(() => recreateViewer(envConfig, bookKey), RELOAD_BEFORE_SAVED_TIMEOUT_MS);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [translationEnabled]);
@@ -152,7 +153,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
useEffect(() => {
if (showTranslateSource === viewSettings.showTranslateSource) return;
saveViewSettings(envConfig, bookKey, 'showTranslateSource', showTranslateSource, false, false);
saveAndReload();
setTimeout(() => recreateViewer(envConfig, bookKey), RELOAD_BEFORE_SAVED_TIMEOUT_MS);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showTranslateSource]);
@@ -12,12 +12,11 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { isCJKEnv } from '@/utils/misc';
import { getStyles } from '@/utils/style';
import { saveAndReload } from '@/utils/reload';
import { getMaxInlineSize } from '@/utils/config';
import { lockScreenOrientation } from '@/utils/bridge';
import { saveViewSettings } from '@/helpers/settings';
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
import { MIGHT_BE_RTL_LANGS } from '@/services/constants';
import { MIGHT_BE_RTL_LANGS, RELOAD_BEFORE_SAVED_TIMEOUT_MS } from '@/services/constants';
import { SettingsPanelPanelProp } from './SettingsDialog';
import Select from '@/components/Select';
import NumberInput from './NumberInput';
@@ -26,7 +25,8 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { getView, getViewSettings, getGridInsets, setViewSettings } = useReaderStore();
const { getView, getViewSettings, getGridInsets } = useReaderStore();
const { setViewSettings, recreateViewer } = useReaderStore();
const { getBookData } = useBookDataStore();
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
@@ -283,7 +283,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
(['horizontal-rl', 'vertical-rl'].includes(writingMode) ||
['horizontal-rl', 'vertical-rl'].includes(prevWritingMode))
) {
saveAndReload();
setTimeout(() => recreateViewer(envConfig, bookKey), RELOAD_BEFORE_SAVED_TIMEOUT_MS);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writingMode]);
@@ -0,0 +1,145 @@
import React from 'react';
import { MdRadioButtonChecked, MdClose, MdAdd } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import Select from '@/components/Select';
interface Texture {
id: string;
url?: string;
blobUrl?: string;
loaded?: boolean;
}
interface BackgroundTextureSelectorProps {
predefinedTextures: Texture[];
customTextures: Texture[];
selectedTextureId: string;
backgroundOpacity: number;
backgroundSize: string;
onTextureSelect: (id: string) => void;
onOpacityChange: (opacity: number) => void;
onSizeChange: (size: string) => void;
onImportImage: () => void;
onDeleteTexture: (id: string) => void;
}
const BackgroundTextureSelector: React.FC<BackgroundTextureSelectorProps> = ({
predefinedTextures,
customTextures,
selectedTextureId,
backgroundOpacity,
backgroundSize,
onTextureSelect,
onOpacityChange,
onSizeChange,
onImportImage,
onDeleteTexture,
}) => {
const _ = useTranslation();
const iconSize24 = useResponsiveSize(24);
const allTextures = [...predefinedTextures, ...customTextures];
return (
<div>
<h2 className='mb-2 font-medium'>{_('Background Image')}</h2>
<div className='mb-4 grid grid-cols-2 gap-4'>
{allTextures.map((texture) => (
<button
key={texture.id}
onClick={() => onTextureSelect(texture.id)}
className={`bg-base-100 relative flex flex-col items-center justify-center rounded-lg border-2 p-4 shadow-md transition-all ${
selectedTextureId === texture.id
? 'ring-2 ring-indigo-500 ring-offset-2'
: 'border-base-300'
}`}
style={{
backgroundImage: texture.loaded ? `url("${texture.blobUrl || texture.url}")` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'top',
minHeight: '80px',
}}
>
{selectedTextureId === texture.id && (
<MdRadioButtonChecked
size={iconSize24}
className='absolute right-2 top-2 rounded-full bg-white text-indigo-500'
/>
)}
{!predefinedTextures.find((t) => t.id === texture.id) && (
<button
onClick={(e) => {
e.stopPropagation();
onDeleteTexture(texture.id);
}}
className='absolute left-2 top-2 rounded-full bg-red-500 p-1 text-white transition-colors hover:bg-red-600'
title={_('Delete')}
>
<MdClose size={16} />
</button>
)}
</button>
))}
{/* Custom Image Upload */}
<div
className='border-base-300 relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 shadow-md transition-all'
style={{ minHeight: '80px' }}
>
<button
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
onClick={onImportImage}
>
<div className='flex items-center gap-2'>
<div className='flex items-center justify-center'>
<MdAdd className='text-primary/85 group-hover:text-primary h-6 w-6' />
</div>
<div className='text-primary/85 group-hover:text-primary line-clamp-1 font-medium'>
{_('Import Image')}
</div>
</div>
</button>
</div>
</div>
{/* Background Image Settings */}
{selectedTextureId !== 'none' && (
<div className='card border-base-200 bg-base-100 space-y-4 border p-4 shadow'>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Opacity')}</span>
<div className='flex items-center gap-2'>
<input
type='range'
min='0'
max='1'
step='0.05'
value={backgroundOpacity}
onChange={(e) => onOpacityChange(parseFloat(e.target.value))}
className='range range-sm w-32'
/>
<span className='w-12 text-right text-sm'>
{Math.round(backgroundOpacity * 100)}%
</span>
</div>
</div>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Size')}</span>
<Select
value={backgroundSize}
onChange={(e) => onSizeChange(e.target.value)}
options={[
{ value: 'auto', label: _('Auto') },
{ value: 'cover', label: _('Cover') },
{ value: 'contain', label: _('Contain') },
]}
/>
</div>
</div>
)}
</div>
);
};
export default BackgroundTextureSelector;
@@ -0,0 +1,54 @@
import React from 'react';
import { CODE_LANGUAGES, CodeLanguage } from '@/utils/highlightjs';
import { useTranslation } from '@/hooks/useTranslation';
import Select from '@/components/Select';
interface CodeHighlightingSettingsProps {
codeHighlighting: boolean;
codeLanguage: string;
onToggle: (enabled: boolean) => void;
onLanguageChange: (language: CodeLanguage) => void;
}
const CodeHighlightingSettings: React.FC<CodeHighlightingSettingsProps> = ({
codeHighlighting,
codeLanguage,
onToggle,
onLanguageChange,
}) => {
const _ = useTranslation();
return (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Code Highlighting')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200'>
<div className='config-item'>
<span>{_('Enable Highlighting')}</span>
<input
type='checkbox'
className='toggle'
checked={codeHighlighting}
onChange={() => onToggle(!codeHighlighting)}
/>
</div>
<div className='config-item'>
<span>{_('Code Language')}</span>
<Select
value={codeLanguage}
onChange={(event) => onLanguageChange(event.target.value as CodeLanguage)}
options={CODE_LANGUAGES.map((lang) => ({
value: lang,
label: lang === 'auto-detect' ? _('Auto') : lang,
}))}
disabled={!codeHighlighting}
/>
</div>
</div>
</div>
</div>
);
};
export default CodeHighlightingSettings;
@@ -9,7 +9,13 @@ type ColorInputProps = {
pickerPosition?: 'left' | 'center' | 'right';
};
const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange, compact = false, pickerPosition = 'left' }) => {
const ColorInput: React.FC<ColorInputProps> = ({
label,
value,
onChange,
compact = false,
pickerPosition = 'left',
}) => {
const [isOpen, setIsOpen] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
@@ -53,9 +59,9 @@ const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange, compact
/>
{isOpen && (
<div
ref={pickerRef}
className={`absolute top-full z-50 mt-1 ${getPickerPositionClass()}`}
<div
ref={pickerRef}
className={`absolute top-full z-50 py-1 ${getPickerPositionClass()}`}
>
<SketchPicker
width='200px'
@@ -0,0 +1,54 @@
import React from 'react';
import { HighlightColor } from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
import ColorInput from './ColorInput';
interface HighlightColorsEditorProps {
customHighlightColors: Record<HighlightColor, string>;
onChange: (colors: Record<HighlightColor, string>) => void;
}
const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
customHighlightColors,
onChange,
}) => {
const _ = useTranslation();
const handleColorChange = (color: HighlightColor, value: string) => {
const updated = { ...customHighlightColors, [color]: value };
onChange(updated);
};
return (
<div>
<h2 className='mb-2 font-medium'>{_('Highlight Colors')}</h2>
<div className='card border-base-200 bg-base-100 overflow-visible border p-4 shadow'>
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
{(['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[]).map(
(color, index, array) => {
const position =
index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
return (
<div key={color} className='flex flex-col items-center gap-2'>
<div
className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'
style={{ backgroundColor: customHighlightColors[color] }}
/>
<ColorInput
label=''
value={customHighlightColors[color]}
compact={true}
pickerPosition={position}
onChange={(value: string) => handleColorChange(color, value)}
/>
</div>
);
},
)}
</div>
</div>
</div>
);
};
export default HighlightColorsEditor;
@@ -0,0 +1,147 @@
import React from 'react';
import { MdClose } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import Select from '@/components/Select';
import ColorInput from './ColorInput';
export type TTSHighlightStyle =
| 'highlight'
| 'underline'
| 'strikethrough'
| 'squiggly'
| 'outline';
interface TTSHighlightStyleEditorProps {
style: TTSHighlightStyle;
color: string;
customColors: string[];
onStyleChange: (style: TTSHighlightStyle) => void;
onColorChange: (color: string) => void;
onCustomColorsChange: (colors: string[]) => void;
}
const TTSHighlightStyleEditor: React.FC<TTSHighlightStyleEditorProps> = ({
style,
color,
customColors,
onStyleChange,
onColorChange,
onCustomColorsChange,
}) => {
const _ = useTranslation();
const defaultQuickColors = [
{ color: '#FFD700', label: 'Gold' },
{ color: '#00CED1', label: 'Cyan' },
{ color: '#FF69B4', label: 'Pink' },
{ color: '#90EE90', label: 'Green' },
{ color: '#FFA500', label: 'Orange' },
];
const handleAddCustomColor = () => {
if (!customColors.includes(color) && !defaultQuickColors.some((c) => c.color === color)) {
const updatedColors = [...customColors, color];
onCustomColorsChange(updatedColors);
}
};
const handleDeleteCustomColor = (colorToDelete: string) => {
const updatedColors = customColors.filter((c) => c !== colorToDelete);
onCustomColorsChange(updatedColors);
};
return (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('TTS Highlighting')}</h2>
<div className='card border-base-200 bg-base-100 border p-4 shadow'>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Style')}</span>
<Select
value={style}
onChange={(e) => onStyleChange(e.target.value as TTSHighlightStyle)}
options={[
{ value: 'highlight', label: _('Highlighter') },
{ value: 'underline', label: _('Underline') },
{ value: 'strikethrough', label: _('Strikethrough') },
{ value: 'squiggly', label: _('Squiggly') },
{ value: 'outline', label: _('Outline') },
]}
/>
</div>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Color')}</span>
<div className='flex items-center gap-2'>
<div
className='border-base-300 pointer-events-none h-8 w-8 cursor-pointer rounded-full border-2 shadow-sm'
style={{ backgroundColor: color }}
/>
<ColorInput
label=''
value={color}
compact={true}
pickerPosition='center'
onChange={onColorChange}
/>
<button
onClick={handleAddCustomColor}
disabled={
customColors.includes(color) || defaultQuickColors.some((c) => c.color === color)
}
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent'
title={_('Save Current Color')}
>
<span className='text-xs'>{_('Save')}</span>
</button>
</div>
</div>
<div className='border-base-200 flex justify-between border-t pt-4'>
<div className='flex items-center'>
<span className='text-sm font-medium'>{_('Quick Colors')}</span>
</div>
<div className='flex max-w-[70%] flex-wrap justify-end gap-2'>
{defaultQuickColors.map(({ color: quickColor }) => (
<button
key={quickColor}
onClick={() => onColorChange(quickColor)}
className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
color === quickColor
? 'ring-2 ring-indigo-500 ring-offset-2'
: 'border-base-300'
}`}
style={{ backgroundColor: quickColor }}
/>
))}
{customColors.map((customColor) => (
<div key={customColor} className='group relative'>
<button
onClick={() => onColorChange(customColor)}
className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
color === customColor
? 'ring-2 ring-indigo-500 ring-offset-2'
: 'border-base-300'
}`}
style={{ backgroundColor: customColor }}
/>
<button
onClick={() => handleDeleteCustomColor(customColor)}
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
title={_('Delete')}
>
<MdClose size={12} />
</button>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default TTSHighlightStyleEditor;
@@ -0,0 +1,87 @@
import React from 'react';
import { CgColorPicker } from 'react-icons/cg';
import { MdRadioButtonUnchecked, MdRadioButtonChecked } from 'react-icons/md';
import { PiPlus } from 'react-icons/pi';
import { Theme } from '@/styles/themes';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface ThemeColorSelectorProps {
themes: Theme[];
themeColor: string;
isDarkMode: boolean;
onThemeColorChange: (name: string) => void;
onEditTheme: (name: string) => void;
onCreateTheme: () => void;
}
const ThemeColorSelector: React.FC<ThemeColorSelectorProps> = ({
themes,
themeColor,
isDarkMode,
onThemeColorChange,
onEditTheme,
onCreateTheme,
}) => {
const _ = useTranslation();
const iconSize16 = useResponsiveSize(16);
const iconSize24 = useResponsiveSize(24);
return (
<div>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
{themes.map(({ name, label, colors, isCustomizale }) => (
<button
key={name}
tabIndex={0}
onClick={() => onThemeColorChange(name)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onThemeColorChange(name);
}
e.stopPropagation();
}}
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg p-4 shadow-md ${
themeColor === name ? 'ring-2 ring-indigo-500 ring-offset-2' : ''
}`}
style={{
backgroundColor: isDarkMode ? colors.dark['base-100'] : colors.light['base-100'],
color: isDarkMode ? colors.dark['base-content'] : colors.light['base-content'],
}}
>
<input
aria-label={_(label)}
type='radio'
name='theme'
value={name}
checked={themeColor === name}
onChange={() => onThemeColorChange(name)}
className='hidden'
/>
{themeColor === name ? (
<MdRadioButtonChecked size={iconSize24} />
) : (
<MdRadioButtonUnchecked size={iconSize24} />
)}
<span>{_(label)}</span>
{isCustomizale && themeColor === name && (
<button onClick={() => onEditTheme(name)}>
<CgColorPicker size={iconSize16} className='absolute right-2 top-2' />
</button>
)}
</button>
))}
<button
className='relative flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed p-4 shadow-md'
onClick={onCreateTheme}
>
<PiPlus size={iconSize24} />
<span>{_('Custom')}</span>
</button>
</div>
</div>
);
};
export default ThemeColorSelector;
@@ -0,0 +1,44 @@
import React from 'react';
import { MdOutlineLightMode, MdOutlineDarkMode } from 'react-icons/md';
import { TbSunMoon } from 'react-icons/tb';
import { useTranslation } from '@/hooks/useTranslation';
interface ThemeModeSelectorProps {
themeMode: 'auto' | 'light' | 'dark';
onThemeModeChange: (mode: 'auto' | 'light' | 'dark') => void;
}
const ThemeModeSelector: React.FC<ThemeModeSelectorProps> = ({ themeMode, onThemeModeChange }) => {
const _ = useTranslation();
return (
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Theme Mode')}</h2>
<div className='flex gap-4'>
<button
title={_('Auto Mode')}
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => onThemeModeChange('auto')}
>
<TbSunMoon />
</button>
<button
title={_('Light Mode')}
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
onClick={() => onThemeModeChange('light')}
>
<MdOutlineLightMode />
</button>
<button
title={_('Dark Mode')}
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
onClick={() => onThemeModeChange('dark')}
>
<MdOutlineDarkMode />
</button>
</div>
</div>
);
};
export default ThemeModeSelector;
@@ -7,6 +7,7 @@ export const useEinkMode = () => {
} else {
document.body.classList.remove('no-transitions');
}
document.documentElement.setAttribute('data-eink', isEink.toString());
}, []);
return { applyEinkMode };
+4 -5
View File
@@ -1,19 +1,18 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { QuotaType, UserPlan } from '@/types/quota';
import { getStoragePlanData, getTranslationPlanData, getUserPlan } from '@/utils/access';
import { getStoragePlanData, getTranslationPlanData, getUserProfilePlan } from '@/utils/access';
import { useTranslation } from './useTranslation';
export const useQuotaStats = (briefName = false) => {
const _ = useTranslation();
const { token, user } = useAuth();
const [quotas, setQuotas] = useState<QuotaType[]>([]);
const [userPlan, setUserPlan] = useState<UserPlan | undefined>(undefined);
const [userProfilePlan, setUserProfilePlan] = useState<UserPlan | undefined>(undefined);
useEffect(() => {
if (!user || !token) return;
const userPlan = getUserPlan(token);
const storagPlan = getStoragePlanData(token);
const inGB = storagPlan.quota > 1e9;
const storageQuota: QuotaType = {
@@ -35,13 +34,13 @@ export const useQuotaStats = (briefName = false) => {
total: Math.round(translationPlan.quota / 1024),
unit: 'K',
};
setUserPlan(userPlan);
setUserProfilePlan(getUserProfilePlan(token));
setQuotas([storageQuota, translationQuota]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return {
quotas,
userPlan,
userProfilePlan,
};
};
@@ -2,7 +2,11 @@ import crypto from 'crypto';
import { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { getDailyTranslationPlanData, getUserPlan, validateUserAndToken } from '@/utils/access';
import {
getDailyTranslationPlanData,
getSubscriptionPlan,
validateUserAndToken,
} from '@/utils/access';
import { ErrorCodes } from '@/services/translators';
import { UsageStatsManager } from '@/utils/usage';
@@ -53,7 +57,7 @@ const updateDailyUsage = async (
if (!userId || !token) return 0;
try {
const userPlan = getUserPlan(token);
const userPlan = getSubscriptionPlan(token);
const newUsage = await UsageStatsManager.trackUsage(
userId,
'translation_chars',
@@ -90,7 +94,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
let deeplApiUrl = deepFreeApiUrl;
let userPlan = 'free';
if (user && token) {
userPlan = getUserPlan(token);
userPlan = getSubscriptionPlan(token);
if (userPlan === 'pro') deeplApiUrl = deeplProApiUrl;
}
const deeplAuthKey =
@@ -104,6 +104,7 @@ export const DEFAULT_READSETTINGS: ReadSettings = {
squiggly: 'blue',
},
customHighlightColors: HIGHLIGHT_COLOR_HEX,
customTtsHighlightColors: [],
};
export const DEFAULT_MOBILE_READSETTINGS: Partial<ReadSettings> = {
@@ -219,6 +220,7 @@ export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsVoice: '',
ttsLocation: '',
showTTSBar: false,
ttsHighlightOptions: { style: 'highlight', color: '#808080' },
};
export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = {
@@ -38,6 +38,7 @@ import { getOSPlatform, isContentURI, isValidURL } from '@/utils/misc';
import { getDirPath, getFilename } from '@/utils/path';
import { NativeFile, RemoteFile } from '@/utils/file';
import { copyURIToPath } from '@/utils/bridge';
import { copyFiles } from '@/utils/files';
import { BaseAppService } from './appService';
import {
@@ -78,7 +79,8 @@ const getPathResolver = ({
const getCustomBasePrefixSync = isCustomBaseDir
? (baseDir: BaseDir) => {
return () => {
const leafDir = ['Settings', 'Data', 'Books', 'Fonts'].includes(baseDir) ? '' : baseDir;
const dataDirs = ['Settings', 'Data', 'Books', 'Fonts', 'Images'];
const leafDir = dataDirs.includes(baseDir) ? '' : baseDir;
return leafDir ? `${customRootDir}/${leafDir}` : customRootDir!;
};
}
@@ -392,6 +394,33 @@ export class NativeAppService extends BaseAppService {
});
}
await this.prepareBooksDir();
await this.runMigrations();
}
private CURRENT_MIGRATION_VERSION = 20251029;
private async runMigrations() {
try {
const settings = await this.loadSettings();
const lastMigrationVersion = settings.migrationVersion || 0;
if (lastMigrationVersion < 20251029) {
try {
await this.migrate20251029();
} catch (error) {
console.error('Error migrating to version 20251029:', error);
}
}
if (lastMigrationVersion < this.CURRENT_MIGRATION_VERSION) {
await this.saveSettings({
...settings,
migrationVersion: this.CURRENT_MIGRATION_VERSION,
});
}
} catch (error) {
console.error('Failed to run migrations:', error);
}
}
override resolvePath(fp: string, base: BaseDir): ResolvedPath {
@@ -422,4 +451,16 @@ export class NativeAppService extends BaseAppService {
});
return Array.isArray(selected) ? selected : selected ? [selected] : [];
}
async migrate20251029() {
console.log('Running migration 20251029 to update paths in Images dir...');
const rootPath = await this.resolveFilePath('..', 'Data');
const newDir = await this.fs.getPrefix('Images');
const oldDir = await join(rootPath, 'Images', 'Readest', 'Images');
await copyFiles(this, oldDir, newDir);
const dirToDelete = await join(rootPath, 'Images', 'Readest');
await this.deleteDir(dirToDelete, 'None', true);
}
}
@@ -18,18 +18,23 @@ export const sanitizerTransformer: Transformer = {
ADD_TAGS: ['link', 'meta'],
ADD_ATTR: (attributeName: string) => {
return (
['xmlns'].includes(attributeName) ||
['xmlns', 'http-equiv', 'content', 'charset', 'link', 'vlink'].includes(attributeName) ||
attributeName.startsWith('xml:') ||
attributeName.startsWith('xmlns:') ||
attributeName.startsWith('epub:')
);
},
RETURN_DOM: true,
});
sanitized = '<?xml version="1.0" encoding="utf-8"?><!DOCTYPE html>' + sanitized;
const serializer = new XMLSerializer();
let serialized = serializer.serializeToString(sanitized);
serialized = '<?xml version="1.0" encoding="utf-8"?>' + serialized;
serialized = serialized.replace(/(<head[^>]*>)/i, '\n$1');
serialized = serialized.replace(/(<\/body>)(<\/html>)/i, '$1\n$2');
// console.log(`Sanitizer diff:\n${diff(result, sanitized)}`);
// console.log(`Sanitizer diff:\n${diff(result, serialized)}`);
return sanitized;
return serialized;
},
};
@@ -2,7 +2,7 @@ import { getAPIBaseUrl } from '@/services/environment';
import { stubTranslation as _ } from '@/utils/misc';
import { ErrorCodes, TranslationProvider } from '../types';
import { UserPlan } from '@/types/quota';
import { getUserPlan } from '@/utils/access';
import { getSubscriptionPlan } from '@/utils/access';
import { normalizeToShortLang } from '@/utils/lang';
import { DEFAULT_DAILY_TRANSLATION_QUOTA } from '@/services/constants';
import { saveDailyUsage } from '../utils';
@@ -29,7 +29,7 @@ export const deeplProvider: TranslationProvider = {
let userPlan: UserPlan = 'free';
if (token) {
userPlan = getUserPlan(token);
userPlan = getSubscriptionPlan(token);
headers['Authorization'] = `Bearer ${token}`;
}
@@ -41,6 +41,8 @@ export class TTSController extends EventTarget {
ttsNativeVoices: TTSVoice[] = [];
ttsTargetLang: string = '';
options: TTSHighlightOptions = { style: 'highlight', color: 'gray' };
constructor(appService: AppService | null, view: FoliateView) {
super();
this.ttsWebClient = new WebSpeechClient(this);
@@ -80,10 +82,10 @@ export class TTSController extends EventTarget {
this.ttsEdgeVoices = await this.ttsEdgeClient.getAllVoices();
}
#getHighlighter(options: TTSHighlightOptions) {
#getHighlighter() {
return (range: Range) => {
const { overlayer } = this.view.renderer.getContents()[0] as { overlayer: Overlayer };
const { style, color } = options;
const { style, color } = this.options;
overlayer?.remove(HIGHLIGHT_KEY);
overlayer?.add(HIGHLIGHT_KEY, range, Overlayer[style], { color });
};
@@ -94,20 +96,23 @@ export class TTSController extends EventTarget {
overlayer?.remove(HIGHLIGHT_KEY);
}
async initViewTTS() {
async initViewTTS(options?: TTSHighlightOptions) {
if (options) {
this.options.style = options.style;
this.options.color = options.color;
}
let granularity: TTSGranularity = this.view.language.isCJK ? 'sentence' : 'word';
const supportedGranularities = this.ttsClient.getGranularities();
if (!supportedGranularities.includes(granularity)) {
granularity = supportedGranularities[0]!;
}
const highlightOptions: TTSHighlightOptions = { style: 'highlight', color: 'gray' };
await this.view.initTTS(
granularity,
createRejectFilter({
tags: ['rt', 'sup'],
contents: [{ tag: 'a', content: /^\d+$/ }],
}),
this.#getHighlighter(highlightOptions),
this.#getHighlighter(),
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
export type TTSGranularity = 'sentence' | 'word';
export type TTSHighlightOptions = {
style: 'highlight' | 'underline' | 'squiggly' | 'outline';
style: 'highlight' | 'underline' | 'strikethrough' | 'squiggly' | 'outline';
color: string;
};
+32 -2
View File
@@ -20,11 +20,13 @@ import { SUPPORTED_LANGNAMES } from '@/services/constants';
import { useSettingsStore } from './settingsStore';
import { useBookDataStore } from './bookDataStore';
import { useLibraryStore } from './libraryStore';
import { uniqueId } from '@/utils/misc';
interface ViewState {
/* Unique key for each book view */
key: string;
view: FoliateView | null;
viewerKey: string;
isPrimary: boolean;
loading: boolean;
inited: boolean;
@@ -72,12 +74,14 @@ interface ReaderStore {
id: string,
key: string,
isPrimary?: boolean,
reload?: boolean,
) => Promise<void>;
clearViewState: (key: string) => void;
getViewState: (key: string) => ViewState | null;
getGridInsets: (key: string) => Insets | null;
setGridInsets: (key: string, insets: Insets | null) => void;
setViewInited: (key: string, inited: boolean) => void;
recreateViewer: (envConfig: EnvConfigType, key: string) => void;
}
export const useReaderStore = create<ReaderStore>((set, get) => ({
@@ -110,7 +114,13 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
});
},
getViewState: (key: string) => get().viewStates[key] || null,
initViewState: async (envConfig: EnvConfigType, id: string, key: string, isPrimary = true) => {
initViewState: async (
envConfig: EnvConfigType,
id: string,
key: string,
isPrimary = true,
reload = false,
) => {
const booksData = useBookDataStore.getState().booksData;
const bookData = booksData[id];
set((state) => ({
@@ -119,6 +129,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
[key]: {
key: '',
view: null,
viewerKey: '',
isPrimary: false,
loading: true,
inited: false,
@@ -134,7 +145,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
}));
try {
const { settings } = useSettingsStore.getState();
if (!bookData) {
if (!bookData || reload) {
const appService = await envConfig.getAppService();
const { library } = useLibraryStore.getState();
const book = library.find((b) => b.hash === id);
@@ -183,6 +194,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
...state.viewStates[key],
key,
view: null,
viewerKey: `${key}-${uniqueId()}`,
isPrimary,
loading: false,
inited: false,
@@ -205,6 +217,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
...state.viewStates[key],
key: '',
view: null,
viewerKey: '',
isPrimary: false,
loading: false,
inited: false,
@@ -378,4 +391,21 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
},
})),
recreateViewer: (envConfig: EnvConfigType, key: string) => {
const id = key.split('-')[0]!;
get()
.initViewState(envConfig, id, key, true, true)
.then(() => {
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
viewerKey: `${key}-${uniqueId()}`,
},
},
}));
});
},
}));
+5 -1
View File
@@ -412,6 +412,10 @@ foliate-view {
animation: spin-counterclockwise 1s linear;
}
[data-eink="true"] [class*="shadow-"] {
box-shadow: none !important;
}
.no-transitions * {
transition: none !important;
transition: none !important;
}
+2
View File
@@ -1,4 +1,5 @@
import { BookMetadata } from '@/libs/document';
import { TTSHighlightOptions } from '@/services/tts/types';
export type BookFormat = 'EPUB' | 'PDF' | 'MOBI' | 'AZW' | 'AZW3' | 'CBZ' | 'FB2' | 'FBZ';
export type BookNoteType = 'bookmark' | 'annotation' | 'excerpt';
@@ -175,6 +176,7 @@ export interface TTSConfig {
ttsVoice: string;
ttsLocation: string;
showTTSBar: boolean;
ttsHighlightOptions: TTSHighlightOptions;
}
export interface TranslatorConfig {
+4
View File
@@ -23,6 +23,7 @@ export interface ReadSettings {
highlightStyle: HighlightStyle;
highlightStyles: Record<HighlightStyle, HighlightColor>;
customHighlightColors: Record<HighlightColor, string>;
customTtsHighlightColors: string[];
customThemes: CustomTheme[];
}
@@ -55,6 +56,7 @@ export interface SystemSettings {
openLastBooks: boolean;
lastOpenBooks: string[];
autoImportBooksOnOpen: boolean;
savedBookCoverForLockScreen: string;
telemetryEnabled: boolean;
libraryViewMode: LibraryViewModeType;
librarySortBy: LibrarySortByType;
@@ -69,6 +71,8 @@ export interface SystemSettings {
lastSyncedAtConfigs: number;
lastSyncedAtNotes: number;
migrationVersion: number;
globalReadSettings: ReadSettings;
globalViewSettings: ViewSettings;
}
+13 -1
View File
@@ -12,11 +12,23 @@ interface Token {
[key: string]: string | number;
}
export const getUserPlan = (token: string): UserPlan => {
export const getSubscriptionPlan = (token: string): UserPlan => {
const data = jwtDecode<Token>(token) || {};
return data['plan'] || 'free';
};
export const getUserProfilePlan = (token: string): UserPlan => {
const data = jwtDecode<Token>(token) || {};
let plan = data['plan'] || 'free';
if (plan === 'free') {
const purchasedQuota = data['storage_purchased_bytes'] || 0;
if (purchasedQuota > 0) {
plan = 'purchase';
}
}
return plan;
};
export const STORAGE_QUOTA_GRACE_BYTES = 10 * 1024 * 1024; // 10 MB grace
export const getStoragePlanData = (token: string) => {
+13 -9
View File
@@ -4,8 +4,8 @@
*/
export function diff(str1: string, str2: string) {
const lines1 = str1.split('\n');
const lines2 = str2.split('\n');
const lines1 = str1.split('\n').filter((line) => line.trim() !== '');
const lines2 = str2.split('\n').filter((line) => line.trim() !== '');
const lcs = longestCommonSubsequence(lines1, lines2);
@@ -17,17 +17,17 @@ export function diff(str1: string, str2: string) {
const addRange = (start: number, end: number) => (start === end ? `${start}` : `${start},${end}`);
while (i < lines1.length || j < lines2.length) {
if (k < lcs.length && i < lines1.length && lines1[i] === lcs[k]) {
if (k < lcs.length && i < lines1.length && trimCompare(lines1[i], lcs[k])) {
// common line
i++;
j++;
k++;
} else {
let delStart = i,
addStart = j;
let delStart = i;
let addStart = j;
while (i < lines1.length && (k >= lcs.length || lines1[i] !== lcs[k])) i++;
while (j < lines2.length && (k >= lcs.length || lines2[j] !== lcs[k])) j++;
while (i < lines1.length && (k >= lcs.length || !trimCompare(lines1[i], lcs[k]))) i++;
while (j < lines2.length && (k >= lcs.length || !trimCompare(lines2[j], lcs[k]))) j++;
const delRange = addRange(delStart + 1, i);
const addRangeStr = addRange(addStart + 1, j);
@@ -53,6 +53,10 @@ export function diff(str1: string, str2: string) {
return result.join('\n');
}
function trimCompare(a: string | undefined, b: string | undefined) {
return (a || '').trim() === (b || '').trim();
}
function longestCommonSubsequence(arr1: string[], arr2: string[]) {
const m = arr1.length;
const n = arr2.length;
@@ -62,7 +66,7 @@ function longestCommonSubsequence(arr1: string[], arr2: string[]) {
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (arr1[i - 1] === arr2[j - 1]) {
if (trimCompare(arr1[i - 1], arr2[j - 1])) {
dp[i]![j] = dp[i - 1]![j - 1] + 1;
} else {
dp[i]![j] = Math.max(dp[i - 1]![j], dp[i]![j - 1]);
@@ -75,7 +79,7 @@ function longestCommonSubsequence(arr1: string[], arr2: string[]) {
j = n;
while (i > 0 && j > 0) {
if (arr1[i - 1] === arr2[j - 1]) {
if (trimCompare(arr1[i - 1], arr2[j - 1])) {
lcs.unshift(arr1[i - 1]!);
i--;
j--;
+24
View File
@@ -0,0 +1,24 @@
import { AppService, FileItem } from '@/types/system';
export const copyFiles = async (appService: AppService, srcDir: string, dstDir: string) => {
let filesToCopy: FileItem[] = [];
try {
filesToCopy = await appService.readDirectory(srcDir, 'None');
} catch (error) {
throw new Error(`Dir ${srcDir} failed to read.`);
}
for (let i = 0; i < filesToCopy.length; i++) {
const file = filesToCopy[i]!;
const srcPath = `${srcDir}/${file.path}`;
const destPath = `${dstDir}/${file.path}`;
await appService.copyFile(srcPath, destPath, 'None');
}
const filesCopied = await appService.readDirectory(dstDir, 'None');
for (const file of filesToCopy) {
if (!filesCopied.find((f) => f.path === file.path && f.size === file.size)) {
throw new Error(`File ${file.path} failed to copy.`);
}
}
};
-5
View File
@@ -1,5 +0,0 @@
import { RELOAD_BEFORE_SAVED_TIMEOUT_MS } from '@/services/constants';
export const saveAndReload = async () => {
setTimeout(() => window.location.reload(), RELOAD_BEFORE_SAVED_TIMEOUT_MS);
};
+2
View File
@@ -391,6 +391,8 @@ export const getFootnoteStyles = () => `
}
a:any-link {
cursor: default;
pointer-events: none;
text-decoration: none;
padding: unset;
margin: unset;