Compare commits

...

30 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
Huang Xin 363b98dd3b release: version 0.9.52 (#1327) 2025-06-03 15:49:04 +02:00
Huang Xin 43532a0ee8 feat: add an option to show remaining in minutes of the current chapter, closes #406 (#1326) 2025-06-03 15:37:55 +02:00
Huang Xin 03b2af1f97 chore: bump pdf.js to latest release of version 4 (#1325) 2025-06-03 13:22:52 +02:00
Huang Xin 3f8d03ae28 sync: add sync status menu item in view menu, closes #844 (#1324) 2025-06-03 11:43:58 +02:00
Huang Xin 146a71fdef font: load CJK fonts only in CJK env or the book has CJK languages, closes #1298 (#1323) 2025-06-03 08:55:40 +02:00
Huang Xin 734e22d92c layout: notebook layout tweaks (#1319) 2025-06-02 17:27:02 +02:00
Huang Xin a77986c0e6 feat: add search functionality to the notebook (#1318) 2025-06-02 16:03:55 +02:00
Huang Xin 4e7f9c49b0 layout: render book description as html in book details, closes #1316 (#1317) 2025-06-02 14:25:51 +02:00
Huang Xin c51c95b883 feat: support note taking with markdown, closes #1097 (#1315) 2025-06-02 11:35:09 +02:00
Huang Xin 62081bebfd fix: update current bookshelf items when library is updated (#1314) 2025-06-02 09:57:11 +02:00
Huang Xin 15394d17d8 feat: show current books count in search bar, closes #1310 (#1312) 2025-06-02 09:31:35 +02:00
Huang Xin 5d1deadc1e fix: defers rendering all component until after hydration for Tauri platforms, closes #1301 (#1311) 2025-06-02 08:25:08 +02:00
Huang Xin b497ddacac css: add margin for div only when overriding layout, closes #1286 (#1309) 2025-06-02 07:59:58 +02:00
Huang Xin a9965fd52c config: add an option to sort TOC by page number, closes #1304 (#1308) 2025-06-02 07:23:59 +02:00
Huang Xin 87d610bb62 revert: some browsers may report wrong pixel density, closes #1305 (#1306)
This reverts commit 6e0cc38aa7ca726e251c16e453922fcd637cee49.
2025-06-02 06:19:13 +02:00
Huang Xin dbc4651141 chore: parse webkit version on linux (#1299) 2025-06-01 04:57:53 +02:00
Huang Xin dad2dfd078 i18n: update translations (#1297) 2025-05-31 18:58:43 +02:00
78 changed files with 1858 additions and 1248 deletions
+1
View File
@@ -253,6 +253,7 @@ The following libraries and frameworks are used in this software:
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
- [daisyUI](https://github.com/saadeghi/daisyui), which is MIT licensed.
- [marked](https://github.com/markedjs/marked), which is MIT licensed.
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
- [react](https://github.com/facebook/react), which is MIT licensed.
+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,
});
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.51",
"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",
@@ -67,6 +69,7 @@
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"marked": "^15.0.12",
"next": "15.2.4",
"posthog-js": "^1.246.0",
"react": "19.0.0",
@@ -80,7 +83,7 @@
"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",
"@types/cssbeautify": "^0.3.5",
@@ -105,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": "أجهزة مزامنة سحابية غير محدودة",
@@ -337,5 +336,25 @@
"System Language": "لغة النظام",
"Security": "الأمان",
"Allow JavaScript": "السماح بـ JavaScript",
"Enable only if you trust the file.": "تمكين فقط إذا كنت تثق في الملف."
"Enable only if you trust the file.": "تمكين فقط إذا كنت تثق في الملف.",
"Sort TOC by Page": "ترتيب جدول المحتويات حسب الصفحة",
"Search in {{count}} Book(s)..._zero": "بحث في الكتب...",
"Search in {{count}} Book(s)..._one": "بحث في كتاب...",
"Search in {{count}} Book(s)..._two": "بحث في كتابين...",
"Search in {{count}} Book(s)..._few": "بحث في {{count}} كتب...",
"Search in {{count}} Book(s)..._many": "بحث في {{count}} كتابًا...",
"Search in {{count}} Book(s)..._other": "بحث في {{count}} من الكتب...",
"No notes match your search": "لا توجد ملاحظات تطابق بحثك",
"Search notes and excerpts...": "بحث في الملاحظات والمقتطفات...",
"Sign in to Sync": "سجّل للمزامنة",
"Synced at {{time}}": "تمت عند {{time}}",
"Never synced": "لم تُزامَن",
"Show Remaining Time": "إظهار الوقت المتبقي",
"Show Page Number": "إظهار رقم الصفحة",
"{{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",
@@ -333,5 +332,21 @@
"System Language": "Systemsprache",
"Security": "Sicherheit",
"Allow JavaScript": "JavaScript zulassen",
"Enable only if you trust the file.": "Nur aktivieren, wenn Sie der Datei vertrauen."
"Enable only if you trust the file.": "Nur aktivieren, wenn Sie der Datei vertrauen.",
"Sort TOC by Page": "TOC nach Seite sortieren",
"Search in {{count}} Book(s)..._one": "Suche in {{count}} Buch...",
"Search in {{count}} Book(s)..._other": "Suche in {{count}} Büchern...",
"No notes match your search": "Noch keine Notizen gefunden",
"Search notes and excerpts...": "Notizen und Auszüge suchen...",
"Sign in to Sync": "Anmelden zum Synchronisieren",
"Synced at {{time}}": "Synchronisiert um {{time}}",
"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",
"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",
@@ -333,5 +332,21 @@
"System Language": "Γλώσσα συστήματος",
"Security": "Ασφάλεια",
"Allow JavaScript": "Επιτρέψτε JavaScript",
"Enable only if you trust the file.": "Ενεργοποιήστε μόνο εάν εμπιστεύεστε το αρχείο."
"Enable only if you trust the file.": "Ενεργοποιήστε μόνο εάν εμπιστεύεστε το αρχείο.",
"Sort TOC by Page": "Ταξινόμηση TOC κατά σελίδα",
"Search in {{count}} Book(s)..._one": "Αναζήτηση σε {{count}} Βιβλίο...",
"Search in {{count}} Book(s)..._other": "Αναζήτηση σε {{count}} Βιβλία...",
"No notes match your search": "Καμία σημείωση δεν βρέθηκε",
"Search notes and excerpts...": "Αναζήτηση σημειώσεων...",
"Sign in to Sync": "Σύνδεση για συγχρονισμό",
"Synced at {{time}}": "Συγχρονίστηκε στις {{time}}",
"Never synced": "Ποτέ δεν συγχρονίστηκε",
"Show Remaining Time": "Εμφάνιση υπολειπόμενου χρόνου",
"Show Page Number": "Εμφάνιση αριθμού σελίδας",
"{{time}} min left in chapter": "{{time}} λεπτά απομένουν στο κεφάλαιο",
"Override Book Color": "Παράκαμψη χρώματος βιβλίου",
"Login Required": "Απαιτείται σύνδεση",
"Quota Exceeded": "Υπέρβαση ορίου",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% των ημερήσιων χαρακτήρων μετάφρασης χρησιμοποιήθηκαν.",
"Translation Characters": "Χαρακτήρες μετάφρασης"
}
@@ -1,4 +1,8 @@
{
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC"
"LXGW WenKai TC": "LXGW WenKai TC",
"Are you sure to delete {{count}} selected book(s)?_one": "Are you sure to delete {{count}} selected book?",
"Are you sure to delete {{count}} selected book(s)?_other": "Are you sure to delete {{count}} selected books?",
"Search in {{count}} Book(s)..._one": "Search in {{count}} book...",
"Search in {{count}} Book(s)..._other": "Search in {{count}} books..."
}
@@ -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",
@@ -334,5 +333,22 @@
"System Language": "Idioma del sistema",
"Security": "Seguridad",
"Allow JavaScript": "Permitir JavaScript",
"Enable only if you trust the file.": "Permitir solo si confías en el archivo."
"Enable only if you trust the file.": "Permitir solo si confías en el archivo.",
"Sort TOC by Page": "Ordenar TOC por página",
"Search in {{count}} Book(s)..._one": "Buscar en {{count}} libro...",
"Search in {{count}} Book(s)..._many": "Buscar en {{count}} libros...",
"Search in {{count}} Book(s)..._other": "Buscar en {{count}} libros...",
"No notes match your search": "No se encontraron notas",
"Search notes and excerpts...": "Buscar notas...",
"Sign in to Sync": "Inicia sesión para sincronizar",
"Synced at {{time}}": "Sincronizado a las {{time}}",
"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",
"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",
@@ -334,5 +333,22 @@
"System Language": "Langue du système",
"Security": "Sécurité",
"Allow JavaScript": "Autoriser JavaScript",
"Enable only if you trust the file.": "Activer uniquement si vous faites confiance au fichier."
"Enable only if you trust the file.": "Activer uniquement si vous faites confiance au fichier.",
"Sort TOC by Page": "Tris la table des matières par page",
"Search in {{count}} Book(s)..._one": "Rechercher dans {{count}} Livre...",
"Search in {{count}} Book(s)..._many": "Rechercher dans {{count}} Livres...",
"Search in {{count}} Book(s)..._other": "Rechercher dans {{count}} Livres...",
"No notes match your search": "Aucune note trouvée",
"Search notes and excerpts...": "Rechercher des notes...",
"Sign in to Sync": "Connectez-vous pour synchroniser",
"Synced at {{time}}": "Synchronisé à {{time}}",
"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",
"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": "असीमित क्लाउड सिंक उपकरण",
@@ -333,5 +332,21 @@
"System Language": "सिस्टम भाषा",
"Security": "सुरक्षा",
"Allow JavaScript": "जावास्क्रिप्ट की अनुमति दें",
"Enable only if you trust the file.": "केवल तभी सक्षम करें जब आप फ़ाइल पर भरोसा करते हों।"
"Enable only if you trust the file.": "केवल तभी सक्षम करें जब आप फ़ाइल पर भरोसा करते हों।",
"Sort TOC by Page": "सामग्री को पृष्ठ द्वारा क्रमबद्ध करें",
"Search in {{count}} Book(s)..._one": "{{count}} पुस्तक में खोजें...",
"Search in {{count}} Book(s)..._other": "{{count}} पुस्तकों में खोजें...",
"No notes match your search": "कोई नोट्स नहीं मिले",
"Search notes and excerpts...": "नोट्स खोजें...",
"Sign in to Sync": "सिंक करने के लिए साइन इन करें",
"Synced at {{time}}": "{{time}} पर सिंक किया गया",
"Never synced": "कभी सिंक नहीं किया गया",
"Show Remaining Time": "शेष समय दिखाएं",
"Show Page Number": "पृष्ठ संख्या दिखाएं",
"{{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",
@@ -332,5 +331,20 @@
"System Language": "Bahasa Sistem",
"Security": "Keamanan",
"Allow JavaScript": "Izinkan JavaScript",
"Enable only if you trust the file.": "Aktifkan hanya jika Anda mempercayai file tersebut."
"Enable only if you trust the file.": "Aktifkan hanya jika Anda mempercayai file tersebut.",
"Sort TOC by Page": "Urutkan TOC berdasarkan Halaman",
"Search in {{count}} Book(s)..._other": "Cari di {{count}} Buku...",
"No notes match your search": "Tidak ada catatan ditemukan",
"Search notes and excerpts...": "Cari catatan...",
"Sign in to Sync": "Masuk untuk Sinkronisasi",
"Synced at {{time}}": "Disinkronkan pada {{time}}",
"Never synced": "Belum pernah disinkronkan",
"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",
@@ -334,5 +333,22 @@
"System Language": "Lingua di sistema",
"Security": "Security",
"Allow JavaScript": "Consenti JavaScript",
"Enable only if you trust the file.": "Abilita solo se ti fidi del file."
"Enable only if you trust the file.": "Abilita solo se ti fidi del file.",
"Sort TOC by Page": "Ordina sommario per pagina",
"Search in {{count}} Book(s)..._one": "Cerca in {{count}} libro...",
"Search in {{count}} Book(s)..._many": "Cerca in {{count}} libri...",
"Search in {{count}} Book(s)..._other": "Cerca in {{count}} libri...",
"No notes match your search": "Nessuna nota trovata",
"Search notes and excerpts...": "Cerca nelle note...",
"Sign in to Sync": "Accedi per sincronizzare",
"Synced at {{time}}": "Sincronizzato alle {{time}}",
"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",
"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": "無制限のクラウド同期デバイス",
@@ -332,5 +331,20 @@
"System Language": "システム言語",
"Security": "セキュリティ",
"Allow JavaScript": "JavaScriptを許可",
"Enable only if you trust the file.": "ファイルを信頼する場合にのみ有効にしてください。"
"Enable only if you trust the file.": "ファイルを信頼する場合にのみ有効にしてください。",
"Sort TOC by Page": "目次をページで並べ替え",
"Search in {{count}} Book(s)..._other": "{{count}}冊の本を検索...",
"No notes match your search": "ノートが見つかりません",
"Search notes and excerpts...": "ノートを検索...",
"Sign in to Sync": "同期するにはサインイン",
"Synced at {{time}}": "{{time}} に同期済み",
"Never synced": "まだ同期されていません",
"Show Remaining Time": "残り時間を表示",
"Show Page Number": "ページ番号を表示",
"{{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": "무제한 클라우드 동기화 기기",
@@ -332,5 +331,20 @@
"System Language": "시스템 언어",
"Security": "보안",
"Allow JavaScript": "JavaScript 허용",
"Enable only if you trust the file.": "파일을 신뢰하는 경우에만 활성화하세요."
"Enable only if you trust the file.": "파일을 신뢰하는 경우에만 활성화하세요.",
"Sort TOC by Page": "목차를 페이지별로 정렬",
"Search in {{count}} Book(s)..._other": "{{count}}권의 책에서 검색...",
"No notes match your search": "일치하는 노트가 없습니다",
"Search notes and excerpts...": "노트 검색...",
"Sign in to Sync": "동기화를 위해 로그인하세요",
"Synced at {{time}}": "{{time}}에 동기화됨",
"Never synced": "동기화된 적 없음",
"Show Remaining Time": "남은 시간 표시",
"Show Page Number": "페이지 번호 표시",
"{{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",
@@ -333,5 +332,21 @@
"System Language": "Systemtaal",
"Security": "Beveiliging",
"Allow JavaScript": "JavaScript toestaan",
"Enable only if you trust the file.": "Schakel alleen in als u het bestand vertrouwt."
"Enable only if you trust the file.": "Schakel alleen in als u het bestand vertrouwt.",
"Sort TOC by Page": "__STRING_NOT_TRANSLATED__",
"Search in {{count}} Book(s)..._one": "Zoeken in {{count}} Boek...",
"Search in {{count}} Book(s)..._other": "Zoeken in {{count}} Boeken...",
"No notes match your search": "Geen notities gevonden",
"Search notes and excerpts...": "Zoek notities...",
"Sign in to Sync": "Meld je aan om te synchroniseren",
"Synced at {{time}}": "Gesynchroniseerd om {{time}}",
"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",
"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",
@@ -335,5 +334,23 @@
"System Language": "Język systemu",
"Security": "Bezpieczeństwo",
"Allow JavaScript": "Zezwól na JavaScript",
"Enable only if you trust the file.": "Zezwól tylko wtedy, gdy ufasz plikowi."
"Enable only if you trust the file.": "Zezwól tylko wtedy, gdy ufasz plikowi.",
"Sort TOC by Page": "Sortuj spis treści według strony",
"Search in {{count}} Book(s)..._one": "Szukaj w {{count}} książce...",
"Search in {{count}} Book(s)..._few": "Szukaj w {{count}} książkach...",
"Search in {{count}} Book(s)..._many": "Szukaj w {{count}} książkach...",
"Search in {{count}} Book(s)..._other": "Szukaj w {{count}} książkach...",
"No notes match your search": "Nie znaleziono notatek",
"Search notes and excerpts...": "Szukaj notatek...",
"Sign in to Sync": "Zaloguj się",
"Synced at {{time}}": "Sync: {{time}}",
"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",
"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",
@@ -334,5 +333,22 @@
"System Language": "Idioma do Sistema",
"Security": "Segurança",
"Allow JavaScript": "Permitir JavaScript",
"Enable only if you trust the file.": "Ativar apenas se você confiar no arquivo."
"Enable only if you trust the file.": "Ativar apenas se você confiar no arquivo.",
"Sort TOC by Page": "Ordenar TOC por Página",
"Search in {{count}} Book(s)..._one": "Pesquisar em {{count}} Livro...",
"Search in {{count}} Book(s)..._many": "Pesquisar em {{count}} Livros...",
"Search in {{count}} Book(s)..._other": "Pesquisar em {{count}} Livros...",
"No notes match your search": "Nenhuma nota encontrada",
"Search notes and excerpts...": "Buscar notas...",
"Sign in to Sync": "Entrar",
"Synced at {{time}}": "Sincronizado às {{time}}",
"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",
"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": "Неограниченное количество устройств для облачной синхронизации",
@@ -335,5 +334,23 @@
"System Language": "Системный язык",
"Security": "Безопасность",
"Allow JavaScript": "Разрешить JavaScript",
"Enable only if you trust the file.": "Включите только если доверяете файлу."
"Enable only if you trust the file.": "Включите только если доверяете файлу.",
"Sort TOC by Page": "Сортировать содержание по странице",
"Search in {{count}} Book(s)..._one": "Поиск в {{count}} книге...",
"Search in {{count}} Book(s)..._few": "Поиск в {{count}} книгах...",
"Search in {{count}} Book(s)..._many": "Поиск в {{count}} книгах...",
"Search in {{count}} Book(s)..._other": "Поиск в {{count}} книгах...",
"No notes match your search": "Записи не найдены",
"Search notes and excerpts...": "Искать записи...",
"Sign in to Sync": "Войти",
"Synced at {{time}}": "Синхр. в {{time}}",
"Never synced": "Не синхр.",
"Show Remaining Time": "Показать оставшееся время",
"Show Page Number": "Показать номер страницы",
"{{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ı",
@@ -333,5 +332,21 @@
"System Language": "System Dili",
"Security": "Security",
"Allow JavaScript": "JavaScript'e İzin Ver",
"Enable only if you trust the file.": "Yalnızca dosyaya güveniyorsanız etkinleştirin."
"Enable only if you trust the file.": "Yalnızca dosyaya güveniyorsanız etkinleştirin.",
"Sort TOC by Page": "İçindekileri Sayfaya Göre Sırala",
"Search in {{count}} Book(s)..._one": "{{count}} kitapta ara...",
"Search in {{count}} Book(s)..._other": "{{count}} kitapta ara...",
"No notes match your search": "Eşleşen not bulunamadı",
"Search notes and excerpts...": "Notlarda ara...",
"Sign in to Sync": "Giriş yap",
"Synced at {{time}}": "{{time}}'de eşzlendi",
"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ı",
"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": "Необмежена кількість пристроїв для хмарної синхронізації",
@@ -335,5 +334,23 @@
"System Language": "Системна мова",
"Security": "Безпека",
"Allow JavaScript": "Дозволити JavaScript",
"Enable only if you trust the file.": "Увімкніть лише якщо довіряєте файлу."
"Enable only if you trust the file.": "Увімкніть лише якщо довіряєте файлу.",
"Sort TOC by Page": "Сортувати зміст за сторінкою",
"Search in {{count}} Book(s)..._one": "Шукати в {{count}} книзі...",
"Search in {{count}} Book(s)..._few": "Шукати в {{count}} книгах...",
"Search in {{count}} Book(s)..._many": "Шукати в {{count}} книгах...",
"Search in {{count}} Book(s)..._other": "Шукати в {{count}} книгах...",
"No notes match your search": "Нотаток не знайдено",
"Search notes and excerpts...": "Шукати нотатки...",
"Sign in to Sync": "Увійти",
"Synced at {{time}}": "Синхр. о {{time}}",
"Never synced": "Ніколи не синхр.",
"Show Remaining Time": "Показати залишок часу",
"Show Page Number": "Показати номер сторінки",
"{{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",
@@ -332,5 +331,20 @@
"System Language": "Ngôn ngữ hệ thống",
"Security": "An ninh",
"Allow JavaScript": "Cho phép JavaScript",
"Enable only if you trust the file.": "Chỉ bật nếu bạn tin tưởng tệp."
"Enable only if you trust the file.": "Chỉ bật nếu bạn tin tưởng tệp.",
"Sort TOC by Page": "Sort Mục lục theo Trang",
"Search in {{count}} Book(s)..._other": "Tìm kiếm trong {{count}} cuốn sách...",
"No notes match your search": "Không tìm thấy ghi chú nào",
"Search notes and excerpts...": "Tìm ghi chú...",
"Sign in to Sync": "Đăng nhập",
"Synced at {{time}}": "Đã đồng bộ lúc {{time}}",
"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",
"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": "无限云同步设备个数",
@@ -332,5 +331,20 @@
"System Language": "系统语言",
"Security": "安全",
"Allow JavaScript": "允许 JavaScript",
"Enable only if you trust the file.": "建議僅在您信任該檔案時啟用"
"Enable only if you trust the file.": "建议仅在您信任该文件时启用",
"Sort TOC by Page": "按页码排序目录",
"Search in {{count}} Book(s)..._other": "在 {{count}} 本书籍中搜索...",
"No notes match your search": "未找到匹配的笔记",
"Search notes and excerpts...": "搜索笔记...",
"Sign in to Sync": "登录后同步",
"Synced at {{time}}": "同步于:{{time}}",
"Never synced": "从未同步",
"Show Remaining Time": "显示剩余时间",
"Show Page Number": "显示页码",
"{{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": "無限雲端同步裝置",
@@ -332,5 +331,20 @@
"System Language": "系統語言",
"Security": "安全",
"Allow JavaScript": "允許 JavaScript",
"Enable only if you trust the file.": "僅在您信任該文件時啟用"
"Enable only if you trust the file.": "建議僅在您信任該檔案時啟用",
"Sort TOC by Page": "依頁碼排序目錄",
"Search in {{count}} Book(s)..._other": "在 {{count}} 本書中搜索...",
"No notes match your search": "找不到符合的筆記",
"Search notes and excerpts...": "搜尋筆記...",
"Sign in to Sync": "登入以同步",
"Synced at {{time}}": "同步於:{{time}}",
"Never synced": "從未同步",
"Show Remaining Time": "顯示剩餘時間",
"Show Page Number": "顯示頁碼",
"{{time}} min left in chapter": "本章剩餘 {{time}} 分鐘",
"Override Book Color": "覆蓋書籍顏色",
"Login Required": "需登入",
"Quota Exceeded": "額度不足",
"{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻譯字數",
"Translation Characters": "翻譯字數"
}
+22
View File
@@ -1,5 +1,27 @@
{
"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": [
"Library: The search bar now shows the number of books currently displayed",
"Book Details: Book descriptions now support rich HTML formatting",
"Notebook: Added support for writing notes using Markdown",
"Notebook: You can now search within your notes",
"Sync: A sync status item has been added to the View menu",
"TOC: You can now sort the table of contents by page number",
"Reader: Option added to show remaining time in the current chapter"
]
},
"0.9.51": {
"date": "2025-06-01",
"notes": [
@@ -68,12 +68,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
);
const isImportingBook = useRef(false);
const { setLibrary } = useLibraryStore();
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
const allBookshelfItems =
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
useEffect(() => {
if (isImportingBook.current) return;
isImportingBook.current = true;
@@ -93,6 +91,15 @@ const Bookshelf: React.FC<BookshelfProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importBookUrl, appService]);
useEffect(() => {
if (navBooksGroup) {
setCurrentBookshelf(navBooksGroup.books);
} else {
setCurrentBookshelf(allBookshelfItems);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libraryBooks, navBooksGroup]);
useEffect(() => {
const group = searchParams?.get('group') || '';
const query = searchParams?.get('q') || '';
@@ -227,6 +234,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
};
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
const filteredBookshelfItems = currentBookshelfItems
.filter((item) => {
if ('name' in item) return item.books.some((book) => bookFilter(book, 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 {
@@ -11,10 +11,11 @@ import { IoMdCloseCircle } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { navigateToLibrary } from '@/utils/nav';
import { throttle } from '@/utils/throttle';
import { debounce } from '@/utils/debounce';
import useShortcuts from '@/hooks/useShortcuts';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
@@ -44,6 +45,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const searchParams = useSearchParams();
const { appService } = useEnv();
const { statusBarHeight } = useThemeStore();
const { currentBookshelf } = useLibraryStore();
const {
isTrafficLightVisible,
initializeTrafficLightStore,
@@ -62,8 +64,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
});
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledUpdateQueryParam = useCallback(
throttle((value: string) => {
const debouncedUpdateQueryParam = useCallback(
debounce((value: string) => {
const params = new URLSearchParams(searchParams?.toString());
if (value) {
params.set('q', value);
@@ -71,14 +73,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
params.delete('q');
}
router.push(`?${params.toString()}`);
}, 1000),
}, 500),
[searchParams],
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setSearchQuery(newQuery);
throttledUpdateQueryParam(newQuery);
debouncedUpdateQueryParam(newQuery);
};
useEffect(() => {
@@ -95,6 +97,10 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const isInGroupView = !!searchParams?.get('group');
const currentBooksCount = currentBookshelf.reduce(
(acc, item) => acc + ('books' in item ? item.books.length : 1),
0,
);
return (
<div
@@ -130,7 +136,13 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<input
type='text'
value={searchQuery}
placeholder={_('Search Books...')}
placeholder={
currentBooksCount > 1
? _('Search in {{count}} Book(s)...', {
count: currentBooksCount,
})
: _('Search Books...')
}
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
@@ -146,7 +158,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
type='button'
onClick={() => {
setSearchQuery('');
throttledUpdateQueryParam('');
debouncedUpdateQueryParam('');
}}
className='pe-1 text-gray-400 hover:text-gray-600'
aria-label={_('Clear Search')}
@@ -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>
+9 -2
View File
@@ -32,7 +32,7 @@ import { useThemeStore } from '@/store/themeStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { lockScreenOrientation } from '@/utils/bridge';
import { mountAdditionalFonts } from '@/utils/style';
import { mountAdditionalFonts } from '@/utils/font';
import {
tauriHandleSetAlwaysOnTop,
tauriHandleToggleFullScreen,
@@ -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);
@@ -11,7 +11,7 @@ import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
import FooterBar from './FooterBar';
import PageInfoView from './PageInfo';
import ProgressInfoView from './ProgressInfo';
import Ribbon from './Ribbon';
import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
@@ -57,7 +57,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { book, bookDoc } = bookData || {};
if (!book || !config || !bookDoc || !viewSettings) return null;
const { section, pageinfo, sectionLabel } = progress || {};
const { section, pageinfo, timeinfo, sectionLabel } = progress || {};
const isBookmarked = getViewState(bookKey)?.ribbonVisible;
const horizontalGapPercent = viewSettings.gapPercent;
const verticalMarginPixels = viewSettings.marginPx;
@@ -139,13 +139,12 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
verticalMargin={verticalMarginPixels}
/>
{showFooter && (
<PageInfoView
<ProgressInfoView
bookKey={bookKey}
bookFormat={book.format}
section={section}
pageinfo={pageinfo}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isScrolled={viewSettings.scrolled}
isVertical={viewSettings.vertical}
timeinfo={timeinfo}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
@@ -5,13 +5,15 @@ import { FoliateView, wrappedFoliateView } from '@/types/view';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { usePagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { getStyles, mountAdditionalFonts, transformStylesheet } from '@/utils/style';
import { getStyles, transformStylesheet } from '@/utils/style';
import { mountAdditionalFonts } from '@/utils/font';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useUICSS } from '@/hooks/useUICSS';
import {
@@ -26,6 +28,7 @@ import {
} from '../utils/iframeEventHandlers';
import { getMaxInlineSize } from '@/utils/config';
import { getDirFromUILanguage } from '@/utils/rtl';
import { isCJKLang } from '@/utils/lang';
import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
@@ -42,6 +45,7 @@ const FoliateViewer: React.FC<{
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { getBookData } = useBookDataStore();
const { themeCode, isDarkMode } = useThemeStore();
const [toastMessage, setToastMessage] = useState('');
@@ -57,7 +61,15 @@ const FoliateViewer: React.FC<{
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
setProgress(
bookKey,
detail.cfi,
detail.tocItem,
detail.section,
detail.location,
detail.time,
detail.range,
);
};
const getDocTransformHandler = ({ width, height }: { width: number; height: number }) => {
@@ -92,6 +104,7 @@ const FoliateViewer: React.FC<{
if (detail.doc) {
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
const viewSettings = getViewSettings(bookKey)!;
const bookData = getBookData(bookKey)!;
viewSettings.vertical =
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
viewSettings.rtl =
@@ -101,7 +114,7 @@ const FoliateViewer: React.FC<{
false;
setViewSettings(bookKey, { ...viewSettings });
mountAdditionalFonts(detail.doc);
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
if (!detail.doc.isEventListenersAdded) {
// listened events in iframes are posted to the main window
@@ -2,12 +2,15 @@ import React, { useEffect, useRef, useState } from 'react';
import { BookDoc } from '@/libs/document';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { getFootnoteStyles, getStyles, getThemeCode, mountAdditionalFonts } from '@/utils/style';
import { getFootnoteStyles, getStyles, getThemeCode } from '@/utils/style';
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
import { FootnoteHandler } from 'foliate-js/footnotes.js';
import { mountAdditionalFonts } from '@/utils/font';
import { eventDispatcher } from '@/utils/event';
import { FoliateView } from '@/types/view';
import { FootnoteHandler } from 'foliate-js/footnotes.js';
import { isCJKLang } from '@/utils/lang';
import Popup from '@/components/Popup';
interface FootnotePopupProps {
@@ -26,6 +29,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const [popupPosition, setPopupPosition] = useState<Position | null>();
const [showPopup, setShowPopup] = useState(false);
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
@@ -55,7 +59,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
});
view.addEventListener('load', (e: CustomEvent) => {
const { doc } = e.detail;
mountAdditionalFonts(doc);
const bookData = getBookData(bookKey)!;
mountAdditionalFonts(doc, isCJKLang(bookData.book?.primaryLanguage));
});
footnoteViewRef.current = view;
footnoteRef.current?.replaceChildren(view);
@@ -1,32 +1,37 @@
import clsx from 'clsx';
import React from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { PageInfo } from '@/types/book';
import { PageInfo, TimeInfo } from '@/types/book';
interface PageInfoProps {
bookKey: string;
bookFormat: string;
section?: PageInfo;
pageinfo?: PageInfo;
showDoubleBorder: boolean;
isScrolled: boolean;
isVertical: boolean;
timeinfo?: TimeInfo;
horizontalGap: number;
verticalMargin: number;
}
const PageInfoView: React.FC<PageInfoProps> = ({
const ProgressInfoView: React.FC<PageInfoProps> = ({
bookKey,
bookFormat,
section,
pageinfo,
showDoubleBorder,
isScrolled,
isVertical,
timeinfo,
horizontalGap,
verticalMargin,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
const isScrolled = viewSettings.scrolled;
const isVertical = viewSettings.vertical;
const pageInfo = ['PDF', 'CBZ'].includes(bookFormat)
? section
? isVertical
@@ -39,11 +44,15 @@ const PageInfoView: React.FC<PageInfoProps> = ({
totalPage: pageinfo.total,
})
: '';
const timeInfo = timeinfo
? _('{{time}} min left in chapter', { time: Math.round(timeinfo.section) })
: '';
return (
<div
className={clsx(
'pageinfo absolute bottom-0 flex items-center justify-end',
'pageinfo absolute bottom-0 flex items-center justify-between',
'text-neutral-content font-sans text-xs font-extralight',
isVertical ? 'writing-vertical-rl' : 'h-12 w-full',
isScrolled && !isVertical && 'bg-base-100',
)}
@@ -53,21 +62,21 @@ const PageInfoView: React.FC<PageInfoProps> = ({
bottom: `${verticalMargin * 1.5}px`,
left: showDoubleBorder ? `calc(${horizontalGap}% - 32px)` : 0,
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
height: `calc(100% - ${verticalMargin * 2}px)`,
height: `calc(100% - ${verticalMargin * 3}px)`,
}
: {
insetInlineEnd: `${horizontalGap}%`,
paddingInlineStart: `${horizontalGap}%`,
paddingInlineEnd: `${horizontalGap}%`,
paddingBottom: appService?.hasSafeAreaInset
? 'calc(env(safe-area-inset-bottom)*0.67)'
: 0,
}
}
>
<h2 className='text-neutral-content text-right font-sans text-xs font-extralight'>
{pageInfo}
</h2>
{viewSettings.showRemainingTime && <span className='text-start'>{timeInfo}</span>}
{viewSettings.showPageNumber && <span className='ms-auto text-end'>{pageInfo}</span>}
</div>
);
};
export default PageInfoView;
export default ProgressInfoView;
@@ -15,8 +15,8 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { eventDispatcher } from '@/utils/event';
import { interceptGlobalOpen } from '@/utils/open';
import { mountAdditionalFonts } from '@/utils/style';
import { interceptWindowOpen } from '@/utils/open';
import { mountAdditionalFonts } from '@/utils/font';
import { setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
@@ -40,7 +40,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useEffect(() => {
mountAdditionalFonts(document);
interceptGlobalOpen();
interceptWindowOpen();
}, []);
useEffect(() => {
@@ -1,16 +1,22 @@
import clsx from 'clsx';
import React, { useEffect } from 'react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { BiMoon, BiSun } from 'react-icons/bi';
import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
import { MdSync, MdSyncProblem } from 'react-icons/md';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { navigateToLogin } from '@/utils/nav';
import { eventDispatcher } from '@/utils/event';
import { getMaxInlineSize } from '@/utils/config';
import { tauriHandleToggleFullScreen } from '@/utils/window';
import { saveViewSettings } from '../utils/viewSettingsHelper';
@@ -28,8 +34,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
onSetSettingsDialogOpen,
}) => {
const _ = useTranslation();
const router = useRouter();
const { user } = useAuth();
const { envConfig, appService } = useEnv();
const { getConfig } = useBookDataStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const config = getConfig(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const { themeMode, isDarkMode, setThemeMode } = useThemeStore();
@@ -59,6 +69,15 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
setIsDropdownOpen?.(false);
};
const handleSync = () => {
if (!user) {
navigateToLogin(router);
setIsDropdownOpen?.(false);
} else {
eventDispatcher.dispatch('sync-book-progress', { bookKey });
}
};
useEffect(() => {
if (isScrolledMode === viewSettings!.scrolled) return;
viewSettings!.scrolled = isScrolledMode;
@@ -83,10 +102,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invertImgColorInDark]);
const lastSyncTime = Math.max(config?.lastSyncedAtConfig || 0, config?.lastSyncedAtNotes || 0);
return (
<div
tabIndex={0}
className='view-menu dropdown-content bgcolor-base-200 dropdown-right no-triangle border-base-200 z-20 mt-1 w-72 border shadow-2xl'
className='view-menu dropdown-content bgcolor-base-200 dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl'
>
<div className={clsx('flex items-center justify-between rounded-md')}>
<button
@@ -130,6 +151,22 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<hr className='border-base-300 my-1' />
<MenuItem
label={
!user
? _('Sign in to Sync')
: lastSyncTime
? _('Synced at {{time}}', {
time: new Date(lastSyncTime).toLocaleString(),
})
: _('Never synced')
}
Icon={user ? MdSync : MdSyncProblem}
onClick={handleSync}
/>
<hr className='border-base-300 my-1' />
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
<MenuItem
label={
@@ -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 }))}
@@ -9,9 +9,11 @@ import { useResponsiveSize } from '@/hooks/useResponsiveSize';
const NotebookHeader: React.FC<{
isPinned: boolean;
isSearchBarVisible: boolean;
handleClose: () => void;
handleTogglePin: () => void;
}> = ({ isPinned, handleClose, handleTogglePin }) => {
handleToggleSearchBar: () => void;
}> = ({ isPinned, isSearchBarVisible, handleClose, handleTogglePin, handleToggleSearchBar }) => {
const _ = useTranslation();
const iconSize14 = useResponsiveSize(14);
return (
@@ -38,7 +40,10 @@ const NotebookHeader: React.FC<{
</button>
</div>
<div className='flex items-center justify-end gap-x-4'>
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
<button
onClick={handleToggleSearchBar}
className={clsx('btn btn-ghost h-8 min-h-8 w-8 p-0', isSearchBarVisible && 'bg-base-300')}
>
<FiSearch />
</button>
</div>
@@ -2,6 +2,7 @@ import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { TextSelection } from '@/utils/sel';
import { md5Fingerprint } from '@/utils/md5';
import { BookNote } from '@/types/book';
@@ -24,6 +25,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
} = useNotebookStore();
const editorRef = useRef<HTMLTextAreaElement>(null);
const [note, setNote] = React.useState('');
const separatorWidth = useResponsiveSize(3);
useEffect(() => {
if (editorRef.current) {
@@ -125,10 +127,15 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
></textarea>
</div>
</div>
<div className='flex items-start pt-2'>
<div className='mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
<div className='content font-size-sm line-clamp-3 py-2'>
<span className='content font-size-xs inline text-gray-500'>{getAnnotationText()}</span>
<div className='flex items-center pt-2'>
<div
className='me-2 mt-0.5 min-h-full self-stretch rounded-xl bg-gray-300'
style={{
minWidth: `${separatorWidth}px`,
}}
></div>
<div className='content font-size-sm line-clamp-3'>
<span className='content font-size-xs text-gray-500'>{getAnnotationText()}</span>
</div>
</div>
<div className='flex justify-end p-2' dir='ltr'>
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useEffect } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
@@ -18,6 +18,7 @@ import { getBookDirFromLanguage } from '@/utils/book';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
import NoteEditor from './NoteEditor';
import SearchBar from './SearchBar';
const MIN_NOTEBOOK_WIDTH = 0.15;
const MAX_NOTEBOOK_WIDTH = 0.45;
@@ -35,6 +36,10 @@ const Notebook: React.FC = ({}) => {
const { setNotebookWidth, setNotebookVisible, toggleNotebookPin } = useNotebookStore();
const { setNotebookNewAnnotation, setNotebookEditAnnotation } = useNotebookStore();
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
const [searchResults, setSearchResults] = useState<BookNote[] | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const onNavigateEvent = async () => {
const pinButton = document.querySelector('.sidebar-pin-btn');
const isPinButtonHidden = !pinButton || window.getComputedStyle(pinButton).display === 'none';
@@ -135,6 +140,39 @@ const Notebook: React.FC = ({}) => {
const { handleDragStart } = useDrag(onDragMove);
const config = getConfig(sideBarBookKey);
const { booknotes: allNotes = [] } = config || {};
const annotationNotes = allNotes
.filter((note) => note.type === 'annotation' && note.note && !note.deletedAt)
.sort((a, b) => b.createdAt - a.createdAt);
const excerptNotes = allNotes
.filter((note) => note.type === 'excerpt' && note.text && !note.deletedAt)
.sort((a, b) => a.createdAt - b.createdAt);
const handleToggleSearchBar = () => {
setIsSearchBarVisible((prev) => !prev);
if (isSearchBarVisible) {
setSearchResults(null);
setSearchTerm('');
}
};
const filteredAnnotationNotes = useMemo(
() =>
isSearchBarVisible && searchResults
? searchResults.filter((note) => note.type === 'annotation' && note.note && !note.deletedAt)
: annotationNotes,
[annotationNotes, searchResults, isSearchBarVisible],
);
const filteredExcerptNotes = useMemo(
() =>
isSearchBarVisible && searchResults
? searchResults.filter((note) => note.type === 'excerpt' && note.text && !note.deletedAt)
: excerptNotes,
[excerptNotes, searchResults, isSearchBarVisible],
);
if (!sideBarBookKey) return null;
const bookData = getBookData(sideBarBookKey);
@@ -145,14 +183,8 @@ const Notebook: React.FC = ({}) => {
const { bookDoc } = bookData;
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
const config = getConfig(sideBarBookKey);
const { booknotes: allNotes = [] } = config || {};
const annotationNotes = allNotes
.filter((note) => note.type === 'annotation' && note.note && !note.deletedAt)
.sort((a, b) => b.createdAt - a.createdAt);
const excerptNotes = allNotes
.filter((note) => note.type === 'excerpt' && note.text && !note.deletedAt)
.sort((a, b) => a.createdAt - b.createdAt);
const hasSearchResults = filteredAnnotationNotes.length > 0 || filteredExcerptNotes.length > 0;
const hasAnyNotes = annotationNotes.length > 0 || excerptNotes.length > 0;
return isNotebookVisible ? (
<>
@@ -161,7 +193,7 @@ const Notebook: React.FC = ({}) => {
)}
<div
className={clsx(
'notebook-container bg-base-200 right-0 z-20 min-w-60 select-none',
'notebook-container bg-base-200 right-0 z-20 flex min-w-60 select-none flex-col',
'font-sans text-base font-normal sm:text-sm',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
@@ -187,29 +219,60 @@ const Notebook: React.FC = ({}) => {
className='drag-bar absolute left-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleDragStart}
/>
<NotebookHeader
isPinned={isNotebookPinned}
handleClose={() => setNotebookVisible(false)}
handleTogglePin={handleTogglePin}
/>
<div className='max-h-[calc(100vh-44px)] overflow-y-auto px-3'>
<div className='flex-shrink-0'>
<NotebookHeader
isPinned={isNotebookPinned}
isSearchBarVisible={isSearchBarVisible}
handleClose={() => setNotebookVisible(false)}
handleTogglePin={handleTogglePin}
handleToggleSearchBar={handleToggleSearchBar}
/>
<div
className={clsx('search-bar', {
'search-bar-visible': isSearchBarVisible,
})}
>
<SearchBar
isVisible={isSearchBarVisible}
bookKey={sideBarBookKey}
searchTerm={searchTerm}
onSearchResultChange={setSearchResults}
/>
</div>
</div>
<div className='flex-grow overflow-y-auto px-3'>
{isSearchBarVisible && searchResults && !hasSearchResults && hasAnyNotes && (
<div className='flex h-32 items-center justify-center text-gray-500'>
<p className='font-size-sm text-center'>{_('No notes match your search')}</p>
</div>
)}
<div dir='ltr'>
{excerptNotes.length > 0 && (
<p className='content font-size-base pt-1'>{_('Excerpts')}</p>
{filteredExcerptNotes.length > 0 && (
<p className='content font-size-base'>
{_('Excerpts')}
{isSearchBarVisible && searchResults && (
<span className='font-size-xs ml-2 text-gray-500'>
({filteredExcerptNotes.length})
</span>
)}
</p>
)}
</div>
<ul className=''>
{excerptNotes.map((item, index) => (
{filteredExcerptNotes.map((item, index) => (
<li key={`${index}-${item.id}`} className='my-2'>
<div
tabIndex={0}
className='collapse-arrow border-base-300 bg-base-100 collapse border'
>
<div
className='collapse-title font-size-sm h-9 min-h-9 p-2 pe-8 font-medium'
className={clsx(
'collapse-title pe-8 text-sm font-medium',
'h-[2.5rem] min-h-[2.5rem] p-[0.6rem]',
)}
style={
{
'--top-override': '1.2rem',
'--top-override': '1.25rem',
'--end-override': '0.7rem',
} as React.CSSProperties
}
@@ -232,15 +295,22 @@ const Notebook: React.FC = ({}) => {
))}
</ul>
<div dir='ltr'>
{(notebookNewAnnotation || annotationNotes.length > 0) && (
<p className='content font-size-base pt-1'>{_('Notes')}</p>
{(notebookNewAnnotation || filteredAnnotationNotes.length > 0) && (
<p className='content font-size-base'>
{_('Notes')}
{isSearchBarVisible && searchResults && filteredAnnotationNotes.length > 0 && (
<span className='font-size-xs ml-2 text-gray-500'>
({filteredAnnotationNotes.length})
</span>
)}
</p>
)}
</div>
{(notebookNewAnnotation || notebookEditAnnotation) && (
{(notebookNewAnnotation || notebookEditAnnotation) && !isSearchBarVisible && (
<NoteEditor onSave={handleSaveNote} onEdit={(item) => handleEditNote(item, false)} />
)}
<ul>
{annotationNotes.map((item, index) => (
{filteredAnnotationNotes.map((item, index) => (
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={sideBarBookKey} item={item} />
))}
</ul>
@@ -0,0 +1,153 @@
import React, { useEffect, useRef, useState } from 'react';
import { FaSearch, FaTimes } from 'react-icons/fa';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { BookNote } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface SearchBarProps {
isVisible: boolean;
bookKey: string;
searchTerm: string;
onSearchResultChange: (results: BookNote[] | null) => void;
}
const SearchBar: React.FC<SearchBarProps> = ({
isVisible,
bookKey,
searchTerm: term,
onSearchResultChange,
}) => {
const _ = useTranslation();
const { getConfig } = useBookDataStore();
const [searchTerm, setSearchTerm] = useState(term);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const iconSize16 = useResponsiveSize(16);
const iconSize12 = useResponsiveSize(12);
useEffect(() => {
handleSearchTermChange(searchTerm);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
useEffect(() => {
setSearchTerm(term);
handleSearchTermChange(term);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [term]);
useEffect(() => {
if (isVisible && inputRef.current) {
inputRef.current.focus();
}
}, [isVisible]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && inputRef.current) {
inputRef.current.blur();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
};
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchTerm(value);
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
searchTimeout.current = setTimeout(() => {
handleSearchTermChange(value);
}, 300);
};
const handleClearSearch = () => {
setSearchTerm('');
handleSearchTermChange('');
if (inputRef.current) {
inputRef.current.focus();
}
};
const filterNotes = (notes: BookNote[], query: string): BookNote[] => {
if (!query.trim()) return [];
const lowercaseQuery = query.toLowerCase();
return notes.filter((note) => {
const textMatch = note.text?.toLowerCase().includes(lowercaseQuery) || false;
const noteMatch = note.note?.toLowerCase().includes(lowercaseQuery) || false;
return textMatch || noteMatch;
});
};
const handleSearchTermChange = (term: string) => {
if (term.trim().length >= 1) {
handleSearch(term);
} else {
resetSearch();
}
};
const handleSearch = (term: string) => {
const config = getConfig(bookKey);
if (!config) {
resetSearch();
return;
}
const { booknotes: allNotes = [] } = config;
const validNotes = allNotes.filter((note) => !note.deletedAt);
const results = filterNotes(validNotes, term);
onSearchResultChange(results);
};
const resetSearch = () => {
onSearchResultChange(null);
};
return (
<div className='relative px-3 py-2'>
<div className='bg-base-100 flex h-8 items-center rounded-lg'>
<div className='pl-3'>
<FaSearch size={iconSize16} className='text-gray-500' />
</div>
<input
ref={inputRef}
type='text'
value={searchTerm}
spellCheck={false}
onChange={handleInputChange}
placeholder={_('Search notes and excerpts...')}
className='w-full bg-transparent p-2 font-sans text-sm font-light focus:outline-none'
/>
{searchTerm && (
<div className='bg-base-300 flex h-8 w-8 items-center rounded-r-lg'>
<button
onClick={handleClearSearch}
className='btn btn-ghost h-8 min-h-8 w-8 rounded-none rounded-r-lg p-0'
>
<FaTimes size={iconSize12} className='text-gray-500' />
</button>
</div>
)}
</div>
</div>
);
};
export default SearchBar;
@@ -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>
@@ -45,6 +45,8 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [showHeader, setShowHeader] = useState(viewSettings.showHeader!);
const [showFooter, setShowFooter] = useState(viewSettings.showFooter!);
const [showBarsOnScroll, setShowBarsOnScroll] = useState(viewSettings.showBarsOnScroll!);
const [showRemainingTime, setShowRemainingTime] = useState(viewSettings.showRemainingTime!);
const [showPageNumber, setShowPageNumber] = useState(viewSettings.showPageNumber!);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'paragraphMargin', paragraphMargin);
@@ -191,6 +193,16 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showBarsOnScroll]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'showRemainingTime', showRemainingTime, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showRemainingTime]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'showPageNumber', showPageNumber, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showPageNumber]);
const applyMarginAndGap = () => {
const isCompact = !showHeader && !showFooter;
const marginPx = isCompact ? viewSettings.compactMarginPx : viewSettings.marginPx;
@@ -475,6 +487,26 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onChange={() => setShowFooter(!showFooter)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Remaining Time')}</span>
<input
type='checkbox'
className='toggle'
checked={showRemainingTime}
disabled={!showFooter}
onChange={() => setShowRemainingTime(!showRemainingTime)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Page Number')}</span>
<input
type='checkbox'
className='toggle'
checked={showPageNumber}
disabled={!showFooter}
onChange={() => setShowPageNumber(!showPageNumber)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Apply also in Scrolled Mode')}</span>
<input
@@ -2,8 +2,9 @@ import clsx from 'clsx';
import React from 'react';
import Image from 'next/image';
import MenuItem from '@/components/MenuItem';
import { MdCheck } from 'react-icons/md';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { useReaderStore } from '@/store/readerStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -11,6 +12,7 @@ import { isWebAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import useBooksManager from '../../hooks/useBooksManager';
import MenuItem from '@/components/MenuItem';
interface BookMenuProps {
menuClassName?: string;
@@ -19,9 +21,13 @@ interface BookMenuProps {
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
const _ = useTranslation();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getVisibleLibrary } = useLibraryStore();
const { openParallelView } = useBooksManager();
const { sideBarBookKey } = useSidebarStore();
const viewSettings = getViewSettings(sideBarBookKey!);
const [isSortedTOC, setIsSortedTOC] = React.useState(viewSettings?.sortedTOC || false);
const handleParallelView = (id: string) => {
openParallelView(id);
@@ -43,18 +49,25 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
eventDispatcher.dispatch('export-annotations', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const handleToggleSortTOC = () => {
setIsSortedTOC((prev) => !prev);
setIsDropdownOpen?.(false);
if (sideBarBookKey) {
const viewSettings = getViewSettings(sideBarBookKey)!;
viewSettings.sortedTOC = !isSortedTOC;
setViewSettings(sideBarBookKey, viewSettings);
}
setTimeout(() => window.location.reload(), 100);
};
const isWebApp = isWebAppPlatform();
return (
<div
tabIndex={0}
className={clsx(
'book-menu dropdown-content border-base-100 z-20 w-60 shadow-2xl',
menuClassName,
)}
className={clsx('book-menu dropdown-content border-base-100 z-20 shadow-2xl', menuClassName)}
>
<MenuItem label={_('Parallel Read')} noIcon>
<MenuItem label={_('Parallel Read')}>
<ul className='max-h-60 overflow-y-auto'>
{getVisibleLibrary()
.filter((book) => book.format !== 'PDF' && book.format !== 'CBZ')
@@ -81,11 +94,16 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
))}
</ul>
</MenuItem>
<MenuItem label={_('Export Annotations')} noIcon onClick={handleExportAnnotations} />
<MenuItem label={_('Reload Page')} noIcon shortcut='Shift+R' onClick={handleReloadPage} />
<MenuItem label={_('Export Annotations')} onClick={handleExportAnnotations} />
<MenuItem
label={_('Sort TOC by Page')}
Icon={isSortedTOC ? MdCheck : undefined}
onClick={handleToggleSortTOC}
/>
<MenuItem label={_('Reload Page')} shortcut='Shift+R' onClick={handleReloadPage} />
<hr className='border-base-200 my-1' />
{isWebApp && <MenuItem label={_('Download Readest')} noIcon onClick={downloadReadest} />}
<MenuItem label={_('About Readest')} noIcon onClick={showAboutReadest} />
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
</div>
);
};
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React from 'react';
import { marked } from 'marked';
import { useEnv } from '@/context/EnvContext';
import { BookNote } from '@/types/book';
import { useSettingsStore } from '@/store/settingsStore';
@@ -8,8 +9,9 @@ import { useReaderStore } from '@/store/readerStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import useScrollToItem from '../../hooks/useScrollToItem';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
import useScrollToItem from '../../hooks/useScrollToItem';
interface BooknoteItemProps {
bookKey: string;
@@ -23,6 +25,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getProgress, getView, getViewsById } = useReaderStore();
const { setNotebookEditAnnotation, setNotebookVisible } = useNotebookStore();
const separatorWidth = useResponsiveSize(3);
const { text, cfi, note } = item;
const progress = getProgress(bookKey);
@@ -82,15 +85,22 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
}
>
{item.note && (
<span className='content font-size-sm font-normal' dir='auto'>
{item.note}
</span>
<div
className='content prose prose-sm font-size-sm'
dir='auto'
dangerouslySetInnerHTML={{ __html: marked.parse(item.note) }}
></div>
)}
<div className='flex items-start'>
{item.note && (
<div className='my-1 me-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
<div
className='me-2 mt-2.5 min-h-full self-stretch rounded-xl bg-gray-300'
style={{
minWidth: `${separatorWidth}px`,
}}
></div>
)}
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'my-2')}>
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'mt-2')}>
<span
className={clsx(
'inline',
@@ -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,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useSync } from '@/hooks/useSync';
import { BookConfig } from '@/types/book';
@@ -8,6 +8,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { serializeConfig } from '@/utils/serializer';
import { CFI } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
@@ -21,9 +22,9 @@ export const useProgressSync = (bookKey: string) => {
const view = getView(bookKey);
const config = getConfig(bookKey);
const progress = getProgress(bookKey);
// flag to prevent accidental sync without first pulling the config
const configSynced = useRef(false);
const firstPulled = useRef(false);
const configPulled = useRef(false);
const hasPulledConfigOnce = useRef(false);
const pushConfig = (bookKey: string, config: BookConfig | null) => {
if (!config || !user) return;
@@ -41,9 +42,10 @@ export const useProgressSync = (bookKey: string) => {
syncConfigs([], bookHash, 'pull');
};
const syncConfig = () => {
if (!configSynced.current) {
if (!configPulled.current) {
pullConfig(bookKey);
} else {
const config = getConfig(bookKey);
if (config && config.progress && config.progress[0] > 0) {
pushConfig(bookKey, config);
}
@@ -57,6 +59,7 @@ export const useProgressSync = (bookKey: string) => {
}
};
// Push: ad-hoc push when the book is closed
useEffect(() => {
eventDispatcher.on('sync-book-progress', handleSyncBookProgress);
return () => {
@@ -65,45 +68,33 @@ export const useProgressSync = (bookKey: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
useEffect(() => {
if (!progress || firstPulled.current) return;
firstPulled.current = true;
pullConfig(bookKey);
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedAutoSync = useCallback(
debounce(() => {
syncConfig();
};
}, SYNC_PROGRESS_INTERVAL_SEC * 1000),
[],
);
// Push: auto-push progress when progress changes with a debounce
useEffect(() => {
if (!progress?.location || !user) return;
debouncedAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
const lastProgressSyncTime = useRef<number>(0);
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Pull: pull progress once when the book is opened
useEffect(() => {
if (!config?.location || !user) return;
const now = Date.now();
const timeSinceLastSync = now - lastProgressSyncTime.current;
if (timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
lastProgressSyncTime.current = now;
syncConfig();
} else {
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = setTimeout(
() => {
lastProgressSyncTime.current = Date.now();
syncTimeoutRef.current = null;
syncConfig();
},
SYNC_PROGRESS_INTERVAL_SEC * 1000 - timeSinceLastSync,
);
}
if (!progress || hasPulledConfigOnce.current) return;
hasPulledConfigOnce.current = true;
pullConfig(bookKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
}, [progress]);
// sync progress once when the book is opened
// Pull: proccess the pulled progress
useEffect(() => {
if (!configSynced.current && syncedConfigs) {
configSynced.current = true;
if (!configPulled.current && syncedConfigs) {
configPulled.current = true;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const configCFI = config?.location;
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { getOSPlatform } from '@/utils/misc';
@@ -11,6 +12,7 @@ export const useTextSelector = (
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>,
handleDismissPopup: () => void,
) => {
const { appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
@@ -109,10 +111,15 @@ export const useTextSelector = (
};
const handleScroll = () => {
// Prevent the container from scrolling when text is selected in paginated mode
// This is a workaround for the issue #873
// FIXME: this is a workaround for issue #873
// TODO: support text selection across pages
const viewSettings = getViewSettings(bookKey);
if (!viewSettings?.scrolled && view?.renderer?.containerPosition && selectionPosition.current) {
if (
appService?.isAndroidApp &&
!viewSettings?.scrolled &&
view?.renderer?.containerPosition &&
selectionPosition.current
) {
console.warn('Keep container position', selectionPosition.current);
view.renderer.containerPosition = selectionPosition.current;
}
+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>
)}
@@ -196,8 +196,8 @@ const BookDetailModal = ({
</div>
<div>
<span className='font-bold'>{_('Description:')}</span>
<p className='text-neutral-content text-sm'>
{bookMeta.description || _('No description available')}
<p className='text-neutral-content text-sm prose prose-sm'
dangerouslySetInnerHTML={{ __html: bookMeta.description || _('No description available') }}>
</p>
</div>
</div>
@@ -1,15 +1,19 @@
'use client';
import { useEffect, useState } from 'react';
import { IconContext } from 'react-icons';
import { AuthProvider } from '@/context/AuthContext';
import { EnvProvider } from '@/context/EnvContext';
import { CSPostHogProvider } from '@/context/PHContext';
import { SyncProvider } from '@/context/SyncContext';
import { IconContext } from 'react-icons';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { isWebAppPlatform } from '@/services/environment';
const Providers = ({ children }: { children: React.ReactNode }) => {
const iconSize = useDefaultIconSize();
return (
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted || isWebAppPlatform() ? (
<CSPostHogProvider>
<EnvProvider>
<AuthProvider>
@@ -19,7 +23,7 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
</AuthProvider>
</EnvProvider>
</CSPostHogProvider>
);
) : null;
};
export default Providers;
+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,
};
};
@@ -1,17 +1,11 @@
import { getOSPlatform } from '@/utils/misc';
import { useMediaQuery } from 'react-responsive';
// use desktop size as base size
export const useResponsiveSize = (baseSize: number) => {
const isPhone = useMediaQuery({ maxWidth: 480 });
const isTablet = useMediaQuery({ minWidth: 481, maxWidth: 1024 });
if (typeof window === 'undefined') {
return baseSize;
}
const pixelRatio = window.devicePixelRatio || 2.4;
const isMobile = ['android', 'ios'].includes(getOSPlatform());
if (isPhone && isMobile) return baseSize * (pixelRatio / 3) * 1.25;
if (isTablet && isMobile) return baseSize * (pixelRatio / 3) * 1.25;
if (isPhone) return baseSize * 1.25;
if (isTablet) return baseSize * 1.25;
return baseSize;
};
+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;
}
+17 -11
View File
@@ -103,11 +103,6 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
writingMode: 'auto',
vertical: false,
rtl: false,
doubleBorder: false,
borderColor: 'red',
showHeader: true,
showFooter: true,
showBarsOnScroll: false,
scrollingOverlap: 0,
allowScript: false,
};
@@ -125,6 +120,7 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
theme: 'light',
overrideFont: false,
overrideLayout: false,
overrideColor: false,
userStylesheet: '',
};
@@ -142,6 +138,16 @@ export const DEFAULT_CJK_VIEW_SETTINGS: Partial<ViewSettings> = {
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
sideBarTab: 'toc',
uiLanguage: '',
sortedTOC: false,
doubleBorder: false,
borderColor: 'red',
showHeader: true,
showFooter: true,
showBarsOnScroll: false,
showRemainingTime: false,
showPageNumber: true,
};
export const DEFAULT_TTS_CONFIG: TTSConfig = {
@@ -188,7 +194,7 @@ export const CJK_SERIF_FONTS = [
export const CJK_SANS_SERIF_FONTS = ['Noto Sans SC', 'Noto Sans TC'];
export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'Helvetica', 'Arial'];
export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'Helvetica'];
export const MONOSPACE_FONTS = ['Fira Code', 'Lucida Console', 'Consolas', 'Courier New'];
@@ -514,7 +520,7 @@ 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 = 60;
export const SYNC_PROGRESS_INTERVAL_SEC = 3;
export const SYNC_NOTES_INTERVAL_SEC = 10;
export const SYNC_BOOKS_INTERVAL_SEC = 10;
export const CHECK_UPDATE_INTERVAL_SEC = 24 * 60 * 60;
@@ -530,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;
@@ -618,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;
};
+7 -1
View File
@@ -1,23 +1,29 @@
import { create } from 'zustand';
import { Book } from '@/types/book';
import { Book, BooksGroup } from '@/types/book';
import { EnvConfigType, isTauriAppPlatform } from '@/services/environment';
interface LibraryState {
library: Book[]; // might contain deleted books
checkOpenWithBooks: boolean;
checkLastOpenBooks: boolean;
currentBookshelf: (Book | BooksGroup)[];
getVisibleLibrary: () => Book[];
setCheckOpenWithBooks: (check: boolean) => void;
setCheckLastOpenBooks: (check: boolean) => void;
setLibrary: (books: Book[]) => void;
updateBook: (envConfig: EnvConfigType, book: Book) => void;
setCurrentBookshelf: (bookshelf: (Book | BooksGroup)[]) => void;
}
export const useLibraryStore = create<LibraryState>((set, get) => ({
library: [],
currentBookshelf: [],
checkOpenWithBooks: isTauriAppPlatform(),
checkLastOpenBooks: isTauriAppPlatform(),
getVisibleLibrary: () => get().library.filter((book) => !book.deletedAt),
setCurrentBookshelf: (bookshelf: (Book | BooksGroup)[]) => {
set({ currentBookshelf: bookshelf });
},
setCheckOpenWithBooks: (check) => set({ checkOpenWithBooks: check }),
setCheckLastOpenBooks: (check) => set({ checkLastOpenBooks: check }),
setLibrary: (books) => set({ library: books }),
+14 -7
View File
@@ -1,9 +1,16 @@
import { create } from 'zustand';
import { BookContent, BookConfig, PageInfo, BookProgress, ViewSettings } from '@/types/book';
import {
BookContent,
BookConfig,
PageInfo,
BookProgress,
ViewSettings,
TimeInfo,
} from '@/types/book';
import { EnvConfigType } from '@/services/environment';
import { FoliateView } from '@/types/view';
import { BookDoc, DocumentLoader, TOCItem } from '@/libs/document';
import { DocumentLoader, TOCItem } from '@/libs/document';
import { updateToc } from '@/utils/toc';
import { useSettingsStore } from './settingsStore';
import { useBookDataStore } from './bookDataStore';
@@ -41,6 +48,7 @@ interface ReaderStore {
tocItem: TOCItem,
section: PageInfo,
pageinfo: PageInfo,
timeinfo: TimeInfo,
range: Range,
) => void;
getProgress: (key: string) => BookProgress | null;
@@ -122,13 +130,10 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const content = (await appService.loadBookContent(book, settings)) as BookContent;
const { file, config } = content;
console.log('Loading book', key);
const { book: loadedBookDoc } = await new DocumentLoader(file).open({
const { book: bookDoc } = await new DocumentLoader(file).open({
allowScript: config.viewSettings?.allowScript,
});
const bookDoc = loadedBookDoc as BookDoc;
if (bookDoc.toc?.length && bookDoc.sections?.length) {
updateToc(bookDoc, bookDoc.toc, bookDoc.sections);
}
updateToc(bookDoc, config.viewSettings?.sortedTOC ?? false);
// Set the book's language for formerly imported books, newly imported books have this field set
book.primaryLanguage =
book.primaryLanguage ?? getPrimaryLanguage(bookDoc.metadata.language);
@@ -218,6 +223,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
tocItem: TOCItem,
section: PageInfo,
pageinfo: PageInfo,
timeinfo: TimeInfo,
range: Range,
) =>
set((state) => {
@@ -273,6 +279,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
sectionId: tocItem?.id,
section,
pageinfo,
timeinfo,
range,
},
},
+18 -5
View File
@@ -41,6 +41,12 @@ export interface PageInfo {
total: number;
}
// Remaining time of the book in minutes
export interface TimeInfo {
section: number;
total: number;
}
export interface BookNote {
bookHash?: string;
id: string;
@@ -82,11 +88,6 @@ export interface BookLayout {
writingMode: WritingMode;
vertical: boolean;
rtl: boolean;
doubleBorder: boolean;
borderColor: string;
showHeader: boolean;
showFooter: boolean;
showBarsOnScroll: boolean;
scrollingOverlap: number;
allowScript: boolean;
}
@@ -104,6 +105,7 @@ export interface BookStyle {
theme: string;
overrideFont: boolean;
overrideLayout: boolean;
overrideColor: boolean;
userStylesheet: string;
}
@@ -121,6 +123,16 @@ export interface BookFont {
export interface ViewConfig {
sideBarTab: string;
uiLanguage: string;
sortedTOC: boolean;
doubleBorder: boolean;
borderColor: string;
showHeader: boolean;
showFooter: boolean;
showRemainingTime: boolean;
showPageNumber: boolean;
showBarsOnScroll: boolean;
}
export interface TTSConfig {
@@ -155,6 +167,7 @@ export interface BookProgress {
sectionLabel: string;
section: PageInfo;
pageinfo: PageInfo;
timeinfo: TimeInfo;
range: Range;
}
+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';
+109
View File
@@ -0,0 +1,109 @@
import { isCJKEnv } from './misc';
const basicGoogleFonts = [
{ family: 'Bitter', weights: 'ital,wght@0,100..900;1,100..900' },
{ family: 'Fira Code', weights: 'wght@300..700' },
{ family: 'Literata', weights: 'ital,opsz,wght@0,7..72,200..900;1,7..72,200..900' },
{ family: 'Merriweather', weights: 'ital,opsz,wght@0,18..144,300..900;1,18..144,300..900' },
{ family: 'Noto Sans', weights: 'ital,wght@0,100..900;1,100..900' },
{ family: 'Open Sans', weights: 'ital,wght@0,300..800;1,300..800' },
{ family: 'Roboto', weights: 'ital,wght@0,100..900;1,100..900' },
{ family: 'Vollkorn', weights: 'ital,wght@0,400..900;1,400..900' },
];
const cjkGoogleFonts = [
{ family: 'LXGW WenKai TC', weights: '' },
{ family: 'Noto Sans SC', weights: '' },
{ family: 'Noto Sans TC', weights: '' },
{ family: 'Noto Serif JP', weights: '' },
];
const getAdditionalBasicFontLinks = () => `
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${basicGoogleFonts
.map(
({ family, weights }) =>
`family=${encodeURIComponent(family)}${weights ? `:${weights}` : ''}`,
)
.join('&')}&display=swap" crossorigin="anonymous">
`;
const getAdditionalCJKFontLinks = () => `
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-wen-kai-gb-screen@1.0.6/font.min.css" crossorigin="anonymous">
<link rel='stylesheet' href='https://fontsapi.zeoseven.com/431/main/result.css' crossorigin="anonymous"/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${cjkGoogleFonts
.map(
({ family, weights }) =>
`family=${encodeURIComponent(family)}${weights ? `:${weights}` : ''}`,
)
.join('&')}&display=swap" crossorigin="anonymous">
`;
const getAdditionalCJKFontFaces = () => `
@font-face {
font-family: "FangSong";
font-display: swap;
src: local("Fang Song"), local("FangSong"), local("Noto Serif CJK"), local("Source Han Serif SC VF"), url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot");
src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot?#iefix") format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff2") format("woff2"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff") format("woff"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.ttf") format("truetype"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.svg#FangSong") format("svg");
}
@font-face {
font-family: "Kaiti";
font-display: swap;
src: local("Kai"), local("KaiTi"), local("AR PL UKai"), local("LXGW WenKai GB Screen"), url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot");
src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff")format("woff"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.ttf")format("truetype"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.svg#STKaiti")format("svg");
}
@font-face {
font-family: "Heiti";
font-display: swap;
src: local("Hei"), local("SimHei"), local("WenQuanYi Zen Hei"), local("Source Han Sans SC VF"), url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot");
src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff")format("woff"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.ttf")format("truetype"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.svg#WenQuanYi Micro Hei")format("svg");
}
@font-face {
font-family: "XiHeiti";
font-display: swap;
src: local("PingFang SC"), local("Microsoft YaHei"), local("WenQuanYi Micro Hei"), local("FZHei-B01"), url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot");
src: url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff")format("woff"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.ttf")format("truetype"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.svg#STHeiti J Light")format("svg");
}
`;
export const mountAdditionalFonts = (document: Document, isCJK = false) => {
const mountCJKFonts = isCJK || isCJKEnv();
let links = getAdditionalBasicFontLinks();
if (mountCJKFonts) {
const style = document.createElement('style');
style.textContent = getAdditionalCJKFontFaces();
document.head.appendChild(style);
links = `${links}\n${getAdditionalCJKFontLinks()}`;
}
const parser = new DOMParser();
const parsedDocument = parser.parseFromString(links, 'text/html');
Array.from(parsedDocument.head.children).forEach((child) => {
if (child.tagName === 'LINK') {
const link = document.createElement('link');
link.rel = child.getAttribute('rel') || '';
link.href = child.getAttribute('href') || '';
link.crossOrigin = child.getAttribute('crossorigin') || '';
document.head.appendChild(link);
}
});
};
+6
View File
@@ -2,6 +2,12 @@ export const isCJKStr = (str: string) => {
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(str ?? '');
};
export const isCJKLang = (lang: string | null | undefined): boolean => {
if (!lang) return false;
const normalizedLang = lang.split('-')[0]!.toLowerCase();
return ['zh', 'ja', 'ko'].includes(normalizedLang);
};
export const langToDefaultLocale = (langCode: string): string => {
const mapping: Record<string, string> = {
en: 'en-US',
+1 -1
View File
@@ -1,7 +1,7 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { isTauriAppPlatform } from '@/services/environment';
export const interceptGlobalOpen = () => {
export const interceptWindowOpen = () => {
const windowOpen = window.open;
globalThis.open = function (
url?: string | URL,
+70 -141
View File
@@ -14,7 +14,6 @@ import {
generateLightPalette,
generateDarkPalette,
} from '@/styles/themes';
import { getOSPlatform } from './misc';
const getFontStyles = (
@@ -27,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,
@@ -83,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"],
@@ -97,79 +135,22 @@ 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 googleFontsData = [
{ family: 'Bitter', weights: 'ital,wght@0,100..900;1,100..900' },
{ family: 'Fira Code', weights: 'wght@300..700' },
{ family: 'Literata', weights: 'ital,opsz,wght@0,7..72,200..900;1,7..72,200..900' },
{ family: 'Merriweather', weights: 'ital,opsz,wght@0,18..144,300..900;1,18..144,300..900' },
{ family: 'Noto Sans', weights: 'ital,wght@0,100..900;1,100..900' },
{ family: 'Roboto', weights: 'ital,wght@0,100..900;1,100..900' },
{ family: 'Vollkorn', weights: 'ital,wght@0,400..900;1,400..900' },
{ family: 'LXGW WenKai TC' },
{ family: 'Noto Sans SC' },
{ family: 'Noto Sans TC' },
{ family: 'Noto Serif JP' },
];
const getAdditionalFontLinks = () => `
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-wen-kai-gb-screen@1.0.6/font.min.css" crossorigin="anonymous">
<link rel='stylesheet' href='https://fontsapi.zeoseven.com/431/main/result.css' crossorigin="anonymous"/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${googleFontsData
.map(
({ family, weights }) =>
`family=${encodeURIComponent(family)}${weights ? `:${weights}` : ''}`,
)
.join('&')}&display=swap" crossorigin="anonymous">
`;
const getAdditionalFontFaces = () => `
@font-face {
font-family: "FangSong";
font-display: swap;
src: local("Fang Song"), local("FangSong"), local("Noto Serif CJK"), local("Source Han Serif SC VF"), url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot");
src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot?#iefix") format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff2") format("woff2"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff") format("woff"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.ttf") format("truetype"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.svg#FangSong") format("svg");
}
@font-face {
font-family: "Kaiti";
font-display: swap;
src: local("Kai"), local("KaiTi"), local("AR PL UKai"), local("LXGW WenKai GB Screen"), url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot");
src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff")format("woff"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.ttf")format("truetype"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.svg#STKaiti")format("svg");
}
@font-face {
font-family: "Heiti";
font-display: swap;
src: local("Hei"), local("SimHei"), local("WenQuanYi Zen Hei"), local("Source Han Sans SC VF"), url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot");
src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff")format("woff"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.ttf")format("truetype"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.svg#WenQuanYi Micro Hei")format("svg");
}
@font-face {
font-family: "XiHeiti";
font-display: swap;
src: local("PingFang SC"), local("Microsoft YaHei"), local("WenQuanYi Micro Hei"), local("FZHei-B01"), url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot");
src: url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff")format("woff"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.ttf")format("truetype"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.svg#STHeiti J Light")format("svg");
}
`;
const getLayoutStyles = (
overrideLayout: boolean,
paragraphMargin: number,
@@ -182,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;
@@ -216,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;
@@ -251,12 +222,18 @@ const getLayoutStyles = (
p:has(> img:only-child), p:has(> span:only-child > img:only-child) {
text-indent: unset !important;
}
p, div {
p {
${vertical ? `margin-left: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${vertical ? `margin-right: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${!vertical ? `margin-top: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
${!vertical ? `margin-bottom: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
}
div {
${vertical && overrideLayout ? `margin-left: ${paragraphMargin}em !important;` : ''}
${vertical && overrideLayout ? `margin-right: ${paragraphMargin}em !important;` : ''}
${!vertical && overrideLayout ? `margin-top: ${paragraphMargin}em !important;` : ''}
${!vertical && overrideLayout ? `margin-bottom: ${paragraphMargin}em !important;` : ''}
}
pre {
white-space: pre-wrap !important;
@@ -271,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;' : ''}
@@ -291,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; }
@@ -320,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;
};
@@ -434,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());
@@ -450,33 +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}`;
};
export const mountAdditionalFonts = (document: Document) => {
const links = getAdditionalFontLinks();
const parser = new DOMParser();
const parsedDocument = parser.parseFromString(links, 'text/html');
Array.from(parsedDocument.head.children).forEach((child) => {
if (child.tagName === 'LINK') {
const link = document.createElement('link');
link.rel = child.getAttribute('rel') || '';
link.href = child.getAttribute('href') || '';
link.crossOrigin = child.getAttribute('crossorigin') || '';
document.head.appendChild(link);
}
});
const style = document.createElement('style');
style.textContent = getAdditionalFontFaces();
document.head.appendChild(style);
return `${layoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${userStylesheet}`;
};
export const transformStylesheet = (
@@ -528,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)');
+19 -7
View File
@@ -37,7 +37,11 @@ export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
return result;
};
export const updateToc = (bookDoc: BookDoc, items: TOCItem[], sections: SectionItem[]): void => {
export const updateToc = (bookDoc: BookDoc, sortedTOC: boolean): void => {
const items = bookDoc?.toc || [];
const sections = bookDoc?.sections || [];
if (!items.length || !sections.length) return;
const sizes = sections.map((s) => (s.linear != 'no' && s.size > 0 ? s.size : 0));
let cumulativeSize = 0;
const cumulativeSizes = sizes.reduce((acc: number[], size) => {
@@ -59,13 +63,12 @@ export const updateToc = (bookDoc: BookDoc, items: TOCItem[], sections: SectionI
map[section.id] = section;
return map;
}, {});
updateTocData(bookDoc, items, sections, sectionsMap);
items.sort((a, b) => {
if (a.location && b.location) {
return a.location.current - b.location.current;
}
return 0;
});
if (sortedTOC) {
sortTocItems(items);
}
};
const updateTocData = (
@@ -95,3 +98,12 @@ const updateTocData = (
});
return index;
};
const sortTocItems = (items: TOCItem[]): void => {
items.sort((a, b) => {
if (a.location && b.location) {
return a.location.current - b.location.current;
}
return 0;
});
};
+1 -1
View File
@@ -21,7 +21,7 @@ export const parseWebViewVersion = (appService: AppService | null): string => {
return match ? `Edge ${match[1]}` : 'Edge WebView2';
} else if (appService?.appPlatform === 'tauri' && appService?.osPlatform === 'linux') {
// Linux WebView
const match = ua.match(/Chromium\/([0-9.]+)/);
const match = ua.match(/AppleWebKit\/([0-9.]+)/);
return match ? `WebView ${match[1]}` : 'Linux WebView';
} else if (ua.includes('CriOS') && ua.includes('Mobile/') && ua.includes('Safari')) {
// iOS Chrome WebView
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Config } from 'tailwindcss';
import { themes } from './src/styles/themes';
import daisyui from 'daisyui';
import typography from '@tailwindcss/typography';
const config: Config = {
content: [
@@ -23,7 +24,7 @@ const config: Config = {
},
},
},
plugins: [daisyui],
plugins: [daisyui, typography],
daisyui: {
themes: themes.reduce(
(acc, { name, colors }) => {
+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"
+397 -724
View File
File diff suppressed because it is too large Load Diff