Compare commits

...

21 Commits

Author SHA1 Message Date
Huang Xin cab757257e release: version 0.9.18 (#458) 2025-02-26 19:42:11 +01:00
Huang Xin fc8c9546b6 fix: normalize language code for TTS (#457) 2025-02-26 19:30:22 +01:00
Huang Xin 3cc41b7b71 auth: redirect to auth page when callback page has no token (#454) 2025-02-26 01:31:50 +01:00
Huang Xin e65380aaf5 ui: use less saturated and modern colors when drawing highlights (#453) 2025-02-26 00:45:33 +01:00
Huang Xin 24b68ac046 feat: add user profile page and option to delete account in the cloud (#452) 2025-02-26 00:16:42 +01:00
Huang Xin e0591ce41c ui: style tweaks on Popup and Dialog (#448) 2025-02-25 15:49:27 +01:00
Huang Xin 7be6c07344 ux: enhancements on iOS with modals and annotation tools (#447)
* mobile: auto save progress also for iOS

* ux: enhancements on pull-down to dismiss modals

* doc: update reademe

* ux: tricks to dismiss system selection tools on iOS
2025-02-25 13:07:44 +01:00
Huang Xin f8ea7fc463 chore: add stub android plugin for safari-auth (#443) 2025-02-24 11:41:26 +01:00
Huang Xin 36e09582bc release: version 0.9.17 (#441) 2025-02-24 01:24:28 +01:00
Huang Xin ccc04825b7 feat(ux): improve pull-down interaction for mobile modal (#440)
* Adjusts overlay background opacity dynamically based on drag position
* Ensures smooth transition as the modal is pulled down
* Enhances UX for mobile users by making dismissal more intuitive
2025-02-24 01:05:13 +01:00
Huang Xin 9e7e41a623 fix: prerender library page without localStorage (#439) 2025-02-23 17:04:02 +01:00
Huang Xin 1ff929ece5 feat: add toggle select mode and quit app shortcuts in library page, closes #96 (#438) 2025-02-23 16:46:51 +01:00
Huang Xin 4c7b7881c8 feat: add d/u shortcuts for half page navigation, closes #62 (#437) 2025-02-23 15:56:00 +01:00
Huang Xin 1d5b189185 fix: preserve note id when changing style and preserve style when changing color, closes #252 (#436) 2025-02-23 15:32:57 +01:00
Huang Xin a9aa2fe7a4 fix: set minimum font size for obsolete font tag, closes #434 (#435) 2025-02-23 13:42:37 +01:00
Huang Xin 0253afca86 auth: add safari-auth plugin for iOS OAuth (#433)
* auth: add safari-auth plugin for iOS OAuth

* fix: temporarily disable email auth provider for iOS
2025-02-23 03:31:10 +01:00
Ahmed 7ddbaa644b Add arabic localization (#432)
* Add arabic localization

* Add 'ar' lang to scanner config

* Add 'ar' lang to scanner config
2025-02-22 21:51:20 +01:00
Huang Xin e96782baab api: load balance of the API keys for deepl translate, closes #429 (#431) 2025-02-22 09:09:25 +01:00
Gigoo25 66784736ee Add Dockerfile to selfhost (#430) 2025-02-22 08:03:12 +01:00
Huang Xin 2bb6ed2602 feat: add haptics feedback when entering select mode (#428) 2025-02-22 00:18:06 +01:00
Huang Xin 48ef2d634f chore: fix aarch64 linux build 2025-02-21 20:09:18 +01:00
106 changed files with 2725 additions and 219 deletions
+1 -8
View File
@@ -2,12 +2,6 @@ name: Release Readest
on:
workflow_dispatch:
inputs:
test_ubuntu_arm:
description: 'Run only ubuntu-22.04-arm'
required: false
default: true
type: boolean
release:
types: [published]
@@ -83,7 +77,6 @@ jobs:
runs-on: ${{ matrix.config.os }}
timeout-minutes: 30
if: ${{ github.event.inputs.test_arm_only }} != 'true' || matrix.config.os == 'ubuntu-22.04-arm'
steps:
- uses: actions/checkout@v4
@@ -131,7 +124,7 @@ jobs:
if: contains(matrix.config.os, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- uses: tauri-apps/tauri-action@v0
env:
Generated
+26
View File
@@ -23,12 +23,14 @@ dependencies = [
"tauri-plugin-devtools",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-haptics",
"tauri-plugin-http",
"tauri-plugin-log",
"tauri-plugin-oauth",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-safari-auth",
"tauri-plugin-shell",
"tauri-plugin-sign-in-with-apple",
"tauri-plugin-single-instance",
@@ -5207,6 +5209,20 @@ dependencies = [
"uuid",
]
[[package]]
name = "tauri-plugin-haptics"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52db8ae1bb6eab8502ee5c32acb905323c9651491c7f229d84f45b8c57d12327"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.11",
]
[[package]]
name = "tauri-plugin-http"
version = "2.3.0"
@@ -5316,6 +5332,16 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-safari-auth"
version = "0.1.0"
dependencies = [
"serde",
"tauri",
"tauri-plugin",
"thiserror 2.0.11",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.2.0"
+38
View File
@@ -0,0 +1,38 @@
FROM node:22-slim
ENV PNPM_HOME="/root/.local/share/pnpm"
ENV PATH="${PATH}:${PNPM_HOME}"
RUN npm install --global pnpm
# Install necessary packages
RUN apt update -y && apt install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
ca-certificates \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Install Rust and Cargo
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
COPY . /app
WORKDIR /app
RUN pnpm install
RUN pnpm --filter @readest/readest-app setup-pdfjs
WORKDIR /app/apps/readest-app
RUN pnpm build-web
ENTRYPOINT ["pnpm", "start-web"]
+5 -5
View File
@@ -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 -2
View File
@@ -4,8 +4,10 @@ NEXT_PUBLIC_POSTHOG_HOST=YOUR_POSTHOG_HOST
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
DEEPL_PRO_API_KEY=YOUR_DEEPL_PRO_API_KEY
DEEPL_FREE_API_KEY=YOUR_DEEPL_FREE_API_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
R2_TOKEN_VALUE=YOUR_R2_TOKEN_VALUE
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
@@ -24,6 +24,7 @@ module.exports = {
'hi',
'id',
'vi',
'ar',
'zh-CN',
'zh-TW',
],
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.15",
"version": "0.9.18",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -47,6 +47,7 @@
"@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-haptics": "^2.2.3",
"@tauri-apps/plugin-http": "^2.3.0",
"@tauri-apps/plugin-log": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.2.5",
@@ -0,0 +1,234 @@
{
"(detected)": "(تم التعرف)",
"About Readest": "حول ريديست",
"Add your notes here...": "أضف ملاحظاتك هنا...",
"Animation": "الرسوم المتحركة",
"Annotate": "إضافة ملاحظة",
"Apply": "تطبيق",
"Are you sure to delete the selected books?": "هل أنت متأكد من رغبتك في حذف الكتب المحددة؟",
"Auto Mode": "الوضع التلقائي",
"Behavior": "السلوك",
"Book": "الكتاب",
"Book Cover": "غلاف الكتاب",
"Bookmark": "علامة مرجعية",
"Cancel": "إلغاء",
"Chapter": "الفصل",
"Cherry": "كرزي",
"Color": "اللون",
"Confirm": "تأكيد",
"Confirm Deletion": "تأكيد الحذف",
"Copied to notebook": "تم النسخ إلى المفكرة",
"Copy": "نسخ",
"Custom CSS": "CSS مخصص",
"Dark Mode": "الوضع المظلم",
"Default": "الافتراضي",
"Default Font": "الخط الافتراضي",
"Default Font Size": "حجم الخط الافتراضي",
"Delete": "حذف",
"Delete Highlight": "حذف التمييز",
"Dictionary": "القاموس",
"Disable Click-to-Flip": "تعطيل التقليب بالنقر",
"Download Readest": "تحميل ريديست",
"Edit": "تحرير",
"Enter your custom CSS here...": "أدخل الـ CSS المخصص هنا...",
"Excerpts": "مقتطفات",
"Failed to import book(s): {{filenames}}": "فشل استيراد الكتاب/الكتب: {{filenames}}",
"Fast": "سريع",
"Font": "الخط",
"Font & Layout": "الخط والتخطيط",
"Font Face": "نوع الخط",
"Font Family": "عائلة الخط",
"Font Size": "حجم الخط",
"Full Justification": "محاذاة كاملة",
"Global Settings": "الإعدادات العامة",
"Go Back": "العودة",
"Go Forward": "التقدم",
"Go Left": "الذهاب لليسار",
"Go Right": "الذهاب لليمين",
"Grass": "عشبي",
"Gray": "رمادي",
"Gruvbox": "چروفبوكس",
"Highlight": "تمييز",
"Horizontal Direction": "الاتجاه الأفقي",
"Hyphenation": "الواصلة",
"Identifier:": "المعرف:",
"Import Books": "استيراد الكتب",
"Invert Colors in Dark Mode": "عكس الألوان في الوضع المظلم",
"Language:": "اللغة:",
"Layout": "التخطيط",
"Light Mode": "الوضع الفاتح",
"Loading...": "جارٍ التحميل...",
"Loc. {{currentPage}} / {{totalPage}}": "الصفحة {{currentPage}} \\ {{totalPage}}",
"Logged in": "تم تسجيل الدخول",
"Logged in as {{userDisplayName}}": "تم تسجيل الدخول باسم {{userDisplayName}}",
"Match Case": "مطابقة حالة الأحرف",
"Match Diacritics": "مطابقة التشكيل",
"Match Whole Words": "مطابقة الكلمات الكاملة",
"Maximum Block Size": "أقصى مساحة رأسية",
"Maximum Inline Size": "أقصى مساحة أفقية",
"Maximum Number of Columns": "العدد الأقصى للأعمدة",
"Minimum Font Size": "الحد الأدنى لحجم الخط",
"Misc": "أخرى",
"Monospace Font": "خط أحادي المسافة (Monospace)",
"More Info": "مزيد من المعلومات",
"Nord": "نورد",
"Notebook": "المفكرة",
"Notes": "الملاحظات",
"Open": "فتح",
"Original Text": "النص الأصلي",
"Page": "الصفحة",
"Paging Animation": "رسوم متحركةعند الانتقال إلى صفحة جديدة",
"Paragraph": "فقرة",
"Parallel Read": "قراءة متوازية",
"Published:": "تاريخ النشر:",
"Publisher:": "الناشر:",
"Reading Progress Synced": "تمت مزامنة تقدم القراءة",
"Reload Page": "إعادة تحميل الصفحة",
"Reveal in File Explorer": "إظهار في مستكشف الملفات",
"Reveal in Finder": "إظهار في Finder",
"Reveal in Folder": "إظهار في المجلد",
"Sans-Serif Font": "خط غير مذيل (Sans-Serif)",
"Save": "حفظ",
"Scrolled Mode": "وضع التمرير",
"Search": "البحث",
"Search Books...": "بحث في الكتب...",
"Search...": "بحث...",
"Select Book": "تحديد الكتاب",
"Select Books": "تحديد الكتب",
"Select Multiple Books": "تحديد عدة كتب",
"Sepia": "بني داكن",
"Serif Font": "خط مذيل (Serif)",
"Show Book Details": "عرض تفاصيل الكتاب",
"Sidebar": "الشريط الجانبي",
"Sign In": "تسجيل الدخول",
"Sign Out": "تسجيل الخروج",
"Sky": "سماوي",
"Slow": "بطيء",
"Solarized": "سولاريزد",
"Speak": "تحدث",
"Subjects:": "المواضيع:",
"System Fonts": "خطوط النظام",
"TTS not supported in this device": "القراءة الصوتية غير مدعومة في هذا الجهاز",
"Theme Color": "لون السمة",
"Theme Mode": "وضع السمة",
"Translate": "ترجمة",
"Translated Text": "النص المترجم",
"Unknown": "غير معروف",
"Untitled": "بدون عنوان",
"Updated:": "تم التحديث:",
"User avatar": "صورة المستخدم",
"Version {{version}}": "الإصدار {{version}}",
"Vertical Direction": "الاتجاه العمودي",
"Welcome to your library. You can import your books here and read them anytime.": "مرحبًا بكم في مكتبتكم. يمكنكم استيراد كتبكم هنا وقراءتها في أي وقت.",
"Wikipedia": "ويكيبيديا",
"Writing Mode": "وضع الكتابة",
"Your Library": "المكتبة الخاصة بك",
"TTS not supported for PDF": "القراءة الصوتية غير مدعومة لملفات PDF",
"Override Book Font": "تجاوز خط الكتاب",
"Vertical Margins (px)": "هوامش عمودية (بكسل)",
"Horizontal Margins (%)": "هوامش أفقية (%)",
"Apply to All Books": "تطبيق على جميع الكتب",
"Apply to This Book": "تطبيق على هذا الكتاب",
"Unable to fetch the translation. Try again later.": "تعذر جلب الترجمة. حاول مرة أخرى لاحقًا.",
"Update Now!": "قم بالتحديث الآن!",
"Update": "تحديث",
"Check Update": "التحقق من التحديث",
"Already the latest version": "الإصدار الحالي هو الإصدار الأحدث بالفعل",
"Book Details": "تفاصيل الكتاب",
"From Local File": "من ملف محلي",
"TOC": "جدول المحتويات",
"Book uploaded: {{title}}": "تم تحميل الكتاب: {{title}}",
"Failed to upload book: {{title}}": "فشل تحميل الكتاب: {{title}}",
"Book downloaded: {{title}}": "تم تنزيل الكتاب: {{title}}",
"Failed to download book: {{title}}": "فشل تنزيل الكتاب: {{title}}",
"Upload Book": "تحميل الكتاب",
"Auto Upload Books to Cloud": "تحميل الكتب تلقائيًا إلى السحابة",
"{{percentage}}% of Cloud Storage Used.": "تم استخدام {{percentage}}% من مساحة التخزين السحابية.",
"Storage": "التخزين",
"Book deleted: {{title}}": "تم حذف الكتاب: {{title}}",
"Failed to delete book: {{title}}": "فشل حذف الكتاب: {{title}}",
"Check Updates on Start": "التحقق من التحديثات عند البدء",
"Insufficient storage quota": "حصة التخزين غير كافية",
"Font Weight": "سمك الخط",
"Line Spacing": "تباعد الأسطر",
"Word Spacing": "تباعد الكلمات",
"Letter Spacing": "تباعد الأحرف",
"Text Indent": "مسافة بادئة للنص",
"Paragraph Margin": "هامش الفقرة",
"Override Book Layout": "تجاوز تخطيط الكتاب",
"Add to Group": "إضافة إلى مجموعة",
"Untitled Group": "مجموعة بدون عنوان",
"Group Books": "تجميع الكتب",
"Remove From Group": "إزالة من المجموعة",
"Create New Group": "إنشاء مجموعة جديدة",
"Deselect Book": "إلغاء تحديد الكتاب",
"Download Book": "تنزيل الكتاب",
"Deselect Group": "إلغاء تحديد المجموعة",
"Select Group": "تحديد المجموعة",
"Keep Screen Awake": "إبقاء الشاشة نشطة",
"Email address": "البريد الإلكتروني",
"Your Password": "كلمة المرور",
"Your email address": "البريد الإلكتروني الخاص بك",
"Your password": "كلمة المرور الخاصة بك",
"Sign in": "تسجيل الدخول",
"Signing in...": "يتم الآن تسجيل الدخول...",
"Sign in with {{provider}}": "تسجيل الدخول باستخدام {{provider}}",
"Already have an account? Sign in": "هل لديك حساب بالفعل؟ تسجيل الدخول",
"Create a Password": "إنشاء كلمة مرور",
"Sign up": "إنشاء حساب",
"Signing up...": "يتم الآن إنشاء الحساب...",
"Dont have an account? Sign up": "ليس لديك حساب؟ قم بالتسجيل",
"Check your email for the confirmation link": "تحقق من بريدك الإلكتروني للحصول على رابط التأكيد",
"Signing in ...": "يتم الآن تسجيل الدخول ...",
"Send a magic link email": "إرسال رابط سحري إلى بريدك الإلكتروني",
"Check your email for the magic link": "تحقق من بريدك الإلكتروني للحصول على الرابط السحري",
"Send reset password instructions": "أرسل تعليمات إعادة تعيين كلمة المرور",
"Sending reset instructions ...": "يتم الآن إرسال تعليمات إعادة تعيين كلمة المرور ...",
"Forgot your password?": "نسيت كلمة المرور؟",
"Check your email for the password reset link": "تحقق من بريدك الإلكتروني للحصول على رابط إعادة تعيين كلمة المرور",
"New Password": "كلمة المرور الجديدة",
"Your new password": "أدخل كلمة المرور الجديدة",
"Update password": "تغيير كلمة المرور",
"Updating password ...": "يتم الآن تغيير كلمة المرور ...",
"Your password has been updated": "تم تغيير كلمة المرور الخاصة بك بنجاح",
"Phone number": "رقم الجوال",
"Your phone number": "أدخل رقم الجوال",
"Token": "الرمز",
"Your OTP token": "رمز OTP الذي وصلك",
"Verify token": "التحقق من الرمز",
"Sign in with Google": "تسجيل الدخول عبر Google",
"Sign in with Apple": "تسجيل الدخول عبر Apple",
"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": "專業方案"
}
+18
View File
@@ -1,5 +1,23 @@
{
"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": [
"Add Arabic localization",
"Add Dockerfile to selfhost",
"Add safari-auth plugin for iOS OAuth",
"UX enhancements for mobile platforms with haptics feedback and pull-down dismissal"
]
},
"0.9.15": {
"date": "2025-02-21",
"notes": [
+2
View File
@@ -45,6 +45,8 @@ tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.2"
tauri-plugin-deep-link = "2"
tauri-plugin-sign-in-with-apple = "1.0.2"
tauri-plugin-haptics = "2.2.3"
tauri-plugin-safari-auth = { path = "./plugins/tauri-plugin-safari-auth" }
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
@@ -70,6 +70,11 @@
"oauth:allow-cancel",
"sign-in-with-apple:default",
"opener:default",
"haptics:allow-vibrate",
"haptics:allow-impact-feedback",
"haptics:allow-notification-feedback",
"haptics:allow-selection-feedback",
"safari-auth:default",
"deep-link:default"
]
}
@@ -0,0 +1,17 @@
/.vs
.DS_Store
.Thumbs.db
*.sublime*
.idea/
debug.log
package-lock.json
.vscode/settings.json
yarn.lock
/.tauri
/target
Cargo.lock
node_modules/
dist-js
dist
@@ -0,0 +1,17 @@
[package]
name = "tauri-plugin-safari-auth"
version = "0.1.0"
authors = [ "chrox" ]
description = "OAuth with ASWebAuthenticationSession on iOS"
edition = "2021"
rust-version = "1.77.2"
exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-safari-auth"
[dependencies]
tauri = { version = "2.2.4" }
serde = "1.0"
thiserror = "2"
[build-dependencies]
tauri-plugin = { version = "2.0.3", features = ["build"] }
@@ -0,0 +1 @@
# Tauri Plugin safari-auth
@@ -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"))
}
@@ -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')
@@ -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)
}
}
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -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
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -0,0 +1,8 @@
const COMMANDS: &[&str] = &["auth_with_safari"];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.android_path("android")
.ios_path("ios")
.build();
}
@@ -0,0 +1,10 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
Package.resolved
@@ -0,0 +1,32 @@
// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "tauri-plugin-safari-auth",
platforms: [
.macOS(.v10_13),
.iOS(.v13),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-safari-auth",
type: .static,
targets: ["tauri-plugin-safari-auth"]),
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-safari-auth",
dependencies: [
.byName(name: "Tauri")
],
path: "Sources")
]
)
@@ -0,0 +1,3 @@
# Tauri Plugin safari-auth
A description of this package.
@@ -0,0 +1,59 @@
import AuthenticationServices
import SwiftRs
import Tauri
import UIKit
import WebKit
class RequestArgs: Decodable {
let authUrl: String
}
struct Response: Encodable {
let redirectUrl: String
}
class SafariAuthPlugin: Plugin {
private var authSession: ASWebAuthenticationSession?
@objc public func auth_with_safari(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(RequestArgs.self)
let authUrl = URL(string: args.authUrl)!
authSession = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: "readest") {
[weak self] callbackURL, error in
guard let strongSelf = self else { return }
if let error = error {
Logger.error("Auth session error: \(error.localizedDescription)")
invoke.reject(error.localizedDescription)
return
}
if let callbackURL = callbackURL {
Logger.info("Auth session callback URL: \(callbackURL.absoluteString)")
strongSelf.authSession?.cancel()
strongSelf.authSession = nil
invoke.resolve(["redirectUrl": callbackURL.absoluteString])
}
}
if #available(iOS 13.0, *) {
authSession?.presentationContextProvider = self
}
let started = authSession?.start() ?? false
Logger.info("Auth session start result: \(started)")
}
}
@_cdecl("init_plugin_safari_auth")
func initPlugin() -> Plugin {
return SafariAuthPlugin()
}
@available(iOS 13.0, *)
extension SafariAuthPlugin: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return UIApplication.shared.windows.first ?? UIWindow()
}
}
@@ -0,0 +1,8 @@
import XCTest
@testable import SafariAuthPlugin
final class SafariAuthPluginTests: XCTestCase {
func testExample() throws {
let plugin = SafariAuthPlugin()
}
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-auth-with-safari"
description = "Enables the auth_with_safari command without any pre-configured scope."
commands.allow = ["auth_with_safari"]
[[permission]]
identifier = "deny-auth-with-safari"
description = "Denies the auth_with_safari command without any pre-configured scope."
commands.deny = ["auth_with_safari"]
@@ -0,0 +1,41 @@
## Default Permission
Default permissions for the plugin
- `allow-auth-with-safari`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`safari-auth:allow-auth-with-safari`
</td>
<td>
Enables the auth_with_safari command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`safari-auth:deny-auth-with-safari`
</td>
<td>
Denies the auth_with_safari command without any pre-configured scope.
</td>
</tr>
</table>
@@ -0,0 +1,3 @@
[default]
description = "Default permissions for the plugin"
permissions = ["allow-auth-with-safari"]
@@ -0,0 +1,315 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the auth_with_safari command without any pre-configured scope.",
"type": "string",
"const": "allow-auth-with-safari"
},
{
"description": "Denies the auth_with_safari command without any pre-configured scope.",
"type": "string",
"const": "deny-auth-with-safari"
},
{
"description": "Default permissions for the plugin",
"type": "string",
"const": "default"
}
]
}
}
}
@@ -0,0 +1,13 @@
use tauri::{command, AppHandle, Runtime};
use crate::models::*;
use crate::Result;
use crate::SafariAuthExt;
#[command]
pub(crate) async fn auth_with_safari<R: Runtime>(
app: AppHandle<R>,
payload: SafariAuthRequest,
) -> Result<SafariAuthResponse> {
app.safari_auth().auth_with_safari(payload)
}
@@ -0,0 +1,23 @@
use serde::de::DeserializeOwned;
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::models::*;
pub fn init<R: Runtime, C: DeserializeOwned>(
app: &AppHandle<R>,
_api: PluginApi<R, C>,
) -> crate::Result<SafariAuth<R>> {
Ok(SafariAuth(app.clone()))
}
/// Access to the safari-auth APIs.
pub struct SafariAuth<R: Runtime>(AppHandle<R>);
impl<R: Runtime> SafariAuth<R> {
pub fn auth_with_safari(
&self,
_payload: SafariAuthRequest,
) -> crate::Result<SafariAuthResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -0,0 +1,23 @@
use serde::{ser::Serializer, Serialize};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Unsupported platform for this plugin")]
UnsupportedPlatformError,
#[error(transparent)]
Io(#[from] std::io::Error),
#[cfg(mobile)]
#[error(transparent)]
PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
@@ -0,0 +1,48 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
pub use models::*;
#[cfg(desktop)]
mod desktop;
#[cfg(mobile)]
mod mobile;
mod commands;
mod error;
mod models;
pub use error::{Error, Result};
#[cfg(desktop)]
use desktop::SafariAuth;
#[cfg(mobile)]
use mobile::SafariAuth;
/// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the safari-auth APIs.
pub trait SafariAuthExt<R: Runtime> {
fn safari_auth(&self) -> &SafariAuth<R>;
}
impl<R: Runtime, T: Manager<R>> crate::SafariAuthExt<R> for T {
fn safari_auth(&self) -> &SafariAuth<R> {
self.state::<SafariAuth<R>>().inner()
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("safari-auth")
.invoke_handler(tauri::generate_handler![commands::auth_with_safari])
.setup(|app, api| {
#[cfg(mobile)]
let safari_auth = mobile::init(app, api)?;
#[cfg(desktop)]
let safari_auth = desktop::init(app, api)?;
app.manage(safari_auth);
Ok(())
})
.build()
}
@@ -0,0 +1,37 @@
use serde::de::DeserializeOwned;
use tauri::{
plugin::{PluginApi, PluginHandle},
AppHandle, Runtime,
};
use crate::models::*;
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_safari_auth);
// initializes the Kotlin or Swift plugin classes
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))
}
/// Access to the safari-auth APIs.
pub struct SafariAuth<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> SafariAuth<R> {
pub fn auth_with_safari(
&self,
payload: SafariAuthRequest,
) -> crate::Result<SafariAuthResponse> {
self.0
.run_mobile_plugin("auth_with_safari", payload)
.map_err(Into::into)
}
}
@@ -0,0 +1,13 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SafariAuthRequest {
pub auth_url: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SafariAuthResponse {
pub redirect_url: String,
}
+8 -2
View File
@@ -9,7 +9,7 @@ extern crate objc;
#[cfg(target_os = "macos")]
mod menu;
#[cfg(target_os = "macos")]
mod traffic_light_plugin;
mod traffic_light;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -127,11 +127,17 @@ pub fn run() {
let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
#[cfg(target_os = "macos")]
let builder = builder.plugin(traffic_light_plugin::init());
let builder = builder.plugin(traffic_light::init());
#[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());
builder
.setup(|#[allow(unused_variables)] app| {
#[cfg(desktop)]
@@ -7,14 +7,14 @@ use tauri::{
}; // 0.8
const WINDOW_CONTROL_PAD_X: f64 = 10.0;
const WINDOW_CONTROL_PAD_Y: f64 = 20.0;
const WINDOW_CONTROL_PAD_Y: f64 = 22.0;
struct UnsafeWindowHandle(*mut std::ffi::c_void);
unsafe impl Send for UnsafeWindowHandle {}
unsafe impl Sync for UnsafeWindowHandle {}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("traffic_light_positioner")
Builder::new("traffic_light")
.on_window_ready(|window| {
#[cfg(target_os = "macos")]
setup_traffic_light_positioner(window.clone());
+1 -5
View File
@@ -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
+23 -9
View File
@@ -22,6 +22,7 @@ import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oau
import { openUrl } from '@tauri-apps/plugin-opener';
import { handleAuthCallback } from '@/helpers/auth';
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
import { authWithSafari } from './utils/safariAuth';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
@@ -66,12 +67,16 @@ export default function AuthPage() {
const isOAuthServerRunning = useRef(false);
const [isMounted, setIsMounted] = useState(false);
const getTauriRedirectTo = () => {
return process.env.NODE_ENV === 'production' || appService?.isMobile
? appService?.isMobile
? WEB_AUTH_CALLBACK
: DEEPLINK_CALLBACK
: `http://localhost:${port}`; // only for development env on Desktop
const getTauriRedirectTo = (isOAuth: boolean) => {
if (process.env.NODE_ENV === 'production' || appService?.isMobile) {
if (appService?.isIOSApp) {
return isOAuth ? DEEPLINK_CALLBACK : WEB_AUTH_CALLBACK;
} else if (appService?.isAndroidApp) {
return WEB_AUTH_CALLBACK;
}
return DEEPLINK_CALLBACK;
}
return `http://localhost:${port}`; // only for development env on Desktop
};
const tauriSignInApple = async () => {
@@ -103,7 +108,7 @@ export default function AuthPage() {
provider,
options: {
skipBrowserRedirect: true,
redirectTo: getTauriRedirectTo(),
redirectTo: getTauriRedirectTo(true),
},
});
@@ -111,7 +116,16 @@ export default function AuthPage() {
console.error('Authentication error:', error);
return;
}
await openUrl(data.url);
// Open the OAuth URL in a ASWebAuthenticationSession on iOS to comply with Apple's guidelines
// for other platforms, open the OAuth URL in the default browser
if (appService?.isIOSApp) {
const res = await authWithSafari({ authUrl: data.url });
if (res) {
handleOAuthUrl(res.redirectUrl);
}
} else {
await openUrl(data.url);
}
};
const handleOAuthUrl = async (url: string) => {
@@ -325,7 +339,7 @@ export default function AuthPage() {
theme={isDarkMode ? 'dark' : 'light'}
magicLink={true}
providers={[]}
redirectTo={getTauriRedirectTo()}
redirectTo={getTauriRedirectTo(false)}
localization={getAuthLocalization()}
/>
</div>
@@ -0,0 +1,17 @@
import { invoke } from '@tauri-apps/api/core';
export interface SafariAuthRequest {
authUrl: string;
}
export interface SafariAuthResponse {
redirectUrl: string;
}
export async function authWithSafari(request: SafariAuthRequest): Promise<SafariAuthResponse> {
const result = await invoke<SafariAuthResponse>('plugin:safari-auth|auth_with_safari', {
payload: request,
});
return result;
}
@@ -219,7 +219,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
};
const { pressing, handlers } = useLongPress({
onLongPress: () => {
onLongPress: async () => {
if (!isSelectMode) {
handleSetSelectMode(true);
}
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import React, { useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
@@ -16,6 +16,7 @@ import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SettingsMenu from './SettingsMenu';
import ImportMenu from './ImportMenu';
import useShortcuts from '@/hooks/useShortcuts';
interface LibraryHeaderProps {
isSelectMode: boolean;
@@ -37,17 +38,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const iconSize16 = useResponsiveSize(16);
const iconSize20 = useResponsiveSize(20);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.shiftKey) {
onToggleSelectMode();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [onToggleSelectMode]);
useShortcuts({
onToggleSelectMode,
});
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const isInGroupView = !!searchParams?.get('group');
@@ -56,7 +49,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar z-10 flex h-11 w-full items-center py-2 pr-4 sm:pr-6',
'titlebar h-13 z-10 flex w-full items-center py-2 pr-4 sm:pr-6',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
@@ -75,7 +68,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</div>
</button>
)}
<div className='relative flex h-7 w-full items-center'>
<div className='relative flex h-9 w-full items-center sm:h-7'>
<span className='absolute left-3 text-gray-500'>
<FaSearch className='h-4 w-4' />
</span>
@@ -84,14 +77,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
placeholder={_('Search Books...')}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
'input rounded-badge bg-base-300/50 h-9 w-full pl-10 pr-10 sm:h-7',
'font-sans text-sm font-light',
'border-none focus:outline-none focus:ring-0',
)}
/>
</div>
<div className='absolute right-4 flex items-center space-x-2 text-gray-500 sm:space-x-4'>
<span className='mx-2 h-6 w-[1px] bg-gray-400'></span>
<span className='bg-base-content/50 mx-2 h-4 w-[0.5px]'></span>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center'
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center'
@@ -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>
) : (
+21 -3
View File
@@ -16,6 +16,7 @@ import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
@@ -26,6 +27,8 @@ import { useSettingsStore } from '@/store/settingsStore';
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { tauriQuitApp } from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
@@ -33,7 +36,7 @@ import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import BookDetailModal from '@/components/BookDetailModal';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import useShortcuts from '@/hooks/useShortcuts';
const LibraryPage = () => {
const router = useRouter();
@@ -68,6 +71,14 @@ const LibraryPage = () => {
usePullToRefresh(containerRef, pullLibrary);
useScreenWakeLock(settings.screenWakeLock);
useShortcuts({
onQuitApp: async () => {
if (isTauriAppPlatform()) {
await tauriQuitApp();
}
},
});
useEffect(() => {
updateAppTheme('base-200');
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -335,10 +346,16 @@ const LibraryPage = () => {
};
const handleToggleSelectMode = () => {
setIsSelectMode(!isSelectMode);
if (!isSelectMode && appService?.hasHaptics) {
impactFeedback('medium');
}
setIsSelectMode((pre) => !pre);
};
const handleSetSelectMode = (selectMode: boolean) => {
if (selectMode && appService?.hasHaptics) {
impactFeedback('medium');
}
setIsSelectMode(selectMode);
};
@@ -385,8 +402,9 @@ const LibraryPage = () => {
<div
ref={containerRef}
className={clsx(
'mt-12 flex-grow overflow-auto px-4 sm:px-2',
'scroll-container mt-12 flex-grow overflow-auto px-4 sm:px-2',
appService?.hasSafeAreaInset && 'mt-[calc(48px+env(safe-area-inset-top))]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
)}
>
<Bookshelf
@@ -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,
@@ -1,6 +1,7 @@
import React from 'react';
import { TbLayoutSidebar, TbLayoutSidebarFilled } from 'react-icons/tb';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import Button from '@/components/Button';
@@ -12,6 +13,7 @@ interface SidebarTogglerProps {
const SidebarToggler: React.FC<SidebarTogglerProps> = ({ bookKey }) => {
const _ = useTranslation();
const { sideBarBookKey, isSideBarVisible, setSideBarBookKey, toggleSideBar } = useSidebarStore();
const { setHoveredBookKey } = useReaderStore();
const handleToggleSidebar = () => {
if (sideBarBookKey === bookKey) {
toggleSideBar();
@@ -19,6 +21,7 @@ const SidebarToggler: React.FC<SidebarTogglerProps> = ({ bookKey }) => {
setSideBarBookKey(bookKey);
if (!isSideBarVisible) toggleSideBar();
}
setHoveredBookKey('');
};
return (
<Button
@@ -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,
@@ -357,6 +378,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
if (existingIndex !== -1) {
views.forEach((view) => view?.addAnnotation(annotation, true));
if (update) {
annotation.id = annotations[existingIndex]!.id;
annotations[existingIndex] = annotation;
views.forEach((view) => view?.addAnnotation(annotation));
} else {
@@ -37,8 +37,8 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
onHandleHighlight(true);
};
const handleSelectColor = (color: HighlightColor) => {
const style = globalReadSettings.highlightStyle;
globalReadSettings.highlightStyles[style] = color;
globalReadSettings.highlightStyle = selectedStyle;
globalReadSettings.highlightStyles[selectedStyle] = color;
setSettings(settings);
setSelectedColor(color);
onHandleHighlight(true);
@@ -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>
);
@@ -9,11 +9,11 @@ import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { useEnv } from '@/context/EnvContext';
import { useDrag } from '@/hooks/useDrag';
import { TextSelection } from '@/utils/sel';
import { BookNote } from '@/types/book';
import { uniqueId } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import useDragBar from '../../hooks/useDragBar';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
import NoteEditor from './NoteEditor';
@@ -81,12 +81,6 @@ const Notebook: React.FC = ({}) => {
setNotebookEditAnnotation(null);
};
const handleDragMove = (e: MouseEvent) => {
const widthFraction = 1 - e.clientX / window.innerWidth;
const newWidth = Math.max(MIN_NOTEBOOK_WIDTH, Math.min(MAX_NOTEBOOK_WIDTH, widthFraction));
handleNotebookResize(`${Math.round(newWidth * 10000) / 100}%`);
};
const handleSaveNote = (selection: TextSelection, note: string) => {
if (!sideBarBookKey) return;
const view = getView(sideBarBookKey);
@@ -132,7 +126,13 @@ const Notebook: React.FC = ({}) => {
setNotebookEditAnnotation(null);
};
const { handleMouseDown } = useDragBar(handleDragMove);
const onDragMove = (data: { clientX: number }) => {
const widthFraction = 1 - data.clientX / window.innerWidth;
const newWidth = Math.max(MIN_NOTEBOOK_WIDTH, Math.min(MAX_NOTEBOOK_WIDTH, widthFraction));
handleNotebookResize(`${Math.round(newWidth * 10000) / 100}%`);
};
const { handleDragStart } = useDrag(onDragMove);
if (!sideBarBookKey) return null;
@@ -175,7 +175,7 @@ const Notebook: React.FC = ({}) => {
`}</style>
<div
className='drag-bar absolute left-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleMouseDown}
onMouseDown={handleDragStart}
/>
<NotebookHeader
isPinned={isNotebookPinned}
@@ -64,6 +64,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
/>
}
label={book.title}
labelClass='max-w-36'
onClick={() => handleParallelView(book.hash)}
/>
))}
@@ -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';
@@ -9,11 +10,11 @@ import { BookSearchResult } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useDrag } from '@/hooks/useDrag';
import SidebarHeader from './Header';
import SidebarContent from './Content';
import BookCard from './BookCard';
import useSidebar from '../../hooks/useSidebar';
import useDragBar from '../../hooks/useDragBar';
import SearchBar from './SearchBar';
import SearchResults from './SearchResults';
import useShortcuts from '@/hooks/useShortcuts';
@@ -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 }) => {
@@ -33,6 +36,7 @@ const SideBar: React.FC<{
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
const [searchResults, setSearchResults] = useState<BookSearchResult[] | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const isMobile = window.innerWidth < 640;
const {
sideBarWidth,
isSideBarPinned,
@@ -42,7 +46,7 @@ const SideBar: React.FC<{
handleSideBarTogglePin,
} = useSidebar(
settings.globalReadSettings.sideBarWidth,
window.innerWidth >= 640 ? settings.globalReadSettings.isSideBarPinned : false,
isMobile ? false : settings.globalReadSettings.isSideBarPinned,
);
const onSearchEvent = async (event: CustomEvent) => {
@@ -79,12 +83,62 @@ const SideBar: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDragMove = (e: MouseEvent) => {
const widthFraction = e.clientX / window.innerWidth;
const handleVerticalDragMove = (data: { clientY: number }) => {
if (!isMobile) return;
const heightFraction = data.clientY / window.innerHeight;
const newTop = Math.max(0.0, Math.min(1, heightFraction));
const sidebar = document.querySelector('.sidebar-container') as HTMLElement;
const overlay = document.querySelector('.overlay') as HTMLElement;
if (sidebar && overlay) {
sidebar.style.top = `${newTop * 100}%`;
overlay.style.opacity = `${1 - heightFraction}`;
}
};
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 (
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 ${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');
}
}
};
const handleHorizontalDragMove = (data: { clientX: number }) => {
const widthFraction = data.clientX / window.innerWidth;
const newWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, widthFraction));
handleSideBarResize(`${Math.round(newWidth * 10000) / 100}%`);
};
const { handleMouseDown } = useDragBar(handleDragMove);
const { handleDragStart: handleVerticalDragStart } = useDrag(
handleVerticalDragMove,
handleVerticalDragEnd,
);
const { handleDragStart: handleHorizontalDragStart } = useDrag(handleHorizontalDragMove);
const handleClickOverlay = () => {
setSideBarVisible(false);
@@ -135,10 +189,27 @@ const SideBar: React.FC<{
.sidebar-container {
width: 100%;
min-width: 100%;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.sidebar-container.open {
top: 0%;
}
.overlay {
transition: opacity 0.3s ease-in-out;
}
}
`}</style>
<div className='flex-shrink-0'>
{isMobile && (
<div
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
onMouseDown={handleVerticalDragStart}
onTouchStart={handleVerticalDragStart}
>
<div className='bg-base-content/50 h-1 w-10 rounded-full'></div>
</div>
)}
<SidebarHeader
isPinned={isSideBarPinned}
isSearchBarVisible={isSearchBarVisible}
@@ -174,11 +245,14 @@ const SideBar: React.FC<{
)}
<div
className='drag-bar absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleMouseDown}
onMouseDown={handleHorizontalDragStart}
></div>
</div>
{!isSideBarPinned && (
<div className='overlay fixed inset-0 z-10 bg-black/20' onClick={handleClickOverlay} />
<div
className='overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20'
onClick={handleClickOverlay}
/>
)}
</>
) : null;
@@ -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
@@ -7,7 +7,7 @@ import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { eventDispatcher } from '@/utils/event';
import { tauriQuitApp } from '@/utils/window';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
interface UseBookShortcutsProps {
@@ -61,6 +61,22 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
getView(sideBarBookKey)?.history.back();
};
const goHalfPageDown = () => {
const view = getView(sideBarBookKey);
const viewSettings = getViewSettings(sideBarBookKey ?? '');
if (view && viewSettings && viewSettings.scrolled) {
view.next(view.renderer.size / 2);
}
};
const goHalfPageUp = () => {
const view = getView(sideBarBookKey);
const viewSettings = getViewSettings(sideBarBookKey ?? '');
if (view && viewSettings && viewSettings.scrolled) {
view.prev(view.renderer.size / 2);
}
};
const goForward = () => {
getView(sideBarBookKey)?.history.forward();
};
@@ -72,9 +88,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const quitApp = async () => {
// on web platform use browser's default shortcut to close the tab
if (isTauriAppPlatform()) {
await eventDispatcher.dispatch('quit-app');
const { exit } = await import('@tauri-apps/plugin-process');
await exit(0);
await tauriQuitApp();
}
};
@@ -123,6 +137,8 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onGoRight: goRight,
onGoPrev: goPrev,
onGoNext: goNext,
onGoHalfPageDown: goHalfPageDown,
onGoHalfPageUp: goHalfPageUp,
onGoBack: goBack,
onGoForward: goForward,
onZoomIn: zoomIn,
@@ -1,28 +0,0 @@
import { useCallback } from 'react';
const useDragBar = (handleDragMove: (e: MouseEvent) => void) => {
const handleMouseMove = useCallback(
(e: MouseEvent) => {
handleDragMove(e);
},
[handleDragMove],
);
const handleMouseUp = useCallback(() => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}, [handleMouseMove]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[handleMouseMove, handleMouseUp],
);
return { handleMouseDown };
};
export default useDragBar;
@@ -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]);
+288
View File
@@ -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'
>
+69 -5
View File
@@ -2,7 +2,11 @@ import clsx from 'clsx';
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 VELOCITY_THRESHOLD = 0.5;
interface DialogProps {
id?: string;
@@ -11,6 +15,7 @@ interface DialogProps {
header?: ReactNode;
title?: string;
className?: string;
bgClassName?: string;
boxClassName?: string;
contentClassName?: string;
onClose: () => void;
@@ -23,12 +28,15 @@ const Dialog: React.FC<DialogProps> = ({
header,
title,
className,
bgClassName,
boxClassName,
contentClassName,
onClose,
}) => {
const { appService } = useEnv();
const iconSize22 = useResponsiveSize(22);
const isMobile = window.innerWidth < 640;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
@@ -43,23 +51,79 @@ const Dialog: React.FC<DialogProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDragMove = (data: { clientY: number; deltaY: number }) => {
if (!isMobile) return;
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 = '';
modal.style.transform = `translateY(${newTop * 100}%)`;
overlay.style.opacity = `${1 - heightFraction}`;
}
};
const handleDragEnd = (data: { velocity: number; clientY: number }) => {
const modal = document.querySelector('.modal-box') as HTMLElement;
const overlay = document.querySelector('.overlay') as HTMLElement;
if (!modal || !overlay) return;
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`;
modal.style.transform = `translateY(0%)`;
overlay.style.opacity = '0';
if (appService?.hasHaptics) {
impactFeedback('medium');
}
}
};
const { handleDragStart } = useDrag(handleDragMove, handleDragEnd);
return (
<dialog
id={id ?? 'dialog'}
open={isOpen}
className={clsx(
'modal sm:min-w-90 z-50 h-full w-full !bg-[rgba(0,0,0,0.2)] 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={clsx('overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20', bgClassName)} />
<div
className={clsx(
'modal-box settings-content flex flex-col rounded-none p-0 sm:rounded-2xl',
'modal-box settings-content z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
'h-full max-h-full w-full max-w-full sm:w-[65%] sm:max-w-[600px]',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)] sm:pt-0',
boxClassName,
)}
>
{window.innerWidth < 640 && (
<div
className='drag-handle flex h-10 w-full cursor-row-resize items-center justify-center'
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
>
<div className='bg-base-content/50 h-1 w-10 rounded-full'></div>
</div>
)}
<div className='dialog-header bg-base-100 sticky top-1 z-10 flex items-center justify-between px-4'>
{header ? (
header
+36 -13
View File
@@ -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>
);
};
+8 -1
View File
@@ -4,6 +4,7 @@ export interface ShortcutConfig {
onToggleNotebook: string[];
onToggleSearchBar: string[];
onToggleScrollMode: string[];
onToggleSelectMode: string[];
onOpenFontLayoutSettings: string[];
onReloadPage: string[];
onQuitApp: string[];
@@ -11,6 +12,8 @@ export interface ShortcutConfig {
onGoRight: string[];
onGoNext: string[];
onGoPrev: string[];
onGoHalfPageDown: string[];
onGoHalfPageUp: string[];
onGoBack: string[];
onGoForward: string[];
onZoomIn: string[];
@@ -26,6 +29,7 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
onToggleNotebook: ['n'],
onToggleSearchBar: ['ctrl+f', 'cmd+f'],
onToggleScrollMode: ['shift+j'],
onToggleSelectMode: ['shift+s'],
onOpenFontLayoutSettings: ['shift+f'],
onReloadPage: ['shift+r'],
onQuitApp: ['ctrl+q', 'cmd+q'],
@@ -33,17 +37,20 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
onGoRight: ['ArrowRight', 'PageDown', 'l', ' '],
onGoNext: ['ArrowDown', 'j'],
onGoPrev: ['ArrowUp', 'k'],
onGoHalfPageDown: ['shift+ArrowDown', 'd'],
onGoHalfPageUp: ['shift+ArrowUp', 'u'],
onGoBack: ['shift+ArrowLeft', 'shift+h'],
onGoForward: ['shift+ArrowRight', 'shift+l'],
onZoomIn: ['ctrl+=', 'cmd+=', 'shift+='],
onZoomOut: ['ctrl+-', 'cmd+-', 'shift+-'],
onResetZoom: ['ctrl+0', 'cmd+0'],
onSaveNote: ['ctrl+Enter'],
onCloseNote: ['Escape']
onCloseNote: ['Escape'],
};
// Load shortcuts from localStorage or fallback to defaults
export const loadShortcuts = (): ShortcutConfig => {
if (typeof localStorage === 'undefined') return DEFAULT_SHORTCUTS;
const customShortcuts = JSON.parse(localStorage.getItem('customShortcuts') || '{}');
return {
...DEFAULT_SHORTCUTS,
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useRef } from 'react';
export const useDrag = (
onDragMove: (data: { clientX: number; clientY: number; deltaX: number; deltaY: 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);
const startY = useRef(0);
const lastX = useRef(0);
const lastY = useRef(0);
const startTime = useRef(0);
const handleDragStart = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
isDragging.current = true;
if ('touches' in e) {
startY.current = e.touches[0]!.clientY;
startX.current = e.touches[0]!.clientX;
} else {
startY.current = e.clientY;
startX.current = e.clientX;
}
startTime.current = performance.now();
const handleMove = (event: MouseEvent | TouchEvent) => {
if (isDragging.current) {
let deltaX = 0;
let deltaY = 0;
let clientX = 0;
let clientY = 0;
if ('touches' in event && event.touches.length > 0) {
const currentTouch = event.touches[0]!;
clientX = currentTouch.clientX;
clientY = currentTouch.clientY;
} else {
const evt = event as MouseEvent;
clientX = evt.clientX;
clientY = evt.clientY;
}
deltaX = clientX - lastX.current;
deltaY = clientY - lastY.current;
lastX.current = clientX;
lastY.current = clientY;
onDragMove({ clientX, clientY, deltaX, deltaY });
}
};
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;
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, deltaT, clientX, clientY, deltaX, deltaY });
}
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleEnd);
window.removeEventListener('touchmove', handleMove);
window.removeEventListener('touchend', handleEnd);
};
window.addEventListener('mousemove', handleMove, { passive: true });
window.addEventListener('mouseup', handleEnd);
window.addEventListener('touchmove', handleMove, { passive: true });
window.addEventListener('touchend', handleEnd);
},
[onDragMove, onDragEnd],
);
return { handleDragStart };
};
@@ -25,7 +25,7 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
const initialX = startEvent.touches[0]!.clientX;
const initialY = startEvent.touches[0]!.clientY;
el.addEventListener('touchmove', handleTouchMove, { passive: false });
el.addEventListener('touchmove', handleTouchMove, { passive: true });
el.addEventListener('touchend', handleTouchEnd);
function handleTouchMove(moveEvent: TouchEvent) {
@@ -16,7 +16,7 @@ export const useScreenWakeLock = (lock: boolean) => {
console.log('Wake lock acquired');
}
} catch (err) {
console.error('Failed to acquire wake lock:', err);
console.info('Failed to acquire wake lock:', err);
}
};
+2 -22
View File
@@ -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 },
+21
View File
@@ -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');
}
};
@@ -1,6 +1,7 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { supabase } from '@/utils/supabase';
import { getUserPlan } from '@/utils/access';
const DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
const DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
@@ -18,11 +19,22 @@ const getUserAndToken = async (authHeader: string | undefined) => {
return { user, token };
};
const getDeepLAPIKey = (keys: string | undefined) => {
const keyArray = keys?.split(',') ?? [];
return keyArray.length ? keyArray[Math.floor(Math.random() * keyArray.length)] : '';
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { user, token } = await getUserAndToken(req.headers['authorization']);
const deeplApiUrl = user && token ? DEEPL_PRO_API : DEEPL_FREE_API;
let deeplApiUrl = DEEPL_FREE_API;
if (user && token) {
const userPlan = getUserPlan(token);
if (userPlan !== 'free') deeplApiUrl = DEEPL_PRO_API;
}
const deeplAuthKey =
user && token ? process.env['DEEPL_PRO_API_KEY'] : process.env['DEEPL_FREE_API_KEY'];
deeplApiUrl === DEEPL_PRO_API
? getDeepLAPIKey(process.env['DEEPL_PRO_API_KEYS'])
: getDeepLAPIKey(process.env['DEEPL_FREE_API_KEYS']);
await runMiddleware(req, res, corsAllMethods);
@@ -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' });
}
}
@@ -52,6 +52,7 @@ export abstract class BaseAppService implements AppService {
abstract hasContextMenu: boolean;
abstract hasRoundedWindow: boolean;
abstract hasSafeAreaInset: boolean;
abstract hasHaptics: boolean;
abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string };
abstract getCoverImageUrl(book: Book): string;
@@ -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
};
@@ -126,6 +126,7 @@ export class NativeAppService extends BaseAppService {
hasContextMenu = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android';
hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android';
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
@@ -188,6 +188,7 @@ export class WebAppService extends BaseAppService {
hasContextMenu = false;
hasRoundedWindow = false;
hasSafeAreaInset = isPWA();
hasHaptics = false;
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
+1
View File
@@ -204,6 +204,7 @@ foliate-view {
.scroll-container {
overflow-y: scroll;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.scroll-container.hidden-scrollbar {
+1
View File
@@ -29,6 +29,7 @@ export interface AppService {
hasContextMenu: boolean;
hasRoundedWindow: boolean;
hasSafeAreaInset: boolean;
hasHaptics: boolean;
isMobile: boolean;
isAppDataSandbox: boolean;
isAndroidApp: boolean;
+2 -2
View File
@@ -36,8 +36,8 @@ export interface FoliateView extends HTMLElement {
};
renderer: {
scrolled?: boolean;
size: number;
viewSize: number;
size: number; // current page height
viewSize: number; // whole document view height
start: number;
end: number;
setAttribute: (name: string, value: string | number) => void;
+5
View File
@@ -10,6 +10,11 @@ interface Token {
[key: string]: string | number;
}
export const getUserPlan = (token: string): UserPlan => {
const data = jwtDecode<Token>(token) || {};
return data['plan'] || 'free';
};
export const getStoragePlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan = data['plan'] || 'free';
+22
View File
@@ -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;
};
+4
View File
@@ -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,

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