forked from akai/readest
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2048c7f33e | |||
| 792e8657f7 | |||
| 7a786478f1 | |||
| 0963969330 | |||
| 6889d6341f | |||
| 44885f2901 | |||
| b6c8bfb52b | |||
| 90e46860a5 | |||
| fef75f123d | |||
| 27c6ed402e | |||
| 85c14002c6 | |||
| b7d06b6312 | |||
| cb04af1b17 | |||
| 01ff687472 | |||
| 27eca300fa | |||
| 377f5c314d | |||
| c0d715bf77 | |||
| 171a7ee759 | |||
| efd83180f1 | |||
| 4cea0bf75a | |||
| a827316982 | |||
| dc8cae5c77 | |||
| 0fd0204869 | |||
| 8fd92efbc7 | |||
| 4ec4b22038 | |||
| 6b09145546 | |||
| ae60606470 | |||
| 6c6af63ecc | |||
| 942524c110 | |||
| 18dfd73bed | |||
| 58d3e7b221 | |||
| d757555fcb | |||
| 6315e4b751 |
@@ -9,7 +9,7 @@ jobs:
|
||||
rust_lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: "-C target-cpu=skylake"
|
||||
RUSTFLAGS: '-C target-cpu=skylake'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -20,7 +20,9 @@ jobs:
|
||||
toolchain: stable
|
||||
override: true
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get install -y libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev
|
||||
- name: Format
|
||||
working-directory: apps/readest-app/src-tauri
|
||||
run: cargo fmt --check
|
||||
|
||||
+5
-6
@@ -75,13 +75,12 @@ Now you're all setup and can start implementing your changes.
|
||||
|
||||
### Implement your changes
|
||||
|
||||
This project is a monorepo. The code for the `readest-app` is in the `app/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
|
||||
This project is a monorepo. The code for the `readest-app` is in the `apps/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | -------------------------------------------------- |
|
||||
| `pnpm dev-web` | Starts the development server for the web app only |
|
||||
| `TAURI_DEV_HOST=192.168.1.22 pnpm dev-web` | Start the dev server on a LAN address |
|
||||
| `pnpm build-web` | Builds the web app |
|
||||
| Command | Description |
|
||||
| ---------------- | -------------------------------------------------- |
|
||||
| `pnpm dev-web` | Starts the development server for the web app only |
|
||||
| `pnpm build-web` | Builds the web app |
|
||||
|
||||
Recommended Visual Studio Code plugins for development:
|
||||
|
||||
|
||||
Generated
+17
@@ -7,9 +7,12 @@ name = "Readest"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"cocoa 0.25.0",
|
||||
"futures-util",
|
||||
"log",
|
||||
"objc",
|
||||
"rand 0.8.5",
|
||||
"read-progress-stream",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -29,6 +32,9 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"thiserror 2.0.11",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3941,6 +3947,17 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "read-progress-stream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6435842fc2fea44b528719eb8c32203bbc1bb2f5b619fbe0c0a3d8350fd8d2a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.8"
|
||||
|
||||
@@ -39,20 +39,20 @@
|
||||
|
||||
<div align="left">✅ Implemented</div>
|
||||
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
|
||||
@@ -216,7 +216,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/OS-macOS%2C%20Windows%2C%20Linux%2C%20Web-green
|
||||
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%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
|
||||
|
||||
@@ -53,3 +53,6 @@ src-tauri/gen
|
||||
# Auto Generated PWA files
|
||||
/public/sw.js
|
||||
/public/workbox-*.js
|
||||
/public/fallback-*.js
|
||||
/public/swe-worker-*.js
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import withPWA from 'next-pwa';
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
const internalHost = process.env.TAURI_DEV_HOST || 'localhost';
|
||||
import withPWAInit from '@ducanh2912/next-pwa';
|
||||
|
||||
const isDev = process.env['NODE_ENV'] === 'development';
|
||||
const appPlatform = process.env['NEXT_PUBLIC_APP_PLATFORM'];
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Ensure Next.js uses SSG instead of SSR
|
||||
// https://nextjs.org/docs/pages/building-your-application/deploying/static-exports
|
||||
output: appPlatform === 'web' ? undefined : 'export',
|
||||
output: appPlatform === 'web' || isDev ? undefined : 'export',
|
||||
// Note: This feature is required to use the Next.js Image component in SSG mode.
|
||||
// See https://nextjs.org/docs/messages/export-image-api for different workarounds.
|
||||
images: {
|
||||
@@ -21,13 +17,23 @@ const nextConfig = {
|
||||
appIsrStatus: false,
|
||||
},
|
||||
// Configure assetPrefix or else the server won't properly resolve your assets.
|
||||
assetPrefix: isProd ? '' : `http://${internalHost}:3000`,
|
||||
assetPrefix: '',
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default withPWA({
|
||||
const withPWA = withPWAInit({
|
||||
dest: 'public',
|
||||
disable: appPlatform !== 'web',
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
})(nextConfig);
|
||||
disable: isDev || appPlatform !== 'web',
|
||||
cacheOnFrontEndNav: true,
|
||||
aggressiveFrontEndNavCaching: true,
|
||||
reloadOnOnline: true,
|
||||
swcMinify: true,
|
||||
fallbacks: {
|
||||
document: '/offline',
|
||||
},
|
||||
workboxOptions: {
|
||||
disableDevLogs: true,
|
||||
},
|
||||
});
|
||||
|
||||
export default withPWA(nextConfig);
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.735.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
@@ -54,6 +57,7 @@
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "15.1.0",
|
||||
"posthog-js": "^1.205.0",
|
||||
"react": "19.0.0",
|
||||
@@ -80,7 +84,6 @@
|
||||
"eslint-config-next": "15.0.3",
|
||||
"i18next-scanner": "^4.6.0",
|
||||
"mkdirp": "^3.0.1",
|
||||
"next-pwa": "^5.6.0",
|
||||
"node-env-run": "^4.0.2",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-cli": "^11.0.0",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Paralleles Lesen",
|
||||
"Published:": "Veröffentlicht:",
|
||||
"Publisher:": "Verlag:",
|
||||
"Reading progress synced": "Lesevorgang synchronisiert",
|
||||
"Reading Progress Synced": "Lesevorgang synchronisiert",
|
||||
"Reload Page": "Seite neu laden",
|
||||
"Reveal in File Explorer": "Im Datei-Explorer anzeigen",
|
||||
"Reveal in Finder": "Im Finder anzeigen",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Speichern",
|
||||
"Scrolled Mode": "Scroll-Modus",
|
||||
"Search": "Suchen",
|
||||
"Search books...": "Bücher suchen...",
|
||||
"Search Books...": "Bücher suchen...",
|
||||
"Search...": "Suchen...",
|
||||
"Select Book": "Buch auswählen",
|
||||
"Select books": "Bücher auswählen",
|
||||
"Select multiple books": "Mehrere Bücher auswählen",
|
||||
"Select Books": "Bücher auswählen",
|
||||
"Select Multiple Books": "Mehrere Bücher auswählen",
|
||||
"Sepia": "Sepia",
|
||||
"Serif Font": "Serifenschrift",
|
||||
"Show Book Details": "Buchdetails anzeigen",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Sprechen",
|
||||
"Subjects:": "Themen:",
|
||||
"System Fonts": "System-Schriftarten",
|
||||
"Table of Contents": "Inhaltsverzeichnis",
|
||||
"TTS not supported in this device": "TTS wird auf diesem Gerät nicht unterstützt",
|
||||
"Theme Color": "Themenfarbe",
|
||||
"Theme Mode": "Themenmodus",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Buch-Schriftart überschreiben",
|
||||
"Vertical Margins (px)": "Vertikale Ränder (px)",
|
||||
"Horizontal Margins (%)": "Horizontale Ränder (%)",
|
||||
"Apply to all books": "Auf alle Bücher anwenden",
|
||||
"Apply to this book": "Auf dieses Buch anwenden",
|
||||
"Apply to All Books": "Auf alle Bücher anwenden",
|
||||
"Apply to This Book": "Auf dieses Buch anwenden",
|
||||
"Unable to fetch the translation. Try again later.": "Übersetzung konnte nicht abgerufen werden. Versuchen Sie es später erneut.",
|
||||
"Update Now!": "Jetzt aktualisieren!",
|
||||
"Update": "Aktualisieren",
|
||||
"Check update": "Aktualisierung prüfen",
|
||||
"Check Update": "Aktualisierung prüfen",
|
||||
"Already the latest version": "Bereits die neueste Version",
|
||||
"Book Details": "Buchdetails",
|
||||
"Import books": "Bücher importieren",
|
||||
"From local file": "Aus lokaler Datei"
|
||||
"From Local File": "Aus lokaler Datei",
|
||||
"TOC": "Inhaltsverzeichnis",
|
||||
"Book uploaded: {{title}}": "Buch hochgeladen: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Fehler beim Hochladen des Buches: {{title}}",
|
||||
"Book downloaded: {{title}}": "Buch heruntergeladen: {{title}}",
|
||||
"Failed to download book: {{title}}": "Fehler beim Herunterladen des Buches: {{title}}",
|
||||
"Upload Book": "Buch hochladen",
|
||||
"Auto Upload Books to Cloud": "Bücher automatisch in die Cloud hochladen",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% des Cloud-Speichers verwendet.",
|
||||
"Storage": "Speicher",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Παράλληλη ανάγνωση",
|
||||
"Published:": "Δημοσιεύτηκε:",
|
||||
"Publisher:": "Εκδότης:",
|
||||
"Reading progress synced": "Ο συγχρονισμός προόδου ανάγνωσης ολοκληρώθηκε",
|
||||
"Reading Progress Synced": "Ο συγχρονισμός προόδου ανάγνωσης ολοκληρώθηκε",
|
||||
"Reload Page": "Επαναφόρτωση σελίδας",
|
||||
"Reveal in File Explorer": "Εμφάνιση στον εξερευνητή αρχείων",
|
||||
"Reveal in Finder": "Εμφάνιση στον Finder",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Αποθήκευση",
|
||||
"Scrolled Mode": "Λειτουργία κύλισης",
|
||||
"Search": "Αναζήτηση",
|
||||
"Search books...": "Αναζήτηση βιβλίων...",
|
||||
"Search Books...": "Αναζήτηση βιβλίων...",
|
||||
"Search...": "Αναζήτηση...",
|
||||
"Select Book": "Επιλογή βιβλίου",
|
||||
"Select books": "Επιλογή βιβλίων",
|
||||
"Select multiple books": "Επιλογή πολλαπλών βιβλίων",
|
||||
"Select Books": "Επιλογή βιβλίων",
|
||||
"Select Multiple Books": "Επιλογή πολλαπλών βιβλίων",
|
||||
"Sepia": "Σέπια",
|
||||
"Serif Font": "Γραμματοσειρά Serif",
|
||||
"Show Book Details": "Εμφάνιση λεπτομερειών βιβλίου",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Ομιλία",
|
||||
"Subjects:": "Θέματα:",
|
||||
"System Fonts": "Γραμματοσειρές συστήματος",
|
||||
"Table of Contents": "Πίνακας περιεχομένων",
|
||||
"TTS not supported in this device": "Η μετατροπή κειμένου σε ομιλία δεν υποστηρίζεται σε αυτή τη συσκευή",
|
||||
"Theme Color": "Χρώμα θέματος",
|
||||
"Theme Mode": "Λειτουργία θέματος",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Παράκαμψη γραμματοσειράς βιβλίου",
|
||||
"Vertical Margins (px)": "Κάθετα περιθώρια (px)",
|
||||
"Horizontal Margins (%)": "Οριζόντια περιθώρια (%)",
|
||||
"Apply to all books": "Εφαρμογή σε όλα τα βιβλία",
|
||||
"Apply to this book": "Εφαρμογή σε αυτό το βιβλίο",
|
||||
"Apply to All Books": "Εφαρμογή σε όλα τα βιβλία",
|
||||
"Apply to This Book": "Εφαρμογή σε αυτό το βιβλίο",
|
||||
"Unable to fetch the translation. Try again later.": "Αδυναμία λήψης της μετάφρασης. Δοκιμάστε ξανά αργότερα.",
|
||||
"Update Now!": "Ενημέρωση τώρα!",
|
||||
"Update": "Ενημέρωση",
|
||||
"Check update": "Έλεγχος ενημέρωσης",
|
||||
"Check Update": "Έλεγχος ενημέρωσης",
|
||||
"Already the latest version": "Ήδη η τελευταία έκδοση",
|
||||
"Book Details": "Λεπτομέρειες βιβλίου",
|
||||
"Import books": "Εισαγωγή βιβλίων",
|
||||
"From local file": "Από τοπικό αρχείο"
|
||||
"From Local File": "Από τοπικό αρχείο",
|
||||
"TOC": "Πίνακας περιεχομένων",
|
||||
"Book uploaded: {{title}}": "Το βιβλίο με τίτλο {{title}} ανέβηκε",
|
||||
"Failed to upload book: {{title}}": "Αποτυχία ανέβασμα βιβλίου: {{title}}",
|
||||
"Book downloaded: {{title}}": "Το βιβλίο με τίτλο {{title}} κατέβηκε",
|
||||
"Failed to download book: {{title}}": "Αποτυχία λήψης βιβλίου: {{title}}",
|
||||
"Upload Book": "Ανέβασμα βιβλίου",
|
||||
"Auto Upload Books to Cloud": "Αυτόματο ανέβασμα βιβλίων στο cloud",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% της αποθήκευσης στο cloud χρησιμοποιήθηκε.",
|
||||
"Storage": "Αποθήκευση",
|
||||
"Book deleted: {{title}}": "Το βιβλίο με τίτλο {{title}} διαγράφηκε",
|
||||
"Failed to delete book: {{title}}": "Αποτυχία διαγραφής βιβλίου: {{title}}",
|
||||
"Check Updates on Start": "Έλεγχος ενημερώσεων κατά την εκκίνηση"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Lectura paralela",
|
||||
"Published:": "Publicado:",
|
||||
"Publisher:": "Editorial:",
|
||||
"Reading progress synced": "Progreso de lectura sincronizado",
|
||||
"Reading Progress Synced": "Progreso de lectura sincronizado",
|
||||
"Reload Page": "Recargar página",
|
||||
"Reveal in File Explorer": "Mostrar en el Explorador de archivos",
|
||||
"Reveal in Finder": "Mostrar en Finder",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Guardar",
|
||||
"Scrolled Mode": "Modo desplazamiento",
|
||||
"Search": "Buscar",
|
||||
"Search books...": "Buscar libros...",
|
||||
"Search Books...": "Buscar libros...",
|
||||
"Search...": "Buscar...",
|
||||
"Select Book": "Seleccionar libro",
|
||||
"Select books": "Seleccionar libros",
|
||||
"Select multiple books": "Seleccionar varios libros",
|
||||
"Select Books": "Seleccionar libros",
|
||||
"Select Multiple Books": "Seleccionar varios libros",
|
||||
"Sepia": "Sepia",
|
||||
"Serif Font": "Fuente serif",
|
||||
"Show Book Details": "Mostrar detalles del libro",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Leer",
|
||||
"Subjects:": "Temas:",
|
||||
"System Fonts": "Fuentes del sistema",
|
||||
"Table of Contents": "Tabla de contenidos",
|
||||
"TTS not supported in this device": "TTS no es compatible con este dispositivo",
|
||||
"Theme Color": "Color del tema",
|
||||
"Theme Mode": "Modo del tema",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Sobrescribir fuente del libro",
|
||||
"Vertical Margins (px)": "Márgenes verticales (px)",
|
||||
"Horizontal Margins (%)": "Márgenes horizontales (%)",
|
||||
"Apply to all books": "Aplicar a todos los libros",
|
||||
"Apply to this book": "Aplicar a este libro",
|
||||
"Apply to All Books": "Aplicar a todos los libros",
|
||||
"Apply to This Book": "Aplicar a este libro",
|
||||
"Unable to fetch the translation. Try again later.": "No se puede obtener la traducción. Inténtalo de nuevo más tarde.",
|
||||
"Update Now!": "¡Actualizar ahora!",
|
||||
"Update": "Actualizar",
|
||||
"Check update": "Comprobar actualización",
|
||||
"Check Update": "Comprobar actualización",
|
||||
"Already the latest version": "Ya es la última versión",
|
||||
"Book Details": "Detalles del libro",
|
||||
"Import books": "Importar libros",
|
||||
"From local file": "Desde archivo local"
|
||||
"From Local File": "Desde archivo local",
|
||||
"TOC": "Índice",
|
||||
"Book uploaded: {{title}}": "Libro subido: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Error al subir libro: {{title}}",
|
||||
"Book downloaded: {{title}}": "Libro descargado: {{title}}",
|
||||
"Failed to download book: {{title}}": "Error al descargar libro: {{title}}",
|
||||
"Upload Book": "Subir libro",
|
||||
"Auto Upload Books to Cloud": "Subir libros automáticamente a la nube",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% del almacenamiento en la nube utilizado.",
|
||||
"Storage": "Almacenamiento",
|
||||
"Book deleted: {{title}}": "Libro eliminado: {{title}}",
|
||||
"Failed to delete book: {{title}}": "Error al eliminar libro: {{title}}",
|
||||
"Check Updates on Start": "Comprobar actualizaciones al iniciar"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Lecture parallèle",
|
||||
"Published:": "Publié :",
|
||||
"Publisher:": "Éditeur :",
|
||||
"Reading progress synced": "Progression de lecture synchronisée",
|
||||
"Reading Progress Synced": "Progression de lecture synchronisée",
|
||||
"Reload Page": "Recharger la page",
|
||||
"Reveal in File Explorer": "Afficher dans l'explorateur de fichiers",
|
||||
"Reveal in Finder": "Afficher dans le Finder",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Enregistrer",
|
||||
"Scrolled Mode": "Mode défilement",
|
||||
"Search": "Rechercher",
|
||||
"Search books...": "Rechercher des livres...",
|
||||
"Search Books...": "Rechercher des livres...",
|
||||
"Search...": "Rechercher...",
|
||||
"Select Book": "Sélectionner un livre",
|
||||
"Select books": "Sélectionner des livres",
|
||||
"Select multiple books": "Sélectionner plusieurs livres",
|
||||
"Select Books": "Sélectionner des livres",
|
||||
"Select Multiple Books": "Sélectionner plusieurs livres",
|
||||
"Sepia": "Sépia",
|
||||
"Serif Font": "Police avec empattement",
|
||||
"Show Book Details": "Afficher les détails du livre",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Lire",
|
||||
"Subjects:": "Sujets :",
|
||||
"System Fonts": "Polices système",
|
||||
"Table of Contents": "Table des matières",
|
||||
"TTS not supported in this device": "La synthèse vocale n'est pas prise en charge sur cet appareil",
|
||||
"Theme Color": "Couleur du thème",
|
||||
"Theme Mode": "Mode du thème",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Remplacer la police du livre",
|
||||
"Vertical Margins (px)": "Marges verticales (px)",
|
||||
"Horizontal Margins (%)": "Marges horizontales (%)",
|
||||
"Apply to all books": "Appliquer à tous les livres",
|
||||
"Apply to this book": "Appliquer à ce livre",
|
||||
"Apply to All Books": "Appliquer à tous les livres",
|
||||
"Apply to This Book": "Appliquer à ce livre",
|
||||
"Unable to fetch the translation. Try again later.": "Impossible de récupérer la traduction. Réessayez plus tard.",
|
||||
"Update Now!": "Mettre à jour maintenant !",
|
||||
"Update": "Mettre à jour",
|
||||
"Check update": "Vérifier la mise à jour",
|
||||
"Check Update": "Vérifier la mise à jour",
|
||||
"Already the latest version": "Déjà la dernière version",
|
||||
"Book Details": "Détails du livre",
|
||||
"Import books": "Importer des livres",
|
||||
"From local file": "Depuis un fichier local"
|
||||
"From Local File": "Depuis un fichier local",
|
||||
"TOC": "Table des matières",
|
||||
"Book uploaded: {{title}}": "Livre téléchargé : {{title}}",
|
||||
"Failed to upload book: {{title}}": "Échec du téléchargement du livre : {{title}}",
|
||||
"Book downloaded: {{title}}": "Livre téléchargé : {{title}}",
|
||||
"Failed to download book: {{title}}": "Échec du téléchargement du livre : {{title}}",
|
||||
"Upload Book": "Télécharger un livre",
|
||||
"Auto Upload Books to Cloud": "Télécharger automatiquement les livres dans le cloud",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% de l'espace de stockage dans le cloud utilisé.",
|
||||
"Storage": "Stockage",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "समानांतर पढ़ें",
|
||||
"Published:": "प्रकाशित:",
|
||||
"Publisher:": "प्रकाशक:",
|
||||
"Reading progress synced": "पढ़ने की प्रगति सिंक की गई",
|
||||
"Reading Progress Synced": "पढ़ने की प्रगति सिंक की गई",
|
||||
"Reload Page": "पृष्ठ रीलोड करें",
|
||||
"Reveal in File Explorer": "फ़ाइल एक्सप्लोरर में दिखाएं",
|
||||
"Reveal in Finder": "फाइंडर में दिखाएं",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "सहेजें",
|
||||
"Scrolled Mode": "स्क्रॉल मोड",
|
||||
"Search": "खोजें",
|
||||
"Search books...": "पुस्तकें खोजें...",
|
||||
"Search Books...": "पुस्तकें खोजें...",
|
||||
"Search...": "खोजें...",
|
||||
"Select Book": "पुस्तक चुनें",
|
||||
"Select books": "पुस्तकें चुनें",
|
||||
"Select multiple books": "कई पुस्तकें चुनें",
|
||||
"Select Books": "पुस्तकें चुनें",
|
||||
"Select Multiple Books": "कई पुस्तकें चुनें",
|
||||
"Sepia": "सेपिया",
|
||||
"Serif Font": "सेरिफ फ़ॉन्ट",
|
||||
"Show Book Details": "पुस्तक विवरण दिखाएं",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "बोलें",
|
||||
"Subjects:": "विषय:",
|
||||
"System Fonts": "सिस्टम फ़ॉन्ट्स",
|
||||
"Table of Contents": "विषय-सूची",
|
||||
"TTS not supported in this device": "यह डिवाइस TTS का समर्थन नहीं करता",
|
||||
"Theme Color": "थीम रंग",
|
||||
"Theme Mode": "थीम मोड",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "पुस्तक फ़ॉन्ट ओवरराइड करें",
|
||||
"Vertical Margins (px)": "ऊर्ध्वाधर मार्जिन (px)",
|
||||
"Horizontal Margins (%)": "क्षैतिज मार्जिन (%)",
|
||||
"Apply to all books": "सभी पुस्तकों पर लागू करें",
|
||||
"Apply to this book": "इस पुस्तक पर लागू करें",
|
||||
"Apply to All Books": "सभी पुस्तकों पर लागू करें",
|
||||
"Apply to This Book": "इस पुस्तक पर लागू करें",
|
||||
"Unable to fetch the translation. Try again later.": "अनुवाद प्राप्त करने में असमर्थ। बाद में पुनः प्रयास करें।",
|
||||
"Update Now!": "अभी अपडेट करें!",
|
||||
"Update": "अपडेट करें",
|
||||
"Check update": "अपडेट जांचें",
|
||||
"Check Update": "अपडेट जांचें",
|
||||
"Already the latest version": "पहले से ही नवीनतम संस्करण",
|
||||
"Book Details": "पुस्तक विवरण",
|
||||
"Import books": "पुस्तकें आयात करें",
|
||||
"From local file": "स्थानीय फ़ाइल से"
|
||||
"From Local File": "स्थानीय फ़ाइल से",
|
||||
"TOC": "सामग्री",
|
||||
"Book uploaded: {{title}}": "पुस्तक अपलोड की गई: {{title}}",
|
||||
"Failed to upload book: {{title}}": "पुस्तक अपलोड करने में विफल: {{title}}",
|
||||
"Book downloaded: {{title}}": "पुस्तक डाउनलोड की गई: {{title}}",
|
||||
"Failed to download book: {{title}}": "पुस्तक डाउनलोड करने में विफल: {{title}}",
|
||||
"Upload Book": "पुस्तक अपलोड करें",
|
||||
"Auto Upload Books to Cloud": "पुस्तकें बादल में स्वचालित रूप से अपलोड करें",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% क्लाउड स्टोरेज का उपयोग किया गया है।",
|
||||
"Storage": "स्टोरेज",
|
||||
"Book deleted: {{title}}": "पुस्तक हटाई गई: {{title}}",
|
||||
"Failed to delete book: {{title}}": "पुस्तक हटाने में विफल: {{title}}",
|
||||
"Check Updates on Start": "शुरू में अपडेट जांचें"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Baca Paralel",
|
||||
"Published:": "Diterbitkan:",
|
||||
"Publisher:": "Penerbit:",
|
||||
"Reading progress synced": "Progres membaca disinkronkan",
|
||||
"Reading Progress Synced": "Progres membaca disinkronkan",
|
||||
"Reload Page": "Muat Ulang Halaman",
|
||||
"Reveal in File Explorer": "Tampilkan di File Explorer",
|
||||
"Reveal in Finder": "Tampilkan di Finder",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Simpan",
|
||||
"Scrolled Mode": "Mode Gulir",
|
||||
"Search": "Cari",
|
||||
"Search books...": "Cari buku...",
|
||||
"Search Books...": "Cari buku...",
|
||||
"Search...": "Cari...",
|
||||
"Select Book": "Pilih Buku",
|
||||
"Select books": "Pilih buku",
|
||||
"Select multiple books": "Pilih beberapa buku",
|
||||
"Select Books": "Pilih buku",
|
||||
"Select Multiple Books": "Pilih beberapa buku",
|
||||
"Sepia": "Sepia",
|
||||
"Serif Font": "Font Serif",
|
||||
"Show Book Details": "Tampilkan Detail Buku",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Bicara",
|
||||
"Subjects:": "Subjek:",
|
||||
"System Fonts": "Font Sistem",
|
||||
"Table of Contents": "Daftar Isi",
|
||||
"TTS not supported in this device": "TTS tidak didukung di perangkat ini",
|
||||
"Theme Color": "Warna Tema",
|
||||
"Theme Mode": "Mode Tema",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Ganti Font Buku",
|
||||
"Vertical Margins (px)": "Margin Vertikal (px)",
|
||||
"Horizontal Margins (%)": "Margin Horizontal (%)",
|
||||
"Apply to all books": "Terapkan ke semua buku",
|
||||
"Apply to this book": "Terapkan ke buku ini",
|
||||
"Apply to All Books": "Terapkan ke semua buku",
|
||||
"Apply to This Book": "Terapkan ke buku ini",
|
||||
"Unable to fetch the translation. Try again later.": "Tidak dapat mengambil terjemahan. Coba lagi nanti.",
|
||||
"Update Now!": "Perbarui Sekarang!",
|
||||
"Update": "Perbarui",
|
||||
"Check update": "Periksa pembaruan",
|
||||
"Check Update": "Periksa pembaruan",
|
||||
"Already the latest version": "Sudah versi terbaru",
|
||||
"Book Details": "Detail Buku",
|
||||
"Import books": "Impor buku",
|
||||
"From local file": "Dari file lokal"
|
||||
"From Local File": "Dari file lokal",
|
||||
"TOC": "Daftar Isi",
|
||||
"Book uploaded: {{title}}": "Buku diunggah: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Gagal mengunggah buku: {{title}}",
|
||||
"Book downloaded: {{title}}": "Buku diunduh: {{title}}",
|
||||
"Failed to download book: {{title}}": "Gagal mengunduh buku: {{title}}",
|
||||
"Upload Book": "Unggah Buku",
|
||||
"Auto Upload Books to Cloud": "Unggah Buku Otomatis ke Cloud",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% dari Penyimpanan Cloud Digunakan.",
|
||||
"Storage": "Penyimpanan",
|
||||
"Book deleted: {{title}}": "Buku dihapus: {{title}}",
|
||||
"Failed to delete book: {{title}}": "Gagal menghapus buku: {{title}}",
|
||||
"Check Updates on Start": "Periksa Pembaruan saat Memulai"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Lettura parallela",
|
||||
"Published:": "Pubblicato:",
|
||||
"Publisher:": "Editore:",
|
||||
"Reading progress synced": "Progresso lettura sincronizzato",
|
||||
"Reading Progress Synced": "Progresso lettura sincronizzato",
|
||||
"Reload Page": "Ricarica pagina",
|
||||
"Reveal in File Explorer": "Mostra in Esplora file",
|
||||
"Reveal in Finder": "Mostra nel Finder",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Salva",
|
||||
"Scrolled Mode": "Modalità scorrimento",
|
||||
"Search": "Cerca",
|
||||
"Search books...": "Cerca libri...",
|
||||
"Search Books...": "Cerca libri...",
|
||||
"Search...": "Cerca...",
|
||||
"Select Book": "Seleziona libro",
|
||||
"Select books": "Seleziona libri",
|
||||
"Select multiple books": "Seleziona più libri",
|
||||
"Select Books": "Seleziona libri",
|
||||
"Select Multiple Books": "Seleziona più libri",
|
||||
"Sepia": "Seppia",
|
||||
"Serif Font": "Font serif",
|
||||
"Show Book Details": "Mostra dettagli libro",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Leggi",
|
||||
"Subjects:": "Argomenti:",
|
||||
"System Fonts": "Font di sistema",
|
||||
"Table of Contents": "Indice",
|
||||
"TTS not supported in this device": "TTS non supportato su questo dispositivo",
|
||||
"Theme Color": "Colore tema",
|
||||
"Theme Mode": "Modalità tema",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Sovrascrivi font libro",
|
||||
"Vertical Margins (px)": "Margini verticali (px)",
|
||||
"Horizontal Margins (%)": "Margini orizzontali (%)",
|
||||
"Apply to all books": "Applica a tutti i libri",
|
||||
"Apply to this book": "Applica a questo libro",
|
||||
"Apply to All Books": "Applica a tutti i libri",
|
||||
"Apply to This Book": "Applica a questo libro",
|
||||
"Unable to fetch the translation. Try again later.": "Impossibile recuperare la traduzione. Riprova più tardi.",
|
||||
"Update Now!": "Aggiorna ora!",
|
||||
"Update": "Aggiorna",
|
||||
"Check update": "Controlla aggiornamento",
|
||||
"Check Update": "Controlla aggiornamento",
|
||||
"Already the latest version": "Già l'ultima versione",
|
||||
"Book Details": "Dettagli libro",
|
||||
"Import books": "Importa libri",
|
||||
"From local file": "Da file locale"
|
||||
"From Local File": "Da file locale",
|
||||
"TOC": "Sommario",
|
||||
"Book uploaded: {{title}}": "Libro caricato: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Caricamento libro non riuscito: {{title}}",
|
||||
"Book downloaded: {{title}}": "Libro scaricato: {{title}}",
|
||||
"Failed to download book: {{title}}": "Download libro non riuscito: {{title}}",
|
||||
"Upload Book": "Carica libro",
|
||||
"Auto Upload Books to Cloud": "Carica libri automaticamente su cloud",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% dello spazio di archiviazione cloud utilizzato.",
|
||||
"Storage": "Archiviazione",
|
||||
"Book deleted: {{title}}": "Libro eliminato: {{title}}",
|
||||
"Failed to delete book: {{title}}": "Eliminazione libro non riuscita: {{title}}",
|
||||
"Check Updates on Start": "Controlla aggiornamenti all'avvio"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "並列読書",
|
||||
"Published:": "出版日:",
|
||||
"Publisher:": "出版社:",
|
||||
"Reading progress synced": "読書進捗が同期されました",
|
||||
"Reading Progress Synced": "読書進捗が同期されました",
|
||||
"Reload Page": "ページを再読み込み",
|
||||
"Reveal in File Explorer": "エクスプローラーで表示",
|
||||
"Reveal in Finder": "Finderで表示",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "保存",
|
||||
"Scrolled Mode": "スクロールモード",
|
||||
"Search": "検索",
|
||||
"Search books...": "書籍を検索...",
|
||||
"Search Books...": "書籍を検索...",
|
||||
"Search...": "検索...",
|
||||
"Select Book": "書籍を選択",
|
||||
"Select books": "書籍を選択",
|
||||
"Select multiple books": "複数の書籍を選択",
|
||||
"Select Books": "書籍を選択",
|
||||
"Select Multiple Books": "複数の書籍を選択",
|
||||
"Sepia": "セピア",
|
||||
"Serif Font": "明朝体",
|
||||
"Show Book Details": "書籍の詳細を表示",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "読み上げ",
|
||||
"Subjects:": "主題:",
|
||||
"System Fonts": "システムフォント",
|
||||
"Table of Contents": "目次",
|
||||
"TTS not supported in this device": "このデバイスではTTSがサポートされていません",
|
||||
"Theme Color": "テーマカラー",
|
||||
"Theme Mode": "テーマモード",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "書籍フォントを上書き",
|
||||
"Vertical Margins (px)": "縦マージン(px)",
|
||||
"Horizontal Margins (%)": "横マージン(%)",
|
||||
"Apply to all books": "すべての書籍に適用",
|
||||
"Apply to this book": "この書籍に適用",
|
||||
"Apply to All Books": "すべての書籍に適用",
|
||||
"Apply to This Book": "この書籍に適用",
|
||||
"Unable to fetch the translation. Try again later.": "翻訳を取得できません。後で再試行してください。",
|
||||
"Update Now!": "今すぐ更新!",
|
||||
"Update": "更新",
|
||||
"Check update": "更新を確認",
|
||||
"Check Update": "更新を確認",
|
||||
"Already the latest version": "すでに最新バージョン",
|
||||
"Book Details": "書籍の詳細",
|
||||
"Import books": "書籍をインポート",
|
||||
"From local file": "ローカルファイルから"
|
||||
"From Local File": "ローカルファイルから",
|
||||
"TOC": "目次",
|
||||
"Book uploaded: {{title}}": "書籍がアップロードされました:{{title}}",
|
||||
"Failed to upload book: {{title}}": "書籍のアップロードに失敗しました:{{title}}",
|
||||
"Book downloaded: {{title}}": "書籍がダウンロードされました:{{title}}",
|
||||
"Failed to download book: {{title}}": "書籍のダウンロードに失敗しました:{{title}}",
|
||||
"Upload Book": "書籍をアップロード",
|
||||
"Auto Upload Books to Cloud": "書籍を自動的にクラウドにアップロード",
|
||||
"{{percentage}}% of Cloud Storage Used.": "クラウドストレージの{{percentage}}%が使用されています。",
|
||||
"Storage": "ストレージ",
|
||||
"Book deleted: {{title}}": "書籍が削除されました:{{title}}",
|
||||
"Failed to delete book: {{title}}": "書籍の削除に失敗しました:{{title}}",
|
||||
"Check Updates on Start": "開始時に更新を確認"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "병렬 읽기",
|
||||
"Published:": "출판일:",
|
||||
"Publisher:": "출판사:",
|
||||
"Reading progress synced": "읽기 진행 상황 동기화됨",
|
||||
"Reading Progress Synced": "읽기 진행 상황 동기화됨",
|
||||
"Reload Page": "페이지 새로고침",
|
||||
"Reveal in File Explorer": "파일 탐색기에서 표시",
|
||||
"Reveal in Finder": "Finder에서 표시",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "저장",
|
||||
"Scrolled Mode": "스크롤 모드",
|
||||
"Search": "검색",
|
||||
"Search books...": "책 검색...",
|
||||
"Search Books...": "책 검색...",
|
||||
"Search...": "검색...",
|
||||
"Select Book": "책 선택",
|
||||
"Select books": "책 선택",
|
||||
"Select multiple books": "여러 책 선택",
|
||||
"Select Books": "책 선택",
|
||||
"Select Multiple Books": "여러 책 선택",
|
||||
"Sepia": "세피아",
|
||||
"Serif Font": "세리프체",
|
||||
"Show Book Details": "책 세부 정보 표시",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "말하기",
|
||||
"Subjects:": "주제:",
|
||||
"System Fonts": "시스템 글꼴",
|
||||
"Table of Contents": "목차",
|
||||
"TTS not supported in this device": "이 장치에서 TTS가 지원되지 않음",
|
||||
"Theme Color": "테마 색상",
|
||||
"Theme Mode": "테마 모드",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "책 글꼴 덮어쓰기",
|
||||
"Vertical Margins (px)": "수직 여백 (px)",
|
||||
"Horizontal Margins (%)": "수평 여백 (%)",
|
||||
"Apply to all books": "모든 책에 적용",
|
||||
"Apply to this book": "이 책에 적용",
|
||||
"Apply to All Books": "모든 책에 적용",
|
||||
"Apply to This Book": "이 책에 적용",
|
||||
"Unable to fetch the translation. Try again later.": "번역을 가져올 수 없습니다. 나중에 다시 시도하세요.",
|
||||
"Update Now!": "지금 업데이트!",
|
||||
"Update": "업데이트",
|
||||
"Check update": "업데이트 확인",
|
||||
"Check Update": "업데이트 확인",
|
||||
"Already the latest version": "이미 최신 버전",
|
||||
"Book Details": "책 세부 정보",
|
||||
"Import books": "책 가져오기",
|
||||
"From local file": "로컬 파일에서"
|
||||
"From Local File": "로컬 파일에서",
|
||||
"TOC": "목차",
|
||||
"Book uploaded: {{title}}": "책 업로드됨: {{title}}",
|
||||
"Failed to upload book: {{title}}": "책 업로드 실패: {{title}}",
|
||||
"Book downloaded: {{title}}": "책 다운로드됨: {{title}}",
|
||||
"Failed to download book: {{title}}": "책 다운로드 실패: {{title}}",
|
||||
"Upload Book": "책 업로드",
|
||||
"Auto Upload Books to Cloud": "책 자동으로 클라우드에 업로드",
|
||||
"{{percentage}}% of Cloud Storage Used.": "클라우드 저장소 사용량 {{percentage}}%.",
|
||||
"Storage": "저장소",
|
||||
"Book deleted: {{title}}": "책 삭제됨: {{title}}",
|
||||
"Failed to delete book: {{title}}": "책 삭제 실패: {{title}}",
|
||||
"Check Updates on Start": "시작 시 업데이트 확인"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Czytanie równoległe",
|
||||
"Published:": "Opublikowano:",
|
||||
"Publisher:": "Wydawca:",
|
||||
"Reading progress synced": "Zsynchronizowano postęp czytania",
|
||||
"Reading Progress Synced": "Zsynchronizowano postęp czytania",
|
||||
"Reload Page": "Odśwież stronę",
|
||||
"Reveal in File Explorer": "Pokaż w Eksploratorze plików",
|
||||
"Reveal in Finder": "Pokaż w Finderze",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Zapisz",
|
||||
"Scrolled Mode": "Tryb przewijania",
|
||||
"Search": "Szukaj",
|
||||
"Search books...": "Szukaj książek...",
|
||||
"Search Books...": "Szukaj książek...",
|
||||
"Search...": "Szukaj...",
|
||||
"Select Book": "Wybierz książkę",
|
||||
"Select books": "Wybierz książki",
|
||||
"Select multiple books": "Wybierz wiele książek",
|
||||
"Select Books": "Wybierz książki",
|
||||
"Select Multiple Books": "Wybierz wiele książek",
|
||||
"Sepia": "Sepia",
|
||||
"Serif Font": "Czcionka szeryfowa",
|
||||
"Show Book Details": "Pokaż szczegóły książki",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Czytaj",
|
||||
"Subjects:": "Tematy:",
|
||||
"System Fonts": "Czcionki systemowe",
|
||||
"Table of Contents": "Spis treści",
|
||||
"TTS not supported in this device": "TTS nie jest obsługiwane na tym urządzeniu",
|
||||
"Theme Color": "Kolor motywu",
|
||||
"Theme Mode": "Tryb motywu",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Nadpisz czcionkę książki",
|
||||
"Vertical Margins (px)": "Marginesy pionowe (px)",
|
||||
"Horizontal Margins (%)": "Marginesy poziome (%)",
|
||||
"Apply to all books": "Zastosuj do wszystkich książek",
|
||||
"Apply to this book": "Zastosuj do tej książki",
|
||||
"Apply to All Books": "Zastosuj do wszystkich książek",
|
||||
"Apply to This Book": "Zastosuj do tej książki",
|
||||
"Unable to fetch the translation. Try again later.": "Nie udało się pobrać tłumaczenia. Spróbuj ponownie później.",
|
||||
"Update Now!": "Zaktualizuj teraz!",
|
||||
"Update": "Zaktualizuj",
|
||||
"Check update": "Sprawdź aktualizację",
|
||||
"Check Update": "Sprawdź aktualizację",
|
||||
"Already the latest version": "Już najnowsza wersja",
|
||||
"Book Details": "Szczegóły książki",
|
||||
"Import books": "Importuj książki",
|
||||
"From local file": "Z pliku lokalnego"
|
||||
"From Local File": "Z pliku lokalnego",
|
||||
"TOC": "Spis treści",
|
||||
"Book uploaded: {{title}}": "Książka przesłana: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Nie udało się przesłać książki: {{title}}",
|
||||
"Book downloaded: {{title}}": "Książka pobrana: {{title}}",
|
||||
"Failed to download book: {{title}}": "Nie udało się pobrać książki: {{title}}",
|
||||
"Upload Book": "Prześlij książkę",
|
||||
"Auto Upload Books to Cloud": "Automatycznie przesyłaj książki do chmury",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% przestrzeni dyskowej w chmurze użytej.",
|
||||
"Storage": "Przechowywanie",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Leitura Paralela",
|
||||
"Published:": "Publicado:",
|
||||
"Publisher:": "Editora:",
|
||||
"Reading progress synced": "Progresso de leitura sincronizado",
|
||||
"Reading Progress Synced": "Progresso de leitura sincronizado",
|
||||
"Reload Page": "Recarregar Página",
|
||||
"Reveal in File Explorer": "Mostrar no Explorador de Arquivos",
|
||||
"Reveal in Finder": "Mostrar no Finder",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Salvar",
|
||||
"Scrolled Mode": "Modo de Rolagem",
|
||||
"Search": "Buscar",
|
||||
"Search books...": "Procurar livros...",
|
||||
"Search Books...": "Procurar livros...",
|
||||
"Search...": "Pesquisar...",
|
||||
"Select Book": "Selecionar Livro",
|
||||
"Select books": "Selecionar livros",
|
||||
"Select multiple books": "Selecionar múltiplos livros",
|
||||
"Select Books": "Selecionar livros",
|
||||
"Select Multiple Books": "Selecionar múltiplos livros",
|
||||
"Sepia": "Sépia",
|
||||
"Serif Font": "Fonte Serif",
|
||||
"Show Book Details": "Mostrar Detalhes do Livro",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Falar",
|
||||
"Subjects:": "Assuntos:",
|
||||
"System Fonts": "Fontes do Sistema",
|
||||
"Table of Contents": "Índice",
|
||||
"TTS not supported in this device": "TTS não suportado neste dispositivo",
|
||||
"Theme Color": "Cor do Tema",
|
||||
"Theme Mode": "Modo do Tema",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Sobrescrever Fonte do Livro",
|
||||
"Vertical Margins (px)": "Margens Verticais (px)",
|
||||
"Horizontal Margins (%)": "Margens Horizontais (%)",
|
||||
"Apply to all books": "Aplicar a todos os livros",
|
||||
"Apply to this book": "Aplicar a este livro",
|
||||
"Apply to All Books": "Aplicar a todos os livros",
|
||||
"Apply to This Book": "Aplicar a este livro",
|
||||
"Unable to fetch the translation. Try again later.": "Não foi possível buscar a tradução. Tente novamente mais tarde.",
|
||||
"Update Now!": "Atualizar Agora!",
|
||||
"Update": "Atualizar",
|
||||
"Check update": "Verificar atualização",
|
||||
"Check Update": "Verificar atualização",
|
||||
"Already the latest version": "Já é a versão mais recente",
|
||||
"Book Details": "Detalhes do Livro",
|
||||
"Import books": "Importar livros",
|
||||
"From local file": "Do arquivo local"
|
||||
"From Local File": "Do arquivo local",
|
||||
"TOC": "Índice",
|
||||
"Book uploaded: {{title}}": "Livro enviado: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Falha ao enviar livro: {{title}}",
|
||||
"Book downloaded: {{title}}": "Livro baixado: {{title}}",
|
||||
"Failed to download book: {{title}}": "Falha ao baixar livro: {{title}}",
|
||||
"Upload Book": "Enviar Livro",
|
||||
"Auto Upload Books to Cloud": "Enviar Livros para a Nuvem Automaticamente",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% do Armazenamento na Nuvem Usado.",
|
||||
"Storage": "Armazenamento",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Параллельное чтение",
|
||||
"Published:": "Опубликовано:",
|
||||
"Publisher:": "Издатель:",
|
||||
"Reading progress synced": "Синхронизирован прогресс чтения",
|
||||
"Reading Progress Synced": "Синхронизирован прогресс чтения",
|
||||
"Reload Page": "Перезагрузить страницу",
|
||||
"Reveal in File Explorer": "Показать в проводнике",
|
||||
"Reveal in Finder": "Показать в Finder",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Сохранить",
|
||||
"Scrolled Mode": "Режим прокрутки",
|
||||
"Search": "Поиск",
|
||||
"Search books...": "Поиск книг...",
|
||||
"Search Books...": "Поиск книг...",
|
||||
"Search...": "Поиск...",
|
||||
"Select Book": "Выбрать книгу",
|
||||
"Select books": "Выбрать книги",
|
||||
"Select multiple books": "Выбрать несколько книг",
|
||||
"Select Books": "Выбрать книги",
|
||||
"Select Multiple Books": "Выбрать несколько книг",
|
||||
"Sepia": "Сепия",
|
||||
"Serif Font": "Шрифт с засечками",
|
||||
"Show Book Details": "Показать детали книги",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Произнести",
|
||||
"Subjects:": "Темы:",
|
||||
"System Fonts": "Системные шрифты",
|
||||
"Table of Contents": "Содержание",
|
||||
"TTS not supported in this device": "TTS не поддерживается на этом устройстве",
|
||||
"Theme Color": "Цвет темы",
|
||||
"Theme Mode": "Режим темы",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Переопределить шрифт книги",
|
||||
"Vertical Margins (px)": "Вертикальные поля (px)",
|
||||
"Horizontal Margins (%)": "Горизонтальные поля (%)",
|
||||
"Apply to all books": "Применить ко всем книгам",
|
||||
"Apply to this book": "Применить к этой книге",
|
||||
"Apply to All Books": "Применить ко всем книгам",
|
||||
"Apply to This Book": "Применить к этой книге",
|
||||
"Unable to fetch the translation. Try again later.": "Не удалось получить перевод. Попробуйте позже.",
|
||||
"Update Now!": "Обновить сейчас!",
|
||||
"Update": "Обновить",
|
||||
"Check update": "Проверить обновление",
|
||||
"Check Update": "Проверить обновление",
|
||||
"Already the latest version": "Уже последняя версия",
|
||||
"Book Details": "Детали книги",
|
||||
"Import books": "Импортировать книги",
|
||||
"From local file": "Из локального файла"
|
||||
"From Local File": "Из локального файла",
|
||||
"TOC": "Содержание",
|
||||
"Book uploaded: {{title}}": "Книга загружена: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Не удалось загрузить книгу: {{title}}",
|
||||
"Book downloaded: {{title}}": "Книга скачана: {{title}}",
|
||||
"Failed to download book: {{title}}": "Не удалось скачать книгу: {{title}}",
|
||||
"Upload Book": "Загрузить книгу",
|
||||
"Auto Upload Books to Cloud": "Автоматическая загрузка книг в облако",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% использовано облачного хранилища.",
|
||||
"Storage": "Хранилище",
|
||||
"Book deleted: {{title}}": "Книга удалена: {{title}}",
|
||||
"Failed to delete book: {{title}}": "Не удалось удалить книгу: {{title}}",
|
||||
"Check Updates on Start": "Проверять обновления при запуске"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Paralel Okuma",
|
||||
"Published:": "Yayınlanma:",
|
||||
"Publisher:": "Yayıncı:",
|
||||
"Reading progress synced": "Okuma ilerlemesi senkronize edildi",
|
||||
"Reading Progress Synced": "Okuma ilerlemesi senkronize edildi",
|
||||
"Reload Page": "Sayfayı Yenile",
|
||||
"Reveal in File Explorer": "Dosya Gezgininde Göster",
|
||||
"Reveal in Finder": "Finder'da Göster",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Kaydet",
|
||||
"Scrolled Mode": "Kaydırma Modu",
|
||||
"Search": "Ara",
|
||||
"Search books...": "Kitap ara...",
|
||||
"Search Books...": "Kitap ara...",
|
||||
"Search...": "Ara...",
|
||||
"Select Book": "Kitap Seç",
|
||||
"Select books": "Kitapları seç",
|
||||
"Select multiple books": "Birden fazla kitap seç",
|
||||
"Select Books": "Kitapları seç",
|
||||
"Select Multiple Books": "Birden fazla kitap seç",
|
||||
"Sepia": "Sepya",
|
||||
"Serif Font": "Serif Yazı Tipi",
|
||||
"Show Book Details": "Kitap Detaylarını Göster",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Konuş",
|
||||
"Subjects:": "Konular:",
|
||||
"System Fonts": "Sistem Yazı Tipleri",
|
||||
"Table of Contents": "İçindekiler",
|
||||
"TTS not supported in this device": "Bu cihazda TTS desteklenmiyor",
|
||||
"Theme Color": "Tema Rengi",
|
||||
"Theme Mode": "Tema Modu",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Kitap Yazı Tipini Geçersiz Kıl",
|
||||
"Vertical Margins (px)": "Dikey Kenar Boşlukları (px)",
|
||||
"Horizontal Margins (%)": "Yatay Kenar Boşlukları (%)",
|
||||
"Apply to all books": "Tüm kitaplara uygula",
|
||||
"Apply to this book": "Bu kitaba uygula",
|
||||
"Apply to All Books": "Tüm kitaplara uygula",
|
||||
"Apply to This Book": "Bu kitaba uygula",
|
||||
"Unable to fetch the translation. Try again later.": "Çeviri alınamıyor. Daha sonra tekrar deneyin.",
|
||||
"Update Now!": "Şimdi Güncelle!",
|
||||
"Update": "Güncelle",
|
||||
"Check update": "Güncellemeyi kontrol et",
|
||||
"Check Update": "Güncellemeyi kontrol et",
|
||||
"Already the latest version": "Zaten en son sürüm",
|
||||
"Book Details": "Kitap Detayları",
|
||||
"Import books": "Kitapları içe aktar",
|
||||
"From local file": "Yerel dosyadan"
|
||||
"From Local File": "Yerel dosyadan",
|
||||
"TOC": "İçindekiler",
|
||||
"Book uploaded: {{title}}": "Kitap yüklendi: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Kitap yüklenemedi: {{title}}",
|
||||
"Book downloaded: {{title}}": "Kitap indirildi: {{title}}",
|
||||
"Failed to download book: {{title}}": "Kitap indirilemedi: {{title}}",
|
||||
"Upload Book": "Kitap Yükle",
|
||||
"Auto Upload Books to Cloud": "Kitapları Otomatik Olarak Buluta Yükle",
|
||||
"{{percentage}}% of Cloud Storage Used.": "Bulut Depolamanın %{{percentage}}'si kullanıldı.",
|
||||
"Storage": "Depolama",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Паралельне читання",
|
||||
"Published:": "Опубліковано:",
|
||||
"Publisher:": "Видавець:",
|
||||
"Reading progress synced": "Прогрес читання синхронізовано",
|
||||
"Reading Progress Synced": "Прогрес читання синхронізовано",
|
||||
"Reload Page": "Перезавантажити сторінку",
|
||||
"Reveal in File Explorer": "Показати у провіднику",
|
||||
"Reveal in Finder": "Показати у Finder",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Зберегти",
|
||||
"Scrolled Mode": "Режим прокрутки",
|
||||
"Search": "Пошук",
|
||||
"Search books...": "Шукати книги...",
|
||||
"Search Books...": "Шукати книги...",
|
||||
"Search...": "Пошук...",
|
||||
"Select Book": "Вибрати книгу",
|
||||
"Select books": "Вибрати книги",
|
||||
"Select multiple books": "Вибрати кілька книг",
|
||||
"Select Books": "Вибрати книги",
|
||||
"Select Multiple Books": "Вибрати кілька книг",
|
||||
"Sepia": "Сепія",
|
||||
"Serif Font": "Шрифт із засічками",
|
||||
"Show Book Details": "Показати деталі книги",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Озвучити",
|
||||
"Subjects:": "Теми:",
|
||||
"System Fonts": "Системні шрифти",
|
||||
"Table of Contents": "Зміст",
|
||||
"TTS not supported in this device": "TTS не підтримується на цьому пристрої",
|
||||
"Theme Color": "Колір теми",
|
||||
"Theme Mode": "Режим теми",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Перевизначити шрифт книги",
|
||||
"Vertical Margins (px)": "Вертикальні відступи (px)",
|
||||
"Horizontal Margins (%)": "Горизонтальні відступи (%)",
|
||||
"Apply to all books": "Застосувати до всіх книг",
|
||||
"Apply to this book": "Застосувати до цієї книги",
|
||||
"Apply to All Books": "Застосувати до всіх книг",
|
||||
"Apply to This Book": "Застосувати до цієї книги",
|
||||
"Unable to fetch the translation. Try again later.": "Не вдалося отримати переклад. Спробуйте пізніше.",
|
||||
"Update Now!": "Оновити зараз!",
|
||||
"Update": "Оновити",
|
||||
"Check update": "Перевірити оновлення",
|
||||
"Check Update": "Перевірити оновлення",
|
||||
"Already the latest version": "Вже остання версія",
|
||||
"Book Details": "Деталі книги",
|
||||
"Import books": "Імпортувати книги",
|
||||
"From local file": "З локального файлу"
|
||||
"From Local File": "З локального файлу",
|
||||
"TOC": "Зміст",
|
||||
"Book uploaded: {{title}}": "Книгу завантажено: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Не вдалося завантажити книгу: {{title}}",
|
||||
"Book downloaded: {{title}}": "Книгу завантажено: {{title}}",
|
||||
"Failed to download book: {{title}}": "Не вдалося завантажити книгу: {{title}}",
|
||||
"Upload Book": "Завантажити книгу",
|
||||
"Auto Upload Books to Cloud": "Автоматично завантажувати книги в хмару",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% використано хмарного сховища.",
|
||||
"Storage": "Сховище",
|
||||
"Book deleted: {{title}}": "Книгу видалено: {{title}}",
|
||||
"Failed to delete book: {{title}}": "Не вдалося видалити книгу: {{title}}",
|
||||
"Check Updates on Start": "Перевіряти оновлення при запуску"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "Đọc song song",
|
||||
"Published:": "Xuất bản:",
|
||||
"Publisher:": "Nhà xuất bản:",
|
||||
"Reading progress synced": "Tiến độ đọc đã được đồng bộ",
|
||||
"Reading Progress Synced": "Tiến độ đọc đã được đồng bộ",
|
||||
"Reload Page": "Tải lại trang",
|
||||
"Reveal in File Explorer": "Hiển thị trong File Explorer",
|
||||
"Reveal in Finder": "Hiển thị trong Finder",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "Lưu",
|
||||
"Scrolled Mode": "Chế độ cuộn",
|
||||
"Search": "Tìm kiếm",
|
||||
"Search books...": "Tìm kiếm sách...",
|
||||
"Search Books...": "Tìm kiếm sách...",
|
||||
"Search...": "Tìm kiếm...",
|
||||
"Select Book": "Chọn sách",
|
||||
"Select books": "Chọn sách",
|
||||
"Select multiple books": "Chọn nhiều sách",
|
||||
"Select Books": "Chọn sách",
|
||||
"Select Multiple Books": "Chọn nhiều sách",
|
||||
"Sepia": "Nâu cổ",
|
||||
"Serif Font": "Phông chữ có chân",
|
||||
"Show Book Details": "Hiển thị chi tiết sách",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "Đọc",
|
||||
"Subjects:": "Chủ đề:",
|
||||
"System Fonts": "Phông chữ hệ thống",
|
||||
"Table of Contents": "Mục lục",
|
||||
"TTS not supported in this device": "TTS không được hỗ trợ trên thiết bị này",
|
||||
"Theme Color": "Màu chủ đề",
|
||||
"Theme Mode": "Chế độ chủ đề",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "Ghi đè phông chữ sách",
|
||||
"Vertical Margins (px)": "Lề dọc (px)",
|
||||
"Horizontal Margins (%)": "Lề ngang (%)",
|
||||
"Apply to all books": "Áp dụng cho tất cả sách",
|
||||
"Apply to this book": "Áp dụng cho sách này",
|
||||
"Apply to All Books": "Áp dụng cho tất cả sách",
|
||||
"Apply to This Book": "Áp dụng cho sách này",
|
||||
"Unable to fetch the translation. Try again later.": "Không thể tải dịch vụ. Vui lòng thử lại sau.",
|
||||
"Update Now!": "Cập nhật ngay!",
|
||||
"Update": "Cập nhật",
|
||||
"Check update": "Kiểm tra cập nhật",
|
||||
"Check Update": "Kiểm tra cập nhật",
|
||||
"Already the latest version": "Đã là phiên bản mới nhất",
|
||||
"Book Details": "Chi tiết sách",
|
||||
"Import books": "Nhập sách",
|
||||
"From local file": "Từ tệp địa phương"
|
||||
"From Local File": "Từ tệp địa phương",
|
||||
"TOC": "Mục lục",
|
||||
"Book uploaded: {{title}}": "Sách đã tải lên: {{title}}",
|
||||
"Failed to upload book: {{title}}": "Không thể tải lên sách: {{title}}",
|
||||
"Book downloaded: {{title}}": "Sách đã tải về: {{title}}",
|
||||
"Failed to download book: {{title}}": "Không thể tải về sách: {{title}}",
|
||||
"Upload Book": "Tải lên sách",
|
||||
"Auto Upload Books to Cloud": "Tự động tải sách lên đám mây",
|
||||
"{{percentage}}% of Cloud Storage Used.": "{{percentage}}% dung lượng lưu trữ đám mây đã sử dụng.",
|
||||
"Storage": "Lưu trữ",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "并排阅读",
|
||||
"Published:": "出版日期",
|
||||
"Publisher:": "出版商",
|
||||
"Reading progress synced": "阅读进度已同步",
|
||||
"Reading Progress Synced": "阅读进度已同步",
|
||||
"Reload Page": "重新加载页面",
|
||||
"Reveal in File Explorer": "在文件管理器中显示",
|
||||
"Reveal in Finder": "在访达中显示",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "保存",
|
||||
"Scrolled Mode": "滚动模式",
|
||||
"Search": "搜索",
|
||||
"Search books...": "搜索书籍...",
|
||||
"Search Books...": "搜索书籍...",
|
||||
"Search...": "搜索...",
|
||||
"Select Book": "选择书籍",
|
||||
"Select books": "选择书籍",
|
||||
"Select multiple books": "选择多本书籍",
|
||||
"Select Books": "选择书籍",
|
||||
"Select Multiple Books": "选择多本书籍",
|
||||
"Sepia": "旧韵",
|
||||
"Serif Font": "衬线字体",
|
||||
"Show Book Details": "显示书籍详情",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "朗读",
|
||||
"Subjects:": "主题",
|
||||
"System Fonts": "系统字体",
|
||||
"Table of Contents": "目录",
|
||||
"TTS not supported in this device": "此设备不支持 TTS",
|
||||
"Theme Color": "主题颜色",
|
||||
"Theme Mode": "主题模式",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "覆盖书籍字体",
|
||||
"Vertical Margins (px)": "垂直边距(px)",
|
||||
"Horizontal Margins (%)": "水平边距(%)",
|
||||
"Apply to all books": "应用于所有书籍",
|
||||
"Apply to this book": "应用于此书籍",
|
||||
"Apply to All Books": "应用于所有书籍",
|
||||
"Apply to This Book": "应用于此书籍",
|
||||
"Unable to fetch the translation. Try again later.": "无法获取翻译,请稍后重试。",
|
||||
"Update Now!": "立即更新!",
|
||||
"Update": "更新",
|
||||
"Check update": "检查更新",
|
||||
"Check Update": "检查更新",
|
||||
"Already the latest version": "已经是最新版本",
|
||||
"Book Details": "书籍详情",
|
||||
"Import books": "导入书籍",
|
||||
"From local file": "从本地文件导入"
|
||||
"From Local File": "从本地文件导入",
|
||||
"TOC": "目录",
|
||||
"Book uploaded: {{title}}": "书籍已上传:{{title}}",
|
||||
"Failed to upload book: {{title}}": "上传书籍失败:{{title}}",
|
||||
"Book downloaded: {{title}}": "书籍已下载:{{title}}",
|
||||
"Failed to download book: {{title}}": "下载书籍失败:{{title}}",
|
||||
"Upload Book": "上传书籍",
|
||||
"Auto Upload Books to Cloud": "自动上传书籍到云端",
|
||||
"{{percentage}}% of Cloud Storage Used.": "已使用 {{percentage}}% 云存储空间",
|
||||
"Storage": "存储空间",
|
||||
"Book deleted: {{title}}": "书籍已删除:{{title}}",
|
||||
"Failed to delete book: {{title}}": "删除书籍失败:{{title}}",
|
||||
"Check Updates on Start": "启动时检查更新"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"Parallel Read": "並排閱讀",
|
||||
"Published:": "出版日期",
|
||||
"Publisher:": "出版商",
|
||||
"Reading progress synced": "閱讀進度已同步",
|
||||
"Reading Progress Synced": "閱讀進度已同步",
|
||||
"Reload Page": "重新載入頁面",
|
||||
"Reveal in File Explorer": "在檔案管理器中顯示",
|
||||
"Reveal in Finder": "在訪達中顯示",
|
||||
@@ -93,11 +93,11 @@
|
||||
"Save": "保存",
|
||||
"Scrolled Mode": "滾動模式",
|
||||
"Search": "搜索",
|
||||
"Search books...": "搜索書籍...",
|
||||
"Search Books...": "搜索書籍...",
|
||||
"Search...": "搜索...",
|
||||
"Select Book": "選擇書籍",
|
||||
"Select books": "選擇書籍",
|
||||
"Select multiple books": "選擇多本書籍",
|
||||
"Select Books": "選擇書籍",
|
||||
"Select Multiple Books": "選擇多本書籍",
|
||||
"Sepia": "舊韻",
|
||||
"Serif Font": "襯線字體",
|
||||
"Show Book Details": "顯示書籍詳情",
|
||||
@@ -110,7 +110,6 @@
|
||||
"Speak": "朗讀",
|
||||
"Subjects:": "主題",
|
||||
"System Fonts": "系統字體",
|
||||
"Table of Contents": "目錄",
|
||||
"TTS not supported in this device": "此設備不支持 TTS",
|
||||
"Theme Color": "主題顏色",
|
||||
"Theme Mode": "主題模式",
|
||||
@@ -130,14 +129,25 @@
|
||||
"Override Book Font": "覆蓋書籍字體",
|
||||
"Vertical Margins (px)": "垂直邊距(px)",
|
||||
"Horizontal Margins (%)": "水平邊距(%)",
|
||||
"Apply to all books": "應用於所有書籍",
|
||||
"Apply to this book": "應用於此書籍",
|
||||
"Apply to All Books": "應用於所有書籍",
|
||||
"Apply to This Book": "應用於此書籍",
|
||||
"Unable to fetch the translation. Try again later.": "無法獲取翻譯,請稍後再試。",
|
||||
"Update Now!": "立即更新!",
|
||||
"Update": "更新",
|
||||
"Check update": "檢查更新",
|
||||
"Check Update": "檢查更新",
|
||||
"Already the latest version": "已經是最新版本",
|
||||
"Book Details": "書籍詳情",
|
||||
"Import books": "導入書籍",
|
||||
"From local file": "從本地文件導入"
|
||||
"From Local File": "從本地文件導入",
|
||||
"TOC": "目錄",
|
||||
"Book uploaded: {{title}}": "書籍已上傳:{{title}}",
|
||||
"Failed to upload book: {{title}}": "書籍上傳失敗:{{title}}",
|
||||
"Book downloaded: {{title}}": "書籍已下載:{{title}}",
|
||||
"Failed to download book: {{title}}": "書籍下載失敗:{{title}}",
|
||||
"Upload Book": "上傳書籍",
|
||||
"Auto Upload Books to Cloud": "自動上傳書籍到雲端",
|
||||
"{{percentage}}% of Cloud Storage Used.": "已使用 {{percentage}}% 雲存儲空間",
|
||||
"Storage": "存儲空間",
|
||||
"Book deleted: {{title}}": "書籍已刪除:{{title}}",
|
||||
"Failed to delete book: {{title}}": "書籍刪除失敗:{{title}}",
|
||||
"Check Updates on Start": "啟動時檢查更新"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.8": {
|
||||
"date": "2025-02-01",
|
||||
"notes": [
|
||||
"Support cloud sync for book files",
|
||||
"Use larger font size on mobile devices for better reading experience",
|
||||
"Various fixes and enhancements on sync, rendering and settings"
|
||||
]
|
||||
},
|
||||
"0.9.7": {
|
||||
"date": "2025-01-23",
|
||||
"notes": [
|
||||
|
||||
@@ -21,6 +21,15 @@ tauri-build = { version = "2.0.3", features = [] }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["fs"] }
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
futures-util = "0.3"
|
||||
read-progress-stream = "1.0.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = [
|
||||
"json",
|
||||
"stream",
|
||||
] }
|
||||
# FIXME: remove the devtools feature in production
|
||||
tauri = { version = "2.1.1", features = [ "protocol-asset", "devtools"] }
|
||||
tauri-build = "2.0.3"
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://*.deepl.com"
|
||||
},
|
||||
{
|
||||
"url": "https://*.cloudflarestorage.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -11,6 +11,9 @@ 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;
|
||||
|
||||
@@ -80,7 +83,11 @@ pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_oauth::init())
|
||||
.invoke_handler(tauri::generate_handler![start_server])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
start_server,
|
||||
download_file,
|
||||
upload_file
|
||||
])
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Upload files from disk to a remote server over HTTP.
|
||||
//!
|
||||
//! Download files from a remote HTTP server to disk.
|
||||
|
||||
use futures_util::TryStreamExt;
|
||||
use serde::{ser::Serializer, Serialize};
|
||||
use tauri::{command, ipc::Channel};
|
||||
use tokio::{
|
||||
fs::File,
|
||||
io::{AsyncWriteExt, BufWriter},
|
||||
};
|
||||
use tokio_util::codec::{BytesCodec, FramedRead};
|
||||
|
||||
use read_progress_stream::ReadProgressStream;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
// The TransferStats struct tracks both transfer speed and cumulative transfer progress.
|
||||
pub struct TransferStats {
|
||||
accumulated_chunk_len: usize, // Total length of chunks transferred in the current period
|
||||
accumulated_time: u128, // Total time taken for the transfers in the current period
|
||||
pub transfer_speed: u64, // Calculated transfer speed in bytes per second
|
||||
pub total_transferred: u64, // Cumulative total of all transferred data
|
||||
start_time: Instant, // Time when the current period started
|
||||
granularity: u32, // Time period (in milliseconds) over which the transfer speed is calculated
|
||||
}
|
||||
|
||||
impl TransferStats {
|
||||
// Initializes a new TransferStats instance with the specified granularity.
|
||||
pub fn start(granularity: u32) -> Self {
|
||||
Self {
|
||||
accumulated_chunk_len: 0,
|
||||
accumulated_time: 0,
|
||||
transfer_speed: 0,
|
||||
total_transferred: 0,
|
||||
start_time: Instant::now(),
|
||||
granularity,
|
||||
}
|
||||
}
|
||||
// Records the transfer of a data chunk and updates both transfer speed and total progress.
|
||||
pub fn record_chunk_transfer(&mut self, chunk_len: usize) {
|
||||
let now = Instant::now();
|
||||
let it_took = now.duration_since(self.start_time).as_millis();
|
||||
self.accumulated_chunk_len += chunk_len;
|
||||
self.total_transferred += chunk_len as u64;
|
||||
self.accumulated_time += it_took;
|
||||
|
||||
// Calculate transfer speed if accumulated time exceeds granularity.
|
||||
if self.accumulated_time >= self.granularity as u128 {
|
||||
self.transfer_speed =
|
||||
(self.accumulated_chunk_len as u128 / self.accumulated_time * 1024) as u64;
|
||||
self.accumulated_chunk_len = 0;
|
||||
self.accumulated_time = 0;
|
||||
}
|
||||
|
||||
// Reset the start time for the next period.
|
||||
self.start_time = now;
|
||||
}
|
||||
}
|
||||
|
||||
// Provides a default implementation for TransferStats with a granularity of 500 milliseconds.
|
||||
impl Default for TransferStats {
|
||||
fn default() -> Self {
|
||||
Self::start(500) // Default granularity is 500 ms
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Request(#[from] reqwest::Error),
|
||||
#[error("{0}")]
|
||||
ContentLength(String),
|
||||
#[error("request failed with status code {0}: {1}")]
|
||||
HttpErrorCode(u16, String),
|
||||
}
|
||||
|
||||
impl Serialize for Error {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.to_string().as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProgressPayload {
|
||||
progress: u64,
|
||||
total: u64,
|
||||
transfer_speed: u64,
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn download_file(
|
||||
url: &str,
|
||||
file_path: &str,
|
||||
headers: HashMap<String, String>,
|
||||
body: Option<String>,
|
||||
on_progress: Channel<ProgressPayload>,
|
||||
) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = if let Some(body) = body {
|
||||
client.post(url).body(body)
|
||||
} else {
|
||||
client.get(url)
|
||||
};
|
||||
// Loop trought the headers keys and values
|
||||
// and add them to the request object.
|
||||
for (key, value) in headers {
|
||||
request = request.header(&key, value);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::HttpErrorCode(
|
||||
response.status().as_u16(),
|
||||
response.text().await.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
let total = response.content_length().unwrap_or(0);
|
||||
|
||||
let mut file = BufWriter::new(File::create(file_path).await?);
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
let mut stats = TransferStats::default();
|
||||
while let Some(chunk) = stream.try_next().await? {
|
||||
file.write_all(&chunk).await?;
|
||||
stats.record_chunk_transfer(chunk.len());
|
||||
let _ = on_progress.send(ProgressPayload {
|
||||
progress: stats.total_transferred,
|
||||
total,
|
||||
transfer_speed: stats.transfer_speed,
|
||||
});
|
||||
}
|
||||
file.flush().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn upload_file(
|
||||
url: &str,
|
||||
file_path: &str,
|
||||
method: &str,
|
||||
headers: HashMap<String, String>,
|
||||
on_progress: Channel<ProgressPayload>,
|
||||
) -> Result<String> {
|
||||
let file = File::open(file_path).await?;
|
||||
let file_len = file.metadata().await.unwrap().len();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = match method.to_uppercase().as_str() {
|
||||
"POST" => client.post(url),
|
||||
"PUT" => client.put(url),
|
||||
_ => return Err(Error::ContentLength("Invalid HTTP method".into())),
|
||||
};
|
||||
|
||||
request = request
|
||||
.header(reqwest::header::CONTENT_LENGTH, file_len)
|
||||
.body(file_to_body(on_progress.clone(), file, file_len));
|
||||
|
||||
for (key, value) in headers {
|
||||
request = request.header(&key, value);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
if response.status().is_success() {
|
||||
response.text().await.map_err(Into::into)
|
||||
} else {
|
||||
Err(Error::HttpErrorCode(
|
||||
response.status().as_u16(),
|
||||
response.text().await.unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn file_to_body(channel: Channel<ProgressPayload>, file: File, file_len: u64) -> reqwest::Body {
|
||||
let stream = FramedRead::new(file, BytesCodec::new()).map_ok(|r| r.freeze());
|
||||
|
||||
let mut stats = TransferStats::default();
|
||||
reqwest::Body::wrap_stream(ReadProgressStream::new(
|
||||
stream,
|
||||
Box::new(move |progress_chunk, _progress_total| {
|
||||
stats.record_chunk_transfer(progress_chunk as usize);
|
||||
let _ = channel.send(ProgressPayload {
|
||||
progress: stats.total_transferred,
|
||||
total: file_len,
|
||||
transfer_speed: stats.transfer_speed,
|
||||
});
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
"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",
|
||||
"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",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net",
|
||||
"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",
|
||||
"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"
|
||||
|
||||
@@ -167,7 +167,8 @@ export default function AuthPage() {
|
||||
const { data: subscription } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (session?.access_token && session.user) {
|
||||
login(session.access_token, session.user);
|
||||
router.push('/library');
|
||||
const redirectTo = new URLSearchParams(window.location.search).get('redirect');
|
||||
router.push(redirectTo ?? '/library');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const viewport = {
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: 'cover',
|
||||
themeColor: 'white',
|
||||
};
|
||||
|
||||
@@ -44,9 +45,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<html>
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<meta
|
||||
name='viewport'
|
||||
content='minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, user-scalable=no, viewport-fit=cover'
|
||||
/>
|
||||
<meta name='mobile-web-app-capable' content='yes' />
|
||||
<meta name='apple-mobile-web-app-capable' content='yes' />
|
||||
<meta name='apple-mobile-web-app-status-bar-style' content='default' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1' />
|
||||
<meta name='apple-mobile-web-app-title' content='Readest' />
|
||||
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
<link rel='manifest' href='/manifest.json' />
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import ReadingProgress from './ReadingProgress';
|
||||
|
||||
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;
|
||||
bookContextMenuHandler: (book: Book, e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
const BookItem: React.FC<BookItemProps> = ({
|
||||
book,
|
||||
isSelectMode,
|
||||
selectedBooks,
|
||||
clickedBookHash,
|
||||
transferProgress,
|
||||
handleBookClick,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
showBookDetailsModal,
|
||||
bookContextMenuHandler,
|
||||
}) => {
|
||||
const iconSize15 = useResponsiveSize(15);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='book-item 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='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'>
|
||||
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
|
||||
{book.title}
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
|
||||
>
|
||||
{book.progress && <ReadingProgress book={book} />}
|
||||
<div className='flex items-center gap-x-1'>
|
||||
{transferProgress !== null ? (
|
||||
transferProgress === 100 ? null : (
|
||||
<div
|
||||
className='radial-progress opacity-0 group-hover:opacity-100'
|
||||
style={
|
||||
{
|
||||
'--value': transferProgress,
|
||||
'--size': `${iconSize15}px`,
|
||||
'--thickness': '2px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
role='progressbar'
|
||||
></div>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onClick={() => {
|
||||
if (!book.uploadedAt) {
|
||||
handleBookUpload(book);
|
||||
} else if (!book.downloadedAt) {
|
||||
handleBookDownload(book);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!book.uploadedAt && <LiaCloudUploadAltSolid size={iconSize15} />}
|
||||
{book.uploadedAt && !book.downloadedAt && (
|
||||
<LiaCloudDownloadAltSolid size={iconSize15} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onClick={() => showBookDetailsModal(book)}
|
||||
>
|
||||
<CiCircleMore size={iconSize15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookItem;
|
||||
@@ -1,14 +1,9 @@
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { MdDelete, MdOpenInNew } from 'react-icons/md';
|
||||
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
|
||||
import { CiCircleMore } from 'react-icons/ci';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MdDelete, MdOpenInNew } from 'react-icons/md';
|
||||
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';
|
||||
@@ -16,17 +11,17 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
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 { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import BookDetailModal from '@/components/BookDetailModal';
|
||||
import ReadingProgress from './ReadingProgress';
|
||||
import BookItem from './BookItem';
|
||||
import GroupItem from './GroupItem';
|
||||
|
||||
type BookshelfItem = Book | BooksGroup;
|
||||
|
||||
@@ -34,6 +29,7 @@ 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)];
|
||||
@@ -57,16 +53,28 @@ const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
|
||||
interface BookshelfProps {
|
||||
libraryBooks: Book[];
|
||||
isSelectMode: boolean;
|
||||
onImportBooks: () => void;
|
||||
handleImportBooks: () => void;
|
||||
handleBookUpload: (book: Book) => void;
|
||||
handleBookDownload: (book: Book) => void;
|
||||
handleBookDelete: (book: Book) => void;
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
}
|
||||
|
||||
const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImportBooks }) => {
|
||||
const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
libraryBooks,
|
||||
isSelectMode,
|
||||
handleImportBooks,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
handleBookDelete,
|
||||
booksTransferProgress,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { deleteBook } = useLibraryStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
@@ -74,10 +82,25 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
const isImportingBook = useRef(false);
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const iconSize15 = useResponsiveSize(15);
|
||||
|
||||
const showBookDetailsModal = (book: Book) => {
|
||||
setShowDetailsBook(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 showBookDetailsModal = async (book: Book) => {
|
||||
if (await makeBookAvailable(book)) {
|
||||
setShowDetailsBook(book);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissBookDetailsModal = () => {
|
||||
@@ -111,14 +134,16 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
|
||||
const bookshelfItems = generateBookshelfItems(libraryBooks);
|
||||
|
||||
const handleBookClick = (id: string) => {
|
||||
const handleBookClick = async (book: Book) => {
|
||||
if (!(await makeBookAvailable(book))) return;
|
||||
|
||||
if (isSelectMode) {
|
||||
toggleSelection(id);
|
||||
toggleSelection(book.hash);
|
||||
} else {
|
||||
setClickedImage(id);
|
||||
setClickedImage(book.hash);
|
||||
setTimeout(() => setClickedImage(null), 300);
|
||||
setTimeout(() => setLoading(true), 200);
|
||||
navigateToReader(router, [id]);
|
||||
navigateToReader(router, [book.hash]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,11 +158,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
navigateToReader(router, selectedBooks);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
const confirmDelete = async () => {
|
||||
for (const selectedBook of selectedBooks) {
|
||||
const book = libraryBooks.find((b) => b.hash === selectedBook);
|
||||
if (book) {
|
||||
deleteBook(envConfig, book);
|
||||
await handleBookDelete(book);
|
||||
}
|
||||
}
|
||||
setSelectedBooks([]);
|
||||
@@ -158,7 +183,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
const openBookMenuItem = await MenuItem.new({
|
||||
text: isSelectMode ? _('Select Book') : _('Open Book'),
|
||||
action: async () => {
|
||||
handleBookClick(book.hash);
|
||||
handleBookClick(book);
|
||||
},
|
||||
});
|
||||
const showBookInFinderMenuItem = await MenuItem.new({
|
||||
@@ -174,16 +199,23 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
showBookDetailsModal(book);
|
||||
},
|
||||
});
|
||||
const uploadBookMenuItem = await MenuItem.new({
|
||||
text: _('Upload Book'),
|
||||
action: async () => {
|
||||
handleBookUpload(book);
|
||||
},
|
||||
});
|
||||
const deleteBookMenuItem = await MenuItem.new({
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
deleteBook(envConfig, book);
|
||||
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();
|
||||
};
|
||||
@@ -198,137 +230,41 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
>
|
||||
<div className='flex-grow'>
|
||||
{'format' in item ? (
|
||||
<div
|
||||
className='book-item cursor-pointer'
|
||||
onContextMenu={bookContextMenuHandler.bind(null, item as Book)}
|
||||
>
|
||||
<div
|
||||
key={(item as Book).hash}
|
||||
className='bg-base-100 shadow-md'
|
||||
onClick={() => handleBookClick(item.hash)}
|
||||
>
|
||||
<div className='relative aspect-[28/41]'>
|
||||
<Image
|
||||
src={item.coverImageUrl!}
|
||||
alt={item.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 flex items-center justify-center p-1',
|
||||
'text-neutral-content rounded-none text-center font-serif text-base font-medium',
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
{(selectedBooks.includes(item.hash) || clickedImage === item.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(item.hash) ? (
|
||||
<MdCheckCircle className='fill-blue-500' />
|
||||
) : (
|
||||
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex w-full p-0 pt-2',
|
||||
isWebAppPlatform() ? 'flex-col' : 'flex-row justify-between',
|
||||
)}
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
|
||||
{(item as Book).title}
|
||||
</h4>
|
||||
</div>
|
||||
{item.progress && (
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<ReadingProgress book={item as Book} />
|
||||
{isWebAppPlatform() && (
|
||||
<button
|
||||
type='button'
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onClick={showBookDetailsModal.bind(null, item as Book)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
showBookDetailsModal(item as Book);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CiCircleMore size={iconSize15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!item.progress && isWebAppPlatform() && (
|
||||
<div className={'flex items-center justify-end'}>
|
||||
<button
|
||||
type='button'
|
||||
className='show-detail-button opacity-0 group-hover:opacity-100'
|
||||
onClick={showBookDetailsModal.bind(null, item as Book)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
showBookDetailsModal(item as Book);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CiCircleMore size={iconSize15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<BookItem
|
||||
book={item}
|
||||
isSelectMode={isSelectMode}
|
||||
selectedBooks={selectedBooks}
|
||||
clickedBookHash={clickedImage}
|
||||
handleBookClick={handleBookClick}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
showBookDetailsModal={showBookDetailsModal}
|
||||
bookContextMenuHandler={bookContextMenuHandler}
|
||||
transferProgress={booksTransferProgress[item.hash] || null}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{(item as BooksGroup).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>
|
||||
))}
|
||||
<h2 className='mb-2 text-lg font-bold'>{(item as BooksGroup).name}</h2>
|
||||
</div>
|
||||
<GroupItem group={item} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{bookshelfItems.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'
|
||||
onClick={onImportBooks}
|
||||
onClick={handleImportBooks}
|
||||
>
|
||||
<PiPlus className='size-10' color='gray' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedBooks.length > 0 && (
|
||||
<div className='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'>
|
||||
<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>
|
||||
@@ -352,7 +288,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
onClickConfirm={confirmDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDetailsBook && (
|
||||
<BookDetailModal
|
||||
isOpen={!!showDetailsBook}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import Image from 'next/image';
|
||||
import { BooksGroup } from '@/types/book';
|
||||
import ReadingProgress from './ReadingProgress';
|
||||
|
||||
interface GroupItemProps {
|
||||
group: BooksGroup;
|
||||
}
|
||||
|
||||
const GroupItem: React.FC<GroupItemProps> = ({ group }) => {
|
||||
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>
|
||||
))}
|
||||
<h2 className='mb-2 text-lg font-bold'>{group.name}</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupItem;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface ImportMenuProps {
|
||||
setIsDropdownOpen?: (open: boolean) => void;
|
||||
onImportBooks: () => void;
|
||||
}
|
||||
|
||||
const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBooks }) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
const handleImportBooks = () => {
|
||||
onImportBooks();
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ul
|
||||
tabIndex={-1}
|
||||
className='dropdown-content dropdown-center bg-base-100 menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
|
||||
>
|
||||
<MenuItem label={_('From Local File')} onClick={handleImportBooks} />
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportMenu;
|
||||
@@ -12,6 +12,7 @@ import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
import ImportMenu from './ImportMenu';
|
||||
|
||||
interface LibraryHeaderProps {
|
||||
isSelectMode: boolean;
|
||||
@@ -57,7 +58,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
</span>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={_('Search books...')}
|
||||
placeholder={_('Search Books...')}
|
||||
spellCheck='false'
|
||||
className={clsx(
|
||||
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
|
||||
@@ -66,30 +67,26 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
)}
|
||||
/>
|
||||
<div className='absolute right-4 flex items-center space-x-4 text-gray-500'>
|
||||
<span className='mx-2 h-5 w-[1px] bg-gray-400'></span>
|
||||
<div className='dropdown dropdown-bottom flex h-5 cursor-pointer justify-center'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Import books')}>
|
||||
<PiPlus tabIndex={-1} className='h-5 w-5' />
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={-1}
|
||||
className='dropdown-content dropdown-center bg-base-100 menu rounded-box z-[1] mt-3 w-52 p-2 shadow'
|
||||
>
|
||||
<li>
|
||||
<button className='text-base-content' onClick={onImportBooks}>
|
||||
{_('From local file')}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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'
|
||||
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center'
|
||||
toggleButton={
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Import Books')}>
|
||||
<PiPlus className='m-0.5 h-5 w-5' />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ImportMenu onImportBooks={onImportBooks} />
|
||||
</Dropdown>
|
||||
<button
|
||||
onClick={onToggleSelectMode}
|
||||
aria-label={_('Select multiple books')}
|
||||
aria-label={_('Select Multiple Books')}
|
||||
className='h-6'
|
||||
>
|
||||
<div
|
||||
className='lg:tooltip lg:tooltip-bottom cursor-pointer'
|
||||
data-tip={_('Select books')}
|
||||
data-tip={_('Select Books')}
|
||||
>
|
||||
<PiSelectionAllDuotone
|
||||
role='button'
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PiUserCircle } from 'react-icons/pi';
|
||||
import { PiUserCircleCheck } from 'react-icons/pi';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { hasUpdater, isWebAppPlatform } from '@/services/environment';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getStoragePlanData } from '@/utils/access';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { QuotaType } from '@/types/user';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Quota from '@/components/Quota';
|
||||
|
||||
interface BookMenuProps {
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
@@ -21,8 +26,11 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig } = useEnv();
|
||||
const { user, logout } = useAuth();
|
||||
const { token, user, logout } = useAuth();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
|
||||
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
|
||||
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
|
||||
|
||||
const showAboutReadest = () => {
|
||||
setAboutDialogVisible(true);
|
||||
@@ -34,7 +42,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
};
|
||||
|
||||
const handleUserLogin = () => {
|
||||
router.push('/auth');
|
||||
navigateToLogin(router);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
@@ -46,6 +54,45 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleReloadPage = () => {
|
||||
window.location.reload();
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const toggleAutoUploadBooks = () => {
|
||||
settings.autoUpload = !settings.autoUpload;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsAutoUpload(settings.autoUpload);
|
||||
|
||||
if (settings.autoUpload && !user) {
|
||||
navigateToLogin(router);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAutoCheckUpdates = () => {
|
||||
settings.autoCheckUpdates = !settings.autoCheckUpdates;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
setIsAutoCheckUpdates(settings.autoCheckUpdates);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token) return;
|
||||
const storagPlan = getStoragePlanData(token);
|
||||
const storageQuota: QuotaType = {
|
||||
name: _('Storage'),
|
||||
tooltip: _('{{percentage}}% of Cloud Storage Used.', {
|
||||
percentage: Math.round((storagPlan.usage / storagPlan.quota) * 100),
|
||||
}),
|
||||
used: Math.round(storagPlan.usage / 1024 / 1024),
|
||||
total: Math.round(storagPlan.quota / 1024 / 1024),
|
||||
unit: 'MB',
|
||||
};
|
||||
setQuotas([storageQuota]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const isWebApp = isWebAppPlatform();
|
||||
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
|
||||
const userFullName = user?.user_metadata?.['full_name'];
|
||||
@@ -54,7 +101,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-3 w-60 shadow-2xl'
|
||||
className='settings-menu dropdown-content no-triangle border-base-100 z-20 mt-3 w-72 shadow-2xl'
|
||||
>
|
||||
{user ? (
|
||||
<MenuItem
|
||||
@@ -80,12 +127,27 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
}
|
||||
>
|
||||
<ul>
|
||||
<Quota quotas={quotas} />
|
||||
<MenuItem label={_('Sign Out')} noIcon onClick={handleUserLogout} />
|
||||
</ul>
|
||||
</MenuItem>
|
||||
) : (
|
||||
<MenuItem label={_('Sign In')} icon={<PiUserCircle />} onClick={handleUserLogin}></MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
label={_('Auto Upload Books to Cloud')}
|
||||
icon={isAutoUpload ? <MdCheck className='text-base-content' /> : undefined}
|
||||
onClick={toggleAutoUploadBooks}
|
||||
/>
|
||||
{hasUpdater() && (
|
||||
<MenuItem
|
||||
label={_('Check Updates on Start')}
|
||||
icon={isAutoCheckUpdates ? <MdCheck className='text-base-content' /> : undefined}
|
||||
onClick={toggleAutoCheckUpdates}
|
||||
/>
|
||||
)}
|
||||
<hr className='border-base-200 my-1' />
|
||||
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
|
||||
<hr className='border-base-200 my-1' />
|
||||
{isWebApp && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
|
||||
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSync } from '@/hooks/useSync';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
|
||||
import { Book } from '@/types/book';
|
||||
|
||||
export const useBooksSync = () => {
|
||||
const { user } = useAuth();
|
||||
const { appService } = useEnv();
|
||||
const { library, setLibrary } = useLibraryStore();
|
||||
const { syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
|
||||
const syncBooksPullingRef = useRef(false);
|
||||
|
||||
const pullLibrary = async () => {
|
||||
if (!user) return;
|
||||
syncBooks([], 'pull');
|
||||
};
|
||||
|
||||
const pushLibrary = async () => {
|
||||
if (!user) return;
|
||||
const newBooks = getNewBooks();
|
||||
syncBooks(newBooks, 'push');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (syncBooksPullingRef.current) return;
|
||||
syncBooksPullingRef.current = true;
|
||||
|
||||
pullLibrary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const lastSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const getNewBooks = () => {
|
||||
if (!user) return [];
|
||||
const newBooks = library.filter(
|
||||
(book) => lastSyncedAtBooks < book.updatedAt || lastSyncedAtBooks < (book.deletedAt ?? 0),
|
||||
);
|
||||
return newBooks;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const now = Date.now();
|
||||
const timeSinceLastSync = now - lastSyncTime.current;
|
||||
if (timeSinceLastSync > SYNC_BOOKS_INTERVAL_SEC * 1000) {
|
||||
lastSyncTime.current = now;
|
||||
const newBooks = getNewBooks();
|
||||
syncBooks(newBooks, 'both');
|
||||
} else {
|
||||
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
lastSyncTime.current = Date.now();
|
||||
const newBooks = getNewBooks();
|
||||
syncBooks(newBooks, 'both');
|
||||
syncTimeoutRef.current = null;
|
||||
},
|
||||
SYNC_BOOKS_INTERVAL_SEC * 1000 - timeSinceLastSync,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [library]);
|
||||
|
||||
const updateLibrary = async () => {
|
||||
if (!syncedBooks?.length) return;
|
||||
|
||||
const processOldBook = async (oldBook: Book) => {
|
||||
const matchingBook = syncedBooks.find((newBook) => newBook.hash === oldBook.hash);
|
||||
if (matchingBook) {
|
||||
if (!matchingBook.deletedAt && matchingBook.uploadedAt && !oldBook.downloadedAt) {
|
||||
await appService?.downloadBook(oldBook, true);
|
||||
}
|
||||
const mergedBook =
|
||||
matchingBook.updatedAt > oldBook.updatedAt
|
||||
? { ...oldBook, ...matchingBook, updatedAt: oldBook.updatedAt }
|
||||
: { ...matchingBook, ...oldBook, updatedAt: oldBook.updatedAt };
|
||||
mergedBook.progress = matchingBook.progress ?? oldBook.progress;
|
||||
return mergedBook;
|
||||
}
|
||||
return oldBook;
|
||||
};
|
||||
|
||||
const updatedLibrary = await Promise.all(library.map(processOldBook));
|
||||
const processNewBook = async (newBook: Book) => {
|
||||
if (!updatedLibrary.some((oldBook) => oldBook.hash === newBook.hash)) {
|
||||
if (newBook.uploadedAt && !newBook.deletedAt) {
|
||||
try {
|
||||
updatedLibrary.push(newBook);
|
||||
await appService?.downloadBook(newBook, true);
|
||||
newBook.coverImageUrl = await appService?.generateCoverImageUrl(newBook);
|
||||
} catch {
|
||||
console.error('Failed to download book:', newBook);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(syncedBooks.map(processNewBook));
|
||||
setLibrary(updatedLibrary);
|
||||
appService?.saveLibraryBooks(updatedLibrary);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateLibrary();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [syncedBooks]);
|
||||
|
||||
return { pullLibrary, pushLibrary };
|
||||
};
|
||||
@@ -21,13 +21,17 @@ import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
||||
import { useDemoBooks } from './hooks/useDemoBooks';
|
||||
import { useBooksSync } from './hooks/useBooksSync';
|
||||
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
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';
|
||||
|
||||
const LibraryPage = () => {
|
||||
const router = useRouter();
|
||||
@@ -35,29 +39,42 @@ const LibraryPage = () => {
|
||||
const { token, user } = useAuth();
|
||||
const {
|
||||
library: libraryBooks,
|
||||
updateBook,
|
||||
setLibrary,
|
||||
checkOpenWithBooks,
|
||||
clearOpenWithBooks,
|
||||
} = useLibraryStore();
|
||||
const _ = useTranslation();
|
||||
const { updateAppTheme } = useTheme();
|
||||
const { setSettings, saveSettings } = useSettingsStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isInitiating = useRef(false);
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
const [booksTransferProgress, setBooksTransferProgress] = useState<{
|
||||
[key: string]: number | null;
|
||||
}>({});
|
||||
const demoBooks = useDemoBooks();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { pullLibrary, pushLibrary } = useBooksSync();
|
||||
|
||||
usePullToRefresh(containerRef, pullLibrary);
|
||||
|
||||
useEffect(() => {
|
||||
updateAppTheme('base-200');
|
||||
const doAppUpdates = async () => {
|
||||
if (hasUpdater()) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const doCheckAppUpdates = async () => {
|
||||
if (hasUpdater() && settings.autoCheckUpdates) {
|
||||
await checkForAppUpdates(_);
|
||||
}
|
||||
};
|
||||
doAppUpdates();
|
||||
doCheckAppUpdates();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [settings]);
|
||||
|
||||
const processOpenWithFiles = React.useCallback(
|
||||
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
|
||||
@@ -163,8 +180,12 @@ const LibraryPage = () => {
|
||||
const failedFiles = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
await appService?.importBook(file, libraryBooks);
|
||||
const book = await appService?.importBook(file, libraryBooks);
|
||||
setLibrary(libraryBooks);
|
||||
if (user && book && !book.uploadedAt && settings.autoUpload) {
|
||||
console.log('Uploading book:', book.title);
|
||||
handleBookUpload(book);
|
||||
}
|
||||
} catch (error) {
|
||||
const filename = typeof file === 'string' ? file : file.name;
|
||||
const baseFilename = getBaseFilename(filename);
|
||||
@@ -200,6 +221,84 @@ const LibraryPage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateBookTransferProgress = throttle((bookHash: string, progress: ProgressPayload) => {
|
||||
if (progress.total === 0) return;
|
||||
const progressPct = (progress.progress / progress.total) * 100;
|
||||
setBooksTransferProgress((prev) => ({
|
||||
...prev,
|
||||
[bookHash]: progressPct,
|
||||
}));
|
||||
}, 500);
|
||||
|
||||
const handleBookUpload = async (book: Book) => {
|
||||
try {
|
||||
await appService?.uploadBook(book, (progress) => {
|
||||
updateBookTransferProgress(book.hash, progress);
|
||||
});
|
||||
await updateBook(envConfig, book);
|
||||
pushLibrary();
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
timeout: 2000,
|
||||
message: _('Book uploaded: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to upload book: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBookDownload = async (book: Book) => {
|
||||
try {
|
||||
await appService?.downloadBook(book, false, (progress) => {
|
||||
updateBookTransferProgress(book.hash, progress);
|
||||
});
|
||||
await updateBook(envConfig, book);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
timeout: 2000,
|
||||
message: _('Book downloaded: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Failed to download book: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBookDelete = async (book: Book) => {
|
||||
try {
|
||||
await appService?.deleteBook(book, !!book.uploadedAt);
|
||||
await updateBook(envConfig, book);
|
||||
pushLibrary();
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
timeout: 2000,
|
||||
message: _('Book deleted: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Failed to delete book: {{title}}', {
|
||||
title: book.title,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportBooks = async () => {
|
||||
console.log('Importing books...');
|
||||
let files;
|
||||
@@ -256,12 +355,16 @@ const LibraryPage = () => {
|
||||
)}
|
||||
{libraryLoaded &&
|
||||
(libraryBooks.length > 0 ? (
|
||||
<div className='mt-12 flex-grow overflow-auto px-2'>
|
||||
<div ref={containerRef} className='mt-12 flex-grow overflow-auto px-2'>
|
||||
<Suspense>
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
onImportBooks={handleImportBooks}
|
||||
handleImportBooks={handleImportBooks}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function Offline() {
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-gray-100 text-center'>
|
||||
<div className='mb-4'>
|
||||
<Image src='/icon.png' alt='App Icon' width={100} height={100} className='rounded-lg' />
|
||||
</div>
|
||||
|
||||
<h1 className='text-2xl font-bold text-gray-800'>Readest</h1>
|
||||
|
||||
<p className='mt-2 text-gray-600'>
|
||||
It seems you're offline. Please check your internet connection and try again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
|
||||
import Spinner from '@/components/Spinner';
|
||||
import { redirectToLibrary } from '@/utils/nav';
|
||||
|
||||
const HomePage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
navigateToLibrary(router);
|
||||
}, [router]);
|
||||
|
||||
return <Spinner loading={true} />;
|
||||
redirectToLibrary();
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
|
||||
@@ -15,6 +15,7 @@ import Ribbon from './Ribbon';
|
||||
import SettingsDialog from './settings/SettingsDialog';
|
||||
import Annotator from './annotator/Annotator';
|
||||
import FootnotePopup from './FootnotePopup';
|
||||
import HintInfo from './HintInfo';
|
||||
|
||||
interface BooksGridProps {
|
||||
bookKeys: string[];
|
||||
@@ -76,6 +77,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
|
||||
{viewSettings.scrolled ? null : (
|
||||
<>
|
||||
<SectionInfo section={sectionLabel} gapLeft={marginGap} />
|
||||
<HintInfo bookKey={bookKey} gapRight={marginGap} />
|
||||
<PageInfoView
|
||||
bookFormat={book.format}
|
||||
section={section ?? null}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BookConfig } from '@/types/book';
|
||||
import { FoliateView, wrappedFoliateView } from '@/types/view';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useParallelViewStore } from '@/store/parallelViewStore';
|
||||
import { useClickEvent, useTouchEvent } from '../hooks/useClickEvent';
|
||||
import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useProgressSync } from '../hooks/useProgressSync';
|
||||
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
|
||||
@@ -80,6 +80,17 @@ const FoliateViewer: React.FC<{
|
||||
const docRelocateHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
if (detail.reason !== 'scroll' && detail.reason !== 'page') return;
|
||||
|
||||
if (detail.reason === 'scroll') {
|
||||
const renderer = viewRef.current?.renderer;
|
||||
if (renderer) {
|
||||
if (renderer.start <= 0) {
|
||||
viewRef.current?.prev(1);
|
||||
} else if (renderer.end >= renderer.viewSize) {
|
||||
viewRef.current?.next(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
const parallelViews = getParallels(bookKey);
|
||||
if (parallelViews && parallelViews.size > 0) {
|
||||
parallelViews.forEach((key) => {
|
||||
@@ -95,6 +106,7 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
useTouchEvent(bookKey, viewRef);
|
||||
const { handleTurnPage } = useClickEvent(bookKey, viewRef, containerRef);
|
||||
|
||||
useFoliateEvents(viewRef.current, {
|
||||
onLoad: docLoadHandler,
|
||||
onRelocate: progressRelocateHandler,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import useTrafficLight from '@/hooks/useTrafficLight';
|
||||
|
||||
interface SectionInfoProps {
|
||||
bookKey: string;
|
||||
gapRight: string;
|
||||
}
|
||||
|
||||
const HintInfo: React.FC<SectionInfoProps> = ({ bookKey, gapRight }) => {
|
||||
const { isSideBarVisible } = useSidebarStore();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const [hintMessage, setHintMessage] = React.useState<string | null>(null);
|
||||
const hintTimeout = useRef(2000);
|
||||
const dismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleShowHint = (event: CustomEvent) => {
|
||||
const { message, bookKey: hintBookKey, timeout = 2000 } = event.detail;
|
||||
if (hintBookKey !== bookKey) return;
|
||||
setHintMessage(message);
|
||||
hintTimeout.current = timeout;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
eventDispatcher.on('hint', handleShowHint);
|
||||
return () => {
|
||||
eventDispatcher.off('hint', handleShowHint);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dismissTimeout.current) clearTimeout(dismissTimeout.current);
|
||||
dismissTimeout.current = setTimeout(() => setHintMessage(''), hintTimeout.current);
|
||||
return () => {
|
||||
if (dismissTimeout.current) clearTimeout(dismissTimeout.current);
|
||||
};
|
||||
}, [hintMessage]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'pageinfo absolute right-0 top-0 flex max-w-[50%] items-end',
|
||||
isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]',
|
||||
hintMessage ? '' : '',
|
||||
)}
|
||||
style={{ right: gapRight }}
|
||||
>
|
||||
<h2 className='text-neutral-content line-clamp-1 text-center font-sans text-xs font-light'>
|
||||
{hintMessage || ''}
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HintInfo;
|
||||
@@ -59,7 +59,9 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
const isPrimary = !uniqueIds.has(id);
|
||||
uniqueIds.add(id);
|
||||
if (!getViewState(key)) {
|
||||
initViewState(envConfig, id, key, isPrimary);
|
||||
initViewState(envConfig, id, key, isPrimary).catch((error) => {
|
||||
console.log('Error initializing book', key, error);
|
||||
});
|
||||
if (index === 0) setSideBarBookKey(key);
|
||||
}
|
||||
});
|
||||
@@ -89,6 +91,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
const { book } = getBookData(bookKey) || {};
|
||||
const { isPrimary } = getViewState(bookKey) || {};
|
||||
if (isPrimary && book && config) {
|
||||
eventDispatcher.dispatch('sync-book-progress', { bookKey });
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
await saveConfig(envConfig, bookKey, config, settings);
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@ const SectionInfo: React.FC<SectionInfoProps> = ({ section, gapLeft }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'pageinfo absolute right-0 top-0 flex items-end',
|
||||
'pageinfo absolute right-0 top-0 flex max-w-[50%] items-end',
|
||||
isTrafficLightVisible && !isSideBarVisible ? 'h-[44px]' : 'h-[30px]',
|
||||
)}
|
||||
style={{ left: gapLeft }}
|
||||
>
|
||||
<h2 className='text-neutral-content text-center font-sans text-xs font-light'>
|
||||
<h2 className='text-neutral-content line-clamp-1 text-center font-sans text-xs font-light'>
|
||||
{section || ''}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr className='border-base-200 my-1' />
|
||||
<hr className='border-base-300 my-1' />
|
||||
|
||||
<MenuItem label={_('Font & Layout')} shortcut='Shift+F' onClick={openFontLayoutMenu} />
|
||||
|
||||
@@ -128,7 +128,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
|
||||
onClick={toggleScrolledMode}
|
||||
/>
|
||||
|
||||
<hr className='border-base-200 my-1' />
|
||||
<hr className='border-base-300 my-1' />
|
||||
|
||||
<MenuItem
|
||||
label={
|
||||
|
||||
@@ -4,6 +4,7 @@ import PopupButton from './PopupButton';
|
||||
import HighlightOptions from './HighlightOptions';
|
||||
import { Position } from '@/utils/sel';
|
||||
import { HighlightColor, HighlightStyle } from '@/types/book';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
|
||||
interface AnnotationPopupProps {
|
||||
buttons: Array<{ tooltipText: string; Icon: React.ElementType; onClick: () => void }>;
|
||||
@@ -17,8 +18,8 @@ interface AnnotationPopupProps {
|
||||
onHighlight: (update?: boolean) => void;
|
||||
}
|
||||
|
||||
const highlightOptionsHeightPx = 28;
|
||||
const highlightOptionsPaddingPx = 16;
|
||||
const OPTIONS_HEIGHT_PIX = 28;
|
||||
const OPTIONS_PADDING_PIX = 16;
|
||||
|
||||
const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
buttons,
|
||||
@@ -31,6 +32,8 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
popupHeight,
|
||||
onHighlight,
|
||||
}) => {
|
||||
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
|
||||
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
|
||||
return (
|
||||
<div>
|
||||
<Popup
|
||||
@@ -41,7 +44,12 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
className='selection-popup bg-gray-600 px-4 text-white'
|
||||
triangleClassName='text-gray-600'
|
||||
>
|
||||
<div className='selection-buttons flex h-11 items-center justify-between'>
|
||||
<div
|
||||
className='selection-buttons flex items-center justify-between'
|
||||
style={{
|
||||
height: popupHeight,
|
||||
}}
|
||||
>
|
||||
{buttons.map((button, index) => (
|
||||
<PopupButton
|
||||
key={index}
|
||||
|
||||
@@ -13,12 +13,13 @@ import * as CFI from 'foliate-js/epubcfi.js';
|
||||
import { Overlayer } from 'foliate-js/overlayer.js';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { BookNote, HighlightColor, HighlightStyle } from '@/types/book';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
import { getOSPlatform, uniqueId } from '@/utils/misc';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useNotebookStore } from '@/store/notebookStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
|
||||
import { useNotesSync } from '../../hooks/useNotesSync';
|
||||
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
|
||||
@@ -34,11 +35,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
|
||||
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
|
||||
const { isNotebookPinned, isNotebookVisible } = useNotebookStore();
|
||||
const { setNotebookVisible, setNotebookNewAnnotation } = useNotebookStore();
|
||||
|
||||
useNotesSync(bookKey);
|
||||
|
||||
const osPlatform = getOSPlatform();
|
||||
const config = getConfig(bookKey)!;
|
||||
const progress = getProgress(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
@@ -47,6 +48,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
const isShowingPopup = useRef(false);
|
||||
const isTextSelected = useRef(false);
|
||||
const isUpToShowPopup = useRef(false);
|
||||
const [selection, setSelection] = useState<TextSelection | null>();
|
||||
const [showAnnotPopup, setShowAnnotPopup] = useState(false);
|
||||
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
|
||||
@@ -65,26 +67,78 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
settings.globalReadSettings.highlightStyles[selectedStyle],
|
||||
);
|
||||
|
||||
const dictPopupWidth = 480;
|
||||
const dictPopupHeight = 300;
|
||||
const transPopupWidth = 480;
|
||||
const transPopupHeight = 360;
|
||||
const annotPopupWidth = 280;
|
||||
const annotPopupHeight = 44;
|
||||
const popupPadding = 10;
|
||||
const popupPadding = useResponsiveSize(10);
|
||||
const maxWidth = window.innerWidth - 2 * popupPadding;
|
||||
const maxHeight = window.innerHeight - 2 * popupPadding;
|
||||
const dictPopupWidth = Math.min(480, maxWidth);
|
||||
const dictPopupHeight = Math.min(300, maxHeight);
|
||||
const transPopupWidth = Math.min(480, maxWidth);
|
||||
const transPopupHeight = Math.min(360, maxHeight);
|
||||
const annotPopupWidth = useResponsiveSize(280);
|
||||
const annotPopupHeight = useResponsiveSize(44);
|
||||
const androidSelectionHandlerHeight = 8;
|
||||
|
||||
const onLoad = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const { doc, index } = detail;
|
||||
const handlePointerup = () => {
|
||||
const sel = doc.getSelection();
|
||||
if (sel && sel.toString().trim().length > 0 && sel.rangeCount > 0) {
|
||||
isTextSelected.current = true;
|
||||
setSelection({ key: bookKey, text: sel.toString(), range: sel.getRangeAt(0), index });
|
||||
|
||||
const isValidSelection = (sel: Selection) => {
|
||||
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
|
||||
};
|
||||
const makeSelection = (sel: Selection, rebuildRange = false) => {
|
||||
isTextSelected.current = true;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (rebuildRange) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
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,
|
||||
// but on Android no proper events are fired to notify selection done or I didn't find it,
|
||||
// we make the popup show when the selection is changed
|
||||
const sel = doc.getSelection();
|
||||
if (isValidSelection(sel)) {
|
||||
if (osPlatform === 'android') {
|
||||
makeSelection(sel, false);
|
||||
}
|
||||
} else if (!isUpToShowPopup.current) {
|
||||
isTextSelected.current = false;
|
||||
setShowAnnotPopup(false);
|
||||
setShowWiktionaryPopup(false);
|
||||
setShowWikipediaPopup(false);
|
||||
setShowDeepLPopup(false);
|
||||
}
|
||||
};
|
||||
const handleTouchmove = () => {
|
||||
// Available on iOS
|
||||
// To make the popup not to follow the selection
|
||||
setShowAnnotPopup(false);
|
||||
};
|
||||
if (bookData.book?.format !== 'PDF') {
|
||||
detail.doc?.addEventListener('pointerup', handlePointerup);
|
||||
detail.doc?.addEventListener('touchmove', handleTouchmove);
|
||||
detail.doc?.addEventListener('selectionchange', handleSelectionchange);
|
||||
|
||||
// Disable the default context menu on mobile devices,
|
||||
// although it should but doesn't work on iOS
|
||||
if (osPlatform === 'android' || osPlatform === 'ios') {
|
||||
detail.doc?.addEventListener('contextmenu', (event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,13 +160,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const onShowAnnotation = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const { value: cfi, index, range } = detail;
|
||||
const { booknotes = [] } = config;
|
||||
const { booknotes = [] } = getConfig(bookKey)!;
|
||||
const annotations = booknotes.filter(
|
||||
(booknote) => booknote.type === 'annotation' && !booknote.deletedAt,
|
||||
);
|
||||
const annotation = annotations.find((annotation) => annotation.cfi === cfi);
|
||||
if (!annotation) return;
|
||||
const selection = { key: bookKey, annotated: true, text: annotation.text ?? '', range, index };
|
||||
isUpToShowPopup.current = true;
|
||||
setSelectedStyle(annotation.style!);
|
||||
setSelectedColor(annotation.color!);
|
||||
setSelection(selection);
|
||||
@@ -137,12 +192,16 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleSingleClick = (): boolean => {
|
||||
if (isUpToShowPopup.current) {
|
||||
isUpToShowPopup.current = false;
|
||||
return true;
|
||||
}
|
||||
if (isTextSelected.current) {
|
||||
view?.deselect();
|
||||
isTextSelected.current = false;
|
||||
handleDismissPopupAndSelection();
|
||||
return true;
|
||||
}
|
||||
if (showAnnotPopup || isShowingPopup.current) {
|
||||
handleDismissPopup();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -169,6 +228,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
annotPopupHeight,
|
||||
popupPadding,
|
||||
);
|
||||
if (isTextSelected.current && annotPopupPos.dir === 'down' && osPlatform === 'android') {
|
||||
triangPos.point.y += androidSelectionHandlerHeight;
|
||||
annotPopupPos.point.y += androidSelectionHandlerHeight;
|
||||
}
|
||||
const dictPopupPos = getPopupPosition(
|
||||
triangPos,
|
||||
rect,
|
||||
@@ -226,7 +289,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
});
|
||||
|
||||
const { booknotes: annotations = [] } = config;
|
||||
if (selection) navigator.clipboard.writeText(selection.text);
|
||||
if (selection) navigator.clipboard?.writeText(selection.text);
|
||||
const cfi = view?.getCFI(selection.index, selection.range);
|
||||
if (!cfi) return;
|
||||
const annotation: BookNote = {
|
||||
@@ -360,14 +423,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{(showAnnotPopup || showWiktionaryPopup || showWikipediaPopup || showDeepLPopup) &&
|
||||
(!isNotebookVisible || isNotebookPinned) && (
|
||||
<div
|
||||
className='fixed inset-0'
|
||||
onClick={handleDismissPopupAndSelection}
|
||||
onContextMenu={handleDismissPopup}
|
||||
/>
|
||||
)}
|
||||
{showWiktionaryPopup && trianglePosition && dictPopupPosition && (
|
||||
<WiktionaryPopup
|
||||
word={selection?.text as string}
|
||||
|
||||
@@ -25,7 +25,9 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
||||
const globalReadSettings = settings.globalReadSettings;
|
||||
const [selectedStyle, setSelectedStyle] = React.useState<HighlightStyle>(_selectedStyle);
|
||||
const [selectedColor, setSelectedColor] = React.useState<HighlightColor>(_selectedColor);
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const size16 = useResponsiveSize(16);
|
||||
const size18 = useResponsiveSize(18);
|
||||
const size28 = useResponsiveSize(28);
|
||||
|
||||
const handleSelectStyle = (style: HighlightStyle) => {
|
||||
globalReadSettings.highlightStyle = style;
|
||||
@@ -42,18 +44,19 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
||||
onHandleHighlight(true);
|
||||
};
|
||||
return (
|
||||
<div className='highlight-options absolute flex h-7 items-center justify-between' style={style}>
|
||||
<div className='flex h-7 gap-2'>
|
||||
<div className='highlight-options absolute flex items-center justify-between' style={style}>
|
||||
<div className='flex gap-2' style={{ height: size28 }}>
|
||||
{styles.map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => handleSelectStyle(style)}
|
||||
className={`flex h-7 min-h-7 w-7 items-center justify-center rounded-full bg-gray-700 p-0`}
|
||||
className={`flex items-center justify-center rounded-full bg-gray-700 p-0`}
|
||||
style={{ width: size28, height: size28, minHeight: size28 }}
|
||||
>
|
||||
<div
|
||||
style={{ width: size16, height: style === 'squiggly' ? size18 : size16 }}
|
||||
className={clsx(
|
||||
'w-4 p-0 text-center leading-none',
|
||||
style === 'highlight' ? 'h-4' : 'h-5',
|
||||
style === 'highlight' &&
|
||||
(selectedStyle === 'highlight' ? `bg-${selectedColor}-400` : `bg-gray-300`),
|
||||
(style === 'underline' || style === 'squiggly') &&
|
||||
@@ -74,18 +77,19 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='flex h-7 items-center justify-center gap-2 rounded-3xl bg-gray-700 px-2'>
|
||||
<div
|
||||
className='flex items-center justify-center gap-2 rounded-3xl bg-gray-700 px-2'
|
||||
style={{ height: size28 }}
|
||||
>
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => handleSelectColor(color)}
|
||||
className={clsx(
|
||||
`h-4 w-4 rounded-full p-0`,
|
||||
selectedColor !== color && `bg-${color}-400`,
|
||||
)}
|
||||
style={{ width: size16, height: size16 }}
|
||||
className={clsx(`rounded-full p-0`, selectedColor !== color && `bg-${color}-400`)}
|
||||
>
|
||||
{selectedColor === color && (
|
||||
<FaCheckCircle size={iconSize16} className={clsx(`fill-${color}-400`)} />
|
||||
<FaCheckCircle size={size16} className={clsx(`fill-${color}-400`)} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -34,7 +34,7 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
|
||||
<div
|
||||
className='lg:tooltip'
|
||||
data-tip={
|
||||
isFontLayoutSettingsGlobal ? _('Apply to all books') : _('Apply to this book')
|
||||
isFontLayoutSettingsGlobal ? _('Apply to All Books') : _('Apply to This Book')
|
||||
}
|
||||
>
|
||||
<span className='ml-2'>{_('Global Settings')}</span>
|
||||
|
||||
@@ -36,7 +36,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
||||
</button>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className='dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-0 z-[1] mt-4 w-44 shadow'
|
||||
className='dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-[-32px] z-[1] mt-4 w-44 shadow sm:right-0'
|
||||
>
|
||||
{options.map((option) => (
|
||||
<li key={option} onClick={() => onSelect(option)}>
|
||||
@@ -51,7 +51,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
||||
{moreOptions && moreOptions.length > 0 && (
|
||||
<li className='dropdown dropdown-left dropdown-top'>
|
||||
<div className='flex items-center px-0'>
|
||||
<span style={{ minWidth: `${iconSize16}px` }}>
|
||||
<span style={{ minWidth: `${defaultIconSize}px` }}>
|
||||
<FiChevronLeft size={iconSize16} />
|
||||
</span>
|
||||
<span>{_('System Fonts')}</span>
|
||||
@@ -60,7 +60,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] overflow-y-scroll shadow',
|
||||
'!mr-5 mb-[-46px] inline max-h-80 w-[220px] overflow-y-scroll',
|
||||
'!mr-5 mb-[-46px] inline max-h-80 w-[200px] overflow-y-scroll',
|
||||
)}
|
||||
>
|
||||
{moreOptions.map((option) => (
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useEffect, useState } from 'react';
|
||||
import NumberInput from './NumberInput';
|
||||
import FontDropdown from './FontDropDown';
|
||||
import {
|
||||
ANDROID_FONTS,
|
||||
IOS_FONTS,
|
||||
LINUX_FONTS,
|
||||
MACOS_FONTS,
|
||||
MONOSPACE_FONTS,
|
||||
@@ -76,6 +78,12 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
case 'linux':
|
||||
moreFonts = LINUX_FONTS;
|
||||
break;
|
||||
case 'ios':
|
||||
moreFonts = IOS_FONTS;
|
||||
break;
|
||||
case 'android':
|
||||
moreFonts = ANDROID_FONTS;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Book } from '@/types/book';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { formatAuthors, formatTitle } from '@/utils/book';
|
||||
|
||||
const BookCard = ({ book }: { book: Book }) => {
|
||||
const { coverImageUrl, title, author } = book;
|
||||
@@ -28,8 +29,8 @@ const BookCard = ({ book }: { book: Book }) => {
|
||||
}}
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='line-clamp-2 w-[90%] text-sm font-semibold'>{title}</h4>
|
||||
<p className='text-neutral-content truncate text-xs'>{author}</p>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
className='btn btn-ghost hover:bg-base-300 h-6 min-h-6 w-6 rounded-full p-0 transition-colors'
|
||||
|
||||
@@ -93,7 +93,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
||||
item.note && 'text-xs text-gray-500',
|
||||
(item.style === 'underline' || item.style === 'squiggly') &&
|
||||
'underline decoration-2',
|
||||
item.style === 'highlight' && `bg-${item.color}-100`,
|
||||
item.style === 'highlight' && `bg-${item.color}-500 bg-opacity-40`,
|
||||
item.style === 'underline' && `decoration-${item.color}-400`,
|
||||
item.style === 'squiggly' && `decoration-wavy decoration-${item.color}-400`,
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -90,7 +91,9 @@ const SidebarContent: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
<div
|
||||
className={clsx('flex-shrink-0', isPWA() ? 'pb-[calc(env(safe-area-inset-bottom)/2)]' : '')}
|
||||
>
|
||||
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,6 @@ import React from 'react';
|
||||
import { MdToc, MdEditNote, MdBookmarkBorder } from 'react-icons/md';
|
||||
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { isPWA } from '@/services/environment';
|
||||
|
||||
const TabNavigation: React.FC<{
|
||||
activeTab: string;
|
||||
@@ -15,14 +14,11 @@ const TabNavigation: React.FC<{
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bottom-tab border-base-300/50 relative flex w-full border-t',
|
||||
isPWA() ? 'bottom-[calc(env(safe-area-inset-bottom)_/_2)]' : '',
|
||||
)}
|
||||
className={clsx('bottom-tab border-base-300/50 bg-base-200 relative flex w-full border-t')}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-300 absolute bottom-1.5 left-1 -z-10 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
|
||||
'bg-base-300 absolute bottom-1.5 left-1 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
|
||||
'transform transition-transform duration-300',
|
||||
activeTab === 'toc' && 'translate-x-0',
|
||||
activeTab === 'annotations' && 'translate-x-[calc(100%+8px)]',
|
||||
@@ -34,11 +30,7 @@ const TabNavigation: React.FC<{
|
||||
key={tab}
|
||||
className='lg:tooltip lg:tooltip-top z-50 m-1.5 flex-1 cursor-pointer rounded-md p-2'
|
||||
data-tip={
|
||||
tab === 'toc'
|
||||
? _('Table of Contents')
|
||||
: tab === 'annotations'
|
||||
? _('Annotate')
|
||||
: _('Bookmark')
|
||||
tab === 'toc' ? _('TOC') : tab === 'annotations' ? _('Annotate') : _('Bookmark')
|
||||
}
|
||||
>
|
||||
<div className={clsx('')} onClick={() => onTabChange(tab)}>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getPopupPosition, Position } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { parseSSMLLang } from '@/utils/ssml';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
import { throttle } from '@/utils/ui';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { isPWA } from '@/services/environment';
|
||||
import Popup from '@/components/Popup';
|
||||
import TTSPanel from './TTSPanel';
|
||||
|
||||
@@ -7,20 +7,23 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
|
||||
import { CFI } from '@/libs/document';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
|
||||
|
||||
export const useProgressSync = (bookKey: string) => {
|
||||
const _ = useTranslation();
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
const { getView } = useReaderStore();
|
||||
const { getView, getProgress } = useReaderStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { syncedConfigs, syncConfigs } = useSync(bookKey);
|
||||
const { user } = useAuth();
|
||||
const view = getView(bookKey);
|
||||
const config = getConfig(bookKey);
|
||||
const progress = getProgress(bookKey);
|
||||
// flag to prevent accidental sync without first pulling the config
|
||||
const configSynced = useRef(false);
|
||||
const firstPulled = useRef(false);
|
||||
|
||||
const pushConfig = (bookKey: string, config: BookConfig | null) => {
|
||||
if (!config || !user) return;
|
||||
@@ -47,16 +50,31 @@ export const useProgressSync = (bookKey: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncBookProgress = (event: CustomEvent) => {
|
||||
const { bookKey: syncBookKey } = event.detail;
|
||||
if (syncBookKey === bookKey) {
|
||||
syncConfig();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
pullConfig(bookKey);
|
||||
// return () => {
|
||||
// if (configSynced.current) {
|
||||
// pushConfig(bookKey, config);
|
||||
// }
|
||||
// };
|
||||
eventDispatcher.on('sync-book-progress', handleSyncBookProgress);
|
||||
return () => {
|
||||
eventDispatcher.off('sync-book-progress', handleSyncBookProgress);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [bookKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!progress || firstPulled.current) return;
|
||||
firstPulled.current = true;
|
||||
pullConfig(bookKey);
|
||||
|
||||
return () => {
|
||||
syncConfig();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress]);
|
||||
|
||||
const lastProgressSyncTime = useRef<number>(0);
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -94,15 +112,17 @@ export const useProgressSync = (bookKey: string) => {
|
||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||
);
|
||||
setConfig(bookKey, { ...config, ...newConfig });
|
||||
if ((syncedConfig.progress?.[1] ?? 0) > 0 && (config?.progress?.[1] ?? 0) > 0) {
|
||||
const syncedFraction = syncedConfig.progress![0] / syncedConfig.progress![1];
|
||||
const configFraction = config!.progress![0] / config!.progress![1];
|
||||
if (syncedFraction > configFraction) {
|
||||
view?.goToFraction(syncedFraction);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Reading progress synced'),
|
||||
});
|
||||
const syncedCFI = syncedConfig.location;
|
||||
const configCFI = config?.location;
|
||||
if (syncedCFI && configCFI) {
|
||||
if (CFI.compare(configCFI, syncedCFI) < 0) {
|
||||
if (view) {
|
||||
view.goTo(syncedCFI);
|
||||
eventDispatcher.dispatch('hint', {
|
||||
bookKey,
|
||||
message: _('Reading Progress Synced'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,24 @@ import { useTheme } from '@/hooks/useTheme';
|
||||
import { hasUpdater } from '@/services/environment';
|
||||
import { checkForAppUpdates } from '@/helpers/updater';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import Reader from './components/Reader';
|
||||
|
||||
export default function Page() {
|
||||
const _ = useTranslation();
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const doAppUpdates = async () => {
|
||||
if (hasUpdater()) {
|
||||
const doCheckAppUpdates = async () => {
|
||||
if (hasUpdater() && settings.autoCheckUpdates) {
|
||||
await checkForAppUpdates(_);
|
||||
}
|
||||
};
|
||||
doAppUpdates();
|
||||
doCheckAppUpdates();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [settings]);
|
||||
|
||||
return <Reader />;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export const AboutWindow = () => {
|
||||
</p>
|
||||
{hasUpdater() && !isUpdated && (
|
||||
<span className='badge badge-primary mt-2 cursor-pointer' onClick={handleCheckUpdate}>
|
||||
{_('Check update')}
|
||||
{_('Check Update')}
|
||||
</span>
|
||||
)}
|
||||
{isUpdated && (
|
||||
|
||||
@@ -14,7 +14,7 @@ const Alert: React.FC<{
|
||||
role='alert'
|
||||
className={clsx(
|
||||
'alert fixed bottom-4 left-1/2 z-[100] flex -translate-x-1/2 transform items-center justify-between',
|
||||
'rounded-lg bg-gray-100 p-4 shadow-2xl',
|
||||
'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]',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,14 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { formatDate, formatLanguage, formatPublisher, formatSubject } from '@/utils/book';
|
||||
import {
|
||||
formatAuthors,
|
||||
formatDate,
|
||||
formatLanguage,
|
||||
formatPublisher,
|
||||
formatSubject,
|
||||
formatTitle,
|
||||
} from '@/utils/book';
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from './Spinner';
|
||||
import Dialog from './Dialog';
|
||||
@@ -24,9 +31,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [bookMeta, setBookMeta] = useState<BookDoc['metadata'] | null>(null);
|
||||
const { envConfig } = useEnv();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { deleteBook } = useLibraryStore();
|
||||
const { updateBook } = useLibraryStore();
|
||||
|
||||
useEffect(() => {
|
||||
const loadingTimeout = setTimeout(() => setLoading(true), 300);
|
||||
@@ -50,8 +57,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
setShowDeleteAlert(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
deleteBook(envConfig, book);
|
||||
const confirmDelete = async () => {
|
||||
await appService?.deleteBook(book, !!book.uploadedAt);
|
||||
await updateBook(envConfig, book);
|
||||
handleClose();
|
||||
setShowDeleteAlert(false);
|
||||
};
|
||||
@@ -71,8 +79,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
title={_('Book Details')}
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
className='!bg-[rgba(0,0,0,0.5)]'
|
||||
boxClassName='sm:min-w-[480px]'
|
||||
contentClassName='!p-6'
|
||||
contentClassName='!px-6 !py-2'
|
||||
>
|
||||
<div className='z-50 flex w-full select-text items-center justify-center'>
|
||||
<div className='relative w-full rounded-lg'>
|
||||
@@ -80,7 +89,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
<div className='book-cover relative mr-10 aspect-[28/41] h-40 items-end shadow-lg'>
|
||||
<Image
|
||||
src={book.coverImageUrl!}
|
||||
alt={book.title}
|
||||
alt={formatTitle(book.title)}
|
||||
fill={true}
|
||||
className='w-10 object-cover'
|
||||
onError={(e) => {
|
||||
@@ -96,16 +105,18 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
|
||||
'text-neutral-content rounded-none text-center font-serif text-base font-medium',
|
||||
)}
|
||||
>
|
||||
{book.title}
|
||||
{formatTitle(book.title)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='title-author flex h-40 flex-col justify-between'>
|
||||
<div>
|
||||
<p className='text-base-content mb-2 line-clamp-2 break-all text-2xl font-bold'>
|
||||
{book.title || _('Untitled')}
|
||||
{formatTitle(book.title) || _('Untitled')}
|
||||
</p>
|
||||
<p className='text-neutral-content line-clamp-1'>
|
||||
{formatAuthors(bookMeta.language, book.author) || _('Unknown')}
|
||||
</p>
|
||||
<p className='text-neutral-content line-clamp-1'>{book.author || _('Unknown')}</p>
|
||||
</div>
|
||||
{window.innerWidth >= 400 && (
|
||||
<div className='flex flex-wrap items-center gap-x-4 gap-y-2 py-2'>
|
||||
|
||||
@@ -57,7 +57,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
boxClassName,
|
||||
)}
|
||||
>
|
||||
<div className='dialog-header bg-base-100 sticky top-2 z-10 flex items-center justify-between px-4'>
|
||||
<div className='dialog-header bg-base-100 sticky top-1 z-10 flex items-center justify-between px-4'>
|
||||
{header ? (
|
||||
header
|
||||
) : (
|
||||
@@ -99,7 +99,7 @@ const Dialog: React.FC<DialogProps> = ({
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'text-base-content my-2 flex-grow overflow-y-auto px-[10%]',
|
||||
'text-base-content my-2 flex-grow overflow-y-auto px-6 sm:px-[10%]',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -33,11 +33,18 @@ const MenuItem: React.FC<MenuItemProps> = ({
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<div className='flex min-w-0 items-center'>
|
||||
{!noIcon && <span style={{ minWidth: `${iconSize}px` }}>{icon}</span>}
|
||||
<span className={clsx('ml-2 max-w-32 truncate', labelClass)}>{label}</span>
|
||||
<span
|
||||
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm', labelClass)}
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{shortcut && <span className='text-neutral-content hidden text-sm sm:flex'>{shortcut}</span>}
|
||||
{shortcut && (
|
||||
<span className='text-neutral-content hidden shrink-0 text-sm sm:flex'>{shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
type QuotaProps = {
|
||||
quotas: {
|
||||
name: string;
|
||||
tooltip: string;
|
||||
used: number;
|
||||
total: number;
|
||||
unit: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
const Quota: React.FC<QuotaProps> = ({ quotas }) => {
|
||||
return (
|
||||
<div className='w-full max-w-lg rounded-md pl-4 pr-2 text-base sm:text-sm'>
|
||||
<div>
|
||||
{quotas.map((quota) => (
|
||||
<div key={quota.name} className='flex h-10 items-center justify-between'>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={quota.tooltip}>
|
||||
<div className='flex max-w-28 items-center gap-x-2'>
|
||||
<span className='truncate'>{quota.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='py-3 text-right text-xs'>
|
||||
{quota.used} / {quota.total} {quota.unit}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Quota;
|
||||
@@ -48,14 +48,24 @@ export const Toast = () => {
|
||||
|
||||
return (
|
||||
toastMessage && (
|
||||
<div className={clsx('toast toast-center toast-middle', toastClassMap[toastType.current])}>
|
||||
<div
|
||||
className={clsx(
|
||||
'toast toast-center toast-middle w-auto max-w-screen-sm',
|
||||
toastClassMap[toastType.current],
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'alert flex max-w-80 items-center justify-center border-0',
|
||||
alertClassMap[toastType.current],
|
||||
)}
|
||||
>
|
||||
<span className={clsx('whitespace-normal break-words', messageClass.current)}>
|
||||
<span
|
||||
className={clsx(
|
||||
'max-h-[50vh] min-w-[30vw] overflow-scroll whitespace-normal break-words text-center',
|
||||
messageClass.current,
|
||||
)}
|
||||
>
|
||||
{toastMessage}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,9 @@ export const checkForAppUpdates = async (_: TranslationFunc) => {
|
||||
if (lastCheck && now - parseInt(lastCheck, 10) < CHECK_UPDATE_INTERVAL_SEC * 1000) return;
|
||||
localStorage.setItem(LAST_CHECK_KEY, now.toString());
|
||||
|
||||
console.log('Checking for updates');
|
||||
const update = await check();
|
||||
console.log('update found', update);
|
||||
console.log('Update found', update);
|
||||
if (update) {
|
||||
const yes = await ask(
|
||||
`
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const TRIGGER_THRESHOLD = 120;
|
||||
const SHOW_INDICATOR_THRESHOLD = 60;
|
||||
|
||||
const MAX = 128;
|
||||
const k = 0.4;
|
||||
function appr(x: number) {
|
||||
return MAX * (1 - Math.exp((-k * x) / MAX));
|
||||
}
|
||||
|
||||
export const usePullToRefresh = (ref: React.RefObject<HTMLDivElement>, onTrigger: () => void) => {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
el.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
|
||||
function handleTouchStart(startEvent: TouchEvent) {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const initialY = startEvent.touches[0]!.clientY;
|
||||
|
||||
el.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
el.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
function handleTouchMove(moveEvent: TouchEvent) {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const currentY = moveEvent.touches[0]!.clientY;
|
||||
const dy = currentY - initialY;
|
||||
|
||||
if (dy < 0) return;
|
||||
const parentEl = el.parentNode as HTMLDivElement;
|
||||
if (dy > TRIGGER_THRESHOLD) {
|
||||
flipArrow(parentEl);
|
||||
} else if (dy > SHOW_INDICATOR_THRESHOLD) {
|
||||
addPullIndicator(parentEl);
|
||||
} else {
|
||||
removePullIndicator(parentEl);
|
||||
}
|
||||
|
||||
el.style.transform = `translateY(${appr(dy)}px)`;
|
||||
}
|
||||
|
||||
function addPullIndicator(el: HTMLDivElement) {
|
||||
const indicator = el.querySelector('.pull-indicator');
|
||||
if (indicator) {
|
||||
if (indicator.classList.contains('flip')) {
|
||||
indicator.classList.remove('flip');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pullIndicator = document.createElement('div');
|
||||
pullIndicator.className = 'pull-indicator text-gray-500';
|
||||
pullIndicator.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 19c-.3 0-.6-.1-.8-.3l-6-6c-.4-.4-.4-1 0-1.4s1-.4 1.4 0L11 16.2V5c0-.6.4-1 1-1s1 .4 1 1v11.2l4.4-4.4c.4-.4 1-.4 1.4 0s.4 1 0 1.4l-6 6c-.2.2-.5.3-.8.3z"/>
|
||||
</svg>
|
||||
`;
|
||||
el.appendChild(pullIndicator);
|
||||
}
|
||||
|
||||
function removePullIndicator(el: HTMLDivElement) {
|
||||
const pullIndicator = el.querySelector('.pull-indicator');
|
||||
if (pullIndicator) {
|
||||
pullIndicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function flipArrow(el: HTMLDivElement) {
|
||||
const pullIndicator = el.querySelector('.pull-indicator');
|
||||
if (pullIndicator && !pullIndicator.classList.contains('flip')) {
|
||||
pullIndicator.classList.add('flip');
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd(endEvent: TouchEvent) {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
el.style.transform = 'translateY(0)';
|
||||
removePullIndicator(el.parentNode as HTMLDivElement);
|
||||
|
||||
el.style.transition = 'transform 0.2s';
|
||||
|
||||
const y = endEvent.changedTouches[0]!.clientY;
|
||||
const dy = y - initialY;
|
||||
if (dy > TRIGGER_THRESHOLD) {
|
||||
onTrigger();
|
||||
}
|
||||
|
||||
el.addEventListener('transitionend', onTransitionEnd);
|
||||
|
||||
el.removeEventListener('touchmove', handleTouchMove);
|
||||
el.removeEventListener('touchend', handleTouchEnd);
|
||||
}
|
||||
|
||||
function onTransitionEnd() {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
el.style.transition = '';
|
||||
el.removeEventListener('transitionend', onTransitionEnd);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('touchstart', handleTouchStart);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref.current]);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSyncContext } from '@/context/SyncContext';
|
||||
import { SyncData, SyncOp, SyncResult, SyncType } from '@/libs/sync';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -8,6 +9,7 @@ import { transformBookNoteFromDB } from '@/utils/transform';
|
||||
import { transformBookFromDB } from '@/utils/transform';
|
||||
import { DBBook, DBBookConfig, DBBookNote } from '@/types/records';
|
||||
import { Book, BookConfig, BookDataRecord, BookNote } from '@/types/book';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
|
||||
const transformsFromDB = {
|
||||
books: transformBookFromDB,
|
||||
@@ -33,29 +35,19 @@ const computeMaxTimestamp = (records: BookDataRecord[]): number => {
|
||||
const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function useSync(bookKey?: string) {
|
||||
const { settings } = useSettingsStore();
|
||||
const router = useRouter();
|
||||
const { settings, setSettings } = useSettingsStore();
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
const config = bookKey ? getConfig(bookKey) : null;
|
||||
|
||||
const [syncingBooks, setSyncingBooks] = useState(false);
|
||||
const [syncingConfigs, setSyncingConfigs] = useState(false);
|
||||
const [syncingNotes, setSyncingNotes] = useState(false);
|
||||
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
|
||||
const lastSyncedBooksAt = settings.lastSyncedAtBooks ?? 0;
|
||||
const lastSyncedConfigsAt = config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0;
|
||||
const lastSyncedNotesAt = config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0;
|
||||
|
||||
const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState<number>(
|
||||
lastSyncedBooksAt > 0 ? lastSyncedBooksAt - SEVEN_DAYS_IN_MS : 0,
|
||||
);
|
||||
const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState<number>(
|
||||
lastSyncedConfigsAt > 0 ? lastSyncedConfigsAt - SEVEN_DAYS_IN_MS : 0,
|
||||
);
|
||||
const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState<number>(
|
||||
lastSyncedNotesAt > 0 ? lastSyncedNotesAt - SEVEN_DAYS_IN_MS : 0,
|
||||
);
|
||||
const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState<number>(0);
|
||||
const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState<number>(0);
|
||||
const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState<number>(0);
|
||||
const lastSyncedAtInited = useRef(false);
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
// null means unsynced, empty array means synced no changes
|
||||
@@ -70,6 +62,19 @@ export function useSync(bookKey?: string) {
|
||||
|
||||
const { syncClient } = useSyncContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings || !config) return;
|
||||
if (lastSyncedAtInited.current) return;
|
||||
lastSyncedAtInited.current = true;
|
||||
|
||||
const lastSyncedBooksAt = settings.lastSyncedAtBooks ?? 0;
|
||||
const lastSyncedConfigsAt = config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0;
|
||||
const lastSyncedNotesAt = config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0;
|
||||
setLastSyncedAtBooks(lastSyncedBooksAt > 0 ? lastSyncedBooksAt - SEVEN_DAYS_IN_MS : 0);
|
||||
setLastSyncedAtConfigs(lastSyncedConfigsAt > 0 ? lastSyncedConfigsAt - SEVEN_DAYS_IN_MS : 0);
|
||||
setLastSyncedAtNotes(lastSyncedNotesAt > 0 ? lastSyncedNotesAt - SEVEN_DAYS_IN_MS : 0);
|
||||
}, [settings, config]);
|
||||
|
||||
// bookId is for configs and notes only, if bookId is provided, only pull changes for that book
|
||||
// and update the lastSyncedAt for that book in the book config
|
||||
const pullChanges = async (
|
||||
@@ -89,13 +94,19 @@ export function useSync(bookKey?: string) {
|
||||
if (!records?.length) return;
|
||||
const maxTime = computeMaxTimestamp(records);
|
||||
setLastSyncedAt(maxTime);
|
||||
|
||||
// due to closures in React hooks the settings might be stale
|
||||
// we need to fetch the latest settings from store
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
switch (type) {
|
||||
case 'books':
|
||||
settings.lastSyncedAtBooks = maxTime;
|
||||
setSettings(settings);
|
||||
break;
|
||||
case 'configs':
|
||||
if (!bookId) {
|
||||
settings.lastSyncedAtConfigs = maxTime;
|
||||
setSettings(settings);
|
||||
} else if (bookKey && config) {
|
||||
config.lastSyncedAtConfig = maxTime;
|
||||
setConfig(bookKey, config);
|
||||
@@ -104,6 +115,7 @@ export function useSync(bookKey?: string) {
|
||||
case 'notes':
|
||||
if (!bookId) {
|
||||
settings.lastSyncedAtNotes = maxTime;
|
||||
setSettings(settings);
|
||||
} else if (bookKey && config) {
|
||||
config.lastSyncedAtNotes = maxTime;
|
||||
setConfig(bookKey, config);
|
||||
@@ -113,6 +125,9 @@ export function useSync(bookKey?: string) {
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('Not authenticated')) {
|
||||
navigateToLogin(router);
|
||||
}
|
||||
setSyncError(err.message || `Error pulling ${type}`);
|
||||
} else {
|
||||
setSyncError(`Error pulling ${type}`);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { getAPIBaseUrl, isWebAppPlatform } from '@/services/environment';
|
||||
import { getAccessToken, getUserID } from '@/utils/access';
|
||||
import {
|
||||
tauriUpload,
|
||||
tauriDownload,
|
||||
webUpload,
|
||||
webDownload,
|
||||
ProgressHandler,
|
||||
ProgressPayload,
|
||||
} from '@/utils/transfer';
|
||||
|
||||
const API_ENDPOINTS = {
|
||||
upload: getAPIBaseUrl() + '/storage/upload',
|
||||
download: getAPIBaseUrl() + '/storage/download',
|
||||
delete: getAPIBaseUrl() + '/storage/delete',
|
||||
};
|
||||
|
||||
const fetchWithAuth = async (url: string, options: RequestInit) => {
|
||||
const token = await getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const headers = {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Error:', errorData.error || response.statusText);
|
||||
throw new Error(errorData.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export const createProgressHandler = (
|
||||
totalFiles: number,
|
||||
completedFilesRef: { count: number },
|
||||
onProgress?: ProgressHandler,
|
||||
) => {
|
||||
return (progress: ProgressPayload) => {
|
||||
const fileProgress = progress.progress / progress.total;
|
||||
const overallProgress = ((completedFilesRef.count + fileProgress) / totalFiles) * 100;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
progress: overallProgress,
|
||||
total: 100,
|
||||
transferSpeed: progress.transferSpeed,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const uploadFile = async (
|
||||
file: File,
|
||||
fileFullPath: string,
|
||||
onProgress?: ProgressHandler,
|
||||
bookHash?: string,
|
||||
) => {
|
||||
try {
|
||||
const response = await fetchWithAuth(API_ENDPOINTS.upload, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
bookHash,
|
||||
}),
|
||||
});
|
||||
|
||||
const { uploadUrl } = await response.json();
|
||||
if (isWebAppPlatform()) {
|
||||
await webUpload(file, uploadUrl, onProgress);
|
||||
} else {
|
||||
await tauriUpload(uploadUrl, fileFullPath, 'PUT', onProgress);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File upload failed:', error);
|
||||
throw new Error('File upload failed');
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadFile = async (
|
||||
filePath: string,
|
||||
fileFullPath: string,
|
||||
onProgress?: ProgressHandler,
|
||||
) => {
|
||||
try {
|
||||
const userId = await getUserID();
|
||||
if (!userId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const fileKey = `${userId}/${filePath}`;
|
||||
const response = await fetchWithAuth(
|
||||
`${API_ENDPOINTS.download}?fileKey=${encodeURIComponent(fileKey)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
);
|
||||
|
||||
const { downloadUrl } = await response.json();
|
||||
|
||||
if (isWebAppPlatform()) {
|
||||
return await webDownload(downloadUrl, onProgress);
|
||||
} else {
|
||||
await tauriDownload(downloadUrl, fileFullPath, onProgress);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File download failed:', error);
|
||||
throw new Error('File download failed');
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFile = async (filePath: string) => {
|
||||
try {
|
||||
const userId = await getUserID();
|
||||
if (!userId) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const fileKey = `${userId}/${filePath}`;
|
||||
await fetchWithAuth(`${API_ENDPOINTS.delete}?fileKey=${encodeURIComponent(fileKey)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('File deletion failed:', error);
|
||||
throw new Error('File deletion failed');
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { Book, BookConfig, BookNote, BookDataRecord } from '@/types/book';
|
||||
import { getAPIBaseUrl, isWebAppPlatform } from '@/services/environment';
|
||||
import { getAPIBaseUrl } from '@/services/environment';
|
||||
import { getAccessToken } from '@/utils/access';
|
||||
|
||||
const SYNC_API_ENDPOINT = getAPIBaseUrl() + '/sync';
|
||||
|
||||
@@ -29,7 +29,7 @@ export class SyncClient {
|
||||
* Returns updated or deleted records since that time.
|
||||
*/
|
||||
async pullChanges(since: number, type?: SyncType, book?: string): Promise<SyncResult> {
|
||||
const token = await this.getAccessToken();
|
||||
const token = await getAccessToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
const url = `${SYNC_API_ENDPOINT}?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}`;
|
||||
@@ -52,7 +52,7 @@ export class SyncClient {
|
||||
* Uses last-writer-wins logic as implemented on the server side.
|
||||
*/
|
||||
async pushChanges(payload: SyncData): Promise<SyncResult> {
|
||||
const token = await this.getAccessToken();
|
||||
const token = await getAccessToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
const res = await fetch(SYNC_API_ENDPOINT, {
|
||||
@@ -71,15 +71,4 @@ export class SyncClient {
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string | null> {
|
||||
// In browser context there might be two instances of supabase one in the app route
|
||||
// and the other in the pages route, and they might have different sessions
|
||||
// making the access token invalid for API calls. In that case we should use localStorage.
|
||||
if (isWebAppPlatform()) {
|
||||
return localStorage.getItem('token') ?? null;
|
||||
}
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data?.session?.access_token ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { corsAllMethods, runMiddleware } from '@/utils/cors';
|
||||
import { createSupabaseClient } from '@/utils/supabase';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { s3Client } from '@/utils/s3';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
await runMiddleware(req, res, corsAllMethods);
|
||||
|
||||
if (req.method !== 'DELETE') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, token } = await validateUserAndToken(req.headers['authorization']);
|
||||
if (!user || !token) {
|
||||
return res.status(403).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { fileKey } = req.query;
|
||||
|
||||
if (!fileKey || typeof fileKey !== 'string') {
|
||||
return res.status(400).json({ error: 'Missing or invalid fileKey' });
|
||||
}
|
||||
|
||||
const supabase = createSupabaseClient(token);
|
||||
const { data: fileRecord, error: fileError } = await supabase
|
||||
.from('files')
|
||||
.select('user_id, id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('file_key', fileKey)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (fileError || !fileRecord) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
if (fileRecord.user_id !== user.id) {
|
||||
return res.status(403).json({ error: 'Unauthorized access to the file' });
|
||||
}
|
||||
|
||||
const deleteCommand = new DeleteObjectCommand({
|
||||
Bucket: process.env['R2_BUCKET_NAME'] || '',
|
||||
Key: fileKey,
|
||||
});
|
||||
|
||||
try {
|
||||
await s3Client.send(deleteCommand);
|
||||
const { error: deleteError } = await supabase.from('files').delete().eq('id', fileRecord.id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Error updating file record:', deleteError);
|
||||
return res.status(500).json({ error: 'Could not update file record' });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'File deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting file from S3:', error);
|
||||
res.status(500).json({ error: 'Could not delete file from storage' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Something went wrong' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { supabase, createSupabaseClient } from '@/utils/supabase';
|
||||
import { corsAllMethods, runMiddleware } from '@/utils/cors';
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { s3Client } from '@/utils/s3';
|
||||
|
||||
const getUserAndToken = async (authHeader: string | undefined) => {
|
||||
if (!authHeader) return {};
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) return {};
|
||||
return { user, token };
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
await runMiddleware(req, res, corsAllMethods);
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, token } = await getUserAndToken(req.headers['authorization']);
|
||||
if (!user || !token) {
|
||||
return res.status(403).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { fileKey } = req.query;
|
||||
|
||||
if (!fileKey || typeof fileKey !== 'string') {
|
||||
return res.status(400).json({ error: 'Missing or invalid fileKey' });
|
||||
}
|
||||
|
||||
// Verify the file belongs to the user
|
||||
const supabase = createSupabaseClient(token);
|
||||
const { data: fileRecord, error: fileError } = await supabase
|
||||
.from('files')
|
||||
.select('user_id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('file_key', fileKey) // index idx_files_file_key_deleted_at on public.files
|
||||
.is('deleted_at', null)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (fileError || !fileRecord) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
if (fileRecord.user_id !== user.id) {
|
||||
return res.status(403).json({ error: 'Unauthorized access to the file' });
|
||||
}
|
||||
|
||||
const getCommand = new GetObjectCommand({
|
||||
Bucket: process.env['R2_BUCKET_NAME'] || '',
|
||||
Key: fileKey,
|
||||
});
|
||||
|
||||
try {
|
||||
const downloadUrl = await getSignedUrl(s3Client, getCommand, {
|
||||
expiresIn: 1800,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
downloadUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating signed URL for download:', error);
|
||||
res.status(500).json({ error: 'Could not create signed URL for download' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Something went wrong' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { supabase, createSupabaseClient } from '@/utils/supabase';
|
||||
import { corsAllMethods, runMiddleware } from '@/utils/cors';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { getStoragePlanData } from '@/utils/access';
|
||||
import { s3Client } from '@/utils/s3';
|
||||
|
||||
const getUserAndToken = async (authHeader: string | undefined) => {
|
||||
if (!authHeader) return {};
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) return {};
|
||||
return { user, token };
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
await runMiddleware(req, res, corsAllMethods);
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, token } = await getUserAndToken(req.headers['authorization']);
|
||||
if (!user || !token) {
|
||||
return res.status(403).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { fileName, fileSize, bookHash } = req.body;
|
||||
if (!fileName || !fileSize) {
|
||||
return res.status(400).json({ error: 'Missing file info' });
|
||||
}
|
||||
|
||||
const { usage, quota } = getStoragePlanData(token);
|
||||
if (usage + fileSize > quota) {
|
||||
return res.status(403).json({ error: 'Insufficient storage quota', usage });
|
||||
}
|
||||
|
||||
const objectKey = `${user.id}/${fileName}`;
|
||||
const supabase = createSupabaseClient(token);
|
||||
const { data: existingRecord, error: fetchError } = await supabase
|
||||
.from('files')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('file_key', objectKey)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (fetchError && fetchError.code !== 'PGRST116') {
|
||||
return res.status(500).json({ error: fetchError.message });
|
||||
}
|
||||
let objSize = fileSize;
|
||||
if (existingRecord) {
|
||||
objSize = existingRecord.file_size;
|
||||
} else {
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from('files')
|
||||
.insert([
|
||||
{
|
||||
user_id: user.id,
|
||||
book_hash: bookHash,
|
||||
file_key: objectKey,
|
||||
file_size: fileSize,
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
console.log('Inserted record:', inserted);
|
||||
if (insertError) return res.status(500).json({ error: insertError.message });
|
||||
}
|
||||
|
||||
const signableHeaders = new Set<string>();
|
||||
signableHeaders.add('content-length');
|
||||
const putCommand = new PutObjectCommand({
|
||||
Bucket: process.env['R2_BUCKET_NAME'] || '',
|
||||
Key: objectKey,
|
||||
ContentLength: objSize,
|
||||
});
|
||||
|
||||
try {
|
||||
const uploadUrl = await getSignedUrl(s3Client, putCommand, {
|
||||
expiresIn: 1800,
|
||||
signableHeaders,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
uploadUrl,
|
||||
fileKey: objectKey,
|
||||
usage: usage + fileSize,
|
||||
quota,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating presigned post:', error);
|
||||
res.status(500).json({ error: 'Could not create presigned post' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Something went wrong' });
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,18 @@ import {
|
||||
SYSTEM_SETTINGS_VERSION,
|
||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||
DEFAULT_TTS_CONFIG,
|
||||
CLOUD_BOOKS_SUBDIR,
|
||||
DEFAULT_MOBILE_VIEW_SETTINGS,
|
||||
DEFAULT_SYSTEM_SETTINGS,
|
||||
} from './constants';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
import { isWebAppPlatform } from './environment';
|
||||
import { getOSPlatform, 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 {
|
||||
isMobile: boolean = ['android', 'ios'].includes(getOSPlatform());
|
||||
localBooksDir: string = '';
|
||||
abstract fs: FileSystem;
|
||||
abstract appPlatform: AppPlatform;
|
||||
@@ -63,32 +70,32 @@ export abstract class BaseAppService implements AppService {
|
||||
settings.localBooksDir = await this.getInitBooksDir();
|
||||
settings.version = SYSTEM_SETTINGS_VERSION;
|
||||
}
|
||||
settings = { ...DEFAULT_SYSTEM_SETTINGS, ...settings };
|
||||
settings.globalReadSettings = { ...DEFAULT_READSETTINGS, ...settings.globalReadSettings };
|
||||
settings.globalViewSettings = {
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
...DEFAULT_BOOK_STYLE,
|
||||
...DEFAULT_BOOK_FONT,
|
||||
...(this.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
...DEFAULT_TTS_CONFIG,
|
||||
...settings.globalViewSettings,
|
||||
};
|
||||
} catch {
|
||||
settings = {
|
||||
...DEFAULT_SYSTEM_SETTINGS,
|
||||
version: SYSTEM_SETTINGS_VERSION,
|
||||
localBooksDir: await this.getInitBooksDir(),
|
||||
lastSyncedAtBooks: 0,
|
||||
lastSyncedAtConfigs: 0,
|
||||
lastSyncedAtNotes: 0,
|
||||
keepLogin: false,
|
||||
globalReadSettings: DEFAULT_READSETTINGS,
|
||||
globalViewSettings: {
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
...DEFAULT_BOOK_STYLE,
|
||||
...DEFAULT_BOOK_FONT,
|
||||
...(this.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
...DEFAULT_TTS_CONFIG,
|
||||
},
|
||||
};
|
||||
} as SystemSettings;
|
||||
|
||||
await this.fs.createDir('', 'Books', true);
|
||||
await this.fs.createDir('', base, true);
|
||||
@@ -150,6 +157,8 @@ export abstract class BaseAppService implements AppService {
|
||||
title: formatTitle(loadedBook.metadata.title),
|
||||
author: formatAuthors(loadedBook.metadata.language, loadedBook.metadata.author),
|
||||
createdAt: existingBook ? existingBook.createdAt : Date.now(),
|
||||
uploadedAt: existingBook ? existingBook.uploadedAt : null,
|
||||
downloadedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (!(await this.fs.exists(getDir(book), 'Books'))) {
|
||||
@@ -177,25 +186,133 @@ export abstract class BaseAppService implements AppService {
|
||||
if (typeof file === 'string' && isValidURL(file)) {
|
||||
book.url = file;
|
||||
}
|
||||
if (this.appPlatform === 'web') {
|
||||
book.coverImageUrl = await this.getCoverImageBlobUrl(book);
|
||||
} else {
|
||||
book.coverImageUrl = this.getCoverImageUrl(book);
|
||||
}
|
||||
book.coverImageUrl = await this.generateCoverImageUrl(book);
|
||||
|
||||
return book;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async deleteBook(book: Book): Promise<void> {
|
||||
for (const fp of [getFilename(book), getCoverFilename(book)]) {
|
||||
if (await this.fs.exists(fp, 'Books')) {
|
||||
await this.fs.removeFile(fp, 'Books');
|
||||
async deleteBook(book: Book, includingUploaded = false): Promise<void> {
|
||||
const fps = [getFilename(book), getCoverFilename(book)];
|
||||
const localDeleteFps = (
|
||||
await Promise.all(fps.map(async (fp) => ((await this.fs.exists(fp, 'Books')) ? fp : null)))
|
||||
).filter(Boolean) as string[];
|
||||
for (const fp of localDeleteFps) {
|
||||
await this.fs.removeFile(fp, 'Books');
|
||||
}
|
||||
for (const fp of fps) {
|
||||
if (includingUploaded) {
|
||||
console.log('Deleting uploaded file:', fp);
|
||||
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
|
||||
try {
|
||||
deleteFile(cfp);
|
||||
} catch (error) {
|
||||
console.log('Failed to delete uploaded file:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
book.deletedAt = Date.now();
|
||||
book.downloadedAt = null;
|
||||
if (includingUploaded) {
|
||||
book.uploadedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void> {
|
||||
let file: File;
|
||||
let uploaded = false;
|
||||
const completedFiles = { count: 0 };
|
||||
const fps = (
|
||||
await Promise.all(
|
||||
[getCoverFilename(book), getFilename(book)].map(async (fp) =>
|
||||
(await this.fs.exists(fp, 'Books')) ? fp : null,
|
||||
),
|
||||
)
|
||||
).filter(Boolean) as string[];
|
||||
if (!fps.includes(getFilename(book)) && book.url) {
|
||||
// download the book from the URL
|
||||
const fileobj = await new RemoteFile(book.url).open();
|
||||
await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer());
|
||||
fps.push(getFilename(book));
|
||||
}
|
||||
const handleProgress = createProgressHandler(fps.length, completedFiles, onProgress);
|
||||
for (const fp of fps) {
|
||||
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
|
||||
const fullpath = `${this.localBooksDir}/${fp}`;
|
||||
if (this.appPlatform === 'web') {
|
||||
const content = await this.fs.readFile(fp, 'Books', 'binary');
|
||||
file = new File([content], cfp);
|
||||
} else {
|
||||
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), cfp).open();
|
||||
}
|
||||
console.log('Uploading file:', fp);
|
||||
await uploadFile(file, fullpath, handleProgress, book.hash);
|
||||
uploaded = true;
|
||||
completedFiles.count++;
|
||||
}
|
||||
if (uploaded) {
|
||||
book.deletedAt = null;
|
||||
book.updatedAt = Date.now();
|
||||
book.uploadedAt = Date.now();
|
||||
book.downloadedAt = Date.now();
|
||||
} else {
|
||||
throw new Error('Book file not uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBook(book: Book, onlyCover = false, onProgress?: ProgressHandler): Promise<void> {
|
||||
const fps = onlyCover ? [getCoverFilename(book)] : [getCoverFilename(book), getFilename(book)];
|
||||
|
||||
let bookDownloaded = false;
|
||||
const completedFiles = { count: 0 };
|
||||
const toDownloadFps = (
|
||||
await Promise.all(
|
||||
[getFilename(book), getCoverFilename(book)].map(async (fp) =>
|
||||
(await this.fs.exists(fp, 'Books')) ? null : fp,
|
||||
),
|
||||
)
|
||||
).filter(Boolean) as string[];
|
||||
const handleProgress = createProgressHandler(toDownloadFps.length, completedFiles, onProgress);
|
||||
for (const fp of fps) {
|
||||
let downloaded = false;
|
||||
const existed = !toDownloadFps.includes(fp);
|
||||
if (existed) {
|
||||
downloaded = true;
|
||||
} else {
|
||||
console.log('Downloading file:', fp);
|
||||
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
|
||||
const fullpath = `${this.localBooksDir}/${fp}`;
|
||||
if (!(await this.fs.exists(getDir(book), 'Books'))) {
|
||||
await this.fs.createDir(getDir(book), 'Books');
|
||||
}
|
||||
try {
|
||||
const result = await downloadFile(cfp, fullpath, handleProgress);
|
||||
if (isWebAppPlatform()) {
|
||||
const fileobj = result as Blob;
|
||||
await this.fs.writeFile(fp, 'Books', await fileobj.arrayBuffer());
|
||||
downloaded = true;
|
||||
} else {
|
||||
downloaded = await this.fs.exists(fp, 'Books');
|
||||
}
|
||||
} catch {
|
||||
if (fp === getCoverFilename(book)) {
|
||||
console.log('Failed to download cover image:', fp);
|
||||
} else {
|
||||
throw new Error('Failed to download book file');
|
||||
}
|
||||
}
|
||||
completedFiles.count++;
|
||||
}
|
||||
if (fp === getFilename(book)) {
|
||||
bookDownloaded = downloaded;
|
||||
}
|
||||
}
|
||||
// some books may not have cover image, so we need to check if the book is downloaded
|
||||
if (bookDownloaded) {
|
||||
book.downloadedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
async loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent> {
|
||||
@@ -217,12 +334,15 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
|
||||
async loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig> {
|
||||
const { globalViewSettings } = settings;
|
||||
try {
|
||||
const str = await this.fs.readFile(getConfigFilename(book), 'Books', 'text');
|
||||
const { globalViewSettings } = settings;
|
||||
return deserializeConfig(str as string, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||
let str = '{}';
|
||||
if (await this.fs.exists(getConfigFilename(book), 'Books')) {
|
||||
str = (await this.fs.readFile(getConfigFilename(book), 'Books', 'text')) as string;
|
||||
}
|
||||
return deserializeConfig(str, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||
} catch {
|
||||
return INIT_BOOK_CONFIG;
|
||||
return deserializeConfig('{}', globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +363,12 @@ export abstract class BaseAppService implements AppService {
|
||||
await this.fs.writeFile(getConfigFilename(book), 'Books', serializedConfig);
|
||||
}
|
||||
|
||||
async generateCoverImageUrl(book: Book): Promise<string> {
|
||||
return this.appPlatform === 'web'
|
||||
? await this.getCoverImageBlobUrl(book)
|
||||
: this.getCoverImageUrl(book);
|
||||
}
|
||||
|
||||
async loadLibraryBooks(): Promise<Book[]> {
|
||||
console.log('Loading library books...');
|
||||
let books: Book[] = [];
|
||||
@@ -258,11 +384,7 @@ export abstract class BaseAppService implements AppService {
|
||||
|
||||
await Promise.all(
|
||||
books.map(async (book) => {
|
||||
if (this.appPlatform === 'web') {
|
||||
book.coverImageUrl = await this.getCoverImageBlobUrl(book);
|
||||
} else {
|
||||
book.coverImageUrl = this.getCoverImageUrl(book);
|
||||
}
|
||||
book.coverImageUrl = await this.generateCoverImageUrl(book);
|
||||
book.updatedAt ??= book.lastUpdated || Date.now();
|
||||
return book;
|
||||
}),
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
BookStyle,
|
||||
TTSConfig,
|
||||
ViewConfig,
|
||||
ViewSettings,
|
||||
} from '@/types/book';
|
||||
import { ReadSettings } from '@/types/settings';
|
||||
import { ReadSettings, SystemSettings } from '@/types/settings';
|
||||
import { UserStorageQuota } from '@/types/user';
|
||||
|
||||
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
|
||||
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
|
||||
@@ -14,6 +16,16 @@ export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
|
||||
export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'pdf'];
|
||||
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
|
||||
|
||||
export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
keepLogin: false,
|
||||
autoUpload: true,
|
||||
autoCheckUpdates: true,
|
||||
|
||||
lastSyncedAtBooks: 0,
|
||||
lastSyncedAtConfigs: 0,
|
||||
lastSyncedAtNotes: 0,
|
||||
};
|
||||
|
||||
export const DEFAULT_READSETTINGS: ReadSettings = {
|
||||
sideBarWidth: '15%',
|
||||
isSideBarPinned: true,
|
||||
@@ -64,6 +76,10 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
userStylesheet: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_MOBILE_VIEW_SETTINGS: Partial<ViewSettings> = {
|
||||
fullJustification: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
||||
sideBarTab: 'toc',
|
||||
};
|
||||
@@ -116,6 +132,7 @@ export const WINDOWS_FONTS = [
|
||||
'Gabriola',
|
||||
'Gadugi',
|
||||
'Georgia',
|
||||
'Heiti',
|
||||
'HoloLens MDL2 Assets',
|
||||
'Impact',
|
||||
'Ink Free',
|
||||
@@ -165,6 +182,7 @@ export const WINDOWS_FONTS = [
|
||||
'Verdana',
|
||||
'Webdings',
|
||||
'Wingdings',
|
||||
'XiHeiti',
|
||||
'Yu Gothic',
|
||||
'Yu Mincho',
|
||||
];
|
||||
@@ -200,6 +218,7 @@ export const MACOS_FONTS = [
|
||||
'Didot',
|
||||
'DIN Alternate',
|
||||
'DIN Condensed',
|
||||
'FangSong',
|
||||
'Futura',
|
||||
'Geneva',
|
||||
'Georgia',
|
||||
@@ -249,6 +268,7 @@ export const MACOS_FONTS = [
|
||||
'Trattatello',
|
||||
'Trebuchet MS',
|
||||
'Verdana',
|
||||
'XiHeiti',
|
||||
'Yu Mincho',
|
||||
'Zapfino',
|
||||
];
|
||||
@@ -263,11 +283,14 @@ export const LINUX_FONTS = [
|
||||
'DejaVu Serif',
|
||||
'Droid Sans',
|
||||
'Droid Sans Mono',
|
||||
'FangSong',
|
||||
'FreeMono',
|
||||
'FreeSans',
|
||||
'FreeSerif',
|
||||
'Georgia',
|
||||
'Heiti',
|
||||
'Impact',
|
||||
'Kaiti',
|
||||
'Liberation Mono',
|
||||
'Liberation Sans',
|
||||
'Liberation Serif',
|
||||
@@ -293,6 +316,59 @@ export const LINUX_FONTS = [
|
||||
'Wingdings',
|
||||
'WenQuanYi Micro Hei',
|
||||
'WenQuanYi Zen Hei',
|
||||
'XiHeiti',
|
||||
];
|
||||
|
||||
export const IOS_FONTS = [
|
||||
'Avenir',
|
||||
'Avenir Next',
|
||||
'Courier',
|
||||
'Courier New',
|
||||
'FangSong',
|
||||
'Georgia',
|
||||
'Heiti',
|
||||
'Helvetica',
|
||||
'Helvetica Neue',
|
||||
'Hiragino Mincho',
|
||||
'Hiragino Sans',
|
||||
'Kaiti',
|
||||
'Palatino',
|
||||
'PingFang SC',
|
||||
'PingFang TC',
|
||||
'San Francisco',
|
||||
'SF Pro Display',
|
||||
'SF Pro Rounded',
|
||||
'SF Pro Text',
|
||||
'Songti',
|
||||
'Times New Roman',
|
||||
'Verdana',
|
||||
'XiHeiti',
|
||||
];
|
||||
|
||||
export const ANDROID_FONTS = [
|
||||
'Arial',
|
||||
'Droid Sans',
|
||||
'Droid Serif',
|
||||
'FangSong',
|
||||
'FZLanTingHei',
|
||||
'Georgia',
|
||||
'Heiti',
|
||||
'Kaiti',
|
||||
'Noto Sans',
|
||||
'Noto Sans CJK',
|
||||
'Noto Sans JP',
|
||||
'Noto Serif',
|
||||
'Noto Serif CJK',
|
||||
'Noto Serif JP',
|
||||
'PingFang SC',
|
||||
'Roboto',
|
||||
'Source Han Sans',
|
||||
'Source Han Serif',
|
||||
'STHeiti',
|
||||
'STSong',
|
||||
'Tahoma',
|
||||
'Verdana',
|
||||
'XiHeiti',
|
||||
];
|
||||
|
||||
export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
|
||||
@@ -304,8 +380,15 @@ export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web'
|
||||
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
|
||||
|
||||
export const SYNC_PROGRESS_INTERVAL_SEC = 60;
|
||||
export const SYNC_NOTES_INTERVAL_SEC = 60;
|
||||
export const SYNC_NOTES_INTERVAL_SEC = 10;
|
||||
export const SYNC_BOOKS_INTERVAL_SEC = 10;
|
||||
export const CHECK_UPDATE_INTERVAL_SEC = 24 * 60 * 60;
|
||||
|
||||
export const MAX_ZOOM_LEVEL = 140;
|
||||
export const MIN_ZOOM_LEVEL = 95;
|
||||
|
||||
export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
|
||||
free: 500 * 1024 * 1024,
|
||||
plus: 2 * 1024 * 1024 * 1024,
|
||||
pro: 10 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export const isPWA = () => window.matchMedia('(display-mode: standalone)').match
|
||||
export const getAPIBaseUrl = () =>
|
||||
process.env['NODE_ENV'] === 'development' && isWebAppPlatform()
|
||||
? '/api'
|
||||
: `${READEST_WEB_BASE_URL}/api`;
|
||||
: `${process.env['NEXT_PUBLIC_API_BASE_URL'] ?? READEST_WEB_BASE_URL}/api`;
|
||||
|
||||
export interface EnvConfigType {
|
||||
getAppService: () => Promise<AppService>;
|
||||
|
||||
@@ -25,13 +25,13 @@ interface BookDataState {
|
||||
settings: SystemSettings,
|
||||
) => void;
|
||||
updateBooknotes: (key: string, booknotes: BookNote[]) => BookConfig | undefined;
|
||||
getBookData: (key: string) => BookData | null;
|
||||
getBookData: (keyOrId: string) => BookData | null;
|
||||
}
|
||||
|
||||
export const useBookDataStore = create<BookDataState>((set, get) => ({
|
||||
booksData: {},
|
||||
getBookData: (key: string) => {
|
||||
const id = key.split('-')[0]!;
|
||||
getBookData: (keyOrId: string) => {
|
||||
const id = keyOrId.split('-')[0]!;
|
||||
return get().booksData[id] || null;
|
||||
},
|
||||
getConfig: (key: string | null) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ interface LibraryState {
|
||||
checkOpenWithBooks: boolean;
|
||||
clearOpenWithBooks: () => void;
|
||||
setLibrary: (books: Book[]) => void;
|
||||
deleteBook: (envConfig: EnvConfigType, book: Book) => void;
|
||||
updateBook: (envConfig: EnvConfigType, book: Book) => void;
|
||||
}
|
||||
|
||||
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
@@ -15,13 +15,12 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
checkOpenWithBooks: true,
|
||||
clearOpenWithBooks: () => set({ checkOpenWithBooks: false }),
|
||||
setLibrary: (books) => set({ library: books }),
|
||||
deleteBook: async (envConfig: EnvConfigType, book: Book) => {
|
||||
updateBook: async (envConfig: EnvConfigType, book: Book) => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const { library } = get();
|
||||
const bookIndex = library.findIndex((b) => b.hash === book.hash);
|
||||
if (bookIndex !== -1) {
|
||||
library.splice(bookIndex, 1);
|
||||
appService.deleteBook(book);
|
||||
library[bookIndex] = book;
|
||||
}
|
||||
set({ library });
|
||||
appService.saveLibraryBooks(library);
|
||||
|
||||
@@ -49,7 +49,12 @@ interface ReaderStore {
|
||||
setViewSettings: (key: string, viewSettings: ViewSettings) => void;
|
||||
getViewSettings: (key: string) => ViewSettings | null;
|
||||
|
||||
initViewState: (envConfig: EnvConfigType, id: string, key: string, isPrimary?: boolean) => void;
|
||||
initViewState: (
|
||||
envConfig: EnvConfigType,
|
||||
id: string,
|
||||
key: string,
|
||||
isPrimary?: boolean,
|
||||
) => Promise<void>;
|
||||
clearViewState: (key: string) => void;
|
||||
getViewState: (key: string) => ViewState | null;
|
||||
}
|
||||
|
||||
@@ -209,3 +209,24 @@ foliate-view {
|
||||
.scroll-container.hidden-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.pull-indicator {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-lighter);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.pull-indicator svg {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.pull-indicator.flip svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface Book {
|
||||
updatedAt: number;
|
||||
deletedAt?: number | null;
|
||||
|
||||
uploadedAt?: number | null;
|
||||
downloadedAt?: number | null;
|
||||
|
||||
lastUpdated?: number; // deprecated in favor of updatedAt
|
||||
progress?: [number, number]; // Add progress field: [current, total]
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ export interface DBBook {
|
||||
title: string;
|
||||
author: string;
|
||||
group?: string;
|
||||
tags?: string;
|
||||
tags?: string[];
|
||||
progress?: [number, number];
|
||||
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
uploaded_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DBBookConfig {
|
||||
|
||||
@@ -17,7 +17,10 @@ export interface ReadSettings {
|
||||
export interface SystemSettings {
|
||||
version: number;
|
||||
localBooksDir: string;
|
||||
|
||||
keepLogin: boolean;
|
||||
autoUpload: boolean;
|
||||
autoCheckUpdates: boolean;
|
||||
|
||||
lastSyncedAtBooks: number;
|
||||
lastSyncedAtConfigs: number;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SystemSettings } from './settings';
|
||||
import { Book, BookConfig, BookContent } from './book';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { ProgressHandler } from '@/utils/transfer';
|
||||
|
||||
export type AppPlatform = 'web' | 'tauri';
|
||||
export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None';
|
||||
@@ -24,6 +25,7 @@ export interface AppService {
|
||||
appPlatform: AppPlatform;
|
||||
hasTrafficLight: boolean;
|
||||
hasWindowBar: boolean;
|
||||
isMobile: boolean;
|
||||
isAppDataSandbox: boolean;
|
||||
|
||||
selectFiles(name: string, extensions: string[]): Promise<string[]>;
|
||||
@@ -38,7 +40,9 @@ export interface AppService {
|
||||
saveCover?: boolean,
|
||||
overwrite?: boolean,
|
||||
): Promise<Book | null>;
|
||||
deleteBook(book: Book): Promise<void>;
|
||||
deleteBook(book: Book, includingUploaded?: boolean): Promise<void>;
|
||||
uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void>;
|
||||
downloadBook(book: Book, onlyCover?: boolean, onProgress?: ProgressHandler): Promise<void>;
|
||||
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
|
||||
fetchBookDetails(book: Book, settings: SystemSettings): Promise<BookDoc['metadata']>;
|
||||
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
|
||||
@@ -47,4 +51,5 @@ export interface AppService {
|
||||
saveLibraryBooks(books: Book[]): Promise<void>;
|
||||
getCoverImageUrl(book: Book): string;
|
||||
getCoverImageBlobUrl(book: Book): Promise<string>;
|
||||
generateCoverImageUrl(book: Book): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export type UserStorageQuota = {
|
||||
free: number;
|
||||
plus: number;
|
||||
pro: number;
|
||||
};
|
||||
|
||||
export type UserPlan = keyof UserStorageQuota;
|
||||
|
||||
export type QuotaType = {
|
||||
name: string;
|
||||
tooltip: string;
|
||||
used: number;
|
||||
total: number;
|
||||
unit: string;
|
||||
};
|
||||
@@ -35,7 +35,10 @@ export interface FoliateView extends HTMLElement {
|
||||
};
|
||||
renderer: {
|
||||
scrolled?: boolean;
|
||||
size: number;
|
||||
viewSize: number;
|
||||
start: number;
|
||||
end: number;
|
||||
setAttribute: (name: string, value: string | number) => void;
|
||||
removeAttribute: (name: string) => void;
|
||||
next: () => Promise<void>;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { UserPlan } from '@/types/user';
|
||||
import { DEFAULT_STORAGE_QUOTA } from '@/services/constants';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
|
||||
interface Token {
|
||||
plan: UserPlan;
|
||||
storage_usage_bytes: number;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export const getStoragePlanData = (token: string) => {
|
||||
const data = jwtDecode<Token>(token) || {};
|
||||
const plan = data['plan'] || 'free';
|
||||
const usage = data['storage_usage_bytes'] || 0;
|
||||
const quota = DEFAULT_STORAGE_QUOTA[plan] || DEFAULT_STORAGE_QUOTA['free'];
|
||||
|
||||
return {
|
||||
plan,
|
||||
usage,
|
||||
quota,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAccessToken = async (): Promise<string | null> => {
|
||||
// In browser context there might be two instances of supabase one in the app route
|
||||
// and the other in the pages route, and they might have different sessions
|
||||
// making the access token invalid for API calls. In that case we should use localStorage.
|
||||
if (isWebAppPlatform()) {
|
||||
return localStorage.getItem('token') ?? null;
|
||||
}
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data?.session?.access_token ?? null;
|
||||
};
|
||||
|
||||
export const getUserID = async (): Promise<string | null> => {
|
||||
if (isWebAppPlatform()) {
|
||||
const user = localStorage.getItem('user') ?? '{}';
|
||||
return JSON.parse(user).id ?? null;
|
||||
}
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data?.session?.user?.id ?? null;
|
||||
};
|
||||
|
||||
export const validateUserAndToken = async (authHeader: string | undefined) => {
|
||||
if (!authHeader) return {};
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) return {};
|
||||
return { user, token };
|
||||
};
|
||||
@@ -17,5 +17,5 @@ export const runMiddleware = (req: NextApiRequest, res: NextApiResponse, fn: Fun
|
||||
};
|
||||
|
||||
export const corsAllMethods = Cors({
|
||||
methods: ['POST', 'GET', 'HEAD'],
|
||||
methods: ['POST', 'GET', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'],
|
||||
});
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
class EventDispatcher {
|
||||
private syncListeners: Map<string, Set<(event: CustomEvent) => boolean>>;
|
||||
private asyncListeners: Map<string, Set<(event: CustomEvent) => Promise<void>>>;
|
||||
private asyncListeners: Map<string, Set<(event: CustomEvent) => Promise<void> | void>>;
|
||||
|
||||
constructor() {
|
||||
this.syncListeners = new Map();
|
||||
this.asyncListeners = new Map();
|
||||
}
|
||||
|
||||
on(event: string, callback: (event: CustomEvent) => Promise<void>): void {
|
||||
on(event: string, callback: (event: CustomEvent) => Promise<void> | void): void {
|
||||
if (!this.asyncListeners.has(event)) {
|
||||
this.asyncListeners.set(event, new Set());
|
||||
}
|
||||
this.asyncListeners.get(event)!.add(callback);
|
||||
}
|
||||
|
||||
off(event: string, callback: (event: CustomEvent) => Promise<void>): void {
|
||||
off(event: string, callback: (event: CustomEvent) => Promise<void> | void): void {
|
||||
this.asyncListeners.get(event)?.delete(callback);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { useRouter, redirect } from 'next/navigation';
|
||||
import { isPWA, isWebAppPlatform } from '@/services/environment';
|
||||
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
|
||||
|
||||
export const navigateToReader = (
|
||||
@@ -9,7 +9,7 @@ export const navigateToReader = (
|
||||
navOptions?: { scroll?: boolean },
|
||||
) => {
|
||||
const ids = bookIds.join(BOOK_IDS_SEPARATOR);
|
||||
if (isWebAppPlatform()) {
|
||||
if (isWebAppPlatform() && !isPWA()) {
|
||||
router.push(`/reader/${ids}${queryParams ? `?${queryParams}` : ''}`, navOptions);
|
||||
} else {
|
||||
const params = new URLSearchParams(queryParams || '');
|
||||
@@ -18,6 +18,17 @@ export const navigateToReader = (
|
||||
}
|
||||
};
|
||||
|
||||
export const navigateToLogin = (router: ReturnType<typeof useRouter>) => {
|
||||
const pathname = window.location.pathname;
|
||||
const search = window.location.search;
|
||||
const currentPath = pathname !== '/auth' ? pathname + search : '/';
|
||||
router.push(`/auth?redirect=${encodeURIComponent(currentPath)}`);
|
||||
};
|
||||
|
||||
export const navigateToLibrary = (router: ReturnType<typeof useRouter>) => {
|
||||
router.push('/library');
|
||||
};
|
||||
|
||||
export const redirectToLibrary = () => {
|
||||
redirect('/library');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
|
||||
const S3_ACCOUNT_ID = process.env['R2_ACCOUNT_ID'] || '';
|
||||
const S3_ACCESS_KEY_ID = process.env['R2_ACCESS_KEY_ID'] || '';
|
||||
const S3_SECRET_ACCESS_KEY = process.env['R2_SECRET_ACCESS_KEY'] || '';
|
||||
const S3_REGION = process.env['R2_REGION'] || 'auto';
|
||||
|
||||
export const s3Client = new S3Client({
|
||||
region: S3_REGION,
|
||||
endpoint: `https://${S3_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
export const r2Client = s3Client;
|
||||
@@ -95,7 +95,7 @@ export const getPosition = (target: Range | Element, rect: Rect, isVertical: boo
|
||||
dir: 'up',
|
||||
} as Position;
|
||||
const end = {
|
||||
point: { x: (last.left + last.right) / 2 - rect.left, y: last.bottom - rect.top },
|
||||
point: { x: (last.left + last.right) / 2 - rect.left, y: last.bottom - rect.top + 6 },
|
||||
dir: 'down',
|
||||
} as Position;
|
||||
const startInView = pointIsInView(start.point);
|
||||
@@ -119,12 +119,12 @@ export const getPopupPosition = (
|
||||
popupPoint.y = position.point.y - popupHeightPx;
|
||||
} else if (position.dir === 'down') {
|
||||
popupPoint.x = position.point.x - popupWidthPx / 2;
|
||||
popupPoint.y = position.point.y + 6;
|
||||
popupPoint.y = position.point.y + 5;
|
||||
} else if (position.dir === 'left') {
|
||||
popupPoint.x = position.point.x - popupWidthPx;
|
||||
popupPoint.y = position.point.y - popupHeightPx / 2;
|
||||
} else if (position.dir === 'right') {
|
||||
popupPoint.x = position.point.x + 6;
|
||||
popupPoint.x = position.point.x + 5;
|
||||
popupPoint.y = position.point.y - popupHeightPx / 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ViewSettings } from '@/types/book';
|
||||
import { Palette } from '@/styles/themes';
|
||||
|
||||
import fontfacesCSS from '!!raw-loader!../styles/fonts.css';
|
||||
import { getOSPlatform } from './misc';
|
||||
|
||||
const getFontStyles = (
|
||||
serif: string,
|
||||
@@ -30,9 +31,9 @@ const getFontStyles = (
|
||||
--sans-serif: ${sansSerifFonts.map((font) => `"${font}"`).join(', ')}, sans-serif;
|
||||
--monospace: ${monospaceFonts.map((font) => `"${font}"`).join(', ')}, monospace;
|
||||
}
|
||||
body {
|
||||
html, body {
|
||||
font-family: var(${defaultFont.toLowerCase() === 'serif' ? '--serif' : '--sans-serif'}) ${overrideFont ? '!important' : ''};
|
||||
font-size: ${fontSize}px ${overrideFont ? '!important' : ''};
|
||||
font-size: ${fontSize}px !important;
|
||||
}
|
||||
body * {
|
||||
font-family: revert ${overrideFont ? '!important' : ''};
|
||||
@@ -209,12 +210,15 @@ export const getStyles = (viewSettings: ViewSettings, themeCode: ThemeCode) => {
|
||||
themeCode.fg,
|
||||
themeCode.primary,
|
||||
);
|
||||
// scale the font size on-the-fly so that we can sync the same font size on different devices
|
||||
const isMobile = ['ios', 'android'].includes(getOSPlatform());
|
||||
const fontScale = isMobile ? 1.25 : 1;
|
||||
const fontStyles = getFontStyles(
|
||||
viewSettings.serifFont!,
|
||||
viewSettings.sansSerifFont!,
|
||||
viewSettings.monospaceFont!,
|
||||
viewSettings.defaultFont!,
|
||||
viewSettings.defaultFontSize!,
|
||||
viewSettings.defaultFontSize! * fontScale,
|
||||
viewSettings.overrideFont!,
|
||||
);
|
||||
const userStylesheet = viewSettings.userStylesheet!;
|
||||
|
||||
@@ -4,6 +4,7 @@ export const throttle = <T extends (...args: Parameters<T>) => void | Promise<vo
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let lastCall = 0;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastArgs: Parameters<T> | null = null;
|
||||
|
||||
return (...args: Parameters<T>): void => {
|
||||
const now = Date.now();
|
||||
@@ -21,8 +22,17 @@ export const throttle = <T extends (...args: Parameters<T>) => void | Promise<vo
|
||||
timeout = null;
|
||||
}
|
||||
callFunc();
|
||||
} else if (!timeout) {
|
||||
timeout = setTimeout(callFunc, remaining);
|
||||
} else {
|
||||
lastArgs = args;
|
||||
if (!timeout) {
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
if (lastArgs) {
|
||||
func(...(lastArgs as Parameters<T>));
|
||||
lastArgs = null;
|
||||
}
|
||||
}, remaining);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { invoke, Channel } from '@tauri-apps/api/core';
|
||||
|
||||
export type UploadMethod = 'POST' | 'PUT';
|
||||
|
||||
export interface ProgressPayload {
|
||||
progress: number;
|
||||
total: number;
|
||||
transferSpeed: number;
|
||||
}
|
||||
|
||||
export type ProgressHandler = (progress: ProgressPayload) => void;
|
||||
|
||||
export const webUpload = (file: File, uploadUrl: string, onProgress?: ProgressHandler) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', uploadUrl, true);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (onProgress && event.lengthComputable) {
|
||||
onProgress({
|
||||
progress: event.loaded,
|
||||
total: event.total,
|
||||
transferSpeed: event.loaded / ((Date.now() - startTime) / 1000),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('Upload failed'));
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
};
|
||||
|
||||
export const webDownload = async (downloadUrl: string, onProgress?: ProgressHandler) => {
|
||||
const response = await fetch(downloadUrl);
|
||||
if (!response.ok) throw new Error('File download failed');
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (!contentLength) throw new Error('Cannot track progress: Content-Length missing');
|
||||
|
||||
const totalSize = parseInt(contentLength, 10);
|
||||
let receivedSize = 0;
|
||||
const reader = response.body!.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
const startTime = Date.now();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
chunks.push(value);
|
||||
receivedSize += value.length;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
progress: receivedSize,
|
||||
total: totalSize,
|
||||
transferSpeed: receivedSize / ((Date.now() - startTime) / 1000),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob(chunks);
|
||||
};
|
||||
|
||||
export const tauriUpload = async (
|
||||
url: string,
|
||||
filePath: string,
|
||||
method: UploadMethod,
|
||||
progressHandler?: ProgressHandler,
|
||||
headers?: Map<string, string>,
|
||||
): Promise<string> => {
|
||||
const ids = new Uint32Array(1);
|
||||
window.crypto.getRandomValues(ids);
|
||||
const id = ids[0];
|
||||
|
||||
const onProgress = new Channel<ProgressPayload>();
|
||||
if (progressHandler) {
|
||||
onProgress.onmessage = progressHandler;
|
||||
}
|
||||
|
||||
return await invoke('upload_file', {
|
||||
id,
|
||||
url,
|
||||
filePath,
|
||||
method,
|
||||
headers: headers ?? {},
|
||||
onProgress,
|
||||
});
|
||||
};
|
||||
|
||||
export const tauriDownload = async (
|
||||
url: string,
|
||||
filePath: string,
|
||||
progressHandler?: ProgressHandler,
|
||||
headers?: Map<string, string>,
|
||||
body?: string,
|
||||
): Promise<void> => {
|
||||
const ids = new Uint32Array(1);
|
||||
window.crypto.getRandomValues(ids);
|
||||
const id = ids[0];
|
||||
|
||||
const onProgress = new Channel<ProgressPayload>();
|
||||
if (progressHandler) {
|
||||
onProgress.onmessage = progressHandler;
|
||||
}
|
||||
|
||||
await invoke('download_file', {
|
||||
id,
|
||||
url,
|
||||
filePath,
|
||||
headers: headers ?? {},
|
||||
onProgress,
|
||||
body,
|
||||
});
|
||||
};
|
||||
@@ -20,7 +20,7 @@ export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DB
|
||||
progress: progress && JSON.stringify(progress),
|
||||
search_config: searchConfig && JSON.stringify(searchConfig),
|
||||
view_settings: viewSettings && JSON.stringify(viewSettings),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
updated_at: new Date(updatedAt ?? Date.now()).toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,8 +37,19 @@ export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfi
|
||||
};
|
||||
|
||||
export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
const { hash, format, title, author, group, tags, createdAt, updatedAt, deletedAt } =
|
||||
book as Book;
|
||||
const {
|
||||
hash,
|
||||
format,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags,
|
||||
progress,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
deletedAt,
|
||||
uploadedAt,
|
||||
} = book as Book;
|
||||
|
||||
return {
|
||||
user_id: userId,
|
||||
@@ -47,16 +58,29 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags: tags && JSON.stringify(tags),
|
||||
created_at: new Date(createdAt).toISOString(),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
tags: tags,
|
||||
progress: progress,
|
||||
created_at: new Date(createdAt ?? Date.now()).toISOString(),
|
||||
updated_at: new Date(updatedAt ?? Date.now()).toISOString(),
|
||||
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
|
||||
uploaded_at: uploadedAt ? new Date(uploadedAt).toISOString() : null,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
const { book_hash, format, title, author, group, tags, created_at, updated_at, deleted_at } =
|
||||
dbBook;
|
||||
const {
|
||||
book_hash,
|
||||
format,
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags,
|
||||
progress,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at,
|
||||
uploaded_at,
|
||||
} = dbBook;
|
||||
|
||||
return {
|
||||
hash: book_hash,
|
||||
@@ -64,10 +88,12 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
|
||||
title,
|
||||
author,
|
||||
group,
|
||||
tags: tags && JSON.parse(tags),
|
||||
tags: tags,
|
||||
progress: progress,
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : null,
|
||||
deletedAt: deleted_at ? new Date(deleted_at).getTime() : null,
|
||||
uploadedAt: uploaded_at ? new Date(uploaded_at).getTime() : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -85,8 +111,8 @@ export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBook
|
||||
style,
|
||||
color,
|
||||
note,
|
||||
created_at: new Date(createdAt).toISOString(),
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
created_at: new Date(createdAt ?? Date.now()).toISOString(),
|
||||
updated_at: new Date(updatedAt ?? Date.now()).toISOString(),
|
||||
// note that only null deleted_at is updated to the database, undefined is not
|
||||
deleted_at: deletedAt ? new Date(deletedAt).toISOString() : null,
|
||||
};
|
||||
@@ -107,6 +133,6 @@ export const transformBookNoteFromDB = (dbBookNote: DBBookNote): BookNote => {
|
||||
note,
|
||||
createdAt: new Date(created_at!).getTime(),
|
||||
updatedAt: new Date(updated_at!).getTime(),
|
||||
deletedAt: deleted_at ? new Date(deleted_at!).getTime() : null,
|
||||
deletedAt: deleted_at ? new Date(deleted_at).getTime() : null,
|
||||
};
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user