Compare commits

...

30 Commits

Author SHA1 Message Date
Huang Xin b91d7b7a61 release: version 0.9.37 (#957) 2025-04-24 21:14:57 +02:00
Huang Xin 7c0d9bf375 fix: popup footnotes for anchors without epub namespace, closes #948 (#956) 2025-04-24 21:03:05 +02:00
Huang Xin ea8ab91ed3 feat: add list view for the bookshelf, closes #542 (#955) 2025-04-24 19:53:09 +02:00
Huang Xin efd415ece2 fix: dismiss status bar when resumed from background on Android (#952) 2025-04-24 11:12:10 +02:00
Huang Xin ec86d68b80 fix: check xdg-mime before registering deeplink in Linux, closes #475 (#951) 2025-04-24 16:19:14 +08:00
Huang Xin 172ab382bc fix: query status bar height on Android, closes #943 (#947) 2025-04-23 14:01:37 +02:00
Huang Xin ad551a04a1 feat: add description from metadata in book details, closes #913 (#946) 2025-04-22 19:01:43 +02:00
Huang Xin 4dc943d23e fix: tts now fallbacks to metadata language of the book, closes #916 (#945) 2025-04-22 18:12:14 +02:00
Huang Xin ee93885c04 fix: sync custom themes from global settings to localstorage, closes #917 (#944) 2025-04-22 14:52:46 +02:00
Huang Xin 14c032aaff feat: add global keyboard shortcut to toggle fullscreen with F11, closes #933 (#942) 2025-04-22 14:13:52 +02:00
Huang Xin 2f619ca745 fix: open book even the library is empty, closes #937 (#941) 2025-04-22 13:56:07 +02:00
Huang Xin 8979827cf5 fix: compat language code for api versions (#940) 2025-04-22 12:38:07 +02:00
Huang Xin ae16eeffa7 fix: prevent scrollToAnchor on focus outside content container, closes #906 (#928) 2025-04-21 14:57:01 +02:00
Huang Xin 8e96ef3e5d fix: hide navigation bar on reader page on Android 11, closes #921 (#927) 2025-04-21 14:49:16 +02:00
Huang Xin 38e24da968 fix: support v1 and v2 of translate API (#919) 2025-04-20 12:30:25 +08:00
Huang Xin 7371efe1d1 release: version 0.9.36 (#915) 2025-04-19 18:07:45 +02:00
Huang Xin 3cb37bca5d feat: add GuanKiapTsingKhai-T in the CJK fonts list, closes #884 (#914) 2025-04-19 18:00:35 +02:00
Huang Xin b9fa204eda fix: show system navigation bar by swiping up from bottom on Android, closes #592 (#912) 2025-04-19 17:02:17 +02:00
Huang Xin cf66665096 feat: immersive UI in reader page on iOS and Android, closes #886 and closes #864 (#911) 2025-04-19 16:43:39 +02:00
Huang Xin 759779a98d fix: links in about window is now clickable in apps (#902) 2025-04-17 11:17:07 +02:00
Huang Xin 68409db8a0 compat: more accessible iframes to support LingKuma and Immersive Translate extension, closes #889 (#901) 2025-04-17 10:36:04 +02:00
Huang Xin 46f0e8e7f6 feat: open files with readest from file manager on iOS (#898) 2025-04-16 17:36:11 +02:00
davimed 6260168caa Fix filename with quotes (#897)
* Escape double quotes in filenames

* Format code
2025-04-16 17:34:15 +02:00
Huang Xin a8430a7be1 feat: open files with readest from file manager on Android, closes #865 (#895) 2025-04-16 07:04:30 +02:00
Huang Xin 7bfaa38746 feat: add download/upload buttons in book detail dialogs (#891) 2025-04-15 06:19:29 +02:00
Huang Xin c5b2feda48 release: version 0.9.35 (#888) 2025-04-14 17:50:39 +02:00
Huang Xin 59f875f590 feat: add books sorting by title and author in the library page, closes #540 (#887) 2025-04-14 17:38:41 +02:00
Huang Xin 8efad90932 feat: in-app updater for Android (#885) 2025-04-14 14:21:58 +02:00
Huang Xin 5c23642ac0 chore: generate Android package signature and download url in update file 2025-04-13 23:59:56 +08:00
Huang Xin ee6500cecb fix: books without cover images now can be synced across devices (#878) 2025-04-13 10:47:50 +02:00
102 changed files with 2451 additions and 624 deletions
+34 -1
View File
@@ -130,7 +130,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('Cargo.lock') }}
key: ${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
- name: install dependencies (ubuntu only)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android'
@@ -152,6 +152,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.0.11902837
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
cd apps/readest-app/
rm -rf src-tauri/gen/android
@@ -179,6 +181,37 @@ jobs:
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk --clobber
echo "Uploading $arm64_apk to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk --clobber
echo "Uploading signatures to GitHub release"
pnpm tauri signer sign $universial_apk
pnpm tauri signer sign $arm64_apk
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber
- name: download and update latest.json for Android release
if: matrix.config.release == 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd apps/readest-app/
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
version=${{ needs.get-release.outputs.release_version }}
universial_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_universal.apk"
arm64_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_arm64.apk"
universial_sig=$(cat Readest_${version}_universal.apk.sig)
arm64_sig=$(cat Readest_${version}_arm64.apk.sig)
jq --arg url "$universial_apk_url" \
--arg sig "$universial_sig" \
'.platforms["android-universal"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
jq --arg url "$arm64_apk_url" \
--arg sig "$arm64_sig" \
'.platforms["android-arm64"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
echo "Uploading updated latest.json to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
- uses: tauri-apps/tauri-action@v0
if: matrix.config.release != 'android'
+1 -1
View File
@@ -54,6 +54,7 @@
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
## Planned Features
@@ -62,7 +63,6 @@
| **Feature** | **Description** | **Priority** |
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
+1 -1
View File
@@ -32,7 +32,7 @@ yarn-error.log*
/private_keys
# plists
*.plist
Entitlements*.plist
# local confs
tauri.*.conf.json
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.33",
"version": "0.9.37",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "جارٍ تنزيل {{downloaded}} من {{contentLength}}",
"Download finished": "اكتمل التنزيل",
"DOWNLOAD & INSTALL": "تنزيل وتثبيت",
"Changelog:": "سجل التغييرات:"
"Changelog:": "سجل التغييرات:",
"Software Update": "تحديث البرنامج",
"Title": "العنوان",
"Date Read": "تاريخ القراءة",
"Date Added": "تاريخ الإضافة",
"Format": "التنسيق",
"Ascending": "تصاعدي",
"Descending": "تنازلي",
"Sort by...": "ترتيب حسب...",
"Added:": "تاريخ الإضافة:",
"Format:": "التنسيق:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "الوصف:",
"No description available": "لا يوجد وصف متاح",
"List": "قائمة",
"Grid": "شبكة"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "Herunterladen {{downloaded}} von {{contentLength}}",
"Download finished": "Download abgeschlossen",
"DOWNLOAD & INSTALL": "HERUNTERLADEN & INSTALLIEREN",
"Changelog:": "Änderungsprotokoll:"
"Changelog:": "Änderungsprotokoll:",
"Software Update": "Software-Update",
"Title": "Titel",
"Date Read": "Datum gelesen",
"Date Added": "Datum hinzugefügt",
"Format": "Format",
"Ascending": "Aufsteigend",
"Descending": "Absteigend",
"Sort by...": "Sortieren nach...",
"Added:": "Hinzugefügt:",
"Format:": "Format:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Beschreibung:",
"No description available": "Keine Beschreibung verfügbar",
"List": "Liste",
"Grid": "Raster"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "Κατεβάζω {{downloaded}} από {{contentLength}}",
"Download finished": "Η λήψη ολοκληρώθηκε",
"DOWNLOAD & INSTALL": "ΛΗΨΗ & ΕΓΚΑΤΑΣΤΑΣΗ",
"Changelog:": "Αλλαγές:"
"Changelog:": "Αλλαγές:",
"Software Update": "Ενημέρωση λογισμικού",
"Title": "Τίτλος",
"Date Read": "Ημερομηνία ανάγνωσης",
"Date Added": "Ημερομηνία προσθήκης",
"Format": "Μορφή",
"Ascending": "Αύξουσα",
"Descending": "Φθίνουσα",
"Sort by...": "Ταξινόμηση κατά...",
"Added:": "Προστέθηκε:",
"Format:": "Μορφή:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Περιγραφή:",
"No description available": "Δεν είναι διαθέσιμη καμία περιγραφή",
"List": "Λίστα",
"Grid": "Πλέγμα"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "Descargando {{downloaded}} de {{contentLength}}",
"Download finished": "Descarga finalizada",
"DOWNLOAD & INSTALL": "DESCARGAR E INSTALAR",
"Changelog:": "Notas de la versión:"
"Changelog:": "Notas de la versión:",
"Software Update": "Actualización de software",
"Title": "Título",
"Date Read": "Fecha de lectura",
"Date Added": "Fecha añadida",
"Format": "Formato",
"Ascending": "Ascendente",
"Descending": "Descendente",
"Sort by...": "Ordenar por...",
"Added:": "Agregado:",
"Format:": "Formato:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Descripción:",
"No description available": "No hay descripción disponible",
"List": "Lista",
"Grid": "Cuadrícula"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "Téléchargement {{downloaded}} sur {{contentLength}}",
"Download finished": "Téléchargement terminé",
"DOWNLOAD & INSTALL": "TÉLÉCHARGER ET INSTALLER",
"Changelog:": "Journal des modifications :"
"Changelog:": "Journal des modifications :",
"Software Update": "Mise à jour logicielle",
"Title": "Titre",
"Date Read": "Date de lecture",
"Date Added": "Date ajoutée",
"Format": "Format",
"Ascending": "Ascendant",
"Descending": "Descendant",
"Sort by...": "Trier par...",
"Added:": "Ajouté :",
"Format:": "Format :",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Description :",
"No description available": "Aucune description disponible",
"List": "Liste",
"Grid": "Grille"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "डाउनलोड कर रहा है {{downloaded}} का {{contentLength}}",
"Download finished": "डाउनलोड पूरा हुआ",
"DOWNLOAD & INSTALL": "डाउनलोड और इंस्टॉल करें",
"Changelog:": "चेंजलॉग:"
"Changelog:": "चेंजलॉग:",
"Software Update": "सॉफ़्टवेयर अपडेट",
"Title": "शीर्षक",
"Date Read": "पढ़ने की तारीख",
"Date Added": "जोड़ा गया दिनांक",
"Format": "फॉर्मेट",
"Ascending": "आरोही",
"Descending": "अवरोही",
"Sort by...": "द्वारा क्रमबद्ध करें...",
"Added:": "जोड़ा गया:",
"Format:": "फॉर्मेट:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "विवरण:",
"No description available": "कोई विवरण उपलब्ध नहीं है",
"List": "सूची",
"Grid": "ग्रिड"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "Mengunduh {{downloaded}} dari {{contentLength}}",
"Download finished": "Unduhan selesai",
"DOWNLOAD & INSTALL": "UNDUH & INSTAL",
"Changelog:": "Catatan Perubahan:"
"Changelog:": "Catatan Perubahan:",
"Software Update": "Pembaruan Perangkat Lunak",
"Title": "Judul",
"Date Read": "Tanggal Dibaca",
"Date Added": "Tanggal Ditambahkan",
"Format": "Format",
"Ascending": "Naik",
"Descending": "Turun",
"Sort by...": "Urutkan berdasarkan...",
"Added:": "Ditambahkan:",
"Format:": "Format:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Deskripsi:",
"No description available": "Tidak ada deskripsi yang tersedia",
"List": "Daftar",
"Grid": "Jala"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "Scaricando {{downloaded}} di {{contentLength}}",
"Download finished": "Download completato",
"DOWNLOAD & INSTALL": "SCARICA E INSTALLA",
"Changelog:": "Note di rilascio:"
"Changelog:": "Note di rilascio:",
"Software Update": "Aggiornamento software",
"Title": "Titolo",
"Date Read": "Data di lettura",
"Date Added": "Data aggiunta",
"Format": "Formato",
"Ascending": "Crescente",
"Descending": "Decrescente",
"Sort by...": "Ordina per...",
"Added:": "Aggiunto:",
"Format:": "Formato:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Descrizione:",
"No description available": "Nessuna descrizione disponibile",
"List": "Elenco",
"Grid": "Griglia"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "ダウンロード中 {{downloaded}} / {{contentLength}}",
"Download finished": "ダウンロード完了",
"DOWNLOAD & INSTALL": "更新",
"Changelog:": "変更ログ:"
"Changelog:": "変更ログ:",
"Software Update": "ソフトウェアの更新",
"Title": "タイトル",
"Date Read": "読書日",
"Date Added": "追加日",
"Format": "形式",
"Ascending": "昇順",
"Descending": "降順",
"Sort by...": "並べ替え...",
"Added:": "追加日:",
"Format:": "形式:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "説明:",
"No description available": "説明はありません",
"List": "リスト",
"Grid": "グリッド"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "다운로드 중 {{downloaded}} / {{contentLength}}",
"Download finished": "다운로드 완료",
"DOWNLOAD & INSTALL": "다운로드 및 설치",
"Changelog:": "변경 로그:"
"Changelog:": "변경 로그:",
"Software Update": "소프트웨어 업데이트",
"Title": "제목",
"Date Read": "읽은 날짜",
"Date Added": "추가된 날짜",
"Format": "형식",
"Ascending": "오름차순",
"Descending": "내림차순",
"Sort by...": "정렬 기준...",
"Added:": "추가됨:",
"Format:": "형식:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "설명:",
"No description available": "설명 없음",
"List": "목록",
"Grid": "격자"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "Pobieranie {{downloaded}} z {{contentLength}}",
"Download finished": "Pobieranie zakończone",
"DOWNLOAD & INSTALL": "POBIERZ I ZAINSTALUJ",
"Changelog:": "Dziennik zmian:"
"Changelog:": "Dziennik zmian:",
"Software Update": "Aktualizacja oprogramowania",
"Title": "Tytuł",
"Date Read": "Data przeczytania",
"Date Added": "Data dodania",
"Format": "Format",
"Ascending": "Rosnąco",
"Descending": "Malejąco",
"Sort by...": "Sortuj według...",
"Added:": "Data dodania:",
"Format:": "Format:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Opis:",
"No description available": "Brak opisu",
"List": "Lista",
"Grid": "Siatka"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "Baixando {{downloaded}} de {{contentLength}}",
"Download finished": "Download concluído",
"DOWNLOAD & INSTALL": "BAIXAR E INSTALAR",
"Changelog:": "Notas de versão:"
"Changelog:": "Notas de versão:",
"Software Update": "Atualização de Software",
"Title": "Título",
"Date Read": "Data de leitura",
"Date Added": "Data de adição",
"Format": "Formato",
"Ascending": "Crescente",
"Descending": "Decrescente",
"Sort by...": "Ordenar por...",
"Added:": "Adicionado:",
"Format:": "Formato:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Descrição:",
"No description available": "Nenhuma descrição disponível",
"List": "Lista",
"Grid": "Grade"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "Скачано {{downloaded}} из {{contentLength}}",
"Download finished": "Загрузка завершена",
"DOWNLOAD & INSTALL": "СКАЧАТЬ И УСТАНОВИТЬ",
"Changelog:": "Изменения:"
"Changelog:": "Изменения:",
"Software Update": "Обновление программного обеспечения",
"Title": "Название",
"Date Read": "Дата прочтения",
"Date Added": "Дата добавления",
"Format": "Формат",
"Ascending": "По возрастанию",
"Descending": "По убыванию",
"Sort by...": "Сортировать по...",
"Added:": "Добавлено:",
"Format:": "Формат:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Описание:",
"No description available": "Нет доступного описания",
"List": "Список",
"Grid": "Сетка"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "İndiriliyor {{downloaded}}/{{contentLength}}",
"Download finished": "İndirme tamamlandı",
"DOWNLOAD & INSTALL": "İNDİR & YÜKLE",
"Changelog:": "Değişiklik Günlüğü:"
"Changelog:": "Değişiklik Günlüğü:",
"Software Update": "Yazılım Güncellemesi",
"Title": "Başlık",
"Date Read": "Okunma Tarihi",
"Date Added": "Eklenme Tarihi",
"Format": "Biçim",
"Ascending": "Artan",
"Descending": "Azalan",
"Sort by...": "Şuna göre sırala...",
"Added:": "Eklenme:",
"Format:": "Biçim:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Tanım:",
"No description available": "Tanım mevcut değil",
"List": "Liste",
"Grid": "Izgara"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "Завантаження {{downloaded}} з {{contentLength}}",
"Download finished": "Завантаження завершено",
"DOWNLOAD & INSTALL": "ЗАВАНТАЖИТИ І ВСТАНОВИТИ",
"Changelog:": "Журнал змін:"
"Changelog:": "Журнал змін:",
"Software Update": "Оновлення програмного забезпечення",
"Title": "Назва",
"Date Read": "Дата прочитання",
"Date Added": "Дата додавання",
"Format": "Формат",
"Ascending": "За зростанням",
"Descending": "За спаданням",
"Sort by...": "Сортувати за...",
"Added:": "Додано:",
"Format:": "Формат:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Опис:",
"No description available": "Опис недоступний",
"List": "Список",
"Grid": "Сітка"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "Đang tải {{downloaded}} trong {{contentLength}}",
"Download finished": "Tải xuống hoàn tất",
"DOWNLOAD & INSTALL": "TẢI XUỐNG & CÀI ĐẶT",
"Changelog:": "Thay đổi:"
"Changelog:": "Thay đổi:",
"Software Update": "Cập nhật phần mềm",
"Title": "Tiêu đề",
"Date Read": "Ngày đã đọc",
"Date Added": "Ngày thêm",
"Format": "Định dạng",
"Ascending": "Tăng dần",
"Descending": "Giảm dần",
"Sort by...": "Sắp xếp theo...",
"Added:": "Đã thêm:",
"Format:": "Định dạng:",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Description:": "Miêu tả:",
"No description available": "Không có mô tả nào",
"List": "Danh sách",
"Grid": "Lưới"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "正在下载 {{downloaded}} / {{contentLength}}",
"Download finished": "下载完成",
"DOWNLOAD & INSTALL": "下载并安装",
"Changelog:": "更新日志:"
"Changelog:": "更新日志:",
"Software Update": "软件更新",
"Title": "书名",
"Date Read": "阅读日期",
"Date Added": "添加日期",
"Format": "格式",
"Ascending": "升序",
"Descending": "降序",
"Sort by...": "排序方式...",
"Added:": "添加日期",
"Format:": "格式",
"GuanKiapTsingKhai-T": "原俠正楷-T",
"Description:": "简介",
"No description available": "暂无简介",
"List": "列表",
"Grid": "网格"
}
@@ -281,5 +281,20 @@
"Downloading {{downloaded}} of {{contentLength}}": "正在下載 {{downloaded}} / {{contentLength}}",
"Download finished": "下載完成",
"DOWNLOAD & INSTALL": "下載並安裝",
"Changelog:": "更新日誌:"
"Changelog:": "更新日誌:",
"Software Update": "軟體更新",
"Title": "書名",
"Date Read": "閱讀日期",
"Date Added": "添加日期",
"Format": "格式",
"Ascending": "升序",
"Descending": "降序",
"Sort by...": "排序方式...",
"Added:": "添加日期",
"Format:": "格式",
"GuanKiapTsingKhai-T": "原俠正楷-T",
"Description:": "簡介",
"No description available": "暫無簡介",
"List": "列表",
"Grid": "網格"
}
+29
View File
@@ -1,5 +1,34 @@
{
"releases": {
"0.9.37": {
"date": "2025-04-25",
"notes": [
"Full-Screen Toggle: You can now press F11 to quickly toggle full-screen mode",
"List View: A new list layout is available for your bookshelf",
"Book Details: Book descriptions from metadata are now shown in the details view",
"Enhanced Android behavior: better navigation bar handling and status bar dismissal when resuming",
"TTS now defaults to the books metadata language when needed",
"Synced custom themes reliably across sessions"
]
},
"0.9.36": {
"date": "2025-04-20",
"notes": [
"📥 New download and upload buttons in the book detail dialog for easier file management",
"📂 You can now open files directly in Readest from your file manager on Android and iOS",
"🛠️ Fixed an issue where files with quotation marks in the filename could not be opened",
"📖 Improved the immersive reading experience on the reader page for Android and iOS",
"🌐 Added GuanKiapTsingKhai-T to the list of available CJK fonts"
]
},
"0.9.35": {
"date": "2025-04-15",
"notes": [
"🛠️ Fixed an issue where books without cover images can not be synced across devices",
"📱 Added in-app update support for Android",
"📚 You can now sort books by title or author in your library"
]
},
"0.9.33": {
"date": "2025-04-13",
"notes": [
+201
View File
@@ -0,0 +1,201 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<false/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UIHomeIndicatorAutoHidden</key>
<true/>
<key>UIRequiresFullScreen</key>
<true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>EPUB Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>org.idpf.epub-container</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>PDF Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.adobe.pdf</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>FB2 Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.readest.fb2</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>CBZ Archive</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.readest.cbz</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>MOBI Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>org.mobipocket.mobi</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>AZW Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.amazon.azw</string>
<string>com.amazon.azw3</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Text File</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.plain-text</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>org.idpf.epub-container</string>
<key>UTTypeDescription</key>
<string>EPUB Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>epub</string>
</array>
<key>public.mime-type</key>
<string>application/epub+zip</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.readest.fb2</string>
<key>UTTypeDescription</key>
<string>FB2 Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.xml</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>fb2</string>
</array>
<key>public.mime-type</key>
<string>application/xml</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.readest.cbz</string>
<key>UTTypeDescription</key>
<string>CBZ Archive</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.archive</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>cbz</string>
</array>
<key>public.mime-type</key>
<string>application/x-cbz</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>org.mobipocket.mobi</string>
<key>UTTypeDescription</key>
<string>MOBI Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mobi</string>
</array>
<key>public.mime-type</key>
<string>application/x-mobipocket-ebook</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.amazon.azw</string>
<key>UTTypeDescription</key>
<string>AZW Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>azw</string>
<string>azw3</string>
</array>
<key>public.mime-type</key>
<string>application/vnd.amazon.ebook</string>
</dict>
</dict>
</array>
</dict>
</plist>
@@ -39,6 +39,17 @@
}
]
},
{
"identifier": "fs:allow-cache-read",
"allow": [
{
"path": "$CACHEDIR/**/*"
},
{
"path": "/private/var/mobile/Containers/Data/Application/**/*"
}
]
},
{
"identifier": "http:default",
"allow": [
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
@@ -19,12 +20,43 @@
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter android:label="oauth">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="readest" android:host="auth-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/epub+zip" />
<data android:mimeType="application/pdf" />
<data android:mimeType="application/fb2" />
<data android:mimeType="application/x-fb2" />
<data android:mimeType="application/vnd.amazon.mobi8-ebook" />
<data android:mimeType="application/vnd.comicbook+zip" />
<data android:mimeType="application/x-cbz" />
<data android:mimeType="application/x-mobipocket-ebook" />
<data android:mimeType="application/zip" />
<data android:mimeType="text/plain" />
<data android:pathPattern=".*\\.epub" />
<data android:pathPattern=".*\\.pdf" />
<data android:pathPattern=".*\\.fb2" />
<data android:pathPattern=".*\\.fb2.zip" />
<data android:pathPattern=".*\\.mobi" />
<data android:pathPattern=".*\\.azw" />
<data android:pathPattern=".*\\.cbz" />
<data android:pathPattern=".*\\.zip" />
<data android:pathPattern=".*\\.txt" />
</intent-filter>
<intent-filter android:label="oauth">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="readest" android:host="auth-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -1,51 +1,11 @@
package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import android.content.Intent
import android.net.Uri
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.activity.enableEdgeToEdge
import app.tauri.plugin.JSObject
import com.readest.native_bridge.NativeBridgePlugin
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
hideSystemUI()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val uri = intent.data ?: return
if (uri.scheme == "readest" && uri.host == "auth-callback") {
val result = JSObject().apply {
put("redirectUrl", uri.toString())
}
NativeBridgePlugin.pendingInvoke?.resolve(result)
NativeBridgePlugin.pendingInvoke = null
}
}
private fun hideSystemUI() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.decorView.windowInsetsController?.let { controller ->
controller.hide(WindowInsets.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
)
}
}
}
@@ -2,8 +2,12 @@
<!-- Base application theme. -->
<style name="Theme.readest" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowTranslucentStatus">false</item>
<item name="android:windowTranslucentNavigation">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:fitsSystemWindows">false</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowFullscreen">true</item>
</style>
</resources>
@@ -4,6 +4,14 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.os.Build
import android.view.View
import android.view.WindowInsets
import android.view.WindowManager
import android.view.WindowInsetsController
import android.graphics.Color
import android.webkit.WebView
import androidx.core.content.FileProvider
import androidx.browser.customtabs.CustomTabsIntent
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
@@ -24,6 +32,17 @@ class CopyURIRequestArgs {
var dst: String? = null
}
@InvokeArg
class InstallPackageRequestArgs {
var path: String? = null
}
@InvokeArg
class SetSystemUIVisibilityRequestArgs {
var visible: Boolean? = false
var darkMode: Boolean? = false
}
@TauriPlugin
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = NativeBridge()
@@ -34,6 +53,40 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
var pendingInvoke: Invoke? = null
}
override fun load(webView: WebView) {
super.load(webView)
handleIntent(activity.intent)
}
override fun onNewIntent(intent: Intent) {
handleIntent(intent)
}
private fun handleIntent(intent: Intent?) {
val uri = intent?.data ?: return
Log.e("NativeBridgePlugin", "Received intent: $uri")
when {
uri.scheme == "readest" && uri.host == "auth-callback" -> {
val result = JSObject().apply {
put("redirectUrl", uri.toString())
}
pendingInvoke?.resolve(result)
pendingInvoke = null
}
intent.action == Intent.ACTION_VIEW -> {
try {
activity.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (e: SecurityException) {
Log.e("NativeBridgePlugin", "Failed to take persistable URI permission: ${e.message}")
}
}
}
}
@Command
fun auth_with_custom_tab(invoke: Invoke) {
val args = invoke.parseArgs(AuthRequestArgs::class.java)
@@ -74,4 +127,122 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve(ret)
}
@Command
fun install_package(invoke: Invoke) {
val args = invoke.parseArgs(InstallPackageRequestArgs::class.java)
val ret = JSObject()
try {
val file = File(args.path ?: "")
if (file.exists()) {
val intent = Intent(Intent.ACTION_VIEW)
val apkUri = FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", file)
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
val packageManager = activity.packageManager
val resolveInfos = packageManager.queryIntentActivities(intent, 0)
for (resolveInfo in resolveInfos) {
val packageName = resolveInfo.activityInfo.packageName
activity.grantUriPermission(packageName, apkUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
activity.startActivity(intent)
ret.put("success", true)
} else {
ret.put("success", false)
ret.put("error", "File does not exist")
}
} catch (e: Exception) {
ret.put("success", false)
ret.put("error", e.message)
}
invoke.resolve(ret)
}
@Command
fun set_system_ui_visibility(invoke: Invoke) {
val args = invoke.parseArgs(SetSystemUIVisibilityRequestArgs::class.java)
val visible = args.visible ?: false
var isDarkMode = args.darkMode ?: false
val ret = JSObject()
try {
val window = activity.window
val decorView = window.decorView
if (!visible) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.setDecorFitsSystemWindows(false)
val controller = window.insetsController
if (controller != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE
}
if (isDarkMode) {
controller.setSystemBarsAppearance(
0,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
)
} else {
controller.setSystemBarsAppearance(
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
)
}
if (visible) {
controller.show(WindowInsets.Type.statusBars())
} else {
controller.hide(WindowInsets.Type.systemBars())
}
}
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
} else {
@Suppress("DEPRECATION")
decorView.systemUiVisibility = when {
visible && !isDarkMode -> View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
visible -> View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
else -> View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN
}
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}
ret.put("success", true)
} catch (e: Exception) {
ret.put("success", false)
ret.put("error", e.message)
}
invoke.resolve(ret)
}
@Command
fun get_status_bar_height(invoke: Invoke) {
val ret = JSObject()
try {
val resourceId = activity.resources.getIdentifier("status_bar_height", "dimen", "android")
val height = if (resourceId > 0) {
activity.resources.getDimensionPixelSize(resourceId)
} else {
0
}
ret.put("height", height)
} catch (e: Exception) {
ret.put("height", -1)
ret.put("error", e.message)
}
invoke.resolve(ret)
}
}
@@ -3,6 +3,9 @@ const COMMANDS: &[&str] = &[
"auth_with_custom_tab",
"copy_uri_to_path",
"use_background_audio",
"install_package",
"set_system_ui_visibility",
"get_status_bar_height",
];
fn main() {
@@ -14,6 +14,11 @@ class UseBackgroundAudioRequestArgs: Decodable {
let enabled: Bool
}
class SetSystemUIVisibilityRequestArgs: Decodable {
let visible: Bool
let darkMode: Bool
}
class NativeBridgePlugin: Plugin {
private var authSession: ASWebAuthenticationSession?
@@ -66,6 +71,30 @@ class NativeBridgePlugin: Plugin {
let started = authSession?.start() ?? false
Logger.info("Auth session start result: \(started)")
}
@objc public func set_system_ui_visibility(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(SetSystemUIVisibilityRequestArgs.self)
let visible = args.visible
let darkMode = args.darkMode
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = !visible
UIApplication.shared.setStatusBarHidden(!visible, with: .none)
let windows = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
let keyWindow = windows.first(where: { $0.isKeyWindow }) ?? windows.first
if let keyWindow = keyWindow {
keyWindow.overrideUserInterfaceStyle = darkMode ? .dark : .light
keyWindow.layoutIfNeeded()
} else {
print("No key window found")
}
}
invoke.resolve(["success": true])
}
}
@_cdecl("init_plugin_native_bridge")
@@ -78,4 +107,4 @@ extension NativeBridgePlugin: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return UIApplication.shared.windows.first ?? UIWindow()
}
}
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-status-bar-height"
description = "Enables the get_status_bar_height command without any pre-configured scope."
commands.allow = ["get_status_bar_height"]
[[permission]]
identifier = "deny-get-status-bar-height"
description = "Denies the get_status_bar_height command without any pre-configured scope."
commands.deny = ["get_status_bar_height"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-install-package"
description = "Enables the install_package command without any pre-configured scope."
commands.allow = ["install_package"]
[[permission]]
identifier = "deny-install-package"
description = "Denies the install_package command without any pre-configured scope."
commands.deny = ["install_package"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-system-ui-visibility"
description = "Enables the set_system_ui_visibility command without any pre-configured scope."
commands.allow = ["set_system_ui_visibility"]
[[permission]]
identifier = "deny-set-system-ui-visibility"
description = "Denies the set_system_ui_visibility command without any pre-configured scope."
commands.deny = ["set_system_ui_visibility"]
@@ -8,6 +8,9 @@ Default permissions for the plugin
- `allow-auth-with-custom-tab`
- `allow-copy-uri-to-path`
- `allow-use-background-audio`
- `allow-install-package`
- `allow-set-system-ui-visibility`
- `allow-get-status-bar-height`
## Permission Table
@@ -99,6 +102,84 @@ Denies the copy_uri_to_path command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-get-status-bar-height`
</td>
<td>
Enables the get_status_bar_height command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-get-status-bar-height`
</td>
<td>
Denies the get_status_bar_height command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-install-package`
</td>
<td>
Enables the install_package command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-install-package`
</td>
<td>
Denies the install_package command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-set-system-ui-visibility`
</td>
<td>
Enables the set_system_ui_visibility command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-set-system-ui-visibility`
</td>
<td>
Denies the set_system_ui_visibility command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-use-background-audio`
</td>
@@ -1,3 +1,11 @@
[default]
description = "Default permissions for the plugin"
permissions = ["allow-auth-with-safari", "allow-auth-with-custom-tab", "allow-copy-uri-to-path", "allow-use-background-audio"]
permissions = [
"allow-auth-with-safari",
"allow-auth-with-custom-tab",
"allow-copy-uri-to-path",
"allow-use-background-audio",
"allow-install-package",
"allow-set-system-ui-visibility",
"allow-get-status-bar-height",
]
@@ -330,6 +330,42 @@
"const": "deny-copy-uri-to-path",
"markdownDescription": "Denies the copy_uri_to_path command without any pre-configured scope."
},
{
"description": "Enables the get_status_bar_height command without any pre-configured scope.",
"type": "string",
"const": "allow-get-status-bar-height",
"markdownDescription": "Enables the get_status_bar_height command without any pre-configured scope."
},
{
"description": "Denies the get_status_bar_height command without any pre-configured scope.",
"type": "string",
"const": "deny-get-status-bar-height",
"markdownDescription": "Denies the get_status_bar_height command without any pre-configured scope."
},
{
"description": "Enables the install_package command without any pre-configured scope.",
"type": "string",
"const": "allow-install-package",
"markdownDescription": "Enables the install_package command without any pre-configured scope."
},
{
"description": "Denies the install_package command without any pre-configured scope.",
"type": "string",
"const": "deny-install-package",
"markdownDescription": "Denies the install_package command without any pre-configured scope."
},
{
"description": "Enables the set_system_ui_visibility command without any pre-configured scope.",
"type": "string",
"const": "allow-set-system-ui-visibility",
"markdownDescription": "Enables the set_system_ui_visibility command without any pre-configured scope."
},
{
"description": "Denies the set_system_ui_visibility command without any pre-configured scope.",
"type": "string",
"const": "deny-set-system-ui-visibility",
"markdownDescription": "Denies the set_system_ui_visibility command without any pre-configured scope."
},
{
"description": "Enables the use_background_audio command without any pre-configured scope.",
"type": "string",
@@ -343,10 +379,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`"
}
]
}
@@ -35,3 +35,26 @@ pub(crate) async fn use_background_audio<R: Runtime>(
) -> Result<()> {
app.native_bridge().use_background_audio(payload)
}
#[command]
pub(crate) async fn install_package<R: Runtime>(
app: AppHandle<R>,
payload: InstallPackageRequest,
) -> Result<InstallPackageResponse> {
app.native_bridge().install_package(payload)
}
#[command]
pub(crate) async fn set_system_ui_visibility<R: Runtime>(
app: AppHandle<R>,
payload: SetSystemUIVisibilityRequest,
) -> Result<SetSystemUIVisibilityResponse> {
app.native_bridge().set_system_ui_visibility(payload)
}
#[command]
pub(crate) async fn get_status_bar_height<R: Runtime>(
app: AppHandle<R>,
) -> Result<GetStatusBarHeightResponse> {
app.native_bridge().get_status_bar_height()
}
@@ -29,4 +29,22 @@ impl<R: Runtime> NativeBridge<R> {
pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn install_package(
&self,
_payload: InstallPackageRequest,
) -> crate::Result<InstallPackageResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_system_ui_visibility(
&self,
_payload: SetSystemUIVisibilityRequest,
) -> crate::Result<SetSystemUIVisibilityResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_status_bar_height(&self) -> crate::Result<GetStatusBarHeightResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -41,6 +41,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::auth_with_custom_tab,
commands::copy_uri_to_path,
commands::use_background_audio,
commands::install_package,
commands::set_system_ui_visibility,
commands::get_status_bar_height,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -55,3 +55,33 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn install_package(
&self,
payload: InstallPackageRequest,
) -> crate::Result<InstallPackageResponse> {
self.0
.run_mobile_plugin("install_package", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn set_system_ui_visibility(
&self,
payload: SetSystemUIVisibilityRequest,
) -> crate::Result<SetSystemUIVisibilityResponse> {
self.0
.run_mobile_plugin("set_system_ui_visibility", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_status_bar_height(&self) -> crate::Result<GetStatusBarHeightResponse> {
self.0
.run_mobile_plugin("get_status_bar_height", ())
.map_err(Into::into)
}
}
@@ -31,3 +31,37 @@ pub struct CopyURIResponse {
pub struct UseBackgroundAudioRequest {
pub enabled: bool,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallPackageRequest {
pub path: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallPackageResponse {
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetSystemUIVisibilityRequest {
pub visible: bool,
pub dark_mode: bool,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetSystemUIVisibilityResponse {
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetStatusBarHeightResponse {
pub height: u32,
pub error: Option<String>,
}
+34 -9
View File
@@ -71,7 +71,10 @@ fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
let files = files
.into_iter()
.map(|f| {
let file = f.to_string_lossy().replace("\\", "\\\\");
let file = f
.to_string_lossy()
.replace("\\", "\\\\")
.replace("\"", "\\\"");
format!("\"{file}\"",)
})
.collect::<Vec<_>>()
@@ -201,24 +204,46 @@ pub fn run() {
let app_handle = app.handle().clone();
app.listen("window-ready", move |_| {
app_handle.get_webview_window("main").unwrap()
.eval("window.__READEST_CLI_ACCESS = true; window.__READEST_UPDATER_ACCESS = true;")
app_handle
.get_webview_window("main")
.unwrap()
.eval("window.__READEST_CLI_ACCESS = true;")
.expect("Failed to set cli access config");
set_rounded_window(&app_handle, true);
#[cfg(target_os = "windows")]
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
if tauri_plugin_os::version()
<= tauri_plugin_os::Version::from_string("10.0.19045")
{
set_rounded_window(&app_handle, false);
}
});
}
#[cfg(any(windows, target_os = "linux"))]
#[cfg(target_os = "windows")]
{
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
}
#[cfg(target_os = "linux")]
{
fn has_xdg_mime() -> bool {
std::process::Command::new("which")
.arg("xdg-mime")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
if has_xdg_mime() {
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
} else {
println!("xdg-mime not found; skipping deep link setup on Linux.");
}
}
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
@@ -228,9 +253,7 @@ pub fn run() {
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default());
#[cfg(desktop)]
let win_builder = win_builder
.inner_size(800.0, 600.0)
.resizable(true);
let win_builder = win_builder.inner_size(800.0, 600.0).resizable(true);
#[cfg(target_os = "macos")]
let win_builder = win_builder
@@ -247,7 +270,9 @@ pub fn run() {
.title("Readest");
if cfg!(target_os = "windows") {
if tauri_plugin_os::version() <= tauri_plugin_os::Version::from_string("10.0.19045") {
if tauri_plugin_os::version()
<= tauri_plugin_os::Version::from_string("10.0.19045")
{
win_builder = win_builder.shadow(false);
} else {
win_builder = win_builder.shadow(true);
+8 -3
View File
@@ -17,15 +17,20 @@
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://fontsapi.zeoseven.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://fontsapi.zeoseven.com",
"frame-src": "'self' blob: asset: http://asset.localhost",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com"
},
"assetProtocol": {
"enable": true,
"scope": {
"allow": ["$RESOURCE/**", "$APPDATA/**/*", "$TEMP/**/*"],
"allow": [
"$RESOURCE/**",
"$APPDATA/**/*",
"$TEMP/**/*",
"/private/var/mobile/Containers/Data/Application/**/*"
],
"deny": []
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { useTheme } from '@/hooks/useTheme';
export default function AuthErrorPage() {
const router = useRouter();
useTheme();
useTheme({ systemUIVisible: false });
useEffect(() => {
const timer = setTimeout(() => {
+3
View File
@@ -13,6 +13,7 @@ import { IoArrowBack } from 'react-icons/io5';
import { useAuth } from '@/context/AuthContext';
import { supabase } from '@/utils/supabase';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -74,6 +75,8 @@ export default function AuthPage() {
const headerRef = useRef<HTMLDivElement>(null);
useTheme({ systemUIVisible: false });
const getTauriRedirectTo = (isOAuth: boolean) => {
if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) {
if (appService?.isMobile) {
@@ -1,14 +1,16 @@
import clsx from 'clsx';
import Image from 'next/image';
import { Book } from '@/types/book';
import { LibraryViewModeType } from '@/types/settings';
import { formatAuthors, formatTitle } from '@/utils/book';
interface BookCoverProps {
book: Book;
isPreview?: boolean;
mode?: LibraryViewModeType;
}
const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
const BookCover: React.FC<BookCoverProps> = ({ mode = 'grid', book, isPreview }) => {
return (
<div className='relative flex h-full w-full'>
<Image
@@ -31,8 +33,8 @@ const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
<div className='flex h-1/2 items-center justify-center'>
<span
className={clsx(
isPreview ? 'line-clamp-2' : 'line-clamp-3',
isPreview ? 'text-[0.5em]' : 'text-lg',
isPreview ? 'line-clamp-2' : mode === 'grid' ? 'line-clamp-3' : 'line-clamp-2',
isPreview ? 'text-[0.5em]' : mode === 'grid' ? 'text-lg' : 'text-sm',
)}
>
{formatTitle(book.title)}
@@ -43,7 +45,7 @@ const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
<span
className={clsx(
'text-neutral-content/50 line-clamp-1',
isPreview ? 'text-[0.4em]' : 'text-base',
isPreview ? 'text-[0.4em]' : mode === 'grid' ? 'text-base' : 'text-xs',
)}
>
{formatAuthors(book.author)}
@@ -6,10 +6,13 @@ import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/li
import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { LibraryViewModeType } from '@/types/settings';
import { formatAuthors } from '@/utils/book';
import ReadingProgress from './ReadingProgress';
import BookCover from './BookCover';
interface BookItemProps {
mode: LibraryViewModeType;
book: Book;
isSelectMode: boolean;
selectedBooks: string[];
@@ -20,6 +23,7 @@ interface BookItemProps {
}
const BookItem: React.FC<BookItemProps> = ({
mode,
book,
isSelectMode,
selectedBooks,
@@ -39,12 +43,19 @@ const BookItem: React.FC<BookItemProps> = ({
return (
<div
className={clsx(
'book-item flex h-full flex-col',
'book-item flex',
mode === 'grid' && 'h-full flex-col',
mode === 'list' && 'h-28 flex-row gap-4 overflow-hidden',
appService?.hasContextMenu ? 'cursor-pointer' : '',
)}
>
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden shadow-md'>
<BookCover book={book} />
<div
className={clsx(
'bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden shadow-md',
mode === 'list' && 'min-w-20',
)}
>
<BookCover mode={mode} book={book} />
{selectedBooks.includes(book.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
@@ -58,11 +69,28 @@ const BookItem: React.FC<BookItemProps> = ({
</div>
)}
</div>
<div className={clsx('flex w-full flex-col p-0 pt-2')}>
<div className='min-w-0 flex-1'>
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
<div
className={clsx(
'flex w-full flex-col p-0',
mode === 'grid' && 'pt-2',
mode === 'list' && 'py-2',
)}
>
<div className={clsx('min-w-0 flex-1', mode === 'list' && 'flex flex-col gap-2')}>
<h4
className={clsx(
'overflow-hidden text-ellipsis font-semibold',
mode === 'grid' && 'block whitespace-nowrap text-[0.6em] text-xs',
mode === 'list' && 'line-clamp-2 text-base',
)}
>
{book.title}
</h4>
{mode === 'list' && (
<p className='text-neutral-content line-clamp-1 text-sm'>
{formatAuthors(book.author, book.primaryLanguage) || ''}
</p>
)}
</div>
<div
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
@@ -6,7 +6,9 @@ import { MdDelete, MdOpenInNew, MdOutlineCancel } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
import { Book, BooksGroup } from '@/types/book';
import { LibraryViewModeType } from '@/types/settings';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
@@ -15,7 +17,7 @@ import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import BookshelfItem, { generateGridItems, generateListItems } from './BookshelfItem';
import GroupingModal from './GroupingModal';
interface BookshelfProps {
@@ -45,6 +47,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const [loading, setLoading] = useState(false);
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
@@ -53,10 +56,16 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [queryTerm, setQueryTerm] = useState<string | null>(null);
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const [viewMode, setViewMode] = useState(searchParams?.get('view') || settings.libraryViewMode);
const [sortBy, setSortBy] = useState(searchParams?.get('sort') || settings.librarySortBy);
const [sortOrder, setSortOrder] = useState(
searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc'),
);
const isImportingBook = useRef(false);
const { setLibrary } = useLibraryStore();
const allBookshelfItems = generateBookshelfItems(libraryBooks);
const allBookshelfItems =
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
useEffect(() => {
if (isSelectMode) {
@@ -89,23 +98,55 @@ const Bookshelf: React.FC<BookshelfProps> = ({
useEffect(() => {
const group = searchParams?.get('group') || '';
const query = searchParams?.get('q') || '';
const view = searchParams?.get('view') || settings.libraryViewMode;
const sort = searchParams?.get('sort') || settings.librarySortBy;
const order = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
const params = new URLSearchParams(searchParams?.toString());
if (query) {
params.set('q', query);
setQueryTerm(query);
} else {
params.delete('q');
setQueryTerm(null);
}
if (sort) {
params.set('sort', sort);
setSortBy(sort);
} else {
params.delete('sort');
}
if (order) {
params.set('order', order);
setSortOrder(order);
} else {
params.delete('order');
}
if (view) {
params.set('view', view);
setViewMode(view);
} else {
params.delete('view');
}
if (sort === 'updated' && order === 'desc' && view === 'grid') {
params.delete('sort');
params.delete('order');
params.delete('view');
}
if (group) {
const booksGroup = allBookshelfItems.find(
(item) => 'name' in item && item.id === group,
) as BooksGroup;
if (booksGroup) {
setNavBooksGroup(booksGroup);
params.set('group', group);
} else {
navigateToLibrary(router, query ? `q=${query}` : undefined);
params.delete('group');
navigateToLibrary(router, `${params.toString()}`);
}
} else {
setNavBooksGroup(null);
navigateToLibrary(router, query ? `q=${query}` : undefined);
params.delete('group');
navigateToLibrary(router, `${params.toString()}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, libraryBooks, showGroupingModal]);
@@ -152,23 +193,65 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const authors = formatAuthors(item.author);
return searchTerm.test(title) || searchTerm.test(authors);
};
const filteredBookshelfItems = currentBookshelfItems.filter((item) => {
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
else if (queryTerm) return bookFilter(item, queryTerm);
return true;
});
const bookSorter = (a: Book, b: Book) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
switch (sortBy) {
case 'title':
const aTitle = formatTitle(a.title);
const bTitle = formatTitle(b.title);
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
case 'author':
const aAuthors = formatAuthors(a.author);
const bAuthors = formatAuthors(b.author);
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
case 'updated':
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
case 'created':
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'format':
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
default:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
};
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
const filteredBookshelfItems = currentBookshelfItems
.filter((item) => {
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
else if (queryTerm) return bookFilter(item, queryTerm);
return true;
})
.sort((a, b) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
if (sortBy === 'updated') {
return (
(new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * sortOrderMultiplier
);
} else if ('name' in a || 'name' in b) {
const aName = 'name' in a ? a.name : formatTitle(a.title);
const bName = 'name' in b ? b.name : formatTitle(b.title);
return aName.localeCompare(bName, uiLanguage || navigator.language) * sortOrderMultiplier;
} else if (!('name' in a || 'name' in b)) {
return bookSorter(a, b) * sortOrderMultiplier;
} else {
return 0;
}
});
return (
<div className='bookshelf'>
<div
className={clsx(
'transform-wrapper grid flex-1 gap-x-4 sm:gap-x-0',
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
'transform-wrapper',
viewMode === 'grid' && 'grid flex-1 grid-cols-3 gap-x-4 px-4 sm:gap-x-0 sm:px-2',
viewMode === 'grid' && 'sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
viewMode === 'list' && 'flex flex-col',
)}
>
{filteredBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
mode={viewMode as LibraryViewModeType}
item={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
@@ -184,7 +267,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
/>
))}
{!navBooksGroup && allBookshelfItems.length > 0 && (
{viewMode === 'grid' && !navBooksGroup && allBookshelfItems.length > 0 && (
<div
className={clsx(
'border-1 bg-base-100 hover:bg-base-300/50 flex items-center justify-center',
@@ -10,6 +10,7 @@ import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getOSPlatform } from '@/utils/misc';
import { getLocalBookFilename } from '@/utils/book';
import { LibraryViewModeType } from '@/types/settings';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BookGroupType, BooksGroup } from '@/types/book';
@@ -18,7 +19,7 @@ import GroupItem from './GroupItem';
export type BookshelfItem = Book | BooksGroup;
export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] => {
export const generateGridItems = (books: Book[]): (Book | BooksGroup)[] => {
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
if (book.deletedAt) return acc;
book.groupId = book.groupId || BOOK_UNGROUPED_ID;
@@ -47,6 +48,10 @@ export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] =>
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
};
export const generateListItems = (books: Book[]): (Book | BooksGroup)[] => {
return books.filter((book) => !book.deletedAt).sort((a, b) => b.updatedAt - a.updatedAt);
};
export const generateGroupsList = (items: Book[]): BookGroupType[] => {
return items
.sort((a, b) => b.updatedAt - a.updatedAt)
@@ -66,6 +71,7 @@ export const generateGroupsList = (items: Book[]): BookGroupType[] => {
};
interface BookshelfItemProps {
mode: LibraryViewModeType;
item: BookshelfItem;
isSelectMode: boolean;
selectedBooks: string[];
@@ -80,6 +86,7 @@ interface BookshelfItemProps {
}
const BookshelfItem: React.FC<BookshelfItemProps> = ({
mode,
item,
isSelectMode,
selectedBooks,
@@ -248,30 +255,35 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
});
return (
<div
className={clsx(
'sm:hover:bg-base-300/50 group flex h-full flex-col px-0 py-4 sm:px-4',
pressing ? 'scale-95' : 'scale-100',
)}
style={{
transition: 'transform 0.2s',
}}
{...handlers}
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
transferProgress={transferProgress}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
<div className={clsx(mode === 'list' && 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
<div
className={clsx(
'group',
mode === 'grid' && 'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-4 sm:px-4',
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
pressing ? (mode === 'grid' ? 'scale-95' : 'scale-98') : 'scale-100',
)}
style={{
transition: 'transform 0.2s',
}}
{...handlers}
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
mode={mode}
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
transferProgress={transferProgress}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
)}
</div>
</div>
</div>
);
@@ -4,20 +4,23 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
import { PiSelectionAllDuotone } from 'react-icons/pi';
import { PiDotsThreeCircle } from 'react-icons/pi';
import { MdOutlineMenu, MdArrowBackIosNew } from 'react-icons/md';
import { IoMdCloseCircle } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { navigateToLibrary } from '@/utils/nav';
import { throttle } from '@/utils/throttle';
import useShortcuts from '@/hooks/useShortcuts';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SettingsMenu from './SettingsMenu';
import ImportMenu from './ImportMenu';
import useShortcuts from '@/hooks/useShortcuts';
import SortMenu from './SortMenu';
interface LibraryHeaderProps {
isSelectMode: boolean;
@@ -34,6 +37,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { statusBarHeight } = useThemeStore();
const {
isTrafficLightVisible,
initializeTrafficLightStore,
@@ -44,7 +48,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const headerRef = useRef<HTMLDivElement>(null);
const iconSize16 = useResponsiveSize(16);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
useShortcuts({
@@ -90,10 +94,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
'titlebar bg-base-200 z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px] sm:pr-6',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
style={{
marginTop: appService?.hasSafeAreaInset
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '',
}}
>
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
@@ -173,7 +181,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu size={iconSize16} />}
toggleButton={<PiDotsThreeCircle size={iconSize18} />}
>
<SortMenu />
</Dropdown>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu size={iconSize18} />}
>
<SettingsMenu />
</Dropdown>
@@ -6,7 +6,7 @@ import { PiUserCircleCheck } from 'react-icons/pi';
import { MdCheck } from 'react-icons/md';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { hasUpdater, isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
@@ -19,11 +19,11 @@ import { QuotaType } from '@/types/user';
import MenuItem from '@/components/MenuItem';
import Quota from '@/components/Quota';
interface BookMenuProps {
interface SettingsMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
@@ -132,7 +132,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
return (
<div
tabIndex={0}
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-3 w-72 shadow-2xl'
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
>
{user ? (
<MenuItem
@@ -142,7 +142,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
: _('Logged in')
}
labelClass='!max-w-40'
icon={
Icon={
avatarUrl ? (
<Image
src={avatarUrl}
@@ -153,34 +153,34 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
height={20}
/>
) : (
<PiUserCircleCheck />
PiUserCircleCheck
)
}
>
<ul>
<Quota quotas={quotas} className='h-10 pl-4 pr-2' />
<Quota quotas={quotas} className='h-10 pl-3 pr-2' />
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
</ul>
</MenuItem>
) : (
<MenuItem label={_('Sign In')} icon={<PiUserCircle />} onClick={handleUserLogin}></MenuItem>
<MenuItem label={_('Sign In')} Icon={PiUserCircle} onClick={handleUserLogin}></MenuItem>
)}
<MenuItem
label={_('Auto Upload Books to Cloud')}
icon={isAutoUpload ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAutoUpload ? MdCheck : undefined}
onClick={toggleAutoUploadBooks}
/>
{isTauriAppPlatform() && !appService?.isMobile && (
<MenuItem
label={_('Auto Import on File Open')}
icon={isAutoImportBooksOnOpen ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAutoImportBooksOnOpen ? MdCheck : undefined}
onClick={toggleAutoImportBooksOnOpen}
/>
)}
{hasUpdater() && (
{appService?.hasUpdater && (
<MenuItem
label={_('Check Updates on Start')}
icon={isAutoCheckUpdates ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAutoCheckUpdates ? MdCheck : undefined}
onClick={toggleAutoCheckUpdates}
/>
)}
@@ -189,13 +189,13 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
{appService?.hasWindow && (
<MenuItem
label={_('Always on Top')}
icon={isAlwaysOnTop ? <MdCheck className='text-base-content' /> : undefined}
Icon={isAlwaysOnTop ? MdCheck : undefined}
onClick={toggleAlwaysOnTop}
/>
)}
<MenuItem
label={_('Keep Screen Awake')}
icon={isScreenWakeLock ? <MdCheck className='text-base-content' /> : undefined}
Icon={isScreenWakeLock ? MdCheck : undefined}
onClick={toggleScreenWakeLock}
/>
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
@@ -0,0 +1,121 @@
import React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { MdCheck } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { LibrarySortByType, LibraryViewModeType } from '@/types/settings';
import { navigateToLibrary } from '@/utils/nav';
import MenuItem from '@/components/MenuItem';
interface SortMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const SortMenu: React.FC<SortMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const viewMode = settings.libraryViewMode;
const sortBy = settings.librarySortBy;
const isAscending = settings.librarySortAscending;
const viewOptions = [
{ label: _('List'), value: 'list' },
{ label: _('Grid'), value: 'grid' },
];
const sortByOptions = [
{ label: _('Title'), value: 'title' },
{ label: _('Author'), value: 'author' },
{ label: _('Format'), value: 'format' },
{ label: _('Date Read'), value: 'updated' },
{ label: _('Date Added'), value: 'created' },
];
const sortingOptions = [
{ label: _('Ascending'), value: true },
{ label: _('Descending'), value: false },
];
const handleSetViewMode = (value: LibraryViewModeType) => {
settings.libraryViewMode = value;
setSettings(settings);
saveSettings(envConfig, settings);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('view', value);
navigateToLibrary(router, `${params.toString()}`);
};
const handleSetSortBy = (value: LibrarySortByType) => {
settings.librarySortBy = value;
setSettings(settings);
saveSettings(envConfig, settings);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('sort', value);
navigateToLibrary(router, `${params.toString()}`);
};
const handleSetSortAscending = (value: boolean) => {
settings.librarySortAscending = value;
setSettings(settings);
saveSettings(envConfig, settings);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('order', value ? 'asc' : 'desc');
navigateToLibrary(router, `${params.toString()}`);
};
return (
<div
tabIndex={0}
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
>
{viewOptions.map((option) => (
<MenuItem
key={option.value}
label={option.label}
buttonClass='h-8'
Icon={viewMode === option.value ? MdCheck : undefined}
onClick={() => handleSetViewMode(option.value as LibraryViewModeType)}
/>
))}
<hr className='border-base-200 my-1' />
<MenuItem
label={_('Sort by...')}
buttonClass='h-8'
labelClass='text-sm sm:text-xs'
disabled
/>
{sortByOptions.map((option) => (
<MenuItem
key={option.value}
label={option.label}
buttonClass='h-8'
Icon={sortBy === option.value ? MdCheck : undefined}
onClick={() => handleSetSortBy(option.value as LibrarySortByType)}
/>
))}
<hr className='border-base-200 my-1' />
{sortingOptions.map((option) => (
<MenuItem
key={option.value.toString()}
label={option.label}
buttonClass='h-8'
Icon={isAscending === option.value ? MdCheck : undefined}
onClick={() => handleSetSortAscending(option.value)}
/>
))}
</div>
);
};
export default SortMenu;
@@ -98,11 +98,12 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
if (newBook.uploadedAt && !newBook.deletedAt) {
try {
await appService?.downloadBook(newBook, true);
} catch {
console.error('Failed to download book:', newBook);
} finally {
newBook.coverImageUrl = await appService?.generateCoverImageUrl(newBook);
updatedLibrary.push(newBook);
setLibrary(updatedLibrary);
} catch {
console.error('Failed to download book:', newBook);
}
}
}
+29 -15
View File
@@ -12,8 +12,8 @@ import { getFilename, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { isTauriAppPlatform } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
import { impactFeedback } from '@tauri-apps/plugin-haptics';
@@ -21,7 +21,6 @@ import { getCurrentWebview } from '@tauri-apps/api/webview';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -29,11 +28,17 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import { useTheme } from '@/hooks/useTheme';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useThemeStore } from '@/store/themeStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { tauriHandleSetAlwaysOnTop, tauriQuitApp } from '@/utils/window';
import {
tauriHandleSetAlwaysOnTop,
tauriHandleToggleFullScreen,
tauriQuitApp,
} from '@/utils/window';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
@@ -59,9 +64,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setCheckOpenWithBooks,
} = useLibraryStore();
const _ = useTranslation();
useTheme();
const { updateAppTheme } = useThemeStore();
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
const { settings, setSettings, saveSettings } = useSettingsStore();
const { statusBarHeight } = useThemeStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
@@ -86,6 +91,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useScreenWakeLock(settings.screenWakeLock);
useShortcuts({
onToggleFullscreen: async () => {
if (isTauriAppPlatform()) {
await tauriHandleToggleFullScreen();
}
},
onQuitApp: async () => {
if (isTauriAppPlatform()) {
await tauriQuitApp();
@@ -93,14 +103,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
},
});
useEffect(() => {
updateAppTheme('base-200');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const doCheckAppUpdates = async () => {
if (hasUpdater() && settings.autoCheckUpdates) {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
await checkForAppUpdates(_);
}
};
@@ -203,7 +208,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
for (const file of openWithFiles) {
console.log('Open with book:', file);
try {
const temp = !settings.autoImportBooksOnOpen;
const temp = appService.isMobile ? false : !settings.autoImportBooksOnOpen;
const book = await appService.importBook(file, libraryBooks, true, true, false, temp);
if (book) {
bookIds.push(book.hash);
@@ -532,11 +537,16 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<div
ref={containerRef}
className={clsx(
'scroll-container drop-zone mt-[48px] flex-grow overflow-y-auto px-4 sm:px-2',
appService?.hasSafeAreaInset && 'mt-[calc(52px+env(safe-area-inset-top))]',
'scroll-container drop-zone flex-grow overflow-y-auto',
appService?.hasSafeAreaInset && 'pt-[52px]',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom))]',
isDragging && 'drag-over',
)}
style={{
marginTop: appService?.hasSafeAreaInset
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: '48px',
}}
>
<DropIndicator />
<Bookshelf
@@ -574,9 +584,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={() => setShowDetailsBook(null)}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
/>
)}
<AboutWindow />
{appService?.isAndroidApp && <UpdaterWindow />}
<Toast />
</div>
);
@@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
@@ -43,6 +44,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
} = useTrafficLightStore();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { bookKeys, hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { isSideBarVisible } = useSidebarStore();
const iconSize16 = useResponsiveSize(16);
@@ -89,20 +91,27 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
'bg-base-100 absolute left-0 right-0 top-0 z-10 h-[env(safe-area-inset-top)]',
isVisible ? 'visible' : 'hidden',
)}
style={{
height: systemUIVisible ? `max(env(safe-area-inset-top), ${statusBarHeight}px)` : '',
}}
/>
<div
ref={headerRef}
className={clsx(
`header-bar bg-base-100 absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
`shadow-xs transition-opacity duration-300`,
`shadow-xs transition-[opacity,margin-top] duration-300`,
isTrafficLightVisible && isTopLeft && !isSideBarVisible ? 'pl-16' : 'pl-4',
appService?.hasRoundedWindow && 'rounded-window-top-right',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-top-left',
isHoveredAnim && 'hover-bar-anim',
isVisible ? 'pointer-events-auto visible' : 'pointer-events-none opacity-0',
isDropdownOpen && 'header-bar-pinned',
)}
style={{
marginTop: systemUIVisible
? `max(env(safe-area-inset-top), ${statusBarHeight}px)`
: 'env(safe-area-inset-top)',
}}
onMouseLeave={() => !appService?.isMobile && setHoveredBookKey('')}
>
<div className='sidebar-bookmark-toggler z-20 flex h-full items-center gap-x-4'>
@@ -139,7 +148,10 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
showMaximize={
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
}
onClose={() => onCloseBook(bookKey)}
onClose={() => {
setHoveredBookKey(null);
onCloseBook(bookKey);
}}
/>
</div>
</div>
@@ -2,32 +2,50 @@
import clsx from 'clsx';
import * as React from 'react';
import { useEffect, Suspense, useRef } from 'react';
import { useEffect, Suspense, useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
import { useSidebarStore } from '@/store/sidebarStore';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig, appService } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { isDarkMode, showSystemUI, dismissSystemUI } = useThemeStore();
const { hoveredBookKey } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const { getVisibleLibrary, setLibrary } = useLibraryStore();
const { setLibrary } = useLibraryStore();
const [libraryLoaded, setLibraryLoaded] = useState(false);
const isInitiating = useRef(false);
const { updateAppTheme } = useThemeStore();
useTheme();
useTheme({ systemUIVisible: false, appThemeColor: 'base-100' });
useScreenWakeLock(settings.screenWakeLock);
useEffect(() => {
updateAppTheme('base-100');
const handleVisibilityChange = () => {
if (appService?.isMobile && !document.hidden) {
dismissSystemUI();
setSystemUIVisibility({ visible: false, darkMode: isDarkMode });
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService, isDarkMode]);
useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
const initLibrary = async () => {
@@ -35,14 +53,27 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const settings = await appService.loadSettings();
setSettings(settings);
setLibrary(await appService.loadLibraryBooks());
setLibraryLoaded(true);
};
initLibrary();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!appService?.isMobile) return;
const systemUIVisible = !!hoveredBookKey;
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
if (systemUIVisible) {
showSystemUI();
} else {
dismissSystemUI();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey]);
return (
getVisibleLibrary().length > 0 &&
libraryLoaded &&
settings.globalReadSettings && (
<div
className={clsx(
@@ -53,6 +84,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
{appService?.isAndroidApp && <UpdaterWindow />}
<Toast />
</Suspense>
</div>
@@ -12,7 +12,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { Book } from '@/types/book';
import { SystemSettings } from '@/types/settings';
import { parseOpenWithFiles } from '@/helpers/cli';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { tauriHandleClose, tauriHandleOnCloseWindow } from '@/utils/window';
import { isTauriAppPlatform } from '@/services/environment';
import { uniqueId } from '@/utils/misc';
@@ -135,7 +135,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
dismissBook(bookKey);
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
const openWithFiles = (await parseOpenWithFiles()) || [];
if (openWithFiles.length > 0) {
if (openWithFiles.length > 0 && !appService?.isMobile) {
tauriHandleClose();
} else {
saveSettingsAndGoToLibrary();
@@ -117,7 +117,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<MenuItem
label={_('Scrolled Mode')}
shortcut='Shift+J'
icon={isScrolledMode ? <MdCheck /> : undefined}
Icon={isScrolledMode ? MdCheck : undefined}
onClick={toggleScrolledMode}
/>
@@ -132,7 +132,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
? _('Light Mode')
: _('Auto Mode')
}
icon={themeMode === 'dark' ? <BiMoon /> : themeMode === 'light' ? <BiSun /> : <TbSunMoon />}
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
onClick={cycleThemeMode}
/>
</div>
@@ -200,8 +200,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const lineHeightValue =
parseFloat(lineHeight) || viewSettings.lineHeight * viewSettings.defaultFontSize;
const fontSizeValue = parseFloat(fontSize) || viewSettings.defaultFontSize;
const strokeWidth = style === 'underline' ? 2 : 4;
const padding = (lineHeightValue - fontSizeValue - strokeWidth) / 2;
const strokeWidth = 2;
const padding = viewSettings.vertical
? (lineHeightValue - fontSizeValue - strokeWidth) / 2
: strokeWidth;
draw(Overlayer[style as keyof typeof Overlayer], { writingMode, color: hexColor, padding });
}
};
@@ -42,13 +42,13 @@ const ThemeEditor: React.FC<ThemeEditorProps> = ({ customTheme, onSave, onDelete
<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'
className='border-base-300 overflow-hidden rounded border p-2'
style={{
backgroundColor: backgroundColor,
color: textColor,
}}
>
<p className='mb-2 whitespace-pre-line text-sm'>
<p className='mb-2 whitespace-pre-line text-xs'>
{_(
"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",
)}
@@ -62,13 +62,13 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
.map((book) => (
<MenuItem
key={book.hash}
icon={
Icon={
<Image
src={book.coverImageUrl!}
alt={book.title}
width={56}
height={80}
className='aspect-auto max-h-8 max-w-6 rounded-sm shadow-md'
className='aspect-auto max-h-8 max-w-4 rounded-sm shadow-md'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
@@ -38,9 +38,10 @@ const TTSControl = () => {
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
const popupWidth = useResponsiveSize(POPUP_WIDTH);
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
const popupPadding = useResponsiveSize(POPUP_PADDING);
const maxWidth = window.innerWidth - 2 * popupPadding;
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
const iconRef = useRef<HTMLDivElement>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
@@ -98,7 +99,7 @@ const TTSControl = () => {
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (!view || !viewSettings || !bookData) return;
if (!view || !viewSettings || !bookData || !bookData.book) return;
if (bookData.book?.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for PDF'),
@@ -107,6 +108,7 @@ const TTSControl = () => {
return;
}
const primaryLang = bookData.book.primaryLanguage;
setBookKey(bookKey);
if (ttsControllerRef.current) {
@@ -127,9 +129,13 @@ const TTSControl = () => {
await ttsController.initViewTTS();
const ssml = view.tts?.from(range);
if (ssml) {
const lang = parseSSMLLang(ssml) || 'en';
setTtsLang(lang);
let lang = parseSSMLLang(ssml) || 'en';
// We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value
if (lang === 'en' && primaryLang && primaryLang !== 'en') {
lang = primaryLang.split('-')[0]!;
}
setIsPlaying(true);
setTtsLang(lang);
ttsController.setLang(lang);
ttsController.setRate(viewSettings.ttsRate);
@@ -6,7 +6,7 @@ import useBooksManager from './useBooksManager';
import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { getStyles } from '@/utils/style';
import { tauriQuitApp } from '@/utils/window';
import { tauriHandleToggleFullScreen, tauriQuitApp } from '@/utils/window';
import { eventDispatcher } from '@/utils/event';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
@@ -84,6 +84,12 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
window.location.reload();
};
const toggleFullscreen = async () => {
if (isTauriAppPlatform()) {
await tauriHandleToggleFullScreen();
}
};
const quitApp = async () => {
// on web platform use browser's default shortcut to close the tab
if (isTauriAppPlatform()) {
@@ -136,6 +142,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onOpenFontLayoutSettings: () => setFontLayoutSettingsDialogOpen(true),
onToggleSearchBar: showSearchBar,
onReloadPage: reloadPage,
onToggleFullscreen: toggleFullscreen,
onQuitApp: quitApp,
onGoLeft: goLeft,
onGoRight: goRight,
+4 -3
View File
@@ -1,22 +1,23 @@
'use client';
import { useEffect } from 'react';
import { hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { useSettingsStore } from '@/store/settingsStore';
import { checkForAppUpdates } from '@/helpers/updater';
import Reader from './components/Reader';
export default function Page() {
const _ = useTranslation();
const { appService } = useEnv();
const { settings } = useSettingsStore();
useOpenWithBooks();
useEffect(() => {
const doCheckAppUpdates = async () => {
if (hasUpdater() && settings.autoCheckUpdates) {
if (appService?.hasUpdater && settings.autoCheckUpdates) {
await checkForAppUpdates(_);
}
};
+6 -231
View File
@@ -1,240 +1,13 @@
'use client';
import clsx from 'clsx';
import semver from 'semver';
import Image from 'next/image';
import { Suspense, useEffect, useState } from 'react';
import { check, Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { fetch } from '@tauri-apps/plugin-http';
import { useTranslation } from '@/hooks/useTranslation';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { UpdaterContent } from '@/components/UpdaterWindow';
import { useTheme } from '@/hooks/useTheme';
import packageJson from '../../../package.json';
import Spinner from '@/components/Spinner';
const CHANGELOG_URL =
'https://github.com/readest/readest/releases/latest/download/release-notes.json';
interface ReleaseNotes {
releases: Record<
string,
{
date: string;
notes: string[];
}
>;
}
interface Changelog {
version: string;
date: string;
notes: string[];
}
const UpdaterDialog = () => {
const _ = useTranslation();
const searchParams = useSearchParams();
const currentVersion = packageJson.version;
const version = searchParams?.get('version') || '';
const [update, setUpdate] = useState<Update | null>(null);
const [isMounted, setIsMounted] = useState(false);
const [newVersion, setNewVersion] = useState(version);
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
const [progress, setProgress] = useState<number | null>(null);
const [contentLength, setContentLength] = useState<number | null>(null);
const [downloaded, setDownloaded] = useState<number | null>(null);
useEffect(() => {
const fetchChangelogs = async (
fromVersion: string,
toVersion: string,
): Promise<Changelog[]> => {
try {
const res = await fetch(CHANGELOG_URL);
const data: ReleaseNotes = await res.json();
const releases = data.releases;
const entries = Object.entries(releases)
.filter(([ver]) => semver.gt(ver, fromVersion) && semver.lte(ver, toVersion))
.sort(([a], [b]) => semver.rcompare(a, b))
.map(([version, info]) => ({
version,
date: new Date(info.date).toDateString(),
notes: info.notes,
}));
return entries;
} catch (error) {
console.error('Failed to fetch changelog:', error);
return [];
}
};
const parseNumberedList = (input: string): string[] => {
return input
.split(/(?:^|\s)\d+\.\s/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
};
const checkForUpdates = async () => {
const update = await check();
setUpdate(update);
if (update) {
setNewVersion(update.version);
const changelogs = await fetchChangelogs(currentVersion, update.version);
if (changelogs.length > 0) {
setChangelogs(changelogs);
} else {
setChangelogs([
{
version: update.version,
date: new Date(update.date!).toDateString(),
notes: parseNumberedList(update.body ?? ''),
},
]);
}
}
};
checkForUpdates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const UpdaterPage = () => {
useTheme();
useEffect(() => {
setIsMounted(true);
}, []);
const handleDownloadInstall = async () => {
if (!update) {
return;
}
console.log(`found update ${update.version} from ${update.date} with notes ${update.body}`);
let downloaded = 0;
let contentLength = 0;
let lastLogged = 0;
setProgress(0);
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength!;
setContentLength(contentLength);
console.log(`started downloading ${event.data.contentLength} bytes`);
break;
case 'Progress':
downloaded += event.data.chunkLength;
setDownloaded(downloaded);
const percent = Math.floor((downloaded / contentLength) * 100);
setProgress(percent);
if (downloaded - lastLogged >= 1 * 1024 * 1024) {
console.log(`downloaded ${downloaded} bytes from ${contentLength}`);
lastLogged = downloaded;
}
break;
case 'Finished':
console.log('download finished');
setProgress(100);
break;
}
});
console.log('update installed');
await relaunch();
};
if (!isMounted) {
return null;
}
return (
<div className='bg-base-300 flex min-h-screen items-center justify-center'>
<div className='card bg-base-200 w-full max-w-2xl !rounded-none shadow-xl'>
<div className='card-body'>
<div className='flex items-start gap-8'>
<div className='flex flex-col items-center justify-center gap-4'>
<div className='bg-base-200 flex items-center justify-center rounded-2xl shadow-md'>
<Image src='/icon.png' alt='Logo' className='h-20 w-20' width={64} height={64} />
</div>
</div>
<div className='text-base-content flex-1 text-sm'>
<h2 className='mb-2 font-bold'>{_('A new version of Readest is Available!')}</h2>
<p className='mb-2'>
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
newVersion,
currentVersion,
})}
</p>
<p className='mb-2'>{_('Download and install now?')}</p>
<div className='flex w-full flex-row items-center justify-end gap-4'>
{progress !== null && (
<div className='flex flex-grow flex-col'>
<progress
className='progress my-1 h-4 w-full'
value={progress}
max='100'
></progress>
<p className='text-base-content/75 flex items-center justify-center text-sm'>
{progress < 100
? _('Downloading {{downloaded}} of {{contentLength}}', {
downloaded: downloaded
? `${Math.floor(downloaded / 1024 / 1024)} MB`
: '0 MB',
contentLength: contentLength
? `${Math.floor(contentLength / 1024 / 1024)} MB`
: '0 MB',
})
: _('Download finished')}
</p>
</div>
)}
<div className='card-actions'>
<button
className={clsx(
'btn btn-warning text-base-100 px-6 font-bold',
!update && 'btn-disabled',
)}
onClick={handleDownloadInstall}
>
{_('DOWNLOAD & INSTALL')}
</button>
</div>
</div>
</div>
</div>
</div>
<div className='text-base-content px-8 text-sm'>
<h3 className='mb-2 font-bold'>{_('Changelog:')}</h3>
<div className='bg-base-300 mb-4 rounded-lg px-4 pb-2 pt-4'>
{changelogs.length > 0 ? (
changelogs.map((entry: Changelog) => (
<div key={entry.version} className='mb-4'>
<h4 className='mb-2 font-bold'>
{entry.version} ({entry.date})
</h4>
<ul className='list-disc space-y-1 pl-6 text-sm'>
{entry.notes.map((note: string, i: number) => (
<li key={i}>{note}</li>
))}
</ul>
</div>
))
) : (
<div className='flex h-56 w-full flex-col gap-4'>
<div className='skeleton h-4 w-28'></div>
<div className='skeleton h-4 w-full'></div>
<div className='skeleton h-4 w-full'></div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
const UpdaterPage = () => {
return (
<Suspense
fallback={
@@ -243,7 +16,9 @@ const UpdaterPage = () => {
</div>
}
>
<UpdaterDialog />
<div className='px-12 py-4'>
<UpdaterContent />
</div>
</Suspense>
);
};
+1 -1
View File
@@ -34,7 +34,7 @@ const ProfilePage = () => {
const headerRef = useRef<HTMLDivElement>(null);
useTheme();
useTheme({ systemUIVisible: false });
useEffect(() => {
if (!user || !token) return;
+57 -69
View File
@@ -3,10 +3,10 @@ import Image from 'next/image';
import packageJson from '../../package.json';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { parseWebViewVersion } from '@/utils/ua';
import Dialog from './Dialog';
import Link from './Link';
export const setAboutDialogVisible = (visible: boolean) => {
const dialog = document.getElementById('about_window');
@@ -30,82 +30,70 @@ export const AboutWindow = () => {
const handleCheckUpdate = async () => {
const update = await checkForAppUpdates(_, false);
if (!update) {
if (update) {
setAboutDialogVisible(false);
} else {
setIsUpdated(true);
}
};
return (
<>
<Dialog
id='about_window'
isOpen={false}
title={_('About Readest')}
onClose={() => setAboutDialogVisible(false)}
boxClassName='sm:!w-96 sm:h-auto'
>
<div className='about-content flex h-full flex-col items-center justify-center'>
<div className='flex flex-col items-center px-8'>
<div className='mb-3'>
<Image src='/icon.png' alt='App Logo' className='h-24 w-24' width={64} height={64} />
</div>
<div className='flex select-text flex-col items-center'>
<h2 className='text-2xl font-bold'>Readest</h2>
<p className='text-neutral-content text-sm'>
{_('Version {{version}}', { version: packageJson.version })} {`(${browserInfo})`}
</p>
</div>
<div className='h-5'>
{hasUpdater() && !isUpdated && (
<span
className='badge badge-primary mt-2 cursor-pointer'
onClick={handleCheckUpdate}
>
{_('Check Update')}
</span>
)}
{isUpdated && (
<p className='text-neutral-content mt-2 text-xs'>
{_('Already the latest version')}
</p>
)}
</div>
<Dialog
id='about_window'
isOpen={false}
title={_('About Readest')}
onClose={() => setAboutDialogVisible(false)}
boxClassName='sm:!w-96 sm:h-auto'
>
<div className='about-content flex h-full flex-col items-center justify-center'>
<div className='flex flex-col items-center px-8'>
<div className='mb-3'>
<Image src='/icon.png' alt='App Logo' className='h-24 w-24' width={64} height={64} />
</div>
<div className='divider py-12 sm:py-2'></div>
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
<p className='text-neutral-content text-sm'>
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
</p>
<p className='text-neutral-content mt-2 text-xs'>
This software is licensed under the{' '}
<a
href='https://www.gnu.org/licenses/agpl-3.0.html'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
GNU Affero General Public License v3.0
</a>
. You are free to use, modify, and distribute this software under the terms of the
AGPL v3 license. Please see the license for more details.
</p>
<p className='text-neutral-content my-2 text-xs'>
Source code is available at{' '}
<a
href='https://github.com/readest/readest'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
GitHub
</a>
.
<div className='flex select-text flex-col items-center'>
<h2 className='text-2xl font-bold'>Readest</h2>
<p className='text-neutral-content text-center text-sm'>
{_('Version {{version}}', { version: packageJson.version })} {`(${browserInfo})`}
</p>
</div>
<div className='h-5'>
{appService?.hasUpdater && !isUpdated && (
<span className='badge badge-primary mt-2 cursor-pointer' onClick={handleCheckUpdate}>
{_('Check Update')}
</span>
)}
{isUpdated && (
<p className='text-neutral-content mt-2 text-xs'>{_('Already the latest version')}</p>
)}
</div>
</div>
</Dialog>
</>
<div className='divider py-12 sm:py-2'></div>
<div className='flex flex-col items-center px-4 text-center' dir='ltr'>
<p className='text-neutral-content text-sm'>
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
</p>
<p className='text-neutral-content mt-2 text-xs'>
This software is licensed under the{' '}
<Link
href='https://www.gnu.org/licenses/agpl-3.0.html'
className='text-blue-500 underline'
>
GNU Affero General Public License v3.0
</Link>
. You are free to use, modify, and distribute this software under the terms of the AGPL
v3 license. Please see the license for more details.
</p>
<p className='text-neutral-content my-2 text-xs'>
Source code is available at{' '}
<Link href='https://github.com/readest/readest' className='text-blue-500 underline'>
GitHub
</Link>
.
</p>
</div>
</div>
</Dialog>
);
};
+5 -4
View File
@@ -17,9 +17,10 @@ const Alert: React.FC<{
'alert flex items-center justify-between',
'bg-base-300 rounded-lg border-none p-4 shadow-2xl',
'w-full max-w-[90vw] sm:max-w-[70vw] md:max-w-[50vw] lg:max-w-[40vw] xl:max-w-[40vw]',
'min-w-[70vw] flex-col sm:min-w-[40vw] sm:flex-row',
)}
>
<div className='flex items-center space-x-2'>
<div className='flex items-center space-x-2 self-start'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
@@ -34,12 +35,12 @@ const Alert: React.FC<{
></path>
</svg>
<div className=''>
<h3 className='font-sm text-base'>{title}</h3>
<h3 className='font-sm text-start text-base sm:text-center'>{title}</h3>
<div className='text-xs'>{message}</div>
</div>
</div>
<div className='flex flex-wrap items-center justify-center gap-2'>
<button className='btn btn-sm' onClick={onCancel}>
<div className='flex flex-wrap items-center gap-2 self-end sm:max-w-[20vw]'>
<button className='btn btn-sm btn-neutral' onClick={onCancel}>
{_('Cancel')}
</button>
<button className='btn btn-sm btn-warning' onClick={onConfirm}>
@@ -1,13 +1,13 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { MdDelete, MdCloudDownload, MdCloudUpload } from 'react-icons/md';
import { Book } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import {
formatAuthors,
formatDate,
@@ -24,16 +24,25 @@ interface BookDetailModalProps {
book: Book;
isOpen: boolean;
onClose: () => void;
handleBookDownload?: (book: Book) => void;
handleBookUpload?: (book: Book) => void;
handleBookDelete?: (book: Book) => void;
}
const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
const BookDetailModal = ({
book,
isOpen,
onClose,
handleBookDownload,
handleBookUpload,
handleBookDelete,
}: BookDetailModalProps) => {
const _ = useTranslation();
const [loading, setLoading] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [bookMeta, setBookMeta] = useState<BookDoc['metadata'] | null>(null);
const { envConfig, appService } = useEnv();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
useEffect(() => {
const loadingTimeout = setTimeout(() => setLoading(true), 300);
@@ -61,10 +70,25 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
};
const confirmDelete = async () => {
await appService?.deleteBook(book, !!book.uploadedAt);
await updateBook(envConfig, book);
handleClose();
setShowDeleteAlert(false);
if (handleBookDelete) {
handleBookDelete(book);
}
};
const handleRedownload = async () => {
handleClose();
if (handleBookDownload) {
handleBookDownload(book);
}
};
const handleReupload = async () => {
handleClose();
if (handleBookUpload) {
handleBookUpload(book);
}
};
if (!bookMeta)
@@ -83,13 +107,13 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
isOpen={isOpen}
onClose={handleClose}
bgClassName='sm:bg-black/50'
boxClassName='sm:min-w-[480px] sm:h-auto'
boxClassName='sm:min-w-[480px] sm:max-w-[480px] sm:h-auto sm:max-h-[90%]'
contentClassName='!px-6 !py-2'
>
<div className='flex w-full select-text items-center justify-center'>
<div className='relative w-full rounded-lg'>
<div className='mb-10 flex h-40 items-start'>
<div className='book-cover relative mr-10 aspect-[28/41] h-40 items-end shadow-lg'>
<div className='mb-6 me-4 flex h-32 items-start'>
<div className='book-cover relative mr-10 aspect-[28/41] h-32 items-end shadow-lg'>
<Image
src={book.coverImageUrl!}
alt={formatTitle(book.title)}
@@ -112,45 +136,35 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
</div>
</div>
<div className='title-author flex h-40 flex-col justify-between'>
<div className='title-author flex h-32 flex-col justify-between'>
<div>
<p className='text-base-content mb-2 line-clamp-2 break-all text-2xl font-bold'>
<p className='text-base-content mb-2 line-clamp-2 break-all text-lg font-bold'>
{formatTitle(book.title) || _('Untitled')}
</p>
<p className='text-neutral-content line-clamp-1'>
{formatAuthors(book.author, bookMeta.language) || _('Unknown')}
{formatAuthors(book.author, book.primaryLanguage) || _('Unknown')}
</p>
</div>
{window.innerWidth >= 400 && (
<div className='flex flex-wrap items-center gap-x-4 gap-y-2 py-2'>
<button
className='btn rounded-xl bg-red-600 px-4 text-white hover:bg-red-700'
onClick={handleDelete}
>
{_('Delete')}
<div className='flex flex-wrap items-center gap-x-4'>
{handleBookDelete && (
<button onClick={handleDelete}>
<MdDelete className='fill-red-500' />
</button>
<button className='btn btn-disabled bg-primary/25 hover:bg-primary/85 rounded-xl px-4 text-white'>
{_('More Info')}
)}
{book.uploadedAt && handleBookDownload && (
<button onClick={handleRedownload}>
<MdCloudDownload className='fill-base-content' />
</button>
</div>
)}
)}
{book.downloadedAt && handleBookUpload && (
<button onClick={handleReupload}>
<MdCloudUpload className='fill-base-content' />
</button>
)}
</div>
</div>
</div>
{window.innerWidth < 400 && (
<div className='flex flex-wrap items-center gap-x-4 gap-y-2 py-2'>
<button
className='btn rounded bg-red-600 text-white hover:bg-red-700'
onClick={handleDelete}
>
{_('Delete')}
</button>
<button className='btn btn-disabled bg-primary/25 hover:bg-primary/85 rounded px-4 text-white'>
{_('More Info')}
</button>
</div>
)}
<div className='text-base-content my-4'>
<div className='mb-4 grid grid-cols-2 gap-4 sm:grid-cols-3'>
<div className='overflow-hidden'>
@@ -167,11 +181,12 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Updated:')}</span>
<p className='text-neutral-content text-sm'>
{formatDate(book.lastUpdated) || ''}
</p>
<p className='text-neutral-content text-sm'>{formatDate(book.updatedAt) || ''}</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Added:')}</span>
<p className='text-neutral-content text-sm'>{formatDate(book.createdAt) || ''}</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Language:')}</span>
<p className='text-neutral-content text-sm'>
@@ -190,6 +205,18 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
{formatSubject(bookMeta.subject) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Format:')}</span>
<p className='text-neutral-content line-clamp-1 text-sm'>
{book.format || _('Unknown')}
</p>
</div>
</div>
<div>
<span className='font-bold'>{_('Description:')}</span>
<p className='text-neutral-content text-sm'>
{bookMeta.description || _('No description available')}
</p>
</div>
</div>
</div>
+23
View File
@@ -0,0 +1,23 @@
import { isTauriAppPlatform } from '@/services/environment';
import { openUrl } from '@tauri-apps/plugin-opener';
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
}
const Link: React.FC<LinkProps> = ({ href, children, ...props }) => {
const handleClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
if (isTauriAppPlatform()) {
e.preventDefault();
await openUrl(href);
}
};
return (
<a href={href} target='_blank' rel='noopener noreferrer' onClick={handleClick} {...props}>
{children}
</a>
);
};
export default Link;
+18 -6
View File
@@ -1,40 +1,52 @@
import clsx from 'clsx';
import React from 'react';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { IconType } from 'react-icons';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface MenuItemProps {
label: string;
buttonClass?: string;
labelClass?: string;
shortcut?: string;
disabled?: boolean;
noIcon?: boolean;
icon?: React.ReactNode;
Icon?: React.ReactNode | IconType;
children?: React.ReactNode;
onClick?: () => void;
}
const MenuItem: React.FC<MenuItemProps> = ({
label,
buttonClass,
labelClass,
shortcut,
disabled,
noIcon = false,
icon,
Icon,
children,
onClick,
}) => {
const iconSize = useDefaultIconSize();
const iconSize = useResponsiveSize(16);
const menuButton = (
<button
className={clsx(
'hover:bg-base-300 text-base-content flex h-10 w-full items-center justify-between rounded-md p-2',
'hover:bg-base-300 text-base-content flex h-10 w-full items-center justify-between rounded-md p-1',
disabled && 'btn-disabled text-gray-400',
buttonClass,
)}
onClick={onClick}
disabled={disabled}
>
<div className='flex min-w-0 items-center'>
{!noIcon && <span style={{ minWidth: `${iconSize}px` }}>{icon}</span>}
{!noIcon && (
<span style={{ minWidth: `${iconSize}px` }}>
{typeof Icon === 'function' ? (
<Icon className='text-base-content' size={iconSize} />
) : (
Icon
)}
</span>
)}
<span
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm', labelClass)}
style={{ minWidth: 0 }}
+3 -1
View File
@@ -40,7 +40,9 @@ const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className }) => {
></div>
)}
<div className={clsx('relative flex items-center justify-between p-2', className)}>
<div
className={clsx('relative flex items-center justify-between gap-4 p-2', className)}
>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={quota.tooltip}>
<span className='truncate'>{quota.name}</span>
</div>
+10 -7
View File
@@ -17,10 +17,10 @@ export const Toast = () => {
error: 'toast-error toast-top toast-end',
};
const alertClassMap = {
info: 'alert-primary',
success: 'alert-success',
warning: 'alert-warning',
error: 'alert-error',
info: 'alert-primary border-base-300',
success: 'alert-success border-0',
warning: 'alert-warning border-0',
error: 'alert-error border-0',
};
useEffect(() => {
@@ -58,14 +58,17 @@ export const Toast = () => {
>
<div
className={clsx(
'alert flex max-w-80 items-center justify-center border-0',
'alert flex items-center justify-center',
alertClassMap[toastType.current],
)}
>
<span
className={clsx(
'max-h-[50vh] min-w-32 max-w-80',
'overflow-y-auto whitespace-normal break-words text-center',
'max-h-[50vh] min-w-32',
'overflow-y-auto text-center',
toastType.current === 'info'
? 'max-w-[80vw]'
: 'max-w-80 whitespace-normal break-words',
messageClass.current,
)}
>
@@ -0,0 +1,371 @@
import clsx from 'clsx';
import semver from 'semver';
import Image from 'next/image';
import { useEnv } from '@/context/EnvContext';
import { useEffect, useState } from 'react';
import { type as osType, arch as osArch } from '@tauri-apps/plugin-os';
import { check, Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { fetch } from '@tauri-apps/plugin-http';
import { useTranslation } from '@/hooks/useTranslation';
import { useSearchParams } from 'next/navigation';
import { tauriDownload } from '@/utils/transfer';
import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants';
import packageJson from '../../package.json';
import Dialog from '@/components/Dialog';
import { installPackage } from '@/utils/bridge';
interface ReleaseNotes {
releases: Record<
string,
{
date: string;
notes: string[];
}
>;
}
interface Changelog {
version: string;
date: string;
notes: string[];
}
type DownloadEvent =
| {
event: 'Started';
data: {
contentLength?: number;
};
}
| {
event: 'Progress';
data: {
chunkLength: number;
};
}
| {
event: 'Finished';
};
interface GenericUpdate {
currentVersion: string;
version: string;
date?: string;
body?: string;
downloadAndInstall?(onEvent?: (progress: DownloadEvent) => void): Promise<void>;
}
export const UpdaterContent = ({ version }: { version?: string }) => {
const _ = useTranslation();
const { appService } = useEnv();
const searchParams = useSearchParams();
const currentVersion = packageJson.version;
const resolvedVersion = version ?? searchParams?.get('version') ?? '';
const [update, setUpdate] = useState<GenericUpdate | Update | null>(null);
const [isMounted, setIsMounted] = useState(false);
const [newVersion, setNewVersion] = useState(resolvedVersion);
const [changelogs, setChangelogs] = useState<Changelog[]>([]);
const [progress, setProgress] = useState<number | null>(null);
const [contentLength, setContentLength] = useState<number | null>(null);
const [isDownloading, setIsDownloading] = useState(false);
const [downloaded, setDownloaded] = useState<number | null>(null);
useEffect(() => {
const checkDesktopUpdate = async () => {
const update = await check();
if (update) {
setUpdate(update);
}
};
const checkAndroidUpdate = async () => {
const response = await fetch(READEST_UPDATER_FILE);
const data = await response.json();
if (semver.gt(data.version, packageJson.version)) {
const OS_ARCH = osArch();
const platformKey = OS_ARCH === 'aarch64' ? 'android-arm64' : 'android-universal';
const arch = OS_ARCH === 'aarch64' ? 'arm64' : 'universal';
const downloadUrl = data.platforms[platformKey]?.url as string;
const cachePrefix = appService?.fs.getPrefix('Cache');
const apkFilePath = `${cachePrefix}/Readest_${data.version}_${arch}.apk`;
setUpdate({
currentVersion: packageJson.version,
version: data.version,
date: data.date,
body: data.notes,
downloadAndInstall: async (onEvent) => {
await new Promise<void>(async (resolve, reject) => {
let downloaded = 0;
let total = 0;
await tauriDownload(downloadUrl, apkFilePath, (progress) => {
if (!onEvent) return;
if (!total && progress.total) {
total = progress.total;
onEvent({
event: 'Started',
data: { contentLength: total },
});
} else if (downloaded > 0 && progress.progress === progress.total) {
console.log('APK downloaded to', apkFilePath);
onEvent?.({ event: 'Finished' });
setTimeout(() => {
resolve();
}, 1000);
}
onEvent({
event: 'Progress',
data: { chunkLength: progress.progress - downloaded },
});
downloaded = progress.progress;
}).catch((error) => {
console.error('Download failed:', error);
reject(error);
});
});
const res = await installPackage({
path: apkFilePath,
});
if (res.success) {
console.log('APK installed successfully');
} else {
console.error('Failed to install APK:', res.error);
}
},
} as GenericUpdate);
}
};
const checkForUpdates = async () => {
const OS_TYPE = osType();
if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
checkDesktopUpdate();
} else if (OS_TYPE === 'android') {
checkAndroidUpdate();
}
};
checkForUpdates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const fetchChangelogs = async (
fromVersion: string,
toVersion: string,
): Promise<Changelog[]> => {
try {
const res = await fetch(READEST_CHANGELOG_FILE);
const data: ReleaseNotes = await res.json();
const releases = data.releases;
const entries = Object.entries(releases)
.filter(([ver]) => semver.gt(ver, fromVersion) && semver.lte(ver, toVersion))
.sort(([a], [b]) => semver.rcompare(a, b))
.map(([version, info]) => ({
version,
date: new Date(info.date).toDateString(),
notes: info.notes,
}));
return entries;
} catch (error) {
console.error('Failed to fetch changelog:', error);
return [];
}
};
const parseNumberedList = (input: string): string[] => {
return input
.split(/(?:^|\s)\d+\.\s/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
};
const updateChangelogs = async (update: GenericUpdate) => {
setNewVersion(update.version);
const changelogs = await fetchChangelogs(currentVersion, update.version);
if (changelogs.length > 0) {
setChangelogs(changelogs);
} else {
setChangelogs([
{
version: update.version,
date: new Date(update.date!).toDateString(),
notes: parseNumberedList(update.body ?? ''),
},
]);
}
};
if (update) {
updateChangelogs(update);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [update]);
useEffect(() => {
setIsMounted(true);
}, []);
const handleDownloadInstall = async () => {
if (!update) {
return;
}
let downloaded = 0;
let contentLength = 0;
let lastLogged = 0;
setProgress(0);
setIsDownloading(true);
await update.downloadAndInstall?.((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength!;
setContentLength(contentLength);
break;
case 'Progress':
downloaded += event.data.chunkLength;
setDownloaded(downloaded);
const percent = Math.floor((downloaded / contentLength) * 100);
setProgress(percent);
if (downloaded - lastLogged >= 1 * 1024 * 1024) {
console.log(`downloaded ${downloaded} bytes from ${contentLength}`);
lastLogged = downloaded;
}
break;
case 'Finished':
console.log('download finished');
setProgress(100);
break;
}
});
console.log('package installed');
if (!appService?.isAndroidApp && process.env.NODE_ENV === 'production') {
await relaunch();
}
};
if (!isMounted) {
return null;
}
return (
<div className='bg-base-100 flex min-h-screen justify-center'>
<div className='flex w-full max-w-2xl flex-col gap-4'>
<div className='flex flex-col justify-center gap-4 sm:flex-row sm:items-start'>
<div className='flex items-center justify-center'>
<Image src='/icon.png' alt='Logo' className='h-20 w-20' width={64} height={64} />
</div>
<div className='text-base-content flex-grow text-sm'>
<h2 className='mb-4 text-center font-bold sm:text-start'>
{_('A new version of Readest is Available!')}
</h2>
<p className='mb-2'>
{_('Readest {{newVersion}} is available (installed version {{currentVersion}}).', {
newVersion,
currentVersion,
})}
</p>
<p className='mb-2'>{_('Download and install now?')}</p>
<div className='flex w-full flex-row items-center justify-end gap-4'>
{progress !== null && (
<div className='flex flex-grow flex-col'>
<progress
className='progress my-1 h-4 w-full'
value={progress}
max='100'
></progress>
<p className='text-base-content/75 flex items-center justify-center text-sm'>
{progress < 100
? _('Downloading {{downloaded}} of {{contentLength}}', {
downloaded: downloaded
? `${Math.floor(downloaded / 1024 / 1024)} MB`
: '0 MB',
contentLength: contentLength
? `${Math.floor(contentLength / 1024 / 1024)} MB`
: '0 MB',
})
: _('Download finished')}
</p>
</div>
)}
<div className='card-actions'>
<button
className={clsx(
'btn btn-warning text-base-100 px-6 font-bold',
(!update || isDownloading) && 'btn-disabled',
)}
onClick={handleDownloadInstall}
>
{_('DOWNLOAD & INSTALL')}
</button>
</div>
</div>
</div>
</div>
<div className='text-base-content text-sm'>
<h3 className='mb-2 font-bold'>{_('Changelog:')}</h3>
<div className='bg-base-300 mb-4 rounded-lg px-4 pb-2 pt-4'>
{changelogs.length > 0 ? (
changelogs.map((entry: Changelog) => (
<div key={entry.version} className='mb-4'>
<h4 className='mb-2 font-bold'>
{entry.version} ({entry.date})
</h4>
<ul className='list-disc space-y-1 ps-6 text-sm'>
{entry.notes.map((note: string, i: number) => (
<li key={i}>{note}</li>
))}
</ul>
</div>
))
) : (
<div className='flex h-56 w-full flex-col gap-4'>
<div className='skeleton h-4 w-28'></div>
<div className='skeleton h-4 w-full'></div>
<div className='skeleton h-4 w-full'></div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export const setUpdaterWindowVisible = (visible: boolean, newVersion?: string) => {
if (newVersion) {
localStorage.setItem('newVersion', newVersion);
window.dispatchEvent(new CustomEvent('new-version-detected'));
}
const dialog = document.getElementById('updater_window');
if (visible) {
(dialog as HTMLDialogElement)?.showModal();
} else {
(dialog as HTMLDialogElement)?.close();
}
};
export const UpdaterWindow = () => {
const _ = useTranslation();
const [newVersion, setNewVersion] = useState(localStorage.getItem('newVersion') || '');
useEffect(() => {
const handler = () => {
setNewVersion(localStorage.getItem('newVersion') || '');
};
window.addEventListener('new-version-detected', handler);
return () => window.removeEventListener('new-version-detected', handler);
}, []);
return (
<Dialog
id='updater_window'
isOpen={false}
title={_('Software Update')}
onClose={() => setUpdaterWindowVisible(false)}
boxClassName='sm:!w-[80%] sm:h-auto'
>
<UpdaterContent version={newVersion ?? undefined} />
</Dialog>
);
};
@@ -1,8 +1,9 @@
import { isWebAppPlatform, hasCli } from '@/services/environment';
import { getCurrent } from '@tauri-apps/plugin-deep-link';
declare global {
interface Window {
OPEN_WITH_FILES?: string[];
OPEN_WITH_FILES?: string[] | null;
}
}
@@ -32,12 +33,35 @@ const parseCLIOpenWithFiles = async () => {
return files;
};
const parseIntentOpenWithFiles = async () => {
const urls = await getCurrent();
if (urls && urls.length > 0) {
console.log('Intent Open with URL:', urls);
return urls
.map((url) => {
if (url.startsWith('file://')) {
return decodeURI(url.replace('file://', ''));
} else if (url.startsWith('content://')) {
return url;
} else {
console.info('Skip non-file URL:', url);
return null;
}
})
.filter((url) => url !== null) as string[];
}
return null;
};
export const parseOpenWithFiles = async () => {
if (isWebAppPlatform()) return [];
let files = parseWindowOpenWithFiles();
if (!files && hasCli()) {
if ((!files || files.length === 0) && hasCli()) {
files = await parseCLIOpenWithFiles();
}
if (!files || files.length === 0) {
files = await parseIntentOpenWithFiles();
}
return files;
};
@@ -7,6 +7,7 @@ export interface ShortcutConfig {
onToggleSelectMode: string[];
onOpenFontLayoutSettings: string[];
onReloadPage: string[];
onToggleFullscreen: string[];
onQuitApp: string[];
onGoLeft: string[];
onGoRight: string[];
@@ -32,6 +33,7 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
onToggleSelectMode: ['shift+s'],
onOpenFontLayoutSettings: ['shift+f'],
onReloadPage: ['shift+r'],
onToggleFullscreen: ['F11'],
onQuitApp: ['ctrl+q', 'cmd+q'],
onGoLeft: ['ArrowLeft', 'PageUp', 'h'],
onGoRight: ['ArrowRight', 'PageDown', 'l', ' '],
+50 -21
View File
@@ -1,35 +1,64 @@
import semver from 'semver';
import { check } from '@tauri-apps/plugin-updater';
import { type as osType } from '@tauri-apps/plugin-os';
import { fetch } from '@tauri-apps/plugin-http';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { CHECK_UPDATE_INTERVAL_SEC } from '@/services/constants';
import { TranslationFunc } from '@/hooks/useTranslation';
import { setUpdaterWindowVisible } from '@/components/UpdaterWindow';
import { CHECK_UPDATE_INTERVAL_SEC, READEST_UPDATER_FILE } from '@/services/constants';
import packageJson from '../../package.json';
const LAST_CHECK_KEY = 'lastAppUpdateCheck';
export const checkForAppUpdates = async (_: TranslationFunc, autoCheck = true) => {
const showUpdateWindow = (newVersion: string) => {
const win = new WebviewWindow('updater', {
url: `/updater?version=${newVersion}`,
title: 'Software Update',
width: 626,
height: 406,
center: true,
resizable: true,
});
win.once('tauri://created', () => {
console.log('new window created');
});
win.once('tauri://error', (e) => {
console.error('error creating window', e);
});
};
export const checkForAppUpdates = async (
_: TranslationFunc,
autoCheck = true,
): Promise<boolean> => {
const lastCheck = localStorage.getItem(LAST_CHECK_KEY);
const now = Date.now();
if (autoCheck && lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000)
return;
return false;
localStorage.setItem(LAST_CHECK_KEY, now.toString());
console.log('Checking for updates');
const update = await check();
console.log('Update found', update);
if (update) {
const win = new WebviewWindow('updater', {
url: `/updater?version=${update.version}`,
title: 'Software Update',
width: 626,
height: 406,
center: true,
resizable: true,
});
win.once('tauri://created', () => {
console.log('new window created');
});
win.once('tauri://error', (e) => {
console.error('error creating window', e);
});
const OS_TYPE = osType();
if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
const update = await check();
if (update) {
showUpdateWindow(update.version);
}
return !!update;
} else if (OS_TYPE === 'android') {
try {
const response = await fetch(READEST_UPDATER_FILE);
const data = await response.json();
const isNewer = semver.gt(data.version, packageJson.version);
if (isNewer && ('android-arm64' in data.platforms || 'android-universal' in data.platforms)) {
setUpdaterWindowVisible(true, data.version);
}
return isNewer;
} catch (err) {
console.warn('Failed to fetch Android update info', err);
return false;
}
}
return update;
return false;
};
@@ -62,7 +62,10 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
return;
}
const headerbar = document.querySelector('.titlebar');
const pullIndicator = document.createElement('div');
const headerBottom = headerbar?.getBoundingClientRect().bottom || 0;
pullIndicator.style.top = `${headerBottom + 20}px`;
pullIndicator.className = 'pull-indicator text-gray-500';
pullIndicator.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
+7 -6
View File
@@ -63,8 +63,8 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
handler &&
shortcutList?.some((shortcut) =>
isShortcutMatch(shortcut, key, ctrlKey, altKey, metaKey, shiftKey),
)
) {
)
) {
handler();
return true;
}
@@ -76,11 +76,12 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
// Check if the focus is on an input, textarea, or contenteditable element
const activeElement = document.activeElement as HTMLElement;
const isInteractiveElement =
(activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable);
activeElement.isContentEditable;
const isNoteEditor = (activeElement.tagName === 'TEXTAREA' && activeElement.classList.contains('note-editor'))
const isNoteEditor =
activeElement.tagName === 'TEXTAREA' && activeElement.classList.contains('note-editor');
if (isInteractiveElement && !isNoteEditor) {
return; // Skip handling if the user is typing in an input, textarea, or contenteditable
@@ -89,7 +90,7 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency
if (event instanceof KeyboardEvent) {
const { key, ctrlKey, altKey, metaKey, shiftKey } = event;
if (isNoteEditor && !((key === "Enter" && ctrlKey) || (key == "Escape"))) {
if (isNoteEditor && !((key === 'Enter' && ctrlKey) || key == 'Escape')) {
return;
}
+31 -3
View File
@@ -1,16 +1,44 @@
import { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { applyCustomTheme } from '@/styles/themes';
import { applyCustomTheme, Palette } from '@/styles/themes';
import { getStatusBarHeight, setSystemUIVisibility } from '@/utils/bridge';
export const useTheme = () => {
type UseThemeProps = {
systemUIVisible?: boolean;
appThemeColor?: keyof Palette;
};
export const useTheme = ({
systemUIVisible = true,
appThemeColor = 'base-100',
}: UseThemeProps = {}) => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { themeColor, isDarkMode } = useThemeStore();
const { themeColor, isDarkMode, updateAppTheme, setStatusBarHeight } = useThemeStore();
useEffect(() => {
updateAppTheme(appThemeColor);
if (appService?.isMobile) {
setSystemUIVisibility({ visible: systemUIVisible, darkMode: isDarkMode });
}
if (appService?.isAndroidApp) {
getStatusBarHeight().then((res) => {
if (res.height && res.height > 0) {
setStatusBarHeight(res.height / window.devicePixelRatio);
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService, isDarkMode]);
useEffect(() => {
const customThemes = settings.globalReadSettings?.customThemes ?? [];
customThemes.forEach((customTheme) => {
applyCustomTheme(customTheme);
});
localStorage.setItem('customThemes', JSON.stringify(customThemes));
}, [settings]);
useEffect(() => {
+1 -1
View File
@@ -97,7 +97,7 @@ export const downloadFile = async (
return;
}
} catch (error) {
console.error('File download failed:', error);
console.error(`File '${filePath}' download failed:`, error);
throw new Error('File download failed');
}
};
@@ -20,6 +20,11 @@ const getUserAndToken = async (authHeader: string | undefined) => {
return { user, token };
};
const LANG_V2_V1_MAP: Record<string, string> = {
'ZH-HANS': 'ZH',
'ZH-HANT': 'ZH-TW',
};
const getDeepLAPIKey = (keys: string | undefined) => {
const keyArray = keys?.split(',') ?? [];
return keyArray.length ? keyArray[Math.floor(Math.random() * keyArray.length)] : '';
@@ -52,12 +57,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const {
text,
source_lang: sourceLang = 'auto',
target_lang: targetLang = 'en',
source_lang: sourceLang = 'AUTO',
target_lang: targetLang = 'EN',
}: { text: string[]; source_lang: string; target_lang: string } = req.body;
try {
if (user && token) {
console.log('deeplApiUrl', deeplApiUrl);
const isV2Api = deeplApiUrl.endsWith('/v2/translate');
const requestBody = {
text: isV2Api ? text : (text[0] ?? ''),
source_lang: isV2Api ? sourceLang : (LANG_V2_V1_MAP[sourceLang] ?? sourceLang),
target_lang: isV2Api ? targetLang : (LANG_V2_V1_MAP[targetLang] ?? targetLang),
};
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
@@ -65,10 +75,18 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
'x-fingerprint': process.env['DEEPL_X_FINGERPRINT'] || '',
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body),
body: JSON.stringify(requestBody),
});
res.status(response.status);
res.json(await response.json());
const data = await response.json();
const result = {
...data,
translations: data.translations || [
{
text: data.data,
},
],
};
res.status(response.status).json(result);
} else {
const result = await deeplQuery({
text: text[0] ?? '',
+21 -7
View File
@@ -15,6 +15,7 @@ import {
formatTitle,
formatAuthors,
getFilename,
getPrimaryLanguage,
} from '@/utils/book';
import { partialMD5 } from '@/utils/md5';
import { BookDoc, DocumentLoader } from '@/libs/document';
@@ -31,6 +32,7 @@ import {
DEFAULT_MOBILE_VIEW_SETTINGS,
DEFAULT_SYSTEM_SETTINGS,
DEFAULT_CJK_VIEW_SETTINGS,
DEFAULT_MOBILE_READSETTINGS,
} from './constants';
import { getOSPlatform, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
@@ -57,6 +59,7 @@ export abstract class BaseAppService implements AppService {
hasSafeAreaInset = false;
hasHaptics = false;
hasSysFontsList = false;
hasUpdater = false;
abstract fs: FileSystem;
@@ -98,7 +101,10 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_SYSTEM_SETTINGS,
version: SYSTEM_SETTINGS_VERSION,
localBooksDir: await this.getInitBooksDir(),
globalReadSettings: DEFAULT_READSETTINGS,
globalReadSettings: {
...DEFAULT_READSETTINGS,
...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
},
globalViewSettings: {
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_STYLE,
@@ -136,7 +142,8 @@ export abstract class BaseAppService implements AppService {
async importBook(
// file might be:
// 1. absolute path for local file
// 1.1 absolute path for local file on Desktop
// 1.2 /private/var inbox file path on iOS
// 2. remote url
// 3. content provider uri
// 4. File object from browsers
@@ -192,6 +199,7 @@ export abstract class BaseAppService implements AppService {
format,
title: formatTitle(loadedBook.metadata.title),
author: formatAuthors(loadedBook.metadata.author, loadedBook.metadata.language),
primaryLanguage: getPrimaryLanguage(loadedBook.metadata.language),
createdAt: existingBook ? existingBook.createdAt : Date.now(),
uploadedAt: existingBook ? existingBook.uploadedAt : null,
deletedAt: transient ? Date.now() : null,
@@ -202,6 +210,7 @@ export abstract class BaseAppService implements AppService {
if (existingBook) {
existingBook.title = book.title;
existingBook.author = book.author;
existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage;
}
if (!(await this.fs.exists(getDir(book), 'Books'))) {
@@ -375,11 +384,16 @@ export abstract class BaseAppService implements AppService {
await this.fs.createDir(getDir(book), 'Books');
}
if (needDownCover) {
const lfp = getCoverFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
completedFiles.count++;
try {
if (needDownCover) {
const lfp = getCoverFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
completedFiles.count++;
}
} catch (error) {
// don't throw error here since some books may not have cover images at all
console.log('Failed to download cover file:', error);
}
if (needDownBook) {
+22 -1
View File
@@ -38,6 +38,9 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
autoCheckUpdates: true,
screenWakeLock: true,
autoImportBooksOnOpen: false,
libraryViewMode: 'grid',
librarySortBy: 'updated',
librarySortAscending: false,
lastSyncedAtBooks: 0,
lastSyncedAtConfigs: 0,
@@ -61,6 +64,11 @@ export const DEFAULT_READSETTINGS: ReadSettings = {
},
};
export const DEFAULT_MOBILE_READSETTINGS: Partial<ReadSettings> = {
sideBarWidth: '25%',
isSideBarPinned: false,
};
export const DEFAULT_BOOK_FONT: BookFont = {
serifFont: 'Bitter',
sansSerifFont: 'Roboto',
@@ -148,7 +156,11 @@ export const SERIF_FONTS = [
'Times New Roman',
];
export const CJK_SERIF_FONTS = [_('LXGW WenKai GB Screen'), _('LXGW WenKai TC')];
export const CJK_SERIF_FONTS = [
_('LXGW WenKai GB Screen'),
_('LXGW WenKai TC'),
_('GuanKiapTsingKhai-T'),
];
export const CJK_SANS_SERIF_FONTS = ['Noto Sans SC', 'Noto Sans TC'];
@@ -435,6 +447,7 @@ export const CJK_FONTS_PATTENS = new RegExp(
'Hei',
'Yan',
'Min',
'Khai',
'Yuan',
'Song',
'Ming',
@@ -468,6 +481,12 @@ export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web'
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
export const GITHUB_LATEST_DOWNLOAD = 'https://github.com/readest/readest/releases/latest/download';
export const READEST_UPDATER_FILE = `${GITHUB_LATEST_DOWNLOAD}/latest.json`;
export const READEST_CHANGELOG_FILE = `${GITHUB_LATEST_DOWNLOAD}/release-notes.json`;
export const SYNC_PROGRESS_INTERVAL_SEC = 60;
export const SYNC_NOTES_INTERVAL_SEC = 10;
export const SYNC_BOOKS_INTERVAL_SEC = 10;
@@ -570,3 +589,5 @@ export const TRANSLATED_LANGS = {
'zh-CN': '简体中文',
'zh-TW': '正體中文',
};
export const SUPPORTED_LANGS: Record<string, string> = { ...TRANSLATED_LANGS, zh: '中文' };
@@ -4,14 +4,11 @@ import { READEST_WEB_BASE_URL } from './constants';
declare global {
interface Window {
__READEST_CLI_ACCESS?: boolean;
__READEST_UPDATER_ACCESS?: boolean;
}
}
export const isTauriAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri';
export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
export const hasUpdater = () =>
window.__READEST_UPDATER_ACCESS === true && !process.env['NEXT_PUBLIC_DISABLE_UPDATER'];
export const hasCli = () => window.__READEST_CLI_ACCESS === true;
export const isPWA = () => window.matchMedia('(display-mode: standalone)').matches;
@@ -99,12 +99,12 @@ export const nativeFileSystem: FileSystem = {
}
} else {
const prefix = this.getPrefix(base);
if (prefix && OS_TYPE !== 'android') {
const absolutePath = path.startsWith('/') ? path : prefix ? await join(prefix, path) : null;
if (absolutePath && OS_TYPE !== 'android') {
// NOTE: RemoteFile currently performs about 2× faster than NativeFile
// due to an unresolved performance issue in Tauri (see tauri-apps/tauri#9190).
// Once the bug is resolved, we should switch back to using NativeFile.
// RemoteFile is not usable on Android due to unknown issues of range fetch with Android WebView.
const absolutePath = await join(prefix, path);
return await new RemoteFile(this.getURL(absolutePath), fname).open();
} else {
return await new NativeFile(fp, fname, base ? baseDir : null).open();
@@ -221,6 +221,7 @@ export class NativeAppService extends BaseAppService {
override hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android';
override hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android';
override hasSysFontsList = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
override hasUpdater = OS_TYPE !== 'ios' && !process.env['NEXT_PUBLIC_DISABLE_UPDATER'];
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
@@ -6,6 +6,7 @@ import { TTSGranularity } from '@/types/view';
import { TTSUtils } from './TTSUtils';
export class EdgeTTSClient implements TTSClient {
#primaryLang = 'en';
#rate = 1.0;
#pitch = 1.0;
#voice: TTSVoice | null = null;
@@ -50,7 +51,10 @@ export class EdgeTTSClient implements TTSClient {
preload = false,
): AsyncGenerator<TTSMessageEvent> {
const { marks } = parseSSMLMarks(ssml);
const lang = parseSSMLLang(ssml) || 'en';
let lang = parseSSMLLang(ssml) || 'en';
if (lang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
lang = this.#primaryLang;
}
let voiceId = 'en-US-AriaNeural';
if (!this.#voice || this.#currentVoiceLang !== lang) {
const preferredVoiceId = TTSUtils.getPreferredVoice('edge-tts', lang);
@@ -207,6 +211,10 @@ export class EdgeTTSClient implements TTSClient {
}
}
setPrimaryLang(lang: string) {
this.#primaryLang = lang;
}
async setRate(rate: number) {
// The Edge TTS API uses rate in [0.5 .. 2.0].
this.#rate = rate;
@@ -21,6 +21,7 @@ export interface TTSClient {
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
setPrimaryLang(lang: string): void;
setRate(rate: number): Promise<void>;
setPitch(pitch: number): Promise<void>;
setVoice(voice: string): Promise<void>;
@@ -219,6 +219,12 @@ export class TTSController extends EventTarget {
async setLang(lang: string) {
this.ttsLang = lang;
this.setPrimaryLang(lang);
}
async setPrimaryLang(lang: string) {
this.ttsEdgeClient.setPrimaryLang(lang);
this.ttsWebClient.setPrimaryLang(lang);
}
async setRate(rate: number) {
@@ -173,6 +173,7 @@ async function* speakWithMarks(
}
export class WebSpeechClient implements TTSClient {
#primaryLang = 'en';
#rate = 1.0;
#pitch = 1.0;
#voice: SpeechSynthesisVoice | null = null;
@@ -215,7 +216,10 @@ export class WebSpeechClient implements TTSClient {
// no need to preload for web speech
if (preload) return;
const lang = parseSSMLLang(ssml) || 'en';
let lang = parseSSMLLang(ssml) || 'en';
if (lang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
lang = this.#primaryLang;
}
if (!this.#voice || this.#currentVoiceLang !== lang) {
const preferredVoiceId = TTSUtils.getPreferredVoice('web-speech', lang);
const preferredVoice = this.#voices.find((v) => v.voiceURI === preferredVoiceId);
@@ -262,6 +266,10 @@ export class WebSpeechClient implements TTSClient {
this.#synth.cancel();
}
setPrimaryLang(lang: string) {
this.#primaryLang = lang;
}
async setRate(rate: number) {
// The Web Speech API uses utterance.rate in [0.1 .. 10],
this.#rate = rate;
+4 -2
View File
@@ -8,6 +8,7 @@ import { updateTocCFI, updateTocID } from '@/utils/toc';
import { useSettingsStore } from './settingsStore';
import { useBookDataStore } from './bookDataStore';
import { useLibraryStore } from './libraryStore';
import { getPrimaryLanguage } from '@/utils/book';
interface ViewState {
/* Unique key for each book view */
@@ -32,7 +33,6 @@ interface ReaderStore {
setBookKeys: (keys: string[]) => void;
setHoveredBookKey: (key: string | null) => void;
setBookmarkRibbonVisibility: (key: string, visible: boolean) => void;
setProgress: (
key: string,
location: string,
@@ -65,7 +65,6 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
hoveredBookKey: null,
setBookKeys: (keys: string[]) => set({ bookKeys: keys }),
setHoveredBookKey: (key: string | null) => set({ hoveredBookKey: key }),
getView: (key: string | null) => (key && get().viewStates[key]?.view) || null,
setView: (key: string, view) =>
set((state) => ({
@@ -130,6 +129,9 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
}, {});
updateTocCFI(bookDoc, bookDoc.toc, sections);
}
// Set the book's language for formerly imported books, newly imported books have this field set
book.primaryLanguage =
book.primaryLanguage ?? getPrimaryLanguage(bookDoc.metadata.language);
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
+10
View File
@@ -10,6 +10,11 @@ interface ThemeState {
systemIsDarkMode: boolean;
themeCode: ThemeCode;
isDarkMode: boolean;
systemUIVisible: boolean;
statusBarHeight: number;
setStatusBarHeight: (height: number) => void;
showSystemUI: () => void;
dismissSystemUI: () => void;
getIsDarkMode: () => boolean;
setThemeMode: (mode: ThemeMode) => void;
setThemeColor: (color: string) => void;
@@ -62,6 +67,11 @@ export const useThemeStore = create<ThemeState>((set, get) => {
systemIsDarkMode,
isDarkMode,
themeCode,
systemUIVisible: false,
statusBarHeight: 24,
showSystemUI: () => set({ systemUIVisible: true }),
dismissSystemUI: () => set({ systemUIVisible: false }),
setStatusBarHeight: (height: number) => set({ statusBarHeight: height }),
getIsDarkMode: () => get().isDarkMode,
setThemeMode: (mode) => {
if (typeof window !== 'undefined' && localStorage) {
-1
View File
@@ -220,7 +220,6 @@ foliate-view {
.pull-indicator {
position: fixed;
top: calc(64px + env(safe-area-inset-top));
width: 100%;
background-color: transparent;
display: flex;
+1
View File
@@ -27,6 +27,7 @@ export interface Book {
lastUpdated?: number; // deprecated in favor of updatedAt
progress?: [number, number]; // Add progress field: [current, total], 1-based page number
primaryLanguage?: string;
}
export interface BookGroupType {
+5
View File
@@ -2,6 +2,8 @@ import { CustomTheme } from '@/styles/themes';
import { HighlightColor, HighlightStyle, ViewSettings } from './book';
export type ThemeType = 'light' | 'dark' | 'auto';
export type LibraryViewModeType = 'grid' | 'list';
export type LibrarySortByType = 'title' | 'author' | 'updated' | 'created' | 'size' | 'format';
export interface ReadSettings {
sideBarWidth: string;
@@ -26,6 +28,9 @@ export interface SystemSettings {
autoCheckUpdates: boolean;
screenWakeLock: boolean;
autoImportBooksOnOpen: boolean;
libraryViewMode: LibraryViewModeType;
librarySortBy: LibrarySortByType;
librarySortAscending: boolean;
lastSyncedAtBooks: number;
lastSyncedAtConfigs: number;
+1
View File
@@ -34,6 +34,7 @@ export interface AppService {
hasSafeAreaInset: boolean;
hasHaptics: boolean;
hasSysFontsList: boolean;
hasUpdater: boolean;
isMobile: boolean;
isAppDataSandbox: boolean;
isAndroidApp: boolean;
+12 -5
View File
@@ -3,6 +3,7 @@ import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book';
import { getUserLang, isContentURI, isValidURL, makeSafeFilename } from './misc';
import { getStorageType } from './object';
import { getDirFromLanguage } from './rtl';
import { SUPPORTED_LANGS } from '@/services/constants';
export const getDir = (book: Book) => {
return `${book.hash}`;
@@ -107,15 +108,21 @@ export const formatPublisher = (publisher: string | LanguageMap) => {
return typeof publisher === 'string' ? publisher : formatLanguageMap(publisher);
};
export const formatLanguage = (lang: string | string[] | undefined) => {
return Array.isArray(lang) ? lang.join(', ') : lang;
const langCodeToLangName = (langCode: string) => {
return SUPPORTED_LANGS[langCode] || langCode.toUpperCase();
};
export const primaryLanguage = (lang: string | string[] | undefined) => {
export const formatLanguage = (lang: string | string[] | undefined): string => {
return Array.isArray(lang)
? lang.map(langCodeToLangName).join(', ')
: langCodeToLangName(lang || '');
};
export const getPrimaryLanguage = (lang: string | string[] | undefined) => {
return Array.isArray(lang) ? lang[0] : lang;
};
export const formatDate = (date: string | number | Date | undefined) => {
export const formatDate = (date: string | number | Date | null | undefined) => {
if (!date) return;
const userLang = getUserLang();
try {
@@ -159,6 +166,6 @@ export const getBookDirFromWritingMode = (writingMode: WritingMode) => {
};
export const getBookDirFromLanguage = (language: string | string[] | undefined) => {
const lang = primaryLanguage(language) || '';
const lang = getPrimaryLanguage(language) || '';
return getDirFromLanguage(lang);
};
+52
View File
@@ -14,6 +14,30 @@ export interface UseBackgroundAudioRequest {
enabled: boolean;
}
export interface InstallPackageRequest {
path: string;
}
export interface InstallPackageResponse {
success: boolean;
error?: string;
}
export interface SetSystemUIVisibilityRequest {
visible: boolean;
darkMode: boolean;
}
export interface SetSystemUIVisibilityResponse {
success: boolean;
error?: string;
}
export interface GetStatusBarHeightResponse {
height: number;
error?: string;
}
export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIResponse> {
const result = await invoke<CopyURIResponse>('plugin:native-bridge|copy_uri_to_path', {
payload: request,
@@ -27,3 +51,31 @@ export async function invokeUseBackgroundAudio(request: UseBackgroundAudioReques
payload: request,
});
}
export async function installPackage(
request: InstallPackageRequest,
): Promise<InstallPackageResponse> {
const result = await invoke<InstallPackageResponse>('plugin:native-bridge|install_package', {
payload: request,
});
return result;
}
export async function setSystemUIVisibility(
request: SetSystemUIVisibilityRequest,
): Promise<SetSystemUIVisibilityResponse> {
const result = await invoke<SetSystemUIVisibilityResponse>(
'plugin:native-bridge|set_system_ui_visibility',
{
payload: request,
},
);
return result;
}
export async function getStatusBarHeight(): Promise<GetStatusBarHeightResponse> {
const result = await invoke<GetStatusBarHeightResponse>(
'plugin:native-bridge|get_status_bar_height',
);
return result;
}
+5 -2
View File
@@ -110,6 +110,7 @@ const getFontStyles = (
const getAdditionalFontLinks = () => `
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-wen-kai-gb-screen@1.0.6/font.min.css" crossorigin="anonymous">
<link rel='stylesheet' href='https://fontsapi.zeoseven.com/431/main/result.css' crossorigin="anonymous"/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=LXGW+WenKai+TC&display=swap" crossorigin="anonymous">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC&family=Noto+Sans+TC&display=swap" crossorigin="anonymous">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Serif+JP&display=swap" crossorigin="anonymous">
@@ -419,6 +420,8 @@ export const mountAdditionalFonts = (document: Document) => {
};
export const transformStylesheet = (css: string) => {
const isMobile = ['ios', 'android'].includes(getOSPlatform());
const fontScale = isMobile ? 1.25 : 1;
// replace absolute font sizes with rem units
// replace hardcoded colors
return css
@@ -431,11 +434,11 @@ export const transformStylesheet = (css: string) => {
.replace(/font-size\s*:\s*xx-large/gi, 'font-size: 2rem')
.replace(/font-size\s*:\s*xxx-large/gi, 'font-size: 3rem')
.replace(/font-size\s*:\s*(\d+(?:\.\d+)?)px/gi, (_, px) => {
const rem = parseFloat(px) / 16;
const rem = parseFloat(px) / fontScale / 16;
return `font-size: ${rem}rem`;
})
.replace(/font-size\s*:\s*(\d+(?:\.\d+)?)pt/gi, (_, pt) => {
const rem = parseFloat(pt) / 12;
const rem = parseFloat(pt) / fontScale / 12;
return `font-size: ${rem}rem`;
})
.replace(/[\s;]color\s*:\s*#000000/gi, 'color: var(--theme-fg-color)')

Some files were not shown because too many files have changed in this diff Show More