Compare commits

..

17 Commits

Author SHA1 Message Date
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
Huang Xin dfa0468d24 release: hot fix version 0.9.21 (#551)
* sync: update partial config to sync more actively

* rtl: make the order of go back/forward buttons consistent

* fix: wheel events conflict with touchpad scroll on macOS

* release: hot fix version 0.9.21
2025-03-09 20:22:47 +01:00
53 changed files with 1450 additions and 266 deletions
+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.20",
"version": "0.9.22",
"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,16 @@
"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": "لون الحدود"
}
@@ -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,16 @@
"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"
}
@@ -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,16 @@
"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": "Χρώμα περιγράμματος"
}
@@ -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,16 @@
"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"
}
@@ -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,16 @@
"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"
}
@@ -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,16 @@
"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": "बॉर्डर रंग"
}
@@ -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,16 @@
"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"
}
@@ -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,16 @@
"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"
}
@@ -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,16 @@
"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": "ボーダーカラー"
}
@@ -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,16 @@
"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": "테두리 색상"
}
@@ -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,16 @@
"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"
}
@@ -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,16 @@
"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"
}
@@ -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,16 @@
"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": "Цвет границы"
}
@@ -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,16 @@
"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"
}
@@ -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,16 @@
"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": "Колір рамки"
}
@@ -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,16 @@
"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"
}
@@ -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,16 @@
"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": "版框颜色"
}
@@ -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,16 @@
"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": "邊框顏色"
}
+19
View File
@@ -1,5 +1,24 @@
{
"releases": {
"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": [
"Fix column height in vertical layout on mobile",
"Fix drag handle height not constant on mobile",
"Add fullscreen option on desktop",
"Add drag and drop to import books on desktop",
"Various fixes and enhancements on updater, footerbar and note"
]
},
"0.9.20": {
"date": "2025-03-10",
"notes": [
@@ -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}
@@ -81,13 +83,32 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
{viewSettings.scrolled ? null : (
<>
<SectionInfo section={sectionLabel} gapLeft={marginGap} />
<HintInfo bookKey={bookKey} gapRight={marginGap} />
{viewSettings.vertical && viewSettings.doubleBorder && (
<DoubleBorder
borderColor={viewSettings.borderColor}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
)}
<SectionInfo
section={sectionLabel}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
<HintInfo
bookKey={bookKey}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
<PageInfoView
bookFormat={book.format}
section={section}
pageinfo={pageinfo}
gapRight={marginGap}
isVertical={viewSettings.vertical}
horizontalGap={horizontalGapPercent}
verticalMargin={verticalMarginPixels}
/>
</>
)}
@@ -0,0 +1,62 @@
interface DoubleBorderProps {
borderColor: string;
horizontalGap: number;
verticalMargin: number;
}
const paddingPx = 10;
const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderColor,
horizontalGap,
verticalMargin,
}) => {
return (
<>
<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 * 2}% - 32px - ${paddingPx}px)`,
right: `calc(${horizontalGap * 2}% - 32px - ${paddingPx}px)`,
}}
></div>
<div
className={'borderframe pointer-events-none absolute'}
style={{
border: `1px solid ${borderColor}`,
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: `${horizontalGap * 2}%`,
right: `${horizontalGap * 2}%`,
}}
/>
<div
className={'borderframe pointer-events-none absolute'}
style={{
border: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: `calc(${horizontalGap * 2}% - 32px)`,
borderRight: 'none',
}}
/>
<div
className={'borderframe pointer-events-none absolute'}
style={{
border: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${verticalMargin * 2}px)`,
top: `${verticalMargin}px`,
left: `calc(100% - ${horizontalGap * 2}%)`,
borderLeft: 'none',
}}
/>
</>
);
};
export default DoubleBorder;
@@ -9,7 +9,6 @@ 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 { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { useUICSS } from '@/hooks/useUICSS';
@@ -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 } = 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);
@@ -65,9 +63,6 @@ const FoliateViewer: React.FC<{
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);
}
mountAdditionalFonts(detail.doc);
@@ -129,7 +124,7 @@ const FoliateViewer: React.FC<{
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode]);
}, [themeCode, isDarkMode]);
useEffect(() => {
if (isViewCreated.current) return;
@@ -75,9 +75,12 @@ const FooterBar: React.FC<FooterBarProps> = ({
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(
@@ -103,15 +106,15 @@ const FooterBar: React.FC<FooterBarProps> = ({
</div>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
onClick={viewSettings?.rtl ? handleGoForward : handleGoBack}
tooltip={viewSettings?.rtl ? _('Go Forward') : _('Go Back')}
disabled={viewSettings?.rtl ? !view?.history.canGoForward : !view?.history.canGoBack}
onClick={handleGoBack}
tooltip={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
onClick={viewSettings?.rtl ? handleGoBack : handleGoForward}
tooltip={viewSettings?.rtl ? _('Go Back') : _('Go Forward')}
disabled={viewSettings?.rtl ? !view?.history.canGoBack : !view?.history.canGoForward}
onClick={handleGoForward}
tooltip={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<span className='mx-2 text-center text-sm'>
{progressValid ? `${Math.round(progressFraction * 100)}%` : ''}
@@ -36,6 +36,15 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
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;
@@ -6,10 +6,17 @@ import { eventDispatcher } from '@/utils/event';
interface SectionInfoProps {
bookKey: string;
gapRight: string;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const HintInfo: React.FC<SectionInfoProps> = ({ bookKey, gapRight }) => {
const HintInfo: React.FC<SectionInfoProps> = ({
bookKey,
isVertical,
horizontalGap,
verticalMargin,
}) => {
const { isSideBarVisible } = useSidebarStore();
const { isTrafficLightVisible } = useTrafficLightStore();
const [hintMessage, setHintMessage] = React.useState<string | null>(null);
@@ -42,13 +49,26 @@ 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 ? '' : '',
'sectioninfo absolute flex justify-end overflow-hidden',
!isVertical && (isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]'),
isVertical ? 'writing-vertical-rl w-[32px] items-center' : 'top-0 items-end',
isVertical ? 'max-h-[50%]' : 'max-w-[50%]',
)}
style={{ right: gapRight }}
style={
isVertical
? {
bottom: `calc(${horizontalGap / 2}% + ${verticalMargin}px)`,
left: `calc(100% - ${horizontalGap * 2}%)`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: { right: `${horizontalGap}%` }
}
>
<h2 className='text-neutral-content line-clamp-1 text-center font-sans text-xs font-light'>
<h2
className={clsx(
'text-neutral-content line-clamp-1 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,53 @@ interface PageInfoProps {
bookFormat: string;
section?: PageInfo;
pageinfo?: PageInfo;
gapRight: string;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const PageInfoView: React.FC<PageInfoProps> = ({ bookFormat, section, pageinfo, gapRight }) => {
const PageInfoView: React.FC<PageInfoProps> = ({
bookFormat,
section,
pageinfo,
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.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 ? 'left-0' : 'right-0',
isVertical ? 'writing-vertical-rl w-[32px]' : 'h-12',
)}
style={
isVertical
? {
bottom: `calc(${horizontalGap / 2}% + ${verticalMargin}px)`,
left: `calc(${horizontalGap * 2}% - 32px)`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: { paddingRight: `${horizontalGap}%` }
}
>
<h2 className='text-neutral-content text-right font-sans text-xs font-extralight'>
{pageInfo}
@@ -6,21 +6,42 @@ import { useTrafficLightStore } from '@/store/trafficLightStore';
interface SectionInfoProps {
section?: string;
gapLeft: string;
isVertical: boolean;
horizontalGap: number;
verticalMargin: number;
}
const SectionInfo: React.FC<SectionInfoProps> = ({ section, gapLeft }) => {
const SectionInfo: React.FC<SectionInfoProps> = ({
section,
isVertical,
horizontalGap,
verticalMargin,
}) => {
const { isSideBarVisible } = useSidebarStore();
const { isTrafficLightVisible } = useTrafficLightStore();
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 overflow-hidden',
!isVertical && (isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]'),
isVertical ? 'writing-vertical-rl w-[32px] items-center' : 'top-0 items-end',
isVertical ? 'max-h-[50%]' : 'max-w-[50%]',
)}
style={{ left: gapLeft }}
style={
isVertical
? {
top: `calc(${horizontalGap / 2}% + ${verticalMargin}px)`,
left: `calc(100% - ${horizontalGap * 2}%)`,
height: `calc(100% - ${verticalMargin * 2}px)`,
}
: { left: `${horizontalGap}%` }
}
>
<h2 className='text-neutral-content line-clamp-1 text-center font-sans text-xs font-light'>
<h2
className={clsx(
'text-neutral-content line-clamp-1 text-center font-sans text-xs font-light',
)}
>
{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}
@@ -7,9 +7,11 @@ import { useSettingsStore } from '@/store/settingsStore';
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 NumberInput from './NumberInput';
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
@@ -35,6 +37,9 @@ 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!);
useEffect(() => {
viewSettings.paragraphMargin = paragraphMargin;
@@ -176,6 +181,9 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// global settings are not supported for writing mode
const prevWritingMode = viewSettings.writingMode;
viewSettings.writingMode = writingMode;
if (writingMode.includes('vertical')) {
viewSettings.vertical = true;
}
setViewSettings(bookKey, viewSettings);
if (view) {
view.renderer.setStyles?.(getStyles(viewSettings));
@@ -201,12 +209,59 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideLayout]);
useEffect(() => {
viewSettings!.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!));
setViewSettings(bookKey, viewSettings!);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.scrolled = isScrolledMode;
setSettings(settings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isScrolledMode]);
useEffect(() => {
viewSettings.doubleBorder = doubleBorder;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.doubleBorder = doubleBorder;
setSettings(settings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doubleBorder]);
useEffect(() => {
viewSettings.borderColor = borderColor;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.borderColor = borderColor;
setSettings(settings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [borderColor]);
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,6 +307,39 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
)}
{viewSettings.vertical && (
<>
<div className='w-full'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Double Border')}</h2>
<input
type='checkbox'
className='toggle'
checked={doubleBorder}
onChange={() => setDoubleBorder(!doubleBorder)}
/>
</div>
</div>
<div className='w-full'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Border Color')}</h2>
<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 className='w-full'>
<h2 className='mb-2 font-medium'>{_('Paragraph')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
@@ -345,7 +433,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
label={_('Horizontal Margins (%)')}
value={gapPercent}
onChange={setGapPercent}
min={0}
min={viewSettings.vertical ? 2 : 0}
max={30}
/>
<NumberInput
@@ -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,15 +58,13 @@ export const useClickEvent = (
}
}
}
} else if (msg.data.type === 'iframe-wheel') {
} 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) {
@@ -72,7 +72,7 @@ export const useNotesSync = (bookKey: string) => {
...oldNotes.filter((oldNote) => !newNotes.some((newNote) => newNote.id === oldNote.id)),
...newNotes.map(processNewNote),
];
setConfig(bookKey, { ...config, booknotes: mergedNotes });
setConfig(bookKey, { booknotes: mergedNotes });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [syncedNotes]);
@@ -6,7 +6,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { serializeConfig } from '@/utils/serializer';
import { CFI } from '@/libs/document';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
@@ -106,14 +106,9 @@ export const useProgressSync = (bookKey: string) => {
configSynced.current = true;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const newConfig = deserializeConfig(
JSON.stringify(syncedConfig),
settings.globalViewSettings,
DEFAULT_BOOK_SEARCH_CONFIG,
);
setConfig(bookKey, { ...config, ...newConfig });
const syncedCFI = syncedConfig.location;
const configCFI = config?.location;
const syncedCFI = syncedConfig.location;
setConfig(bookKey, syncedConfig);
if (syncedCFI && configCFI) {
if (CFI.compare(configCFI, syncedCFI) < 0) {
if (view) {
+13 -4
View File
@@ -1,12 +1,21 @@
import { useEffect } from 'react';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { applyCustomTheme } from '@/styles/themes';
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.style.setProperty('color-scheme', colorScheme);
document.documentElement.setAttribute('data-theme', `${themeColor}-${colorScheme}`);
}, [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,8 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
writingMode: 'auto',
vertical: false,
rtl: false,
doubleBorder: true,
borderColor: 'red',
};
export const DEFAULT_BOOK_STYLE: BookStyle = {
@@ -416,3 +419,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',
'',
];
+4 -2
View File
@@ -17,7 +17,7 @@ interface BookData {
interface BookDataState {
booksData: { [id: string]: BookData };
getConfig: (key: string | null) => BookConfig | null;
setConfig: (key: string, config: BookConfig) => void;
setConfig: (key: string, partialConfig: Partial<BookConfig>) => void;
saveConfig: (
envConfig: EnvConfigType,
bookKey: string,
@@ -39,9 +39,11 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
const id = key.split('-')[0]!;
return get().booksData[id]?.config || null;
},
setConfig: (key: string, config: BookConfig) => {
setConfig: (key: string, partialConfig: Partial<BookConfig>) => {
set((state: BookDataState) => {
const id = key.split('-')[0]!;
const config = (state.booksData[id]?.config || {}) as BookConfig;
Object.assign(config, partialConfig);
return {
booksData: {
...state.booksData,
+31 -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;
@@ -12,6 +13,12 @@ interface ThemeState {
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 +47,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);
@@ -81,5 +90,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
View File
@@ -278,6 +278,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,
};
};
+2
View File
@@ -68,6 +68,8 @@ export interface BookLayout {
writingMode: WritingMode;
vertical: boolean;
rtl: boolean;
doubleBorder: boolean;
borderColor: string;
}
export interface BookStyle {
+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 {
+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 || '',
},
],
};
};
+30 -4
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' : ''};
@@ -260,14 +269,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 +316,7 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
viewSettings.hyphenation!,
viewSettings.zoomLevel! / 100.0,
viewSettings.writingMode!,
viewSettings.vertical!,
themeCode.bg,
themeCode.fg,
themeCode.primary,
+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