forked from akai/readest
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cab757257e | |||
| fc8c9546b6 | |||
| 3cc41b7b71 | |||
| e65380aaf5 | |||
| 24b68ac046 | |||
| e0591ce41c | |||
| 7be6c07344 | |||
| f8ea7fc463 |
@@ -94,10 +94,9 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
|
||||
The Readest app is available for download! 🥳 🚀
|
||||
|
||||
- macOS : Search for "Readest" on the [macOS App Store][link-macos-appstore].
|
||||
- Windows / Linux: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- macOS / iOS / iPadOS : Search for "Readest" on the [App Store][link-appstore], also available on TestFlight for beta test (send your Apple ID to readestapp@gmail.com to request access).
|
||||
- Windows / Linux / Android: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- Web: Visit [https://web.readest.com][link-web-readest].
|
||||
- iOS / Android: coming soon 👀
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -149,8 +148,9 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
|
||||
### 4. Build for Development
|
||||
|
||||
```bash
|
||||
# Start development for the Tauri app
|
||||
pnpm tauri dev
|
||||
# or start development for the Web version only
|
||||
# or start development for the Web app
|
||||
pnpm dev-web
|
||||
```
|
||||
|
||||
@@ -241,7 +241,7 @@ The following fonts are utilized in this software, either bundled within the app
|
||||
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
|
||||
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
|
||||
[badge-hellogithub]: https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8a5b6ade2aee461a8bd94e59200682a7&claim_uid=eRLUbPOy2qZtDgw&theme=small
|
||||
[link-macos-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
|
||||
[link-web-readest]: https://web.readest.com
|
||||
[link-gh-releases]: https://github.com/readest/readest/releases
|
||||
|
||||
@@ -4,6 +4,8 @@ NEXT_PUBLIC_POSTHOG_HOST=YOUR_POSTHOG_HOST
|
||||
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
|
||||
|
||||
SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
|
||||
|
||||
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
|
||||
DEEPL_FREE_API_KEYS=YOUR_DEEPL_FREE_API_KEYS
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.17",
|
||||
"version": "0.9.18",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "التحقق من الرمز",
|
||||
"Sign in with Google": "تسجيل الدخول عبر Google",
|
||||
"Sign in with Apple": "تسجيل الدخول عبر Apple",
|
||||
"Sign in with GitHub": "تسجيل الدخول عبر GitHub"
|
||||
"Sign in with GitHub": "تسجيل الدخول عبر GitHub",
|
||||
"Account": "الحساب",
|
||||
"Cloud Storage": "التخزين السحابي",
|
||||
"Failed to delete user. Please try again later.": "فشل حذف المستخدم. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"Unlimited Offline/Online Reading": "قراءة غير محدودة بدون اتصال/متصلة بالإنترنت",
|
||||
"Unlimited Cloud Sync Devices": "أجهزة مزامنة سحابية غير محدودة",
|
||||
"Essential Text-to-Speech Voices": "أصوات أساسية لتحويل النص إلى كلام",
|
||||
"DeepL Free Access": "وصول مجاني إلى DeepL",
|
||||
"Community Support": "دعم المجتمع",
|
||||
"500 MB Cloud Sync Space": "مساحة مزامنة سحابية 500 ميجابايت",
|
||||
"Includes All Free Tier Benefits": "يتضمن جميع مزايا الفئة المجانية",
|
||||
"AI Summaries": "ملخصات الذكاء الاصطناعي",
|
||||
"AI Translations": "ترجمات الذكاء الاصطناعي",
|
||||
"Priority Support": "دعم ذو أولوية",
|
||||
"DeepL Pro Access": "وصول إلى DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "أصوات موسعة لتحويل النص إلى كلام",
|
||||
"2000 MB Cloud Sync Space": "مساحة مزامنة سحابية 2000 ميجابايت",
|
||||
"Includes All Plus Tier Benefits": "يتضمن جميع مزايا فئة Plus",
|
||||
"Unlimited AI Summaries": "ملخصات ذكاء اصطناعي غير محدودة",
|
||||
"Unlimited AI Translations": "ترجمات ذكاء اصطناعي غير محدودة",
|
||||
"Unlimited DeepL Pro Access": "وصول غير محدود إلى DeepL Pro",
|
||||
"Advanced AI Tools": "أدوات ذكاء اصطناعي متقدمة",
|
||||
"Early Feature Access": "وصول مبكر للميزات",
|
||||
"10 GB Cloud Sync Space": "مساحة مزامنة سحابية 10 جيجابايت",
|
||||
"Loading profile...": "جارٍ تحميل الملف الشخصي...",
|
||||
"Features": "الميزات",
|
||||
"Delete Account": "حذف الحساب",
|
||||
"Delete Your Account?": "حذف حسابك؟",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "لا يمكن التراجع عن هذا الإجراء. سيتم حذف جميع بياناتك في السحابة بشكل دائم.",
|
||||
"Delete Permanently": "حذف نهائي",
|
||||
"Free Tier": "الفئة المجانية",
|
||||
"Plus Tier": "فئة Plus",
|
||||
"Pro Tier": "فئة Pro"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Token überprüfen",
|
||||
"Sign in with Google": "Mit Google anmelden",
|
||||
"Sign in with Apple": "Mit Apple anmelden",
|
||||
"Sign in with GitHub": "Mit GitHub anmelden"
|
||||
"Sign in with GitHub": "Mit GitHub anmelden",
|
||||
"Account": "Konto",
|
||||
"Cloud Storage": "Cloud-Speicher",
|
||||
"Failed to delete user. Please try again later.": "Benutzer konnte nicht gelöscht werden. Bitte versuchen Sie es später erneut.",
|
||||
"Unlimited Offline/Online Reading": "Unbegrenztes Offline/Online Lesen",
|
||||
"Unlimited Cloud Sync Devices": "Unbegrenzte Cloud-Synchronisierungsgeräte",
|
||||
"Essential Text-to-Speech Voices": "Grundlegende Text-zu-Sprache-Stimmen",
|
||||
"DeepL Free Access": "Kostenloser DeepL-Zugang",
|
||||
"Community Support": "Community-Support",
|
||||
"500 MB Cloud Sync Space": "500 MB Cloud-Synchronisierungsspeicher",
|
||||
"Includes All Free Tier Benefits": "Beinhaltet alle Vorteile der kostenlosen Stufe",
|
||||
"AI Summaries": "KI-Zusammenfassungen",
|
||||
"AI Translations": "KI-Übersetzungen",
|
||||
"Priority Support": "Bevorzugter Support",
|
||||
"DeepL Pro Access": "DeepL Pro-Zugang",
|
||||
"Expanded Text-to-Speech Voices": "Erweiterte Text-zu-Sprache-Stimmen",
|
||||
"2000 MB Cloud Sync Space": "2000 MB Cloud-Synchronisierungsspeicher",
|
||||
"Includes All Plus Tier Benefits": "Beinhaltet alle Vorteile der Plus-Stufe",
|
||||
"Unlimited AI Summaries": "Unbegrenzte KI-Zusammenfassungen",
|
||||
"Unlimited AI Translations": "Unbegrenzte KI-Übersetzungen",
|
||||
"Unlimited DeepL Pro Access": "Unbegrenzter DeepL Pro-Zugang",
|
||||
"Advanced AI Tools": "Fortgeschrittene KI-Tools",
|
||||
"Early Feature Access": "Früher Zugang zu neuen Funktionen",
|
||||
"10 GB Cloud Sync Space": "10 GB Cloud-Synchronisierungsspeicher",
|
||||
"Loading profile...": "Profil wird geladen...",
|
||||
"Features": "Funktionen",
|
||||
"Delete Account": "Konto löschen",
|
||||
"Delete Your Account?": "Ihr Konto löschen?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Diese Aktion kann nicht rückgängig gemacht werden. Alle Ihre Daten in der Cloud werden dauerhaft gelöscht.",
|
||||
"Delete Permanently": "Dauerhaft löschen",
|
||||
"Free Tier": "Kostenlose Stufe",
|
||||
"Plus Tier": "Plus-Stufe",
|
||||
"Pro Tier": "Pro-Stufe"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Επαλήθευση κωδικού",
|
||||
"Sign in with Google": "Σύνδεση με Google",
|
||||
"Sign in with Apple": "Σύνδεση με Apple",
|
||||
"Sign in with GitHub": "Σύνδεση με GitHub"
|
||||
"Sign in with GitHub": "Σύνδεση με GitHub",
|
||||
"Account": "Λογαριασμός",
|
||||
"Cloud Storage": "Αποθηκευτικός χώρος Cloud",
|
||||
"Failed to delete user. Please try again later.": "Αποτυχία διαγραφής χρήστη. Παρακαλώ δοκιμάστε ξανά αργότερα.",
|
||||
"Unlimited Offline/Online Reading": "Απεριόριστη ανάγνωση εκτός/εντός σύνδεσης",
|
||||
"Unlimited Cloud Sync Devices": "Απεριόριστες συσκευές συγχρονισμού Cloud",
|
||||
"Essential Text-to-Speech Voices": "Βασικές φωνές μετατροπής κειμένου σε ομιλία",
|
||||
"DeepL Free Access": "Δωρεάν πρόσβαση DeepL",
|
||||
"Community Support": "Υποστήριξη κοινότητας",
|
||||
"500 MB Cloud Sync Space": "500 MB χώρος συγχρονισμού Cloud",
|
||||
"Includes All Free Tier Benefits": "Περιλαμβάνει όλα τα οφέλη της δωρεάν βαθμίδας",
|
||||
"AI Summaries": "Περιλήψεις AI",
|
||||
"AI Translations": "Μεταφράσεις AI",
|
||||
"Priority Support": "Υποστήριξη προτεραιότητας",
|
||||
"DeepL Pro Access": "Πρόσβαση DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Διευρυμένες φωνές μετατροπής κειμένου σε ομιλία",
|
||||
"2000 MB Cloud Sync Space": "2000 MB χώρος συγχρονισμού Cloud",
|
||||
"Includes All Plus Tier Benefits": "Περιλαμβάνει όλα τα οφέλη της βαθμίδας Plus",
|
||||
"Unlimited AI Summaries": "Απεριόριστες περιλήψεις AI",
|
||||
"Unlimited AI Translations": "Απεριόριστες μεταφράσεις AI",
|
||||
"Unlimited DeepL Pro Access": "Απεριόριστη πρόσβαση DeepL Pro",
|
||||
"Advanced AI Tools": "Προηγμένα εργαλεία AI",
|
||||
"Early Feature Access": "Πρώιμη πρόσβαση σε λειτουργίες",
|
||||
"10 GB Cloud Sync Space": "10 GB χώρος συγχρονισμού Cloud",
|
||||
"Loading profile...": "Φόρτωση προφίλ...",
|
||||
"Features": "Χαρακτηριστικά",
|
||||
"Delete Account": "Διαγραφή λογαριασμού",
|
||||
"Delete Your Account?": "Διαγραφή του λογαριασμού σας;",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. Όλα τα δεδομένα σας στο cloud θα διαγραφούν μόνιμα.",
|
||||
"Delete Permanently": "Μόνιμη διαγραφή",
|
||||
"Free Tier": "Δωρεάν βαθμίδα",
|
||||
"Plus Tier": "Βαθμίδα Plus",
|
||||
"Pro Tier": "Βαθμίδα Pro"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Verificar código",
|
||||
"Sign in with Google": "Iniciar sesión con Google",
|
||||
"Sign in with Apple": "Iniciar sesión con Apple",
|
||||
"Sign in with GitHub": "Iniciar sesión con GitHub"
|
||||
"Sign in with GitHub": "Iniciar sesión con GitHub",
|
||||
"Account": "Cuenta",
|
||||
"Cloud Storage": "Almacenamiento en la nube",
|
||||
"Failed to delete user. Please try again later.": "Error al eliminar usuario. Por favor, inténtelo de nuevo más tarde.",
|
||||
"Unlimited Offline/Online Reading": "Lectura sin límites en línea/sin conexión",
|
||||
"Unlimited Cloud Sync Devices": "Sincronización ilimitada de dispositivos en la nube",
|
||||
"Essential Text-to-Speech Voices": "Voces esenciales de texto a voz",
|
||||
"DeepL Free Access": "Acceso gratuito a DeepL",
|
||||
"Community Support": "Soporte comunitario",
|
||||
"500 MB Cloud Sync Space": "500 MB de espacio de sincronización en la nube",
|
||||
"Includes All Free Tier Benefits": "Incluye todos los beneficios del nivel gratuito",
|
||||
"AI Summaries": "Resúmenes con IA",
|
||||
"AI Translations": "Traducciones con IA",
|
||||
"Priority Support": "Soporte prioritario",
|
||||
"DeepL Pro Access": "Acceso a DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Voces ampliadas de texto a voz",
|
||||
"2000 MB Cloud Sync Space": "2000 MB de espacio de sincronización en la nube",
|
||||
"Includes All Plus Tier Benefits": "Incluye todos los beneficios del nivel Plus",
|
||||
"Unlimited AI Summaries": "Resúmenes ilimitados con IA",
|
||||
"Unlimited AI Translations": "Traducciones ilimitadas con IA",
|
||||
"Unlimited DeepL Pro Access": "Acceso ilimitado a DeepL Pro",
|
||||
"Advanced AI Tools": "Herramientas avanzadas de IA",
|
||||
"Early Feature Access": "Acceso anticipado a funciones",
|
||||
"10 GB Cloud Sync Space": "10 GB de espacio de sincronización en la nube",
|
||||
"Loading profile...": "Cargando perfil...",
|
||||
"Features": "Características",
|
||||
"Delete Account": "Eliminar cuenta",
|
||||
"Delete Your Account?": "¿Eliminar tu cuenta?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Esta acción no se puede deshacer. Todos tus datos en la nube se eliminarán permanentemente.",
|
||||
"Delete Permanently": "Eliminar permanentemente",
|
||||
"Free Tier": "Nivel gratuito",
|
||||
"Plus Tier": "Nivel Plus",
|
||||
"Pro Tier": "Nivel Pro"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Vérifier le code",
|
||||
"Sign in with Google": "Se connecter avec Google",
|
||||
"Sign in with Apple": "Se connecter avec Apple",
|
||||
"Sign in with GitHub": "Se connecter avec GitHub"
|
||||
"Sign in with GitHub": "Se connecter avec GitHub",
|
||||
"Account": "Compte",
|
||||
"Cloud Storage": "Stockage cloud",
|
||||
"Failed to delete user. Please try again later.": "Échec de la suppression de l'utilisateur. Veuillez réessayer plus tard.",
|
||||
"Unlimited Offline/Online Reading": "Lecture illimitée hors ligne/en ligne",
|
||||
"Unlimited Cloud Sync Devices": "Synchronisation cloud sur appareils illimitée",
|
||||
"Essential Text-to-Speech Voices": "Voix de synthèse vocale essentielles",
|
||||
"DeepL Free Access": "Accès gratuit à DeepL",
|
||||
"Community Support": "Support communautaire",
|
||||
"500 MB Cloud Sync Space": "500 Mo d'espace de synchronisation cloud",
|
||||
"Includes All Free Tier Benefits": "Inclut tous les avantages du niveau gratuit",
|
||||
"AI Summaries": "Résumés par IA",
|
||||
"AI Translations": "Traductions par IA",
|
||||
"Priority Support": "Support prioritaire",
|
||||
"DeepL Pro Access": "Accès à DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Voix de synthèse vocale étendues",
|
||||
"2000 MB Cloud Sync Space": "2000 Mo d'espace de synchronisation cloud",
|
||||
"Includes All Plus Tier Benefits": "Inclut tous les avantages du niveau Plus",
|
||||
"Unlimited AI Summaries": "Résumés par IA illimités",
|
||||
"Unlimited AI Translations": "Traductions par IA illimitées",
|
||||
"Unlimited DeepL Pro Access": "Accès illimité à DeepL Pro",
|
||||
"Advanced AI Tools": "Outils IA avancés",
|
||||
"Early Feature Access": "Accès anticipé aux fonctionnalités",
|
||||
"10 GB Cloud Sync Space": "10 Go d'espace de synchronisation cloud",
|
||||
"Loading profile...": "Chargement du profil...",
|
||||
"Features": "Fonctionnalités",
|
||||
"Delete Account": "Supprimer le compte",
|
||||
"Delete Your Account?": "Supprimer votre compte ?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Cette action est irréversible. Toutes vos données dans le cloud seront définitivement supprimées.",
|
||||
"Delete Permanently": "Supprimer définitivement",
|
||||
"Free Tier": "Niveau gratuit",
|
||||
"Plus Tier": "Niveau Plus",
|
||||
"Pro Tier": "Niveau Pro"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "टोकन सत्यापित करें",
|
||||
"Sign in with Google": "Google के साथ साइन इन करें",
|
||||
"Sign in with Apple": "Apple के साथ साइन इन करें",
|
||||
"Sign in with GitHub": "GitHub के साथ साइन इन करें"
|
||||
"Sign in with GitHub": "GitHub के साथ साइन इन करें",
|
||||
"Account": "खाता",
|
||||
"Cloud Storage": "क्लाउड स्टोरेज",
|
||||
"Failed to delete user. Please try again later.": "उपयोगकर्ता को हटाने में विफल। कृपया बाद में पुनः प्रयास करें।",
|
||||
"Unlimited Offline/Online Reading": "असीमित ऑफलाइन/ऑनलाइन पढ़ना",
|
||||
"Unlimited Cloud Sync Devices": "असीमित क्लाउड सिंक उपकरण",
|
||||
"Essential Text-to-Speech Voices": "आवश्यक टेक्स्ट-टू-स्पीच आवाज़ें",
|
||||
"DeepL Free Access": "DeepL मुफ्त पहुंच",
|
||||
"Community Support": "सामुदायिक सहायता",
|
||||
"500 MB Cloud Sync Space": "500 MB क्लाउड सिंक स्पेस",
|
||||
"Includes All Free Tier Benefits": "सभी फ्री टियर लाभ शामिल हैं",
|
||||
"AI Summaries": "AI सारांश",
|
||||
"AI Translations": "AI अनुवाद",
|
||||
"Priority Support": "प्राथमिकता सहायता",
|
||||
"DeepL Pro Access": "DeepL प्रो पहुंच",
|
||||
"Expanded Text-to-Speech Voices": "विस्तारित टेक्स्ट-टू-स्पीच आवाज़ें",
|
||||
"2000 MB Cloud Sync Space": "2000 MB क्लाउड सिंक स्पेस",
|
||||
"Includes All Plus Tier Benefits": "सभी प्लस टियर लाभ शामिल हैं",
|
||||
"Unlimited AI Summaries": "असीमित AI सारांश",
|
||||
"Unlimited AI Translations": "असीमित AI अनुवाद",
|
||||
"Unlimited DeepL Pro Access": "असीमित DeepL प्रो पहुंच",
|
||||
"Advanced AI Tools": "उन्नत AI टूल्स",
|
||||
"Early Feature Access": "प्रारंभिक फीचर पहुंच",
|
||||
"10 GB Cloud Sync Space": "10 GB क्लाउड सिंक स्पेस",
|
||||
"Loading profile...": "प्रोफ़ाइल लोड हो रहा है...",
|
||||
"Features": "विशेषताएं",
|
||||
"Delete Account": "खाता हटाएं",
|
||||
"Delete Your Account?": "अपना खाता हटाएं?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "यह क्रिया पूर्ववत नहीं की जा सकती। क्लाउड में आपका सभी डेटा स्थायी रूप से हटा दिया जाएगा।",
|
||||
"Delete Permanently": "स्थायी रूप से हटाएं",
|
||||
"Free Tier": "फ्री टियर",
|
||||
"Plus Tier": "प्लस टियर",
|
||||
"Pro Tier": "प्रो टियर"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Verifikasi token",
|
||||
"Sign in with Google": "Masuk dengan Google",
|
||||
"Sign in with Apple": "Masuk dengan Apple",
|
||||
"Sign in with GitHub": "Masuk dengan GitHub"
|
||||
"Sign in with GitHub": "Masuk dengan GitHub",
|
||||
"Account": "Akun",
|
||||
"Cloud Storage": "Penyimpanan Cloud",
|
||||
"Failed to delete user. Please try again later.": "Gagal menghapus pengguna. Silakan coba lagi nanti.",
|
||||
"Unlimited Offline/Online Reading": "Membaca Offline/Online Tanpa Batas",
|
||||
"Unlimited Cloud Sync Devices": "Perangkat Sinkronisasi Cloud Tanpa Batas",
|
||||
"Essential Text-to-Speech Voices": "Suara Text-to-Speech Esensial",
|
||||
"DeepL Free Access": "Akses DeepL Gratis",
|
||||
"Community Support": "Dukungan Komunitas",
|
||||
"500 MB Cloud Sync Space": "Ruang Sinkronisasi Cloud 500 MB",
|
||||
"Includes All Free Tier Benefits": "Termasuk Semua Manfaat Tingkat Gratis",
|
||||
"AI Summaries": "Ringkasan AI",
|
||||
"AI Translations": "Terjemahan AI",
|
||||
"Priority Support": "Dukungan Prioritas",
|
||||
"DeepL Pro Access": "Akses DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Suara Text-to-Speech yang Diperluas",
|
||||
"2000 MB Cloud Sync Space": "Ruang Sinkronisasi Cloud 2000 MB",
|
||||
"Includes All Plus Tier Benefits": "Termasuk Semua Manfaat Tingkat Plus",
|
||||
"Unlimited AI Summaries": "Ringkasan AI Tanpa Batas",
|
||||
"Unlimited AI Translations": "Terjemahan AI Tanpa Batas",
|
||||
"Unlimited DeepL Pro Access": "Akses DeepL Pro Tanpa Batas",
|
||||
"Advanced AI Tools": "Alat AI Lanjutan",
|
||||
"Early Feature Access": "Akses Fitur Awal",
|
||||
"10 GB Cloud Sync Space": "Ruang Sinkronisasi Cloud 10 GB",
|
||||
"Loading profile...": "Memuat profil...",
|
||||
"Features": "Fitur",
|
||||
"Delete Account": "Hapus Akun",
|
||||
"Delete Your Account?": "Hapus Akun Anda?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Tindakan ini tidak dapat dibatalkan. Semua data Anda di cloud akan dihapus secara permanen.",
|
||||
"Delete Permanently": "Hapus Secara Permanen",
|
||||
"Free Tier": "Tingkat Gratis",
|
||||
"Plus Tier": "Tingkat Plus",
|
||||
"Pro Tier": "Tingkat Pro"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Verifica token",
|
||||
"Sign in with Google": "Accedi con Google",
|
||||
"Sign in with Apple": "Accedi con Apple",
|
||||
"Sign in with GitHub": "Accedi con GitHub"
|
||||
"Sign in with GitHub": "Accedi con GitHub",
|
||||
"Account": "Account",
|
||||
"Cloud Storage": "Archiviazione Cloud",
|
||||
"Failed to delete user. Please try again later.": "Impossibile eliminare l'utente. Riprova più tardi.",
|
||||
"Unlimited Offline/Online Reading": "Lettura offline/online illimitata",
|
||||
"Unlimited Cloud Sync Devices": "Sincronizzazione cloud su dispositivi illimitati",
|
||||
"Essential Text-to-Speech Voices": "Voci essenziali di sintesi vocale",
|
||||
"DeepL Free Access": "Accesso gratuito a DeepL",
|
||||
"Community Support": "Supporto della community",
|
||||
"500 MB Cloud Sync Space": "500 MB di spazio di sincronizzazione cloud",
|
||||
"Includes All Free Tier Benefits": "Include tutti i vantaggi del piano gratuito",
|
||||
"AI Summaries": "Riassunti AI",
|
||||
"AI Translations": "Traduzioni AI",
|
||||
"Priority Support": "Supporto prioritario",
|
||||
"DeepL Pro Access": "Accesso a DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Voci di sintesi vocale ampliate",
|
||||
"2000 MB Cloud Sync Space": "2000 MB di spazio di sincronizzazione cloud",
|
||||
"Includes All Plus Tier Benefits": "Include tutti i vantaggi del piano Plus",
|
||||
"Unlimited AI Summaries": "Riassunti AI illimitati",
|
||||
"Unlimited AI Translations": "Traduzioni AI illimitate",
|
||||
"Unlimited DeepL Pro Access": "Accesso illimitato a DeepL Pro",
|
||||
"Advanced AI Tools": "Strumenti AI avanzati",
|
||||
"Early Feature Access": "Accesso anticipato alle funzionalità",
|
||||
"10 GB Cloud Sync Space": "10 GB di spazio di sincronizzazione cloud",
|
||||
"Loading profile...": "Caricamento profilo...",
|
||||
"Features": "Funzionalità",
|
||||
"Delete Account": "Elimina account",
|
||||
"Delete Your Account?": "Eliminare il tuo account?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Questa azione non può essere annullata. Tutti i tuoi dati nel cloud verranno eliminati definitivamente.",
|
||||
"Delete Permanently": "Elimina definitivamente",
|
||||
"Free Tier": "Piano gratuito",
|
||||
"Plus Tier": "Piano Plus",
|
||||
"Pro Tier": "Piano Pro"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "トークンを確認",
|
||||
"Sign in with Google": "Googleでサインイン",
|
||||
"Sign in with Apple": "Appleでサインイン",
|
||||
"Sign in with GitHub": "GitHubでサインイン"
|
||||
"Sign in with GitHub": "GitHubでサインイン",
|
||||
"Account": "アカウント",
|
||||
"Cloud Storage": "クラウドストレージ",
|
||||
"Failed to delete user. Please try again later.": "ユーザーの削除に失敗しました。後ほど再試行してください。",
|
||||
"Unlimited Offline/Online Reading": "無制限のオフライン/オンライン読書",
|
||||
"Unlimited Cloud Sync Devices": "無制限のクラウド同期デバイス",
|
||||
"Essential Text-to-Speech Voices": "基本的な音声合成ボイス",
|
||||
"DeepL Free Access": "DeepL無料アクセス",
|
||||
"Community Support": "コミュニティサポート",
|
||||
"500 MB Cloud Sync Space": "500 MBのクラウド同期スペース",
|
||||
"Includes All Free Tier Benefits": "無料プランの全特典を含む",
|
||||
"AI Summaries": "AI要約",
|
||||
"AI Translations": "AI翻訳",
|
||||
"Priority Support": "優先サポート",
|
||||
"DeepL Pro Access": "DeepL Proアクセス",
|
||||
"Expanded Text-to-Speech Voices": "拡張された音声合成ボイス",
|
||||
"2000 MB Cloud Sync Space": "2000 MBのクラウド同期スペース",
|
||||
"Includes All Plus Tier Benefits": "Plusプランの全特典を含む",
|
||||
"Unlimited AI Summaries": "無制限のAI要約",
|
||||
"Unlimited AI Translations": "無制限のAI翻訳",
|
||||
"Unlimited DeepL Pro Access": "無制限のDeepL Proアクセス",
|
||||
"Advanced AI Tools": "高度なAIツール",
|
||||
"Early Feature Access": "新機能への早期アクセス",
|
||||
"10 GB Cloud Sync Space": "10 GBのクラウド同期スペース",
|
||||
"Loading profile...": "プロフィール読み込み中...",
|
||||
"Features": "機能",
|
||||
"Delete Account": "アカウント削除",
|
||||
"Delete Your Account?": "アカウントを削除しますか?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "この操作は元に戻せません。クラウド上のすべてのデータが完全に削除されます。",
|
||||
"Delete Permanently": "完全に削除",
|
||||
"Free Tier": "無料プラン",
|
||||
"Plus Tier": "Plusプラン",
|
||||
"Pro Tier": "Proプラン"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "토큰 확인",
|
||||
"Sign in with Google": "Google로 로그인",
|
||||
"Sign in with Apple": "Apple로 로그인",
|
||||
"Sign in with GitHub": "GitHub로 로그인"
|
||||
"Sign in with GitHub": "GitHub로 로그인",
|
||||
"Account": "계정",
|
||||
"Cloud Storage": "클라우드 스토리지",
|
||||
"Failed to delete user. Please try again later.": "사용자 삭제에 실패했습니다. 나중에 다시 시도해 주세요.",
|
||||
"Unlimited Offline/Online Reading": "무제한 오프라인/온라인 읽기",
|
||||
"Unlimited Cloud Sync Devices": "무제한 클라우드 동기화 기기",
|
||||
"Essential Text-to-Speech Voices": "필수 텍스트 음성 변환 목소리",
|
||||
"DeepL Free Access": "DeepL 무료 접근",
|
||||
"Community Support": "커뮤니티 지원",
|
||||
"500 MB Cloud Sync Space": "500 MB 클라우드 동기화 공간",
|
||||
"Includes All Free Tier Benefits": "무료 등급의 모든 혜택 포함",
|
||||
"AI Summaries": "AI 요약",
|
||||
"AI Translations": "AI 번역",
|
||||
"Priority Support": "우선 지원",
|
||||
"DeepL Pro Access": "DeepL 프로 접근",
|
||||
"Expanded Text-to-Speech Voices": "확장된 텍스트 음성 변환 목소리",
|
||||
"2000 MB Cloud Sync Space": "2000 MB 클라우드 동기화 공간",
|
||||
"Includes All Plus Tier Benefits": "플러스 등급의 모든 혜택 포함",
|
||||
"Unlimited AI Summaries": "무제한 AI 요약",
|
||||
"Unlimited AI Translations": "무제한 AI 번역",
|
||||
"Unlimited DeepL Pro Access": "무제한 DeepL 프로 접근",
|
||||
"Advanced AI Tools": "고급 AI 도구",
|
||||
"Early Feature Access": "신기능 조기 접근",
|
||||
"10 GB Cloud Sync Space": "10 GB 클라우드 동기화 공간",
|
||||
"Loading profile...": "프로필 로딩 중...",
|
||||
"Features": "기능",
|
||||
"Delete Account": "계정 삭제",
|
||||
"Delete Your Account?": "계정을 삭제하시겠습니까?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "이 작업은 취소할 수 없습니다. 클라우드에 있는 모든 데이터가 영구적으로 삭제됩니다.",
|
||||
"Delete Permanently": "영구 삭제",
|
||||
"Free Tier": "무료 등급",
|
||||
"Plus Tier": "플러스 등급",
|
||||
"Pro Tier": "프로 등급"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Weryfikacja tokena",
|
||||
"Sign in with Google": "Zaloguj się za pomocą Google",
|
||||
"Sign in with Apple": "Zaloguj się za pomocą Apple",
|
||||
"Sign in with GitHub": "Zaloguj się za pomocą GitHub"
|
||||
"Sign in with GitHub": "Zaloguj się za pomocą GitHub",
|
||||
"Account": "Konto",
|
||||
"Cloud Storage": "Przestrzeń w chmurze",
|
||||
"Failed to delete user. Please try again later.": "Nie udało się usunąć użytkownika. Spróbuj ponownie później.",
|
||||
"Unlimited Offline/Online Reading": "Nieograniczone czytanie offline/online",
|
||||
"Unlimited Cloud Sync Devices": "Nieograniczona liczba urządzeń synchronizowanych w chmurze",
|
||||
"Essential Text-to-Speech Voices": "Podstawowe głosy zamiany tekstu na mowę",
|
||||
"DeepL Free Access": "Darmowy dostęp do DeepL",
|
||||
"Community Support": "Wsparcie społeczności",
|
||||
"500 MB Cloud Sync Space": "500 MB przestrzeni synchronizacji w chmurze",
|
||||
"Includes All Free Tier Benefits": "Zawiera wszystkie korzyści darmowego planu",
|
||||
"AI Summaries": "Podsumowania AI",
|
||||
"AI Translations": "Tłumaczenia AI",
|
||||
"Priority Support": "Priorytetowe wsparcie",
|
||||
"DeepL Pro Access": "Dostęp do DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Rozszerzone głosy zamiany tekstu na mowę",
|
||||
"2000 MB Cloud Sync Space": "2000 MB przestrzeni synchronizacji w chmurze",
|
||||
"Includes All Plus Tier Benefits": "Zawiera wszystkie korzyści planu Plus",
|
||||
"Unlimited AI Summaries": "Nieograniczone podsumowania AI",
|
||||
"Unlimited AI Translations": "Nieograniczone tłumaczenia AI",
|
||||
"Unlimited DeepL Pro Access": "Nieograniczony dostęp do DeepL Pro",
|
||||
"Advanced AI Tools": "Zaawansowane narzędzia AI",
|
||||
"Early Feature Access": "Wczesny dostęp do nowych funkcji",
|
||||
"10 GB Cloud Sync Space": "10 GB przestrzeni synchronizacji w chmurze",
|
||||
"Loading profile...": "Ładowanie profilu...",
|
||||
"Features": "Funkcje",
|
||||
"Delete Account": "Usuń konto",
|
||||
"Delete Your Account?": "Usunąć Twoje konto?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Tej operacji nie można cofnąć. Wszystkie Twoje dane w chmurze zostaną trwale usunięte.",
|
||||
"Delete Permanently": "Usuń trwale",
|
||||
"Free Tier": "Plan darmowy",
|
||||
"Plus Tier": "Plan Plus",
|
||||
"Pro Tier": "Plan Pro"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Verificar token",
|
||||
"Sign in with Google": "Entrar com o Google",
|
||||
"Sign in with Apple": "Entrar com o Apple",
|
||||
"Sign in with GitHub": "Entrar com o GitHub"
|
||||
"Sign in with GitHub": "Entrar com o GitHub",
|
||||
"Account": "Conta",
|
||||
"Cloud Storage": "Armazenamento na nuvem",
|
||||
"Failed to delete user. Please try again later.": "Falha ao excluir usuário. Por favor, tente novamente mais tarde.",
|
||||
"Unlimited Offline/Online Reading": "Leitura offline/online ilimitada",
|
||||
"Unlimited Cloud Sync Devices": "Sincronização ilimitada de dispositivos na nuvem",
|
||||
"Essential Text-to-Speech Voices": "Vozes essenciais de texto para fala",
|
||||
"DeepL Free Access": "Acesso gratuito ao DeepL",
|
||||
"Community Support": "Suporte da comunidade",
|
||||
"500 MB Cloud Sync Space": "500 MB de espaço de sincronização na nuvem",
|
||||
"Includes All Free Tier Benefits": "Inclui todos os benefícios do plano gratuito",
|
||||
"AI Summaries": "Resumos de IA",
|
||||
"AI Translations": "Traduções de IA",
|
||||
"Priority Support": "Suporte prioritário",
|
||||
"DeepL Pro Access": "Acesso ao DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Vozes ampliadas de texto para fala",
|
||||
"2000 MB Cloud Sync Space": "2000 MB de espaço de sincronização na nuvem",
|
||||
"Includes All Plus Tier Benefits": "Inclui todos os benefícios do plano Plus",
|
||||
"Unlimited AI Summaries": "Resumos de IA ilimitados",
|
||||
"Unlimited AI Translations": "Traduções de IA ilimitadas",
|
||||
"Unlimited DeepL Pro Access": "Acesso ilimitado ao DeepL Pro",
|
||||
"Advanced AI Tools": "Ferramentas avançadas de IA",
|
||||
"Early Feature Access": "Acesso antecipado a recursos",
|
||||
"10 GB Cloud Sync Space": "10 GB de espaço de sincronização na nuvem",
|
||||
"Loading profile...": "Carregando perfil...",
|
||||
"Features": "Recursos",
|
||||
"Delete Account": "Excluir conta",
|
||||
"Delete Your Account?": "Excluir sua conta?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Esta ação não pode ser desfeita. Todos os seus dados na nuvem serão excluídos permanentemente.",
|
||||
"Delete Permanently": "Excluir permanentemente",
|
||||
"Free Tier": "Plano gratuito",
|
||||
"Plus Tier": "Plano Plus",
|
||||
"Pro Tier": "Plano Pro"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Проверить токен",
|
||||
"Sign in with Google": "Войти через Google",
|
||||
"Sign in with Apple": "Войти через Apple",
|
||||
"Sign in with GitHub": "Войти через GitHub"
|
||||
"Sign in with GitHub": "Войти через GitHub",
|
||||
"Account": "Аккаунт",
|
||||
"Cloud Storage": "Облачное хранилище",
|
||||
"Failed to delete user. Please try again later.": "Не удалось удалить пользователя. Пожалуйста, повторите попытку позже.",
|
||||
"Unlimited Offline/Online Reading": "Безлимитное чтение офлайн/онлайн",
|
||||
"Unlimited Cloud Sync Devices": "Неограниченное количество устройств для облачной синхронизации",
|
||||
"Essential Text-to-Speech Voices": "Основные голоса для преобразования текста в речь",
|
||||
"DeepL Free Access": "Бесплатный доступ к DeepL",
|
||||
"Community Support": "Поддержка сообщества",
|
||||
"500 MB Cloud Sync Space": "500 МБ облачного пространства для синхронизации",
|
||||
"Includes All Free Tier Benefits": "Включает все преимущества бесплатного тарифа",
|
||||
"AI Summaries": "AI-резюме",
|
||||
"AI Translations": "AI-переводы",
|
||||
"Priority Support": "Приоритетная поддержка",
|
||||
"DeepL Pro Access": "Доступ к DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Расширенный набор голосов для преобразования текста в речь",
|
||||
"2000 MB Cloud Sync Space": "2000 МБ облачного пространства для синхронизации",
|
||||
"Includes All Plus Tier Benefits": "Включает все преимущества тарифа Plus",
|
||||
"Unlimited AI Summaries": "Безлимитные AI-резюме",
|
||||
"Unlimited AI Translations": "Безлимитные AI-переводы",
|
||||
"Unlimited DeepL Pro Access": "Неограниченный доступ к DeepL Pro",
|
||||
"Advanced AI Tools": "Продвинутые AI-инструменты",
|
||||
"Early Feature Access": "Ранний доступ к новым функциям",
|
||||
"10 GB Cloud Sync Space": "10 ГБ облачного пространства для синхронизации",
|
||||
"Loading profile...": "Загрузка профиля...",
|
||||
"Features": "Функции",
|
||||
"Delete Account": "Удалить аккаунт",
|
||||
"Delete Your Account?": "Удалить ваш аккаунт?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Это действие нельзя отменить. Все ваши данные в облаке будут удалены безвозвратно.",
|
||||
"Delete Permanently": "Удалить навсегда",
|
||||
"Free Tier": "Бесплатный тариф",
|
||||
"Plus Tier": "Тариф Plus",
|
||||
"Pro Tier": "Тариф Pro"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Token'ı doğrula",
|
||||
"Sign in with Google": "Google ile giriş yap",
|
||||
"Sign in with Apple": "Apple ile giriş yap",
|
||||
"Sign in with GitHub": "GitHub ile giriş yap"
|
||||
"Sign in with GitHub": "GitHub ile giriş yap",
|
||||
"Account": "Hesap",
|
||||
"Cloud Storage": "Bulut Depolama",
|
||||
"Failed to delete user. Please try again later.": "Kullanıcı silinemedi. Lütfen daha sonra tekrar deneyin.",
|
||||
"Unlimited Offline/Online Reading": "Sınırsız Çevrimdışı/Çevrimiçi Okuma",
|
||||
"Unlimited Cloud Sync Devices": "Sınırsız Bulut Senkronizasyon Cihazları",
|
||||
"Essential Text-to-Speech Voices": "Temel Metinden Sese Dönüştürme Sesleri",
|
||||
"DeepL Free Access": "DeepL Ücretsiz Erişim",
|
||||
"Community Support": "Topluluk Desteği",
|
||||
"500 MB Cloud Sync Space": "500 MB Bulut Senkronizasyon Alanı",
|
||||
"Includes All Free Tier Benefits": "Tüm Ücretsiz Paket Avantajlarını İçerir",
|
||||
"AI Summaries": "Yapay Zeka Özetleri",
|
||||
"AI Translations": "Yapay Zeka Çevirileri",
|
||||
"Priority Support": "Öncelikli Destek",
|
||||
"DeepL Pro Access": "DeepL Pro Erişimi",
|
||||
"Expanded Text-to-Speech Voices": "Genişletilmiş Metinden Sese Dönüştürme Sesleri",
|
||||
"2000 MB Cloud Sync Space": "2000 MB Bulut Senkronizasyon Alanı",
|
||||
"Includes All Plus Tier Benefits": "Tüm Plus Paket Avantajlarını İçerir",
|
||||
"Unlimited AI Summaries": "Sınırsız Yapay Zeka Özetleri",
|
||||
"Unlimited AI Translations": "Sınırsız Yapay Zeka Çevirileri",
|
||||
"Unlimited DeepL Pro Access": "Sınırsız DeepL Pro Erişimi",
|
||||
"Advanced AI Tools": "Gelişmiş Yapay Zeka Araçları",
|
||||
"Early Feature Access": "Erken Özellik Erişimi",
|
||||
"10 GB Cloud Sync Space": "10 GB Bulut Senkronizasyon Alanı",
|
||||
"Loading profile...": "Profil yükleniyor...",
|
||||
"Features": "Özellikler",
|
||||
"Delete Account": "Hesabı Sil",
|
||||
"Delete Your Account?": "Hesabınızı Silmek İstiyor musunuz?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Bu işlem geri alınamaz. Buluttaki tüm verileriniz kalıcı olarak silinecektir.",
|
||||
"Delete Permanently": "Kalıcı Olarak Sil",
|
||||
"Free Tier": "Ücretsiz Paket",
|
||||
"Plus Tier": "Plus Paket",
|
||||
"Pro Tier": "Pro Paket"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Перевірити токен",
|
||||
"Sign in with Google": "Увійти через Google",
|
||||
"Sign in with Apple": "Увійти через Apple",
|
||||
"Sign in with GitHub": "Увійти через GitHub"
|
||||
"Sign in with GitHub": "Увійти через GitHub",
|
||||
"Account": "Обліковий запис",
|
||||
"Cloud Storage": "Хмарне сховище",
|
||||
"Failed to delete user. Please try again later.": "Не вдалося видалити користувача. Будь ласка, спробуйте пізніше.",
|
||||
"Unlimited Offline/Online Reading": "Необмежене читання офлайн/онлайн",
|
||||
"Unlimited Cloud Sync Devices": "Необмежена кількість пристроїв для хмарної синхронізації",
|
||||
"Essential Text-to-Speech Voices": "Основні голоси для перетворення тексту в мовлення",
|
||||
"DeepL Free Access": "Безкоштовний доступ до DeepL",
|
||||
"Community Support": "Підтримка спільноти",
|
||||
"500 MB Cloud Sync Space": "500 МБ простору для хмарної синхронізації",
|
||||
"Includes All Free Tier Benefits": "Включає всі переваги безкоштовного тарифу",
|
||||
"AI Summaries": "AI-резюме",
|
||||
"AI Translations": "AI-переклади",
|
||||
"Priority Support": "Пріоритетна підтримка",
|
||||
"DeepL Pro Access": "Доступ до DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Розширений набір голосів для перетворення тексту в мовлення",
|
||||
"2000 MB Cloud Sync Space": "2000 МБ простору для хмарної синхронізації",
|
||||
"Includes All Plus Tier Benefits": "Включає всі переваги тарифу Plus",
|
||||
"Unlimited AI Summaries": "Необмежені AI-резюме",
|
||||
"Unlimited AI Translations": "Необмежені AI-переклади",
|
||||
"Unlimited DeepL Pro Access": "Необмежений доступ до DeepL Pro",
|
||||
"Advanced AI Tools": "Розширені AI-інструменти",
|
||||
"Early Feature Access": "Ранній доступ до нових функцій",
|
||||
"10 GB Cloud Sync Space": "10 ГБ простору для хмарної синхронізації",
|
||||
"Loading profile...": "Завантаження профілю...",
|
||||
"Features": "Функції",
|
||||
"Delete Account": "Видалити обліковий запис",
|
||||
"Delete Your Account?": "Видалити ваш обліковий запис?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Цю дію неможливо скасувати. Всі ваші дані в хмарі будуть повністю видалені.",
|
||||
"Delete Permanently": "Видалити назавжди",
|
||||
"Free Tier": "Безкоштовний тариф",
|
||||
"Plus Tier": "Тариф Plus",
|
||||
"Pro Tier": "Тариф Pro"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "Xác minh mã token",
|
||||
"Sign in with Google": "Đăng nhập với Google",
|
||||
"Sign in with Apple": "Đăng nhập với Apple",
|
||||
"Sign in with GitHub": "Đăng nhập với GitHub"
|
||||
"Sign in with GitHub": "Đăng nhập với GitHub",
|
||||
"Account": "Tài khoản",
|
||||
"Cloud Storage": "Lưu trữ đám mây",
|
||||
"Failed to delete user. Please try again later.": "Không thể xóa người dùng. Vui lòng thử lại sau.",
|
||||
"Unlimited Offline/Online Reading": "Đọc ngoại tuyến/trực tuyến không giới hạn",
|
||||
"Unlimited Cloud Sync Devices": "Đồng bộ hóa thiết bị đám mây không giới hạn",
|
||||
"Essential Text-to-Speech Voices": "Giọng nói chuyển văn bản thành giọng nói cơ bản",
|
||||
"DeepL Free Access": "Truy cập DeepL miễn phí",
|
||||
"Community Support": "Hỗ trợ cộng đồng",
|
||||
"500 MB Cloud Sync Space": "500 MB không gian đồng bộ hóa đám mây",
|
||||
"Includes All Free Tier Benefits": "Bao gồm tất cả lợi ích của gói miễn phí",
|
||||
"AI Summaries": "Tóm tắt AI",
|
||||
"AI Translations": "Dịch thuật AI",
|
||||
"Priority Support": "Hỗ trợ ưu tiên",
|
||||
"DeepL Pro Access": "Truy cập DeepL Pro",
|
||||
"Expanded Text-to-Speech Voices": "Giọng nói chuyển văn bản thành giọng nói mở rộng",
|
||||
"2000 MB Cloud Sync Space": "2000 MB không gian đồng bộ hóa đám mây",
|
||||
"Includes All Plus Tier Benefits": "Bao gồm tất cả lợi ích của gói Plus",
|
||||
"Unlimited AI Summaries": "Tóm tắt AI không giới hạn",
|
||||
"Unlimited AI Translations": "Dịch thuật AI không giới hạn",
|
||||
"Unlimited DeepL Pro Access": "Truy cập DeepL Pro không giới hạn",
|
||||
"Advanced AI Tools": "Công cụ AI nâng cao",
|
||||
"Early Feature Access": "Truy cập tính năng sớm",
|
||||
"10 GB Cloud Sync Space": "10 GB không gian đồng bộ hóa đám mây",
|
||||
"Loading profile...": "Đang tải hồ sơ...",
|
||||
"Features": "Tính năng",
|
||||
"Delete Account": "Xóa tài khoản",
|
||||
"Delete Your Account?": "Xóa tài khoản của bạn?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Hành động này không thể hoàn tác. Tất cả dữ liệu của bạn trên đám mây sẽ bị xóa vĩnh viễn.",
|
||||
"Delete Permanently": "Xóa vĩnh viễn",
|
||||
"Free Tier": "Gói miễn phí",
|
||||
"Plus Tier": "Gói Plus",
|
||||
"Pro Tier": "Gói Pro"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "验证令牌",
|
||||
"Sign in with Google": "使用 Google 登录",
|
||||
"Sign in with Apple": "使用 Apple 登录",
|
||||
"Sign in with GitHub": "使用 GitHub 登录"
|
||||
"Sign in with GitHub": "使用 GitHub 登录",
|
||||
"Account": "账户",
|
||||
"Cloud Storage": "云存储",
|
||||
"Failed to delete user. Please try again later.": "删除用户失败。请稍后重试。",
|
||||
"Unlimited Offline/Online Reading": "无限离线和在线阅读",
|
||||
"Unlimited Cloud Sync Devices": "无限云同步设备个数",
|
||||
"Essential Text-to-Speech Voices": "基础 TTS 语音包",
|
||||
"DeepL Free Access": "免费版 DeepL 翻译",
|
||||
"Community Support": "社区支持",
|
||||
"500 MB Cloud Sync Space": "500 MB 云同步空间",
|
||||
"Includes All Free Tier Benefits": "包含所有免费版权益",
|
||||
"AI Summaries": "AI 摘要",
|
||||
"AI Translations": "AI 翻译",
|
||||
"Priority Support": "优先支持",
|
||||
"DeepL Pro Access": "专业版 DeepL 翻译",
|
||||
"Expanded Text-to-Speech Voices": "更多文本转语音声音",
|
||||
"2000 MB Cloud Sync Space": "2000 MB 云同步空间",
|
||||
"Includes All Plus Tier Benefits": "包含所有增强版权益",
|
||||
"Unlimited AI Summaries": "无限使用 AI 摘要",
|
||||
"Unlimited AI Translations": "无限使用 AI 翻译",
|
||||
"Unlimited DeepL Pro Access": "无限使用专业版 DeepL 翻译",
|
||||
"Advanced AI Tools": "高级 AI 工具",
|
||||
"Early Feature Access": "抢先体验新功能",
|
||||
"10 GB Cloud Sync Space": "10 GB 云同步空间",
|
||||
"Loading profile...": "加载个人资料中...",
|
||||
"Features": "功能特性",
|
||||
"Delete Account": "删除账户",
|
||||
"Delete Your Account?": "删除您的账户?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "此操作无法撤销。您在云端的所有数据将被永久删除。",
|
||||
"Delete Permanently": "永久删除",
|
||||
"Free Tier": "免费版",
|
||||
"Plus Tier": "增强版",
|
||||
"Pro Tier": "专业版"
|
||||
}
|
||||
|
||||
@@ -198,5 +198,37 @@
|
||||
"Verify token": "驗證令牌",
|
||||
"Sign in with Google": "使用 Google 登入",
|
||||
"Sign in with Apple": "使用 Apple 登入",
|
||||
"Sign in with GitHub": "使用 GitHub 登入"
|
||||
"Sign in with GitHub": "使用 GitHub 登入",
|
||||
"Account": "帳戶",
|
||||
"Cloud Storage": "雲端儲存空間",
|
||||
"Failed to delete user. Please try again later.": "刪除使用者失敗。請稍後再試。",
|
||||
"Unlimited Offline/Online Reading": "無限離線/線上閱讀",
|
||||
"Unlimited Cloud Sync Devices": "無限雲端同步裝置",
|
||||
"Essential Text-to-Speech Voices": "基本文字轉語音聲音",
|
||||
"DeepL Free Access": "DeepL免費存取",
|
||||
"Community Support": "社群支援",
|
||||
"500 MB Cloud Sync Space": "500 MB雲端同步空間",
|
||||
"Includes All Free Tier Benefits": "包含所有免費方案權益",
|
||||
"AI Summaries": "AI摘要",
|
||||
"AI Translations": "AI翻譯",
|
||||
"Priority Support": "優先支援",
|
||||
"DeepL Pro Access": "DeepL專業版存取",
|
||||
"Expanded Text-to-Speech Voices": "擴展文字轉語音聲音",
|
||||
"2000 MB Cloud Sync Space": "2000 MB雲端同步空間",
|
||||
"Includes All Plus Tier Benefits": "包含所有Plus方案權益",
|
||||
"Unlimited AI Summaries": "無限AI摘要",
|
||||
"Unlimited AI Translations": "無限AI翻譯",
|
||||
"Unlimited DeepL Pro Access": "無限DeepL專業版存取",
|
||||
"Advanced AI Tools": "進階AI工具",
|
||||
"Early Feature Access": "搶先體驗新功能",
|
||||
"10 GB Cloud Sync Space": "10 GB雲端同步空間",
|
||||
"Loading profile...": "載入個人資料中...",
|
||||
"Features": "功能",
|
||||
"Delete Account": "刪除帳戶",
|
||||
"Delete Your Account?": "刪除您的帳戶?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "此操作無法撤銷。您在雲端的所有資料將被永久刪除。",
|
||||
"Delete Permanently": "永久刪除",
|
||||
"Free Tier": "免費方案",
|
||||
"Plus Tier": "Plus方案",
|
||||
"Pro Tier": "專業方案"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.18": {
|
||||
"date": "2025-02-26",
|
||||
"notes": [
|
||||
"Add user profile page and option to delete account in the cloud",
|
||||
"Fix TTS failed to start with invalid book language",
|
||||
"Enhancements on iOS with modals and annotation tools",
|
||||
"UX enhancements with modern highlights colors"
|
||||
]
|
||||
},
|
||||
"0.9.17": {
|
||||
"date": "2025-02-24",
|
||||
"notes": [
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/.tauri
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.plugin.safari_auth"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("androidx.core:core-ktx:1.9.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.0")
|
||||
implementation("com.google.android.material:material:1.7.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
implementation(project(":tauri-android"))
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,31 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
}
|
||||
resolutionStrategy {
|
||||
eachPlugin {
|
||||
switch (requested.id.id) {
|
||||
case "com.android.library":
|
||||
useVersion("8.0.2")
|
||||
break
|
||||
case "org.jetbrains.kotlin.android":
|
||||
useVersion("1.8.20")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
include ':tauri-android'
|
||||
project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.plugin.safari_auth
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.plugin.safari_auth", appContext.packageName)
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.plugin.safari_auth
|
||||
|
||||
import android.util.Log
|
||||
|
||||
class Example {
|
||||
fun pong(value: String): String {
|
||||
Log.i("Pong", value)
|
||||
return value
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.plugin.safari_auth
|
||||
|
||||
import android.app.Activity
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.JSObject
|
||||
import app.tauri.plugin.Plugin
|
||||
import app.tauri.plugin.Invoke
|
||||
|
||||
@InvokeArg
|
||||
class PingArgs {
|
||||
var value: String? = null
|
||||
}
|
||||
|
||||
@TauriPlugin
|
||||
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
|
||||
private val implementation = Example()
|
||||
|
||||
@Command
|
||||
fun ping(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(PingArgs::class.java)
|
||||
|
||||
val ret = JSObject()
|
||||
ret.put("value", implementation.pong(args.value ?: "default value :("))
|
||||
invoke.resolve(ret)
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.plugin.safari_auth
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,11 @@ pub fn init<R: Runtime, C: DeserializeOwned>(
|
||||
_app: &AppHandle<R>,
|
||||
api: PluginApi<R, C>,
|
||||
) -> crate::Result<SafariAuth<R>> {
|
||||
#[cfg(target_os = "android")]
|
||||
let handle = api.register_android_plugin("com.bilingify.safari_auth", "ExamplePlugin")?;
|
||||
#[cfg(target_os = "ios")]
|
||||
let handle = api.register_ios_plugin(init_plugin_safari_auth)?;
|
||||
|
||||
Ok(SafariAuth(handle))
|
||||
}
|
||||
|
||||
|
||||
@@ -106,8 +106,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_safari_auth::init());
|
||||
.plugin(tauri_plugin_fs::init());
|
||||
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
|
||||
@@ -133,6 +132,9 @@ pub fn run() {
|
||||
#[cfg(target_os = "ios")]
|
||||
let builder = builder.plugin(tauri_plugin_sign_in_with_apple::init());
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
let builder = builder.plugin(tauri_plugin_safari_auth::init());
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||
let builder = builder.plugin(tauri_plugin_haptics::init());
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function AuthErrorPage() {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
router.push('/auth');
|
||||
}, 5000);
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [router]);
|
||||
@@ -20,10 +20,6 @@ export default function AuthErrorPage() {
|
||||
<div className='bg-base-200/50 text-base-content hero h-screen items-center justify-center'>
|
||||
<div className='hero-content text-neutral-content text-center'>
|
||||
<div className='max-w-md'>
|
||||
<h1 className='mb-5 text-5xl font-bold'>Authentication Error</h1>
|
||||
<p className='mb-5'>
|
||||
Something went wrong during the authentication process. Please try again.
|
||||
</p>
|
||||
<p className='mb-5'>You will be redirected to the login page shortly...</p>
|
||||
<button className='btn btn-primary rounded-xl' onClick={() => router.push('/auth')}>
|
||||
Go to Login
|
||||
|
||||
@@ -332,20 +332,16 @@ export default function AuthPage() {
|
||||
Icon={FaGithub}
|
||||
label={_('Sign in with GitHub')}
|
||||
/>
|
||||
{!appService?.isIOSApp && (
|
||||
<div>
|
||||
<hr className='my-3 mt-6 w-64 border-t border-gray-200' />
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={true}
|
||||
providers={[]}
|
||||
redirectTo={getTauriRedirectTo(false)}
|
||||
localization={getAuthLocalization()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<hr className='my-3 mt-6 w-64 border-t border-gray-200' />
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
theme={isDarkMode ? 'dark' : 'light'}
|
||||
magicLink={true}
|
||||
providers={[]}
|
||||
redirectTo={getTauriRedirectTo(false)}
|
||||
localization={getAuthLocalization()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getStoragePlanData } from '@/utils/access';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
|
||||
import { QuotaType } from '@/types/user';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Quota from '@/components/Quota';
|
||||
@@ -26,7 +26,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig } = useEnv();
|
||||
const { token, user, logout } = useAuth();
|
||||
const { token, user } = useAuth();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
@@ -47,11 +47,8 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleUserLogout = () => {
|
||||
logout();
|
||||
settings.keepLogin = false;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
const handleUserProfile = () => {
|
||||
navigateToProfile(router);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
@@ -135,8 +132,8 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
<Quota quotas={quotas} />
|
||||
<MenuItem label={_('Sign Out')} noIcon onClick={handleUserLogout} />
|
||||
<Quota quotas={quotas} className='h-10 pl-4 pr-2' />
|
||||
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
|
||||
</ul>
|
||||
</MenuItem>
|
||||
) : (
|
||||
|
||||
@@ -76,7 +76,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const triangPos = getPosition(detail.a, rect, viewSettings.vertical);
|
||||
const triangPos = getPosition(detail.a, rect, popupPadding, viewSettings.vertical);
|
||||
const popupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
@@ -113,7 +113,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const triangPos = getPosition(element, rect, viewSettings.vertical);
|
||||
const triangPos = getPosition(element, rect, popupPadding, viewSettings.vertical);
|
||||
const popupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useFoliateEvents } from '../../hooks/useFoliateEvents';
|
||||
import { useNotesSync } from '../../hooks/useNotesSync';
|
||||
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||
import AnnotationPopup from './AnnotationPopup';
|
||||
import WiktionaryPopup from './WiktionaryPopup';
|
||||
import WikipediaPopup from './WikipediaPopup';
|
||||
@@ -95,11 +96,26 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
}
|
||||
setSelection({ key: bookKey, text: sel.toString(), range, index });
|
||||
};
|
||||
// FIXME: extremely hacky way to dismiss system selection tools on iOS
|
||||
const makeSelectionOnIOS = (sel: Selection) => {
|
||||
isTextSelected.current = true;
|
||||
const range = sel.getRangeAt(0);
|
||||
setTimeout(() => {
|
||||
sel.removeAllRanges();
|
||||
setTimeout(() => {
|
||||
if (!isTextSelected.current) return;
|
||||
sel.addRange(range);
|
||||
setSelection({ key: bookKey, text: range.toString(), range, index });
|
||||
}, 40);
|
||||
}, 0);
|
||||
};
|
||||
const handleSelectionchange = () => {
|
||||
// Available on iOS, Android and Desktop, fired when the selection is changed
|
||||
// Ideally the popup only shows when the selection is done,
|
||||
// but on Android no proper events are fired to notify selection done or I didn't find it,
|
||||
// we make the popup show when the selection is changed
|
||||
if (osPlatform === 'ios' || appService?.isIOSApp) return;
|
||||
|
||||
const sel = doc.getSelection();
|
||||
if (isValidSelection(sel)) {
|
||||
if (osPlatform === 'android' && isTouchstarted.current) {
|
||||
@@ -118,7 +134,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// Note that on Android, pointerup event is fired after an additional touch event
|
||||
const sel = doc.getSelection();
|
||||
if (isValidSelection(sel)) {
|
||||
makeSelection(sel, true);
|
||||
if (osPlatform === 'ios' || appService?.isIOSApp) {
|
||||
makeSelectionOnIOS(sel);
|
||||
} else {
|
||||
makeSelection(sel, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleTouchstart = () => {
|
||||
@@ -157,14 +177,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const { draw, annotation, doc, range } = detail;
|
||||
const { style, color } = annotation as BookNote;
|
||||
const hexColor = color ? HIGHLIGHT_COLOR_HEX[color] : color;
|
||||
if (style === 'highlight') {
|
||||
draw(Overlayer.highlight, { color });
|
||||
draw(Overlayer.highlight, { color: hexColor });
|
||||
} else if (['underline', 'squiggly'].includes(style as string)) {
|
||||
const { defaultView } = doc;
|
||||
const node = range.startContainer;
|
||||
const el = node.nodeType === 1 ? node : node.parentElement;
|
||||
const { writingMode } = defaultView.getComputedStyle(el);
|
||||
draw(Overlayer[style as keyof typeof Overlayer], { writingMode, color });
|
||||
draw(Overlayer[style as keyof typeof Overlayer], { writingMode, color: hexColor });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,7 +252,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
const triangPos = getPosition(selection.range, rect, viewSettings.vertical);
|
||||
const triangPos = getPosition(selection.range, rect, popupPadding, viewSettings.vertical);
|
||||
const annotPopupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
|
||||
@@ -108,18 +108,20 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
trianglePosition={trianglePosition}
|
||||
className='select-text overflow-y-auto'
|
||||
className='select-text'
|
||||
>
|
||||
<main className='p-2 font-sans'></main>
|
||||
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='p-4 text-sm opacity-60'>
|
||||
From <a id='link'>Wikipedia</a>, released under the{' '}
|
||||
<a href='https://en.wikipedia.org/wiki/Wikipedia:Text_of_the_Creative_Commons_Attribution-ShareAlike_4.0_International_License'>
|
||||
CC BY-SA License
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</footer>
|
||||
<div className='text-base-content flex h-full flex-col pt-2'>
|
||||
<main className='flex-grow overflow-y-auto px-2 font-sans'></main>
|
||||
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
|
||||
From <a id='link'>Wikipedia</a>, released under the{' '}
|
||||
<a href='https://en.wikipedia.org/wiki/Wikipedia:Text_of_the_Creative_Commons_Attribution-ShareAlike_4.0_International_License'>
|
||||
CC BY-SA License
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -163,15 +163,17 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
|
||||
width={popupWidth}
|
||||
height={popupHeight}
|
||||
position={position}
|
||||
className='select-text overflow-y-auto'
|
||||
className='select-text'
|
||||
>
|
||||
<main className='p-4 font-sans' />
|
||||
<footer className='hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='p-4 text-sm opacity-60'>
|
||||
From <a id='link'>Wiktionary</a>, released under the{' '}
|
||||
<a href='https://creativecommons.org/licenses/by-sa/4.0/'>CC BY-SA License</a>.
|
||||
</div>
|
||||
</footer>
|
||||
<div className='flex h-full flex-col'>
|
||||
<main className='flex-grow overflow-y-auto p-4 font-sans' />
|
||||
<footer className='mt-auto hidden data-[state=loaded]:block data-[state=error]:hidden data-[state=loading]:hidden'>
|
||||
<div className='flex items-center px-4 py-2 text-sm opacity-60'>
|
||||
From <a id='link'>Wiktionary</a>, released under the{' '}
|
||||
<a href='https://creativecommons.org/licenses/by-sa/4.0/'>CC BY-SA License</a>.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
@@ -21,6 +22,8 @@ import useShortcuts from '@/hooks/useShortcuts';
|
||||
const MIN_SIDEBAR_WIDTH = 0.05;
|
||||
const MAX_SIDEBAR_WIDTH = 0.45;
|
||||
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
|
||||
const SideBar: React.FC<{
|
||||
onGoToLibrary: () => void;
|
||||
}> = ({ onGoToLibrary }) => {
|
||||
@@ -95,23 +98,33 @@ const SideBar: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerticalDragEnd = (velocity: number) => {
|
||||
const handleVerticalDragEnd = (data: { velocity: number; clientY: number }) => {
|
||||
const sidebar = document.querySelector('.sidebar-container') as HTMLElement;
|
||||
const overlay = document.querySelector('.overlay') as HTMLElement;
|
||||
|
||||
if (!sidebar || !overlay) return;
|
||||
|
||||
if (velocity > 0.5) {
|
||||
sidebar.style.transition = `top ${0.15 / velocity}s ease-out`;
|
||||
if (
|
||||
data.velocity > VELOCITY_THRESHOLD ||
|
||||
(data.velocity >= 0 && data.clientY >= window.innerHeight * 0.5)
|
||||
) {
|
||||
const transitionDuration = 0.15 / Math.max(data.velocity, 0.5);
|
||||
sidebar.style.transition = `top ${transitionDuration}s ease-out`;
|
||||
sidebar.style.top = '100%';
|
||||
overlay.style.transition = `opacity ${0.15 / velocity}s ease-out`;
|
||||
overlay.style.transition = `opacity ${transitionDuration}s ease-out`;
|
||||
overlay.style.opacity = '0';
|
||||
setTimeout(() => setSideBarVisible(false), 300);
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
} else {
|
||||
sidebar.style.transition = 'top 0.3s ease-out';
|
||||
sidebar.style.top = '0%';
|
||||
overlay.style.transition = 'opacity 0.3s ease-out';
|
||||
overlay.style.opacity = '0.8';
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -112,24 +112,24 @@ const TTSPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center justify-between space-x-2'>
|
||||
<button onClick={onBackward} className='hover:bg-base-200/75 rounded-full p-1'>
|
||||
<button onClick={onBackward} className='rounded-full p-1'>
|
||||
<MdFastRewind size={iconSize32} />
|
||||
</button>
|
||||
<button onClick={onTogglePlay} className='hover:bg-base-200/75 rounded-full p-1'>
|
||||
<button onClick={onTogglePlay} className='rounded-full p-1'>
|
||||
{isPlaying ? (
|
||||
<MdPauseCircle size={iconSize48} className='fill-primary' />
|
||||
) : (
|
||||
<MdPlayCircle size={iconSize48} className='fill-primary' />
|
||||
)}
|
||||
</button>
|
||||
<button onClick={onForward} className='hover:bg-base-200/75 rounded-full p-1'>
|
||||
<button onClick={onForward} className='rounded-full p-1'>
|
||||
<MdFastForward size={iconSize32} />
|
||||
</button>
|
||||
<button onClick={onStop} className='hover:bg-base-200/75 rounded-full p-1'>
|
||||
<button onClick={onStop} className='rounded-full p-1'>
|
||||
<MdStop size={iconSize32} />
|
||||
</button>
|
||||
<div className='dropdown dropdown-top'>
|
||||
<button tabIndex={0} className='hover:bg-base-200/75 rounded-full p-1'>
|
||||
<button tabIndex={0} className='rounded-full p-1'>
|
||||
<RiVoiceAiFill size={iconSize32} />
|
||||
</button>
|
||||
<ul
|
||||
|
||||
@@ -22,9 +22,9 @@ export const useProgressAutoSave = (bookKey: string) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// FIXME: Save book config when progress changes on Android
|
||||
// until we can hook into the lifecycle of the Android app
|
||||
if (!appService?.isAndroidApp || !progress) return;
|
||||
// FIXME: On Android and iOS we need a better way to be notified
|
||||
// when the app is about to go in background
|
||||
if (!appService?.isMobile || !progress) return;
|
||||
saveBookConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress, bookKey]);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IoArrowBack } from 'react-icons/io5';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { QuotaType, UserPlan } from '@/types/user';
|
||||
import { getStoragePlanData, getUserPlan } from '@/utils/access';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import { deleteUser } from '@/libs/user';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import Quota from '@/components/Quota';
|
||||
|
||||
const ProfilePage = () => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user, logout } = useAuth();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [userPlan, setUserPlan] = useState<UserPlan>('free');
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
|
||||
|
||||
useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
|
||||
try {
|
||||
const userPlan = getUserPlan(token);
|
||||
const storagePlan = getStoragePlanData(token);
|
||||
const storageQuota = {
|
||||
name: _('Cloud Storage'),
|
||||
tooltip: _('{{percentage}}% of Cloud Storage Used.', {
|
||||
percentage: Math.round((storagePlan.usage / storagePlan.quota) * 100),
|
||||
}),
|
||||
used: Math.round(storagePlan.usage / 1024 / 1024),
|
||||
total: Math.round(storagePlan.quota / 1024 / 1024),
|
||||
unit: 'MB',
|
||||
};
|
||||
setUserPlan(userPlan);
|
||||
setQuotas([storageQuota]);
|
||||
} catch (error) {
|
||||
console.error('Error loading user plan data:', error);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const handleGoBack = () => {
|
||||
navigateToLibrary(router);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
settings.keepLogin = false;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
navigateToLibrary(router);
|
||||
};
|
||||
|
||||
const handleDeleteRequest = () => {
|
||||
setShowConfirmDelete(true);
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowConfirmDelete(false);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
await deleteUser();
|
||||
handleLogout();
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to delete user. Please try again later.'),
|
||||
});
|
||||
}
|
||||
setShowConfirmDelete(false);
|
||||
};
|
||||
|
||||
const getPlanDetails = (userPlan: UserPlan) => {
|
||||
switch (userPlan) {
|
||||
case 'free':
|
||||
return {
|
||||
name: _('Free Tier'),
|
||||
color: 'bg-gray-200 text-gray-800',
|
||||
features: [
|
||||
_('Community Support'),
|
||||
_('DeepL Free Access'),
|
||||
_('Essential Text-to-Speech Voices'),
|
||||
_('Unlimited Offline/Online Reading'),
|
||||
_('Unlimited Cloud Sync Devices'),
|
||||
_('500 MB Cloud Sync Space'),
|
||||
],
|
||||
};
|
||||
case 'plus':
|
||||
return {
|
||||
name: _('Plus Tier'),
|
||||
color: 'bg-blue-200 text-blue-800',
|
||||
features: [
|
||||
_('Includes All Free Tier Benefits'),
|
||||
_('Priority Support'),
|
||||
_('AI Summaries'),
|
||||
_('AI Translations'),
|
||||
_('DeepL Pro Access'),
|
||||
_('Expanded Text-to-Speech Voices'),
|
||||
_('2000 MB Cloud Sync Space'),
|
||||
],
|
||||
};
|
||||
case 'pro':
|
||||
return {
|
||||
name: _('Pro Tier'),
|
||||
color: 'bg-purple-200 text-purple-800',
|
||||
features: [
|
||||
_('Includes All Plus Tier Benefits'),
|
||||
_('Unlimited AI Summaries'),
|
||||
_('Unlimited AI Translations'),
|
||||
_('Unlimited DeepL Pro Access'),
|
||||
_('Advanced AI Tools'),
|
||||
_('Early Feature Access'),
|
||||
_('10 GB Cloud Sync Space'),
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (!user || !token) {
|
||||
return (
|
||||
<div className='mx-auto max-w-4xl px-4 py-8'>
|
||||
<div className='overflow-hidden rounded-lg shadow-md'>
|
||||
<div className='flex min-h-[300px] items-center justify-center p-6'>
|
||||
<div className='text-base-content animate-pulse'>{_('Loading profile...')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) || getPlanDetails('free');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-6 flex justify-center',
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
|
||||
appService?.hasTrafficLight && 'pt-11',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className={clsx(
|
||||
'btn btn-ghost fixed left-4 h-8 min-h-8 w-8 p-0',
|
||||
appService?.hasSafeAreaInset && 'top-[calc(env(safe-area-inset-top)+16px)]',
|
||||
appService?.hasTrafficLight && 'top-11',
|
||||
)}
|
||||
>
|
||||
<IoArrowBack className='text-base-content' />
|
||||
</button>
|
||||
<div className='w-full max-w-4xl px-6 py-10'>
|
||||
<div className='bg-base-200 overflow-hidden rounded-lg p-2 shadow-md sm:p-6'>
|
||||
<div className='p-6'>
|
||||
<div className='mb-8 flex flex-col items-center gap-x-6 gap-y-4 md:flex-row md:items-start'>
|
||||
<div className='flex-shrink-0'>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={_('User avatar')}
|
||||
className='border-base-100 h-16 w-16 rounded-full border-4 md:h-24 md:w-24'
|
||||
referrerPolicy='no-referrer'
|
||||
width={128}
|
||||
height={128}
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<PiUserCircle className='h-16 w-16 md:h-24 md:w-24' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex-grow text-center md:text-left'>
|
||||
<h2 className='text-base-content text-xl font-bold md:text-2xl'>{userFullName}</h2>
|
||||
<p className='text-base-content/60'>{userEmail}</p>
|
||||
<div className='mt-3'>
|
||||
<span
|
||||
className={`inline-block rounded-full px-3 py-1 text-sm font-medium ${planDetails.color}`}
|
||||
>
|
||||
{planDetails.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-base-100 mb-8 rounded-lg p-6'>
|
||||
<h3 className='text-base-content mb-2 text-lg font-semibold'>{_('Features')}</h3>
|
||||
<div className='mt-6'>
|
||||
<ul className='text-base-content/60 grid grid-cols-1 gap-2 md:grid-cols-2'>
|
||||
{planDetails.features.map((feature, index) => (
|
||||
<li key={index} className='flex items-center'>
|
||||
<svg
|
||||
className='mr-2 h-4 w-4 text-green-500'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-base-300 mb-8 rounded-lg'>
|
||||
<div className='p-0'>
|
||||
{quotas && quotas.length > 0 ? (
|
||||
<Quota quotas={quotas} showProgress className='h-10 pl-4 pr-2' />
|
||||
) : (
|
||||
<div className='h-10 animate-pulse'></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 md:flex-row'>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className='w-full rounded-lg bg-gray-200 px-6 py-3 font-medium text-gray-800 transition-colors hover:bg-gray-300 md:w-auto'
|
||||
>
|
||||
{_('Sign Out')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteRequest}
|
||||
className='w-full rounded-lg bg-red-100 px-6 py-3 font-medium text-red-600 transition-colors hover:bg-red-200 md:w-auto'
|
||||
>
|
||||
{_('Delete Account')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConfirmDelete && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4'>
|
||||
<div className='w-full max-w-md rounded-2xl bg-white p-6'>
|
||||
<h3 className='mb-4 text-xl font-bold text-gray-800'>{_('Delete Your Account?')}</h3>
|
||||
<p className='mb-6 text-gray-600'>
|
||||
{_(
|
||||
'This action cannot be undone. All your data in the cloud will be permanently deleted.',
|
||||
)}
|
||||
</p>
|
||||
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||
<button
|
||||
onClick={handleCancelDelete}
|
||||
className='flex-1 rounded-lg bg-gray-300 px-4 py-2 font-medium text-gray-800 hover:bg-gray-400'
|
||||
>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
className='flex-1 rounded-lg bg-red-500 px-4 py-2 font-medium text-white hover:bg-red-600'
|
||||
>
|
||||
{_('Delete Permanently')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -82,7 +82,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
title={_('Book Details')}
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
className='!bg-[rgba(0,0,0,0.5)]'
|
||||
bgClassName='sm:bg-black/50'
|
||||
boxClassName='sm:min-w-[480px] sm:h-auto'
|
||||
contentClassName='!px-6 !py-2'
|
||||
>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { MdArrowBackIosNew } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useDrag } from '@/hooks/useDrag';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
||||
|
||||
const DISMISS_THRESHOLD = 100;
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
|
||||
interface DialogProps {
|
||||
@@ -16,6 +15,7 @@ interface DialogProps {
|
||||
header?: ReactNode;
|
||||
title?: string;
|
||||
className?: string;
|
||||
bgClassName?: string;
|
||||
boxClassName?: string;
|
||||
contentClassName?: string;
|
||||
onClose: () => void;
|
||||
@@ -28,12 +28,12 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
header,
|
||||
title,
|
||||
className,
|
||||
bgClassName,
|
||||
boxClassName,
|
||||
contentClassName,
|
||||
onClose,
|
||||
}) => {
|
||||
const { appService } = useEnv();
|
||||
const [translateY, setTranslateY] = useState(0);
|
||||
const iconSize22 = useResponsiveSize(22);
|
||||
const isMobile = window.innerWidth < 640;
|
||||
|
||||
@@ -56,27 +56,42 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
|
||||
const modal = document.querySelector('.modal-box') as HTMLElement;
|
||||
const overlay = document.querySelector('.overlay') as HTMLElement;
|
||||
|
||||
const heightFraction = data.clientY / window.innerHeight;
|
||||
const newTop = Math.max(0.0, Math.min(1, heightFraction));
|
||||
|
||||
if (modal && overlay) {
|
||||
modal.style.transition = '';
|
||||
overlay.style.opacity = `${1 - data.clientY / window.innerHeight}`;
|
||||
modal.style.transform = `translateY(${newTop * 100}%)`;
|
||||
overlay.style.opacity = `${1 - heightFraction}`;
|
||||
}
|
||||
setTranslateY((prev) => Math.max(prev + data.deltaY, 0));
|
||||
};
|
||||
|
||||
const handleDragEnd = (velocity: number) => {
|
||||
const handleDragEnd = (data: { velocity: number; clientY: number }) => {
|
||||
const modal = document.querySelector('.modal-box') as HTMLElement;
|
||||
if (!modal) return;
|
||||
const overlay = document.querySelector('.overlay') as HTMLElement;
|
||||
if (!modal || !overlay) return;
|
||||
|
||||
if (translateY > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
||||
modal.style.transition = `transform ${0.15 / velocity}s ease-out`;
|
||||
onClose();
|
||||
setTimeout(() => setTranslateY(0), 300);
|
||||
if (
|
||||
data.velocity > VELOCITY_THRESHOLD ||
|
||||
(data.velocity >= 0 && data.clientY >= window.innerHeight * 0.5)
|
||||
) {
|
||||
const transitionDuration = 0.15 / Math.max(data.velocity, 0.5);
|
||||
modal.style.transition = `transform ${transitionDuration}s ease-out`;
|
||||
modal.style.transform = 'translateY(100%)';
|
||||
overlay.style.transition = `opacity ${transitionDuration}s ease-out`;
|
||||
overlay.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
modal.style.transform = 'translateY(0%)';
|
||||
}, 300);
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
} else {
|
||||
modal.style.transition = `transform 0.3s ease-out`;
|
||||
setTranslateY(0);
|
||||
modal.style.transform = `translateY(0%)`;
|
||||
overlay.style.opacity = '0';
|
||||
if (appService?.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
@@ -89,9 +104,9 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
<dialog
|
||||
id={id ?? 'dialog'}
|
||||
open={isOpen}
|
||||
className={clsx('modal sm:min-w-90 z-50 h-full w-full sm:w-full', className)}
|
||||
className={clsx('modal sm:min-w-90 z-50 h-full w-full !bg-transparent sm:w-full', className)}
|
||||
>
|
||||
<div className='overlay fixed inset-0 z-10 bg-black/30 sm:bg-black/20' />
|
||||
<div className={clsx('overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20', bgClassName)} />
|
||||
<div
|
||||
className={clsx(
|
||||
'modal-box settings-content z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
|
||||
@@ -99,9 +114,6 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)] sm:pt-0',
|
||||
boxClassName,
|
||||
)}
|
||||
style={{
|
||||
transform: `translateY(${translateY}px)`,
|
||||
}}
|
||||
>
|
||||
{window.innerWidth < 640 && (
|
||||
<div
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
type QuotaProps = {
|
||||
@@ -8,26 +9,48 @@ type QuotaProps = {
|
||||
total: number;
|
||||
unit: string;
|
||||
}[];
|
||||
className?: string;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
const Quota: React.FC<QuotaProps> = ({ quotas }) => {
|
||||
const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className }) => {
|
||||
return (
|
||||
<div className='w-full max-w-lg rounded-md pl-4 pr-2 text-base sm:text-sm'>
|
||||
<div>
|
||||
{quotas.map((quota) => (
|
||||
<div key={quota.name} className='flex h-10 items-center justify-between'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={quota.tooltip}>
|
||||
<div className='flex max-w-28 items-center gap-x-2'>
|
||||
<div className={clsx('text-base-content w-full space-y-2 rounded-md text-base sm:text-sm')}>
|
||||
{quotas.map((quota) => {
|
||||
const usagePercentage = (quota.used / quota.total) * 100;
|
||||
let bgColor = 'bg-green-500';
|
||||
if (usagePercentage > 80) {
|
||||
bgColor = 'bg-red-500';
|
||||
} else if (usagePercentage > 50) {
|
||||
bgColor = 'bg-yellow-500';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={quota.name}
|
||||
className={clsx(
|
||||
'relative w-full overflow-hidden rounded-md',
|
||||
showProgress && 'border-base-300 border',
|
||||
)}
|
||||
>
|
||||
{showProgress && (
|
||||
<div
|
||||
className={`absolute left-0 top-0 h-full ${bgColor}`}
|
||||
style={{ width: `${usagePercentage}%` }}
|
||||
></div>
|
||||
)}
|
||||
|
||||
<div className={clsx('relative flex items-center justify-between p-2', className)}>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={quota.tooltip}>
|
||||
<span className='truncate'>{quota.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='py-3 text-right text-xs'>
|
||||
{quota.used} / {quota.total} {quota.unit}
|
||||
<div className='text-right text-xs'>
|
||||
{quota.used} / {quota.total} {quota.unit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,14 @@ import { useCallback, useRef } from 'react';
|
||||
|
||||
export const useDrag = (
|
||||
onDragMove: (data: { clientX: number; clientY: number; deltaX: number; deltaY: number }) => void,
|
||||
onDragEnd?: (velocity: number) => void,
|
||||
onDragEnd?: (data: {
|
||||
velocity: number;
|
||||
deltaT: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
}) => void,
|
||||
) => {
|
||||
const isDragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
@@ -52,17 +59,28 @@ export const useDrag = (
|
||||
|
||||
const handleEnd = (event: MouseEvent | TouchEvent) => {
|
||||
isDragging.current = false;
|
||||
let deltaX = 0;
|
||||
let deltaY = 0;
|
||||
let clientX = 0;
|
||||
let clientY = 0;
|
||||
const endTime = performance.now();
|
||||
const deltaT = endTime - startTime.current;
|
||||
|
||||
const distanceY =
|
||||
'touches' in event
|
||||
? event.changedTouches[0]!.clientY - startY.current
|
||||
: event.clientY - startY.current;
|
||||
const velocity = distanceY / deltaT;
|
||||
if ('touches' in event) {
|
||||
const currentTouch = event.changedTouches[0]!;
|
||||
clientX = currentTouch.clientX;
|
||||
clientY = currentTouch.clientY;
|
||||
} else {
|
||||
const evt = event as MouseEvent;
|
||||
clientX = evt.clientX;
|
||||
clientY = evt.clientY;
|
||||
}
|
||||
deltaX = clientX - startX.current;
|
||||
deltaY = clientY - startY.current;
|
||||
const velocity = deltaY / deltaT;
|
||||
|
||||
if (onDragEnd) {
|
||||
onDragEnd(velocity);
|
||||
onDragEnd({ velocity, deltaT, clientX, clientY, deltaX, deltaY });
|
||||
}
|
||||
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getAPIBaseUrl, isWebAppPlatform } from '@/services/environment';
|
||||
import { getAccessToken, getUserID } from '@/utils/access';
|
||||
import { getUserID } from '@/utils/access';
|
||||
import { fetchWithAuth } from '@/utils/fetch';
|
||||
import {
|
||||
tauriUpload,
|
||||
tauriDownload,
|
||||
@@ -15,27 +16,6 @@ const API_ENDPOINTS = {
|
||||
delete: getAPIBaseUrl() + '/storage/delete',
|
||||
};
|
||||
|
||||
const fetchWithAuth = async (url: string, options: RequestInit) => {
|
||||
const token = await getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const headers = {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Error:', errorData.error || response.statusText);
|
||||
throw new Error(errorData.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export const createProgressHandler = (
|
||||
totalFiles: number,
|
||||
completedFilesRef: { count: number },
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { getUserID } from '@/utils/access';
|
||||
import { fetchWithAuth } from '@/utils/fetch';
|
||||
|
||||
const API_ENDPOINT = getAPIBaseUrl() + '/user/delete';
|
||||
|
||||
export const deleteUser = async () => {
|
||||
try {
|
||||
const userId = await getUserID();
|
||||
if (!userId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
await fetchWithAuth(API_ENDPOINT, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('User deletion failed:', error);
|
||||
throw new Error('User deletion failed');
|
||||
}
|
||||
};
|
||||
@@ -28,7 +28,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { user, token } = await getUserAndToken(req.headers['authorization']);
|
||||
let deeplApiUrl = DEEPL_FREE_API;
|
||||
if (user && token) {
|
||||
const userPlan = await getUserPlan(token);
|
||||
const userPlan = getUserPlan(token);
|
||||
if (userPlan !== 'free') deeplApiUrl = DEEPL_PRO_API;
|
||||
}
|
||||
const deeplAuthKey =
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { corsAllMethods, runMiddleware } from '@/utils/cors';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
await runMiddleware(req, res, corsAllMethods);
|
||||
|
||||
if (req.method !== 'DELETE') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, token } = await validateUserAndToken(req.headers['authorization']);
|
||||
if (!user || !token) {
|
||||
return res.status(403).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const supabaseAdmin = createSupabaseAdminClient();
|
||||
const { error } = await supabaseAdmin.auth.admin.deleteUser(user.id);
|
||||
if (error) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'User deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Something went wrong' });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BookLayout,
|
||||
BookSearchConfig,
|
||||
BookStyle,
|
||||
HighlightColor,
|
||||
TTSConfig,
|
||||
ViewConfig,
|
||||
ViewSettings,
|
||||
@@ -406,3 +407,11 @@ export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
|
||||
export const DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250;
|
||||
export const DISABLE_DOUBLE_CLICK_ON_MOBILE = true;
|
||||
export const LONG_HOLD_THRESHOLD = 500;
|
||||
|
||||
export const HIGHLIGHT_COLOR_HEX: Record<HighlightColor, string> = {
|
||||
red: '#f87171', // red-400
|
||||
yellow: '#facc15', // yellow-400
|
||||
green: '#4ade80', // green-400
|
||||
blue: '#60a5fa', // blue-400
|
||||
violet: '#a78bfa', // violet-400
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Token {
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export const getUserPlan = async (token: string): Promise<UserPlan> => {
|
||||
export const getUserPlan = (token: string): UserPlan => {
|
||||
const data = jwtDecode<Token>(token) || {};
|
||||
return data['plan'] || 'free';
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getAccessToken } from './access';
|
||||
|
||||
export const fetchWithAuth = async (url: string, options: RequestInit) => {
|
||||
const token = await getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const headers = {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Error:', errorData.error || response.statusText);
|
||||
throw new Error(errorData.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
@@ -25,6 +25,10 @@ export const navigateToLogin = (router: ReturnType<typeof useRouter>) => {
|
||||
router.push(`/auth?redirect=${encodeURIComponent(currentPath)}`);
|
||||
};
|
||||
|
||||
export const navigateToProfile = (router: ReturnType<typeof useRouter>) => {
|
||||
router.push('/user');
|
||||
};
|
||||
|
||||
export const navigateToLibrary = (
|
||||
router: ReturnType<typeof useRouter>,
|
||||
queryParams?: string,
|
||||
|
||||
@@ -64,7 +64,19 @@ const getIframeElement = (nodeElement: Range | Element): HTMLIFrameElement | nul
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getPosition = (target: Range | Element, rect: Rect, isVertical: boolean = false) => {
|
||||
const constrainPointWithinRect = (point: Point, rect: Rect, padding: number) => {
|
||||
return {
|
||||
x: Math.max(padding, Math.min(point.x, rect.right - padding)),
|
||||
y: Math.max(padding, Math.min(point.y, rect.bottom - padding)),
|
||||
};
|
||||
};
|
||||
|
||||
export const getPosition = (
|
||||
target: Range | Element,
|
||||
rect: Rect,
|
||||
paddingPx: number,
|
||||
isVertical: boolean = false,
|
||||
) => {
|
||||
const frameElement = getIframeElement(target);
|
||||
const transform = frameElement ? getComputedStyle(frameElement).transform : '';
|
||||
const match = transform.match(/matrix\((.+)\)/);
|
||||
@@ -80,10 +92,14 @@ export const getPosition = (target: Range | Element, rect: Rect, isVertical: boo
|
||||
const rightSpace = rect.right - first.right;
|
||||
const dir = leftSpace > rightSpace ? 'left' : 'right';
|
||||
const position = {
|
||||
point: {
|
||||
x: dir === 'left' ? first.left - rect.left - 6 : first.right - rect.left + 6,
|
||||
y: (first.top + first.bottom) / 2 - rect.top,
|
||||
},
|
||||
point: constrainPointWithinRect(
|
||||
{
|
||||
x: dir === 'left' ? first.left - rect.left - 6 : first.right - rect.left + 6,
|
||||
y: (first.top + first.bottom) / 2 - rect.top,
|
||||
},
|
||||
rect,
|
||||
paddingPx,
|
||||
),
|
||||
dir,
|
||||
} as Position;
|
||||
const inView = pointIsInView(position.point);
|
||||
@@ -119,12 +135,12 @@ export const getPopupPosition = (
|
||||
popupPoint.y = position.point.y - popupHeightPx;
|
||||
} else if (position.dir === 'down') {
|
||||
popupPoint.x = position.point.x - popupWidthPx / 2;
|
||||
popupPoint.y = position.point.y + 5;
|
||||
popupPoint.y = position.point.y + 6;
|
||||
} else if (position.dir === 'left') {
|
||||
popupPoint.x = position.point.x - popupWidthPx;
|
||||
popupPoint.y = position.point.y - popupHeightPx / 2;
|
||||
} else if (position.dir === 'right') {
|
||||
popupPoint.x = position.point.x + 5;
|
||||
popupPoint.x = position.point.x + 6;
|
||||
popupPoint.y = position.point.y - popupHeightPx / 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,5 +58,11 @@ export const findSSMLMark = (charIndex: number, marks: TTSMark[]) => {
|
||||
|
||||
export const parseSSMLLang = (ssml: string): string | null => {
|
||||
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
|
||||
return match ? match[1]! : null;
|
||||
if (match && match[1]) {
|
||||
const parts = match[1].split('-');
|
||||
return parts.length > 1
|
||||
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
|
||||
: parts[0]!.toLowerCase();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ const supabaseUrl =
|
||||
process.env['NEXT_PUBLIC_SUPABASE_URL'] || process.env['NEXT_PUBLIC_DEV_SUPABASE_URL']!;
|
||||
const supabaseAnonKey =
|
||||
process.env['NEXT_PUBLIC_SUPABASE_ANON_KEY'] || process.env['NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY']!;
|
||||
const supabaseAdminKey = process.env['SUPABASE_ADMIN_KEY'] || '';
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
|
||||
@@ -18,3 +19,13 @@ export const createSupabaseClient = (accessToken?: string) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createSupabaseAdminClient = () => {
|
||||
return createClient(supabaseUrl, supabaseAdminKey, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user