Compare commits

...

25 Commits

Author SHA1 Message Date
Huang Xin 948303c43a release: version 0.9.23 (#633) 2025-03-18 17:13:43 +01:00
Huang Xin 82f303fcfe feat: responsive window size for popup footnotes, closes #538 (#632) 2025-03-18 17:00:07 +01:00
Huang Xin 359fa8df35 refactor: always save when changes are made with settings, also closes #626 (#630) 2025-03-18 12:58:01 +01:00
Huang Xin 72e6c058f5 fix: completed books now have 100% progress (#625) 2025-03-18 06:00:33 +01:00
Huang Xin 7648aa47b2 fix: don't reinit footnote handler when theme is changed, closes #619 (#621) 2025-03-17 17:47:31 +01:00
Huang Xin 48074f0f53 feat: add options to show/hide header and footer widgets in paginated and scrolled mode, also closes #602 (#620) 2025-03-17 15:30:12 +01:00
Huang Xin 66bd8d74f3 fix: also set color-scheme in iframe for Safari (#616) 2025-03-17 05:53:42 +01:00
Huang Xin 37f3c8b829 chore: fix node out of memory when building 2025-03-16 03:43:32 +08:00
Huang Xin 8775f90bc1 fix: refresh page when vertical writing mode is set (#615) 2025-03-15 20:07:54 +01:00
Huang Xin d6962055b9 release: version 0.9.22 (#614) 2025-03-15 19:43:44 +01:00
Huang Xin 05489282df cjk: always show vertical layout options in CJK locale (#613) 2025-03-15 19:37:05 +01:00
Huang Xin 58f1b5833f feat: add border frame for vertical layout, also closes #563 (#612) 2025-03-15 18:55:12 +01:00
Huang Xin c66911b060 fix: hiding scrollbars may cause layout changes, closes #600 (#610) 2025-03-15 06:38:48 +01:00
Huang Xin 54debbfe86 api: load balance of translation requests (#609) 2025-03-15 05:24:34 +01:00
Huang Xin 6d580565fd fix: use deepl jsonrpc for free plan translation (#608) 2025-03-14 18:31:51 +01:00
Huang Xin f0270eec4e fix: handle system theme change and set color scheme, closes #571 (#607) 2025-03-14 16:55:27 +01:00
Huang Xin cd9290240a fix: force focus on dropdown menu on older Safari, closes #574 (#606) 2025-03-14 10:14:24 +01:00
Huang Xin 2fc7bc4744 fix: don't handle iframe wheel events for scrolled mode, closes #604 (#605) 2025-03-14 08:43:50 +01:00
Huang Xin 5370a9e7f6 bump: bump update next.js to 15.2.2 (#601) 2025-03-13 19:03:21 +01:00
Huang Xin 08f348ada5 fix: don't hide horizontal scroll bar at the bottom, closes #570 (#599) 2025-03-13 17:45:41 +01:00
Huang Xin 4452f0b810 fix: prevent the footer bar from popping up by tapping at the bottom on mobile devices, closes #597 (#598) 2025-03-13 17:19:31 +01:00
Huang Xin b7dc880a5e feat: add scrolled mode toggler in font/layout settings dialog, closes #580 (#596) 2025-03-13 15:07:17 +01:00
Huang Xin 35596094c2 fix: clear select mode when importing files, closes #581 (#595) 2025-03-13 14:41:19 +01:00
Huang Xin e8eb409f9f feat: add theme editor for custom theme colors, closes #462 (#594) 2025-03-13 14:23:58 +01:00
Huang Xin 6a74a0c5b8 fix: following ordinary link in footnotes, closes #559 (#560) 2025-03-10 18:09:47 +01:00
61 changed files with 1753 additions and 488 deletions
+1
View File
@@ -137,6 +137,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NODE_OPTIONS: '--max-old-space-size=8192'
with:
projectPath: apps/readest-app
releaseId: ${{ needs.get-release.outputs.release_id }}
+1 -3
View File
@@ -13,9 +13,7 @@ const nextConfig = {
images: {
unoptimized: true,
},
devIndicators: {
appIsrStatus: false,
},
devIndicators: false,
// Configure assetPrefix or else the server won't properly resolve your assets.
assetPrefix: '',
reactStrictMode: true,
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.21",
"version": "0.9.23",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -65,9 +65,10 @@
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"next": "15.1.6",
"next": "15.2.2",
"posthog-js": "^1.205.0",
"react": "19.0.0",
"react-color": "^2.19.3",
"react-dom": "19.0.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
@@ -81,11 +82,12 @@
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
"@types/react": "18.3.12",
"@types/react-color": "^3.0.13",
"@types/react-dom": "18.3.1",
"@types/tinycolor2": "^1.4.6",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",
"daisyui": "^4.12.23",
"daisyui": "^4.12.24",
"dotenv-cli": "^7.4.4",
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
@@ -53,12 +53,10 @@
"Hyphenation": "الواصلة",
"Identifier:": "المعرف:",
"Import Books": "استيراد الكتب",
"Invert Colors in Dark Mode": "عكس الألوان في الوضع المظلم",
"Language:": "اللغة:",
"Layout": "التخطيط",
"Light Mode": "الوضع الفاتح",
"Loading...": "جارٍ التحميل...",
"Loc. {{currentPage}} / {{totalPage}}": "الصفحة {{currentPage}} \\ {{totalPage}}",
"Logged in": "تم تسجيل الدخول",
"Logged in as {{userDisplayName}}": "تم تسجيل الدخول باسم {{userDisplayName}}",
"Match Case": "مطابقة حالة الأحرف",
@@ -235,5 +233,19 @@
"Continuous Scroll": "التمرير المستمر",
"Fullscreen": "ملء الشاشة",
"No supported files found. Supported formats: {{formats}}": "لم يتم العثور على ملفات مدعومة. الصيغ المدعومة: {{formats}}",
"Drop to Import Books": "اسحب لاستيراد الكتب"
"Drop to Import Books": "اسحب لاستيراد الكتب",
"Custom": "مخصص",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "العالم مسرح،\nوالناس فيه ممثلون؛\nلهم مخارج ومداخل،\nوالإنسان في حياته يلعب دورًا كثيرًا،\nوأفعاله تمر بسبع مراحل.\n\n— ويليام شكسبير",
"Custom Theme": "سمة مخصصة",
"Theme Name": "اسم السمة",
"Text Color": "لون النص",
"Background Color": "لون الخلفية",
"Preview": "معاينة",
"Contrast": "التباين",
"Sunset": "غروب الشمس",
"Double Border": "حدود مزدوجة",
"Border Color": "لون الحدود",
"Border Frame": "إطار الحدود",
"Show Header": "عرض الرأس",
"Show Footer": "عرض التذييل"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Silbentrennung",
"Identifier:": "Kennung:",
"Import Books": "Bücher importieren",
"Invert Colors in Dark Mode": "Farben im Dunkelmodus invertieren",
"Language:": "Sprache:",
"Layout": "Layout",
"Light Mode": "Hellmodus",
"Loading...": "Wird geladen...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Angemeldet",
"Logged in as {{userDisplayName}}": "Angemeldet als {{userDisplayName}}",
"Match Case": "Groß-/Kleinschreibung beachten",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Kontinuierliches Scrollen",
"Fullscreen": "Vollbild",
"No supported files found. Supported formats: {{formats}}": "Keine unterstützten Dateien gefunden. Unterstützte Formate: {{formats}}",
"Drop to Import Books": "Zum Importieren von Büchern ablegen"
"Drop to Import Books": "Zum Importieren von Büchern ablegen",
"Custom": "Benutzerdefiniert",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Die ganze Welt ist eine Bühne,\nUnd alle Männer und Frauen sind bloß Spieler;\nSie haben ihre Abgänge und ihre Auftritte,\nUnd ein Mann spielt in seinem Leben viele Rollen,\nSeine Taten sind sieben Zeitalter.\n\n— William Shakespeare",
"Custom Theme": "Benutzerdefiniertes Thema",
"Theme Name": "Themenname",
"Text Color": "Textfarbe",
"Background Color": "Hintergrundfarbe",
"Preview": "Vorschau",
"Contrast": "Kontrast",
"Sunset": "Sonnenuntergang",
"Double Border": "Doppelter Rand",
"Border Color": "Randfarbe",
"Border Frame": "Rahmenrand",
"Show Header": "Kopfzeile anzeigen",
"Show Footer": "Fußzeile anzeigen"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Συλλαβισμός",
"Identifier:": "Αναγνωριστικό:",
"Import Books": "Εισαγωγή βιβλίων",
"Invert Colors in Dark Mode": "Αντιστροφή χρωμάτων στη σκοτεινή λειτουργία",
"Language:": "Γλώσσα:",
"Layout": "Διάταξη",
"Light Mode": "Φωτεινή λειτουργία",
"Loading...": "Φόρτωση...",
"Loc. {{currentPage}} / {{totalPage}}": "Θέση {{currentPage}} / {{totalPage}}",
"Logged in": "Συνδεδεμένος",
"Logged in as {{userDisplayName}}": "Συνδεδεμένος ως {{userDisplayName}}",
"Match Case": "Ταίριασμα πεζών-κεφαλαίων",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Συνεχής κύλιση",
"Fullscreen": "Πλήρης οθόνη",
"No supported files found. Supported formats: {{formats}}": "Δεν βρέθηκαν υποστηριζόμενα αρχεία. Υποστηριζόμενες μορφές: {{formats}}",
"Drop to Import Books": "Ρίξτε για εισαγωγή βιβλίων"
"Drop to Import Books": "Ρίξτε για εισαγωγή βιβλίων",
"Custom": "Προσαρμοσμένο",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Όλος ο κόσμος είναι σκηνή,\nΚαι όλοι οι άνδρες και οι γυναίκες απλώς ηθοποιοί·\nΈχουν τις εξόδους και τις εισόδους τους,\nΚαι ένας άνθρωπος στον χρόνο του παίζει πολλούς ρόλους,\nΟι πράξεις του υπάρχουν σε επτά ηλικίες.\n\n— William Shakespeare",
"Custom Theme": "Προσαρμοσμένο θέμα",
"Theme Name": "Όνομα θέματος",
"Text Color": "Χρώμα κειμένου",
"Background Color": "Χρώμα φόντου",
"Preview": "Προεπισκόπηση",
"Contrast": "Αντίθεση",
"Sunset": "Ηλιοβασίλεμα",
"Double Border": "Διπλό περιθώριο",
"Border Color": "Χρώμα περιγράμματος",
"Border Frame": "Πλαίσιο περιγράμματος",
"Show Header": "Εμφάνιση κεφαλίδας",
"Show Footer": "Εμφάνιση υποσέλιδου"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Guionización",
"Identifier:": "Identificador:",
"Import Books": "Importar libros",
"Invert Colors in Dark Mode": "Invertir colores en modo oscuro",
"Language:": "Idioma:",
"Layout": "Diseño",
"Light Mode": "Modo claro",
"Loading...": "Cargando...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Sesión iniciada",
"Logged in as {{userDisplayName}}": "Sesión iniciada como {{userDisplayName}}",
"Match Case": "Coincidir mayúsculas y minúsculas",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Desplazamiento continuo",
"Fullscreen": "Pantalla completa",
"No supported files found. Supported formats: {{formats}}": "No se encontraron archivos compatibles. Formatos compatibles: {{formats}}",
"Drop to Import Books": "Soltar para importar libros"
"Drop to Import Books": "Soltar para importar libros",
"Custom": "Personalizado",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Todo el mundo es un escenario,\nY todos los hombres y mujeres son meros actores;\nTienen sus salidas y sus entradas,\nY un hombre en su tiempo interpreta muchos papeles,\nSus actos son siete edades.\n\n— William Shakespeare",
"Custom Theme": "Tema personalizado",
"Theme Name": "Nombre del tema",
"Text Color": "Color del texto",
"Background Color": "Color de fondo",
"Preview": "Vista previa",
"Contrast": "Contraste",
"Sunset": "Atardecer",
"Double Border": "Doble borde",
"Border Color": "Color del borde",
"Border Frame": "Marco de borde",
"Show Header": "Mostrar encabezado",
"Show Footer": "Mostrar pie de página"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Césure",
"Identifier:": "Identifiant :",
"Import Books": "Importer des livres",
"Invert Colors in Dark Mode": "Inverser les couleurs en mode sombre",
"Language:": "Langue :",
"Layout": "Mise en page",
"Light Mode": "Mode clair",
"Loading...": "Chargement...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Connecté",
"Logged in as {{userDisplayName}}": "Connecté en tant que {{userDisplayName}}",
"Match Case": "Respecter la casse",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Défilement continu",
"Fullscreen": "Plein écran",
"No supported files found. Supported formats: {{formats}}": "Aucun fichier pris en charge trouvé. Formats pris en charge : {{formats}}",
"Drop to Import Books": "Déposez pour importer des livres"
"Drop to Import Books": "Déposez pour importer des livres",
"Custom": "Personnalisé",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Le monde entier est un théâtre,\nEt tous les hommes et les femmes ne sont que des acteurs ;\nIls ont leurs sorties et leurs entrées,\nEt un homme, dans sa vie, joue de nombreux rôles,\nSes actes étant sept âges.\n\n— William Shakespeare",
"Custom Theme": "Thème personnalisé",
"Theme Name": "Nom du thème",
"Text Color": "Couleur du texte",
"Background Color": "Couleur de fond",
"Preview": "Aperçu",
"Contrast": "Contraste",
"Sunset": "Coucher de soleil",
"Double Border": "Double bordure",
"Border Color": "Couleur de la bordure",
"Border Frame": "Cadre de bordure",
"Show Header": "Afficher l'en-tête",
"Show Footer": "Afficher le pied de page"
}
@@ -53,12 +53,10 @@
"Hyphenation": "हाइफ़नेशन",
"Identifier:": "पहचानकर्ता:",
"Import Books": "पुस्तकें आयात करें",
"Invert Colors in Dark Mode": "डार्क मोड में रंग उलटें",
"Language:": "भाषा:",
"Layout": "लेआउट",
"Light Mode": "लाइट मोड",
"Loading...": "लोड हो रहा है...",
"Loc. {{currentPage}} / {{totalPage}}": "स्थान {{currentPage}} / {{totalPage}}",
"Logged in": "लॉग इन किया",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} के रूप में लॉग इन किया",
"Match Case": "केस मैच करें",
@@ -235,5 +233,19 @@
"Continuous Scroll": "निरंतर स्क्रॉल",
"Fullscreen": "पूर्णस्क्रीन",
"No supported files found. Supported formats: {{formats}}": "कोई समर्थित फ़ाइलें नहीं मिलीं। समर्थित प्रारूप: {{formats}}",
"Drop to Import Books": "पुस्तकें आयात करने के लिए छोड़ें"
"Drop to Import Books": "पुस्तकें आयात करने के लिए छोड़ें",
"Custom": "कस्टम",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "पूरी दुनिया एक स्टेज है,\nऔर सभी आदमी और महिलाएँ केवल अभिनेता हैं;\nउनके निकास और प्रवेश होते हैं,\nऔर एक आदमी अपने समय में कई भूमिकाएँ निभाता है,\nउसके कार्य सात युग होते हैं।\n\n— विलियम शेक्सपियर",
"Custom Theme": "कस्टम थीम",
"Theme Name": "थीम का नाम",
"Text Color": "टेक्स्ट रंग",
"Background Color": "पृष्ठभूमि रंग",
"Preview": "पूर्वावलोकन",
"Contrast": "विरोधाभास",
"Sunset": "सनसेट",
"Double Border": "डबल बॉर्डर",
"Border Color": "बॉर्डर रंग",
"Border Frame": "बॉर्डर फ्रेम",
"Show Header": "हेडर दिखाएं",
"Show Footer": "फ़ुटर दिखाएं"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Pemenggalan Kata",
"Identifier:": "Pengenal:",
"Import Books": "Impor Buku",
"Invert Colors in Dark Mode": "Balik Warna di Mode Gelap",
"Language:": "Bahasa:",
"Layout": "Tata Letak",
"Light Mode": "Mode Terang",
"Loading...": "Memuat...",
"Loc. {{currentPage}} / {{totalPage}}": "Lok. {{currentPage}} / {{totalPage}}",
"Logged in": "Sudah Masuk",
"Logged in as {{userDisplayName}}": "Masuk sebagai {{userDisplayName}}",
"Match Case": "Cocokkan Huruf Besar/Kecil",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Gulir Terus",
"Fullscreen": "Layar Penuh",
"No supported files found. Supported formats: {{formats}}": "Tidak ada file yang didukung ditemukan. Format yang didukung: {{formats}}",
"Drop to Import Books": "Jatuhkan untuk Mengimpor Buku"
"Drop to Import Books": "Jatuhkan untuk Mengimpor Buku",
"Custom": "Kustom",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Seluruh dunia adalah panggung,\nDan semua pria dan wanita hanyalah pemain;\nMereka memiliki keluar dan masuk mereka,\nDan seorang pria dalam hidupnya memainkan banyak peran,\nTindakannya ada tujuh zaman.\n\n— William Shakespeare",
"Custom Theme": "Tema Kustom",
"Theme Name": "Nama Tema",
"Text Color": "Warna Teks",
"Background Color": "Warna Latar Belakang",
"Preview": "Pratinjau",
"Contrast": "Kontras",
"Sunset": "Senja",
"Double Border": "Garis Ganda",
"Border Color": "Warna Garis",
"Border Frame": "Bingkai Garis",
"Show Header": "Tampilkan Header",
"Show Footer": "Tampilkan Footer"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Sillabazione",
"Identifier:": "Identificatore:",
"Import Books": "Importa libri",
"Invert Colors in Dark Mode": "Inverti colori in modalità scura",
"Language:": "Lingua:",
"Layout": "Layout",
"Light Mode": "Modalità chiara",
"Loading...": "Caricamento...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Accesso effettuato",
"Logged in as {{userDisplayName}}": "Accesso effettuato come {{userDisplayName}}",
"Match Case": "Maiuscole/minuscole",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Scorrimento continuo",
"Fullscreen": "Schermo intero",
"No supported files found. Supported formats: {{formats}}": "Nessun file supportato trovato. Formati supportati: {{formats}}",
"Drop to Import Books": "Rilascia per importare libri"
"Drop to Import Books": "Rilascia per importare libri",
"Custom": "Personalizzato",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Tutto il mondo è un palcoscenico,\nE tutti gli uomini e le donne sono solo attori;\nHanno le loro uscite e le loro entrate,\nE un uomo nella sua vita interpreta molte parti,\nI suoi atti sono sette età.\n\n— William Shakespeare",
"Custom Theme": "Tema personalizzato",
"Theme Name": "Nome tema",
"Text Color": "Colore testo",
"Background Color": "Colore sfondo",
"Preview": "Anteprima",
"Contrast": "Contrasto",
"Sunset": "Tramonto",
"Double Border": "Doppio bordo",
"Border Color": "Colore bordo",
"Border Frame": "Cornice bordo",
"Show Header": "Mostra intestazione",
"Show Footer": "Mostra piè di pagina"
}
@@ -53,12 +53,10 @@
"Hyphenation": "ハイフネーション",
"Identifier:": "識別子:",
"Import Books": "書籍をインポート",
"Invert Colors in Dark Mode": "ダークモードで色を反転",
"Language:": "言語:",
"Layout": "レイアウト",
"Light Mode": "ライトモード",
"Loading...": "読み込み中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "ログイン済み",
"Logged in as {{userDisplayName}}": "{{userDisplayName}}としてログイン中",
"Match Case": "大文字・小文字を区別",
@@ -235,5 +233,19 @@
"Continuous Scroll": "連続スクロール",
"Fullscreen": "全画面",
"No supported files found. Supported formats: {{formats}}": "サポートされているファイルが見つかりません。サポートされている形式:{{formats}}",
"Drop to Import Books": "書籍をインポートするにはドロップ"
"Drop to Import Books": "書籍をインポートするにはドロップ",
"Custom": "カスタム",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "全世界は舞台であり、\nそしてすべての男性と女性は単なる役者です。\n彼らは出入り口を持っており、\nそして一人の男性は時に多くの役を演じます。\n彼の行為は七つの時代です。\n\n— ウィリアム・シェイクスピア",
"Custom Theme": "カスタムテーマ",
"Theme Name": "テーマ名",
"Text Color": "テキストカラー",
"Background Color": "背景色",
"Preview": "プレビュー",
"Contrast": "コントラスト",
"Sunset": "サンセット",
"Double Border": "二重枠",
"Border Color": "ボーダーカラー",
"Border Frame": "ボーダーフレーム",
"Show Header": "ヘッダーを表示",
"Show Footer": "フッターを表示"
}
@@ -53,12 +53,10 @@
"Hyphenation": "하이픈 넣기",
"Identifier:": "식별자:",
"Import Books": "책 가져오기",
"Invert Colors in Dark Mode": "다크 모드에서 색상 반전",
"Language:": "언어:",
"Layout": "레이아웃",
"Light Mode": "라이트 모드",
"Loading...": "로딩 중...",
"Loc. {{currentPage}} / {{totalPage}}": "위치 {{currentPage}} / {{totalPage}}",
"Logged in": "로그인됨",
"Logged in as {{userDisplayName}}": "{{userDisplayName}}(으)로 로그인됨",
"Match Case": "대소문자 구분",
@@ -235,5 +233,19 @@
"Continuous Scroll": "연속 스크롤",
"Fullscreen": "전체 화면",
"No supported files found. Supported formats: {{formats}}": "지원되는 파일이 없습니다. 지원되는 형식: {{formats}}",
"Drop to Import Books": "책 가져오기 위해 드롭"
"Drop to Import Books": "책 가져오기 위해 드롭",
"Custom": "사용자 정의",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "세상은 무대이며,\n그리고 모든 남자와 여자는 단지 배우일 뿐입니다;\n그들은 나가는 곳과 들어오는 곳이 있으며,\n그리고 한 남자는 그의 시간에 많은 역할을 합니다,\n그의 행동은 일곱 시대입니다.\n\n— 윌리엄 셰익스피어",
"Custom Theme": "사용자 정의 테마",
"Theme Name": "테마 이름",
"Text Color": "텍스트 색상",
"Background Color": "배경 색상",
"Preview": "미리보기",
"Contrast": "대비",
"Sunset": "일몰",
"Double Border": "이중 테두리",
"Border Color": "테두리 색상",
"Border Frame": "테두리 프레임",
"Show Header": "헤더 표시",
"Show Footer": "푸터 표시"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Dzielenie wyrazów",
"Identifier:": "Identyfikator:",
"Import Books": "Importuj książki",
"Invert Colors in Dark Mode": "Odwróć kolory w trybie ciemnym",
"Language:": "Język:",
"Layout": "Układ",
"Light Mode": "Tryb jasny",
"Loading...": "Ładowanie...",
"Loc. {{currentPage}} / {{totalPage}}": "Poz. {{currentPage}} / {{totalPage}}",
"Logged in": "Zalogowano",
"Logged in as {{userDisplayName}}": "Zalogowano jako {{userDisplayName}}",
"Match Case": "Uwzględnij wielkość liter",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Przewijanie ciągłe",
"Fullscreen": "Pełny ekran",
"No supported files found. Supported formats: {{formats}}": "Nie znaleziono obsługiwanych plików. Obsługiwane formaty: {{formats}}",
"Drop to Import Books": "Upuść, aby zaimportować książki"
"Drop to Import Books": "Upuść, aby zaimportować książki",
"Custom": "Niestandardowy",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Cały świat jest sceną,\nA wszyscy mężczyźni i kobiety są tylko aktorami;\nMają swoje wyjścia i wejścia,\nI jeden człowiek w swoim czasie gra wiele ról,\nJego czyny to siedem wieków.\n\n— William Shakespeare",
"Custom Theme": "Niestandardowy motyw",
"Theme Name": "Nazwa motywu",
"Text Color": "Kolor tekstu",
"Background Color": "Kolor tła",
"Preview": "Podgląd",
"Contrast": "Kontrast",
"Sunset": "Zachód słońca",
"Double Border": "Podwójna ramka",
"Border Color": "Kolor ramki",
"Border Frame": "Ramka",
"Show Header": "Pokaż nagłówek",
"Show Footer": "Pokaż stopkę"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Hifenização",
"Identifier:": "Identificador:",
"Import Books": "Importar Livros",
"Invert Colors in Dark Mode": "Inverter Cores no Modo Escuro",
"Language:": "Idioma:",
"Layout": "Layout",
"Light Mode": "Modo Claro",
"Loading...": "Carregando...",
"Loc. {{currentPage}} / {{totalPage}}": "Loc. {{currentPage}} / {{totalPage}}",
"Logged in": "Conectado",
"Logged in as {{userDisplayName}}": "Conectado como {{userDisplayName}}",
"Match Case": "Diferenciar Maiúsculas e Minúsculas",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Rolagem Contínua",
"Fullscreen": "Tela Cheia",
"No supported files found. Supported formats: {{formats}}": "Nenhum arquivo suportado encontrado. Formatos suportados: {{formats}}",
"Drop to Import Books": "Arraste para Importar Livros"
"Drop to Import Books": "Arraste para Importar Livros",
"Custom": "Personalizado",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Todo o mundo é um palco,\nE todos os homens e mulheres são meros atores;\nEles têm suas saídas e suas entradas,\nE um homem em seu tempo desempenha muitos papéis,\nSeus atos sendo sete idades.\n\n— William Shakespeare",
"Custom Theme": "Tema Personalizado",
"Theme Name": "Nome do Tema",
"Text Color": "Cor do Texto",
"Background Color": "Cor de Fundo",
"Preview": "Visualizar",
"Contrast": "Contraste",
"Sunset": "Pôr do Sol",
"Double Border": "Borda Dupla",
"Border Color": "Cor da Borda",
"Border Frame": "Moldura da Borda",
"Show Header": "Mostrar Cabeçalho",
"Show Footer": "Mostrar Rodapé"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Перенос слов",
"Identifier:": "Идентификатор:",
"Import Books": "Импорт книг",
"Invert Colors in Dark Mode": "Инвертировать цвета в тёмной теме",
"Language:": "Язык:",
"Layout": "Макет",
"Light Mode": "Светлая тема",
"Loading...": "Загрузка...",
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Выполнен вход",
"Logged in as {{userDisplayName}}": "Вход выполнен как {{userDisplayName}}",
"Match Case": "Учитывать регистр",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Непрерывная прокрутка",
"Fullscreen": "Полноэкранный режим",
"No supported files found. Supported formats: {{formats}}": "Поддерживаемые файлы не найдены. Поддерживаемые форматы: {{formats}}",
"Drop to Import Books": "Перетащите сюда книги для импорта"
"Drop to Import Books": "Перетащите сюда книги для импорта",
"Custom": "Пользовательский",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Весь мир — театр,\nИ все мужчины и женщины — просто актёры;\nУ них есть свои выходы и входы,\nИ один человек в своё время играет много ролей,\nЕго поступки — это семь веков.\n\n— Уильям Шекспир",
"Custom Theme": "Пользовательская тема",
"Theme Name": "Название темы",
"Text Color": "Цвет текста",
"Background Color": "Цвет фона",
"Preview": "Предпросмотр",
"Contrast": "Контраст",
"Sunset": "Закат",
"Double Border": "Двойная граница",
"Border Color": "Цвет границы",
"Border Frame": "Граница рамки",
"Show Header": "Показать заголовок",
"Show Footer": "Показать нижний колонтитул"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Heceleme",
"Identifier:": "Tanımlayıcı:",
"Import Books": "Kitapları İçe Aktar",
"Invert Colors in Dark Mode": "Karanlık Modda Renkleri Ters Çevir",
"Language:": "Dil:",
"Layout": "Düzen",
"Light Mode": "Aydınlık Mod",
"Loading...": "Yükleniyor...",
"Loc. {{currentPage}} / {{totalPage}}": "Konum {{currentPage}} / {{totalPage}}",
"Logged in": "Giriş Yapıldı",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} olarak giriş yapıldı",
"Match Case": "Büyük/Küçük Harf Eşleştir",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Sürekli Kaydırma",
"Fullscreen": "Tam Ekran",
"No supported files found. Supported formats: {{formats}}": "Desteklenen dosya bulunamadı. Desteklenen formatlar: {{formats}}",
"Drop to Import Books": "Kitapları İçe Aktarmak İçin Bırak"
"Drop to Import Books": "Kitapları İçe Aktarmak İçin Bırak",
"Custom": "Özel",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Tüm dünya bir sahnedir,\nVe tüm erkekler ve kadınlar sadece oyunculardır;\nOnların çıkışları ve girişleri vardır,\nVe bir adam zamanında birçok rol oynar,\nOnun eylemleri yedi yaşındadır.\n\n— William Shakespeare",
"Custom Theme": "Özel Tema",
"Theme Name": "Tema Adı",
"Text Color": "Metin Rengi",
"Background Color": "Arka Plan Rengi",
"Preview": "Önizleme",
"Contrast": "Kontrast",
"Sunset": "Gün Batımı",
"Double Border": "Çift Sınır",
"Border Color": "Sınır Rengi",
"Border Frame": "Sınır Çerçevesi",
"Show Header": "Başlık Göster",
"Show Footer": "Altbilgi Göster"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Перенесення слів",
"Identifier:": "Ідентифікатор:",
"Import Books": "Імпортувати книги",
"Invert Colors in Dark Mode": "Інвертувати кольори в темному режимі",
"Language:": "Мова:",
"Layout": "Макет",
"Light Mode": "Світлий режим",
"Loading...": "Завантаження...",
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Увійшли",
"Logged in as {{userDisplayName}}": "Увійшли як {{userDisplayName}}",
"Match Case": "Враховувати регістр",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Постійна прокрутка",
"Fullscreen": "Повноекранний",
"No supported files found. Supported formats: {{formats}}": "Не знайдено підтримуваних файлів. Підтримувані формати: {{formats}}",
"Drop to Import Books": "Перетягніть для імпорту книг"
"Drop to Import Books": "Перетягніть для імпорту книг",
"Custom": "Користувацький",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Весь світ — театр,\nІ всі чоловіки та жінки — просто актори;\nУ них є свої виходи та входи,\nІ один чоловік у свій час грає багато ролей,\nЙого вчинки — це сім віков.\n\n— Вільям Шекспір",
"Custom Theme": "Користувацька тема",
"Theme Name": "Назва теми",
"Text Color": "Колір тексту",
"Background Color": "Колір фону",
"Preview": "Попередній перегляд",
"Contrast": "Контраст",
"Sunset": "Захід сонця",
"Double Border": "Подвійна рамка",
"Border Color": "Колір рамки",
"Border Frame": "Рамка рамки",
"Show Header": "Показати заголовок",
"Show Footer": "Показати нижню частину"
}
@@ -53,12 +53,10 @@
"Hyphenation": "Gạch nối từ",
"Identifier:": "Định danh:",
"Import Books": "Nhập sách",
"Invert Colors in Dark Mode": "Đảo màu trong chế độ tối",
"Language:": "Ngôn ngữ:",
"Layout": "Bố cục",
"Light Mode": "Chế độ sáng",
"Loading...": "Đang tải...",
"Loc. {{currentPage}} / {{totalPage}}": "Vị trí {{currentPage}} / {{totalPage}}",
"Logged in": "Đã đăng nhập",
"Logged in as {{userDisplayName}}": "Đăng nhập với tên {{userDisplayName}}",
"Match Case": "Phân biệt chữ hoa/thường",
@@ -235,5 +233,19 @@
"Continuous Scroll": "Cuộn liên tục",
"Fullscreen": "Toàn màn hình",
"No supported files found. Supported formats: {{formats}}": "Không tìm thấy tệp được hỗ trợ. Định dạng được hỗ trợ: {{formats}}",
"Drop to Import Books": "Kéo và thả để nhập sách"
"Drop to Import Books": "Kéo và thả để nhập sách",
"Custom": "Tùy chỉnh",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Cả thế giới này là một sân khấu,\nVà tất cả đàn ông và phụ nữ chỉ là những diễn viên;\nHọ có cửa ra và cửa vào của mình,\nVà một người trong cuộc đời mình đóng nhiều vai,\nHành động của mình là bảy tuổi.\n\n— William Shakespeare",
"Custom Theme": "Chủ đề tùy chỉnh",
"Theme Name": "Tên chủ đề",
"Text Color": "Màu văn bản",
"Background Color": "Màu nền",
"Preview": "Xem trước",
"Contrast": "Tương phản",
"Sunset": "Hoàng hôn",
"Double Border": "Đôi viền",
"Border Color": "Màu viền",
"Border Frame": "Khung viền",
"Show Header": "Hiển thị tiêu đề",
"Show Footer": "Hiển thị chân trang"
}
@@ -53,12 +53,10 @@
"Hyphenation": "断字",
"Identifier:": "识别码",
"Import Books": "导入书籍",
"Invert Colors in Dark Mode": "深色主题下反色",
"Language:": "语言",
"Layout": "布局",
"Light Mode": "浅色主题",
"Loading...": "加载中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登录",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登录",
"Match Case": "匹配大小写",
@@ -235,5 +233,19 @@
"Continuous Scroll": "连续滚动",
"Fullscreen": "全屏",
"No supported files found. Supported formats: {{formats}}": "未找到支持的文件。支持的格式:{{formats}}",
"Drop to Import Books": "拖放导入书籍"
"Drop to Import Books": "拖放导入书籍",
"Custom": "自定义",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "世界是座舞台, \n所有男女都只是演员; \n各有其出场和入场; \n每个人皆扮演许多角色, \n而演出共分为七个阶段。\n\n——威廉·莎士比亚",
"Custom Theme": "自定义主题",
"Theme Name": "主题名称",
"Text Color": "文本颜色",
"Background Color": "背景颜色",
"Preview": "预览",
"Contrast": "对比",
"Sunset": "日落",
"Double Border": "显示版框",
"Border Color": "版框颜色",
"Border Frame": "页面版框",
"Show Header": "显示页眉",
"Show Footer": "显示页脚"
}
@@ -53,12 +53,10 @@
"Hyphenation": "斷字",
"Identifier:": "識別碼",
"Import Books": "導入書籍",
"Invert Colors in Dark Mode": "深色主題下反色",
"Language:": "語言",
"Layout": "版面",
"Light Mode": "淺色主題",
"Loading...": "載入中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登入",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登入",
"Match Case": "匹配大小寫",
@@ -235,5 +233,19 @@
"Continuous Scroll": "連續滾動",
"Fullscreen": "全螢幕",
"No supported files found. Supported formats: {{formats}}": "未找到支援的檔案。支援的格式:{{formats}}",
"Drop to Import Books": "拖放導入書籍"
"Drop to Import Books": "拖放導入書籍",
"Custom": "自定義",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "世界是一個舞台,\n所有的男人和女人都只是演員:\n他們有出口,也有入口;\n一個人在他的時代扮演許多角​​色。 \n\n——威廉·莎士比亞",
"Custom Theme": "自定義主題",
"Theme Name": "主題名稱",
"Text Color": "文字顏色",
"Background Color": "背景顏色",
"Preview": "預覽",
"Contrast": "對比",
"Sunset": "日落",
"Double Border": "顯示邊框",
"Border Color": "邊框顏色",
"Border Frame": "頁面邊框",
"Show Header": "顯示頁眉",
"Show Footer": "顯示頁腳"
}
+18
View File
@@ -1,5 +1,23 @@
{
"releases": {
"0.9.23": {
"date": "2025-03-19",
"notes": [
"Add options to show/hide header and footer",
"Add responsive window size for popup footnotes",
"Fix theme color not applied on Safari browsers",
"Fix progress not updated to 100% when finished reading"
]
},
"0.9.22": {
"date": "2025-03-16",
"notes": [
"Add theme color editor for more customization",
"Add border frame for vertical layout",
"Fix layout glitches on macOS in scroll mode",
"Various fixes and enhancements on translator, footnote, and scrollbar"
]
},
"0.9.21": {
"date": "2025-03-10",
"notes": [
@@ -13,7 +13,8 @@ const getProgressPercentage = (book: Book) => {
if (book.progress && book.progress[1] === 1) {
return 100;
}
return Math.round((book.progress[0] / book.progress[1]) * 100);
const percentage = Math.round((book.progress[0] / book.progress[1]) * 100);
return Math.max(0, Math.min(100, percentage));
};
const ReadingProgress: React.FC<ReadingProgressProps> = memo(
@@ -85,9 +85,8 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
}
const mergedBook =
matchingBook.updatedAt > oldBook.updatedAt
? { ...oldBook, ...matchingBook, updatedAt: oldBook.updatedAt }
: { ...matchingBook, ...oldBook, updatedAt: oldBook.updatedAt };
mergedBook.progress = matchingBook.progress ?? oldBook.progress;
? { ...oldBook, ...matchingBook }
: { ...matchingBook, ...oldBook };
return mergedBook;
}
return oldBook;
@@ -434,6 +434,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const handleImportBooks = async () => {
setIsSelectMode(false);
console.log('Importing books...');
let files;
@@ -17,6 +17,7 @@ import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
import FootnotePopup from './FootnotePopup';
import HintInfo from './HintInfo';
import DoubleBorder from './DoubleBorder';
interface BooksGridProps {
bookKeys: string[];
@@ -60,7 +61,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { section, pageinfo, sectionLabel } = progress || {};
const isBookmarked = getViewState(bookKey)?.ribbonVisible;
const marginGap = `${viewSettings.gapPercent}%`;
const horizontalGapPercent = viewSettings.gapPercent;
const verticalMarginPixels = viewSettings.marginPx;
return (
<div
@@ -68,7 +70,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
key={bookKey}
className={`${appService?.hasRoundedWindow ? 'rounded-window' : ''} relative h-full w-full overflow-hidden`}
>
{isBookmarked && <Ribbon width={marginGap} />}
{isBookmarked && <Ribbon width={`${horizontalGapPercent}%`} />}
<HeaderBar
bookKey={bookKey}
bookTitle={book.title}
@@ -79,18 +81,60 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
{viewSettings.scrolled ? null : (
{viewSettings.vertical && (
<>
<SectionInfo section={sectionLabel} gapLeft={marginGap} />
<HintInfo bookKey={bookKey} gapRight={marginGap} />
<PageInfoView
bookFormat={book.format}
section={section}
pageinfo={pageinfo}
gapRight={marginGap}
<div
className='bg-base-100 absolute left-0 top-0 h-full'
style={{
width: `calc(${horizontalGapPercent}%)`,
height: `calc(100% - ${verticalMarginPixels}px)`,
}}
/>
<div
className='bg-base-100 absolute right-0 top-0 h-full'
style={{
width: `calc(${horizontalGapPercent}%)`,
height: `calc(100% - ${verticalMarginPixels}px)`,
}}
/>
</>
)}
{viewSettings.vertical && viewSettings.doubleBorder && (
<DoubleBorder
showHeader={viewSettings.showHeader}
showFooter={viewSettings.showFooter}
borderColor={viewSettings.borderColor}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
)}
{viewSettings.showHeader && (
<SectionInfo
section={sectionLabel}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
)}
<HintInfo
bookKey={bookKey}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
{viewSettings.showFooter && (
<PageInfoView
bookFormat={book.format}
section={section}
pageinfo={pageinfo}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
)}
<Annotator bookKey={bookKey} />
<FooterBar
bookKey={bookKey}
@@ -0,0 +1,76 @@
interface DoubleBorderProps {
borderColor: string;
horizontalGap: number;
verticalMargin: number;
showHeader: boolean;
showFooter: boolean;
}
const paddingPx = 10;
const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderColor,
horizontalGap,
verticalMargin,
showHeader,
showFooter,
}) => {
return (
<>
{/* outter frame */}
<div
className={'borderframe pointer-events-none absolute'}
style={{
border: `4px solid ${borderColor}`,
height: `calc(100% - ${verticalMargin * 2}px + ${paddingPx * 2}px)`,
top: `calc(${verticalMargin}px - ${paddingPx}px)`,
left: `calc(${horizontalGap}% - ${showFooter ? 32 : 0}px - ${paddingPx}px)`,
right: `calc(${horizontalGap}% - ${showHeader ? 32 : 0}px - ${paddingPx}px)`,
}}
></div>
{/* inner frame */}
<div
className={'borderframe pointer-events-none absolute'}
style={{
border: `1px solid ${borderColor}`,
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: showFooter ? `${horizontalGap}%` : `calc(${horizontalGap}%)`,
right: showHeader ? `${horizontalGap}%` : `calc(${horizontalGap}%)`,
}}
/>
{/* footer */}
{showFooter && (
<div
className={'borderframe pointer-events-none absolute'}
style={{
borderTop: `1px solid ${borderColor}`,
borderBottom: `1px solid ${borderColor}`,
borderLeft: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: `calc(${horizontalGap}% - 32px)`,
}}
/>
)}
{/* header */}
{showHeader && (
<div
className={'borderframe pointer-events-none absolute'}
style={{
borderTop: `1px solid ${borderColor}`,
borderBottom: `1px solid ${borderColor}`,
borderRight: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: `calc(100% - ${horizontalGap}%)`,
}}
/>
)}
</>
);
};
export default DoubleBorder;
@@ -9,8 +9,7 @@ import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
import { getStyles, mountAdditionalFonts } from '@/utils/style';
import { applyColorScheme, getStyles, mountAdditionalFonts } from '@/utils/style';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useUICSS } from '@/hooks/useUICSS';
import {
@@ -36,7 +35,7 @@ const FoliateViewer: React.FC<{
const { getView, setView: setFoliateView, setProgress } = useReaderStore();
const { getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { themeCode } = useThemeStore();
const { themeCode, isDarkMode, getIsDarkMode } = useThemeStore();
const viewSettings = getViewSettings(bookKey)!;
const [toastMessage, setToastMessage] = useState('');
@@ -54,7 +53,6 @@ const FoliateViewer: React.FC<{
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
};
const { shouldAutoHideScrollbar, handleScrollbarAutoHide } = useAutoHideScrollbar();
const docLoadHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
console.log('doc loaded:', detail);
@@ -64,11 +62,9 @@ const FoliateViewer: React.FC<{
viewSettings.vertical =
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
viewSettings.rtl = writingDir?.rtl || viewSettings.writingMode.includes('rl') || false;
setViewSettings(bookKey, viewSettings);
if (viewSettings.scrolled && shouldAutoHideScrollbar) {
handleScrollbarAutoHide(detail.doc);
}
setViewSettings(bookKey, { ...viewSettings });
applyColorScheme(detail.doc, getIsDarkMode());
mountAdditionalFonts(detail.doc);
if (!detail.doc.isEventListenersAdded) {
@@ -127,9 +123,12 @@ const FoliateViewer: React.FC<{
if (viewRef.current && viewRef.current.renderer) {
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
// Safari does not recognize color-scheme from the root document in iframe
const doc = viewRef.current.renderer.getContents()[0]!.doc;
applyColorScheme(doc, isDarkMode);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode]);
}, [themeCode, isDarkMode]);
useEffect(() => {
if (isViewCreated.current) return;
@@ -70,14 +70,19 @@ const FooterBar: React.FC<FooterBarProps> = ({
const isVisible = hoveredBookKey === bookKey;
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
const progressValid = !!progressInfo;
const progressFraction = progressValid ? (progressInfo!.current + 1) / progressInfo!.total : 0;
const progressFraction = progressValid
? ((progressInfo!.next ?? progressInfo!.current) + 1) / progressInfo!.total
: 0;
return (
<>
<div
className='absolute bottom-0 left-0 z-10 h-12 w-full'
onMouseEnter={() => setHoveredBookKey(bookKey)}
onTouchStart={() => setHoveredBookKey(bookKey)}
className={clsx(
'absolute bottom-0 left-0 z-10 h-12 w-full',
viewSettings?.vertical && viewSettings?.scrolled ? 'bottom-3 !h-6' : '',
)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
<div
className={clsx(
@@ -1,10 +1,9 @@
import React, { useEffect, useRef, useState } from 'react';
import { BookDoc } from '@/libs/document';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { getFootnoteStyles, getStyles } from '@/utils/style';
import { getFootnoteStyles, getStyles, getThemeCode } from '@/utils/style';
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { FoliateView } from '@/types/view';
@@ -26,23 +25,38 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const [trianglePosition, setTrianglePosition] = useState<Position | null>();
const [popupPosition, setPopupPosition] = useState<Position | null>();
const [showPopup, setShowPopup] = useState(false);
const { getView, getViewSettings } = useReaderStore();
const { themeCode } = useThemeStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const progress = getProgress(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const footnoteHandler = new FootnoteHandler();
const [gridRect, setGridRect] = useState<DOMRect | null>(null);
const [responsiveWidth, setResponsiveWidth] = useState(popupWidth);
const [responsiveHeight, setResponsiveHeight] = useState(popupHeight);
useEffect(() => {
const handleBeforeRender = (e: Event) => {
const detail = (e as CustomEvent).detail;
const { view } = detail;
view.addEventListener('link', (e: Event) => {
e.preventDefault();
const { detail: popupLinkDetail } = e as CustomEvent;
popupLinkDetail['follow'] = true;
footnoteHandler.handle(bookDoc, e)?.catch((err) => {
console.warn(err);
getView(bookKey)?.goTo(popupLinkDetail.href);
});
});
footnoteViewRef.current = view;
footnoteRef.current?.replaceChildren(view);
const { renderer } = view;
renderer.setAttribute('flow', 'scrolled');
renderer.setAttribute('margin', '0px');
renderer.setAttribute('gap', '5%');
renderer.setAttribute('gap', '0%');
const viewSettings = getViewSettings(bookKey)!;
const themeCode = getThemeCode();
const popupTheme = { ...themeCode };
const popupContainer = document.getElementById('popup-container');
if (popupContainer) {
@@ -56,8 +70,17 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const handleRender = (e: Event) => {
const detail = (e as CustomEvent).detail;
console.log('render footnote', detail);
setShowPopup(true);
// console.log('render footnote', detail);
const { view } = detail;
view.addEventListener('relocate', () => {
const { renderer } = view as FoliateView;
if (viewSettings.vertical) {
setResponsiveWidth(Math.min(renderer.viewSize, window.innerWidth / 2));
} else {
setResponsiveHeight(Math.min(renderer.viewSize, window.innerHeight / 2));
}
setShowPopup(true);
});
};
footnoteHandler.addEventListener('before-render', handleBeforeRender);
@@ -67,25 +90,46 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
footnoteHandler.removeEventListener('render', handleRender);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, themeCode]);
}, [view]);
useEffect(() => {
handleDismissPopup();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
useEffect(() => {
if (viewSettings.vertical) {
setResponsiveWidth(popupHeight);
setResponsiveHeight(Math.max(360, window.innerHeight / 4));
} else {
setResponsiveWidth(Math.max(360, window.innerWidth / 4));
setResponsiveHeight(popupHeight);
}
}, [viewSettings]);
useEffect(() => {
if (trianglePosition && gridRect) {
const popupPos = getPopupPosition(
trianglePosition,
gridRect,
responsiveWidth,
responsiveHeight,
popupPadding,
);
setPopupPosition(popupPos);
}
}, [trianglePosition, gridRect, responsiveWidth, responsiveHeight]);
const docLinkHandler = async (event: Event) => {
const detail = (event as CustomEvent).detail;
console.log('doc link click', detail);
// console.log('doc link click', detail);
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
if (!gridFrame) return;
const rect = gridFrame.getBoundingClientRect();
const viewSettings = getViewSettings(bookKey)!;
const triangPos = getPosition(detail.a, rect, popupPadding, viewSettings.vertical);
const popupPos = getPopupPosition(
triangPos,
rect,
viewSettings.vertical ? popupHeight : popupWidth,
viewSettings.vertical ? popupWidth : popupHeight,
popupPadding,
);
setGridRect(rect);
setTrianglePosition(triangPos);
setPopupPosition(popupPos);
footnoteHandler.handle(bookDoc, event)?.catch((err) => {
console.warn(err);
@@ -102,11 +146,13 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const handleDismissPopup = () => {
closePopup();
setGridRect(null);
setPopupPosition(null);
setTrianglePosition(null);
setShowPopup(false);
};
// Handle custom footnote popup event from iframe event
const handleFootnotePopupEvent = (event: CustomEvent) => {
const { element, footnote } = event.detail;
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
@@ -114,21 +160,30 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const rect = gridFrame.getBoundingClientRect();
const viewSettings = getViewSettings(bookKey)!;
const triangPos = getPosition(element, rect, popupPadding, viewSettings.vertical);
const popupPos = getPopupPosition(
triangPos,
rect,
viewSettings.vertical ? popupHeight : popupWidth,
viewSettings.vertical ? popupWidth : popupHeight,
popupPadding,
);
if (footnoteRef.current) {
const elem = document.createElement('p');
elem.textContent = footnote;
elem.setAttribute('style', `padding: 16px; hanging-punctuation: allow-end last;`);
elem.setAttribute('style', `padding: 1em; hanging-punctuation: allow-end last;`);
elem.style.visibility = 'hidden';
if (viewSettings.vertical) {
elem.style.height = `${responsiveHeight}px`;
} else {
elem.style.width = `${responsiveWidth}px`;
}
document.body.appendChild(elem);
const popupSize = elem.getBoundingClientRect();
if (viewSettings.vertical) {
setResponsiveWidth(Math.min(popupSize.width, window.innerWidth / 2));
} else {
setResponsiveHeight(Math.min(popupSize.height, window.innerHeight / 2));
}
document.body.removeChild(elem);
elem.style.visibility = 'visible';
footnoteRef.current.replaceChildren(elem);
setShowPopup(true);
setGridRect(rect);
setTrianglePosition(triangPos);
setPopupPosition(popupPos);
setShowPopup(true);
}
};
@@ -150,9 +205,6 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
}
}, [footnoteRef]);
const width = viewSettings.vertical ? popupHeight : popupWidth;
const height = viewSettings.vertical ? popupWidth : popupHeight;
return (
<div>
{showPopup && (
@@ -163,8 +215,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
/>
)}
<Popup
width={width}
height={height}
width={responsiveWidth}
height={responsiveHeight}
position={showPopup ? popupPosition! : undefined}
trianglePosition={showPopup ? trianglePosition! : undefined}
className='select-text overflow-y-auto'
@@ -173,8 +225,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
className=''
ref={footnoteRef}
style={{
width: `${width}px`,
height: `${height}px`,
width: `${responsiveWidth}px`,
height: `${responsiveHeight}px`,
}}
></div>
</Popup>
@@ -1,17 +1,22 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { eventDispatcher } from '@/utils/event';
interface SectionInfoProps {
bookKey: string;
gapRight: string;
showDoubleBorder: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const HintInfo: React.FC<SectionInfoProps> = ({ bookKey, gapRight }) => {
const { isSideBarVisible } = useSidebarStore();
const { isTrafficLightVisible } = useTrafficLightStore();
const HintInfo: React.FC<SectionInfoProps> = ({
bookKey,
showDoubleBorder,
isVertical,
horizontalGap,
verticalMargin,
}) => {
const [hintMessage, setHintMessage] = React.useState<string | null>(null);
const hintTimeout = useRef(2000);
const dismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -42,13 +47,21 @@ const HintInfo: React.FC<SectionInfoProps> = ({ bookKey, gapRight }) => {
return (
<div
className={clsx(
'pageinfo absolute right-0 top-0 flex max-w-[50%] items-end',
isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]',
hintMessage ? '' : '',
'hintinfo absolute flex items-center justify-end overflow-hidden',
hintMessage ? 'bg-base-100' : 'bg-transparent',
isVertical ? 'writing-vertical-rl max-h-[50%]' : 'top-0 h-[44px] max-w-[50%]',
)}
style={{ right: gapRight }}
style={
isVertical
? {
bottom: `${verticalMargin * 1.5}px`,
left: `calc(100% - ${horizontalGap}%)`,
width: showDoubleBorder ? '30px' : `${horizontalGap}%`,
}
: { insetInlineEnd: `${horizontalGap}%` }
}
>
<h2 className='text-neutral-content line-clamp-1 text-center font-sans text-xs font-light'>
<h2 className={clsx('text-neutral-content text-center font-sans text-xs font-light')}>
{hintMessage || ''}
</h2>
</div>
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { PageInfo } from '@/types/book';
@@ -6,27 +7,55 @@ interface PageInfoProps {
bookFormat: string;
section?: PageInfo;
pageinfo?: PageInfo;
gapRight: string;
showDoubleBorder: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const PageInfoView: React.FC<PageInfoProps> = ({ bookFormat, section, pageinfo, gapRight }) => {
const PageInfoView: React.FC<PageInfoProps> = ({
bookFormat,
section,
pageinfo,
showDoubleBorder,
isVertical,
horizontalGap,
verticalMargin,
}) => {
const _ = useTranslation();
const pageInfo =
bookFormat === 'PDF'
? section
? `${section.current + 1} / ${section.total}`
? isVertical
? `${section.current + 1} · ${section.total}`
: `${section.current + 1} / ${section.total}`
: ''
: pageinfo
? _('Loc. {{currentPage}} / {{totalPage}}', {
currentPage: pageinfo.current + 1,
totalPage: pageinfo.total,
})
? _(
isVertical ? '{{currentPage}} · {{totalPage}}' : 'Loc. {{currentPage}} / {{totalPage}}',
{
currentPage: (pageinfo.next ?? pageinfo.current) + 1,
totalPage: pageinfo.total,
},
)
: '';
return (
<div
className='pageinfo absolute bottom-0 left-0 right-0 flex h-12 items-center justify-end'
style={{ paddingRight: gapRight }}
className={clsx(
'pageinfo absolute bottom-0 flex items-center justify-end',
isVertical ? 'writing-vertical-rl' : 'bg-base-100 h-12 w-full',
)}
style={
isVertical
? {
bottom: `${verticalMargin * 1.5}px`,
left: showDoubleBorder ? `calc(${horizontalGap}% - 32px)` : 0,
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: { insetInlineEnd: `${horizontalGap}%` }
}
>
<h2 className='text-neutral-content text-right font-sans text-xs font-extralight'>
{pageInfo}
@@ -1,26 +1,44 @@
import clsx from 'clsx';
import React from 'react';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
interface SectionInfoProps {
section?: string;
gapLeft: string;
showDoubleBorder: boolean;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const SectionInfo: React.FC<SectionInfoProps> = ({ section, gapLeft }) => {
const { isSideBarVisible } = useSidebarStore();
const { isTrafficLightVisible } = useTrafficLightStore();
const SectionInfo: React.FC<SectionInfoProps> = ({
section,
showDoubleBorder,
isVertical,
horizontalGap,
verticalMargin,
}) => {
return (
<div
className={clsx(
'pageinfo absolute right-0 top-0 flex max-w-[50%] items-end',
isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]',
'sectioninfo absolute flex items-center overflow-hidden',
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'bg-base-100 top-0 h-[44px]',
)}
style={{ left: gapLeft }}
style={
isVertical
? {
top: `${verticalMargin * 1.5}px`,
left: `calc(100% - ${horizontalGap}%)`,
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: { insetInlineStart: `${horizontalGap}%`, width: `calc(100% - ${horizontalGap * 2}%)` }
}
>
<h2 className='text-neutral-content line-clamp-1 text-center font-sans text-xs font-light'>
<h2
className={clsx(
'text-neutral-content text-center font-sans text-xs font-light',
isVertical ? '' : 'line-clamp-1',
)}
>
{section || ''}
</h2>
</div>
@@ -0,0 +1,66 @@
import React, { useState, useEffect, useRef } from 'react';
import { SketchPicker, ColorResult } from 'react-color';
type ColorInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
};
const ColorInput: React.FC<ColorInputProps> = ({ label, value, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
const handlePickerChange = (colorResult: ColorResult) => {
onChange(colorResult.hex);
};
return (
<div className='mb-3'>
<label className='mb-1 block text-sm font-medium'>{label}</label>
<div className='flex items-center'>
<div
className='border-base-200 relative mr-2 flex h-7 w-8 cursor-pointer items-center justify-center overflow-hidden rounded border'
style={{ backgroundColor: value }}
onClick={() => setIsOpen(!isOpen)}
/>
<input
type='text'
value={value}
onChange={(e) => onChange(e.target.value)}
className='bg-base-100 text-base-content border-base-200 min-w-4 max-w-36 flex-1 rounded border p-1 font-mono text-sm'
/>
</div>
{isOpen && (
<div ref={pickerRef} className='relative z-50 mt-2'>
<div className='absolute'>
<SketchPicker
width='100%'
color={value}
onChange={handlePickerChange}
disableAlpha={true}
/>
</div>
</div>
)}
</div>
);
};
export default ColorInput;
@@ -1,96 +1,183 @@
import React, { useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { MdOutlineLightMode, MdOutlineDarkMode } from 'react-icons/md';
import { MdRadioButtonUnchecked, MdRadioButtonChecked } from 'react-icons/md';
import { CgColorPicker } from 'react-icons/cg';
import { TbSunMoon } from 'react-icons/tb';
import { themes } from '@/styles/themes';
import { PiPlus } from 'react-icons/pi';
import {
applyCustomTheme,
CustomTheme,
generateDarkPalette,
generateLightPalette,
Theme,
themes,
} from '@/styles/themes';
import { getStyles } from '@/utils/style';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import ThemeEditor from './ThemeEditor';
const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { themeMode, themeColor, themeCode, isDarkMode, setThemeMode, setThemeColor } =
useThemeStore();
const {
themeMode,
themeColor,
themeCode,
isDarkMode,
setThemeMode,
setThemeColor,
saveCustomTheme,
} = useThemeStore();
const { envConfig } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { getViews, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const iconSize16 = useResponsiveSize(16);
const iconSize24 = useResponsiveSize(24);
const [editTheme, setEditTheme] = useState<CustomTheme | null>(null);
const [customThems, setCustomThemes] = useState<Theme[]>([]);
const [showCustomThemeEditor, setShowCustomThemeEditor] = useState(false);
useEffect(() => {
getViews().forEach((view) => {
view.renderer.setStyles?.(getStyles(viewSettings!));
view.renderer.setStyles?.(getStyles(viewSettings));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode]);
useEffect(() => {
const customThemes = settings.globalReadSettings.customThemes ?? [];
setCustomThemes(
customThemes.map((customTheme) => ({
name: customTheme.name,
label: customTheme.label,
colors: {
light: generateLightPalette(customTheme.colors.light),
dark: generateDarkPalette(customTheme.colors.dark),
},
isCustomizale: true,
})),
);
}, [settings]);
const handleSaveCustomTheme = (customTheme: CustomTheme) => {
applyCustomTheme(customTheme);
saveCustomTheme(envConfig, settings, customTheme);
setSettings({ ...settings });
setThemeColor(customTheme.name);
setShowCustomThemeEditor(false);
};
const handleDeleteCustomTheme = (customTheme: CustomTheme) => {
saveCustomTheme(envConfig, settings, customTheme, true);
setSettings({ ...settings });
setThemeColor('default');
setShowCustomThemeEditor(false);
};
const handleEditTheme = (name: string) => {
const customTheme = settings.globalReadSettings.customThemes.find((t) => t.name === name);
if (customTheme) {
setEditTheme(customTheme);
setShowCustomThemeEditor(true);
}
};
return (
<div className='my-4 w-full space-y-6'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Theme Mode')}</h2>
<div className='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('auto')}
>
<TbSunMoon />
</button>
{showCustomThemeEditor ? (
<ThemeEditor
customTheme={editTheme}
onSave={handleSaveCustomTheme}
onDelete={handleDeleteCustomTheme}
onCancel={() => setShowCustomThemeEditor(false)}
/>
) : (
<>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Theme Mode')}</h2>
<div className='flex gap-4'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('auto')}
>
<TbSunMoon />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Light Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('light')}
>
<MdOutlineLightMode />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Dark Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('dark')}
>
<MdOutlineDarkMode />
</button>
</div>
</div>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Light Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('light')}
>
<MdOutlineLightMode />
</button>
<div>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
{themes.concat(customThems).map(({ name, label, colors, isCustomizale }) => (
<label
key={name}
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg p-4 shadow-md ${
themeColor === name ? 'ring-2 ring-indigo-500 ring-offset-2' : ''
}`}
style={{
backgroundColor: isDarkMode
? colors.dark['base-100']
: colors.light['base-100'],
color: isDarkMode ? colors.dark['base-content'] : colors.light['base-content'],
}}
>
<input
type='radio'
name='theme'
value={name}
checked={themeColor === name}
onChange={() => setThemeColor(name)}
className='hidden'
/>
{themeColor === name ? (
<MdRadioButtonChecked size={iconSize24} />
) : (
<MdRadioButtonUnchecked size={iconSize24} />
)}
<span>{_(label)}</span>
{isCustomizale && themeColor === name && (
<button onClick={() => handleEditTheme(name)}>
<CgColorPicker size={iconSize16} className='absolute right-2 top-2' />
</button>
)}
</label>
))}
<label
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed p-4 shadow-md`}
onClick={() => setShowCustomThemeEditor(true)}
>
<PiPlus size={iconSize24} />
<span>{_('Custom')}</span>
</label>
</div>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Dark Mode')}>
<button
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setThemeMode('dark')}
>
<MdOutlineDarkMode />
</button>
</div>
</div>
</div>
<div>
<h2 className='mb-2 font-medium'>{_('Theme Color')}</h2>
<div className='grid grid-cols-3 gap-4'>
{themes.map(({ name, label, colors }) => (
<label
key={name}
className={`relative flex cursor-pointer flex-col items-center justify-center rounded-lg p-4 shadow-md ${
themeColor === name ? 'ring-2 ring-indigo-500 ring-offset-2' : ''
}`}
style={{
backgroundColor: isDarkMode ? colors.dark['base-100'] : colors.light['base-100'],
color: isDarkMode ? colors.dark['base-content'] : colors.light['base-content'],
}}
>
<input
type='radio'
name='theme'
value={name}
checked={themeColor === name}
onChange={() => setThemeColor(name)}
className='hidden'
/>
{themeColor === name ? (
<MdRadioButtonChecked size={iconSize24} />
) : (
<MdRadioButtonUnchecked size={iconSize24} />
)}
<span>{_(label)}</span>
</label>
))}
</div>
</div>
</>
)}
</div>
);
};
@@ -32,6 +32,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
onClick={(e) => e.currentTarget.focus()}
>
<span style={{ fontFamily: onGetFontFamily(selectedOption.option, family ?? '') }}>
{selectedOption.label}
@@ -14,13 +14,13 @@ import {
WINDOWS_FONTS,
} from '@/services/constants';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import { getStyles } from '@/utils/style';
import { getOSPlatform } from '@/utils/misc';
import { getSysFontsList } from '@/utils/font';
import { isTauriAppPlatform } from '@/services/environment';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
interface FontFaceProps {
className?: string;
@@ -60,9 +60,8 @@ const FontFace = ({
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const { envConfig, appService } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
@@ -118,89 +117,49 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, []);
useEffect(() => {
viewSettings.defaultFont = defaultFont;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.defaultFont = defaultFont;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'defaultFont', defaultFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultFont]);
useEffect(() => {
viewSettings.defaultFontSize = defaultFontSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.defaultFontSize = defaultFontSize;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'defaultFontSize', defaultFontSize);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultFontSize]);
useEffect(() => {
viewSettings.minimumFontSize = minFontSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.minimumFontSize = minFontSize;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'minimumFontSize', minFontSize);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minFontSize]);
useEffect(() => {
viewSettings.fontWeight = fontWeight;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.fontWeight = fontWeight;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'fontWeight', fontWeight);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fontWeight]);
useEffect(() => {
viewSettings.serifFont = serifFont;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.serifFont = serifFont;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'serifFont', serifFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serifFont]);
useEffect(() => {
viewSettings.sansSerifFont = sansSerifFont;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.sansSerifFont = sansSerifFont;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'sansSerifFont', sansSerifFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sansSerifFont]);
useEffect(() => {
viewSettings.monospaceFont = monospaceFont;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.monospaceFont = monospaceFont;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'monospaceFont', monospaceFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [monospaceFont]);
useEffect(() => {
viewSettings.overrideFont = overrideFont;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.overrideFont = overrideFont;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'overrideFont', overrideFont);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideFont]);
@@ -225,7 +184,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label={_('Default Font Size')}
value={defaultFontSize}
onChange={setDefaultFontSize}
@@ -233,7 +191,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
max={120}
/>
<NumberInput
className='config-item-bottom'
label={_('Minimum Font Size')}
value={minFontSize}
onChange={setMinFontSize}
@@ -249,7 +206,6 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label={_('Font Weight')}
value={fontWeight}
onChange={setFontWeight}
@@ -265,7 +221,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<h2 className='mb-2 font-medium'>{_('Font Family')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item config-item-top'>
<div className='config-item'>
<span className=''>{_('Default Font')}</span>
<FontDropdown
options={fontFamilyOptions}
@@ -275,7 +231,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
</div>
<div className='config-item config-item-bottom'>
<div className='config-item'>
<span className=''>{_('Override Book Font')}</span>
<input
type='checkbox'
@@ -3,18 +3,21 @@ import { MdOutlineAutoMode } from 'react-icons/md';
import { MdOutlineTextRotationNone, MdTextRotateVertical } from 'react-icons/md';
import { TbTextDirectionRtl } from 'react-icons/tb';
import { useSettingsStore } from '@/store/settingsStore';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { isCJKEnv } from '@/utils/misc';
import { getStyles } from '@/utils/style';
import { getMaxInlineSize } from '@/utils/config';
import { getBookDirFromWritingMode, getBookLangCode } from '@/utils/book';
import { MIGHT_BE_RTL_LANGS } from '@/services/constants';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import NumberInput from './NumberInput';
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { envConfig } = useEnv();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const view = getView(bookKey);
@@ -35,102 +38,62 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
const [writingMode, setWritingMode] = useState(viewSettings.writingMode!);
const [overrideLayout, setOverrideLayout] = useState(viewSettings.overrideLayout!);
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled!);
const [doubleBorder, setDoubleBorder] = useState(viewSettings.doubleBorder!);
const [borderColor, setBorderColor] = useState(viewSettings.borderColor!);
const [showHeader, setShowHeader] = useState(viewSettings.showHeader!);
const [showFooter, setShowFooter] = useState(viewSettings.showFooter!);
useEffect(() => {
viewSettings.paragraphMargin = paragraphMargin;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.paragraphMargin = paragraphMargin;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'paragraphMargin', paragraphMargin);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paragraphMargin]);
useEffect(() => {
viewSettings.lineHeight = lineHeight;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.lineHeight = lineHeight;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'lineHeight', lineHeight);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lineHeight]);
useEffect(() => {
viewSettings.wordSpacing = wordSpacing;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.wordSpacing = wordSpacing;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'wordSpacing', wordSpacing);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordSpacing]);
useEffect(() => {
viewSettings.letterSpacing = letterSpacing;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.letterSpacing = letterSpacing;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'letterSpacing', letterSpacing);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [letterSpacing]);
useEffect(() => {
viewSettings.textIndent = textIndent;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.textIndent = textIndent;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'textIndent', textIndent);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textIndent]);
useEffect(() => {
viewSettings.fullJustification = fullJustification;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.fullJustification = fullJustification;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'fullJustification', fullJustification);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fullJustification]);
useEffect(() => {
viewSettings.hyphenation = hyphenation;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.hyphenation = hyphenation;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'hyphenation', hyphenation);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hyphenation]);
useEffect(() => {
viewSettings.marginPx = marginPx;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.marginPx = marginPx;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'marginPx', marginPx);
view?.renderer.setAttribute('margin', `${marginPx}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [marginPx]);
useEffect(() => {
viewSettings.gapPercent = gapPercent;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.gapPercent = gapPercent;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (viewSettings.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
@@ -139,35 +102,20 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [gapPercent]);
useEffect(() => {
viewSettings.maxColumnCount = maxColumnCount;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.maxColumnCount = maxColumnCount;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'maxColumnCount', maxColumnCount);
view?.renderer.setAttribute('max-column-count', maxColumnCount);
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxColumnCount]);
useEffect(() => {
viewSettings.maxInlineSize = maxInlineSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.maxInlineSize = maxInlineSize;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'maxInlineSize', maxInlineSize);
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxInlineSize]);
useEffect(() => {
viewSettings.maxBlockSize = maxBlockSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.maxBlockSize = maxBlockSize;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'maxBlockSize', maxBlockSize);
view?.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxBlockSize]);
@@ -175,15 +123,18 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useEffect(() => {
// global settings are not supported for writing mode
const prevWritingMode = viewSettings.writingMode;
viewSettings.writingMode = writingMode;
setViewSettings(bookKey, viewSettings);
if (!writingMode.includes('vertical')) {
viewSettings.vertical = false;
}
saveViewSettings(envConfig, bookKey, 'writingMode', writingMode, true);
if (view) {
view.renderer.setStyles?.(getStyles(viewSettings));
view.book.dir = getBookDirFromWritingMode(writingMode);
}
if (
prevWritingMode !== writingMode &&
(writingMode === 'horizontal-rl' || prevWritingMode === 'horizontal-rl')
(['horizontal-rl', 'vertical-rl'].includes(writingMode) ||
['horizontal-rl', 'vertical-rl'].includes(prevWritingMode))
) {
setTimeout(() => window.location.reload(), 100);
}
@@ -191,22 +142,66 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [writingMode]);
useEffect(() => {
viewSettings.overrideLayout = overrideLayout;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.overrideLayout = overrideLayout;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'overrideLayout', overrideLayout);
view?.renderer.setStyles?.(getStyles(viewSettings));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideLayout]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'scrolled', isScrolledMode);
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute(
'max-inline-size',
`${getMaxInlineSize(viewSettings)}px`,
);
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isScrolledMode]);
useEffect(() => {
if (doubleBorder && viewSettings.vertical) {
viewSettings.gapPercent = Math.max(
viewSettings.gapPercent,
Math.ceil(4800 / window.innerWidth),
);
setViewSettings(bookKey, viewSettings);
}
saveViewSettings(envConfig, bookKey, 'doubleBorder', doubleBorder);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doubleBorder]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'borderColor', borderColor);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [borderColor]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'showHeader', showHeader);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showHeader]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'showFooter', showFooter);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showFooter]);
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
const mightBeRTLBook =
langCode === 'zh' || langCode === 'ja' || langCode === 'ko' || langCode === '';
const mightBeRTLBook = MIGHT_BE_RTL_LANGS.includes(langCode) || isCJKEnv();
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Scrolled Mode')}</h2>
<input
type='checkbox'
className='toggle'
checked={isScrolledMode}
onChange={() => setScrolledMode(!isScrolledMode)}
/>
</div>
</div>
{mightBeRTLBook && (
<div className='w-full'>
<div className='flex items-center justify-between'>
@@ -252,12 +247,45 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
)}
{viewSettings.vertical && (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Border Frame')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Double Border')}</span>
<input
type='checkbox'
className='toggle'
checked={doubleBorder}
onChange={() => setDoubleBorder(!doubleBorder)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Border Color')}</span>
<div className='flex gap-4'>
<button
className={`btn btn-circle btn-sm bg-red-300 hover:bg-red-500 ${borderColor === 'red' ? 'btn-active !bg-red-500' : ''}`}
onClick={() => setBorderColor('red')}
></button>
<button
className={`btn btn-circle btn-sm bg-black/50 hover:bg-black ${borderColor === 'black' ? 'btn-active !bg-black' : ''}`}
onClick={() => setBorderColor('black')}
></button>
</div>
</div>
</div>
</div>
</div>
)}
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Paragraph')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label={_('Paragraph Margin')}
value={paragraphMargin}
onChange={setParagraphMargin}
@@ -297,7 +325,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
max={4}
step={1}
/>
<div className='config-item config-item-bottom'>
<div className='config-item'>
<span className=''>{_('Full Justification')}</span>
<input
type='checkbox'
@@ -306,7 +334,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onChange={() => setFullJustification(!fullJustification)}
/>
</div>
<div className='config-item config-item-bottom'>
<div className='config-item'>
<span className=''>{_('Hyphenation')}</span>
<input
type='checkbox'
@@ -315,7 +343,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onChange={() => setHyphenation(!hyphenation)}
/>
</div>
<div className='config-item config-item-bottom'>
<div className='config-item'>
<span className=''>{_('Override Book Layout')}</span>
<input
type='checkbox'
@@ -332,8 +360,25 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<h2 className='mb-2 font-medium'>{_('Page')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('Show Header')}</span>
<input
type='checkbox'
className='toggle'
checked={showHeader}
onChange={() => setShowHeader(!showHeader)}
/>
</div>
<div className='config-item'>
<span className=''>{_('Show Footer')}</span>
<input
type='checkbox'
className='toggle'
checked={showFooter}
onChange={() => setShowFooter(!showFooter)}
/>
</div>
<NumberInput
className='config-item-top'
label={_('Vertical Margins (px)')}
value={marginPx}
onChange={setMarginPx}
@@ -345,7 +390,11 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
label={_('Horizontal Margins (%)')}
value={gapPercent}
onChange={setGapPercent}
min={0}
min={
viewSettings.vertical && (showFooter || showHeader)
? Math.ceil(4800 / window.innerWidth)
: 0
}
max={30}
/>
<NumberInput
@@ -1,14 +1,17 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
import cssbeautify from 'cssbeautify';
import cssValidate from '@/utils/css';
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
@@ -67,12 +70,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
};
useEffect(() => {
viewSettings.animated = animated;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.animated = animated;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'animated', animated);
if (animated) {
getView(bookKey)?.renderer.setAttribute('animated', '');
} else {
@@ -82,22 +80,12 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}, [animated]);
useEffect(() => {
viewSettings.disableClick = isDisableClick;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.disableClick = isDisableClick;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'disableClick', isDisableClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisableClick]);
useEffect(() => {
viewSettings.continuousScroll = isContinuousScroll;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.continuousScroll = isContinuousScroll;
setSettings(settings);
}
saveViewSettings(envConfig, bookKey, 'continuousScroll', isContinuousScroll);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isContinuousScroll]);
@@ -107,7 +95,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item config-item-top config-item-bottom'>
<div className='config-item'>
<span className=''>{_('Paging Animation')}</span>
<input
type='checkbox'
@@ -124,7 +112,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item config-item-top'>
<div className='config-item'>
<span className=''>{_('Continuous Scroll')}</span>
<input
type='checkbox'
@@ -133,7 +121,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
/>
</div>
<div className='config-item config-item-bottom'>
<div className='config-item'>
<span className=''>{_('Disable Click-to-Flip')}</span>
<input
type='checkbox'
@@ -28,7 +28,9 @@ type TabConfig = {
const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ bookKey }) => {
const _ = useTranslation();
const [activePanel, setActivePanel] = useState<SettingsPanelType>('Font');
const [activePanel, setActivePanel] = useState<SettingsPanelType>(
(localStorage.getItem('lastConfigPanel') || 'Font') as SettingsPanelType,
);
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const tabConfig = [
@@ -54,6 +56,11 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
},
] as TabConfig[];
const handleSetActivePanel = (tab: SettingsPanelType) => {
setActivePanel(tab);
localStorage.setItem('lastConfigPanel', tab);
};
const handleClose = () => {
setFontLayoutSettingsDialogOpen(false);
};
@@ -76,7 +83,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
>
<MdArrowBackIosNew />
</button>
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center pl-4 gap-2'>
<div className='dialog-tabs flex h-10 max-w-[100%] flex-grow items-center gap-2 pl-4'>
{tabConfig.map(({ tab, icon: Icon, label }) => (
<button
key={tab}
@@ -84,7 +91,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
'btn btn-ghost text-base-content btn-sm',
activePanel === tab ? 'btn-active' : '',
)}
onClick={() => setActivePanel(tab)}
onClick={() => handleSetActivePanel(tab)}
>
<Icon className='mr-0' />
{window.innerWidth >= 500 ? label : ''}
@@ -0,0 +1,161 @@
import { useState } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import { CustomTheme } from '@/styles/themes';
import { md5Fingerprint } from '@/utils/md5';
import { CUSTOM_THEME_TEMPLATES } from '@/services/constants';
import { useSettingsStore } from '@/store/settingsStore';
import clsx from 'clsx';
import ColorInput from './ColorInput';
type ThemeEditorProps = {
customTheme: CustomTheme | null;
onSave: (customTheme: CustomTheme) => void;
onDelete: (customTheme: CustomTheme) => void;
onCancel: () => void;
};
const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete, onCancel }) => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const template =
CUSTOM_THEME_TEMPLATES[Math.floor(Math.random() * CUSTOM_THEME_TEMPLATES.length)]!;
const [lightTextColor, setLightTextColor] = useState(
customTheme?.colors.light.fg || template.light.fg,
);
const [lightBackgroundColor, setLightBackgroundColor] = useState(
customTheme?.colors.light.bg || template.light.bg,
);
const [darkTextColor, setDarkTextColor] = useState(
customTheme?.colors.dark.fg || template.dark.fg,
);
const [darkBackgroundColor, setDarkBackgroundColor] = useState(
customTheme?.colors.dark.bg || template.dark.bg,
);
const [themeName, setThemeName] = useState(customTheme?.label || _('Custom'));
const ThemePreview: React.FC<{
textColor: string;
backgroundColor: string;
label: string;
}> = ({ textColor, backgroundColor, label }) => (
<div className='mb-2 mt-4'>
<label className='mb-1 block text-sm font-medium'>{label}</label>
<div
className='border-base-300 overflow-hidden rounded border p-3'
style={{
backgroundColor: backgroundColor,
color: textColor,
}}
>
<p className='mb-2 whitespace-pre-line text-sm'>
{_(
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare",
)}
</p>
</div>
</div>
);
const getCustomTheme = () => {
return {
name: md5Fingerprint(themeName),
label: themeName,
colors: {
light: {
fg: lightTextColor,
bg: lightBackgroundColor,
primary: '#3b82f6',
},
dark: {
fg: darkTextColor,
bg: darkBackgroundColor,
primary: '#60a5fa',
},
},
};
};
return (
<div className='mt-6 rounded-lg'>
<div className='mb-4'>
<div className='mb-4 flex items-center justify-between'>
<label className='font-medium'>{_('Custom Theme')}</label>
<div className='flex w-[calc(50%-12px)] justify-between'>
<button
className='btn btn-ghost btn-sm text-base-content px-2'
onClick={() => onSave(getCustomTheme())}
>
{_('Save')}
</button>
<button
className={clsx(
'btn btn-ghost btn-sm px-2',
!settings.globalReadSettings.customThemes.find(
(theme) => theme.name === md5Fingerprint(themeName),
) && 'btn-disabled',
)}
onClick={() => onDelete(getCustomTheme())}
>
{_('Delete')}
</button>
<button className='btn btn-ghost btn-sm px-2' onClick={onCancel}>
{_('Cancel')}
</button>
</div>
</div>
<div className='mb-4 flex items-center justify-between'>
<label className='font-medium'>{_('Theme Name')}</label>
<input
type='text'
value={themeName}
onChange={(e) => setThemeName(e.target.value)}
className='bg-base-100 text-base-content border-base-200 w-[calc(50%-12px)] rounded border p-2 text-sm'
/>
</div>
</div>
<div className='grid grid-cols-2 gap-6'>
<div className='bg-base-200 rounded-lg p-3'>
<h3 className='mb-3 text-center font-medium'>{_('Light Mode')}</h3>
<ColorInput label={_('Text Color')} value={lightTextColor} onChange={setLightTextColor} />
<ColorInput
label={_('Background Color')}
value={lightBackgroundColor}
onChange={setLightBackgroundColor}
/>
<ThemePreview
textColor={lightTextColor}
backgroundColor={lightBackgroundColor}
label={_('Preview')}
/>
</div>
<div className='bg-base-300 rounded-lg p-3'>
<h3 className='mb-3 text-center font-medium'>{_('Dark Mode')}</h3>
<ColorInput label={_('Text Color')} value={darkTextColor} onChange={setDarkTextColor} />
<ColorInput
label={_('Background Color')}
value={darkBackgroundColor}
onChange={setDarkBackgroundColor}
/>
<ThemePreview
textColor={darkTextColor}
backgroundColor={darkBackgroundColor}
label={_('Preview')}
/>
</div>
</div>
</div>
);
};
export default ThemeEditor;
@@ -1,5 +1,8 @@
import { getOSPlatform } from '@/utils/misc';
// NOTE: Be careful to use this hook. When the scrollbar is set always visible in macOS,
// hide it will change the layout. Make sure to only use it in constrained widgets.
// See https://github.com/readest/readest/issues/600
export const useAutoHideScrollbar = () => {
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
const handleScrollbarAutoHide = (doc: Document) => {
@@ -58,17 +58,13 @@ export const useClickEvent = (
}
}
}
} else if (msg.data.type === 'iframe-wheel') {
// FIXME: conlict with the touchpad scroll event on macOS
if (viewSettings.scrolled && appService?.osPlatform === 'macos') return;
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
// The wheel event is handled by the iframe itself in scrolled mode.
const { deltaY } = msg.data;
const fontSize = viewSettings?.defaultFontSize ?? 16;
const lineHeight = viewSettings?.lineHeight ?? 1.6;
const distance = fontSize * lineHeight * 3;
if (deltaY > 0) {
viewRef.current?.next(viewSettings.scrolled ? distance : 1);
viewRef.current?.next(1);
} else if (deltaY < 0) {
viewRef.current?.prev(viewSettings.scrolled ? distance : 1);
viewRef.current?.prev(1);
}
} else if (msg.data.type === 'iframe-mouseup') {
if (msg.data.button === 3) {
@@ -0,0 +1,29 @@
import { ViewSettings } from '@/types/book';
import { EnvConfigType } from '@/services/environment';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
export const saveViewSettings = async <K extends keyof ViewSettings>(
envConfig: EnvConfigType,
bookKey: string,
key: K,
value: ViewSettings[K],
skipGlobal?: boolean,
) => {
const { settings, isFontLayoutSettingsGlobal, setSettings, saveSettings } =
useSettingsStore.getState();
const { getViewSettings, setViewSettings } = useReaderStore.getState();
const { getConfig, saveConfig } = useBookDataStore.getState();
const viewSettings = getViewSettings(bookKey)!;
const config = getConfig(bookKey)!;
viewSettings[key] = value;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal && !skipGlobal) {
settings.globalViewSettings[key] = value;
setSettings(settings);
}
await saveConfig(envConfig, bookKey, config, settings);
await saveSettings(envConfig, settings);
};
+14 -4
View File
@@ -1,12 +1,22 @@
import { useEffect } from 'react';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { applyCustomTheme } from '@/styles/themes';
import { applyColorScheme } from '@/utils/style';
export const useTheme = () => {
const { settings } = useSettingsStore();
const { themeColor, isDarkMode } = useThemeStore();
useEffect(() => {
document.documentElement.setAttribute(
'data-theme',
`${themeColor}-${isDarkMode ? 'dark' : 'light'}`,
);
const customThemes = settings.globalReadSettings?.customThemes ?? [];
customThemes.forEach((customTheme) => {
applyCustomTheme(customTheme);
});
}, [settings]);
useEffect(() => {
const colorScheme = isDarkMode ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', `${themeColor}-${colorScheme}`);
applyColorScheme(document, isDarkMode);
}, [themeColor, isDarkMode]);
};
@@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { supabase } from '@/utils/supabase';
import { getUserPlan } from '@/utils/access';
import { query as deeplQuery } from '@/utils/deepl';
const DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
const DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
@@ -27,8 +28,9 @@ const getDeepLAPIKey = (keys: string | undefined) => {
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { user, token } = await getUserAndToken(req.headers['authorization']);
let deeplApiUrl = DEEPL_FREE_API;
let userPlan = 'free';
if (user && token) {
const userPlan = getUserPlan(token);
userPlan = getUserPlan(token);
if (userPlan !== 'free') deeplApiUrl = DEEPL_PRO_API;
}
const deeplAuthKey =
@@ -38,17 +40,31 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await runMiddleware(req, res, corsAllMethods);
const {
text,
source_lang: sourceLang = 'auto',
target_lang: targetLang = 'en',
}: { text: string[]; source_lang: string; target_lang: string } = req.body;
try {
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${deeplAuthKey}`,
'Content-Type': 'application/json',
},
body: req.method === 'POST' ? JSON.stringify(req.body) : undefined,
});
res.status(response.status);
res.json(await response.json());
if (targetLang.toLowerCase().includes('zh')) {
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${deeplAuthKey}`,
'Content-Type': 'application/json',
},
body: req.method === 'POST' ? JSON.stringify(req.body) : undefined,
});
res.status(response.status);
res.json(await response.json());
} else {
const result = await deeplQuery({
text: text[0] ?? '',
sourceLang,
targetLang,
});
res.status(200).json(result);
}
} catch (error) {
console.error('Error proxying DeepL request:', error);
res.status(500).json({ error: 'Internal Server Error' });
@@ -39,6 +39,7 @@ export const DEFAULT_READSETTINGS: ReadSettings = {
autohideCursor: true,
translateTargetLang: 'EN',
customThemes: [],
highlightStyle: 'highlight',
highlightStyles: {
highlight: 'yellow',
@@ -70,6 +71,10 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
writingMode: 'auto',
vertical: false,
rtl: false,
doubleBorder: false,
borderColor: 'red',
showHeader: true,
showFooter: true,
};
export const DEFAULT_BOOK_STYLE: BookStyle = {
@@ -416,3 +421,57 @@ export const HIGHLIGHT_COLOR_HEX: Record<HighlightColor, string> = {
blue: '#60a5fa', // blue-400
violet: '#a78bfa', // violet-400
};
export const CUSTOM_THEME_TEMPLATES = [
{
light: {
fg: '#2b2b2b',
bg: '#f3f3f3',
primary: '#3c5a72',
},
dark: {
fg: '#d0d0d0',
bg: '#1a1c1f',
primary: '#486e8a',
},
},
{
light: {
fg: '#3f2f3c',
bg: '#f5ecf8',
primary: '#7b5291',
},
dark: {
fg: '#d6cadd',
bg: '#3a2c3d',
primary: '#bda0cc',
},
},
{
light: {
fg: '#2b2b2b',
bg: '#defcd9',
primary: '#00796b',
},
dark: {
fg: '#c8e6c9',
bg: '#273c33',
primary: '#26a69a',
},
},
];
export const MIGHT_BE_RTL_LANGS = [
'zh',
'ja',
'ko',
'ar',
'he',
'fa',
'ur',
'dv',
'ps',
'sd',
'yi',
'',
];
@@ -66,6 +66,7 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
const bookIndex = library.findIndex((b) => b.hash === bookKey.split('-')[0]);
if (bookIndex == -1) return;
const book = library.splice(bookIndex, 1)[0]!;
book.progress = config.progress;
book.updatedAt = Date.now();
library.unshift(book);
setLibrary(library);
+1 -1
View File
@@ -222,7 +222,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const viewState = state.viewStates[key];
if (!viewState || !bookData) return state;
const progress: [number, number] = [pageinfo.current, pageinfo.total];
const progress: [number, number] = [(pageinfo.next ?? pageinfo.current) + 1, pageinfo.total];
// Update library book progress
const { library, setLibrary } = useLibraryStore.getState();
+33 -3
View File
@@ -1,7 +1,8 @@
import { create } from 'zustand';
import { getThemeCode, ThemeCode } from '@/utils/style';
import { Palette, ThemeMode } from '@/styles/themes';
import { isWebAppPlatform } from '@/services/environment';
import { CustomTheme, Palette, ThemeMode } from '@/styles/themes';
import { EnvConfigType, isWebAppPlatform } from '@/services/environment';
import { SystemSettings } from '@/types/settings';
interface ThemeState {
themeMode: ThemeMode;
@@ -9,9 +10,16 @@ interface ThemeState {
systemIsDarkMode: boolean;
themeCode: ThemeCode;
isDarkMode: boolean;
getIsDarkMode: () => boolean;
setThemeMode: (mode: ThemeMode) => void;
setThemeColor: (color: string) => void;
updateAppTheme: (color: keyof Palette) => void;
saveCustomTheme: (
envConfig: EnvConfigType,
settings: SystemSettings,
theme: CustomTheme,
isDelete?: boolean,
) => void;
}
const getInitialThemeMode = (): ThemeMode => {
@@ -40,7 +48,9 @@ export const useThemeStore = create<ThemeState>((set, get) => {
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
set({ systemIsDarkMode: mediaQuery.matches });
const mode = get().themeMode;
const isDarkMode = mode === 'dark' || (mode === 'auto' && mediaQuery.matches);
set({ systemIsDarkMode: mediaQuery.matches, isDarkMode });
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
@@ -52,6 +62,7 @@ export const useThemeStore = create<ThemeState>((set, get) => {
systemIsDarkMode,
isDarkMode,
themeCode,
getIsDarkMode: () => get().isDarkMode,
setThemeMode: (mode) => {
if (typeof window !== 'undefined' && localStorage) {
localStorage.setItem('themeMode', mode);
@@ -81,5 +92,24 @@ export const useThemeStore = create<ThemeState>((set, get) => {
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', palette[color]);
}
},
saveCustomTheme: async (envConfig, settings, theme, isDelete) => {
const customThemes = settings.globalReadSettings.customThemes || [];
const index = customThemes.findIndex((t) => t.name === theme.name);
if (isDelete) {
if (index > -1) {
customThemes.splice(index, 1);
}
} else {
if (index > -1) {
customThemes[index] = theme;
} else {
customThemes.push(theme);
}
}
settings.globalReadSettings.customThemes = customThemes;
localStorage.setItem('customThemes', JSON.stringify(customThemes));
const appService = await envConfig.getAppService();
await appService.saveSettings(settings);
},
};
});
+4 -8
View File
@@ -191,14 +191,6 @@ foliate-view {
@apply hover:bg-base-100/50;
}
.config-item-top {
@apply rounded-t-2xl;
}
.config-item-bottom {
@apply rounded-b-2xl;
}
.collapse-arrow > .collapse-title:after {
top: var(--top-override, default-value);
inset-inline-end: var(--end-override, default-value);
@@ -278,6 +270,10 @@ foliate-view {
line-height: 1em !important;
}
.writing-vertical-rl {
writing-mode: vertical-rl;
}
.direction-rtl {
direction: rtl;
}
+110 -4
View File
@@ -1,13 +1,15 @@
import tinycolor from 'tinycolor2';
import { stubTranslation as _ } from '../utils/misc';
import { getContrastOklch, hexToOklch } from '../utils/color';
type BaseColor = {
export type BaseColor = {
bg: string;
fg: string;
primary: string;
};
export type ThemeMode = 'auto' | 'light' | 'dark';
export type Palette = {
'base-100': string;
'base-200': string;
@@ -20,7 +22,26 @@ export type Palette = {
accent: string;
};
const generateLightPalette = ({ bg, fg, primary }: BaseColor) => {
export type Theme = {
name: string;
label: string;
colors: {
light: Palette;
dark: Palette;
};
isCustomizale?: boolean;
};
export type CustomTheme = {
name: string;
label: string;
colors: {
light: BaseColor;
dark: BaseColor;
};
};
export const generateLightPalette = ({ bg, fg, primary }: BaseColor) => {
return {
'base-100': bg, // Main background
'base-200': tinycolor(bg).darken(5).toHexString(), // Slightly darker
@@ -34,7 +55,7 @@ const generateLightPalette = ({ bg, fg, primary }: BaseColor) => {
} as Palette;
};
const generateDarkPalette = ({ bg, fg, primary }: BaseColor) => {
export const generateDarkPalette = ({ bg, fg, primary }: BaseColor) => {
return {
'base-100': bg, // Main background
'base-200': tinycolor(bg).lighten(5).toHexString(), // Slightly lighter
@@ -121,4 +142,89 @@ export const themes = [
dark: generateDarkPalette({ fg: '#d8dee9', bg: '#2e3440', primary: '#88c0d0' }),
},
},
];
{
name: 'contrast',
label: _('Contrast'),
colors: {
light: generateLightPalette({ fg: '#000000', bg: '#ffffff', primary: '#4488cc' }),
dark: generateDarkPalette({ fg: '#ffffff', bg: '#000000', primary: '#88ccee' }),
},
},
{
name: 'sunset',
label: _('Sunset'),
colors: {
light: generateLightPalette({ fg: '#423126', bg: '#fff7f0', primary: '#fe6b64' }),
dark: generateDarkPalette({ fg: '#f6e1d7', bg: '#3c2b25', primary: '#ff9c94' }),
},
},
] as Theme[];
const generateCustomThemeVariables = (palette: Palette): string => {
return `
--b1: ${hexToOklch(palette['base-100'])};
--b2: ${hexToOklch(palette['base-200'])};
--b3: ${hexToOklch(palette['base-300'])};
--bc: ${hexToOklch(palette['base-content'])};
--p: ${hexToOklch(palette.primary)};
--pc: ${getContrastOklch(palette.primary)};
--s: ${hexToOklch(palette.secondary)};
--sc: ${getContrastOklch(palette.secondary)};
--a: ${hexToOklch(palette.accent)};
--ac: ${getContrastOklch(palette.accent)};
--n: ${hexToOklch(palette.neutral)};
--nc: ${hexToOklch(palette['neutral-content'])};
--in: 69.37% 0.047 231;
--inc: 100% 0 0;
--su: 78.15% 0.12 160;
--suc: 100% 0 0;
--wa: 90.69% 0.123 84;
--wac: 0% 0 0;
--er: 70.9% 0.184 22;
--erc: 100% 0 0;
`;
};
export const applyCustomTheme = (customTheme: CustomTheme) => {
const lightPalette = generateLightPalette(customTheme.colors.light);
const darkPalette = generateDarkPalette(customTheme.colors.dark);
const lightThemeName = `${customTheme.name}-light`;
const darkThemeName = `${customTheme.name}-dark`;
const css = `
[data-theme="${lightThemeName}"] {
${generateCustomThemeVariables(lightPalette)}
}
[data-theme="${darkThemeName}"] {
${generateCustomThemeVariables(darkPalette)}
}
:root {
--${lightThemeName}: 1;
--${darkThemeName}: 1;
}
`;
const styleElement = document.createElement('style');
styleElement.id = `theme-${lightThemeName}-styles`;
styleElement.textContent = css;
const existingStyle = document.getElementById(styleElement.id);
if (existingStyle) {
existingStyle.remove();
}
document.head.appendChild(styleElement);
return {
light: lightThemeName,
dark: darkThemeName,
};
};
+6 -2
View File
@@ -24,7 +24,7 @@ export interface Book {
downloadedAt?: number | null;
lastUpdated?: number; // deprecated in favor of updatedAt
progress?: [number, number]; // Add progress field: [current, total]
progress?: [number, number]; // Add progress field: [current, total], 1-based page number
}
export interface BookGroupType {
@@ -68,6 +68,10 @@ export interface BookLayout {
writingMode: WritingMode;
vertical: boolean;
rtl: boolean;
doubleBorder: boolean;
borderColor: string;
showHeader: boolean;
showFooter: boolean;
}
export interface BookStyle {
@@ -145,7 +149,7 @@ export interface BookSearchResult {
export interface BookConfig {
bookHash?: string;
progress?: [number, number];
progress?: [number, number]; // [current pagenum, total pagenum], 1-based page number
location?: string;
booknotes?: BookNote[];
searchConfig?: Partial<BookSearchConfig>;
+2
View File
@@ -1,3 +1,4 @@
import { CustomTheme } from '@/styles/themes';
import { HighlightColor, HighlightStyle, ViewSettings } from './book';
export type ThemeType = 'light' | 'dark' | 'auto';
@@ -12,6 +13,7 @@ export interface ReadSettings {
highlightStyle: HighlightStyle;
highlightStyles: Record<HighlightStyle, HighlightColor>;
customThemes: CustomTheme[];
}
export interface SystemSettings {
+1
View File
@@ -46,6 +46,7 @@ export interface FoliateView extends HTMLElement {
prev: () => Promise<void>;
goTo?: (params: { index: number; anchor: number }) => void;
setStyles?: (css: string) => void;
getContents: () => { doc: Document; index?: number }[];
addEventListener: (type: string, listener: EventListener) => void;
removeEventListener: (type: string, listener: EventListener) => void;
};
+40
View File
@@ -0,0 +1,40 @@
import tinycolor from 'tinycolor2';
function srgbToLinear(v: number): number {
// Standard formula for gamma decoding of sRGB
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
export function hexToOklch(hexColor: string): string {
// 1) Convert from hex → sRGB (0..255) → [0..1]
const { r, g, b } = tinycolor(hexColor).toRgb();
const R = srgbToLinear(r / 255);
const G = srgbToLinear(g / 255);
const B = srgbToLinear(b / 255);
// 2) Convert linear sRGB → L'M'S' (the Oklab-specific "LMS" space)
const l_ = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
const m_ = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B);
const s_ = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B);
// 3) Convert L'M'S' → Oklab
const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
const b_ = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
// 4) Convert Oklab → Oklch
const C = Math.sqrt(a * a + b_ * b_);
let h = Math.atan2(b_, a) * (180 / Math.PI);
if (h < 0) h += 360;
// 5) Format as l% c h, with a bit of rounding
const lPercent = (L * 100).toFixed(4);
const cValue = C.toFixed(6);
const hValue = h.toFixed(6);
return `${lPercent}% ${cValue} ${hValue}`;
}
export const getContrastOklch = (hexColor: string): string => {
return tinycolor(hexColor).isDark() ? '100% 0 0' : '0% 0 0';
};
+83
View File
@@ -0,0 +1,83 @@
const API_URL = 'https://www2.deepl.com/jsonrpc';
const HEADERS = {
'Content-Type': 'application/json',
};
export type RequestParams = {
text: string;
sourceLang: string;
targetLang: string;
};
export type RawResponseParams = {
jsonrpc: string;
id: number;
result: {
texts: {
alternatives: {
text: string;
}[];
text: string;
}[];
lang: string;
lang_is_confident: boolean;
detectedLanguages: { unsupported: number } & Record<string, number>;
};
};
const buildRequestData = (params: RequestParams) => {
const getTimeStamp = () => {
const ts = Date.now();
const iCount = params.text.split('i').length;
return iCount > 1 ? ts - (ts % iCount) + iCount : ts;
};
const postData = {
jsonrpc: '2.0',
method: 'LMT_handle_texts',
id: Math.floor(Math.random() * 90000000) + 10000000,
params: {
texts: [{ text: params.text, requestAlternatives: 3 }],
timestamp: getTimeStamp(),
splitting: 'newlines',
lang: {
source_lang_user_selected: params.sourceLang.toUpperCase(),
target_lang: params.targetLang.toUpperCase(),
},
},
};
let postStr = JSON.stringify(postData);
if ((postData.id + 5) % 29 === 0 || (postData.id + 3) % 13 === 0) {
postStr = postStr.replace('"method":"', '"method" : "');
} else {
postStr = postStr.replace('"method":"', '"method": "');
}
return postStr;
};
export const query = async (params: RequestParams) => {
const response = await fetch(API_URL, {
headers: HEADERS,
method: 'POST',
body: buildRequestData(params),
});
if (!response.ok) {
throw new Error(
response.status === 429 ? 'Too many requests, please try again later.' : 'Unknown error.',
);
}
const { result } = (await response.json()) as RawResponseParams;
return {
translations: [
{
detected_source_language: result?.lang || params?.sourceLang || 'auto',
text: result?.texts?.[0]?.text || '',
},
],
};
};
-1
View File
@@ -18,7 +18,6 @@ export const getSysFontsList = async (): Promise<string[]> => {
if (FONT_ENUM_SUPPORTED_OS_PLATFORMS.includes(osPlatform)) {
const fonts = await invoke<string[]>('list_fonts');
cachedSysFonts = [...new Set(fonts.filter((font) => !isSymbolicFontName(font)))].sort();
console.log('Fetched font list:', cachedSysFonts);
return cachedSysFonts;
} else {
console.warn(`Unsupported platform: ${osPlatform}`);
+38 -5
View File
@@ -5,7 +5,14 @@ import {
FALLBACK_FONTS,
} from '@/services/constants';
import { ViewSettings } from '@/types/book';
import { themes, Palette, ThemeMode } from '@/styles/themes';
import {
themes,
Palette,
ThemeMode,
CustomTheme,
generateLightPalette,
generateDarkPalette,
} from '@/styles/themes';
import fontfacesCSS from '!!raw-loader!../styles/fonts.css';
import { getOSPlatform } from './misc';
@@ -126,6 +133,7 @@ const getLayoutStyles = (
hyphenate: boolean,
zoomLevel: number,
writingMode: string,
vertical: boolean,
bg: string,
fg: string,
primary: string,
@@ -174,6 +182,7 @@ const getLayoutStyles = (
color: ${fg};
${writingMode === 'auto' ? '' : `writing-mode: ${writingMode};`}
text-align: var(--default-text-align);
max-height: unset;
background-color: var(--theme-bg-color, transparent);
background: var(--background-set, none);
}
@@ -188,7 +197,7 @@ const getLayoutStyles = (
background-color: transparent !important;
}
p, li, blockquote, dd {
margin: ${paragraphMargin}em 0;
margin: ${vertical ? `0 ${paragraphMargin}em` : `${paragraphMargin}em 0`};
line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''};
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
@@ -245,7 +254,9 @@ export const getFootnoteStyles = () => `
}
p, li, blockquote, dd {
text-indent: 0;
padding: 1em !important;
margin: 0 !important;
text-indent: 0 !important;
}
`;
@@ -260,14 +271,30 @@ export const getThemeCode = () => {
let themeMode = 'auto';
let themeColor = 'default';
let systemIsDarkMode = false;
let customThemes: CustomTheme[] = [];
if (typeof window !== 'undefined') {
themeColor = localStorage.getItem('themeColor') || 'default';
themeMode = localStorage.getItem('themeMode') as ThemeMode;
customThemes = JSON.parse(localStorage.getItem('customThemes') || '[]');
systemIsDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
const defaultTheme = themes.find((theme) => theme.name === themeColor);
const defaultPalette = isDarkMode ? defaultTheme!.colors.dark : defaultTheme!.colors.light;
let currentTheme = themes.find((theme) => theme.name === themeColor);
if (!currentTheme) {
const customTheme = customThemes.find((theme) => theme.name === themeColor);
if (customTheme) {
currentTheme = {
name: customTheme.name,
label: customTheme.label,
colors: {
light: generateLightPalette(customTheme.colors.light),
dark: generateDarkPalette(customTheme.colors.dark),
},
};
}
}
if (!currentTheme) currentTheme = themes[0];
const defaultPalette = isDarkMode ? currentTheme!.colors.dark : currentTheme!.colors.light;
return {
bg: defaultPalette['base-100'],
fg: defaultPalette['base-content'],
@@ -291,6 +318,7 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
viewSettings.hyphenation!,
viewSettings.zoomLevel! / 100.0,
viewSettings.writingMode!,
viewSettings.vertical!,
themeCode.bg,
themeCode.fg,
themeCode.primary,
@@ -333,3 +361,8 @@ export const mountAdditionalFonts = (document: Document) => {
style.textContent = getAdditionalFontFaces();
document.head.appendChild(style);
};
export const applyColorScheme = (document: Document, isDarkMode: boolean) => {
const colorScheme = isDarkMode ? 'dark' : 'light';
document.documentElement.style.setProperty('color-scheme', colorScheme);
};
+119 -49
View File
@@ -40,7 +40,7 @@ importers:
version: 3.735.0
'@ducanh2912/next-pwa':
specifier: ^10.2.9
version: 10.2.9(next@15.1.6(@babel/core@7.26.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(webpack@5.97.1)
version: 10.2.9(next@15.2.2(@babel/core@7.26.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(webpack@5.97.1)
'@fabianlars/tauri-plugin-oauth':
specifier: '2'
version: 2.0.0
@@ -123,14 +123,17 @@ importers:
specifier: ^4.0.0
version: 4.0.0
next:
specifier: 15.1.6
version: 15.1.6(@babel/core@7.26.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
specifier: 15.2.2
version: 15.2.2(@babel/core@7.26.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
posthog-js:
specifier: ^1.205.0
version: 1.205.0
react:
specifier: 19.0.0
version: 19.0.0
react-color:
specifier: ^2.19.3
version: 2.19.3(react@19.0.0)
react-dom:
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
@@ -165,6 +168,9 @@ importers:
'@types/react':
specifier: 18.3.12
version: 18.3.12
'@types/react-color':
specifier: ^3.0.13
version: 3.0.13(@types/react@18.3.12)
'@types/react-dom':
specifier: 18.3.1
version: 18.3.1
@@ -178,8 +184,8 @@ importers:
specifier: ^8.0.0
version: 8.0.0
daisyui:
specifier: ^4.12.23
version: 4.12.23(postcss@8.4.49)
specifier: ^4.12.24
version: 4.12.24(postcss@8.4.49)
dotenv-cli:
specifier: ^7.4.4
version: 7.4.4
@@ -985,6 +991,11 @@ packages:
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
engines: {node: '>=18.18'}
'@icons/material@0.2.4':
resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==}
peerDependencies:
react: '*'
'@img/sharp-darwin-arm64@0.33.5':
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -1130,60 +1141,60 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@next/env@15.1.6':
resolution: {integrity: sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==}
'@next/env@15.2.2':
resolution: {integrity: sha512-yWgopCfA9XDR8ZH3taB5nRKtKJ1Q5fYsTOuYkzIIoS8TJ0UAUKAGF73JnGszbjk2ufAQDj6mDdgsJAFx5CLtYQ==}
'@next/eslint-plugin-next@15.0.3':
resolution: {integrity: sha512-3Ln/nHq2V+v8uIaxCR6YfYo7ceRgZNXfTd3yW1ukTaFbO+/I8jNakrjYWODvG9BuR2v5kgVtH/C8r0i11quOgw==}
'@next/swc-darwin-arm64@15.1.6':
resolution: {integrity: sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==}
'@next/swc-darwin-arm64@15.2.2':
resolution: {integrity: sha512-HNBRnz+bkZ+KfyOExpUxTMR0Ow8nkkcE6IlsdEa9W/rI7gefud19+Sn1xYKwB9pdCdxIP1lPru/ZfjfA+iT8pw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.1.6':
resolution: {integrity: sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==}
'@next/swc-darwin-x64@15.2.2':
resolution: {integrity: sha512-mJOUwp7al63tDpLpEFpKwwg5jwvtL1lhRW2fI1Aog0nYCPAhxbJsaZKdoVyPZCy8MYf/iQVNDuk/+i29iLCzIA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.1.6':
resolution: {integrity: sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==}
'@next/swc-linux-arm64-gnu@15.2.2':
resolution: {integrity: sha512-5ZZ0Zwy3SgMr7MfWtRE7cQWVssfOvxYfD9O7XHM7KM4nrf5EOeqwq67ZXDgo86LVmffgsu5tPO57EeFKRnrfSQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.1.6':
resolution: {integrity: sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==}
'@next/swc-linux-arm64-musl@15.2.2':
resolution: {integrity: sha512-cgKWBuFMLlJ4TWcFHl1KOaVVUAF8vy4qEvX5KsNd0Yj5mhu989QFCq1WjuaEbv/tO1ZpsQI6h/0YR8bLwEi+nA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.1.6':
resolution: {integrity: sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==}
'@next/swc-linux-x64-gnu@15.2.2':
resolution: {integrity: sha512-c3kWSOSsVL8rcNBBfOq1+/j2PKs2nsMwJUV4icUxRgGBwUOfppeh7YhN5s79enBQFU+8xRgVatFkhHU1QW7yUA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.1.6':
resolution: {integrity: sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==}
'@next/swc-linux-x64-musl@15.2.2':
resolution: {integrity: sha512-PXTW9PLTxdNlVYgPJ0equojcq1kNu5NtwcNjRjHAB+/sdoKZ+X8FBu70fdJFadkxFIGekQTyRvPMFF+SOJaQjw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.1.6':
resolution: {integrity: sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==}
'@next/swc-win32-arm64-msvc@15.2.2':
resolution: {integrity: sha512-nG644Es5llSGEcTaXhnGWR/aThM/hIaz0jx4MDg4gWC8GfTCp8eDBWZ77CVuv2ha/uL9Ce+nPTfYkSLG67/sHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.1.6':
resolution: {integrity: sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ==}
'@next/swc-win32-x64-msvc@15.2.2':
resolution: {integrity: sha512-52nWy65S/R6/kejz3jpvHAjZDPKIbEQu4x9jDBzmB9jJfuOy5rspjKu4u77+fI4M/WzLXrrQd57hlFGzz1ubcQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1819,12 +1830,22 @@ packages:
'@types/prop-types@15.7.13':
resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==}
'@types/react-color@3.0.13':
resolution: {integrity: sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom@18.3.1':
resolution: {integrity: sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==}
'@types/react@18.3.12':
resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==}
'@types/reactcss@1.2.13':
resolution: {integrity: sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==}
peerDependencies:
'@types/react': '*'
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -2346,8 +2367,8 @@ packages:
resolution: {integrity: sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
daisyui@4.12.23:
resolution: {integrity: sha512-EM38duvxutJ5PD65lO/AFMpcw+9qEy6XAZrTpzp7WyaPeO/l+F/Qiq0ECHHmFNcFXh5aVoALY4MGrrxtCiaQCQ==}
daisyui@4.12.24:
resolution: {integrity: sha512-JYg9fhQHOfXyLadrBrEqCDM6D5dWCSSiM6eTNCRrBRzx/VlOCrLS8eDfIw9RVvs64v2mJdLooKXY8EwQzoszAA==}
engines: {node: '>=16.9.0'}
damerau-levenshtein@1.0.8:
@@ -3278,6 +3299,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
@@ -3313,6 +3337,9 @@ packages:
matchmediaquery@0.4.2:
resolution: {integrity: sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA==}
material-colors@1.2.6:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -3390,8 +3417,8 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
next@15.1.6:
resolution: {integrity: sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==}
next@15.2.2:
resolution: {integrity: sha512-dgp8Kcx5XZRjMw2KNwBtUzhngRaURPioxoNIVl5BOyJbhi9CUgEtKDO7fx5wh8Z8vOVX1nYZ9meawJoRrlASYA==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@@ -3782,6 +3809,11 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-color@2.19.3:
resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==}
peerDependencies:
react: '*'
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -3827,6 +3859,11 @@ packages:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'}
reactcss@1.2.3:
resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==}
peerDependencies:
react: '*'
read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@@ -5739,10 +5776,10 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.9
optional: true
'@ducanh2912/next-pwa@10.2.9(next@15.1.6(@babel/core@7.26.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(webpack@5.97.1)':
'@ducanh2912/next-pwa@10.2.9(next@15.2.2(@babel/core@7.26.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(webpack@5.97.1)':
dependencies:
fast-glob: 3.3.2
next: 15.1.6(@babel/core@7.26.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.2.2(@babel/core@7.26.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
semver: 7.6.3
webpack: 5.97.1
workbox-build: 7.1.1
@@ -5818,6 +5855,10 @@ snapshots:
'@humanwhocodes/retry@0.4.1': {}
'@icons/material@0.2.4(react@19.0.0)':
dependencies:
react: 19.0.0
'@img/sharp-darwin-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.0.4
@@ -5930,34 +5971,34 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.0
optional: true
'@next/env@15.1.6': {}
'@next/env@15.2.2': {}
'@next/eslint-plugin-next@15.0.3':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.1.6':
'@next/swc-darwin-arm64@15.2.2':
optional: true
'@next/swc-darwin-x64@15.1.6':
'@next/swc-darwin-x64@15.2.2':
optional: true
'@next/swc-linux-arm64-gnu@15.1.6':
'@next/swc-linux-arm64-gnu@15.2.2':
optional: true
'@next/swc-linux-arm64-musl@15.1.6':
'@next/swc-linux-arm64-musl@15.2.2':
optional: true
'@next/swc-linux-x64-gnu@15.1.6':
'@next/swc-linux-x64-gnu@15.2.2':
optional: true
'@next/swc-linux-x64-musl@15.1.6':
'@next/swc-linux-x64-musl@15.2.2':
optional: true
'@next/swc-win32-arm64-msvc@15.1.6':
'@next/swc-win32-arm64-msvc@15.2.2':
optional: true
'@next/swc-win32-x64-msvc@15.1.6':
'@next/swc-win32-x64-msvc@15.2.2':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -6728,6 +6769,11 @@ snapshots:
'@types/prop-types@15.7.13': {}
'@types/react-color@3.0.13(@types/react@18.3.12)':
dependencies:
'@types/react': 18.3.12
'@types/reactcss': 1.2.13(@types/react@18.3.12)
'@types/react-dom@18.3.1':
dependencies:
'@types/react': 18.3.12
@@ -6737,6 +6783,10 @@ snapshots:
'@types/prop-types': 15.7.13
csstype: 3.1.3
'@types/reactcss@1.2.13(@types/react@18.3.12)':
dependencies:
'@types/react': 18.3.12
'@types/resolve@1.20.2': {}
'@types/tinycolor2@1.4.6': {}
@@ -7350,7 +7400,7 @@ snapshots:
culori@3.3.0: {}
daisyui@4.12.23(postcss@8.4.49):
daisyui@4.12.24(postcss@8.4.49):
dependencies:
css-selector-tokenizer: 0.8.0
culori: 3.3.0
@@ -8389,6 +8439,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash-es@4.17.21: {}
lodash.debounce@4.0.8: {}
lodash.merge@4.6.2: {}
@@ -8420,6 +8472,8 @@ snapshots:
dependencies:
css-mediaquery: 0.1.2
material-colors@1.2.6: {}
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@@ -8483,9 +8537,9 @@ snapshots:
neo-async@2.6.2: {}
next@15.1.6(@babel/core@7.26.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
next@15.2.2(@babel/core@7.26.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.1.6
'@next/env': 15.2.2
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
@@ -8495,14 +8549,14 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(@babel/core@7.26.7)(react@19.0.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.1.6
'@next/swc-darwin-x64': 15.1.6
'@next/swc-linux-arm64-gnu': 15.1.6
'@next/swc-linux-arm64-musl': 15.1.6
'@next/swc-linux-x64-gnu': 15.1.6
'@next/swc-linux-x64-musl': 15.1.6
'@next/swc-win32-arm64-msvc': 15.1.6
'@next/swc-win32-x64-msvc': 15.1.6
'@next/swc-darwin-arm64': 15.2.2
'@next/swc-darwin-x64': 15.2.2
'@next/swc-linux-arm64-gnu': 15.2.2
'@next/swc-linux-arm64-musl': 15.2.2
'@next/swc-linux-x64-gnu': 15.2.2
'@next/swc-linux-x64-musl': 15.2.2
'@next/swc-win32-arm64-msvc': 15.2.2
'@next/swc-win32-x64-msvc': 15.2.2
sharp: 0.33.5
transitivePeerDependencies:
- '@babel/core'
@@ -8831,6 +8885,17 @@ snapshots:
strip-json-comments: 2.0.1
optional: true
react-color@2.19.3(react@19.0.0):
dependencies:
'@icons/material': 0.2.4(react@19.0.0)
lodash: 4.17.21
lodash-es: 4.17.21
material-colors: 1.2.6
prop-types: 15.8.1
react: 19.0.0
reactcss: 1.2.3(react@19.0.0)
tinycolor2: 1.6.0
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@@ -8871,6 +8936,11 @@ snapshots:
react@19.0.0: {}
reactcss@1.2.3(react@19.0.0):
dependencies:
lodash: 4.17.21
react: 19.0.0
read-cache@1.0.0:
dependencies:
pify: 2.3.0