Compare commits

...

31 Commits

Author SHA1 Message Date
Huang Xin bd6ef7fe8e release: version 0.9.11
* ui: tweaks on header bar layout

* release: build arm64 linux binaries
2025-02-14 02:31:03 +01:00
Huang Xin b33d85f439 ui: fix layout in mobile platforms (#371) 2025-02-14 00:19:25 +01:00
Huang Xin 95a69d6bdf ux: polish ux of grouping dialog (#370) 2025-02-13 22:49:59 +01:00
Huang Xin ed9a0c6f20 ux: hide action bar when modal is shown (#369) 2025-02-13 21:34:56 +01:00
Huang Xin 71b075dfa8 feat: organize the library with groups (#368) 2025-02-13 18:45:57 +01:00
Huang Xin 937832a8ee ui: fix toast position on mobile devices (#350) 2025-02-10 23:15:43 +01:00
Huang Xin 881a54f155 cjk: default text indent for CJK and fix unusual OEBPS naming, closes #346 (#349) 2025-02-10 22:05:58 +01:00
Huang Xin 2dd0a20925 fix: reintroduce hold detection to avoid unwanted single click (#348) 2025-02-10 20:31:27 +01:00
Huang Xin b7ae3f29d9 chore: support deployment with open-next on cloudflare (#347) 2025-02-10 19:28:06 +01:00
Huang Xin 609ddb776b ux: disable double click timeout on mobile devices for more performant page turning (#339) 2025-02-09 21:36:08 +01:00
Huang Xin 7847360588 cjk: set full justification by default for CJK even on mobile devices (#338) 2025-02-09 20:32:30 +01:00
Huang Xin 8e7240bb9b ux: process synced books in update time ascending order (#337) 2025-02-09 18:58:56 +01:00
Huang Xin effbc08561 mobile: use sticky position for action bars for mobile compatibility (#335) 2025-02-09 18:35:24 +01:00
Huang Xin 6b057ebc14 feat: add override option for paragraph spacing settings, closes #329 (#334) 2025-02-09 16:05:37 +01:00
Huang Xin 26b64adca6 fix: add blob CSP for font and don't override <p> margin by default (#327)
* android: add fullscreen theme

* fix: add blob CSP for font and don't override <p> margin by default, closes #320
2025-02-09 00:54:06 +01:00
Huang Xin 9577f6a026 android: compatibility for Android 8 (#326) 2025-02-08 20:02:05 +01:00
Huang Xin 25da9210e2 readme: update roadmap and badge-platforms (#325) 2025-02-08 19:04:24 +01:00
Huang Xin 8b80c52c54 release: version 0.9.10 (#324) 2025-02-08 18:53:50 +01:00
Huang Xin d3d496b583 mobile: fix TTS icon position on iOS (#322) 2025-02-08 18:21:47 +01:00
Huang Xin ad1365fa23 mobile: fix layout of book detail modal and refresh indicator (#321) 2025-02-08 17:57:49 +01:00
Huang Xin 0ba3b510ce fix: importing fb2.zip file and CSP for embedded cover image (#319)
* fix: don't set window shadow below Win 11

* fix: importing fb2.zip file and CSP for embedded cover image
2025-02-08 16:19:54 +01:00
Huang Xin 1da0817145 mobile: fix sidebar height on iOS (#318)
* mobile: fix sidebar height on iOS

* fix: tts should not resume playing when closing book
2025-02-08 13:32:15 +01:00
Huang Xin 151509b84c mobile: fix iOS safe area insets (#317) 2025-02-08 03:38:17 +01:00
Huang Xin 282b129bf5 mobile: save book config when progress changes on Android (#315) 2025-02-07 23:22:55 +01:00
Huang Xin 7dfd7b9ca2 mobile: fix hasSafeAreaInset as PWA is only for WebAppService (#314) 2025-02-07 22:35:24 +01:00
Huang Xin ce00c3e923 mobile: immersive UI for android (#313) 2025-02-07 22:23:42 +01:00
Huang Xin e2e3418412 mobile: support safe area insets for mobile platforms (#312) 2025-02-07 22:15:18 +01:00
Huang Xin c79657a1ee fix: don't autoscroll to current item in TOC when TTS is on (#311) 2025-02-07 19:08:50 +01:00
Huang Xin dee1e28a61 feat: add options to adjust paragraph margin, word spacing, letter spacing, text indent and font weight (#310) 2025-02-07 18:23:12 +01:00
Huang Xin adfdb10d8e fix: compatibility fix for some formerly imported corrupted metadata (#307)
* fix: compatibility fix for some formerly imported corrupted metadata

* fix: use font family names of sys fonts
2025-02-07 02:54:29 +01:00
Huang Xin 023ba663e3 fix: stop showing annotator popup when speaking with TTS on Android (#306)
* fix: stop showing annotator popup when speaking with TTS on Android

* chore: get rid of the warning complains of rustc
2025-02-07 01:20:25 +01:00
107 changed files with 2146 additions and 534 deletions
+90 -1
View File
@@ -73,7 +73,7 @@ jobs:
args: '--target aarch64-pc-windows-msvc --bundles nsis'
runs-on: ${{ matrix.config.os }}
timeout-minutes: 20
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
@@ -168,6 +168,95 @@ jobs:
echo "Uploading $bin_file to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
build-tauri-linux-arm:
if: false
permissions:
contents: write
runs-on: ubuntu-22.04
needs: [get-release]
strategy:
matrix:
arch: [aarch64, armv7l]
include:
- arch: aarch64
cpu: cortex-a72
base_image: https://dietpi.com/downloads/images/DietPi_RPi-ARMv8-Bookworm.img.xz
deb: arm64
appimage: aarch64
- arch: armv7l
cpu: cortex-a53
deb: armhfp
appimage: armhf
base_image: https://dietpi.com/downloads/images/DietPi_RPi-ARMv7-Bookworm.img.xz
steps:
- uses: actions/checkout@v4
- name: initialize git submodules
run: git submodule update --init --recursive
- name: create .env.local file for Next.js
run: |
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
- name: copy .env.local to apps/readest-app
run: cp .env.local apps/readest-app/.env.local
- name: cache rust build artifacts
uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- name: build app
uses: pguyot/arm-runner-action@v2.6.5
with:
base_image: ${{ matrix.base_image }}
cpu: ${{ matrix.cpu }}
bind_mount_repository: true
image_additional_mb: 10240
optimize_image: no
#exit_on_fail: no
commands: |
# Prevent Rust from complaining about $HOME not matching eid home
export HOME=/root
# Workaround to CI worker being stuck on Updating crates.io index
export CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
# Install setup prerequisites
apt-get update -y --allow-releaseinfo-change
apt-get autoremove -y
apt-get install -y --no-install-recommends --no-install-suggests pkg-config libfontconfig-dev curl libwebkit2gtk-4.1-dev build-essential libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf libfuse2 file
curl https://sh.rustup.rs -sSf | sh -s -- -y
. "$HOME/.cargo/env"
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash
apt-get install -y nodejs
npm install -g pnpm@latest-10
# Install frontend dependencies
pnpm install
# Copy pdfjs-dist to public directory
pnpm --filter @readest/readest-app setup-pdfjs
# Build the application
pnpm tauri build
- name: upload arm bundles
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
version=${{ needs.get-release.outputs.release_version }}
gh release upload ${{ needs.get-release.outputs.release_tag }} target/release/bundle/deb/Readest_${version}_${{ matrix.deb }}.deb --clobber
gh release upload ${{ needs.get-release.outputs.release_tag }} target/release/bundle/appimage/Readest_${version}_${{ matrix.appimage }}.AppImage --clobber
update-release:
permissions:
contents: write
+22 -5
View File
@@ -5,14 +5,14 @@
<h1>Readest</h1>
<br>
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to offer a seamless cross-platform experience on macOS, Windows, Linux and Web, with support for mobile platforms coming soon.
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to deliver a smooth, cross-platform experience across macOS, Windows, Linux, Android, iOS, and the Web.
[![Website][badge-website]][link-website]
[![Web App][badge-web-app]][link-web-readest]
[![OS][badge-platforms]][link-website]
<br>
[![][badge-hellogithub]][link-hellogithub]
[![][badge-discord]][link-discord]
<br>
[![AGPL Licence][badge-license]](LICENSE)
[![Latest release][badge-release]][link-gh-releases]
[![Last commit][badge-last-commit]][link-gh-commits]
@@ -62,9 +62,8 @@
| **Feature** | **Description** | **Priority** |
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
| **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. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
@@ -153,16 +152,34 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
pnpm tauri dev
```
For Android:
```bash
# Initialize the Android environment (run once)
pnpm tauri android init
pnpm tauri android dev
# or if you want to dev on a real device
pnpm tauri android dev --host
```
For iOS:
```bash
# Set up the iOS environment (run once)
pnpm tauri ios init
pnpm tauri ios dev
# or if you want to dev on a real device
pnpm tauri ios dev --host
```
### 5. Build for Production
```bash
pnpm tauri build
pnpm tauri android build
pnpm tauri ios build
```
### 6. Setup dev environment with Nix
@@ -217,7 +234,7 @@ The following fonts are utilized in this software, either bundled within the app
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Web%2C%20PWA-green
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Android%2C%20iOS%2C%20Web%2C%20PWA-green
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
+7
View File
@@ -6,3 +6,10 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
DEEPL_PRO_API_KEY=YOUR_DEEPL_PRO_API_KEY
DEEPL_FREE_API_KEY=YOUR_DEEPL_FREE_API_KEY
R2_TOKEN_VALUE=YOUR_R2_TOKEN_VALUE
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
R2_BUCKET_NAME=YOUR_R2_BUCKET_NAME
R2_ACCOUNT_ID=YOUR_R2_ACCOUNT_ID
R2_REGION=YOUR_R2_REGION
+4
View File
@@ -40,6 +40,10 @@ tauri.*.conf.json
# vercel
.vercel
# open-next
.open-next
.wrangler
# typescript
*.tsbuildinfo
next-env.d.ts
+13
View File
@@ -19,6 +19,19 @@ const nextConfig = {
// Configure assetPrefix or else the server won't properly resolve your assets.
assetPrefix: '',
reactStrictMode: true,
async headers() {
return [
{
source: '/.well-known/apple-app-site-association',
headers: [
{
key: 'Content-Type',
value: 'application/json',
},
],
},
];
},
};
const withPWA = withPWAInit({
+29
View File
@@ -0,0 +1,29 @@
// @ts-nocheck
// default open-next.config.ts file created by @opennextjs/cloudflare
import type { OpenNextConfig } from '@opennextjs/aws/types/open-next.js';
import cache from '@opennextjs/cloudflare/kv-cache';
const config: OpenNextConfig = {
default: {
override: {
wrapper: 'cloudflare-node',
converter: 'edge',
// set `incrementalCache` to "dummy" to disable KV cache
incrementalCache: async () => cache,
tagCache: 'dummy',
queue: 'dummy',
},
},
middleware: {
external: true,
override: {
wrapper: 'cloudflare-edge',
converter: 'edge',
proxyExternalRequest: 'fetch',
},
},
};
export default config;
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.9",
"version": "0.9.11",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -26,7 +26,10 @@
"build-macos-universial": "dotenv -e .env.tauri.local -e .env.apple-nonstore.local -- tauri build -t universal-apple-darwin --bundles dmg",
"build-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore.conf.json",
"build-macos-universial-appstore-dev": "dotenv -e .env.tauri.local -e .env.apple-appstore-dev.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore-dev.conf.json",
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh"
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler dev",
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler deploy",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.735.0",
@@ -57,7 +57,6 @@
"Language:": "Sprache:",
"Layout": "Layout",
"Light Mode": "Hellmodus",
"Line Height": "Zeilenhöhe",
"Loading...": "Wird geladen...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Angemeldet",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Buch gelöscht: {{title}}",
"Failed to delete book: {{title}}": "Fehler beim Löschen des Buches: {{title}}",
"Check Updates on Start": "Aktualisierungen beim Start prüfen",
"Insufficient storage quota": "Unzureichendes Speicherkontingent"
"Insufficient storage quota": "Unzureichendes Speicherkontingent",
"Font Weight": "Schriftgewicht",
"Line Spacing": "Zeilenabstand",
"Word Spacing": "Wortabstand",
"Letter Spacing": "Buchstabenabstand",
"Text Indent": "Texteinzug",
"Paragraph Margin": "Absatzrand",
"Override Book Layout": "Buch-Layout überschreiben",
"Add to Group": "Zur Gruppe hinzufügen",
"Untitled Group": "Unbenannte Gruppe",
"Group Books": "Bücher gruppieren",
"Remove From Group": "Aus Gruppe entfernen",
"Create New Group": "Neue Gruppe erstellen"
}
@@ -57,7 +57,6 @@
"Language:": "Γλώσσα:",
"Layout": "Διάταξη",
"Light Mode": "Φωτεινή λειτουργία",
"Line Height": "Ύψος γραμμής",
"Loading...": "Φόρτωση...",
"Loc. {{currentPage}} / {{totalPage}}": "Θέση {{currentPage}} / {{totalPage}}",
"Logged in": "Συνδεδεμένος",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Το βιβλίο με τίτλο {{title}} διαγράφηκε",
"Failed to delete book: {{title}}": "Αποτυχία διαγραφής βιβλίου: {{title}}",
"Check Updates on Start": "Έλεγχος ενημερώσεων κατά την εκκίνηση",
"Insufficient storage quota": "Ανεπαρκές όριο αποθήκευσης"
"Insufficient storage quota": "Ανεπαρκές όριο αποθήκευσης",
"Font Weight": "Βάρος γραμματοσειράς",
"Line Spacing": "Διάκενο γραμμών",
"Word Spacing": "Διάκενο λέξεων",
"Letter Spacing": "Διάκενο γραμμάτων",
"Text Indent": "Εσοχή κειμένου",
"Paragraph Margin": "Περιθώριο παραγράφου",
"Override Book Layout": "Παράκαμψη διάταξης βιβλίου",
"Add to Group": "Προσθήκη στην ομάδα",
"Untitled Group": "Ομάδα χωρίς τίτλο",
"Group Books": "Ομαδοποίηση βιβλίων",
"Remove From Group": "Αφαίρεση από την ομάδα",
"Create New Group": "Δημιουργία νέας ομάδας"
}
@@ -57,7 +57,6 @@
"Language:": "Idioma:",
"Layout": "Diseño",
"Light Mode": "Modo claro",
"Line Height": "Altura de línea",
"Loading...": "Cargando...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Sesión iniciada",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Libro eliminado: {{title}}",
"Failed to delete book: {{title}}": "Error al eliminar libro: {{title}}",
"Check Updates on Start": "Comprobar actualizaciones al iniciar",
"Insufficient storage quota": "Cuota de almacenamiento insuficiente"
"Insufficient storage quota": "Cuota de almacenamiento insuficiente",
"Font Weight": "Peso de la fuente",
"Line Spacing": "Espaciado de línea",
"Word Spacing": "Espaciado de palabras",
"Letter Spacing": "Espaciado de letras",
"Text Indent": "Sangría de texto",
"Paragraph Margin": "Margen de párrafo",
"Override Book Layout": "Sobrescribir diseño del libro",
"Add to Group": "Añadir a grupo",
"Untitled Group": "Grupo sin título",
"Group Books": "Agrupar libros",
"Remove From Group": "Eliminar del grupo",
"Create New Group": "Crear nuevo grupo"
}
@@ -57,7 +57,6 @@
"Language:": "Langue :",
"Layout": "Mise en page",
"Light Mode": "Mode clair",
"Line Height": "Hauteur de ligne",
"Loading...": "Chargement...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Connecté",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Livre supprimé : {{title}}",
"Failed to delete book: {{title}}": "Échec de la suppression du livre : {{title}}",
"Check Updates on Start": "Vérifier les mises à jour au démarrage",
"Insufficient storage quota": "Quota de stockage insuffisant"
"Insufficient storage quota": "Quota de stockage insuffisant",
"Font Weight": "Poids de la police",
"Line Spacing": "Interligne",
"Word Spacing": "Espacement des mots",
"Letter Spacing": "Espacement des lettres",
"Text Indent": "Retrait du texte",
"Paragraph Margin": "Marge de paragraphe",
"Override Book Layout": "Remplacer la mise en page du livre",
"Add to Group": "Ajouter au groupe",
"Untitled Group": "Groupe sans titre",
"Group Books": "Regrouper les livres",
"Remove From Group": "Retirer du groupe",
"Create New Group": "Créer un nouveau groupe"
}
@@ -57,7 +57,6 @@
"Language:": "भाषा:",
"Layout": "लेआउट",
"Light Mode": "लाइट मोड",
"Line Height": "लाइन हाइट",
"Loading...": "लोड हो रहा है...",
"Loc. {{currentPage}} / {{totalPage}}": "स्थान {{currentPage}} / {{totalPage}}",
"Logged in": "लॉग इन किया",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "पुस्तक हटाई गई: {{title}}",
"Failed to delete book: {{title}}": "पुस्तक हटाने में विफल: {{title}}",
"Check Updates on Start": "शुरू में अपडेट जांचें",
"Insufficient storage quota": "अपर्याप्त स्टोरेज कोटा"
"Insufficient storage quota": "अपर्याप्त स्टोरेज कोटा",
"Font Weight": "फ़ॉन्ट वजन",
"Line Spacing": "लाइन स्पेसिंग",
"Word Spacing": "शब्द स्पेसिंग",
"Letter Spacing": "अक्षर स्पेसिंग",
"Text Indent": "टेक्स्ट इंडेंट",
"Paragraph Margin": "पैराग्राफ मार्जिन",
"Override Book Layout": "पुस्तक लेआउट ओवरराइड करें",
"Add to Group": "समूह में जोड़ें",
"Untitled Group": "अभिनामित समूह",
"Group Books": "समूह पुस्तकें",
"Remove From Group": "समूह से हटाएं",
"Create New Group": "नया समूह बनाएं"
}
@@ -57,7 +57,6 @@
"Language:": "Bahasa:",
"Layout": "Tata Letak",
"Light Mode": "Mode Terang",
"Line Height": "Tinggi Baris",
"Loading...": "Memuat...",
"Loc. {{currentPage}} / {{totalPage}}": "Lok. {{currentPage}} / {{totalPage}}",
"Logged in": "Sudah Masuk",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Buku dihapus: {{title}}",
"Failed to delete book: {{title}}": "Gagal menghapus buku: {{title}}",
"Check Updates on Start": "Periksa Pembaruan saat Memulai",
"Insufficient storage quota": "Kuota penyimpanan tidak mencukupi"
"Insufficient storage quota": "Kuota penyimpanan tidak mencukupi",
"Font Weight": "Berat Font",
"Line Spacing": "Spasi Baris",
"Word Spacing": "Spasi Kata",
"Letter Spacing": "Spasi Huruf",
"Text Indent": "Indentasi Teks",
"Paragraph Margin": "Margin Paragraf",
"Override Book Layout": "Ganti Tata Letak Buku",
"Add to Group": "Tambahkan ke Grup",
"Untitled Group": "Grup Tanpa Judul",
"Group Books": "Grupkan Buku",
"Remove From Group": "Hapus dari Grup",
"Create New Group": "Buat Grup Baru"
}
@@ -57,7 +57,6 @@
"Language:": "Lingua:",
"Layout": "Layout",
"Light Mode": "Modalità chiara",
"Line Height": "Altezza riga",
"Loading...": "Caricamento...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Accesso effettuato",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Libro eliminato: {{title}}",
"Failed to delete book: {{title}}": "Eliminazione libro non riuscita: {{title}}",
"Check Updates on Start": "Controlla aggiornamenti all'avvio",
"Insufficient storage quota": "Quota di archiviazione insufficiente"
"Insufficient storage quota": "Quota di archiviazione insufficiente",
"Font Weight": "Peso font",
"Line Spacing": "Interlinea",
"Word Spacing": "Spaziatura parole",
"Letter Spacing": "Spaziatura lettere",
"Text Indent": "Rientro testo",
"Paragraph Margin": "Margine paragrafo",
"Override Book Layout": "Sovrascrivi layout libro",
"Add to Group": "Aggiungi al gruppo",
"Untitled Group": "Gruppo senza titolo",
"Group Books": "Raggruppa libri",
"Remove From Group": "Rimuovi dal",
"Create New Group": "Crea nuovo gruppo"
}
@@ -57,7 +57,6 @@
"Language:": "言語:",
"Layout": "レイアウト",
"Light Mode": "ライトモード",
"Line Height": "行間",
"Loading...": "読み込み中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "ログイン済み",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "書籍が削除されました:{{title}}",
"Failed to delete book: {{title}}": "書籍の削除に失敗しました:{{title}}",
"Check Updates on Start": "開始時に更新を確認",
"Insufficient storage quota": "ストレージクォータが不足しています"
"Insufficient storage quota": "ストレージクォータが不足しています",
"Font Weight": "フォントウェイト",
"Line Spacing": "行間",
"Word Spacing": "単語間隔",
"Letter Spacing": "文字間隔",
"Text Indent": "テキストインデント",
"Paragraph Margin": "段落マージン",
"Override Book Layout": "書籍のレイアウトを上書き",
"Add to Group": "グループに追加",
"Untitled Group": "無題のグループ",
"Group Books": "書籍をグループ化",
"Remove From Group": "グループから削除",
"Create New Group": "新しいグループを作成"
}
@@ -57,7 +57,6 @@
"Language:": "언어:",
"Layout": "레이아웃",
"Light Mode": "라이트 모드",
"Line Height": "줄 간격",
"Loading...": "로딩 중...",
"Loc. {{currentPage}} / {{totalPage}}": "위치 {{currentPage}} / {{totalPage}}",
"Logged in": "로그인됨",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "책 삭제됨: {{title}}",
"Failed to delete book: {{title}}": "책 삭제 실패: {{title}}",
"Check Updates on Start": "시작 시 업데이트 확인",
"Insufficient storage quota": "저장소 할당량 부족"
"Insufficient storage quota": "저장소 할당량 부족",
"Font Weight": "글꼴 두께",
"Line Spacing": "줄 간격",
"Word Spacing": "단어 간격",
"Letter Spacing": "글자 간격",
"Text Indent": "들여쓰기",
"Paragraph Margin": "단락 여백",
"Override Book Layout": "책 레이아웃 덮어쓰기",
"Add to Group": "그룹에 추가",
"Untitled Group": "제목 없는 그룹",
"Group Books": "책 그룹화",
"Remove From Group": "그룹에서 제거",
"Create New Group": "새 그룹 만들기"
}
@@ -57,7 +57,6 @@
"Language:": "Język:",
"Layout": "Układ",
"Light Mode": "Tryb jasny",
"Line Height": "Wysokość linii",
"Loading...": "Ładowanie...",
"Loc. {{currentPage}} / {{totalPage}}": "Poz. {{currentPage}} / {{totalPage}}",
"Logged in": "Zalogowano",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Książka usunięta: {{title}}",
"Failed to delete book: {{title}}": "Nie udało się usunąć książki: {{title}}",
"Check Updates on Start": "Sprawdź aktualizacje przy starcie",
"Insufficient storage quota": "Niewystarczająca kwota przechowywania"
"Insufficient storage quota": "Niewystarczająca kwota przechowywania",
"Font Weight": "Grubość czcionki",
"Line Spacing": "Interlinia",
"Word Spacing": "Odstęp między wyrazami",
"Letter Spacing": "Odstęp między literami",
"Text Indent": "Wcięcie tekstu",
"Paragraph Margin": "Margines akapitu",
"Override Book Layout": "Nadpisz układ książki",
"Add to Group": "Dodaj do grupy",
"Untitled Group": "Grupa bez tytułu",
"Group Books": "Grupuj książki",
"Remove From Group": "Usuń z grupy",
"Create New Group": "Utwórz nową grupę"
}
@@ -57,7 +57,6 @@
"Language:": "Idioma:",
"Layout": "Layout",
"Light Mode": "Modo Claro",
"Line Height": "Altura da Linha",
"Loading...": "Carregando...",
"Loc. {{currentPage}} / {{totalPage}}": "Loc. {{currentPage}} / {{totalPage}}",
"Logged in": "Conectado",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Livro excluído: {{title}}",
"Failed to delete book: {{title}}": "Falha ao excluir livro: {{title}}",
"Check Updates on Start": "Verificar Atualizações ao Iniciar",
"Insufficient storage quota": "Cota de armazenamento insuficiente"
"Insufficient storage quota": "Cota de armazenamento insuficiente",
"Font Weight": "Peso da Fonte",
"Line Spacing": "Espaçamento de Linha",
"Word Spacing": "Espaçamento de Palavra",
"Letter Spacing": "Espaçamento de Letra",
"Text Indent": "Recuo de Texto",
"Paragraph Margin": "Margem de Parágrafo",
"Override Book Layout": "Sobrescrever Layout do Livro",
"Add to Group": "Adicionar ao Grupo",
"Untitled Group": "Grupo Sem Título",
"Group Books": "Agrupar Livros",
"Remove From Group": "Remover do Grupo",
"Create New Group": "Criar Novo Grupo"
}
@@ -57,7 +57,6 @@
"Language:": "Язык:",
"Layout": "Макет",
"Light Mode": "Светлая тема",
"Line Height": "Межстрочный интервал",
"Loading...": "Загрузка...",
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Выполнен вход",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Книга удалена: {{title}}",
"Failed to delete book: {{title}}": "Не удалось удалить книгу: {{title}}",
"Check Updates on Start": "Проверять обновления при запуске",
"Insufficient storage quota": "Недостаточно квоты хранилища"
"Insufficient storage quota": "Недостаточно квоты хранилища",
"Font Weight": "Насыщенность шрифта",
"Line Spacing": "Межстрочный интервал",
"Word Spacing": "Межсловный интервал",
"Letter Spacing": "Межбуквенный интервал",
"Text Indent": "Отступ текста",
"Paragraph Margin": "Отступ абзаца",
"Override Book Layout": "Переопределить макет книги",
"Add to Group": "Добавить в группу",
"Untitled Group": "Группа без названия",
"Group Books": "Группировать книги",
"Remove From Group": "Удалить из группы",
"Create New Group": "Создать новую группу"
}
@@ -57,7 +57,6 @@
"Language:": "Dil:",
"Layout": "Düzen",
"Light Mode": "Aydınlık Mod",
"Line Height": "Satır Yüksekliği",
"Loading...": "Yükleniyor...",
"Loc. {{currentPage}} / {{totalPage}}": "Konum {{currentPage}} / {{totalPage}}",
"Logged in": "Giriş Yapıldı",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Kitap silindi: {{title}}",
"Failed to delete book: {{title}}": "Kitap silinemedi: {{title}}",
"Check Updates on Start": "Başlangıçta Güncellemeleri Kontrol Et",
"Insufficient storage quota": "Yetersiz depolama kotası"
"Insufficient storage quota": "Yetersiz depolama kotası",
"Font Weight": "Yazı Tipi Ağırlığı",
"Line Spacing": "Satır Aralığı",
"Word Spacing": "Kelime Aralığı",
"Letter Spacing": "Harf Aralığı",
"Text Indent": "Metin Girintisi",
"Paragraph Margin": "Paragraf Kenarı",
"Override Book Layout": "Kitap Düzenini Geçersiz Kıl",
"Add to Group": "Gruba Ekle",
"Untitled Group": "Başlıksız Grup",
"Group Books": "Kitapları Grupla",
"Remove From Group": "Grubu Kaldır",
"Create New Group": "Yeni Grup Oluştur"
}
@@ -57,7 +57,6 @@
"Language:": "Мова:",
"Layout": "Макет",
"Light Mode": "Світлий режим",
"Line Height": "Висота рядка",
"Loading...": "Завантаження...",
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Увійшли",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Книгу видалено: {{title}}",
"Failed to delete book: {{title}}": "Не вдалося видалити книгу: {{title}}",
"Check Updates on Start": "Перевіряти оновлення при запуску",
"Insufficient storage quota": "Недостатньо квоти сховища"
"Insufficient storage quota": "Недостатньо квоти сховища",
"Font Weight": "Насиченість шрифту",
"Line Spacing": "Міжрядковий інтервал",
"Word Spacing": "Міжслівний інтервал",
"Letter Spacing": "Міжбуквений інтервал",
"Text Indent": "Відступ тексту",
"Paragraph Margin": "Відступ абзацу",
"Override Book Layout": "Перевизначити макет книги",
"Add to Group": "Додати до групи",
"Untitled Group": "Група без назви",
"Group Books": "Групувати книги",
"Remove From Group": "Видалити з групи",
"Create New Group": "Створити нову групу"
}
@@ -57,7 +57,6 @@
"Language:": "Ngôn ngữ:",
"Layout": "Bố cục",
"Light Mode": "Chế độ sáng",
"Line Height": "Độ cao dòng",
"Loading...": "Đang tải...",
"Loc. {{currentPage}} / {{totalPage}}": "Vị trí {{currentPage}} / {{totalPage}}",
"Logged in": "Đã đăng nhập",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "Sách đã xóa: {{title}}",
"Failed to delete book: {{title}}": "Không thể xóa sách: {{title}}",
"Check Updates on Start": "Kiểm tra cập nhật khi khởi động",
"Insufficient storage quota": "Không đủ lượng lưu trữ"
"Insufficient storage quota": "Không đủ lượng lưu trữ",
"Font Weight": "Độ đậm của phông chữ",
"Line Spacing": "Khoảng cách dòng",
"Word Spacing": "Khoảng cách từ",
"Letter Spacing": "Khoảng cách chữ",
"Text Indent": "Thụt lề",
"Paragraph Margin": "Lề đoạn",
"Override Book Layout": "Ghi đè bố cục sách",
"Add to Group": "Thêm vào nhóm",
"Untitled Group": "Nhóm không có tiêu đề",
"Group Books": "Nhóm sách",
"Remove From Group": "Xóa khỏi nhóm",
"Create New Group": "Tạo nhóm mới"
}
@@ -57,7 +57,6 @@
"Language:": "语言",
"Layout": "布局",
"Light Mode": "浅色主题",
"Line Height": "行高",
"Loading...": "加载中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登录",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "书籍已删除:{{title}}",
"Failed to delete book: {{title}}": "删除书籍失败:{{title}}",
"Check Updates on Start": "启动时检查更新",
"Insufficient storage quota": "云存储空间不足"
"Insufficient storage quota": "云存储空间不足",
"Font Weight": "字重",
"Line Spacing": "行间距",
"Word Spacing": "词间距",
"Letter Spacing": "字间距",
"Text Indent": "首行缩进",
"Paragraph Margin": "段间距",
"Override Book Layout": "覆盖版面布局",
"Add to Group": "添加到分组",
"Untitled Group": "无标题分组",
"Group Books": "分组书籍",
"Remove From Group": "从分组中移除",
"Create New Group": "创建新分组"
}
@@ -57,7 +57,6 @@
"Language:": "語言",
"Layout": "版面",
"Light Mode": "淺色主題",
"Line Height": "行高",
"Loading...": "載入中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登入",
@@ -150,5 +149,17 @@
"Book deleted: {{title}}": "書籍已刪除:{{title}}",
"Failed to delete book: {{title}}": "書籍刪除失敗:{{title}}",
"Check Updates on Start": "啟動時檢查更新",
"Insufficient storage quota": "雲存儲空間不足"
"Insufficient storage quota": "雲存儲空間不足",
"Font Weight": "字重",
"Line Spacing": "行間距",
"Word Spacing": "字間距",
"Letter Spacing": "字間距",
"Text Indent": "首行縮進",
"Paragraph Margin": "段間距",
"Override Book Layout": "覆蓋版面佈局",
"Add to Group": "添加到組",
"Untitled Group": "無標題組",
"Group Books": "分組書籍",
"Remove From Group": "從組中移除",
"Create New Group": "創建新組"
}
+15
View File
@@ -1,5 +1,20 @@
{
"releases": {
"0.9.11": {
"date": "2025-02-14",
"notes": [
"Organize the library with customizable groups/folders",
"Alpha release for Android and iOS platforms",
"Various fixes and enhancements on Android and iOS platforms"
]
},
"0.9.10": {
"date": "2025-02-08",
"notes": [
"Add options to adjust paragraph margin, word spacing, letter spacing, text indent and font weight",
"Alpha release for Android and iOS platforms"
]
},
"0.9.9": {
"date": "2025-02-06",
"notes": [
@@ -0,0 +1,80 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 34
namespace = "com.bilingify.readest"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.bilingify.readest"
minSdk = 26
targetSdk = 34
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
signingConfigs {
create("release") {
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["password"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["password"] as String
}
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
signingConfig = signingConfigs.getByName("release")
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.6.1")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.activity:activity-ktx:1.9.3")
implementation("com.google.android.material:material:1.8.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
@@ -0,0 +1,33 @@
package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
hideSystemUI()
}
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
)
}
}
}
@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.readest" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowFullscreen">true</item>
</style>
</resources>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+32 -18
View File
@@ -11,21 +11,20 @@ mod menu;
#[cfg(target_os = "macos")]
mod traffic_light_plugin;
mod transfer_file;
use transfer_file::{download_file, upload_file};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use std::path::PathBuf;
use tauri::{command, Window};
use tauri::{AppHandle, Emitter, Manager, Url};
use tauri::{WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_fs::FsExt;
use tauri_plugin_oauth::start;
#[cfg(desktop)]
use tauri::Listener;
use std::path::PathBuf;
#[cfg(desktop)]
use tauri::{AppHandle, Listener, Manager, Url};
#[cfg(desktop)]
use tauri_plugin_fs::FsExt;
mod transfer_file;
use tauri::{command, Emitter, WebviewUrl, WebviewWindowBuilder, Window};
use tauri_plugin_oauth::start;
use transfer_file::{download_file, upload_file};
#[cfg(desktop)]
fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
@@ -78,12 +77,13 @@ async fn list_fonts() -> Result<Vec<String>, String> {
let font_collection = font_enumeration::Collection::new().unwrap();
let mut fonts = Vec::new();
for font in font_collection.all() {
fonts.push(font.font_name.clone());
fonts.push(font.family_name.clone());
}
Ok(fonts)
}
#[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct Payload {
args: Vec<String>,
cwd: String,
@@ -204,12 +204,26 @@ pub fn run() {
.title("");
#[cfg(all(not(target_os = "macos"), desktop))]
let win_builder = win_builder
.decorations(false)
.transparent(true)
.visible(false)
.shadow(true)
.title("Readest");
let win_builder = {
let mut win_builder = win_builder
.decorations(false)
.transparent(true)
.visible(false)
.title("Readest");
if cfg!(target_os = "windows") {
let version = tauri_plugin_os::version();
if version <= tauri_plugin_os::Version::from_string("10.0.19045") {
win_builder = win_builder.shadow(false);
} else {
win_builder = win_builder.shadow(true);
}
} else {
win_builder = win_builder.shadow(true);
}
win_builder
};
win_builder.build().unwrap();
// let win = win_builder.build().unwrap();
+6 -3
View File
@@ -14,11 +14,11 @@
"windows": [],
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: 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",
"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' data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
"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"
},
@@ -59,6 +59,9 @@
"android": {
"minSdkVersion": 26
},
"iOS": {
"developmentTeam": "J5W48D69VR"
},
"fileAssociations": [
{
"name": "epub",
@@ -0,0 +1,57 @@
import clsx from 'clsx';
import Image from 'next/image';
import { Book } from '@/types/book';
import { formatAuthors, formatTitle } from '@/utils/book';
interface BookCoverProps {
book: Book;
isPreview?: boolean;
}
const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
return (
<div className='relative flex h-full w-full'>
<Image
src={book.coverImageUrl!}
alt={book.title}
fill={true}
className='object-cover'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
}}
/>
<div
className={clsx(
'invisible absolute inset-0 rounded-none p-2',
'text-neutral-content text-center font-serif font-medium',
isPreview && 'bg-base-200/50',
)}
>
<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',
)}
>
{formatTitle(book.title)}
</span>
</div>
<div className='h-1/6'></div>
<div className='flex h-1/3 items-center justify-center'>
<span
className={clsx(
'text-neutral-content/50 line-clamp-1',
isPreview ? 'text-[0.4em]' : 'text-base',
)}
>
{formatAuthors(book.author)}
</span>
</div>
</div>
</div>
);
};
export default BookCover;
@@ -1,5 +1,4 @@
import clsx from 'clsx';
import Image from 'next/image';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { CiCircleMore } from 'react-icons/ci';
import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/lia';
@@ -8,14 +7,13 @@ import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import ReadingProgress from './ReadingProgress';
import BookCover from './BookCover';
interface BookItemProps {
book: Book;
isSelectMode: boolean;
selectedBooks: string[];
clickedBookHash: string | null;
transferProgress: number | null;
handleBookClick: (book: Book) => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
showBookDetailsModal: (book: Book) => void;
@@ -26,9 +24,7 @@ const BookItem: React.FC<BookItemProps> = ({
book,
isSelectMode,
selectedBooks,
clickedBookHash,
transferProgress,
handleBookClick,
handleBookUpload,
handleBookDownload,
showBookDetailsModal,
@@ -37,50 +33,33 @@ const BookItem: React.FC<BookItemProps> = ({
const iconSize15 = useResponsiveSize(15);
const { appService } = useEnv();
const stopEvent = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
};
return (
<div
className={clsx('book-item', appService?.hasContextMenu ? 'cursor-pointer' : '')}
className={clsx(
'book-item flex h-full flex-col',
appService?.hasContextMenu ? 'cursor-pointer' : '',
)}
onContextMenu={bookContextMenuHandler.bind(null, book)}
>
<div className='bg-base-100 shadow-md' onClick={() => handleBookClick(book)}>
<div className='relative aspect-[28/41]'>
<Image
src={book.coverImageUrl!}
alt={book.title}
fill={true}
className='object-cover'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
}}
/>
<div
className={clsx(
'invisible absolute inset-0 rounded-none p-2',
'text-neutral-content text-center font-serif font-medium',
<div className='bg-base-100 relative aspect-[28/41] shadow-md'>
<BookCover book={book} />
{selectedBooks.includes(book.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(book.hash) ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
)}
>
<div className='flex h-1/2 items-center justify-center'>
<span className='line-clamp-3 text-lg'>{book.title}</span>
</div>
<div className='h-1/6'></div>
<div className='flex h-1/3 items-center justify-center'>
<span className='text-neutral-content/50 line-clamp-1 text-base'>{book.author}</span>
</div>
</div>
{(selectedBooks.includes(book.hash) || clickedBookHash === book.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(book.hash) ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
)}
</div>
)}
</div>
)}
</div>
<div className={clsx('flex w-full flex-col p-0 pt-2')}>
<div className='min-w-0 flex-1'>
@@ -110,6 +89,10 @@ const BookItem: React.FC<BookItemProps> = ({
) : (
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onTouchStart={(e) => stopEvent(e)}
onMouseDown={(e) => stopEvent(e)}
onTouchEnd={(e) => stopEvent(e)}
onMouseUp={(e) => stopEvent(e)}
onClick={() => {
if (!book.uploadedAt) {
handleBookUpload(book);
@@ -126,6 +109,10 @@ const BookItem: React.FC<BookItemProps> = ({
)}
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onTouchStart={(e) => stopEvent(e)}
onMouseDown={(e) => stopEvent(e)}
onTouchEnd={(e) => stopEvent(e)}
onMouseUp={(e) => stopEvent(e)}
onClick={() => showBookDetailsModal(book)}
>
<CiCircleMore size={iconSize15} />
@@ -2,52 +2,20 @@ import clsx from 'clsx';
import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { MdDelete, MdOpenInNew } from 'react-icons/md';
import { MdDelete, MdOpenInNew, MdOutlineCancel } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { Book, BooksGroup } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToReader } from '@/utils/nav';
import { getOSPlatform } from '@/utils/misc';
import { getFilename } from '@/utils/book';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import BookDetailModal from '@/components/BookDetailModal';
import BookItem from './BookItem';
import GroupItem from './GroupItem';
type BookshelfItem = Book | BooksGroup;
const UNGROUPED_NAME = 'ungrouped';
const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
if (book.deletedAt) return acc;
book.group = book.group || UNGROUPED_NAME;
const groupIndex = acc.findIndex((group) => group.name === book.group);
const booksGroup = acc[acc.findIndex((group) => group.name === book.group)];
if (booksGroup) {
booksGroup.books.push(book);
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
} else {
acc.push({
name: book.group,
books: [book],
updatedAt: book.updatedAt,
});
}
return acc;
}, []);
const ungroupedBooks: Book[] = groups.find((group) => group.name === UNGROUPED_NAME)?.books || [];
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== UNGROUPED_NAME);
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
};
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import GroupingModal from './GroupingModal';
interface BookshelfProps {
libraryBooks: Book[];
@@ -56,6 +24,8 @@ interface BookshelfProps {
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDelete: (book: Book) => void;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
booksTransferProgress: { [key: string]: number | null };
}
@@ -66,50 +36,33 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleBookUpload,
handleBookDownload,
handleBookDelete,
handleSetSelectMode,
handleShowDetailsBook,
booksTransferProgress,
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
const { appService } = useEnv();
const [loading, setLoading] = useState(false);
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [clickedImage, setClickedImage] = useState<string | null>(null);
const [showGroupingModal, setShowGroupingModal] = useState(false);
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const isImportingBook = useRef(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const makeBookAvailable = async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
let available = false;
try {
await handleBookDownload(book);
updateBook(envConfig, book);
available = true;
} finally {
return available;
}
}
return true;
};
const showBookDetailsModal = async (book: Book) => {
if (await makeBookAvailable(book)) {
setShowDetailsBook(book);
}
};
const dismissBookDetailsModal = () => {
setShowDetailsBook(null);
};
const { setLibrary } = useLibraryStore();
const allBookshelfItems = generateBookshelfItems(libraryBooks);
useEffect(() => {
setSelectedBooks([]);
if (isSelectMode) {
setShowSelectModeActions(true);
} else {
setSelectedBooks([]);
setShowSelectModeActions(false);
}
}, [isSelectMode]);
useEffect(() => {
@@ -131,24 +84,27 @@ const Bookshelf: React.FC<BookshelfProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importBookUrl, appService]);
const bookshelfItems = generateBookshelfItems(libraryBooks);
const handleBookClick = async (book: Book) => {
if (!(await makeBookAvailable(book))) return;
if (isSelectMode) {
toggleSelection(book.hash);
useEffect(() => {
const group = searchParams?.get('group') || '';
if (group) {
const booksGroup = allBookshelfItems.find(
(item) => 'name' in item && item.id === group,
) as BooksGroup;
if (booksGroup) {
setNavBooksGroup(booksGroup);
} else {
navigateToLibrary(router);
}
} else {
setClickedImage(book.hash);
setTimeout(() => setClickedImage(null), 300);
setTimeout(() => setLoading(true), 200);
navigateToReader(router, [book.hash]);
setNavBooksGroup(null);
navigateToLibrary(router);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, showGroupingModal]);
const toggleSelection = (id: string) => {
setSelectedBooks((prev) =>
prev.includes(id) ? prev.filter((bookId) => bookId !== id) : [...prev, id],
prev.includes(id) ? prev.filter((selectedId) => selectedId !== id) : [...prev, id],
);
};
@@ -158,8 +114,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const confirmDelete = async () => {
for (const selectedBook of selectedBooks) {
const book = libraryBooks.find((b) => b.hash === selectedBook);
for (const id of selectedBooks) {
const book = libraryBooks.find((b) => b.hash === id || b.groupId === id);
if (book) {
await handleBookDelete(book);
}
@@ -169,85 +125,39 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const deleteSelectedBooks = () => {
setShowSelectModeActions(false);
setShowDeleteAlert(true);
};
const bookContextMenuHandler = async (book: Book, e: React.MouseEvent) => {
if (!appService?.hasContextMenu) return;
e.preventDefault();
e.stopPropagation();
const osPlatform = getOSPlatform();
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
const openBookMenuItem = await MenuItem.new({
text: isSelectMode ? _('Select Book') : _('Open Book'),
action: async () => {
handleBookClick(book);
},
});
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
revealItemInDir(folder);
},
});
const showBookDetailsMenuItem = await MenuItem.new({
text: _('Show Book Details'),
action: async () => {
showBookDetailsModal(book);
},
});
const uploadBookMenuItem = await MenuItem.new({
text: _('Upload Book'),
action: async () => {
handleBookUpload(book);
},
});
const deleteBookMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
await handleBookDelete(book);
},
});
const menu = await Menu.new();
menu.append(openBookMenuItem);
menu.append(showBookDetailsMenuItem);
menu.append(showBookInFinderMenuItem);
menu.append(uploadBookMenuItem);
menu.append(deleteBookMenuItem);
menu.popup();
const groupSelectedBooks = () => {
setShowSelectModeActions(false);
setShowGroupingModal(true);
};
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
return (
<div className='bookshelf'>
<div className='grid grid-cols-3 gap-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8'>
{bookshelfItems.map((item, index) => (
<div
<div className='transform-wrapper grid flex-1 grid-cols-3 gap-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8'>
{currentBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
className='hover:bg-base-300/50 group flex h-full flex-col p-4'
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
clickedBookHash={clickedImage}
handleBookClick={handleBookClick}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
bookContextMenuHandler={bookContextMenuHandler}
transferProgress={booksTransferProgress[item.hash] || null}
/>
) : (
<GroupItem group={item} />
)}
</div>
</div>
item={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
transferProgress={
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
}
/>
))}
{bookshelfItems.length > 0 && (
{!navBooksGroup && allBookshelfItems.length > 0 && (
<div
className='border-1 bg-base-100 hover:bg-base-300/50 m-4 flex aspect-[28/41] items-center justify-center'
role='button'
@@ -257,42 +167,94 @@ const Bookshelf: React.FC<BookshelfProps> = ({
</div>
)}
</div>
{selectedBooks.length > 0 && (
<div
className={clsx(
'text-base-content bg-base-300 fixed bottom-4 left-1/2 flex',
'-translate-x-1/2 transform space-x-4 rounded-lg p-4 shadow-lg',
)}
>
<button onClick={openSelectedBooks} className='flex items-center space-x-2'>
<MdOpenInNew />
<span>{_('Open')}</span>
</button>
<button onClick={deleteSelectedBooks} className='flex items-center space-x-2'>
<MdDelete className='fill-red-500' />
<span className='text-red-500'>{_('Delete')}</span>
</button>
</div>
)}
{loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)}
{showDeleteAlert && (
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the selected books?')}
onClickCancel={() => setShowDeleteAlert(false)}
onClickConfirm={confirmDelete}
/>
<div className='fixed bottom-0 left-0 right-0 z-40 pb-[calc(env(safe-area-inset-bottom)+16px)]'>
{isSelectMode && showSelectModeActions && (
<div
className={clsx(
'flex items-center justify-center shadow-lg',
'text-base-content bg-base-300 text-sm',
'mx-auto w-fit space-x-6 rounded-lg p-4',
)}
>
<button
onClick={openSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
(!selectedBooks.length || !selectedBooks.every((id) => isMd5(id))) &&
'btn-disabled opacity-50',
)}
>
<MdOpenInNew />
<div>{_('Open')}</div>
</button>
<button
onClick={groupSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
!selectedBooks.length && 'btn-disabled opacity-50',
)}
>
<LuFolderPlus />
<div>{_('Add to Group')}</div>
</button>
<button
onClick={deleteSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
!selectedBooks.length && 'btn-disabled opacity-50',
)}
>
<MdDelete className='fill-red-500' />
<div className='text-red-500'>{_('Delete')}</div>
</button>
<button
onClick={() => handleSetSelectMode(false)}
className={clsx('flex flex-col items-center justify-center')}
>
<MdOutlineCancel />
<div>{_('Cancel')}</div>
</button>
</div>
)}
</div>
{showGroupingModal && (
<div>
<GroupingModal
libraryBooks={libraryBooks}
selectedBooks={selectedBooks}
onCancel={() => {
setShowGroupingModal(false);
setShowSelectModeActions(true);
}}
onConfirm={() => {
setShowGroupingModal(false);
handleSetSelectMode(false);
}}
/>
</div>
)}
{showDetailsBook && (
<BookDetailModal
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={dismissBookDetailsModal}
/>
{showDeleteAlert && (
<div
className={clsx(
'fixed bottom-0 z-50 flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the selected books?')}
onCancel={() => {
setShowDeleteAlert(false);
setShowSelectModeActions(true);
}}
onConfirm={confirmDelete}
/>
</div>
)}
</div>
);
@@ -0,0 +1,246 @@
import clsx from 'clsx';
import { useRouter } from 'next/navigation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLongPress } from '@/hooks/useLongPress';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getOSPlatform } from '@/utils/misc';
import { getFilename } from '@/utils/book';
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';
import BookItem from './BookItem';
import GroupItem from './GroupItem';
export type BookshelfItem = Book | BooksGroup;
export const generateBookshelfItems = (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;
book.groupName = book.groupName || BOOK_UNGROUPED_NAME;
const groupIndex = acc.findIndex((group) => group.id === book.groupId);
const booksGroup = acc[acc.findIndex((group) => group.id === book.groupId)];
if (booksGroup) {
booksGroup.books.push(book);
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
} else {
acc.push({
id: book.groupId,
name: book.groupName,
books: [book],
updatedAt: book.updatedAt,
});
}
return acc;
}, []);
groups.forEach((group) => {
group.books.sort((a, b) => b.updatedAt - a.updatedAt);
});
const ungroupedBooks: Book[] =
groups.find((group) => group.name === BOOK_UNGROUPED_NAME)?.books || [];
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== BOOK_UNGROUPED_NAME);
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
};
export const generateGroupsList = (items: Book[]): BookGroupType[] => {
return items
.sort((a, b) => b.updatedAt - a.updatedAt)
.reduce((acc: BookGroupType[], item: Book) => {
if (item.deletedAt) return acc;
if (
item.groupId &&
item.groupName &&
item.groupId !== BOOK_UNGROUPED_ID &&
item.groupName !== BOOK_UNGROUPED_NAME &&
!acc.find((group) => group.id === item.groupId)
) {
acc.push({ id: item.groupId, name: item.groupName });
}
return acc;
}, []) as BookGroupType[];
};
interface BookshelfItemProps {
item: BookshelfItem;
isSelectMode: boolean;
selectedBooks: string[];
transferProgress: number | null;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDelete: (book: Book) => void;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
}
const BookshelfItem: React.FC<BookshelfItemProps> = ({
item,
isSelectMode,
selectedBooks,
transferProgress,
setLoading,
toggleSelection,
handleBookUpload,
handleBookDownload,
handleBookDelete,
handleSetSelectMode,
handleShowDetailsBook,
}) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
const showBookDetailsModal = async (book: Book) => {
if (await makeBookAvailable(book)) {
handleShowDetailsBook(book);
}
};
const makeBookAvailable = async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
let available = false;
try {
await handleBookDownload(book);
updateBook(envConfig, book);
available = true;
} finally {
return available;
}
}
return true;
};
const handleBookClick = async (book: Book) => {
if (!(await makeBookAvailable(book))) return;
if (isSelectMode) {
toggleSelection(book.hash);
} else {
setTimeout(() => setLoading(true), 200);
navigateToReader(router, [book.hash]);
}
};
const handleGroupClick = (group: BooksGroup) => {
if (isSelectMode) {
toggleSelection(group.id);
} else {
navigateToLibrary(router, `group=${group.id}`);
}
};
const handleItemContextMenu = (item: BookshelfItem, event: React.MouseEvent) => {
event.preventDefault();
if ('format' in item) {
bookContextMenuHandler(item as Book, event);
}
};
const bookContextMenuHandler = async (book: Book, e: React.MouseEvent) => {
if (!appService?.hasContextMenu) return;
e.preventDefault();
e.stopPropagation();
const osPlatform = getOSPlatform();
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
const openBookMenuItem = await MenuItem.new({
text: isSelectMode ? _('Select Book') : _('Open Book'),
action: async () => {
handleBookClick(book);
},
});
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
revealItemInDir(folder);
},
});
const showBookDetailsMenuItem = await MenuItem.new({
text: _('Show Book Details'),
action: async () => {
showBookDetailsModal(book);
},
});
const uploadBookMenuItem = await MenuItem.new({
text: _('Upload Book'),
action: async () => {
handleBookUpload(book);
},
});
const deleteBookMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
await handleBookDelete(book);
},
});
const menu = await Menu.new();
menu.append(openBookMenuItem);
menu.append(showBookDetailsMenuItem);
menu.append(showBookInFinderMenuItem);
menu.append(uploadBookMenuItem);
menu.append(deleteBookMenuItem);
menu.popup();
};
const { pressing, handlers } = useLongPress({
onLongPress: () => {
if (!isSelectMode) {
handleSetSelectMode(true);
}
if ('format' in item) {
toggleSelection((item as Book).hash);
} else {
toggleSelection((item as BooksGroup).id);
}
},
onTap: () => {
if ('format' in item) {
handleBookClick(item as Book);
} else {
handleGroupClick(item as BooksGroup);
}
},
});
return (
<div
className={clsx(
'hover:bg-base-300/50 group flex h-full flex-col p-4',
pressing ? 'scale-95' : 'scale-100',
)}
style={{
transition: 'transform 0.2s',
}}
{...handlers}
onContextMenu={(event) => handleItemContextMenu(item, event)}
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
bookContextMenuHandler={bookContextMenuHandler}
transferProgress={transferProgress}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
)}
</div>
</div>
);
};
export default BookshelfItem;
@@ -1,33 +1,42 @@
import Image from 'next/image';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { BooksGroup } from '@/types/book';
import ReadingProgress from './ReadingProgress';
import BookCover from './BookCover';
interface GroupItemProps {
group: BooksGroup;
isSelectMode: boolean;
selectedBooks: string[];
}
const GroupItem: React.FC<GroupItemProps> = ({ group }) => {
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, selectedBooks }) => {
return (
<div>
{group.books.map((book) => (
<div key={book.hash} className='card bg-base-100 w-full shadow-md'>
<figure>
<Image
width={10}
height={10}
src={book.coverImageUrl!}
alt={book.title || ''}
className='h-48 w-full object-cover'
/>
</figure>
<div className='card-body p-4'>
<h3 className='card-title line-clamp-2 text-sm'>{book.title}</h3>
<p className='text-neutral-content line-clamp-1 text-xs'>{book.author}</p>
<ReadingProgress book={book} />
</div>
<div className='group-item flex h-full flex-col'>
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center p-2 shadow-md'>
<div className='grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden'>
{group.books.slice(0, 4).map((book) => (
<div key={book.hash} className='relative aspect-[28/41] h-full w-full'>
<BookCover book={book} isPreview />
</div>
))}
</div>
))}
<h2 className='mb-2 text-lg font-bold'>{group.name}</h2>
{selectedBooks.includes(group.id) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(group.id) ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
)}
</div>
)}
</div>
<div className='min-w-0 flex-1 pt-2'>
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-xs font-semibold'>
{group.name}
</h4>
</div>
</div>
);
};
@@ -0,0 +1,247 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { MdCheck } from 'react-icons/md';
import { HiOutlineFolder, HiOutlineFolderAdd, HiOutlineFolderRemove } from 'react-icons/hi';
import { Book, BookGroupType } from '@/types/book';
import { isMd5, md5Fingerprint } from '@/utils/md5';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { generateGroupsList } from './BookshelfItem';
interface GroupingModalProps {
libraryBooks: Book[];
selectedBooks: string[];
onCancel: () => void;
onConfirm: () => void;
}
const GroupingModal: React.FC<GroupingModalProps> = ({
libraryBooks,
selectedBooks,
onCancel,
onConfirm,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { setLibrary } = useLibraryStore();
const groupsList = generateGroupsList(libraryBooks);
const [showInput, setShowInput] = useState(false);
const [editGroupName, setEditGroupName] = useState(_('Untitled Group'));
const [selectedGroup, setSelectedGroup] = useState<BookGroupType | null>(null);
const [newGroups, setNewGroups] = useState<BookGroupType[]>([]);
const [allGroups, setAllGroups] = useState<BookGroupType[]>(groupsList);
const editorRef = useRef<HTMLInputElement>(null);
const iconSize = useResponsiveSize(16);
const isSelectedBooksHasGroup =
selectedBooks.some((hash) => !isMd5(hash)) ||
selectedBooks
.map((hash) => libraryBooks.find((book) => book.hash === hash)?.groupId)
.some((group) => group && group !== BOOK_UNGROUPED_NAME);
const handleCreateGroup = () => {
setShowInput(true);
};
const handleRemoveFromGroup = () => {
selectedBooks.forEach((id) => {
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
if (
book &&
book.groupId &&
book.groupName &&
book.groupId !== BOOK_UNGROUPED_ID &&
book.groupName !== BOOK_UNGROUPED_NAME
) {
book.groupId = undefined;
book.groupName = undefined;
book.updatedAt = Date.now();
}
}
});
setLibrary(libraryBooks);
appService?.saveLibraryBooks(libraryBooks);
onConfirm();
};
const handleConfirmCreateGroup = () => {
const groupName = editGroupName.trim();
if (groupName) {
const newGroup = { id: md5Fingerprint(groupName), name: groupName };
const existingGroupIndex = newGroups.findIndex((group) => group.name === groupName);
if (existingGroupIndex > -1) {
newGroups.splice(existingGroupIndex, 1);
}
newGroups.push(newGroup);
setSelectedGroup(newGroup);
setNewGroups(newGroups);
for (const newGroup of newGroups) {
const existingGroupIndex = groupsList.findIndex((group) => group.id === newGroup.id);
if (existingGroupIndex > -1) {
groupsList.splice(existingGroupIndex, 1);
}
groupsList.unshift(newGroup);
}
setAllGroups(groupsList);
const untitledGroupPattern = new RegExp(`^${_('Untitled Group')}\\s*(\\d+)?$`);
const untitledGroupNumbers = groupsList
.map((group) => {
const match = group.name.match(untitledGroupPattern);
return match ? parseInt(match[1] || '0', 10) : null;
})
.filter((num) => num !== null);
const nextNumber =
untitledGroupNumbers.length > 0 ? Math.max(...untitledGroupNumbers) + 1 : 1;
setEditGroupName(`${_('Untitled Group')} ${nextNumber}`);
setShowInput(false);
}
};
const handleToggleSelectGroup = (group: BookGroupType) => {
setSelectedGroup((prevGroup) => (prevGroup?.id === group.id ? null : group));
};
const handleConfirmGrouping = () => {
selectedBooks.forEach((id) => {
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
if (book && selectedGroup) {
book.groupId = selectedGroup.id;
book.groupName = selectedGroup.name;
book.updatedAt = Date.now();
}
}
});
setLibrary(libraryBooks);
appService?.saveLibraryBooks(libraryBooks);
onConfirm();
};
useEffect(() => {
if (editorRef.current) {
editorRef.current.select();
}
}, [showInput]);
useEffect(() => {
const groupIds = selectedBooks
.map((id) => libraryBooks.find((book) => book.hash === id || book.groupId === id)?.groupId)
.filter((groupId) => groupId);
if (Array.from(new Set(groupIds)).length === 1) {
setSelectedGroup(groupsList.find((group) => group.id === groupIds[0]) || null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBooks]);
return (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50'>
<div
className={clsx(
'modal-box bg-base-100 overflow-y-auto rounded-2xl shadow-xl',
'max-h-[85%] w-[95%] min-w-64 max-w-[440px] p-6 sm:w-[70%]',
)}
>
<h2 className='text-center text-lg font-bold'>{_('Group Books')}</h2>
<div className={clsx('mt-4 grid grid-cols-1 gap-2 text-base md:grid-cols-2')}>
{isSelectedBooksHasGroup && (
<div
onClick={handleRemoveFromGroup}
role='button'
className='flex items-center space-x-2 p-2 text-blue-500'
>
<HiOutlineFolderRemove size={iconSize} />
<span>{_('Remove From Group')}</span>
</div>
)}
<div
onClick={handleCreateGroup}
role='button'
className='flex items-center space-x-2 p-2 text-blue-500'
>
<HiOutlineFolderAdd size={iconSize} />
<span>{_('Create New Group')}</span>
</div>
</div>
{showInput && (
<div className='mt-4 flex items-center gap-2'>
<input
type='text'
autoFocus
ref={editorRef}
value={editGroupName}
onChange={(e) => setEditGroupName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmCreateGroup();
e.stopPropagation();
}}
className='input input-ghost w-full border-0 px-2 text-base !outline-none sm:text-sm'
/>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={() => handleConfirmCreateGroup()}
>
<div className='pr-1 align-bottom text-base text-blue-500 sm:text-sm'>
{_('Save')}
</div>
</button>
</div>
)}
<ul className='groups-list mt-4 grid grid-cols-2 gap-2'>
{allGroups.map((group, index) => (
<button
key={index}
className={clsx(
'hover:bg-base-300 text-base-content flex w-full',
'items-center justify-between rounded-md px-2 py-2',
)}
onClick={() => handleToggleSelectGroup(group)}
>
<div className='flex min-w-0 items-center'>
<span style={{ minWidth: `${iconSize}px` }}>
<HiOutlineFolder size={iconSize} />
</span>
<span
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm')}
style={{ minWidth: 0 }}
>
{group.name}
</span>
</div>
<span className='text-neutral-content flex shrink-0 text-sm'>
{selectedGroup && selectedGroup.id == group.id && (
<MdCheck className='fill-blue-500' size={iconSize} />
)}
</span>
</button>
))}
</ul>
<div className='mt-6 flex justify-end gap-x-8 p-2'>
<button onClick={onCancel} className='flex items-center'>
{_('Cancel')}
</button>
<button
onClick={handleConfirmGrouping}
className={clsx(
'flex items-center text-blue-500',
!selectedGroup && 'btn-disabled opacity-50',
)}
>
{_('Confirm')}
</button>
</div>
</div>
</div>
);
};
export default GroupingModal;
@@ -1,13 +1,16 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
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 { MdOutlineMenu } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { navigateToLibrary } from '@/utils/nav';
import useTrafficLight from '@/hooks/useTrafficLight';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
@@ -26,10 +29,13 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onToggleSelectMode,
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { isTrafficLightVisible } = useTrafficLight();
const headerRef = useRef<HTMLDivElement>(null);
const iconSize16 = useResponsiveSize(16);
const iconSize20 = useResponsiveSize(20);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -44,31 +50,47 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
}, [onToggleSelectMode]);
const windowButtonVisible = appService?.appPlatform !== 'web' && !isTrafficLightVisible;
const isInGroupView = !!searchParams?.get('group');
return (
<div
ref={headerRef}
className={clsx(
'titlebar z-10 h-11 w-full py-2 pr-6',
'titlebar z-10 flex h-11 w-full items-center py-2 pr-6',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
isTrafficLightVisible ? 'pl-16' : 'pl-2',
)}
>
<div className='flex items-center justify-between space-x-12'>
<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'>
<span className='absolute left-8 text-gray-500'>
<FaSearch className='h-4 w-4' />
</span>
<input
type='text'
placeholder={_('Search Books...')}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
'font-sans text-sm font-light',
'border-none focus:outline-none focus:ring-0',
)}
/>
<div className='absolute right-4 flex items-center space-x-4 text-gray-500'>
{isInGroupView && (
<button
onClick={() => {
navigateToLibrary(router);
}}
className='ml-[-6px] mr-4 flex h-7 min-h-7 w-7 items-center p-0'
>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Go Back')}>
<MdArrowBackIosNew size={iconSize20} />
</div>
</button>
)}
<div className='relative flex h-7 w-full items-center'>
<span className='absolute left-3 text-gray-500'>
<FaSearch className='h-4 w-4' />
</span>
<input
type='text'
placeholder={_('Search Books...')}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
'font-sans text-sm font-light',
'border-none focus:outline-none focus:ring-0',
)}
/>
</div>
<div className='absolute right-4 flex items-center space-x-2 text-gray-500 sm:space-x-4'>
<span className='mx-2 h-6 w-[1px] bg-gray-400'></span>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center'
@@ -98,7 +120,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</button>
</div>
</div>
<div className='flex h-full items-center gap-x-4'>
<div className='flex h-full items-center gap-x-2 sm:gap-x-4'>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
@@ -74,6 +74,8 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
const updateLibrary = async () => {
if (!syncedBooks?.length) return;
// Process old books first so that when we update the library the order is preserved
syncedBooks.sort((a, b) => a.updatedAt - b.updatedAt);
const processOldBook = async (oldBook: Book) => {
const matchingBook = syncedBooks.find((newBook) => newBook.hash === oldBook.hash);
+41 -17
View File
@@ -2,7 +2,7 @@
import clsx from 'clsx';
import * as React from 'react';
import { useState, useRef, useEffect, Suspense } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Book } from '@/types/book';
@@ -10,6 +10,8 @@ import { AppService } from '@/types/system';
import { navigateToLogin, navigateToReader } from '@/utils/nav';
import { getBaseFilename, 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 { checkForAppUpdates } from '@/helpers/updater';
@@ -30,8 +32,7 @@ import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import BookDetailModal from '@/components/BookDetailModal';
const LibraryPage = () => {
const router = useRouter();
@@ -51,6 +52,7 @@ const LibraryPage = () => {
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
const [isSelectMode, setIsSelectMode] = useState(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [booksTransferProgress, setBooksTransferProgress] = useState<{
[key: string]: number | null;
}>({});
@@ -335,6 +337,14 @@ const LibraryPage = () => {
setIsSelectMode(!isSelectMode);
};
const handleSetSelectMode = (selectMode: boolean) => {
setIsSelectMode(selectMode);
};
const handleShowDetailsBook = (book: Book) => {
setShowDetailsBook(book);
};
if (!appService) {
return null;
}
@@ -352,8 +362,9 @@ const LibraryPage = () => {
return (
<div
className={clsx(
'library-page bg-base-200 text-base-content flex h-dvh select-none flex-col overflow-hidden',
isTauriAppPlatform() && 'rounded-window',
'library-page bg-base-200 text-base-content flex select-none flex-col overflow-hidden',
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<div className='fixed top-0 z-40 w-full'>
@@ -370,18 +381,24 @@ const LibraryPage = () => {
)}
{libraryLoaded &&
(libraryBooks.length > 0 ? (
<div ref={containerRef} className='mt-12 flex-grow overflow-auto px-2'>
<Suspense>
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
handleImportBooks={handleImportBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
booksTransferProgress={booksTransferProgress}
/>
</Suspense>
<div
ref={containerRef}
className={clsx(
'mt-12 flex-grow overflow-auto px-2',
appService?.hasSafeAreaInset && 'mt-[calc(48px+env(safe-area-inset-top))]',
)}
>
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
handleImportBooks={handleImportBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
booksTransferProgress={booksTransferProgress}
/>
</div>
) : (
<div className='hero h-screen items-center justify-center'>
@@ -400,6 +417,13 @@ const LibraryPage = () => {
</div>
</div>
))}
{showDetailsBook && (
<BookDetailModal
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={() => setShowDetailsBook(null)}
/>
)}
<AboutWindow />
<Toast />
</div>
@@ -1,10 +1,11 @@
import clsx from 'clsx';
import React, { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { isTauriAppPlatform } from '@/services/environment';
import FoliateViewer from './FoliateViewer';
import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
@@ -23,6 +24,7 @@ interface BooksGridProps {
}
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { appService } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
@@ -39,7 +41,10 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
return (
<div
className='grid h-full flex-grow'
className={clsx(
'grid h-full flex-grow',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
style={{
gridTemplateColumns: gridTemplate.columns,
gridTemplateRows: gridTemplate.rows,
@@ -61,7 +66,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<div
id={`gridcell-${bookKey}`}
key={bookKey}
className={`${isTauriAppPlatform() ? 'rounded-window' : ''} relative h-full w-full overflow-hidden`}
className={`${appService?.hasRoundedWindow ? 'rounded-window' : ''} relative h-full w-full overflow-hidden`}
>
{isBookmarked && <Ribbon width={marginGap} />}
<HeaderBar
@@ -7,6 +7,7 @@ import { useParallelViewStore } from '@/store/parallelViewStore';
import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
import { getStyles, mountAdditionalFonts } from '@/utils/style';
import { getBookDirFromWritingMode } from '@/utils/book';
@@ -43,6 +44,7 @@ const FoliateViewer: React.FC<{
}, [toastMessage]);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -4,11 +4,11 @@ import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { FaHeadphones } from 'react-icons/fa6';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { isPWA, isTauriAppPlatform } from '@/services/environment';
import Button from '@/components/Button';
interface FooterBarProps {
@@ -19,6 +19,7 @@ interface FooterBarProps {
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const view = getView(bookKey);
@@ -62,9 +63,9 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
className={clsx(
'footer-bar absolute bottom-0 z-10 flex h-12 w-full items-center gap-x-4 px-4',
'shadow-xs bg-base-100 transition-opacity duration-300',
isPWA() ? 'pb-[env(safe-area-inset-bottom)]' : '',
isTauriAppPlatform() && 'rounded-window-bottom-right',
!isSideBarVisible && isTauriAppPlatform() && 'rounded-window-bottom-left',
appService?.hasSafeAreaInset && 'pb-[env(safe-area-inset-bottom)]',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
hoveredBookKey === bookKey ? `opacity-100` : `opacity-0`,
)}
@@ -5,7 +5,6 @@ import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { isTauriAppPlatform } from '@/services/environment';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import useTrafficLight from '@/hooks/useTrafficLight';
import WindowButtons from '@/components/WindowButtons';
@@ -53,8 +52,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
`header-bar absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
isTrafficLightVisible && isTopLeft && !isSideBarVisible ? 'pl-16' : 'pl-4',
`shadow-xs bg-base-100 transition-opacity duration-300`,
isTauriAppPlatform() && 'rounded-window-top-right',
!isSideBarVisible && isTauriAppPlatform() && 'rounded-window-top-left',
appService?.hasRoundedWindow && 'rounded-window-top-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-top-left',
isHoveredAnim && 'hover-bar-anim',
hoveredBookKey === bookKey || isDropdownOpen ? `visible` : `opacity-0`,
isDropdownOpen && 'header-bar-pinned',
@@ -8,13 +8,12 @@ import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { isTauriAppPlatform } from '@/services/environment';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { library, setLibrary } = useLibraryStore();
const isInitiating = useRef(false);
@@ -42,7 +41,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<div
className={clsx(
`reader-page bg-base-100 text-base-content select-none`,
isTauriAppPlatform() && 'rounded-window',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<Suspense>
@@ -1,5 +1,6 @@
'use client';
import clsx from 'clsx';
import * as React from 'react';
import { useState, useRef, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
@@ -31,7 +32,7 @@ import TTSControl from './tts/TTSControl';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { bookKeys, dismissBook, getNextBookKey } = useBooksManager();
const { sideBarBookKey, setSideBarBookKey } = useSidebarStore();
const { saveSettings } = useSettingsStore();
@@ -148,7 +149,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
setTimeout(() => setLoading(true), 300);
return (
loading && (
<div className={'hero hero-content h-dvh'}>
<div className={clsx('hero hero-content', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
<Spinner loading={true} />
</div>
)
@@ -156,7 +157,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}
return (
<div className='flex h-dvh'>
<div className={clsx('flex', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
<TTSControl />
@@ -49,6 +49,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const isShowingPopup = useRef(false);
const isTextSelected = useRef(false);
const isUpToShowPopup = useRef(false);
const isTouchstarted = useRef(false);
const [selection, setSelection] = useState<TextSelection | null>();
const [showAnnotPopup, setShowAnnotPopup] = useState(false);
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
@@ -94,14 +95,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}
setSelection({ key: bookKey, text: sel.toString(), range, index });
};
const handlePointerup = () => {
// Available on iOS and Desktop, fired when release the long press
// Note that on Android, pointerup event is fired for any tap but not for long press
const sel = doc.getSelection();
if (isValidSelection(sel)) {
makeSelection(sel, true);
}
};
const handleSelectionchange = () => {
// Available on iOS, Android and Desktop, fired when the selection is changed
// Ideally the popup only shows when the selection is done,
@@ -109,7 +102,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// we make the popup show when the selection is changed
const sel = doc.getSelection();
if (isValidSelection(sel)) {
if (osPlatform === 'android') {
if (osPlatform === 'android' && isTouchstarted.current) {
makeSelection(sel, false);
}
} else if (!isUpToShowPopup.current) {
@@ -120,14 +113,32 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowDeepLPopup(false);
}
};
const handlePointerup = () => {
// Available on iOS and Desktop, fired when release the long press
// Note that on Android, pointerup event is fired after an additional touch event
const sel = doc.getSelection();
if (isValidSelection(sel)) {
makeSelection(sel, true);
}
};
const handleTouchstart = () => {
// Available on iOS and Android for the initial touch event
isTouchstarted.current = true;
};
const handleTouchmove = () => {
// Available on iOS
// Available on iOS, on Android not fired
// To make the popup not to follow the selection
setShowAnnotPopup(false);
};
const handleTouchend = () => {
// Available on iOS, on Android fired after an additional touch event
isTouchstarted.current = false;
};
if (bookData.book?.format !== 'PDF') {
detail.doc?.addEventListener('pointerup', handlePointerup);
detail.doc?.addEventListener('touchstart', handleTouchstart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchend);
detail.doc?.addEventListener('selectionchange', handleSelectionchange);
// Disable the default context menu on mobile devices,
@@ -13,7 +13,6 @@ import { TextSelection } from '@/utils/sel';
import { BookNote } from '@/types/book';
import { uniqueId } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import useDragBar from '../../hooks/useDragBar';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
@@ -25,7 +24,7 @@ const MAX_NOTEBOOK_WIDTH = 0.45;
const Notebook: React.FC = ({}) => {
const _ = useTranslation();
const { updateAppTheme } = useTheme();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { notebookWidth, isNotebookVisible, isNotebookPinned } = useNotebookStore();
@@ -152,7 +151,7 @@ const Notebook: React.FC = ({}) => {
<div
className={clsx(
'notebook-container bg-base-200 right-0 z-20 h-full min-w-60 select-none',
isTauriAppPlatform() && 'rounded-window-top-right rounded-window-bottom-right',
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
!isNotebookPinned && 'shadow-2xl',
)}
style={{
@@ -8,8 +8,8 @@ import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize
interface DropdownProps {
family?: string;
selected: string;
options: { option: string; label: string }[];
moreOptions?: string[];
options: { option: string; label?: string }[];
moreOptions?: { option: string; label?: string }[];
onSelect: (option: string) => void;
onGetFontFamily: (option: string, family: string) => string;
}
@@ -25,7 +25,8 @@ const FontDropdown: React.FC<DropdownProps> = ({
const _ = useTranslation();
const iconSize16 = useResponsiveSize(16);
const defaultIconSize = useDefaultIconSize();
const selectedOption = options.find((option) => option.option === selected)!;
const allOptions = [...options, ...(moreOptions ?? [])];
const selectedOption = allOptions.find((option) => option.option === selected) ?? allOptions[0]!;
return (
<div className='dropdown dropdown-top'>
<button
@@ -68,14 +69,14 @@ const FontDropdown: React.FC<DropdownProps> = ({
'!mr-5 mb-[-46px] inline max-h-80 w-[200px] overflow-y-scroll',
)}
>
{moreOptions.map((option) => (
<li key={option} onClick={() => onSelect(option)}>
{moreOptions.map((option, index) => (
<li key={`${index}-${option.option}`} onClick={() => onSelect(option.option)}>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{selected === option && <MdCheck className='text-base-content' />}
{selected === option.option && <MdCheck className='text-base-content' />}
</span>
<span style={{ fontFamily: onGetFontFamily(option, family ?? '') }}>
{option}
<span style={{ fontFamily: onGetFontFamily(option.option, family ?? '') }}>
{option.label || option.option}
</span>
</div>
</li>
@@ -50,7 +50,7 @@ const FontFace = ({
<FontDropdown
family={family}
options={options.map((option) => ({ option, label: option }))}
moreOptions={moreOptions}
moreOptions={moreOptions?.map((option) => ({ option, label: option })) ?? []}
selected={selected}
onSelect={onSelect}
onGetFontFamily={handleFontFaceFont}
@@ -106,6 +106,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [serifFont, setSerifFont] = useState(viewSettings.serifFont!);
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
useEffect(() => {
if (isTauriAppPlatform() && FONT_ENUM_SUPPORTED_OS_PLATFORMS.includes(osPlatform)) {
@@ -149,6 +150,17 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minFontSize]);
useEffect(() => {
viewSettings.fontWeight = fontWeight;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.fontWeight = fontWeight;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fontWeight]);
useEffect(() => {
viewSettings.serifFont = serifFont;
setViewSettings(bookKey, viewSettings);
@@ -232,6 +244,23 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Font Weight')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label={_('Font Weight')}
value={fontWeight}
onChange={setFontWeight}
min={100}
max={900}
step={100}
/>
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Font Family')}</h2>
<div className='card border-base-200 border shadow'>
@@ -22,7 +22,11 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
const [paragraphMargin, setParagraphMargin] = useState(viewSettings.paragraphMargin!);
const [lineHeight, setLineHeight] = useState(viewSettings.lineHeight!);
const [wordSpacing, setWordSpacing] = useState(viewSettings.wordSpacing!);
const [letterSpacing, setLetterSpacing] = useState(viewSettings.letterSpacing!);
const [textIndent, setTextIndent] = useState(viewSettings.textIndent!);
const [fullJustification, setFullJustification] = useState(viewSettings.fullJustification!);
const [hyphenation, setHyphenation] = useState(viewSettings.hyphenation!);
const [marginPx, setMarginPx] = useState(viewSettings.marginPx!);
@@ -31,6 +35,18 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [maxInlineSize, setMaxInlineSize] = useState(viewSettings.maxInlineSize!);
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
const [writingMode, setWritingMode] = useState(viewSettings.writingMode!);
const [overrideLayout, setOverrideLayout] = useState(viewSettings.overrideLayout!);
useEffect(() => {
viewSettings.paragraphMargin = paragraphMargin;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.paragraphMargin = paragraphMargin;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paragraphMargin]);
useEffect(() => {
viewSettings.lineHeight = lineHeight;
@@ -43,6 +59,39 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lineHeight]);
useEffect(() => {
viewSettings.wordSpacing = wordSpacing;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.wordSpacing = wordSpacing;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wordSpacing]);
useEffect(() => {
viewSettings.letterSpacing = letterSpacing;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.letterSpacing = letterSpacing;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [letterSpacing]);
useEffect(() => {
viewSettings.textIndent = textIndent;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.textIndent = textIndent;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textIndent]);
useEffect(() => {
viewSettings.fullJustification = fullJustification;
setViewSettings(bookKey, viewSettings);
@@ -141,6 +190,17 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writingMode]);
useEffect(() => {
viewSettings.overrideLayout = overrideLayout;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.overrideLayout = overrideLayout;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideLayout]);
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
const isCJKBook = langCode === 'zh' || langCode === 'ja' || langCode === 'ko';
@@ -188,13 +248,49 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label={_('Line Height')}
label={_('Paragraph Margin')}
value={paragraphMargin}
onChange={setParagraphMargin}
min={0}
max={4}
step={0.5}
/>
<NumberInput
className='config-item-top'
label={_('Line Spacing')}
value={lineHeight}
onChange={setLineHeight}
min={1.0}
max={3.0}
step={0.1}
/>
<NumberInput
className='config-item-top'
label={_('Word Spacing')}
value={wordSpacing}
onChange={setWordSpacing}
min={-4}
max={8}
step={0.5}
/>
<NumberInput
className='config-item-top'
label={_('Letter Spacing')}
value={letterSpacing}
onChange={setLetterSpacing}
min={-2}
max={4}
step={0.1}
/>
<NumberInput
className='config-item-top'
label={_('Text Indent')}
value={textIndent}
onChange={setTextIndent}
min={-2}
max={4}
step={1}
/>
<div className='config-item config-item-bottom'>
<span className=''>{_('Full Justification')}</span>
<input
@@ -213,6 +309,15 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onChange={() => setHyphenation(!hyphenation)}
/>
</div>
<div className='config-item config-item-bottom'>
<span className=''>{_('Override Book Layout')}</span>
<input
type='checkbox'
className='toggle'
checked={overrideLayout}
onChange={() => setOverrideLayout(!overrideLayout)}
/>
</div>
</div>
</div>
</div>
@@ -30,7 +30,7 @@ const BookCard = ({ book }: { book: Book }) => {
/>
<div className='min-w-0 flex-1'>
<h4 className='line-clamp-2 w-[90%] text-sm font-semibold'>{formatTitle(title)}</h4>
<p className='text-neutral-content truncate text-xs'>{formatAuthors(undefined, author)}</p>
<p className='text-neutral-content truncate text-xs'>{formatAuthors(author)}</p>
</div>
<button
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
@@ -2,16 +2,17 @@ import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { BookDoc } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useBookDataStore } from '@/store/bookDataStore';
import TOCView from './TOCView';
import BooknoteView from './BooknoteView';
import TabNavigation from './TabNavigation';
import { isPWA } from '@/services/environment';
const SidebarContent: React.FC<{
bookDoc: BookDoc;
sideBarBookKey: string;
}> = ({ bookDoc, sideBarBookKey }) => {
const { appService } = useEnv();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const { getConfig, setConfig } = useBookDataStore();
const config = getConfig(sideBarBookKey);
@@ -92,7 +93,10 @@ const SidebarContent: React.FC<{
</div>
</div>
<div
className={clsx('flex-shrink-0', isPWA() ? 'pb-[calc(env(safe-area-inset-bottom)/2)]' : '')}
className={clsx(
'flex-shrink-0',
appService?.hasSafeAreaInset && 'pb-[calc(env(safe-area-inset-bottom)/2)]',
)}
>
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
</div>
@@ -7,7 +7,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { BookSearchResult } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import SidebarHeader from './Header';
import SidebarContent from './Content';
@@ -24,6 +24,7 @@ const MAX_SIDEBAR_WIDTH = 0.45;
const SideBar: React.FC<{
onGoToLibrary: () => void;
}> = ({ onGoToLibrary }) => {
const { appService } = useEnv();
const { updateAppTheme } = useTheme();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
@@ -117,8 +118,10 @@ const SideBar: React.FC<{
<>
<div
className={clsx(
'sidebar-container bg-base-200 z-20 flex h-full min-w-60 select-none flex-col',
isTauriAppPlatform() && 'rounded-window-top-left rounded-window-bottom-left',
'sidebar-container bg-base-200 z-20 flex min-w-60 select-none flex-col',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
appService?.hasRoundedWindow && 'rounded-window-top-left rounded-window-bottom-left',
!isSideBarPinned && 'shadow-2xl',
)}
style={{
@@ -120,7 +120,7 @@ const TOCView: React.FC<{
};
useEffect(() => {
if (!progress) return;
if (!progress || eventDispatcher.dispatchSync('tts-is-speaking')) return;
const { sectionHref: currentHref } = progress;
const hrefMd5 = currentHref ? getContentMd5(currentHref) : '';
const currentItem = viewRef.current?.querySelector(`[data-href="${hrefMd5}"]`);
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -10,7 +11,6 @@ import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { isPWA } from '@/services/environment';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
@@ -21,6 +21,7 @@ const POPUP_PADDING = 10;
const TTSControl = () => {
const _ = useTranslation();
const { appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const [bookKey, setBookKey] = useState<string>('');
@@ -273,8 +274,10 @@ const TTSControl = () => {
<div
ref={iconRef}
className={clsx(
'absolute right-6 h-12 w-12',
isPWA() ? 'bottom-[calc(env(safe-area-inset-bottom)+48px)]' : 'bottom-12',
'fixed right-6 h-12 w-12',
appService?.hasSafeAreaInset
? 'bottom-[calc(env(safe-area-inset-bottom)+48px)]'
: 'bottom-12',
)}
>
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
@@ -15,9 +15,6 @@ export const useClickEvent = (
if (msg.data && msg.data.bookKey === bookKey) {
const viewSettings = getViewSettings(bookKey)!;
if (msg.data.type === 'iframe-single-click') {
if (viewSettings.disableClick!) {
return;
}
const viewElement = containerRef.current;
if (viewElement) {
const rect = viewElement.getBoundingClientRect();
@@ -32,9 +29,9 @@ export const useClickEvent = (
if (screenX >= centerStartX && screenX <= centerEndX) {
// toggle visibility of the header bar and the footer bar
setHoveredBookKey(hoveredBookKey ? null : bookKey);
} else if (screenX >= rect.left + rect.width / 2) {
} else if (!viewSettings.disableClick! && screenX >= rect.left + rect.width / 2) {
viewRef.current?.goRight();
} else if (screenX < rect.left + rect.width / 2) {
} else if (!viewSettings.disableClick! && screenX < rect.left + rect.width / 2) {
viewRef.current?.goLeft();
}
}
@@ -0,0 +1,31 @@
import { useCallback, useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { throttle } from '@/utils/throttle';
export const useProgressAutoSave = (bookKey: string) => {
const { envConfig, appService } = useEnv();
const { getConfig, saveConfig } = useBookDataStore();
const { getProgress } = useReaderStore();
const progress = getProgress(bookKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
const saveBookConfig = useCallback(
throttle(async () => {
const config = getConfig(bookKey)!;
const settings = useSettingsStore.getState().settings;
await saveConfig(envConfig, bookKey, config, settings);
}, 10000),
[],
);
useEffect(() => {
// FIXME: Save book config when progress changes on Android
// until we can hook into the lifecycle of the Android app
if (!appService?.isAndroidApp || !progress) return;
saveBookConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress, bookKey]);
};
@@ -1,5 +1,13 @@
const doubleClickThreshold = 250;
const longHoldThreshold = 500;
import {
DISABLE_DOUBLE_CLICK_ON_MOBILE,
DOUBLE_CLICK_INTERVAL_THRESHOLD_MS,
LONG_HOLD_THRESHOLD,
} from '@/services/constants';
import { getOSPlatform } from '@/utils/misc';
const doubleClickEnabled =
!DISABLE_DOUBLE_CLICK_ON_MOBILE || !['android', 'ios'].includes(getOSPlatform());
let lastClickTime = 0;
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -25,7 +33,8 @@ export const handleKeydown = (bookKey: string, event: KeyboardEvent) => {
export const handleMousedown = (bookKey: string, event: MouseEvent) => {
longHoldTimeout = setTimeout(() => {
longHoldTimeout = null;
}, longHoldThreshold);
}, LONG_HOLD_THRESHOLD);
window.postMessage(
{
type: 'iframe-mousedown',
@@ -86,7 +95,7 @@ export const handleWheel = (bookKey: string, event: WheelEvent) => {
export const handleClick = (bookKey: string, event: MouseEvent) => {
const now = Date.now();
if (now - lastClickTime < doubleClickThreshold) {
if (doubleClickEnabled && now - lastClickTime < DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
lastClickTime = now;
window.postMessage(
{
@@ -106,35 +115,43 @@ export const handleClick = (bookKey: string, event: MouseEvent) => {
lastClickTime = now;
setTimeout(() => {
if (Date.now() - lastClickTime >= doubleClickThreshold) {
let element: HTMLElement | null = event.target as HTMLElement;
while (element) {
if (['sup', 'a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
return;
}
element = element.parentElement;
}
if (!longHoldTimeout) {
const postSingleClick = () => {
let element: HTMLElement | null = event.target as HTMLElement;
while (element) {
if (['sup', 'a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
return;
}
window.postMessage(
{
type: 'iframe-single-click',
bookKey,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
offsetX: event.offsetX,
offsetY: event.offsetY,
},
'*',
);
element = element.parentElement;
}
}, doubleClickThreshold);
// if long hold is detected, we don't want to send single click event
if (!longHoldTimeout) {
return;
}
window.postMessage(
{
type: 'iframe-single-click',
bookKey,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
offsetX: event.offsetX,
offsetY: event.offsetY,
},
'*',
);
};
if (doubleClickEnabled) {
setTimeout(() => {
if (Date.now() - lastClickTime >= DOUBLE_CLICK_INTERVAL_THRESHOLD_MS) {
postSingleClick();
}
}, DOUBLE_CLICK_INTERVAL_THRESHOLD_MS);
} else {
postSingleClick();
}
};
const handleTouchEv = (bookKey: string, event: TouchEvent, type: string) => {
+39 -37
View File
@@ -1,49 +1,51 @@
import React from 'react';
import clsx from 'clsx';
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
const Alert: React.FC<{
title: string;
message: string;
onClickCancel: () => void;
onClickConfirm: () => void;
}> = ({ title, message, onClickCancel, onClickConfirm }) => {
onCancel: () => void;
onConfirm: () => void;
}> = ({ title, message, onCancel, onConfirm }) => {
const _ = useTranslation();
return (
<div
role='alert'
className={clsx(
'alert fixed bottom-4 left-1/2 z-[100] flex -translate-x-1/2 transform items-center justify-between',
'bg-base-300 rounded-lg 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]',
)}
>
<div className='flex items-center space-x-2'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
className='stroke-info h-6 w-6 shrink-0'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
></path>
</svg>
<div className=''>
<h3 className='font-bold'>{title}</h3>
<div className='text-xs'>{message}</div>
<div className={clsx('z-[100] flex justify-center px-4')}>
<div
role='alert'
className={clsx(
'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]',
)}
>
<div className='flex items-center space-x-2'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
className='stroke-info h-6 w-6 shrink-0'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
></path>
</svg>
<div className=''>
<h3 className='font-sm text-base'>{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}>
{_('Cancel')}
</button>
<button className='btn btn-sm btn-warning' onClick={onConfirm}>
{_('Confirm')}
</button>
</div>
</div>
<div className='flex space-x-2'>
<button className='btn btn-sm' onClick={onClickCancel}>
{_('Cancel')}
</button>
<button className='btn btn-sm btn-warning' onClick={onClickConfirm}>
{_('Confirm')}
</button>
</div>
</div>
);
@@ -74,7 +74,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
);
return (
<>
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Dialog
title={_('Book Details')}
isOpen={isOpen}
@@ -83,7 +83,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
boxClassName='sm:min-w-[480px]'
contentClassName='!px-6 !py-2'
>
<div className='z-50 flex w-full select-text items-center justify-center'>
<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'>
@@ -115,7 +115,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
{formatTitle(book.title) || _('Untitled')}
</p>
<p className='text-neutral-content line-clamp-1'>
{formatAuthors(bookMeta.language, book.author) || _('Unknown')}
{formatAuthors(book.author, bookMeta.language) || _('Unknown')}
</p>
</div>
{window.innerWidth >= 400 && (
@@ -196,13 +196,13 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the selected books?')}
onClickCancel={() => {
onCancel={() => {
setShowDeleteAlert(false);
}}
onClickConfirm={confirmDelete}
onConfirm={confirmDelete}
/>
)}
</>
</div>
);
};
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React, { ReactNode, useEffect } from 'react';
import { MdArrowBackIosNew } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface DialogProps {
@@ -26,6 +27,7 @@ const Dialog: React.FC<DialogProps> = ({
contentClassName,
onClose,
}) => {
const { appService } = useEnv();
const iconSize22 = useResponsiveSize(22);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -54,6 +56,7 @@ const Dialog: React.FC<DialogProps> = ({
className={clsx(
'modal-box settings-content flex h-full max-h-full w-full max-w-full flex-col rounded-none p-0 sm:rounded-2xl',
'sm:h-[60%] sm:w-[65%] sm:max-w-[600px]',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
boxClassName,
)}
>
+5 -1
View File
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import { useTranslation } from '@/hooks/useTranslation';
@@ -9,7 +10,10 @@ const Spinner: React.FC<{
return (
<div
className='absolute left-1/2 top-4 -translate-x-1/2 transform pt-16 text-center'
className={clsx(
'absolute left-1/2 -translate-x-1/2 transform text-center',
'top-4 pt-[calc(env(safe-area-inset-top)+64px)]',
)}
role='status'
>
<span className='loading loading-dots loading-lg'></span>
+8 -6
View File
@@ -1,6 +1,6 @@
import { eventDispatcher } from '@/utils/event';
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { eventDispatcher } from '@/utils/event';
export type ToastType = 'info' | 'success' | 'warning' | 'error';
@@ -12,9 +12,9 @@ export const Toast = () => {
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastClassMap = {
info: 'toast-info toast-center toast-middle',
success: 'toast-success toast-top toast-end pt-11',
warning: 'toast-warning toast-top toast-end pt-11',
error: 'toast-error toast-top toast-end pt-11',
success: 'toast-success toast-top toast-end',
warning: 'toast-warning toast-top toast-end',
error: 'toast-error toast-top toast-end',
};
const alertClassMap = {
info: 'alert-primary',
@@ -32,10 +32,10 @@ export const Toast = () => {
}, [toastMessage]);
const handleShowToast = async (event: CustomEvent) => {
const { message, type = 'info', timeout = 5000, className = '' } = event.detail;
const { message, type = 'info', timeout, className = '' } = event.detail;
setToastMessage(message);
toastType.current = type;
toastTimeout.current = timeout;
if (timeout) toastTimeout.current = timeout;
messageClass.current = className;
};
@@ -52,6 +52,8 @@ export const Toast = () => {
className={clsx(
'toast toast-center toast-middle w-auto max-w-screen-sm',
toastClassMap[toastType.current],
toastClassMap[toastType.current].includes('toast-top') &&
'pt-[calc(44px+env(safe-area-inset-top))]',
)}
>
<div
+131
View File
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseLongPressOptions {
onLongPress?: () => void;
onTap?: () => void;
onCancel?: () => void;
threshold?: number;
moveThreshold?: number;
}
interface UseLongPressResult {
pressing: boolean;
handlers: {
onPointerDown: (e: React.PointerEvent) => void;
onPointerUp: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerCancel: (e: React.PointerEvent) => void;
onPointerLeave: (e: React.PointerEvent) => void;
onContextMenu: (e: React.MouseEvent) => void;
};
}
export const useLongPress = ({
onLongPress,
onTap,
onCancel,
threshold = 500,
moveThreshold = 10,
}: UseLongPressOptions): UseLongPressResult => {
const [pressing, setPressing] = useState(false);
const timerRef = useRef<NodeJS.Timeout>();
const startPosRef = useRef<{ x: number; y: number } | null>(null);
const pointerId = useRef<number | null>(null);
const isLongPressTriggered = useRef(false);
const reset = useCallback(() => {
setPressing(false);
isLongPressTriggered.current = false;
startPosRef.current = null;
pointerId.current = null;
clearTimeout(timerRef.current);
}, []);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
pointerId.current = e.pointerId;
startPosRef.current = { x: e.clientX, y: e.clientY };
isLongPressTriggered.current = false;
setPressing(true);
timerRef.current = setTimeout(() => {
if (startPosRef.current) {
isLongPressTriggered.current = true;
onLongPress?.();
setPressing(false);
}
}, threshold);
},
[onLongPress, threshold],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (e.pointerId !== pointerId.current || !startPosRef.current) return;
const deltaX = Math.abs(e.clientX - startPosRef.current.x);
const deltaY = Math.abs(e.clientY - startPosRef.current.y);
if (deltaX > moveThreshold || deltaY > moveThreshold) {
onCancel?.();
reset();
}
},
[moveThreshold, onCancel, reset],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
if (e.pointerId !== pointerId.current) return;
if (!isLongPressTriggered.current && startPosRef.current) {
const deltaX = Math.abs(e.clientX - startPosRef.current.x);
const deltaY = Math.abs(e.clientY - startPosRef.current.y);
if (deltaX <= moveThreshold && deltaY <= moveThreshold) {
onTap?.();
}
}
reset();
},
[onTap, moveThreshold, reset],
);
const handleCancel = useCallback(
(e: React.PointerEvent) => {
if (e.pointerId !== pointerId.current) return;
onCancel?.();
reset();
},
[onCancel, reset],
);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
if (isLongPressTriggered.current) {
e.preventDefault();
}
}, []);
useEffect(() => {
return () => {
clearTimeout(timerRef.current);
};
}, []);
return {
pressing,
handlers: {
onPointerDown: handlePointerDown,
onPointerUp: handlePointerUp,
onPointerMove: handlePointerMove,
onPointerCancel: handleCancel,
onPointerLeave: handleCancel,
onContextMenu: handleContextMenu,
},
};
};
+14 -3
View File
@@ -20,6 +20,9 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
const el = ref.current;
if (!el) return;
if (el.scrollTop > 0) return;
const initialX = startEvent.touches[0]!.clientX;
const initialY = startEvent.touches[0]!.clientY;
el.addEventListener('touchmove', handleTouchMove, { passive: false });
@@ -29,10 +32,12 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
const el = ref.current;
if (!el) return;
const currentX = moveEvent.touches[0]!.clientX;
const currentY = moveEvent.touches[0]!.clientY;
const dx = currentX - initialX;
const dy = currentY - initialY;
if (dy < 0 || Math.abs(dx) * 2 > Math.abs(dy)) return;
if (dy < 0) return;
const parentEl = el.parentNode as HTMLDivElement;
if (dy > TRIGGER_THRESHOLD) {
flipArrow(parentEl);
@@ -42,7 +47,10 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
removePullIndicator(parentEl);
}
el.style.transform = `translateY(${appr(dy)}px)`;
const wrapper = el.querySelector('.transform-wrapper') as HTMLElement;
if (wrapper) {
wrapper.style.transform = `translate3d(0, ${appr(dy)}px, 0)`;
}
}
function addPullIndicator(el: HTMLDivElement) {
@@ -82,7 +90,10 @@ export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger
const el = ref.current;
if (!el) return;
el.style.transform = 'translateY(0)';
const wrapper = el.querySelector('.transform-wrapper') as HTMLElement;
if (wrapper) {
wrapper.style.transform = 'translateY(0)';
}
removePullIndicator(el.parentNode as HTMLDivElement);
el.style.transition = 'transform 0.2s';
+10 -2
View File
@@ -29,22 +29,28 @@ import {
CLOUD_BOOKS_SUBDIR,
DEFAULT_MOBILE_VIEW_SETTINGS,
DEFAULT_SYSTEM_SETTINGS,
DEFAULT_CJK_VIEW_SETTINGS,
} from './constants';
import { isWebAppPlatform } from './environment';
import { getOSPlatform, isValidURL } from '@/utils/misc';
import { getOSPlatform, isCJKEnv, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
import { ProgressHandler } from '@/utils/transfer';
export abstract class BaseAppService implements AppService {
osPlatform: string = getOSPlatform();
isMobile: boolean = ['android', 'ios'].includes(getOSPlatform());
localBooksDir: string = '';
abstract fs: FileSystem;
abstract appPlatform: AppPlatform;
abstract isAppDataSandbox: boolean;
abstract isAndroidApp: boolean;
abstract isIOSApp: boolean;
abstract hasTrafficLight: boolean;
abstract hasWindowBar: boolean;
abstract hasContextMenu: boolean;
abstract hasRoundedWindow: boolean;
abstract hasSafeAreaInset: boolean;
abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string };
abstract getCoverImageUrl(book: Book): string;
@@ -78,6 +84,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...(this.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...settings.globalViewSettings,
@@ -93,6 +100,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...(this.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
},
@@ -156,7 +164,7 @@ export abstract class BaseAppService implements AppService {
hash,
format,
title: formatTitle(loadedBook.metadata.title),
author: formatAuthors(loadedBook.metadata.language, loadedBook.metadata.author),
author: formatAuthors(loadedBook.metadata.author, loadedBook.metadata.language),
createdAt: existingBook ? existingBook.createdAt : Date.now(),
uploadedAt: existingBook ? existingBook.uploadedAt : null,
downloadedAt: Date.now(),
+17 -1
View File
@@ -13,8 +13,10 @@ import { UserStorageQuota } from '@/types/user';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'pdf'];
export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'zip', 'cbz', 'pdf'];
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
export const BOOK_UNGROUPED_NAME = '';
export const BOOK_UNGROUPED_ID = '';
export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
keepLogin: false,
@@ -67,12 +69,17 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
export const DEFAULT_BOOK_STYLE: BookStyle = {
zoomLevel: 100,
paragraphMargin: 1,
lineHeight: 1.6,
wordSpacing: 0,
letterSpacing: 0,
textIndent: 0,
fullJustification: true,
hyphenation: true,
invert: false,
theme: 'light',
overrideFont: false,
overrideLayout: false,
userStylesheet: '',
};
@@ -80,6 +87,11 @@ export const DEFAULT_MOBILE_VIEW_SETTINGS: Partial<ViewSettings> = {
fullJustification: false,
};
export const DEFAULT_CJK_VIEW_SETTINGS: Partial<ViewSettings> = {
fullJustification: true,
textIndent: 2,
};
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
sideBarTab: 'toc',
};
@@ -388,3 +400,7 @@ export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
plus: 2 * 1024 * 1024 * 1024,
pro: 10 * 1024 * 1024 * 1024,
};
export const DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250;
export const DISABLE_DOUBLE_CLICK_ON_MOBILE = true;
export const LONG_HOLD_THRESHOLD = 500;
@@ -23,7 +23,7 @@ import { isValidURL } from '@/utils/misc';
import { BaseAppService } from './appService';
import { LOCAL_BOOKS_SUBDIR } from './constants';
export const isMobile = ['android', 'ios'].includes(osType());
const OS_TYPE = osType();
const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => {
switch (base) {
@@ -117,10 +117,14 @@ export const nativeFileSystem: FileSystem = {
export class NativeAppService extends BaseAppService {
fs = nativeFileSystem;
appPlatform = 'tauri' as AppPlatform;
isAppDataSandbox = isMobile;
hasTrafficLight = osType() === 'macos';
hasWindowBar = !(osType() === 'ios' || osType() === 'android');
hasContextMenu = !(osType() === 'ios' || osType() === 'android');
isAppDataSandbox = ['android', 'ios'].includes(OS_TYPE);
isAndroidApp = OS_TYPE === 'android';
isIOSApp = OS_TYPE === 'ios';
hasTrafficLight = OS_TYPE === 'macos';
hasWindowBar = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
hasContextMenu = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android');
hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android';
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
@@ -242,7 +242,6 @@ export class TTSController extends EventTarget {
}
async kill() {
this.state = 'stopped';
await this.ttsClient.stop();
await this.stop();
}
}
@@ -3,6 +3,7 @@ import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getCoverFilename } from '@/utils/book';
import { isValidURL } from '@/utils/misc';
import { isPWA } from './environment';
import { BaseAppService } from './appService';
import { LOCAL_BOOKS_SUBDIR } from './constants';
@@ -179,9 +180,13 @@ export class WebAppService extends BaseAppService {
fs = indexedDBFileSystem;
appPlatform = 'web' as AppPlatform;
isAppDataSandbox = false;
isAndroidApp = false;
isIOSApp = false;
hasTrafficLight = false;
hasWindowBar = false;
hasContextMenu = false;
hasRoundedWindow = false;
hasSafeAreaInset = isPWA();
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
+1 -1
View File
@@ -212,7 +212,7 @@ foliate-view {
.pull-indicator {
position: fixed;
top: 64px;
top: calc(64px + env(safe-area-inset-top));
width: 100%;
background-color: transparent;
display: flex;
+14 -1
View File
@@ -10,7 +10,9 @@ export interface Book {
format: BookFormat;
title: string;
author: string;
group?: string;
group?: string; // deprecated in favor of groupId and groupName
groupId?: string;
groupName?: string;
tags?: string[];
coverImageUrl?: string | null;
@@ -25,6 +27,11 @@ export interface Book {
progress?: [number, number]; // Add progress field: [current, total]
}
export interface BookGroupType {
id: string;
name: string;
}
export interface PageInfo {
current: number;
next?: number;
@@ -63,12 +70,17 @@ export interface BookLayout {
export interface BookStyle {
zoomLevel: number;
paragraphMargin: number;
lineHeight: number;
wordSpacing: number;
letterSpacing: number;
textIndent: number;
fullJustification: boolean;
hyphenation: boolean;
invert: boolean;
theme: string;
overrideFont: boolean;
overrideLayout: boolean;
userStylesheet: string;
}
@@ -152,6 +164,7 @@ export interface BookDataRecord {
}
export interface BooksGroup {
id: string;
name: string;
books: Book[];
+2 -1
View File
@@ -4,7 +4,8 @@ export interface DBBook {
format: string;
title: string;
author: string;
group?: string;
group_id?: string;
group_name?: string;
tags?: string[];
progress?: [number, number];
+5
View File
@@ -22,12 +22,17 @@ export interface FileSystem {
export interface AppService {
fs: FileSystem;
osPlatform: string;
appPlatform: AppPlatform;
hasTrafficLight: boolean;
hasWindowBar: boolean;
hasContextMenu: boolean;
hasRoundedWindow: boolean;
hasSafeAreaInset: boolean;
isMobile: boolean;
isAppDataSandbox: boolean;
isAndroidApp: boolean;
isIOSApp: boolean;
selectFiles(name: string, extensions: string[]): Promise<string[]>;
showMessage(msg: string, kind?: ToastType, title?: string, okLabel?: string): Promise<void>;
+1 -1
View File
@@ -64,8 +64,8 @@ export const getBookLangCode = (lang: string | string[] | undefined) => {
};
export const formatAuthors = (
bookLang: string | string[] | undefined,
contributors: string | Contributor | [string | Contributor],
bookLang?: string | string[],
) => {
const langCode = getBookLangCode(bookLang);
return Array.isArray(contributors)
+1 -1
View File
@@ -17,7 +17,7 @@ export const getSysFontsList = async (): Promise<string[]> => {
const osPlatform = getOSPlatform();
if (FONT_ENUM_SUPPORTED_OS_PLATFORMS.includes(osPlatform)) {
const fonts = await invoke<string[]>('list_fonts');
cachedSysFonts = fonts.filter((font) => !isSymbolicFontName(font)).sort();
cachedSysFonts = [...new Set(fonts.filter((font) => !isSymbolicFontName(font)))].sort();
console.log('Fetched font list:', cachedSysFonts);
return cachedSysFonts;
} else {
+8
View File
@@ -1,5 +1,13 @@
import { md5 } from 'js-md5';
export function isMd5(value: string): boolean {
return /^[0-9a-f]{32}$/.test(value);
}
export function md5Fingerprint(value: string): string {
return md5(value).slice(0, 7);
}
export async function partialMD5(file: File): Promise<string> {
const step = 1024;
const size = 1024;

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