forked from akai/readest
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95af3bd3d1 | |||
| 5bdc29fe84 | |||
| 56f4b275f4 | |||
| 00b46dd8bb | |||
| 8ce89ca8f6 | |||
| 648d9ec260 | |||
| ebe1c10c84 | |||
| f2309ef496 | |||
| 8d71593f39 | |||
| 09e3c30a2a | |||
| eee4cbeaca | |||
| 00deca5029 | |||
| c7c20fec55 | |||
| 57e22dc7b6 | |||
| debd60fb70 | |||
| dfa17c1c0b | |||
| 2a26f8b04a | |||
| db955d2c8c | |||
| 51602a77fb | |||
| 48da5ab546 |
@@ -1,8 +1,13 @@
|
||||
import withPWAInit from '@ducanh2912/next-pwa';
|
||||
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
|
||||
|
||||
const isDev = process.env['NODE_ENV'] === 'development';
|
||||
const appPlatform = process.env['NEXT_PUBLIC_APP_PLATFORM'];
|
||||
|
||||
if (isDev) {
|
||||
initOpenNextCloudflareForDev();
|
||||
}
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Ensure Next.js uses SSG instead of SSR
|
||||
|
||||
@@ -1,29 +1,6 @@
|
||||
// @ts-nocheck
|
||||
// default open-next.config.ts file created by @opennextjs/cloudflare
|
||||
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
|
||||
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
|
||||
|
||||
import type { OpenNextConfig } from '@opennextjs/aws/types/open-next.js';
|
||||
import cache from '@opennextjs/cloudflare/kv-cache';
|
||||
|
||||
const config: OpenNextConfig = {
|
||||
default: {
|
||||
override: {
|
||||
wrapper: 'cloudflare-node',
|
||||
converter: 'edge',
|
||||
// set `incrementalCache` to "dummy" to disable KV cache
|
||||
incrementalCache: async () => cache,
|
||||
tagCache: 'dummy',
|
||||
queue: 'dummy',
|
||||
},
|
||||
},
|
||||
|
||||
middleware: {
|
||||
external: true,
|
||||
override: {
|
||||
wrapper: 'cloudflare-edge',
|
||||
converter: 'edge',
|
||||
proxyExternalRequest: 'fetch',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default defineCloudflareConfig({
|
||||
incrementalCache: r2IncrementalCache,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.52",
|
||||
"version": "0.9.55",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -30,9 +30,10 @@
|
||||
"build-ios-appstore": "dotenv -e .env.ios-appstore.local -- tauri ios build --export-method app-store-connect",
|
||||
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh",
|
||||
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
|
||||
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler dev",
|
||||
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler deploy",
|
||||
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
|
||||
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||
"upload": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -40,9 +41,10 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@opennextjs/cloudflare": "^1.1.0",
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
"@supabase/supabase-js": "^2.47.7",
|
||||
"@supabase/supabase-js": "^2.49.10",
|
||||
"@tauri-apps/api": "2.5.0",
|
||||
"@tauri-apps/plugin-cli": "^2.2.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.2.1",
|
||||
@@ -68,7 +70,7 @@
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"next": "15.2.4",
|
||||
"next": "15.3.3",
|
||||
"posthog-js": "^1.246.0",
|
||||
"react": "19.0.0",
|
||||
"react-color": "^2.19.3",
|
||||
@@ -81,7 +83,6 @@
|
||||
"zustand": "5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opennextjs/cloudflare": "^0.5.12",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tauri-apps/cli": "2.5.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
@@ -107,6 +108,6 @@
|
||||
"raw-loader": "^4.0.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"wrangler": "^4.4.0"
|
||||
"wrangler": "^4.19.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/_next/static/*
|
||||
Cache-Control: public,max-age=31536000,immutable
|
||||
@@ -186,7 +186,6 @@
|
||||
"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": "أجهزة مزامنة سحابية غير محدودة",
|
||||
@@ -352,5 +351,11 @@
|
||||
"Never synced": "لم تُزامَن",
|
||||
"Show Remaining Time": "إظهار الوقت المتبقي",
|
||||
"Show Page Number": "إظهار رقم الصفحة",
|
||||
"{{time}} min left in chapter": "{{time}} دقيقة متبقية في الفصل"
|
||||
"{{time}} min left in chapter": "{{time}} دقيقة متبقية في الفصل",
|
||||
"Override Book Color": "تجاوز لون الكتاب",
|
||||
"Login Required": "يلزم الدخول",
|
||||
"Quota Exceeded": "تجاوز السعة",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "تم استخدام {{percentage}}% من أحرف الترجمة اليومية.",
|
||||
"Translation Characters": "أحرف الترجمة",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "تم الوصول إلى حصة الترجمة اليومية. اختر خدمة ترجمة أخرى للمتابعة."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"Sign in with Apple": "Mit Apple 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",
|
||||
@@ -344,5 +343,11 @@
|
||||
"Never synced": "Nie synchronisiert",
|
||||
"Show Remaining Time": "Verbleibende Zeit anzeigen",
|
||||
"Show Page Number": "Seitenzahl anzeigen",
|
||||
"{{time}} min left in chapter": "{{time}} min verbleibend im Kapitel"
|
||||
"{{time}} min left in chapter": "{{time}} min verbleibend im Kapitel",
|
||||
"Override Book Color": "Farbe des Buches überschreiben",
|
||||
"Login Required": "Anmeldung nötig",
|
||||
"Quota Exceeded": "Limit überschritten",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% der täglichen Übersetzungszeichen verwendet.",
|
||||
"Translation Characters": "Übersetzungszeichen",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Das tägliche Übersetzungslimit wurde erreicht. Wählen Sie einen anderen Übersetzungsdienst, um fortzufahren."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"Sign in with Apple": "Σύνδεση με Apple",
|
||||
"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",
|
||||
@@ -344,5 +343,11 @@
|
||||
"Never synced": "Ποτέ δεν συγχρονίστηκε",
|
||||
"Show Remaining Time": "Εμφάνιση υπολειπόμενου χρόνου",
|
||||
"Show Page Number": "Εμφάνιση αριθμού σελίδας",
|
||||
"{{time}} min left in chapter": "{{time}} λεπτά απομένουν στο κεφάλαιο"
|
||||
"{{time}} min left in chapter": "{{time}} λεπτά απομένουν στο κεφάλαιο",
|
||||
"Override Book Color": "Παράκαμψη χρώματος βιβλίου",
|
||||
"Login Required": "Απαιτείται σύνδεση",
|
||||
"Quota Exceeded": "Υπέρβαση ορίου",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% των ημερήσιων χαρακτήρων μετάφρασης χρησιμοποιήθηκαν.",
|
||||
"Translation Characters": "Χαρακτήρες μετάφρασης",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Έχετε φτάσει το ημερήσιο όριο χαρακτήρων μετάφρασης. Επιλέξτε άλλη υπηρεσία μετάφρασης για να συνεχίσετε."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"Sign in with Apple": "Iniciar sesión con Apple",
|
||||
"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",
|
||||
@@ -346,5 +345,11 @@
|
||||
"Never synced": "Nunca sincronizado",
|
||||
"Show Remaining Time": "Mostrar tiempo restante",
|
||||
"Show Page Number": "Mostrar número de página",
|
||||
"{{time}} min left in chapter": "{{time}} min restantes en el capítulo"
|
||||
"{{time}} min left in chapter": "{{time}} min restantes en el capítulo",
|
||||
"Override Book Color": "Sobre escribir color del libro",
|
||||
"Login Required": "Iniciar sesión",
|
||||
"Quota Exceeded": "Límite superado",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% de caracteres de traducción diarios utilizados.",
|
||||
"Translation Characters": "Caracteres de traducción",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Cuota diaria de traducción alcanzada. Selecciona otro servicio de traducción para continuar."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"Sign in with Apple": "Se connecter avec Apple",
|
||||
"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",
|
||||
@@ -346,5 +345,11 @@
|
||||
"Never synced": "Jamais synchronisé",
|
||||
"Show Remaining Time": "Afficher le temps restant",
|
||||
"Show Page Number": "Afficher le numéro de page",
|
||||
"{{time}} min left in chapter": "{{time}} min restant dans le chapitre"
|
||||
"{{time}} min left in chapter": "{{time}} min restant dans le chapitre",
|
||||
"Override Book Color": "Remplacer la couleur du livre",
|
||||
"Login Required": "Connexion requise",
|
||||
"Quota Exceeded": "Quota dépassé",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% des caractères de traduction quotidiens utilisés.",
|
||||
"Translation Characters": "Caractères de traduction",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Quota de traduction quotidien atteint. Sélectionnez un autre service de traduction pour continuer."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"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": "असीमित क्लाउड सिंक उपकरण",
|
||||
@@ -344,5 +343,11 @@
|
||||
"Never synced": "कभी सिंक नहीं किया गया",
|
||||
"Show Remaining Time": "शेष समय दिखाएं",
|
||||
"Show Page Number": "पृष्ठ संख्या दिखाएं",
|
||||
"{{time}} min left in chapter": "{{time}} मिनट शेष अध्याय में"
|
||||
"{{time}} min left in chapter": "{{time}} मिनट शेष अध्याय में",
|
||||
"Override Book Color": "पुस्तक रंग ओवरराइड करें",
|
||||
"Login Required": "लॉगिन ज़रूरी",
|
||||
"Quota Exceeded": "सीमा पार",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% दैनिक अनुवाद वर्णों का उपयोग किया गया है।",
|
||||
"Translation Characters": "अनुवाद वर्ण",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "दैनिक अनुवाद कोटा पूरा हो गया है। आगे बढ़ने के लिए कोई अन्य अनुवाद सेवा चुनें।"
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"Sign in with Apple": "Masuk dengan Apple",
|
||||
"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",
|
||||
@@ -340,7 +339,13 @@
|
||||
"Sign in to Sync": "Masuk untuk Sinkronisasi",
|
||||
"Synced at {{time}}": "Disinkronkan pada {{time}}",
|
||||
"Never synced": "Belum pernah disinkronkan",
|
||||
"Show Remaining Time": "__STRING_NOT_TRANSLATED__",
|
||||
"Show Page Number": "__STRING_NOT_TRANSLATED__",
|
||||
"{{time}} min left in chapter": "{{time}} menit tersisa di bab"
|
||||
"Show Remaining Time": "Perlihatkan Waktu Tersisa",
|
||||
"Show Page Number": "Perlihatkan Nomor Halaman",
|
||||
"{{time}} min left in chapter": "{{time}} menit tersisa di bab",
|
||||
"Override Book Color": "Ubah Warna Buku",
|
||||
"Login Required": "Perlu masuk",
|
||||
"Quota Exceeded": "Kuota terlampaui",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dari Karakter Terjemahan Harian Digunakan.",
|
||||
"Translation Characters": "Karakter Terjemahan",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Kuota terjemahan harian tercapai. Pilih layanan terjemahan lain untuk melanjutkan."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"Sign in with Apple": "Accedi con Apple",
|
||||
"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",
|
||||
@@ -346,5 +345,11 @@
|
||||
"Never synced": "Mai sincronizzato",
|
||||
"Show Remaining Time": "Mostra tempo rimanente",
|
||||
"Show Page Number": "Mostra numero pagina",
|
||||
"{{time}} min left in chapter": "{{time}} min rimasti nel capitolo"
|
||||
"{{time}} min left in chapter": "{{time}} min rimasti nel capitolo",
|
||||
"Override Book Color": "Override colore libro",
|
||||
"Login Required": "Accesso richiesto",
|
||||
"Quota Exceeded": "Limite superato",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dei caratteri di traduzione giornalieri utilizzati.",
|
||||
"Translation Characters": "Caratteri di traduzione",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Quota di traduzione giornaliera raggiunta. Seleziona un altro servizio di traduzione per procedere."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"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": "無制限のクラウド同期デバイス",
|
||||
@@ -342,5 +341,11 @@
|
||||
"Never synced": "まだ同期されていません",
|
||||
"Show Remaining Time": "残り時間を表示",
|
||||
"Show Page Number": "ページ番号を表示",
|
||||
"{{time}} min left in chapter": "{{time}}分残り"
|
||||
"{{time}} min left in chapter": "{{time}}分残り",
|
||||
"Override Book Color": "書籍の色を上書き",
|
||||
"Login Required": "ログイン必須",
|
||||
"Quota Exceeded": "上限超過",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "日次翻訳文字数の使用量は{{percentage}}%です。",
|
||||
"Translation Characters": "翻訳文字数",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "日次翻訳クォータに達しました。続行するには別の翻訳サービスを選択してください。"
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"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": "무제한 클라우드 동기화 기기",
|
||||
@@ -342,5 +341,11 @@
|
||||
"Never synced": "동기화된 적 없음",
|
||||
"Show Remaining Time": "남은 시간 표시",
|
||||
"Show Page Number": "페이지 번호 표시",
|
||||
"{{time}} min left in chapter": "{{time}}분 남음"
|
||||
"{{time}} min left in chapter": "{{time}}분 남음",
|
||||
"Override Book Color": "책 색상 덮어쓰기",
|
||||
"Login Required": "로그인 필요",
|
||||
"Quota Exceeded": "한도 초과",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "일일 번역 문자 사용량 {{percentage}}%.",
|
||||
"Translation Characters": "번역 문자",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "일일 번역 할당량에 도달했습니다. 계속 진행하려면 다른 번역 서비스를 선택하세요."
|
||||
}
|
||||
|
||||
@@ -228,7 +228,6 @@
|
||||
"Slow": "Langzaam",
|
||||
"Fast": "Snel",
|
||||
"Reading Progress Synced": "Leesvoortgang gesynchroniseerd",
|
||||
"Cloud Storage": "Cloudopslag",
|
||||
"Failed to delete user. Please try again later.": "Gebruiker verwijderen mislukt. Probeer het later opnieuw.",
|
||||
"Free Tier": "Gratis niveau",
|
||||
"Community Support": "Community-ondersteuning",
|
||||
@@ -344,5 +343,11 @@
|
||||
"Never synced": "Nooit gesynchroniseerd",
|
||||
"Show Remaining Time": "Toon resterende tijd",
|
||||
"Show Page Number": "Toon paginanummer",
|
||||
"{{time}} min left in chapter": "Nog {{time}} min in hoofdstuk"
|
||||
"{{time}} min left in chapter": "Nog {{time}} min in hoofdstuk",
|
||||
"Override Book Color": "Boekkleur overschrijven",
|
||||
"Login Required": "Inloggen vereist",
|
||||
"Quota Exceeded": "Limiet overschreden",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% van de dagelijkse vertaaltekens gebruikt.",
|
||||
"Translation Characters": "Vertaaltekens",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Dagelijkse vertaalquotum bereikt. Selecteer een andere vertaalservice om door te gaan."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"Sign in with Apple": "Zaloguj się za pomocą Apple",
|
||||
"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",
|
||||
@@ -348,5 +347,11 @@
|
||||
"Never synced": "Brak synchronizacji",
|
||||
"Show Remaining Time": "Pokazuj pozostały czas",
|
||||
"Show Page Number": "Pokaż numer strony",
|
||||
"{{time}} min left in chapter": "{{time}} min pozostało do końca rozdziału"
|
||||
"{{time}} min left in chapter": "{{time}} min pozostało do końca rozdziału",
|
||||
"Override Book Color": "Nadpisz kolor książki",
|
||||
"Login Required": "Wymagane logowanie",
|
||||
"Quota Exceeded": "Limit przekroczony",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% użytych znaków tłumaczenia dziennego.",
|
||||
"Translation Characters": "Znaki tłumaczenia",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Przekroczono dzienny limit znaków tłumaczenia. Wybierz inną usługę tłumaczenia, aby kontynuować."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"Sign in with Apple": "Entrar com o Apple",
|
||||
"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",
|
||||
@@ -346,5 +345,11 @@
|
||||
"Never synced": "Nunca sincronizado",
|
||||
"Show Remaining Time": "Mostrar Tempo Restante",
|
||||
"Show Page Number": "Mostrar Número da Página",
|
||||
"{{time}} min left in chapter": "{{time}} min restantes no capítulo"
|
||||
"{{time}} min left in chapter": "{{time}} min restantes no capítulo",
|
||||
"Override Book Color": "Substituir Cor do Livro",
|
||||
"Login Required": "Login necessário",
|
||||
"Quota Exceeded": "Limite excedido",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% dos caracteres de tradução diários usados.",
|
||||
"Translation Characters": "Caracteres de Tradução",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Quota diária de tradução atingida. Selecione outro serviço de tradução para continuar."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"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": "Неограниченное количество устройств для облачной синхронизации",
|
||||
@@ -348,5 +347,11 @@
|
||||
"Never synced": "Не синхр.",
|
||||
"Show Remaining Time": "Показать оставшееся время",
|
||||
"Show Page Number": "Показать номер страницы",
|
||||
"{{time}} min left in chapter": "{{time}} мин осталось в главе"
|
||||
"{{time}} min left in chapter": "{{time}} мин осталось в главе",
|
||||
"Override Book Color": "Переопределить цвет книги",
|
||||
"Login Required": "Требуется вход",
|
||||
"Quota Exceeded": "Лимит превышен",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "Использовано {{percentage}}% от суточного лимита символов перевода.",
|
||||
"Translation Characters": "Символы перевода",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Достигнут суточный лимит символов перевода. Выберите другой сервис перевода для продолжения."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"Sign in with Apple": "Apple 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ı",
|
||||
@@ -344,5 +343,11 @@
|
||||
"Never synced": "Hiç eşzlenmedi",
|
||||
"Show Remaining Time": "Kalan Süreyi Göster",
|
||||
"Show Page Number": "Sayfa Numarasını Göster",
|
||||
"{{time}} min left in chapter": "{{time}} dakika bölümde kaldı"
|
||||
"{{time}} min left in chapter": "{{time}} dakika bölümde kaldı",
|
||||
"Override Book Color": "Kitap Rengini Geçersiz Kıl",
|
||||
"Login Required": "Giriş gerekli",
|
||||
"Quota Exceeded": "Kota aşıldı",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "Çeviri için günlük karakterlerin %{{percentage}}'si kullanıldı.",
|
||||
"Translation Characters": "Çeviri Karakterleri",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Günlük çeviri kotası aşıldı. Devam etmek için başka bir çeviri hizmeti seçin."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"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": "Необмежена кількість пристроїв для хмарної синхронізації",
|
||||
@@ -348,5 +347,11 @@
|
||||
"Never synced": "Ніколи не синхр.",
|
||||
"Show Remaining Time": "Показати залишок часу",
|
||||
"Show Page Number": "Показати номер сторінки",
|
||||
"{{time}} min left in chapter": "{{time}} хв до кінця розділу"
|
||||
"{{time}} min left in chapter": "{{time}} хв до кінця розділу",
|
||||
"Override Book Color": "Перевизначити колір книги",
|
||||
"Login Required": "Потрібен вхід",
|
||||
"Quota Exceeded": "Перевищено ліміт",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "Використано {{percentage}}% символів перекладу за день.",
|
||||
"Translation Characters": "Символи перекладу",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Досягнуто добового ліміту перекладу. Виберіть іншу службу перекладу для продовження."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"Sign in with Apple": "Đăng nhập với Apple",
|
||||
"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",
|
||||
@@ -342,5 +341,11 @@
|
||||
"Never synced": "Chưa từng đồng bộ",
|
||||
"Show Remaining Time": "Hiển thị thời gian còn lại",
|
||||
"Show Page Number": "Hiển thị số trang",
|
||||
"{{time}} min left in chapter": "{{time}} phút còn lại trong chương"
|
||||
"{{time}} min left in chapter": "{{time}} phút còn lại trong chương",
|
||||
"Override Book Color": "Đổi màu sách",
|
||||
"Login Required": "Cần đăng nhập",
|
||||
"Quota Exceeded": "Vượt hạn mức",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% số ký tự dịch hàng ngày đã sử dụng.",
|
||||
"Translation Characters": "Những ký tự dịch",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "Đã đạt hạn mức dịch hàng ngày. Chọn dịch vụ dịch khác để tiếp tục."
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"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": "无限云同步设备个数",
|
||||
@@ -342,5 +341,11 @@
|
||||
"Never synced": "从未同步",
|
||||
"Show Remaining Time": "显示剩余时间",
|
||||
"Show Page Number": "显示页码",
|
||||
"{{time}} min left in chapter": "本章剩余 {{time}} 分钟"
|
||||
"{{time}} min left in chapter": "本章剩余 {{time}} 分钟",
|
||||
"Override Book Color": "覆盖书籍颜色",
|
||||
"Login Required": "需登录",
|
||||
"Quota Exceeded": "额度不足",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻译字符数",
|
||||
"Translation Characters": "翻译字数",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "已达到每日翻译配额,请选择其他翻译服务继续。"
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@
|
||||
"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": "無限雲端同步裝置",
|
||||
@@ -342,5 +341,11 @@
|
||||
"Never synced": "從未同步",
|
||||
"Show Remaining Time": "顯示剩餘時間",
|
||||
"Show Page Number": "顯示頁碼",
|
||||
"{{time}} min left in chapter": "本章剩餘 {{time}} 分鐘"
|
||||
"{{time}} min left in chapter": "本章剩餘 {{time}} 分鐘",
|
||||
"Override Book Color": "覆蓋書籍顏色",
|
||||
"Login Required": "需登入",
|
||||
"Quota Exceeded": "額度不足",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻譯字數",
|
||||
"Translation Characters": "翻譯字數",
|
||||
"Daily translation quota reached. Select another translate service to proceed.": "已達到每日翻譯字數限制,請選擇其他翻譯服務繼續。"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.55": {
|
||||
"date": "2025-06-07",
|
||||
"notes": [
|
||||
"Sync: Notes deleted on other devices now correctly sync across all platforms",
|
||||
"Layout: Now text justification can be unset in the layout settings",
|
||||
"Translation: Added notification when daily translation quota is exceeded",
|
||||
"Performance: Reduced unnecessary animations on Android to improve efficiency"
|
||||
]
|
||||
},
|
||||
"0.9.53": {
|
||||
"date": "2025-06-05",
|
||||
"notes": [
|
||||
"Reader: New option to customize book foreground and background colors",
|
||||
"Reader: Fixed an issue where images appeared too small in some EPUBs",
|
||||
"EPUB: Improved cover image detection for certain EPUB files",
|
||||
"TTS: Improved background playback stability on iOS devices",
|
||||
"Translation: Introduced a daily DeepL translation quota to ensure long-term availability"
|
||||
]
|
||||
},
|
||||
"0.9.52": {
|
||||
"date": "2025-06-03",
|
||||
"notes": [
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
android:largeHeap="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.readest"
|
||||
android:hardwareAccelerated="true"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:hardwareAccelerated="true"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
);
|
||||
const isImportingBook = useRef(false);
|
||||
|
||||
const { currentBookshelf, setCurrentBookshelf, setLibrary } = useLibraryStore();
|
||||
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
|
||||
const allBookshelfItems =
|
||||
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
|
||||
|
||||
@@ -234,7 +234,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
}
|
||||
};
|
||||
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
|
||||
const filteredBookshelfItems = currentBookshelf
|
||||
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
|
||||
const filteredBookshelfItems = currentBookshelfItems
|
||||
.filter((item) => {
|
||||
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
|
||||
else if (queryTerm) return bookFilter(item, queryTerm);
|
||||
|
||||
@@ -114,6 +114,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
|
||||
const makeBookAvailable = async (book: Book) => {
|
||||
if (book.uploadedAt && !book.downloadedAt) {
|
||||
if (await appService?.isBookAvailable(book)) {
|
||||
return true;
|
||||
}
|
||||
let available = false;
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 200);
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
import { PiUserCircleCheck } from 'react-icons/pi';
|
||||
@@ -13,14 +13,13 @@ import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useQuotaStats } from '@/hooks/useQuotaStats';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { getStoragePlanData } from '@/utils/access';
|
||||
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
|
||||
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
|
||||
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
|
||||
import { QuotaType } from '@/types/user';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Quota from '@/components/Quota';
|
||||
@@ -33,10 +32,9 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const { themeMode, setThemeMode } = useThemeStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
|
||||
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop);
|
||||
@@ -48,6 +46,8 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const [isTelemetryEnabled, setIsTelemetryEnabled] = useState(settings.telemetryEnabled);
|
||||
const iconSize = useResponsiveSize(16);
|
||||
|
||||
const { quotas } = useQuotaStats();
|
||||
|
||||
const showAboutReadest = () => {
|
||||
setAboutDialogVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
@@ -143,22 +143,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsTelemetryEnabled(settings.telemetryEnabled);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
const storagPlan = getStoragePlanData(token);
|
||||
const storageQuota: QuotaType = {
|
||||
name: _('Storage'),
|
||||
tooltip: _('{{percentage}}% of Cloud Storage Used.', {
|
||||
percentage: Math.round((storagPlan.usage / storagPlan.quota) * 100),
|
||||
}),
|
||||
used: Math.round(storagPlan.usage / 1024 / 1024),
|
||||
total: Math.round(storagPlan.quota / 1024 / 1024),
|
||||
unit: 'MB',
|
||||
};
|
||||
setQuotas([storageQuota]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
|
||||
const userFullName = user?.user_metadata?.['full_name'];
|
||||
const userDisplayName = userFullName ? userFullName.split(' ')[0] : null;
|
||||
@@ -188,7 +172,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
<Quota quotas={quotas} className='h-10 pl-3 pr-2' />
|
||||
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
|
||||
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
|
||||
</ul>
|
||||
</MenuItem>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
|
||||
import { Book } from '@/types/book';
|
||||
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
|
||||
export interface UseBooksSyncProps {
|
||||
onSyncStart?: () => void;
|
||||
@@ -38,9 +39,6 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const lastSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const getNewBooks = () => {
|
||||
if (!user) return [];
|
||||
const newBooks = library.filter(
|
||||
@@ -49,26 +47,18 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
|
||||
return newBooks;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - lastSyncTime.current;
|
||||
if (timeSinceLastSync > SYNC_BOOKS_INTERVAL_SEC * 1000) {
|
||||
lastSyncTime.current = now;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleAutoSync = useCallback(
|
||||
debounce(() => {
|
||||
const newBooks = getNewBooks();
|
||||
syncBooks(newBooks, 'both');
|
||||
} else {
|
||||
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
lastSyncTime.current = Date.now();
|
||||
const newBooks = getNewBooks();
|
||||
syncBooks(newBooks, 'both');
|
||||
syncTimeoutRef.current = null;
|
||||
},
|
||||
SYNC_BOOKS_INTERVAL_SEC * 1000 - timeSinceLastSync,
|
||||
);
|
||||
}
|
||||
}, SYNC_BOOKS_INTERVAL_SEC * 1000),
|
||||
[library, lastSyncedAtBooks],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
handleAutoSync();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [library]);
|
||||
|
||||
|
||||
@@ -218,6 +218,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryBooks.some((book) => !book.deletedAt)) {
|
||||
handleSetSelectMode(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [libraryBooks]);
|
||||
|
||||
const processOpenWithFiles = React.useCallback(
|
||||
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
|
||||
const settings = await appService.loadSettings();
|
||||
@@ -366,7 +373,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
for (const file of files) {
|
||||
try {
|
||||
const book = await appService?.importBook(file, library);
|
||||
setLibrary(library);
|
||||
setLibrary([...library]);
|
||||
if (user && book && !book.uploadedAt && settings.autoUpload) {
|
||||
console.log('Uploading book:', book.title);
|
||||
handleBookUpload(book);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTranslator } from '@/hooks/useTranslator';
|
||||
import { TRANSLATED_LANGS } from '@/services/constants';
|
||||
import { UseTranslatorOptions } from '@/services/translators';
|
||||
import { UseTranslatorOptions, getTranslators } from '@/services/translators';
|
||||
import { localeToLang } from '@/utils/lang';
|
||||
import Select from '@/components/Select';
|
||||
|
||||
@@ -76,7 +76,12 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
};
|
||||
|
||||
const handleProviderChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedTranslator = translators.find((t) => t.name === event.target.value);
|
||||
const requestedProvider = event.target.value;
|
||||
const availableTranslators = getTranslators().filter(
|
||||
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
|
||||
);
|
||||
const selectedTranslator =
|
||||
availableTranslators.find((t) => t.name === requestedProvider) || availableTranslators[0]!;
|
||||
if (selectedTranslator) {
|
||||
settings.globalReadSettings.translationProvider = selectedTranslator.name;
|
||||
setSettings(settings);
|
||||
@@ -86,9 +91,16 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
const availableProviders = translators.map((t) => {
|
||||
return { name: t.name, label: t.label };
|
||||
let label = t.label;
|
||||
if (t.authRequired && !token) {
|
||||
label = `${label} (${_('Login Required')})`;
|
||||
} else if (t.quotaExceeded) {
|
||||
label = `${label} (${_('Quota Exceeded')})`;
|
||||
}
|
||||
return { name: t.name, label };
|
||||
});
|
||||
setProviders(availableProviders);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [translators]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -142,6 +154,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<h1 className='text-sm font-normal'>{_('Original Text')}</h1>
|
||||
<Select
|
||||
className='bg-gray-600 text-white/75'
|
||||
value={sourceLang}
|
||||
onChange={handleSourceLangChange}
|
||||
options={[
|
||||
@@ -168,6 +181,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<h2 className='text-sm font-normal'>{_('Translated Text')}</h2>
|
||||
<Select
|
||||
className='bg-gray-600 text-white/75'
|
||||
value={targetLang}
|
||||
onChange={handleTargetLangChange}
|
||||
options={Object.entries(TRANSLATOR_LANGS)
|
||||
@@ -201,6 +215,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
)}
|
||||
<div className='ml-auto'>
|
||||
<Select
|
||||
className='bg-gray-600 text-white/75'
|
||||
value={provider}
|
||||
onChange={handleProviderChange}
|
||||
options={providers.map(({ name: value, label }) => ({ value, label }))}
|
||||
|
||||
@@ -38,6 +38,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [editTheme, setEditTheme] = useState<CustomTheme | null>(null);
|
||||
const [customThems, setCustomThemes] = useState<Theme[]>([]);
|
||||
const [showCustomThemeEditor, setShowCustomThemeEditor] = useState(false);
|
||||
const [overrideColor, setOverrideColor] = useState(viewSettings.overrideColor!);
|
||||
|
||||
useEffect(() => {
|
||||
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
|
||||
@@ -45,6 +46,12 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [invertImgColorInDark]);
|
||||
|
||||
useEffect(() => {
|
||||
if (overrideColor === viewSettings.overrideColor) return;
|
||||
saveViewSettings(envConfig, bookKey, 'overrideColor', overrideColor);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [overrideColor]);
|
||||
|
||||
useEffect(() => {
|
||||
const customThemes = settings.globalReadSettings.customThemes ?? [];
|
||||
setCustomThemes(
|
||||
@@ -137,6 +144,16 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className=''>{_('Override Book Color')}</h2>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle'
|
||||
checked={overrideColor}
|
||||
onChange={() => setOverrideColor(!overrideColor)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
|
||||
@@ -2,15 +2,17 @@ import clsx from 'clsx';
|
||||
import i18n from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { saveViewSettings } from '../../utils/viewSettingsHelper';
|
||||
import { getTranslators } from '@/services/translators';
|
||||
import { TRANSLATED_LANGS } from '@/services/constants';
|
||||
import DropDown from './DropDown';
|
||||
import Select from '@/components/Select';
|
||||
|
||||
const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { token } = useAuth();
|
||||
const { envConfig } = useEnv();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
@@ -22,7 +24,7 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const getCurrentUILangOption = () => {
|
||||
const uiLanguage = viewSettings.uiLanguage;
|
||||
return {
|
||||
option: uiLanguage,
|
||||
value: uiLanguage,
|
||||
label:
|
||||
uiLanguage === ''
|
||||
? _('Auto')
|
||||
@@ -32,13 +34,14 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
const getLangOptions = () => {
|
||||
const langs = TRANSLATED_LANGS as Record<string, string>;
|
||||
const options = Object.entries(langs).map(([option, label]) => ({ option, label }));
|
||||
const options = Object.entries(langs).map(([value, label]) => ({ value, label }));
|
||||
options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
options.unshift({ option: '', label: _('System Language') });
|
||||
options.unshift({ value: '', label: _('System Language') });
|
||||
return options;
|
||||
};
|
||||
|
||||
const handleSelectUILang = (option: string) => {
|
||||
const handleSelectUILang = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const option = event.target.value;
|
||||
saveViewSettings(envConfig, bookKey, 'uiLanguage', option, false, false);
|
||||
i18n.changeLanguage(option ? option : navigator.language);
|
||||
};
|
||||
@@ -46,18 +49,31 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const getTranslationProviderOptions = () => {
|
||||
const translators = getTranslators();
|
||||
const availableProviders = translators.map((t) => {
|
||||
return { option: t.name, label: t.label };
|
||||
let label = t.label;
|
||||
if (t.authRequired && !token) {
|
||||
label = `${label} (${_('Login Required')})`;
|
||||
} else if (t.quotaExceeded) {
|
||||
label = `${label} (${_('Quota Exceeded')})`;
|
||||
}
|
||||
return { value: t.name, label };
|
||||
});
|
||||
return availableProviders;
|
||||
};
|
||||
|
||||
const getCurrentTranslationProviderOption = () => {
|
||||
const option = translationProvider;
|
||||
const availableProviders = getTranslationProviderOptions();
|
||||
return availableProviders.find((p) => p.option === option) || availableProviders[0]!;
|
||||
const value = translationProvider;
|
||||
const allProviders = getTranslationProviderOptions();
|
||||
const availableTranslators = getTranslators().filter(
|
||||
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
|
||||
);
|
||||
const currentProvider = availableTranslators.find((t) => t.name === value)
|
||||
? value
|
||||
: availableTranslators[0]?.name;
|
||||
return allProviders.find((p) => p.value === currentProvider) || allProviders[0]!;
|
||||
};
|
||||
|
||||
const handleSelectTranslationProvider = (option: string) => {
|
||||
const handleSelectTranslationProvider = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const option = event.target.value;
|
||||
setTranslationProvider(option);
|
||||
saveViewSettings(envConfig, bookKey, 'translationProvider', option, false, false);
|
||||
viewSettings.translationProvider = option;
|
||||
@@ -65,12 +81,13 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
};
|
||||
|
||||
const getCurrentTargetLangOption = () => {
|
||||
const option = translateTargetLang;
|
||||
const value = translateTargetLang;
|
||||
const availableOptions = getLangOptions();
|
||||
return availableOptions.find((o) => o.option === option) || availableOptions[0]!;
|
||||
return availableOptions.find((o) => o.value === value) || availableOptions[0]!;
|
||||
};
|
||||
|
||||
const handleSelectTargetLang = (option: string) => {
|
||||
const handleSelectTargetLang = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const option = event.target.value;
|
||||
setTranslateTargetLang(option);
|
||||
saveViewSettings(envConfig, bookKey, 'translateTargetLang', option, false, false);
|
||||
viewSettings.translateTargetLang = option;
|
||||
@@ -93,12 +110,10 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
<div className='divide-base-200 divide-y'>
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Interface Language')}</span>
|
||||
<DropDown
|
||||
<Select
|
||||
value={getCurrentUILangOption().value}
|
||||
onChange={handleSelectUILang}
|
||||
options={getLangOptions()}
|
||||
selected={getCurrentUILangOption()}
|
||||
onSelect={handleSelectUILang}
|
||||
className='dropdown-bottom'
|
||||
listClassName='!max-h-60'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,24 +136,21 @@ const LangPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Translation Service')}</span>
|
||||
<DropDown
|
||||
selected={getCurrentTranslationProviderOption()}
|
||||
<Select
|
||||
value={getCurrentTranslationProviderOption().value}
|
||||
onChange={handleSelectTranslationProvider}
|
||||
options={getTranslationProviderOptions()}
|
||||
onSelect={handleSelectTranslationProvider}
|
||||
disabled={!translationEnabled}
|
||||
className='dropdown-top'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='config-item'>
|
||||
<span className=''>{_('Translate To')}</span>
|
||||
<DropDown
|
||||
<Select
|
||||
value={getCurrentTargetLangOption().value}
|
||||
onChange={handleSelectTargetLang}
|
||||
options={getLangOptions()}
|
||||
selected={getCurrentTargetLangOption()}
|
||||
onSelect={handleSelectTargetLang}
|
||||
disabled={!translationEnabled}
|
||||
className='dropdown-top'
|
||||
listClassName='!max-h-60'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,16 @@ const TTSControl = () => {
|
||||
if (unblockerAudioRef.current) return;
|
||||
unblockerAudioRef.current = document.createElement('audio');
|
||||
unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny');
|
||||
unblockerAudioRef.current.addEventListener('play', () => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
navigator.mediaSession.setActionHandler('play', null);
|
||||
navigator.mediaSession.setActionHandler('pause', null);
|
||||
navigator.mediaSession.setActionHandler('stop', null);
|
||||
navigator.mediaSession.setActionHandler('seekbackward', null);
|
||||
navigator.mediaSession.setActionHandler('seekforward', null);
|
||||
}
|
||||
});
|
||||
unblockerAudioRef.current.preload = 'auto';
|
||||
unblockerAudioRef.current.loop = true;
|
||||
unblockerAudioRef.current.src = SILENCE_DATA;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { SYNC_NOTES_INTERVAL_SEC } from '@/services/constants';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
|
||||
export const useNotesSync = (bookKey: string) => {
|
||||
const { user } = useAuth();
|
||||
@@ -13,10 +14,8 @@ export const useNotesSync = (bookKey: string) => {
|
||||
const config = getConfig(bookKey);
|
||||
const bookHash = bookKey.split('-')[0]!;
|
||||
|
||||
const lastSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const getNewNotes = () => {
|
||||
const config = getConfig(bookKey);
|
||||
if (!config?.location || !user) return [];
|
||||
const bookNotes = config.booknotes ?? [];
|
||||
const newNotes = bookNotes.filter(
|
||||
@@ -28,35 +27,31 @@ export const useNotesSync = (bookKey: string) => {
|
||||
return newNotes;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.location || !user) return;
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - lastSyncTime.current;
|
||||
if (timeSinceLastSync > SYNC_NOTES_INTERVAL_SEC * 1000) {
|
||||
lastSyncTime.current = now;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const handleAutoSync = useCallback(
|
||||
debounce(() => {
|
||||
const newNotes = getNewNotes();
|
||||
syncNotes(newNotes, bookHash, 'both');
|
||||
} else {
|
||||
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
lastSyncTime.current = Date.now();
|
||||
const newNotes = getNewNotes();
|
||||
syncNotes(newNotes, bookHash, 'both');
|
||||
syncTimeoutRef.current = null;
|
||||
},
|
||||
SYNC_NOTES_INTERVAL_SEC * 1000 - timeSinceLastSync,
|
||||
);
|
||||
}
|
||||
}, SYNC_NOTES_INTERVAL_SEC * 1000),
|
||||
[lastSyncedAtNotes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.location || !user) return;
|
||||
handleAutoSync();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
const processNewNote = (note: BookNote) => {
|
||||
const config = getConfig(bookKey);
|
||||
const oldNotes = config?.booknotes ?? [];
|
||||
const existingNote = oldNotes.find((oldNote) => oldNote.id === note.id);
|
||||
if (existingNote) {
|
||||
if (existingNote.updatedAt < note.updatedAt) {
|
||||
if (
|
||||
existingNote.updatedAt < note.updatedAt ||
|
||||
(existingNote.deletedAt ?? 0) < (note.deletedAt ?? 0)
|
||||
) {
|
||||
return { ...existingNote, ...note };
|
||||
} else {
|
||||
return { ...note, ...existingNote };
|
||||
|
||||
@@ -69,7 +69,7 @@ export const useProgressSync = (bookKey: string) => {
|
||||
}, [bookKey]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedAutoSync = useCallback(
|
||||
const handleAutoSync = useCallback(
|
||||
debounce(() => {
|
||||
syncConfig();
|
||||
}, SYNC_PROGRESS_INTERVAL_SEC * 1000),
|
||||
@@ -79,7 +79,7 @@ export const useProgressSync = (bookKey: string) => {
|
||||
// Push: auto-push progress when progress changes with a debounce
|
||||
useEffect(() => {
|
||||
if (!progress?.location || !user) return;
|
||||
debouncedAutoSync();
|
||||
handleAutoSync();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress]);
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
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 { useQuotaStats } from '@/hooks/useQuotaStats';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { QuotaType, UserPlan } from '@/types/user';
|
||||
import { getStoragePlanData, getUserPlan } from '@/utils/access';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import { deleteUser } from '@/libs/user';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
@@ -28,36 +28,13 @@ const ProfilePage = () => {
|
||||
const { token, user, logout } = useAuth();
|
||||
const { isTrafficLightVisible } = useTrafficLightStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [userPlan, setUserPlan] = useState<UserPlan>('free');
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
|
||||
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
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 { quotas, userPlan } = useQuotaStats();
|
||||
|
||||
const handleGoBack = () => {
|
||||
navigateToLibrary(router);
|
||||
@@ -240,10 +217,15 @@ const ProfilePage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-base-300 mb-8 rounded-lg'>
|
||||
<div className='mb-8 rounded-lg'>
|
||||
<div className='p-0'>
|
||||
{quotas && quotas.length > 0 ? (
|
||||
<Quota quotas={quotas} showProgress className='h-10 pl-4 pr-2' />
|
||||
<Quota
|
||||
quotas={quotas}
|
||||
showProgress
|
||||
className='space-y-4'
|
||||
labelClassName='pl-4 pr-2'
|
||||
/>
|
||||
) : (
|
||||
<div className='h-10 animate-pulse'></div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { MdArrowBackIosNew, MdArrowForwardIos } from 'react-icons/md';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useDrag } from '@/hooks/useDrag';
|
||||
@@ -45,6 +45,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
|
||||
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(!snapHeight);
|
||||
const [isRtl] = useState(() => getDirFromUILanguage() === 'rtl');
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const iconSize22 = useResponsiveSize(22);
|
||||
const isMobile = window.innerWidth < 640;
|
||||
|
||||
@@ -79,10 +80,10 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
}, [isOpen]);
|
||||
|
||||
const handleDragMove = (data: { clientY: number; deltaY: number }) => {
|
||||
if (!isMobile) return;
|
||||
if (!isMobile || !dialogRef.current) return;
|
||||
|
||||
const modal = document.querySelector('.modal-box') as HTMLElement;
|
||||
const overlay = document.querySelector('.overlay') as HTMLElement;
|
||||
const modal = dialogRef.current.querySelector('.modal-box') as HTMLElement;
|
||||
const overlay = dialogRef.current.querySelector('.overlay') as HTMLElement;
|
||||
|
||||
const heightFraction = data.clientY / window.innerHeight;
|
||||
const newTop = Math.max(0.0, Math.min(1, heightFraction));
|
||||
@@ -98,8 +99,9 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
};
|
||||
|
||||
const handleDragEnd = (data: { velocity: number; clientY: number }) => {
|
||||
const modal = document.querySelector('.modal-box') as HTMLElement;
|
||||
const overlay = document.querySelector('.overlay') as HTMLElement;
|
||||
if (!isMobile || !dialogRef.current) return;
|
||||
const modal = dialogRef.current.querySelector('.modal-box') as HTMLElement;
|
||||
const overlay = dialogRef.current.querySelector('.overlay') as HTMLElement;
|
||||
if (!modal || !overlay) return;
|
||||
|
||||
const snapUpper = snapHeight ? 1 - snapHeight - SNAP_THRESHOLD : 0.5;
|
||||
@@ -148,6 +150,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
id={id ?? 'dialog'}
|
||||
open={isOpen}
|
||||
className={clsx(
|
||||
|
||||
@@ -10,12 +10,13 @@ type QuotaProps = {
|
||||
unit: string;
|
||||
}[];
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className }) => {
|
||||
const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className, labelClassName }) => {
|
||||
return (
|
||||
<div className={clsx('text-base-content w-full space-y-2 rounded-md text-base sm:text-sm')}>
|
||||
<div className={clsx('text-base-content w-full rounded-md text-base sm:text-sm', className)}>
|
||||
{quotas.map((quota) => {
|
||||
const usagePercentage = (quota.used / quota.total) * 100;
|
||||
let bgColor = 'bg-green-500';
|
||||
@@ -30,7 +31,7 @@ const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className }) => {
|
||||
key={quota.name}
|
||||
className={clsx(
|
||||
'relative w-full overflow-hidden rounded-md',
|
||||
showProgress && 'border-base-300 border',
|
||||
showProgress && 'bg-base-300',
|
||||
)}
|
||||
>
|
||||
{showProgress && (
|
||||
@@ -41,9 +42,12 @@ const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className }) => {
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx('relative flex items-center justify-between gap-4 p-2', className)}
|
||||
className={clsx(
|
||||
'relative flex items-center justify-between gap-4 p-2',
|
||||
labelClassName,
|
||||
)}
|
||||
>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={quota.tooltip}>
|
||||
<div className='lg:tooltip lg:tooltip-right' data-tip={quota.tooltip}>
|
||||
<span className='truncate'>{quota.name}</span>
|
||||
</div>
|
||||
<div className='text-right text-xs'>
|
||||
|
||||
@@ -10,19 +10,27 @@ type SelectProps = {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
options: Option[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Select({ value, onChange, options, className }: SelectProps) {
|
||||
export default function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
disabled = false,
|
||||
}: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
'select h-8 min-h-8 rounded-md border-none text-end text-sm',
|
||||
'bg-gray-600 text-white/75 focus:outline-none focus:ring-0',
|
||||
'focus:outline-none focus:ring-0',
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
textAlignLast: 'end',
|
||||
}}
|
||||
|
||||
@@ -62,6 +62,7 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
|
||||
const _ = useTranslation();
|
||||
const [targetLang, setTargetLang] = useState('EN');
|
||||
const { translate } = useTranslator({
|
||||
provider: 'azure',
|
||||
sourceLang: 'AUTO',
|
||||
targetLang,
|
||||
});
|
||||
@@ -79,14 +80,7 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
|
||||
const [downloaded, setDownloaded] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const locale = getLocale();
|
||||
let userLang = locale.split('-')[0] || 'en';
|
||||
if (locale === 'zh-CN') {
|
||||
userLang = 'zh-Hans';
|
||||
} else if (locale.startsWith('zh')) {
|
||||
userLang = 'zh-Hant';
|
||||
}
|
||||
setTargetLang(userLang.toUpperCase());
|
||||
setTargetLang(getLocale());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -266,7 +260,7 @@ export const UpdaterContent = ({ version }: { version?: string }) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isMounted) {
|
||||
if (!isMounted || !newVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { QuotaType, UserPlan } from '@/types/user';
|
||||
import { getStoragePlanData, getTranslationPlanData, getUserPlan } from '@/utils/access';
|
||||
import { useTranslation } from './useTranslation';
|
||||
|
||||
export const useQuotaStats = () => {
|
||||
const _ = useTranslation();
|
||||
const { token, user } = useAuth();
|
||||
const [quotas, setQuotas] = useState<QuotaType[]>([]);
|
||||
const [userPlan, setUserPlan] = useState<UserPlan>('free');
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
|
||||
const userPlan = getUserPlan(token);
|
||||
const storagPlan = getStoragePlanData(token);
|
||||
const storageQuota: QuotaType = {
|
||||
name: _('Storage'),
|
||||
tooltip: _('{{percentage}}% of Cloud Storage Used.', {
|
||||
percentage: Math.round((storagPlan.usage / storagPlan.quota) * 100),
|
||||
}),
|
||||
used: Math.round(storagPlan.usage / 1024 / 1024),
|
||||
total: Math.round(storagPlan.quota / 1024 / 1024),
|
||||
unit: 'MB',
|
||||
};
|
||||
const translationPlan = getTranslationPlanData(token);
|
||||
const translationQuota: QuotaType = {
|
||||
name: _('Translation Characters'),
|
||||
tooltip: _('{{percentage}}% of Daily Translation Characters Used.', {
|
||||
percentage: Math.round((translationPlan.usage / translationPlan.quota) * 100),
|
||||
}),
|
||||
used: Math.round(translationPlan.usage / 1024),
|
||||
total: Math.round(translationPlan.quota / 1024),
|
||||
unit: 'K',
|
||||
};
|
||||
setUserPlan(userPlan);
|
||||
setQuotas([storageQuota, translationQuota]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
return {
|
||||
quotas,
|
||||
userPlan,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { getTranslator, getTranslators } from '@/services/translators';
|
||||
import { ErrorCodes, getTranslator, getTranslators, TranslatorName } from '@/services/translators';
|
||||
import { getFromCache, storeInCache, polish, UseTranslatorOptions } from '@/services/translators';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { useTranslation } from './useTranslation';
|
||||
|
||||
export function useTranslator({
|
||||
provider = 'deepl',
|
||||
@@ -9,8 +11,10 @@ export function useTranslator({
|
||||
targetLang = 'EN',
|
||||
enablePolishing = true,
|
||||
}: UseTranslatorOptions = {}) {
|
||||
const _ = useTranslation();
|
||||
const { token } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState(provider);
|
||||
const [translator, setTransltor] = useState(() => getTranslator(provider));
|
||||
const [translators] = useState(() => getTranslators());
|
||||
|
||||
@@ -19,7 +23,15 @@ export function useTranslator({
|
||||
}, [provider, sourceLang, targetLang]);
|
||||
|
||||
useEffect(() => {
|
||||
setTransltor(getTranslator(provider));
|
||||
const availableTranslators = getTranslators().filter(
|
||||
(t) => (t.authRequired ? !!token : true) && !t.quotaExceeded,
|
||||
);
|
||||
const selectedTranslator =
|
||||
availableTranslators.find((t) => t.name === provider) || availableTranslators[0]!;
|
||||
const selectedProviderName = selectedTranslator.name as TranslatorName;
|
||||
setTransltor(getTranslator(selectedProviderName));
|
||||
setSelectedProvider(selectedProviderName);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [provider]);
|
||||
|
||||
const translate = useCallback(
|
||||
@@ -47,7 +59,7 @@ export function useTranslator({
|
||||
text,
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
provider,
|
||||
selectedProvider,
|
||||
);
|
||||
if (cachedTranslation) return;
|
||||
|
||||
@@ -59,7 +71,7 @@ export function useTranslator({
|
||||
if (textsNeedingTranslation.length === 0) {
|
||||
const results = await Promise.all(
|
||||
textsToTranslate.map((text) =>
|
||||
getFromCache(text, sourceLanguage, targetLanguage, provider).then(
|
||||
getFromCache(text, sourceLanguage, targetLanguage, selectedProvider).then(
|
||||
(cached) => cached || text,
|
||||
),
|
||||
),
|
||||
@@ -71,9 +83,9 @@ export function useTranslator({
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const translator = translators.find((t) => t.name === provider);
|
||||
const translator = translators.find((t) => t.name === selectedProvider);
|
||||
if (!translator) {
|
||||
throw new Error(`No translator found for provider: ${provider}`);
|
||||
throw new Error(`No translator found for provider: ${selectedProvider}`);
|
||||
}
|
||||
const translatedTexts = await translator.translate(
|
||||
textsNeedingTranslation,
|
||||
@@ -90,7 +102,7 @@ export function useTranslator({
|
||||
translatedTexts[index] || '',
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
provider,
|
||||
selectedProvider,
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -110,7 +122,7 @@ export function useTranslator({
|
||||
originalText,
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
provider,
|
||||
selectedProvider,
|
||||
);
|
||||
|
||||
if (cachedTranslation) {
|
||||
@@ -123,13 +135,21 @@ export function useTranslator({
|
||||
setLoading(false);
|
||||
return enablePolishing ? polish(results, targetLanguage) : results;
|
||||
} catch (err) {
|
||||
console.error('Translation error:', err);
|
||||
if (err instanceof Error && err.message.includes(ErrorCodes.DAILY_QUOTA_EXCEEDED)) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _(
|
||||
'Daily translation quota reached. Select another translate service to proceed.',
|
||||
),
|
||||
type: 'error',
|
||||
});
|
||||
setSelectedProvider('azure');
|
||||
}
|
||||
setLoading(false);
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[provider, sourceLang, targetLang, translator, token],
|
||||
[selectedProvider, sourceLang, targetLang, translator, token],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import crypto from 'crypto';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { corsAllMethods, runMiddleware } from '@/utils/cors';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||
import { getDailyTranslationPlanData, getUserPlan } from '@/utils/access';
|
||||
import { ErrorCodes } from '@/services/translators';
|
||||
|
||||
const DEFAULT_DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
|
||||
const DEFAULT_DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
|
||||
|
||||
const ErrorCodes = {
|
||||
UNAUTHORIZED: 'Unauthorized',
|
||||
DEEPL_API_ERROR: 'DeepL API Error',
|
||||
DAILY_QUOTA_EXCEEDED: 'Daily Quota Exceeded',
|
||||
INTERNAL_SERVER_ERROR: 'Internal Server Error',
|
||||
};
|
||||
|
||||
interface KVNamespace {
|
||||
get(key: string): Promise<string | null>;
|
||||
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
|
||||
@@ -60,7 +55,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const env = (req.env || {}) as CloudflareEnv;
|
||||
const env = (getCloudflareContext().env || {}) as CloudflareEnv;
|
||||
const hasKVCache = !!env['TRANSLATIONS_KV'];
|
||||
|
||||
const { user, token } = await getUserAndToken(req.headers['authorization']);
|
||||
@@ -108,7 +103,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
}
|
||||
}
|
||||
|
||||
// if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED });
|
||||
if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED });
|
||||
|
||||
return await callDeepLAPI(
|
||||
user?.id,
|
||||
@@ -181,14 +176,17 @@ async function callDeepLAPI(
|
||||
throw new Error(`DeepL API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as {
|
||||
translations?: { text: string; detected_source_language?: string }[];
|
||||
data?: string;
|
||||
};
|
||||
|
||||
let translatedText = '';
|
||||
let detectedSourceLanguage = '';
|
||||
|
||||
if (data.translations && data.translations.length > 0) {
|
||||
translatedText = data.translations[0].text;
|
||||
detectedSourceLanguage = data.translations[0].detected_source_language || '';
|
||||
translatedText = data.translations[0]!.text;
|
||||
detectedSourceLanguage = data.translations[0]!.detected_source_language || '';
|
||||
} else if (data.data) {
|
||||
translatedText = data.data;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
theme: 'light',
|
||||
overrideFont: false,
|
||||
overrideLayout: false,
|
||||
overrideColor: false,
|
||||
userStylesheet: '',
|
||||
};
|
||||
|
||||
@@ -520,8 +521,8 @@ export const READEST_UPDATER_FILE = `${GITHUB_LATEST_DOWNLOAD}/latest.json`;
|
||||
export const READEST_CHANGELOG_FILE = `${GITHUB_LATEST_DOWNLOAD}/release-notes.json`;
|
||||
|
||||
export const SYNC_PROGRESS_INTERVAL_SEC = 3;
|
||||
export const SYNC_NOTES_INTERVAL_SEC = 10;
|
||||
export const SYNC_BOOKS_INTERVAL_SEC = 10;
|
||||
export const SYNC_NOTES_INTERVAL_SEC = 5;
|
||||
export const SYNC_BOOKS_INTERVAL_SEC = 5;
|
||||
export const CHECK_UPDATE_INTERVAL_SEC = 24 * 60 * 60;
|
||||
|
||||
export const MAX_ZOOM_LEVEL = 500;
|
||||
@@ -535,9 +536,9 @@ export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
|
||||
};
|
||||
|
||||
export const DEFAULT_DAILY_TRANSLATION_QUOTA: UserDailyTranslationQuota = {
|
||||
free: 100 * 1024,
|
||||
plus: 1 * 1024 * 1024,
|
||||
pro: 10 * 1024 * 1024,
|
||||
free: 50 * 1024,
|
||||
plus: 500 * 1024,
|
||||
pro: 1024 * 1024,
|
||||
};
|
||||
|
||||
export const DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250;
|
||||
@@ -623,7 +624,7 @@ export const TRANSLATED_LANGS = {
|
||||
pl: 'Polski',
|
||||
tr: 'Türkçe',
|
||||
hi: 'हिन्दी',
|
||||
id: 'Bahasa Indonesia',
|
||||
id: 'Indonesia',
|
||||
vi: 'Tiếng Việt',
|
||||
'zh-CN': '简体中文',
|
||||
'zh-TW': '正體中文',
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
import { TranslationProvider } from '../types';
|
||||
import { ErrorCodes, TranslationProvider } from '../types';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { getUserPlan } from '@/utils/access';
|
||||
import { DEFAULT_DAILY_TRANSLATION_QUOTA } from '@/services/constants';
|
||||
import { saveDailyUsage } from '../utils';
|
||||
|
||||
const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate';
|
||||
|
||||
export const deeplProvider: TranslationProvider = {
|
||||
name: 'deepl',
|
||||
label: _('DeepL'),
|
||||
authRequired: true,
|
||||
quotaExceeded: false,
|
||||
translate: async (
|
||||
text: string[],
|
||||
sourceLang: string,
|
||||
@@ -14,14 +20,22 @@ export const deeplProvider: TranslationProvider = {
|
||||
token?: string | null,
|
||||
useCache: boolean = false,
|
||||
): Promise<string[]> => {
|
||||
const authRequired = deeplProvider.authRequired;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
let userPlan: UserPlan = 'free';
|
||||
if (token) {
|
||||
userPlan = getUserPlan(token);
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (authRequired && !token) {
|
||||
throw new Error('Authentication token is required for DeepL translation');
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
text: text,
|
||||
source_lang: sourceLang.toUpperCase(),
|
||||
@@ -29,22 +43,38 @@ export const deeplProvider: TranslationProvider = {
|
||||
use_cache: useCache,
|
||||
});
|
||||
|
||||
const response = await fetch(DEEPL_API_ENDPOINT, { method: 'POST', headers, body });
|
||||
const quota = DEFAULT_DAILY_TRANSLATION_QUOTA[userPlan];
|
||||
try {
|
||||
const response = await fetch(DEEPL_API_ENDPOINT, { method: 'POST', headers, body });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Translation failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || !data.translations) {
|
||||
throw new Error('Invalid response from translation service');
|
||||
}
|
||||
|
||||
return text.map((line, i) => {
|
||||
if (!line?.trim().length) {
|
||||
return line;
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.error && data.error === ErrorCodes.DAILY_QUOTA_EXCEEDED) {
|
||||
saveDailyUsage(quota);
|
||||
deeplProvider.quotaExceeded = true;
|
||||
throw new Error(ErrorCodes.DAILY_QUOTA_EXCEEDED);
|
||||
}
|
||||
throw new Error(`Translation failed with status ${response.status}`);
|
||||
}
|
||||
return data.translations?.[i]?.text || line;
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || !data.translations) {
|
||||
throw new Error('Invalid response from translation service');
|
||||
}
|
||||
|
||||
return text.map((line, i) => {
|
||||
if (!line?.trim().length) {
|
||||
return line;
|
||||
}
|
||||
const translation = data.translations?.[i];
|
||||
if (translation?.daily_usage) {
|
||||
saveDailyUsage(translation.daily_usage);
|
||||
deeplProvider.quotaExceeded = data.daily_usage >= quota;
|
||||
}
|
||||
return translation?.text || line;
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ function createTranslator<T extends string>(
|
||||
`Translator name "${name}" does not match implementation name "${implementation.name}"`,
|
||||
);
|
||||
}
|
||||
return { ...implementation, name };
|
||||
return implementation as TranslationProvider & { name: T };
|
||||
}
|
||||
|
||||
const deeplTranslator = createTranslator('deepl', deeplProvider);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { TranslatorName } from './providers';
|
||||
export interface TranslationProvider {
|
||||
name: string;
|
||||
label: string;
|
||||
authRequired?: boolean;
|
||||
quotaExceeded?: boolean;
|
||||
translate: (
|
||||
texts: string[],
|
||||
sourceLang: string,
|
||||
@@ -22,3 +24,10 @@ export interface UseTranslatorOptions {
|
||||
targetLang?: string;
|
||||
enablePolishing?: boolean;
|
||||
}
|
||||
|
||||
export const ErrorCodes = {
|
||||
UNAUTHORIZED: 'Unauthorized',
|
||||
DEEPL_API_ERROR: 'DeepL API Error',
|
||||
DAILY_QUOTA_EXCEEDED: 'Daily Quota Exceeded',
|
||||
INTERNAL_SERVER_ERROR: 'Internal Server Error',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const DAILY_USAGE_KEY = 'translationDailyUsage';
|
||||
|
||||
export const saveDailyUsage = (usage: number, date?: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const isoDate = date || new Date().toISOString().split('T')[0]!;
|
||||
const dailyUsage = { [isoDate]: usage };
|
||||
localStorage.setItem(DAILY_USAGE_KEY, JSON.stringify(dailyUsage));
|
||||
}
|
||||
};
|
||||
|
||||
export const getDailyUsage = (date?: string): number | null => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const isoDate = date || new Date().toISOString().split('T')[0]!;
|
||||
const usage = localStorage.getItem(DAILY_USAGE_KEY);
|
||||
if (usage) {
|
||||
const dailyUsage = JSON.parse(usage);
|
||||
if (dailyUsage[isoDate]) {
|
||||
return dailyUsage[isoDate];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -105,6 +105,7 @@ export interface BookStyle {
|
||||
theme: string;
|
||||
overrideFont: boolean;
|
||||
overrideLayout: boolean;
|
||||
overrideColor: boolean;
|
||||
userStylesheet: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { DEFAULT_DAILY_TRANSLATION_QUOTA, DEFAULT_STORAGE_QUOTA } from '@/services/constants';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { getDailyUsage } from '@/services/translators/utils';
|
||||
|
||||
interface Token {
|
||||
plan: UserPlan;
|
||||
@@ -29,6 +30,19 @@ export const getStoragePlanData = (token: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getTranslationPlanData = (token: string) => {
|
||||
const data = jwtDecode<Token>(token) || {};
|
||||
const plan: UserPlan = data['plan'] || 'free';
|
||||
const usage = getDailyUsage() || 0;
|
||||
const quota = DEFAULT_DAILY_TRANSLATION_QUOTA[plan];
|
||||
|
||||
return {
|
||||
plan,
|
||||
usage,
|
||||
quota,
|
||||
};
|
||||
};
|
||||
|
||||
export const getDailyTranslationPlanData = (token: string) => {
|
||||
const data = jwtDecode<Token>(token) || {};
|
||||
const plan = data['plan'] || 'free';
|
||||
|
||||
@@ -26,9 +26,7 @@ const getFontStyles = (
|
||||
minFontSize: number,
|
||||
fontWeight: number,
|
||||
overrideFont: boolean,
|
||||
themeCode: ThemeCode,
|
||||
) => {
|
||||
const { fg, primary, isDarkMode } = themeCode;
|
||||
const lastSerifFonts = ['Georgia', 'Times New Roman'];
|
||||
const serifFonts = [
|
||||
serif,
|
||||
@@ -82,13 +80,54 @@ const getFontStyles = (
|
||||
font[size="7"] {
|
||||
font-size: ${fontSize * 3}px;
|
||||
}
|
||||
/* hardcoded inline font size */
|
||||
[style*="font-size: 16px"], [style*="font-size:16px"] {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
body * {
|
||||
${overrideFont ? 'font-family: revert !important;' : ''}
|
||||
}
|
||||
|
||||
`;
|
||||
return fontStyles;
|
||||
};
|
||||
|
||||
const getColorStyles = (
|
||||
overrideColor: boolean,
|
||||
invertImgColorInDark: boolean,
|
||||
themeCode: ThemeCode,
|
||||
) => {
|
||||
const { bg, fg, primary, isDarkMode } = themeCode;
|
||||
const colorStyles = `
|
||||
html {
|
||||
--theme-bg-color: ${bg};
|
||||
--theme-fg-color: ${fg};
|
||||
--theme-primary-color: ${primary};
|
||||
color-scheme: ${isDarkMode ? 'dark' : 'light'};
|
||||
}
|
||||
html, body {
|
||||
color: ${fg};
|
||||
}
|
||||
div, p, span, pre {
|
||||
${overrideColor ? `background-color: ${bg} !important;` : ''}
|
||||
}
|
||||
a:any-link {
|
||||
${overrideFont ? `color: ${primary};` : isDarkMode ? `color: lightblue;` : ''}
|
||||
${overrideColor ? `color: ${primary};` : isDarkMode ? `color: lightblue;` : ''}
|
||||
text-decoration: none;
|
||||
}
|
||||
p:has(img), span:has(img) {
|
||||
background-color: ${bg};
|
||||
}
|
||||
body.pbg {
|
||||
${isDarkMode ? `background-color: ${bg} !important;` : ''}
|
||||
}
|
||||
img {
|
||||
${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''}
|
||||
}
|
||||
/* inline images */
|
||||
p img, span img, sup img {
|
||||
mix-blend-mode: ${isDarkMode ? 'screen' : 'multiply'};
|
||||
}
|
||||
/* override inline hardcoded text color */
|
||||
*[style*="color: rgb(0,0,0)"], *[style*="color: rgb(0, 0, 0)"],
|
||||
*[style*="color: #000"], *[style*="color: #000000"], *[style*="color: black"],
|
||||
@@ -96,8 +135,20 @@ const getFontStyles = (
|
||||
*[style*="color:#000"], *[style*="color:#000000"], *[style*="color:black"] {
|
||||
color: ${fg} !important;
|
||||
}
|
||||
/* for the Gutenberg eBooks */
|
||||
#pg-header * {
|
||||
color: inherit !important;
|
||||
}
|
||||
.x-ebookmaker, .x-ebookmaker-cover, .x-ebookmaker-coverpage {
|
||||
background-color: unset !important;
|
||||
}
|
||||
/* for the Feedbooks eBooks */
|
||||
.chapterHeader, .chapterHeader * {
|
||||
border-color: unset;
|
||||
background-color: ${bg} !important;
|
||||
}
|
||||
`;
|
||||
return fontStyles;
|
||||
return colorStyles;
|
||||
};
|
||||
|
||||
const getLayoutStyles = (
|
||||
@@ -112,19 +163,10 @@ const getLayoutStyles = (
|
||||
zoomLevel: number,
|
||||
writingMode: string,
|
||||
vertical: boolean,
|
||||
invertImgColorInDark: boolean,
|
||||
themeCode: ThemeCode,
|
||||
) => {
|
||||
const { bg, fg, primary, isDarkMode } = themeCode;
|
||||
const layoutStyle = `
|
||||
@namespace epub "http://www.idpf.org/2007/ops";
|
||||
html {
|
||||
color-scheme: ${isDarkMode ? 'dark' : 'light'};
|
||||
}
|
||||
html {
|
||||
--theme-bg-color: ${bg};
|
||||
--theme-fg-color: ${fg};
|
||||
--theme-primary-color: ${primary};
|
||||
--default-text-align: ${justify ? 'justify' : 'start'};
|
||||
hanging-punctuation: allow-end last;
|
||||
orphans: 2;
|
||||
@@ -146,7 +188,6 @@ const getLayoutStyles = (
|
||||
--background-set: var(--theme-bg-color);
|
||||
}
|
||||
html, body {
|
||||
color: ${fg};
|
||||
${writingMode === 'auto' ? '' : `writing-mode: ${writingMode} !important;`}
|
||||
text-align: var(--default-text-align);
|
||||
max-height: unset;
|
||||
@@ -169,7 +210,8 @@ const getLayoutStyles = (
|
||||
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
|
||||
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
|
||||
text-indent: ${vertical ? textIndent * 1.2 : textIndent}em ${overrideLayout ? '!important' : ''};
|
||||
text-align: ${justify ? 'justify' : ''} ${overrideLayout ? '!important' : ''};
|
||||
${justify ? `text-align: justify ${overrideLayout ? '!important' : ''};` : ''}
|
||||
${!justify && overrideLayout ? 'text-align: unset !important;' : ''};
|
||||
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
|
||||
hyphens: ${hyphenate ? 'auto' : 'manual'};
|
||||
-webkit-hyphenate-limit-before: 3;
|
||||
@@ -207,9 +249,6 @@ const getLayoutStyles = (
|
||||
}
|
||||
|
||||
/* Now begins really dirty hacks to fix some badly designed epubs */
|
||||
body.pbg {
|
||||
${isDarkMode ? `background-color: ${bg} !important;` : ''}
|
||||
}
|
||||
img.pi {
|
||||
${vertical ? 'transform: rotate(90deg);' : ''}
|
||||
${vertical ? 'transform-origin: center;' : ''}
|
||||
@@ -227,25 +266,13 @@ const getLayoutStyles = (
|
||||
color: unset;
|
||||
}
|
||||
|
||||
img {
|
||||
${isDarkMode && invertImgColorInDark ? 'filter: invert(100%);' : ''}
|
||||
}
|
||||
/* inline images without dimension */
|
||||
p img, span img, sup img {
|
||||
p > img, span > img, sup img {
|
||||
height: 1em;
|
||||
mix-blend-mode: ${isDarkMode ? 'screen' : 'multiply'};
|
||||
}
|
||||
p:has(> img:only-child) img, span:has(> img:only-child) img {
|
||||
p:has(> a:only-child) img, p:has(> img:only-child) img, span:has(> img:only-child) img {
|
||||
height: auto;
|
||||
}
|
||||
p:has(img), span:has(img) {
|
||||
background-color: ${bg};
|
||||
}
|
||||
|
||||
/* hardcoded inline font size */
|
||||
[style*="font-size: 16px"], [style*="font-size:16px"] {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
/* workaround for some badly designed epubs */
|
||||
div.left *, p.left * { text-align: left; }
|
||||
@@ -256,20 +283,6 @@ const getLayoutStyles = (
|
||||
.nonindent, .noindent {
|
||||
text-indent: unset !important;
|
||||
}
|
||||
|
||||
/* for the Gutenberg eBooks */
|
||||
#pg-header * {
|
||||
color: inherit !important;
|
||||
}
|
||||
.x-ebookmaker, .x-ebookmaker-cover, .x-ebookmaker-coverpage {
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
/* for the Feedbooks eBooks */
|
||||
.chapterHeader, .chapterHeader * {
|
||||
border-color: unset;
|
||||
background-color: ${bg} !important;
|
||||
}
|
||||
`;
|
||||
return layoutStyle;
|
||||
};
|
||||
@@ -370,8 +383,6 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
|
||||
viewSettings.zoomLevel! / 100.0,
|
||||
viewSettings.writingMode!,
|
||||
viewSettings.vertical!,
|
||||
viewSettings.invertImgColorInDark!,
|
||||
themeCode,
|
||||
);
|
||||
// scale the font size on-the-fly so that we can sync the same font size on different devices
|
||||
const isMobile = ['ios', 'android'].includes(getOSPlatform());
|
||||
@@ -386,11 +397,15 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
|
||||
viewSettings.minimumFontSize!,
|
||||
viewSettings.fontWeight!,
|
||||
viewSettings.overrideFont!,
|
||||
);
|
||||
const colorStyles = getColorStyles(
|
||||
viewSettings.overrideColor!,
|
||||
viewSettings.invertImgColorInDark!,
|
||||
themeCode,
|
||||
);
|
||||
const translationStyles = getTranslationStyles();
|
||||
const userStylesheet = viewSettings.userStylesheet!;
|
||||
return `${layoutStyles}\n${fontStyles}\n${translationStyles}\n${userStylesheet}`;
|
||||
return `${layoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${userStylesheet}`;
|
||||
};
|
||||
|
||||
export const transformStylesheet = (
|
||||
@@ -442,6 +457,7 @@ export const transformStylesheet = (
|
||||
})
|
||||
.replace(/(\d*\.?\d+)vw/gi, (_, d) => (parseFloat(d) * w) / 100 + 'px')
|
||||
.replace(/(\d*\.?\d+)vh/gi, (_, d) => (parseFloat(d) * h) / 100 + 'px')
|
||||
.replace(/[\s;]color\s*:\s*black/gi, 'color: var(--theme-fg-color)')
|
||||
.replace(/[\s;]color\s*:\s*#000000/gi, 'color: var(--theme-fg-color)')
|
||||
.replace(/[\s;]color\s*:\s*#000/gi, 'color: var(--theme-fg-color)')
|
||||
.replace(/[\s;]color\s*:\s*rgb\(0,\s*0,\s*0\)/gi, 'color: var(--theme-fg-color)');
|
||||
|
||||
@@ -10,3 +10,7 @@ binding = "ASSETS"
|
||||
[[kv_namespaces]]
|
||||
binding = "TRANSLATIONS_KV"
|
||||
id = "${TRANSLATIONS_KV_ID}"
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "NEXT_INC_CACHE_R2_BUCKET"
|
||||
bucket_name = "readest-next-inc-cache"
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 9265e75784...19c6744d4b
Generated
+586
-541
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user