Compare commits

...

13 Commits

Author SHA1 Message Date
Huang Xin f2309ef496 release: version 0.9.53 (#1351) 2025-06-05 17:42:22 +02:00
Huang Xin 8d71593f39 fix: exit select mode when all books are deleted, closes #1347 (#1350) 2025-06-05 17:26:55 +02:00
Huang Xin 09e3c30a2a feat: add daily translation quota of DeepL API for more sustainable service (#1349) 2025-06-05 16:58:07 +02:00
Huang Xin eee4cbeaca fix: avoid additional click after books is downloaded before opening (#1346) 2025-06-05 11:43:46 +02:00
Huang Xin 00deca5029 bump: upgrade opennext and wrangler to latest versions (#1345) 2025-06-05 10:46:52 +02:00
Huang Xin c7c20fec55 epub: last resort to get the first image in manifest as book cover (#1343) 2025-06-05 08:40:48 +02:00
Huang Xin 57e22dc7b6 layout: use system select widget for language selection, closes #1339 (#1342) 2025-06-05 08:19:24 +02:00
Huang Xin debd60fb70 css: inline image only as direct children of p and span, closes #1340 (#1341) 2025-06-05 07:47:56 +02:00
Huang Xin dfa17c1c0b css: auto height for single images in anchor (#1337) 2025-06-04 20:08:16 +02:00
Huang Xin 2a26f8b04a fix: trigger library update for the books counter after importing books (#1336) 2025-06-04 19:02:49 +02:00
Huang Xin db955d2c8c feat: add an option to override book fg/bg color, closes #1328 (#1335) 2025-06-04 18:58:53 +02:00
Huang Xin 51602a77fb tts: disable media session controls to keep alive tts (#1333) 2025-06-04 08:17:33 +02:00
Huang Xin 48da5ab546 fix: update current bookshelf after importing and deleting books (#1331) 2025-06-03 19:33:23 +02:00
49 changed files with 743 additions and 722 deletions
+5
View File
@@ -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
+5 -28
View File
@@ -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,
});
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.52",
"version": "0.9.53",
"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,6 +41,7 @@
"@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",
@@ -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"
}
}
+2
View File
@@ -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,10 @@
"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": "أحرف الترجمة"
}
@@ -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,10 @@
"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"
}
@@ -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,10 @@
"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": "Χαρακτήρες μετάφρασης"
}
@@ -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,10 @@
"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"
}
@@ -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,10 @@
"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"
}
@@ -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,10 @@
"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": "अनुवाद वर्ण"
}
@@ -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,12 @@
"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"
}
@@ -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,10 @@
"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"
}
@@ -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,10 @@
"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": "翻訳文字数"
}
@@ -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,10 @@
"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": "번역 문자"
}
@@ -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,10 @@
"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"
}
@@ -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,10 @@
"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"
}
@@ -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,10 @@
"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"
}
@@ -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,10 @@
"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": "Символы перевода"
}
@@ -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,10 @@
"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"
}
@@ -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,10 @@
"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": "Символи перекладу"
}
@@ -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,10 @@
"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"
}
@@ -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,10 @@
"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": "翻译字数"
}
@@ -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,10 @@
"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": "翻譯字數"
}
+10
View File
@@ -1,5 +1,15 @@
{
"releases": {
"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": [
@@ -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>
+8 -1
View File
@@ -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;
+11 -29
View File
@@ -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>
)}
+9 -5
View File
@@ -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 -2
View File
@@ -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(() => {
@@ -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,
};
};
+18 -9
View File
@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { getTranslator, getTranslators } from '@/services/translators';
import { getTranslator, getTranslators, TranslatorName } from '@/services/translators';
import { getFromCache, storeInCache, polish, UseTranslatorOptions } from '@/services/translators';
export function useTranslator({
@@ -11,6 +11,7 @@ export function useTranslator({
}: UseTranslatorOptions = {}) {
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 +20,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 +56,7 @@ export function useTranslator({
text,
sourceLanguage,
targetLanguage,
provider,
selectedProvider,
);
if (cachedTranslation) return;
@@ -59,7 +68,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 +80,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 +99,7 @@ export function useTranslator({
translatedTexts[index] || '',
sourceLanguage,
targetLanguage,
provider,
selectedProvider,
);
}),
);
@@ -110,7 +119,7 @@ export function useTranslator({
originalText,
sourceLanguage,
targetLanguage,
provider,
selectedProvider,
);
if (cachedTranslation) {
@@ -129,7 +138,7 @@ export function useTranslator({
}
},
// 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;
}
+5 -4
View File
@@ -120,6 +120,7 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
theme: 'light',
overrideFont: false,
overrideLayout: false,
overrideColor: false,
userStylesheet: '',
};
@@ -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,36 @@ 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) {
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) {
if (error instanceof Error && error.message.includes(ErrorCodes.DAILY_QUOTA_EXCEEDED)) {
saveDailyUsage(quota);
deeplProvider.quotaExceeded = true;
}
throw error;
}
},
};
@@ -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;
};
+1
View File
@@ -105,6 +105,7 @@ export interface BookStyle {
theme: string;
overrideFont: boolean;
overrideLayout: boolean;
overrideColor: boolean;
userStylesheet: string;
}
+15 -1
View File
@@ -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';
+63 -48
View File
@@ -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;
@@ -207,9 +248,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 +265,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 +282,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 +382,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 +396,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 +456,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)');
+4
View File
@@ -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"
+223 -458
View File
File diff suppressed because it is too large Load Diff