Compare commits

...

76 Commits

Author SHA1 Message Date
Huang Xin e77c377839 release: version 0.9.15 (#425) 2025-02-21 19:44:28 +01:00
Huang Xin c2d75deab4 fix: override font color from calibre (#422) 2025-02-21 17:07:27 +01:00
Huang Xin e7579e5867 ui: wrap keyboard shortcuts in kbd tag (#421) 2025-02-21 16:43:38 +01:00
Huang Xin fcc933a243 doc: add instruction for starting development for the Web version only 2025-02-21 16:08:19 +01:00
Huang Xin bbc457a9a5 fix: scroll to current TOC item when toggling sidebar, closes #418 (#420) 2025-02-21 14:18:00 +01:00
Huang Xin f8bd4a0e2c doc: update feature-request template 2025-02-21 12:52:52 +01:00
Huang Xin 3ada880155 ui: fix os platform detection for iPad and layout tweaks for iPad (#416) 2025-02-21 01:11:33 +01:00
Huang Xin 66866372ea ui: responsive font size for book notes (#415) 2025-02-20 12:02:55 +01:00
Huang Xin b321986c40 fix: display complete notes in note list and save note drafts, closes #405 (#412) 2025-02-20 02:13:33 +01:00
Huang Xin 829a698ece auth: use native sign in with Apple on iOS & i18n for auth page (#411) 2025-02-19 23:49:15 +01:00
Huang Xin 978a512ebf mobile: enable paging animation by default on mobile platforms (#410) 2025-02-19 20:27:22 +01:00
Huang Xin aca8c93efb fix: avoid repeative navigation to login after closing login page (#409) 2025-02-19 17:21:03 +01:00
Huang Xin 8cb81b3795 release: version 0.9.13 (#404) 2025-02-19 01:20:16 +01:00
Huang Xin 91716df5e0 feat: add option to keep screen awake when reading (#403) 2025-02-19 00:33:23 +01:00
Huang Xin f5f07e9392 fix: handle malformed href in PDF TOC, closes #357 (#402) 2025-02-18 23:29:23 +01:00
Huang Xin 7e385db4ed compat: support weread footernote, closes #391 (#401) 2025-02-18 22:23:05 +01:00
Huang Xin 1b99915663 fix: override some styles to show footnotes, closes #383 (#400) 2025-02-18 21:12:56 +01:00
Huang Xin 0a213d6a0e chore: clean up oauth callback (#399) 2025-02-18 18:12:25 +01:00
Huang Xin f9e9c90386 fix: swipe gesture in library page should not unintentionally trigger tap (#396) 2025-02-18 13:29:07 +01:00
Huang Xin 8b2888af34 ui: fix title bar padding in the library page (#395) 2025-02-18 12:42:45 +01:00
Huang Xin ffb00f4e0d ux: make the top and bottom toolbar transient and hide in normal reading status (#394) 2025-02-18 12:07:04 +01:00
Huang Xin 77da5423a6 ui: fix notebook layout (#392) 2025-02-17 23:23:24 +01:00
Huang Xin ee201d4571 ui: fix alert position in BookDetailModal and toast content max width (#390) 2025-02-17 14:05:18 +01:00
Huang Xin e48cfc407c release: script for iOS appstore upload (#388) 2025-02-16 20:50:57 +01:00
Huang Xin d49be1f9ca ui: fix alert layout not centered after refactoring (#387) 2025-02-16 15:50:28 +01:00
Huang Xin c46cf5b0bf UX enhancements of grid view in mobile platforms (#379)
* ux: context menu on both group and book items, also closes #376

* ui: more compact grid view of bookshelf on mobile platforms
2025-02-16 01:02:41 +01:00
jackplo 3078b09ba4 FR #241: Added Shortcuts to notetaking UI (#378) 2025-02-15 22:28:33 +01:00
Huang Xin 05cb53ede4 fix: use mouse pos in screen coordinates for click-to-flip, closes #351 (#375) 2025-02-15 02:34:17 +01:00
Huang Xin bd6ef7fe8e release: version 0.9.11
* ui: tweaks on header bar layout

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

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

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

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

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

* chore: get rid of the warning complains of rustc
2025-02-07 01:20:25 +01:00
Huang Xin b2ef55832c hotfix: fix window cannot be dragged when light traffic widget is shown on macOS (#304)
* fix: fix window cannot be dragged when light traffic widget is shown on macOS

* fix: detecting tauri focus event for deeplink redirect in oauth

* release: version 0.9.9
2025-02-06 20:54:39 +01:00
Huang Xin b879184dc6 ux: incrementally loading new books after synchronization (#303)
* auth: workaround to get deeplink data when completing oauth on Android

* ux: incrementally loading new books after synchronization
2025-02-06 19:07:48 +01:00
Huang Xin 8bd9e18381 auth: add applinks for auth on android and ios (#302) 2025-02-06 16:16:15 +01:00
Huang Xin 4121195fd3 fix: remove symbolic fonts in default sys fonts list (#301)
* chore: fix script for appstore release

* fix: remove symbolic fonts in default sys fonts list
2025-02-06 11:32:56 +01:00
Huang Xin cebe8812d3 i18n: translate Sans-serif and Serif font labels (#300) 2025-02-05 16:57:53 +01:00
Huang Xin 0a13f36e33 sync: add toast error for failed uploading when the cloud quota is insufficient (#299) 2025-02-05 15:48:59 +01:00
Huang Xin ded6053134 fix: UA detection of Safari with AppleWebKit (#298)
* fix: UA detection of Safari with AppleWebKit

* readme: add hello-github badge
2025-02-05 11:24:16 +01:00
Huang Xin 1c6cc2bca0 tts: change voice language at speak time when the book language is not accurate (#296) 2025-02-04 23:44:59 +01:00
Huang Xin 6ca96be917 fix: swap paging keys when writing direction is set in config (#295) 2025-02-04 23:03:20 +01:00
Huang Xin a057af7881 fix: avoid over-highlighting on some blank space when selecting multiple paragraphs, closes #238 (#294) 2025-02-04 22:09:13 +01:00
Huang Xin 279504395d fix: handle zoomed text rect on Safari when highlighting, closes #67 (#293) 2025-02-04 19:59:21 +01:00
Huang Xin cfd5d5d106 mobile: several fixes for Android build (#292) 2025-02-04 00:07:55 +01:00
Huang Xin 93eacf91a0 feat: load system fonts list at runtime on desktop, closes #287 #237 (#290) 2025-02-03 00:41:30 +01:00
Huang Xin dc6cbd2029 chore: bump update of next.js (#289) 2025-02-02 22:30:38 +01:00
Huang Xin 643fdd24a9 chore: bump update of tauri (#288) 2025-02-02 22:19:49 +01:00
Huang Xin 5c55233c5c fix: occasionally the scroll end does not reach to the last subpixel (#284) 2025-02-01 17:54:11 +01:00
Huang Xin 4d7db3592f release: version 0.9.8 2025-02-01 16:53:04 +01:00
158 changed files with 4158 additions and 1028 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ labels: enhancement
assignees: ''
---
**Does your feature request involve difficulty completing a task? Please describe.**
**Does your feature request involve difficulty for you to complete a task? Please describe.**
A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
**Describe the solution you'd like**
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- name: Install system dependencies
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
sudo apt-get install -y pkg-config libfontconfig-dev 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
+13 -3
View File
@@ -2,6 +2,12 @@ name: Release Readest
on:
workflow_dispatch:
inputs:
test_ubuntu_arm:
description: 'Run only ubuntu-22.04-arm'
required: false
default: true
type: boolean
release:
types: [published]
@@ -59,6 +65,9 @@ jobs:
- os: ubuntu-22.04
arch: x86_64
rust_target: x86_64-unknown-linux-gnu
- os: ubuntu-22.04-arm
arch: aarch64
rust_target: aarch64-unknown-linux-gnu
- os: macos-latest
arch: aarch64
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
@@ -73,7 +82,8 @@ jobs:
args: '--target aarch64-pc-windows-msvc --bundles nsis'
runs-on: ${{ matrix.config.os }}
timeout-minutes: 20
timeout-minutes: 30
if: ${{ github.event.inputs.test_arm_only }} != 'true' || matrix.config.os == 'ubuntu-22.04-arm'
steps:
- uses: actions/checkout@v4
@@ -118,10 +128,10 @@ jobs:
run: cp .env.local apps/readest-app/.env.local
- name: install dependencies (ubuntu only)
if: matrix.config.os == 'ubuntu-22.04'
if: contains(matrix.config.os, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- uses: tauri-apps/tauri-action@v0
env:
Generated
+301 -149
View File
File diff suppressed because it is too large Load Diff
+42 -20
View File
@@ -5,13 +5,14 @@
<h1>Readest</h1>
<br>
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to offer a seamless cross-platform experience on macOS, Windows, Linux and Web, with support for mobile platforms coming soon.
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to deliver a smooth, cross-platform experience across macOS, Windows, Linux, Android, iOS, and the Web.
[![Website][badge-website]][link-website]
[![Web App][badge-web-app]][link-web-readest]
[![OS][badge-platforms]][link-website]
[![][badge-discord]][link-discord]
<br>
[![][badge-hellogithub]][link-hellogithub]
[![][badge-discord]][link-discord]
[![AGPL Licence][badge-license]](LICENSE)
[![Latest release][badge-release]][link-gh-releases]
[![Last commit][badge-last-commit]][link-gh-commits]
@@ -39,20 +40,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
@@ -61,9 +62,8 @@
| **Feature** | **Description** | **Priority** |
| ------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
@@ -150,18 +150,38 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
```bash
pnpm tauri dev
# or start development for the Web version only
pnpm dev-web
```
For Android:
```bash
# Initialize the Android environment (run once)
pnpm tauri android init
pnpm tauri android dev
# or if you want to dev on a real device
pnpm tauri android dev --host
```
For iOS:
```bash
# Set up the iOS environment (run once)
pnpm tauri ios init
pnpm tauri ios dev
# or if you want to dev on a real device
pnpm tauri ios dev --host
```
### 5. Build for Production
```bash
pnpm tauri build
pnpm tauri android build
pnpm tauri ios build
```
### 6. Setup dev environment with Nix
@@ -193,7 +213,7 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
Readest is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [LICENSE](LICENSE) file for details.
The following libraries and software are used in this software:
The following libraries and frameworks are used in this software:
- [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed.
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
@@ -216,10 +236,11 @@ The following fonts are utilized in this software, either bundled within the app
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Web%2C%20PWA-green
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Android%2C%20iOS%2C%20Web%2C%20PWA-green
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
[badge-hellogithub]: https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8a5b6ade2aee461a8bd94e59200682a7&claim_uid=eRLUbPOy2qZtDgw&theme=small
[link-macos-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
[link-web-readest]: https://web.readest.com
@@ -230,3 +251,4 @@ The following fonts are utilized in this software, either bundled within the app
[link-discord]: https://discord.gg/gntyVNk3BJ
[link-parallel-read]: https://readest.com/#parallel-read
[link-koreader]: https://github.com/koreader/koreader
[link-hellogithub]: https://hellogithub.com/repository/8a5b6ade2aee461a8bd94e59200682a7
+7
View File
@@ -6,3 +6,10 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
DEEPL_PRO_API_KEY=YOUR_DEEPL_PRO_API_KEY
DEEPL_FREE_API_KEY=YOUR_DEEPL_FREE_API_KEY
R2_TOKEN_VALUE=YOUR_R2_TOKEN_VALUE
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
R2_BUCKET_NAME=YOUR_R2_BUCKET_NAME
R2_ACCOUNT_ID=YOUR_R2_ACCOUNT_ID
R2_REGION=YOUR_R2_REGION
+4
View File
@@ -40,6 +40,10 @@ tauri.*.conf.json
# vercel
.vercel
# open-next
.open-next
.wrangler
# typescript
*.tsbuildinfo
next-env.d.ts
+13
View File
@@ -19,6 +19,19 @@ const nextConfig = {
// Configure assetPrefix or else the server won't properly resolve your assets.
assetPrefix: '',
reactStrictMode: true,
async headers() {
return [
{
source: '/.well-known/apple-app-site-association',
headers: [
{
key: 'Content-Type',
value: 'application/json',
},
],
},
];
},
};
const withPWA = withPWAInit({
+29
View File
@@ -0,0 +1,29 @@
// @ts-nocheck
// default open-next.config.ts file created by @opennextjs/cloudflare
import type { OpenNextConfig } from '@opennextjs/aws/types/open-next.js';
import cache from '@opennextjs/cloudflare/kv-cache';
const config: OpenNextConfig = {
default: {
override: {
wrapper: 'cloudflare-node',
converter: 'edge',
// set `incrementalCache` to "dummy" to disable KV cache
incrementalCache: async () => cache,
tagCache: 'dummy',
queue: 'dummy',
},
},
middleware: {
external: true,
override: {
wrapper: 'cloudflare-edge',
converter: 'edge',
proxyExternalRequest: 'fetch',
},
},
};
export default config;
+15 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.7",
"version": "0.9.15",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -26,7 +26,13 @@
"build-macos-universial": "dotenv -e .env.tauri.local -e .env.apple-nonstore.local -- tauri build -t universal-apple-darwin --bundles dmg",
"build-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore.conf.json",
"build-macos-universial-appstore-dev": "dotenv -e .env.tauri.local -e .env.apple-appstore-dev.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore-dev.conf.json",
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh"
"build-ios": "dotenv -e .env.ios-appstore-dev.local -- tauri ios build",
"build-ios-appstore": "dotenv -e .env.ios-appstore.local -- tauri ios build --export-method app-store-connect",
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh",
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler dev",
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare && wrangler deploy",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.735.0",
@@ -36,18 +42,18 @@
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.47.7",
"@tauri-apps/api": "2.1.1",
"@tauri-apps/api": "2.2.0",
"@tauri-apps/plugin-cli": "^2.2.0",
"@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-http": "^2.2.0",
"@tauri-apps/plugin-log": "^2.2.0",
"@tauri-apps/plugin-opener": "^2.2.2",
"@tauri-apps/plugin-http": "^2.3.0",
"@tauri-apps/plugin-log": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.2.5",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "~2.2.0",
"@tauri-apps/plugin-updater": "^2.3.0",
"@tauri-apps/plugin-updater": "^2.4.0",
"@zip.js/zip.js": "^2.7.53",
"clsx": "^2.1.1",
"cors": "^2.8.5",
@@ -58,7 +64,7 @@
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"next": "15.1.0",
"next": "15.1.6",
"posthog-js": "^1.205.0",
"react": "19.0.0",
"react-dom": "19.0.0",
@@ -69,7 +75,7 @@
"zustand": "5.0.1"
},
"devDependencies": {
"@tauri-apps/cli": "2.1.0",
"@tauri-apps/cli": "2.2.7",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
@@ -0,0 +1,17 @@
{
"applinks": {
"details": [
{
"appIDs": [
"J5W48D69VR.com.bilingify.readest"
],
"components": [
{
"/": "/auth/*",
"comment": "Matches any URL whose path starts with /auth/"
}
]
}
]
}
}
@@ -0,0 +1,12 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.bilingify.readest",
"sha256_cert_fingerprints": [
"65:2D:11:67:76:12:29:14:18:42:CB:3D:18:50:B6:E4:7E:46:E1:2F:4B:E4:7F:5A:6C:14:B6:D7:12:74:1E:82"
]
}
}
]
@@ -57,7 +57,6 @@
"Language:": "Sprache:",
"Layout": "Layout",
"Light Mode": "Hellmodus",
"Line Height": "Zeilenhöhe",
"Loading...": "Wird geladen...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Angemeldet",
@@ -76,7 +75,6 @@
"Notebook": "Notizbuch",
"Notes": "Notizen",
"Open": "Öffnen",
"Open Book": "Buch öffnen",
"Original Text": "Originaltext",
"Page": "Seite",
"Paging Animation": "Blätter-Animation",
@@ -149,5 +147,56 @@
"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"
"Check Updates on Start": "Aktualisierungen beim Start prüfen",
"Insufficient storage quota": "Unzureichendes Speicherkontingent",
"Font Weight": "Schriftgewicht",
"Line Spacing": "Zeilenabstand",
"Word Spacing": "Wortabstand",
"Letter Spacing": "Buchstabenabstand",
"Text Indent": "Texteinzug",
"Paragraph Margin": "Absatzrand",
"Override Book Layout": "Buch-Layout überschreiben",
"Add to Group": "Zur Gruppe hinzufügen",
"Untitled Group": "Unbenannte Gruppe",
"Group Books": "Bücher gruppieren",
"Remove From Group": "Aus Gruppe entfernen",
"Create New Group": "Neue Gruppe erstellen",
"Deselect Book": "Buch abwählen",
"Download Book": "Buch herunterladen",
"Deselect Group": "Gruppe abwählen",
"Select Group": "Gruppe auswählen",
"Keep Screen Awake": "Bildschirm aktiv halten",
"Email address": "E-Mail-Adresse",
"Your Password": "Ihr Passwort",
"Your email address": "Ihre E-Mail-Adresse",
"Your password": "Ihr Passwort",
"Sign in": "Anmelden",
"Signing in...": "Anmeldung...",
"Sign in with {{provider}}": "Mit {{provider}} anmelden",
"Already have an account? Sign in": "Haben Sie bereits ein Konto? Anmelden",
"Create a Password": "Passwort erstellen",
"Sign up": "Registrieren",
"Signing up...": "Registrierung...",
"Dont have an account? Sign up": "Kein Konto? Registrieren",
"Check your email for the confirmation link": "Überprüfen Sie Ihre E-Mails auf den Bestätigungslink",
"Signing in ...": "Anmeldung ...",
"Send a magic link email": "Magic-Link-E-Mail senden",
"Check your email for the magic link": "Überprüfen Sie Ihre E-Mails auf den Magic-Link",
"Send reset password instructions": "Anweisungen zum Zurücksetzen des Passworts senden",
"Sending reset instructions ...": "Sende Anweisungen zum Zurücksetzen ...",
"Forgot your password?": "Passwort vergessen?",
"Check your email for the password reset link": "Überprüfen Sie Ihre E-Mails auf den Link zum Zurücksetzen des Passworts",
"New Password": "Neues Passwort",
"Your new password": "Ihr neues Passwort",
"Update password": "Passwort aktualisieren",
"Updating password ...": "Passwort wird aktualisiert ...",
"Your password has been updated": "Ihr Passwort wurde aktualisiert",
"Phone number": "Telefonnummer",
"Your phone number": "Ihre Telefonnummer",
"Token": "Token",
"Your OTP token": "Ihr OTP-Token",
"Verify token": "Token überprüfen",
"Sign in with Google": "Mit Google anmelden",
"Sign in with Apple": "Mit Apple anmelden",
"Sign in with GitHub": "Mit GitHub anmelden"
}
@@ -57,7 +57,6 @@
"Language:": "Γλώσσα:",
"Layout": "Διάταξη",
"Light Mode": "Φωτεινή λειτουργία",
"Line Height": "Ύψος γραμμής",
"Loading...": "Φόρτωση...",
"Loc. {{currentPage}} / {{totalPage}}": "Θέση {{currentPage}} / {{totalPage}}",
"Logged in": "Συνδεδεμένος",
@@ -76,7 +75,6 @@
"Notebook": "Σημειωματάριο",
"Notes": "Σημειώσεις",
"Open": "Άνοιγμα",
"Open Book": "Άνοιγμα βιβλίου",
"Original Text": "Αρχικό κείμενο",
"Page": "Σελίδα",
"Paging Animation": "Κινούμενα σχέδια σελιδοποίησης",
@@ -149,5 +147,56 @@
"Storage": "Αποθήκευση",
"Book deleted: {{title}}": "Το βιβλίο με τίτλο {{title}} διαγράφηκε",
"Failed to delete book: {{title}}": "Αποτυχία διαγραφής βιβλίου: {{title}}",
"Check Updates on Start": "Έλεγχος ενημερώσεων κατά την εκκίνηση"
"Check Updates on Start": "Έλεγχος ενημερώσεων κατά την εκκίνηση",
"Insufficient storage quota": "Ανεπαρκές όριο αποθήκευσης",
"Font Weight": "Βάρος γραμματοσειράς",
"Line Spacing": "Διάκενο γραμμών",
"Word Spacing": "Διάκενο λέξεων",
"Letter Spacing": "Διάκενο γραμμάτων",
"Text Indent": "Εσοχή κειμένου",
"Paragraph Margin": "Περιθώριο παραγράφου",
"Override Book Layout": "Παράκαμψη διάταξης βιβλίου",
"Add to Group": "Προσθήκη στην ομάδα",
"Untitled Group": "Ομάδα χωρίς τίτλο",
"Group Books": "Ομαδοποίηση βιβλίων",
"Remove From Group": "Αφαίρεση από την ομάδα",
"Create New Group": "Δημιουργία νέας ομάδας",
"Deselect Book": "Αποεπιλογή βιβλίου",
"Download Book": "Λήψη βιβλίου",
"Deselect Group": "Αποεπιλογή ομάδας",
"Select Group": "Επιλογή ομάδας",
"Keep Screen Awake": "Διατήρηση ενεργού οθόνης",
"Email address": "Διεύθυνση email",
"Your Password": "Ο κωδικός σας",
"Your email address": "Η διεύθυνση email σας",
"Your password": "Ο κωδικός πρόσβασής σας",
"Sign in": "Σύνδεση",
"Signing in...": "Σύνδεση...",
"Sign in with {{provider}}": "Σύνδεση με {{provider}}",
"Already have an account? Sign in": "Έχετε ήδη λογαριασμό; Συνδεθείτε",
"Create a Password": "Δημιουργία κωδικού πρόσβασης",
"Sign up": "Εγγραφή",
"Signing up...": "Εγγραφή...",
"Dont have an account? Sign up": "Δεν έχετε λογαριασμό; Εγγραφείτε",
"Check your email for the confirmation link": "Ελέγξτε το email σας για τον σύνδεσμο επιβεβαίωσης",
"Signing in ...": "Σύνδεση ...",
"Send a magic link email": "Αποστολή email με μαγικό σύνδεσμο",
"Check your email for the magic link": "Ελέγξτε το email σας για τον μαγικό σύνδεσμο",
"Send reset password instructions": "Αποστολή οδηγιών επαναφοράς κωδικού πρόσβασης",
"Sending reset instructions ...": "Αποστολή οδηγιών επαναφοράς ...",
"Forgot your password?": "Ξεχάσατε τον κωδικό σας;",
"Check your email for the password reset link": "Ελέγξτε το email σας για τον σύνδεσμο επαναφοράς κωδικού",
"New Password": "Νέος κωδικός πρόσβασης",
"Your new password": "Ο νέος σας κωδικός",
"Update password": "Ενημέρωση κωδικού πρόσβασης",
"Updating password ...": "Ενημέρωση κωδικού ...",
"Your password has been updated": "Ο κωδικός πρόσβασής σας ενημερώθηκε",
"Phone number": "Αριθμός τηλεφώνου",
"Your phone number": "Ο αριθμός τηλεφώνου σας",
"Token": "Κωδικός επαλήθευσης",
"Your OTP token": "Ο κωδικός OTP σας",
"Verify token": "Επαλήθευση κωδικού",
"Sign in with Google": "Σύνδεση με Google",
"Sign in with Apple": "Σύνδεση με Apple",
"Sign in with GitHub": "Σύνδεση με GitHub"
}
@@ -57,7 +57,6 @@
"Language:": "Idioma:",
"Layout": "Diseño",
"Light Mode": "Modo claro",
"Line Height": "Altura de línea",
"Loading...": "Cargando...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Sesión iniciada",
@@ -76,7 +75,6 @@
"Notebook": "Cuaderno",
"Notes": "Notas",
"Open": "Abrir",
"Open Book": "Abrir libro",
"Original Text": "Texto original",
"Page": "Página",
"Paging Animation": "Animación de paginación",
@@ -149,5 +147,56 @@
"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"
"Check Updates on Start": "Comprobar actualizaciones al iniciar",
"Insufficient storage quota": "Cuota de almacenamiento insuficiente",
"Font Weight": "Peso de la fuente",
"Line Spacing": "Espaciado de línea",
"Word Spacing": "Espaciado de palabras",
"Letter Spacing": "Espaciado de letras",
"Text Indent": "Sangría de texto",
"Paragraph Margin": "Margen de párrafo",
"Override Book Layout": "Sobrescribir diseño del libro",
"Add to Group": "Añadir a grupo",
"Untitled Group": "Grupo sin título",
"Group Books": "Agrupar libros",
"Remove From Group": "Eliminar del grupo",
"Create New Group": "Crear nuevo grupo",
"Deselect Book": "Deseleccionar libro",
"Download Book": "Descargar libro",
"Deselect Group": "Deseleccionar grupo",
"Select Group": "Seleccionar grupo",
"Keep Screen Awake": "Mantener pantalla activa",
"Email address": "Dirección de correo electrónico",
"Your Password": "Tu contraseña",
"Your email address": "Tu dirección de correo electrónico",
"Your password": "Tu contraseña",
"Sign in": "Iniciar sesión",
"Signing in...": "Iniciando sesión...",
"Sign in with {{provider}}": "Iniciar sesión con {{provider}}",
"Already have an account? Sign in": "¿Ya tienes una cuenta? Inicia sesión",
"Create a Password": "Crear una contraseña",
"Sign up": "Registrarse",
"Signing up...": "Registrándose...",
"Dont have an account? Sign up": "¿No tienes una cuenta? Regístrate",
"Check your email for the confirmation link": "Revisa tu correo electrónico para el enlace de confirmación",
"Signing in ...": "Iniciando sesión ...",
"Send a magic link email": "Enviar un enlace mágico por correo",
"Check your email for the magic link": "Revisa tu correo electrónico para el enlace mágico",
"Send reset password instructions": "Enviar instrucciones para restablecer la contraseña",
"Sending reset instructions ...": "Enviando instrucciones de restablecimiento ...",
"Forgot your password?": "¿Olvidaste tu contraseña?",
"Check your email for the password reset link": "Revisa tu correo electrónico para el enlace de restablecimiento de contraseña",
"New Password": "Nueva contraseña",
"Your new password": "Tu nueva contraseña",
"Update password": "Actualizar contraseña",
"Updating password ...": "Actualizando contraseña ...",
"Your password has been updated": "Tu contraseña ha sido actualizada",
"Phone number": "Número de teléfono",
"Your phone number": "Tu número de teléfono",
"Token": "Código de verificación",
"Your OTP token": "Tu código OTP",
"Verify token": "Verificar código",
"Sign in with Google": "Iniciar sesión con Google",
"Sign in with Apple": "Iniciar sesión con Apple",
"Sign in with GitHub": "Iniciar sesión con GitHub"
}
@@ -57,7 +57,6 @@
"Language:": "Langue :",
"Layout": "Mise en page",
"Light Mode": "Mode clair",
"Line Height": "Hauteur de ligne",
"Loading...": "Chargement...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Connecté",
@@ -76,7 +75,6 @@
"Notebook": "Carnet de notes",
"Notes": "Notes",
"Open": "Ouvrir",
"Open Book": "Ouvrir le livre",
"Original Text": "Texte original",
"Page": "Page",
"Paging Animation": "Animation de page",
@@ -149,5 +147,56 @@
"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"
"Check Updates on Start": "Vérifier les mises à jour au démarrage",
"Insufficient storage quota": "Quota de stockage insuffisant",
"Font Weight": "Poids de la police",
"Line Spacing": "Interligne",
"Word Spacing": "Espacement des mots",
"Letter Spacing": "Espacement des lettres",
"Text Indent": "Retrait du texte",
"Paragraph Margin": "Marge de paragraphe",
"Override Book Layout": "Remplacer la mise en page du livre",
"Add to Group": "Ajouter au groupe",
"Untitled Group": "Groupe sans titre",
"Group Books": "Regrouper les livres",
"Remove From Group": "Retirer du groupe",
"Create New Group": "Créer un nouveau groupe",
"Deselect Book": "Désélectionner le livre",
"Download Book": "Télécharger le livre",
"Deselect Group": "Désélectionner le groupe",
"Select Group": "Sélectionner le groupe",
"Keep Screen Awake": "Maintenir l'écran allumé",
"Email address": "Adresse e-mail",
"Your Password": "Votre mot de passe",
"Your email address": "Votre adresse e-mail",
"Your password": "Votre mot de passe",
"Sign in": "Se connecter",
"Signing in...": "Connexion en cours...",
"Sign in with {{provider}}": "Se connecter avec {{provider}}",
"Already have an account? Sign in": "Vous avez déjà un compte ? Connectez-vous",
"Create a Password": "Créer un mot de passe",
"Sign up": "S'inscrire",
"Signing up...": "Inscription en cours...",
"Dont have an account? Sign up": "Vous n'avez pas de compte ? Inscrivez-vous",
"Check your email for the confirmation link": "Vérifiez votre e-mail pour le lien de confirmation",
"Signing in ...": "Connexion en cours ...",
"Send a magic link email": "Envoyer un e-mail avec un lien magique",
"Check your email for the magic link": "Vérifiez votre e-mail pour le lien magique",
"Send reset password instructions": "Envoyer les instructions de réinitialisation du mot de passe",
"Sending reset instructions ...": "Envoi des instructions de réinitialisation ...",
"Forgot your password?": "Mot de passe oublié ?",
"Check your email for the password reset link": "Vérifiez votre e-mail pour le lien de réinitialisation du mot de passe",
"New Password": "Nouveau mot de passe",
"Your new password": "Votre nouveau mot de passe",
"Update password": "Mettre à jour le mot de passe",
"Updating password ...": "Mise à jour du mot de passe ...",
"Your password has been updated": "Votre mot de passe a été mis à jour",
"Phone number": "Numéro de téléphone",
"Your phone number": "Votre numéro de téléphone",
"Token": "Code de vérification",
"Your OTP token": "Votre code OTP",
"Verify token": "Vérifier le code",
"Sign in with Google": "Se connecter avec Google",
"Sign in with Apple": "Se connecter avec Apple",
"Sign in with GitHub": "Se connecter avec GitHub"
}
@@ -57,7 +57,6 @@
"Language:": "भाषा:",
"Layout": "लेआउट",
"Light Mode": "लाइट मोड",
"Line Height": "लाइन हाइट",
"Loading...": "लोड हो रहा है...",
"Loc. {{currentPage}} / {{totalPage}}": "स्थान {{currentPage}} / {{totalPage}}",
"Logged in": "लॉग इन किया",
@@ -76,7 +75,6 @@
"Notebook": "नोटबुक",
"Notes": "नोट्स",
"Open": "खोलें",
"Open Book": "पुस्तक खोलें",
"Original Text": "मूल पाठ",
"Page": "पृष्ठ",
"Paging Animation": "पेजिंग एनीमेशन",
@@ -149,5 +147,56 @@
"Storage": "स्टोरेज",
"Book deleted: {{title}}": "पुस्तक हटाई गई: {{title}}",
"Failed to delete book: {{title}}": "पुस्तक हटाने में विफल: {{title}}",
"Check Updates on Start": "शुरू में अपडेट जांचें"
"Check Updates on Start": "शुरू में अपडेट जांचें",
"Insufficient storage quota": "अपर्याप्त स्टोरेज कोटा",
"Font Weight": "फ़ॉन्ट वजन",
"Line Spacing": "लाइन स्पेसिंग",
"Word Spacing": "शब्द स्पेसिंग",
"Letter Spacing": "अक्षर स्पेसिंग",
"Text Indent": "टेक्स्ट इंडेंट",
"Paragraph Margin": "पैराग्राफ मार्जिन",
"Override Book Layout": "पुस्तक लेआउट ओवरराइड करें",
"Add to Group": "समूह में जोड़ें",
"Untitled Group": "अभिनामित समूह",
"Group Books": "समूह पुस्तकें",
"Remove From Group": "समूह से हटाएं",
"Create New Group": "नया समूह बनाएं",
"Deselect Book": "पुस्तक का चयन रद्द करें",
"Download Book": "पुस्तक डाउनलोड करें",
"Deselect Group": "समूह का चयन रद्द करें",
"Select Group": "समूह चुनें",
"Keep Screen Awake": "स्क्रीन जागरूक रखें",
"Email address": "ईमेल पता",
"Your Password": "आपका पासवर्ड",
"Your email address": "आपका ईमेल पता",
"Your password": "आपका पासवर्ड",
"Sign in": "साइन इन करें",
"Signing in...": "साइन इन हो रहा है...",
"Sign in with {{provider}}": "{{provider}} के साथ साइन इन करें",
"Already have an account? Sign in": "पहले से ही एक खाता है? साइन इन करें",
"Create a Password": "पासवर्ड बनाएं",
"Sign up": "साइन अप करें",
"Signing up...": "साइन अप हो रहा है...",
"Dont have an account? Sign up": "कोई खाता नहीं है? साइन अप करें",
"Check your email for the confirmation link": "पुष्टि लिंक के लिए अपना ईमेल जांचें",
"Signing in ...": "साइन इन हो रहा है ...",
"Send a magic link email": "जादू लिंक ईमेल भेजें",
"Check your email for the magic link": "जादू लिंक के लिए अपना ईमेल जांचें",
"Send reset password instructions": "पासवर्ड रीसेट निर्देश भेजें",
"Sending reset instructions ...": "रीसेट निर्देश भेजे जा रहे हैं ...",
"Forgot your password?": "पासवर्ड भूल गए?",
"Check your email for the password reset link": "पासवर्ड रीसेट लिंक के लिए अपना ईमेल जांचें",
"New Password": "नया पासवर्ड",
"Your new password": "आपका नया पासवर्ड",
"Update password": "पासवर्ड अपडेट करें",
"Updating password ...": "पासवर्ड अपडेट किया जा रहा है ...",
"Your password has been updated": "आपका पासवर्ड अपडेट कर दिया गया है",
"Phone number": "फोन नंबर",
"Your phone number": "आपका फोन नंबर",
"Token": "टोकन",
"Your OTP token": "आपका ओटीपी टोकन",
"Verify token": "टोकन सत्यापित करें",
"Sign in with Google": "Google के साथ साइन इन करें",
"Sign in with Apple": "Apple के साथ साइन इन करें",
"Sign in with GitHub": "GitHub के साथ साइन इन करें"
}
@@ -57,7 +57,6 @@
"Language:": "Bahasa:",
"Layout": "Tata Letak",
"Light Mode": "Mode Terang",
"Line Height": "Tinggi Baris",
"Loading...": "Memuat...",
"Loc. {{currentPage}} / {{totalPage}}": "Lok. {{currentPage}} / {{totalPage}}",
"Logged in": "Sudah Masuk",
@@ -76,7 +75,6 @@
"Notebook": "Buku Catatan",
"Notes": "Catatan",
"Open": "Buka",
"Open Book": "Buka Buku",
"Original Text": "Teks Asli",
"Page": "Halaman",
"Paging Animation": "Animasi Halaman",
@@ -149,5 +147,56 @@
"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"
"Check Updates on Start": "Periksa Pembaruan saat Memulai",
"Insufficient storage quota": "Kuota penyimpanan tidak mencukupi",
"Font Weight": "Berat Font",
"Line Spacing": "Spasi Baris",
"Word Spacing": "Spasi Kata",
"Letter Spacing": "Spasi Huruf",
"Text Indent": "Indentasi Teks",
"Paragraph Margin": "Margin Paragraf",
"Override Book Layout": "Ganti Tata Letak Buku",
"Add to Group": "Tambahkan ke Grup",
"Untitled Group": "Grup Tanpa Judul",
"Group Books": "Grupkan Buku",
"Remove From Group": "Hapus dari Grup",
"Create New Group": "Buat Grup Baru",
"Deselect Book": "Batalkan Pilihan Buku",
"Download Book": "Unduh Buku",
"Deselect Group": "Batalkan Pilihan Grup",
"Select Group": "Pilih Grup",
"Keep Screen Awake": "Biarkan Layar Tetap Hidup",
"Email address": "Alamat email",
"Your Password": "Kata sandi Anda",
"Your email address": "Alamat email Anda",
"Your password": "Kata sandi Anda",
"Sign in": "Masuk",
"Signing in...": "Sedang masuk...",
"Sign in with {{provider}}": "Masuk dengan {{provider}}",
"Already have an account? Sign in": "Sudah punya akun? Masuk",
"Create a Password": "Buat kata sandi",
"Sign up": "Daftar",
"Signing up...": "Sedang mendaftar...",
"Dont have an account? Sign up": "Belum punya akun? Daftar",
"Check your email for the confirmation link": "Periksa email Anda untuk tautan konfirmasi",
"Signing in ...": "Sedang masuk ...",
"Send a magic link email": "Kirim email tautan ajaib",
"Check your email for the magic link": "Periksa email Anda untuk tautan ajaib",
"Send reset password instructions": "Kirim instruksi reset kata sandi",
"Sending reset instructions ...": "Mengirim instruksi reset ...",
"Forgot your password?": "Lupa kata sandi Anda?",
"Check your email for the password reset link": "Periksa email Anda untuk tautan reset kata sandi",
"New Password": "Kata sandi baru",
"Your new password": "Kata sandi baru Anda",
"Update password": "Perbarui kata sandi",
"Updating password ...": "Memperbarui kata sandi ...",
"Your password has been updated": "Kata sandi Anda telah diperbarui",
"Phone number": "Nomor telepon",
"Your phone number": "Nomor telepon Anda",
"Token": "Token",
"Your OTP token": "Token OTP Anda",
"Verify token": "Verifikasi token",
"Sign in with Google": "Masuk dengan Google",
"Sign in with Apple": "Masuk dengan Apple",
"Sign in with GitHub": "Masuk dengan GitHub"
}
@@ -57,7 +57,6 @@
"Language:": "Lingua:",
"Layout": "Layout",
"Light Mode": "Modalità chiara",
"Line Height": "Altezza riga",
"Loading...": "Caricamento...",
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Accesso effettuato",
@@ -76,7 +75,6 @@
"Notebook": "Quaderno",
"Notes": "Note",
"Open": "Apri",
"Open Book": "Apri libro",
"Original Text": "Testo originale",
"Page": "Pagina",
"Paging Animation": "Animazione cambio pagina",
@@ -149,5 +147,56 @@
"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"
"Check Updates on Start": "Controlla aggiornamenti all'avvio",
"Insufficient storage quota": "Quota di archiviazione insufficiente",
"Font Weight": "Peso font",
"Line Spacing": "Interlinea",
"Word Spacing": "Spaziatura parole",
"Letter Spacing": "Spaziatura lettere",
"Text Indent": "Rientro testo",
"Paragraph Margin": "Margine paragrafo",
"Override Book Layout": "Sovrascrivi layout libro",
"Add to Group": "Aggiungi al gruppo",
"Untitled Group": "Gruppo senza titolo",
"Group Books": "Raggruppa libri",
"Remove From Group": "Rimuovi dal",
"Create New Group": "Crea nuovo gruppo",
"Deselect Book": "Deseleziona libro",
"Download Book": "Scarica libro",
"Deselect Group": "Deseleziona gruppo",
"Select Group": "Seleziona gruppo",
"Keep Screen Awake": "Mantieni schermo attivo",
"Email address": "Indirizzo email",
"Your Password": "La tua password",
"Your email address": "Il tuo indirizzo email",
"Your password": "La tua password",
"Sign in": "Accedi",
"Signing in...": "Accesso in corso...",
"Sign in with {{provider}}": "Accedi con {{provider}}",
"Already have an account? Sign in": "Hai già un account? Accedi",
"Create a Password": "Crea una password",
"Sign up": "Registrati",
"Signing up...": "Registrazione in corso...",
"Dont have an account? Sign up": "Non hai un account? Registrati",
"Check your email for the confirmation link": "Controlla la tua email per il link di conferma",
"Signing in ...": "Accesso in corso ...",
"Send a magic link email": "Invia un'email con link magico",
"Check your email for the magic link": "Controlla la tua email per il link magico",
"Send reset password instructions": "Invia istruzioni per reimpostare la password",
"Sending reset instructions ...": "Invio istruzioni di reimpostazione ...",
"Forgot your password?": "Hai dimenticato la password?",
"Check your email for the password reset link": "Controlla la tua email per il link di reimpostazione della password",
"New Password": "Nuova password",
"Your new password": "La tua nuova password",
"Update password": "Aggiorna password",
"Updating password ...": "Aggiornamento password ...",
"Your password has been updated": "La tua password è stata aggiornata",
"Phone number": "Numero di telefono",
"Your phone number": "Il tuo numero di telefono",
"Token": "Token",
"Your OTP token": "Il tuo token OTP",
"Verify token": "Verifica token",
"Sign in with Google": "Accedi con Google",
"Sign in with Apple": "Accedi con Apple",
"Sign in with GitHub": "Accedi con GitHub"
}
@@ -57,7 +57,6 @@
"Language:": "言語:",
"Layout": "レイアウト",
"Light Mode": "ライトモード",
"Line Height": "行間",
"Loading...": "読み込み中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "ログイン済み",
@@ -76,7 +75,6 @@
"Notebook": "ノート",
"Notes": "メモ",
"Open": "開く",
"Open Book": "書籍を開く",
"Original Text": "原文",
"Page": "ページ",
"Paging Animation": "ページめくりアニメーション",
@@ -99,7 +97,7 @@
"Select Books": "書籍を選択",
"Select Multiple Books": "複数の書籍を選択",
"Sepia": "セピア",
"Serif Font": "明朝体",
"Serif Font": "セリフ体",
"Show Book Details": "書籍の詳細を表示",
"Sidebar": "サイドバー",
"Sign In": "サインイン",
@@ -149,5 +147,56 @@
"Storage": "ストレージ",
"Book deleted: {{title}}": "書籍が削除されました:{{title}}",
"Failed to delete book: {{title}}": "書籍の削除に失敗しました:{{title}}",
"Check Updates on Start": "開始時に更新を確認"
"Check Updates on Start": "開始時に更新を確認",
"Insufficient storage quota": "ストレージクォータが不足しています",
"Font Weight": "フォントウェイト",
"Line Spacing": "行間",
"Word Spacing": "単語間隔",
"Letter Spacing": "文字間隔",
"Text Indent": "テキストインデント",
"Paragraph Margin": "段落マージン",
"Override Book Layout": "書籍のレイアウトを上書き",
"Add to Group": "グループに追加",
"Untitled Group": "無題のグループ",
"Group Books": "書籍をグループ化",
"Remove From Group": "グループから削除",
"Create New Group": "新しいグループを作成",
"Deselect Book": "書籍の選択を解除",
"Download Book": "書籍をダウンロード",
"Deselect Group": "グループの選択を解除",
"Select Group": "グループを選択",
"Keep Screen Awake": "画面をオンのままにする",
"Email address": "メールアドレス",
"Your Password": "あなたのパスワード",
"Your email address": "あなたのメールアドレス",
"Your password": "あなたのパスワード",
"Sign in": "サインイン",
"Signing in...": "サインイン中...",
"Sign in with {{provider}}": "{{provider}}でサインイン",
"Already have an account? Sign in": "すでにアカウントがありますか? サインイン",
"Create a Password": "パスワードを作成",
"Sign up": "サインアップ",
"Signing up...": "サインアップ中...",
"Dont have an account? Sign up": "アカウントをお持ちでないですか? サインアップ",
"Check your email for the confirmation link": "確認リンクのためにメールを確認してください",
"Signing in ...": "サインイン中 ...",
"Send a magic link email": "マジックリンクメールを送信",
"Check your email for the magic link": "マジックリンクのためにメールを確認してください",
"Send reset password instructions": "パスワードリセットの指示を送信",
"Sending reset instructions ...": "リセット指示を送信中 ...",
"Forgot your password?": "パスワードを忘れましたか?",
"Check your email for the password reset link": "パスワードリセットリンクのためにメールを確認してください",
"New Password": "新しいパスワード",
"Your new password": "あなたの新しいパスワード",
"Update password": "パスワードを更新",
"Updating password ...": "パスワード更新中 ...",
"Your password has been updated": "あなたのパスワードは更新されました",
"Phone number": "電話番号",
"Your phone number": "あなたの電話番号",
"Token": "トークン",
"Your OTP token": "あなたのOTPトークン",
"Verify token": "トークンを確認",
"Sign in with Google": "Googleでサインイン",
"Sign in with Apple": "Appleでサインイン",
"Sign in with GitHub": "GitHubでサインイン"
}
@@ -57,7 +57,6 @@
"Language:": "언어:",
"Layout": "레이아웃",
"Light Mode": "라이트 모드",
"Line Height": "줄 간격",
"Loading...": "로딩 중...",
"Loc. {{currentPage}} / {{totalPage}}": "위치 {{currentPage}} / {{totalPage}}",
"Logged in": "로그인됨",
@@ -76,7 +75,6 @@
"Notebook": "노트북",
"Notes": "메모",
"Open": "열기",
"Open Book": "책 열기",
"Original Text": "원본 텍스트",
"Page": "페이지",
"Paging Animation": "페이지 넘김 애니메이션",
@@ -149,5 +147,56 @@
"Storage": "저장소",
"Book deleted: {{title}}": "책 삭제됨: {{title}}",
"Failed to delete book: {{title}}": "책 삭제 실패: {{title}}",
"Check Updates on Start": "시작 시 업데이트 확인"
"Check Updates on Start": "시작 시 업데이트 확인",
"Insufficient storage quota": "저장소 할당량 부족",
"Font Weight": "글꼴 두께",
"Line Spacing": "줄 간격",
"Word Spacing": "단어 간격",
"Letter Spacing": "글자 간격",
"Text Indent": "들여쓰기",
"Paragraph Margin": "단락 여백",
"Override Book Layout": "책 레이아웃 덮어쓰기",
"Add to Group": "그룹에 추가",
"Untitled Group": "제목 없는 그룹",
"Group Books": "책 그룹화",
"Remove From Group": "그룹에서 제거",
"Create New Group": "새 그룹 만들기",
"Deselect Book": "책 선택 해제",
"Download Book": "책 다운로드",
"Deselect Group": "그룹 선택 해제",
"Select Group": "그룹 선택",
"Keep Screen Awake": "화면 켜짐 유지",
"Email address": "이메일 주소",
"Your Password": "비밀번호",
"Your email address": "당신의 이메일 주소",
"Your password": "당신의 비밀번호",
"Sign in": "로그인",
"Signing in...": "로그인 중...",
"Sign in with {{provider}}": "{{provider}}로 로그인",
"Already have an account? Sign in": "이미 계정이 있습니까? 로그인",
"Create a Password": "비밀번호 만들기",
"Sign up": "회원가입",
"Signing up...": "회원가입 중...",
"Dont have an account? Sign up": "계정이 없습니까? 회원가입",
"Check your email for the confirmation link": "확인 링크를 위해 이메일을 확인하세요",
"Signing in ...": "로그인 중 ...",
"Send a magic link email": "마법의 링크 이메일 보내기",
"Check your email for the magic link": "마법의 링크를 위해 이메일을 확인하세요",
"Send reset password instructions": "비밀번호 재설정 지침 보내기",
"Sending reset instructions ...": "재설정 지침 보내는 중 ...",
"Forgot your password?": "비밀번호를 잊으셨나요?",
"Check your email for the password reset link": "비밀번호 재설정 링크를 위해 이메일을 확인하세요",
"New Password": "새로운 비밀번호",
"Your new password": "당신의 새로운 비밀번호",
"Update password": "비밀번호 업데이트",
"Updating password ...": "비밀번호 업데이트 중 ...",
"Your password has been updated": "비밀번호가 업데이트되었습니다",
"Phone number": "전화번호",
"Your phone number": "당신의 전화번호",
"Token": "토큰",
"Your OTP token": "당신의 OTP 토큰",
"Verify token": "토큰 확인",
"Sign in with Google": "Google로 로그인",
"Sign in with Apple": "Apple로 로그인",
"Sign in with GitHub": "GitHub로 로그인"
}
@@ -57,7 +57,6 @@
"Language:": "Język:",
"Layout": "Układ",
"Light Mode": "Tryb jasny",
"Line Height": "Wysokość linii",
"Loading...": "Ładowanie...",
"Loc. {{currentPage}} / {{totalPage}}": "Poz. {{currentPage}} / {{totalPage}}",
"Logged in": "Zalogowano",
@@ -76,7 +75,6 @@
"Notebook": "Notatnik",
"Notes": "Notatki",
"Open": "Otwórz",
"Open Book": "Otwórz książkę",
"Original Text": "Tekst oryginalny",
"Page": "Strona",
"Paging Animation": "Animacja przewracania stron",
@@ -149,5 +147,56 @@
"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"
"Check Updates on Start": "Sprawdź aktualizacje przy starcie",
"Insufficient storage quota": "Niewystarczająca kwota przechowywania",
"Font Weight": "Grubość czcionki",
"Line Spacing": "Interlinia",
"Word Spacing": "Odstęp między wyrazami",
"Letter Spacing": "Odstęp między literami",
"Text Indent": "Wcięcie tekstu",
"Paragraph Margin": "Margines akapitu",
"Override Book Layout": "Nadpisz układ książki",
"Add to Group": "Dodaj do grupy",
"Untitled Group": "Grupa bez tytułu",
"Group Books": "Grupuj książki",
"Remove From Group": "Usuń z grupy",
"Create New Group": "Utwórz nową grupę",
"Deselect Book": "Odznacz książkę",
"Download Book": "Pobierz książkę",
"Deselect Group": "Odznacz grupę",
"Select Group": "Wybierz grupę",
"Keep Screen Awake": "Nie wygaszaj ekranu",
"Email address": "Adres email",
"Your Password": "Twoje hasło",
"Your email address": "Twój adres email",
"Your password": "Twoje hasło",
"Sign in": "Zaloguj się",
"Signing in...": "Logowanie...",
"Sign in with {{provider}}": "Zaloguj się za pomocą {{provider}}",
"Already have an account? Sign in": "Masz już konto? Zaloguj się",
"Create a Password": "Utwórz hasło",
"Sign up": "Zarejestruj się",
"Signing up...": "Rejestracja...",
"Dont have an account? Sign up": "Nie masz konta? Zarejestruj się",
"Check your email for the confirmation link": "Sprawdź swój email, aby uzyskać link potwierdzający",
"Signing in ...": "Logowanie ...",
"Send a magic link email": "Wyślij link magiczny na email",
"Check your email for the magic link": "Sprawdź swój email, aby uzyskać link magiczny",
"Send reset password instructions": "Wyślij instrukcje resetowania hasła",
"Sending reset instructions ...": "Wysyłanie instrukcji resetowania hasła ...",
"Forgot your password?": "Zapomniałeś hasła?",
"Check your email for the password reset link": "Sprawdź swój email, aby uzyskać link resetujący hasło",
"New Password": "Nowe hasło",
"Your new password": "Twoje nowe hasło",
"Update password": "Zaktualizuj hasło",
"Updating password ...": "Aktualizacja hasła ...",
"Your password has been updated": "Twoje hasło zostało zaktualizowane",
"Phone number": "Numer telefonu",
"Your phone number": "Twój numer telefonu",
"Token": "Token",
"Your OTP token": "Twój OTP token",
"Verify token": "Weryfikacja tokena",
"Sign in with Google": "Zaloguj się za pomocą Google",
"Sign in with Apple": "Zaloguj się za pomocą Apple",
"Sign in with GitHub": "Zaloguj się za pomocą GitHub"
}
@@ -57,7 +57,6 @@
"Language:": "Idioma:",
"Layout": "Layout",
"Light Mode": "Modo Claro",
"Line Height": "Altura da Linha",
"Loading...": "Carregando...",
"Loc. {{currentPage}} / {{totalPage}}": "Loc. {{currentPage}} / {{totalPage}}",
"Logged in": "Conectado",
@@ -76,7 +75,6 @@
"Notebook": "Caderno",
"Notes": "Notas",
"Open": "Abrir",
"Open Book": "Abrir Livro",
"Original Text": "Texto Original",
"Page": "Página",
"Paging Animation": "Animação de Página",
@@ -149,5 +147,56 @@
"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"
"Check Updates on Start": "Verificar Atualizações ao Iniciar",
"Insufficient storage quota": "Cota de armazenamento insuficiente",
"Font Weight": "Peso da Fonte",
"Line Spacing": "Espaçamento de Linha",
"Word Spacing": "Espaçamento de Palavra",
"Letter Spacing": "Espaçamento de Letra",
"Text Indent": "Recuo de Texto",
"Paragraph Margin": "Margem de Parágrafo",
"Override Book Layout": "Sobrescrever Layout do Livro",
"Add to Group": "Adicionar ao Grupo",
"Untitled Group": "Grupo Sem Título",
"Group Books": "Agrupar Livros",
"Remove From Group": "Remover do Grupo",
"Create New Group": "Criar Novo Grupo",
"Deselect Book": "Desmarcar Livro",
"Download Book": "Baixar Livro",
"Deselect Group": "Desmarcar Grupo",
"Select Group": "Selecionar Grupo",
"Keep Screen Awake": "Manter a Tela Ativa",
"Email address": "Endereço de e-mail",
"Your Password": "Sua senha",
"Your email address": "Seu endereço de e-mail",
"Your password": "Sua senha",
"Sign in": "Entrar",
"Signing in...": "Entrando...",
"Sign in with {{provider}}": "Entrar com {{provider}}",
"Already have an account? Sign in": "Já tem uma conta? Entrar",
"Create a Password": "Criar uma senha",
"Sign up": "Cadastrar-se",
"Signing up...": "Cadastrando...",
"Dont have an account? Sign up": "Não tem uma conta? Cadastre-se",
"Check your email for the confirmation link": "Verifique seu e-mail para o link de confirmação",
"Signing in ...": "Entrando ...",
"Send a magic link email": "Enviar um link mágico por e-mail",
"Check your email for the magic link": "Verifique seu e-mail para o link mágico",
"Send reset password instructions": "Enviar instruções para redefinir a senha",
"Sending reset instructions ...": "Enviando instruções para redefinir a senha ...",
"Forgot your password?": "Esqueceu sua senha?",
"Check your email for the password reset link": "Verifique seu e-mail para o link de redefinição de senha",
"New Password": "Nova senha",
"Your new password": "Sua nova senha",
"Update password": "Atualizar senha",
"Updating password ...": "Atualizando senha ...",
"Your password has been updated": "Sua senha foi atualizada",
"Phone number": "Número de telefone",
"Your phone number": "Seu número de telefone",
"Token": "Token",
"Your OTP token": "Seu token OTP",
"Verify token": "Verificar token",
"Sign in with Google": "Entrar com o Google",
"Sign in with Apple": "Entrar com o Apple",
"Sign in with GitHub": "Entrar com o GitHub"
}
@@ -57,7 +57,6 @@
"Language:": "Язык:",
"Layout": "Макет",
"Light Mode": "Светлая тема",
"Line Height": "Межстрочный интервал",
"Loading...": "Загрузка...",
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Выполнен вход",
@@ -76,7 +75,6 @@
"Notebook": "Блокнот",
"Notes": "Заметки",
"Open": "Открыть",
"Open Book": "Открыть книгу",
"Original Text": "Оригинальный текст",
"Page": "Страница",
"Paging Animation": "Анимация перелистывания",
@@ -149,5 +147,56 @@
"Storage": "Хранилище",
"Book deleted: {{title}}": "Книга удалена: {{title}}",
"Failed to delete book: {{title}}": "Не удалось удалить книгу: {{title}}",
"Check Updates on Start": "Проверять обновления при запуске"
"Check Updates on Start": "Проверять обновления при запуске",
"Insufficient storage quota": "Недостаточно квоты хранилища",
"Font Weight": "Насыщенность шрифта",
"Line Spacing": "Межстрочный интервал",
"Word Spacing": "Межсловный интервал",
"Letter Spacing": "Межбуквенный интервал",
"Text Indent": "Отступ текста",
"Paragraph Margin": "Отступ абзаца",
"Override Book Layout": "Переопределить макет книги",
"Add to Group": "Добавить в группу",
"Untitled Group": "Группа без названия",
"Group Books": "Группировать книги",
"Remove From Group": "Удалить из группы",
"Create New Group": "Создать новую группу",
"Deselect Book": "Отменить выбор книги",
"Download Book": "Скачать книгу",
"Deselect Group": "Отменить выбор группы",
"Select Group": "Выбрать группу",
"Keep Screen Awake": "Не выключать экран",
"Email address": "Адрес электронной почты",
"Your Password": "Ваш пароль",
"Your email address": "Ваш адрес электронной почты",
"Your password": "Ваш пароль",
"Sign in": "Войти",
"Signing in...": "Вход...",
"Sign in with {{provider}}": "Войти с помощью {{provider}}",
"Already have an account? Sign in": "Уже есть аккаунт? Войдите",
"Create a Password": "Создать пароль",
"Sign up": "Зарегистрироваться",
"Signing up...": "Регистрация...",
"Dont have an account? Sign up": "Нет аккаунта? Зарегистрируйтесь",
"Check your email for the confirmation link": "Проверьте свою почту для подтверждающей ссылки",
"Signing in ...": "Вход ...",
"Send a magic link email": "Отправить магическую ссылку по электронной почте",
"Check your email for the magic link": "Проверьте свою почту для магической ссылки",
"Send reset password instructions": "Отправить инструкции по сбросу пароля",
"Sending reset instructions ...": "Отправка инструкций по сбросу пароля ...",
"Forgot your password?": "Забыли пароль?",
"Check your email for the password reset link": "Проверьте свою почту для ссылки для сброса пароля",
"New Password": "Новый пароль",
"Your new password": "Ваш новый пароль",
"Update password": "Обновить пароль",
"Updating password ...": "Обновление пароля ...",
"Your password has been updated": "Ваш пароль был обновлен",
"Phone number": "Номер телефона",
"Your phone number": "Ваш номер телефона",
"Token": "Токен",
"Your OTP token": "Ваш OTP токен",
"Verify token": "Проверить токен",
"Sign in with Google": "Войти через Google",
"Sign in with Apple": "Войти через Apple",
"Sign in with GitHub": "Войти через GitHub"
}
@@ -57,7 +57,6 @@
"Language:": "Dil:",
"Layout": "Düzen",
"Light Mode": "Aydınlık Mod",
"Line Height": "Satır Yüksekliği",
"Loading...": "Yükleniyor...",
"Loc. {{currentPage}} / {{totalPage}}": "Konum {{currentPage}} / {{totalPage}}",
"Logged in": "Giriş Yapıldı",
@@ -76,7 +75,6 @@
"Notebook": "Not Defteri",
"Notes": "Notlar",
"Open": "Aç",
"Open Book": "Kitabı Aç",
"Original Text": "Orijinal Metin",
"Page": "Sayfa",
"Paging Animation": "Sayfa Çevirme Animasyonu",
@@ -149,5 +147,56 @@
"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"
"Check Updates on Start": "Başlangıçta Güncellemeleri Kontrol Et",
"Insufficient storage quota": "Yetersiz depolama kotası",
"Font Weight": "Yazı Tipi Ağırlığı",
"Line Spacing": "Satır Aralığı",
"Word Spacing": "Kelime Aralığı",
"Letter Spacing": "Harf Aralığı",
"Text Indent": "Metin Girintisi",
"Paragraph Margin": "Paragraf Kenarı",
"Override Book Layout": "Kitap Düzenini Geçersiz Kıl",
"Add to Group": "Gruba Ekle",
"Untitled Group": "Başlıksız Grup",
"Group Books": "Kitapları Grupla",
"Remove From Group": "Grubu Kaldır",
"Create New Group": "Yeni Grup Oluştur",
"Deselect Book": "Kitabı Seçme",
"Download Book": "Kitabı İndir",
"Deselect Group": "Grubu Seçme",
"Select Group": "Grup Seç",
"Keep Screen Awake": "Ekranı Açık Tut",
"Email address": "E-posta adresi",
"Your Password": "Şifreniz",
"Your email address": "E-posta adresiniz",
"Your password": "Şifreniz",
"Sign in": "Giriş yap",
"Signing in...": "Giriş yapılıyor...",
"Sign in with {{provider}}": "{{provider}} ile giriş yap",
"Already have an account? Sign in": "Hesabınız var mı? Giriş yapın",
"Create a Password": "Şifre oluştur",
"Sign up": "Kaydol",
"Signing up...": "Kaydolunuyor...",
"Dont have an account? Sign up": "Hesabınız yok mu? Kaydolun",
"Check your email for the confirmation link": "Onay bağlantısı için e-posta adresinizi kontrol edin",
"Signing in ...": "Giriş yapılıyor...",
"Send a magic link email": "Sihirli bağlantı e-postası gönder",
"Check your email for the magic link": "Sihirli bağlantı için e-posta adresinizi kontrol edin",
"Send reset password instructions": "Şifre sıfırlama talimatları gönder",
"Sending reset instructions ...": "Şifre sıfırlama talimatları gönderiliyor...",
"Forgot your password?": "Şifrenizi mi unuttunuz?",
"Check your email for the password reset link": "Şifre sıfırlama bağlantısı için e-posta adresinizi kontrol edin",
"New Password": "Yeni şifre",
"Your new password": "Yeni şifreniz",
"Update password": "Şifreyi güncelle",
"Updating password ...": "Şifre güncelleniyor...",
"Your password has been updated": "Şifreniz güncellendi",
"Phone number": "Telefon numarası",
"Your phone number": "Telefon numaranız",
"Token": "Token",
"Your OTP token": "OTP token'ınız",
"Verify token": "Token'ı doğrula",
"Sign in with Google": "Google ile giriş yap",
"Sign in with Apple": "Apple ile giriş yap",
"Sign in with GitHub": "GitHub ile giriş yap"
}
@@ -57,7 +57,6 @@
"Language:": "Мова:",
"Layout": "Макет",
"Light Mode": "Світлий режим",
"Line Height": "Висота рядка",
"Loading...": "Завантаження...",
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Увійшли",
@@ -76,7 +75,6 @@
"Notebook": "Нотатник",
"Notes": "Нотатки",
"Open": "Відкрити",
"Open Book": "Відкрити книгу",
"Original Text": "Оригінальний текст",
"Page": "Сторінка",
"Paging Animation": "Анімація гортання",
@@ -149,5 +147,56 @@
"Storage": "Сховище",
"Book deleted: {{title}}": "Книгу видалено: {{title}}",
"Failed to delete book: {{title}}": "Не вдалося видалити книгу: {{title}}",
"Check Updates on Start": "Перевіряти оновлення при запуску"
"Check Updates on Start": "Перевіряти оновлення при запуску",
"Insufficient storage quota": "Недостатньо квоти сховища",
"Font Weight": "Насиченість шрифту",
"Line Spacing": "Міжрядковий інтервал",
"Word Spacing": "Міжслівний інтервал",
"Letter Spacing": "Міжбуквений інтервал",
"Text Indent": "Відступ тексту",
"Paragraph Margin": "Відступ абзацу",
"Override Book Layout": "Перевизначити макет книги",
"Add to Group": "Додати до групи",
"Untitled Group": "Група без назви",
"Group Books": "Групувати книги",
"Remove From Group": "Видалити з групи",
"Create New Group": "Створити нову групу",
"Deselect Book": "Скасувати вибір книги",
"Download Book": "Завантажити книгу",
"Deselect Group": "Скасувати вибір групи",
"Select Group": "Вибрати групу",
"Keep Screen Awake": "Не вимикати екран",
"Email address": "Адреса електронної пошти",
"Your Password": "Ваш пароль",
"Your email address": "Ваша електронна адреса",
"Your password": "Ваш пароль",
"Sign in": "Увійти",
"Signing in...": "Вхід...",
"Sign in with {{provider}}": "Увійти через {{provider}}",
"Already have an account? Sign in": "Уже є акаунт? Увійти",
"Create a Password": "Створити пароль",
"Sign up": "Зареєструватися",
"Signing up...": "Реєстрація...",
"Dont have an account? Sign up": "Немає акаунту? Зареєструйтеся",
"Check your email for the confirmation link": "Перевірте свою електронну пошту для підтвердження посилання",
"Signing in ...": "Увійти...",
"Send a magic link email": "Відправити магічне посилання на електронну пошту",
"Check your email for the magic link": "Перевірте свою електронну пошту для магічного посилання",
"Send reset password instructions": "Надіслати інструкції для скидання пароля",
"Sending reset instructions ...": "Надсилаються інструкції для скидання пароля...",
"Forgot your password?": "Забули пароль?",
"Check your email for the password reset link": "Перевірте свою електронну пошту для посилання на скидання пароля",
"New Password": "Новий пароль",
"Your new password": "Ваш новий пароль",
"Update password": "Оновити пароль",
"Updating password ...": "Оновлення пароля...",
"Your password has been updated": "Ваш пароль оновлено",
"Phone number": "Номер телефону",
"Your phone number": "Ваш номер телефону",
"Token": "Токен",
"Your OTP token": "Ваш OTP токен",
"Verify token": "Перевірити токен",
"Sign in with Google": "Увійти через Google",
"Sign in with Apple": "Увійти через Apple",
"Sign in with GitHub": "Увійти через GitHub"
}
@@ -57,7 +57,6 @@
"Language:": "Ngôn ngữ:",
"Layout": "Bố cục",
"Light Mode": "Chế độ sáng",
"Line Height": "Độ cao dòng",
"Loading...": "Đang tải...",
"Loc. {{currentPage}} / {{totalPage}}": "Vị trí {{currentPage}} / {{totalPage}}",
"Logged in": "Đã đăng nhập",
@@ -76,7 +75,6 @@
"Notebook": "Sổ ghi chép",
"Notes": "Ghi chú",
"Open": "Mở",
"Open Book": "Mở sách",
"Original Text": "Văn bản gốc",
"Page": "Trang",
"Paging Animation": "Hiệu ứng lật trang",
@@ -149,5 +147,56 @@
"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"
"Check Updates on Start": "Kiểm tra cập nhật khi khởi động",
"Insufficient storage quota": "Không đủ lượng lưu trữ",
"Font Weight": "Độ đậm của phông chữ",
"Line Spacing": "Khoảng cách dòng",
"Word Spacing": "Khoảng cách từ",
"Letter Spacing": "Khoảng cách chữ",
"Text Indent": "Thụt lề",
"Paragraph Margin": "Lề đoạn",
"Override Book Layout": "Ghi đè bố cục sách",
"Add to Group": "Thêm vào nhóm",
"Untitled Group": "Nhóm không có tiêu đề",
"Group Books": "Nhóm sách",
"Remove From Group": "Xóa khỏi nhóm",
"Create New Group": "Tạo nhóm mới",
"Deselect Book": "Bỏ chọn sách",
"Download Book": "Tải sách",
"Deselect Group": "Bỏ chọn nhóm",
"Select Group": "Chọn nhóm",
"Keep Screen Awake": "Giữ màn hình luôn bật",
"Email address": "Địa chỉ email",
"Your Password": "Mật khẩu của bạn",
"Your email address": "Địa chỉ email của bạn",
"Your password": "Mật khẩu của bạn",
"Sign in": "Đăng nhập",
"Signing in...": "Đang đăng nhập...",
"Sign in with {{provider}}": "Đăng nhập với {{provider}}",
"Already have an account? Sign in": "Đã có tài khoản? Đăng nhập",
"Create a Password": "Tạo mật khẩu",
"Sign up": "Đăng ký",
"Signing up...": "Đang đăng ký...",
"Dont have an account? Sign up": "Chưa có tài khoản? Đăng ký",
"Check your email for the confirmation link": "Kiểm tra email của bạn để nhận liên kết xác nhận",
"Signing in ...": "Đang đăng nhập ...",
"Send a magic link email": "Gửi email liên kết ma thuật",
"Check your email for the magic link": "Kiểm tra email của bạn để nhận liên kết ma thuật",
"Send reset password instructions": "Gửi hướng dẫn đặt lại mật khẩu",
"Sending reset instructions ...": "Đang gửi hướng dẫn đặt lại mật khẩu ...",
"Forgot your password?": "Quên mật khẩu?",
"Check your email for the password reset link": "Kiểm tra email của bạn để nhận liên kết đặt lại mật khẩu",
"New Password": "Mật khẩu mới",
"Your new password": "Mật khẩu mới của bạn",
"Update password": "Cập nhật mật khẩu",
"Updating password ...": "Đang cập nhật mật khẩu ...",
"Your password has been updated": "Mật khẩu của bạn đã được cập nhật",
"Phone number": "Số điện thoại",
"Your phone number": "Số điện thoại của bạn",
"Token": "Mã token",
"Your OTP token": "Mã OTP của bạn",
"Verify token": "Xác minh mã token",
"Sign in with Google": "Đăng nhập với Google",
"Sign in with Apple": "Đăng nhập với Apple",
"Sign in with GitHub": "Đăng nhập với GitHub"
}
@@ -57,7 +57,6 @@
"Language:": "语言",
"Layout": "布局",
"Light Mode": "浅色主题",
"Line Height": "行高",
"Loading...": "加载中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登录",
@@ -76,7 +75,6 @@
"Notebook": "笔记本",
"Notes": "笔记",
"Open": "打开",
"Open Book": "打开书籍",
"Original Text": "原文",
"Page": "页面",
"Paging Animation": "翻页动画",
@@ -149,5 +147,56 @@
"Storage": "存储空间",
"Book deleted: {{title}}": "书籍已删除:{{title}}",
"Failed to delete book: {{title}}": "删除书籍失败:{{title}}",
"Check Updates on Start": "启动时检查更新"
"Check Updates on Start": "启动时检查更新",
"Insufficient storage quota": "云存储空间不足",
"Font Weight": "字重",
"Line Spacing": "行间距",
"Word Spacing": "词间距",
"Letter Spacing": "字间距",
"Text Indent": "首行缩进",
"Paragraph Margin": "段间距",
"Override Book Layout": "覆盖版面布局",
"Add to Group": "添加到分组",
"Untitled Group": "无标题分组",
"Group Books": "分组书籍",
"Remove From Group": "从分组中移除",
"Create New Group": "创建新分组",
"Deselect Book": "取消选择书籍",
"Download Book": "下载书籍",
"Deselect Group": "取消选择分组",
"Select Group": "选择分组",
"Keep Screen Awake": "保持屏幕常亮",
"Email address": "电子邮件地址",
"Your Password": "您的密码",
"Your email address": "您的电子邮件地址",
"Your password": "您的密码",
"Sign in": "登录",
"Signing in...": "正在登录...",
"Sign in with {{provider}}": "使用 {{provider}} 登录",
"Already have an account? Sign in": "已经有账号?登录",
"Create a Password": "创建密码",
"Sign up": "注册",
"Signing up...": "正在注册...",
"Dont have an account? Sign up": "没有账号?注册",
"Check your email for the confirmation link": "检查您的电子邮件以获取确认链接",
"Signing in ...": "正在登录...",
"Send a magic link email": "发送魔法链接邮件",
"Check your email for the magic link": "检查您的电子邮件以获取魔法链接",
"Send reset password instructions": "发送重置密码说明",
"Sending reset instructions ...": "正在发送重置密码说明...",
"Forgot your password?": "忘记密码?",
"Check your email for the password reset link": "检查您的电子邮件以获取密码重置链接",
"New Password": "新密码",
"Your new password": "您的新密码",
"Update password": "更新密码",
"Updating password ...": "正在更新密码...",
"Your password has been updated": "您的密码已更新",
"Phone number": "电话号码",
"Your phone number": "您的电话号码",
"Token": "令牌",
"Your OTP token": "您的 OTP 令牌",
"Verify token": "验证令牌",
"Sign in with Google": "使用 Google 登录",
"Sign in with Apple": "使用 Apple 登录",
"Sign in with GitHub": "使用 GitHub 登录"
}
@@ -57,7 +57,6 @@
"Language:": "語言",
"Layout": "版面",
"Light Mode": "淺色主題",
"Line Height": "行高",
"Loading...": "載入中...",
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登入",
@@ -76,7 +75,6 @@
"Notebook": "筆記本",
"Notes": "筆記",
"Open": "打開",
"Open Book": "打開書籍",
"Original Text": "原文",
"Page": "頁面",
"Paging Animation": "翻頁動畫",
@@ -149,5 +147,56 @@
"Storage": "存儲空間",
"Book deleted: {{title}}": "書籍已刪除:{{title}}",
"Failed to delete book: {{title}}": "書籍刪除失敗:{{title}}",
"Check Updates on Start": "啟動時檢查更新"
"Check Updates on Start": "啟動時檢查更新",
"Insufficient storage quota": "雲存儲空間不足",
"Font Weight": "字重",
"Line Spacing": "行間距",
"Word Spacing": "字間距",
"Letter Spacing": "字間距",
"Text Indent": "首行縮進",
"Paragraph Margin": "段間距",
"Override Book Layout": "覆蓋版面佈局",
"Add to Group": "添加到組",
"Untitled Group": "無標題組",
"Group Books": "分組書籍",
"Remove From Group": "從組中移除",
"Create New Group": "創建新組",
"Deselect Book": "取消選擇書籍",
"Download Book": "下載書籍",
"Deselect Group": "取消選擇組",
"Select Group": "選擇組",
"Keep Screen Awake": "保持屏幕常亮",
"Email address": "電子郵件地址",
"Your Password": "您的密碼",
"Your email address": "您的電子郵件地址",
"Your password": "您的密碼",
"Sign in": "登入",
"Signing in...": "正在登入...",
"Sign in with {{provider}}": "使用 {{provider}} 登入",
"Already have an account? Sign in": "已經有帳號?登入",
"Create a Password": "創建密碼",
"Sign up": "註冊",
"Signing up...": "正在註冊...",
"Dont have an account? Sign up": "沒有帳號?註冊",
"Check your email for the confirmation link": "檢查您的電子郵件以獲取確認連結",
"Signing in ...": "正在登入...",
"Send a magic link email": "發送魔法連結郵件",
"Check your email for the magic link": "檢查您的電子郵件以獲取魔法連結",
"Send reset password instructions": "發送重設密碼指示",
"Sending reset instructions ...": "正在發送重設密碼指示...",
"Forgot your password?": "忘記密碼?",
"Check your email for the password reset link": "檢查您的電子郵件以獲取密碼重設連結",
"New Password": "新密碼",
"Your new password": "您的新密碼",
"Update password": "更新密碼",
"Updating password ...": "正在更新密碼...",
"Your password has been updated": "您的密碼已更新",
"Phone number": "電話號碼",
"Your phone number": "您的電話號碼",
"Token": "令牌",
"Your OTP token": "您的 OTP 令牌",
"Verify token": "驗證令牌",
"Sign in with Google": "使用 Google 登入",
"Sign in with Apple": "使用 Apple 登入",
"Sign in with GitHub": "使用 GitHub 登入"
}
+48
View File
@@ -1,5 +1,53 @@
{
"releases": {
"0.9.15": {
"date": "2025-02-21",
"notes": [
"Fix hardcoded font color from calibre",
"Fix scroll to current TOC item when toggling sidebar",
"Various fixes and enhancements on iOS platform"
]
},
"0.9.13": {
"date": "2025-02-19",
"notes": [
"Add option to keep screen awake when reading",
"Add shortcuts to notetaking UI",
"UX enhancements of grid view in mobile platforms"
]
},
"0.9.11": {
"date": "2025-02-14",
"notes": [
"Organize the library with customizable groups/folders",
"Alpha release for Android and iOS platforms",
"Various fixes and enhancements on Android and iOS platforms"
]
},
"0.9.10": {
"date": "2025-02-08",
"notes": [
"Add options to adjust paragraph margin, word spacing, letter spacing, text indent and font weight",
"Alpha release for Android and iOS platforms"
]
},
"0.9.9": {
"date": "2025-02-06",
"notes": [
"Load system fonts list at runtime on desktop",
"Incrementally load new books in synchronization",
"Change voice language dynamically by detecting text language",
"Various fixes and enhancements on sync, fonts and highlighting"
]
},
"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": [
@@ -0,0 +1,6 @@
pnpm tauri ios build --export-method app-store-connect
BUNDLE_DIR=src-tauri/gen/apple/build/arm64
IPA_BUNDLE=$BUNDLE_DIR/Readest.ipa
xcrun altool --upload-app --type ios --file $IPA_BUNDLE --apiKey $APPLE_API_KEY --apiIssuer $APPLE_API_ISSUER
@@ -1,6 +1,6 @@
pnpm run build-macos-universial-appstore
BUNDLE_DIR=src-tauri/target/universal-apple-darwin/release/bundle/macos
BUNDLE_DIR=../../target/universal-apple-darwin/release/bundle/macos
APP_BUNDLE=$BUNDLE_DIR/Readest.app
INSTALLER_BUNDLE=$BUNDLE_DIR/Readest.pkg
+2
View File
@@ -44,6 +44,7 @@ tauri-plugin-process = "2"
tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.2"
tauri-plugin-deep-link = "2"
tauri-plugin-sign-in-with-apple = "1.0.2"
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
@@ -57,3 +58,4 @@ tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2.2.0"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2.2.0"
font-enumeration = "0.9.0"
@@ -68,6 +68,7 @@
"process:allow-restart",
"oauth:allow-start",
"oauth:allow-cancel",
"sign-in-with-apple:default",
"opener:default",
"deep-link:default"
]
@@ -0,0 +1,80 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 34
namespace = "com.bilingify.readest"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.bilingify.readest"
minSdk = 26
targetSdk = 34
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
signingConfigs {
create("release") {
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["password"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["password"] as String
}
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
signingConfig = signingConfigs.getByName("release")
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.6.1")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.activity:activity-ktx:1.9.3")
implementation("com.google.android.material:material:1.8.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
@@ -0,0 +1,33 @@
package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
hideSystemUI()
}
private fun hideSystemUI() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.decorView.windowInsetsController?.let { controller ->
controller.hide(WindowInsets.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
)
}
}
}
@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.readest" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowFullscreen">true</item>
</style>
</resources>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+48 -18
View File
@@ -11,21 +11,20 @@ mod menu;
#[cfg(target_os = "macos")]
mod traffic_light_plugin;
mod transfer_file;
use transfer_file::{download_file, upload_file};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use std::path::PathBuf;
use tauri::{command, Window};
use tauri::{AppHandle, Emitter, Manager, Url};
use tauri::{WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_fs::FsExt;
use tauri_plugin_oauth::start;
#[cfg(desktop)]
use tauri::Listener;
use std::path::PathBuf;
#[cfg(desktop)]
use tauri::{AppHandle, Listener, Manager, Url};
#[cfg(desktop)]
use tauri_plugin_fs::FsExt;
mod transfer_file;
use tauri::{command, Emitter, WebviewUrl, WebviewWindowBuilder, Window};
use tauri_plugin_oauth::start;
use transfer_file::{download_file, upload_file};
#[cfg(desktop)]
fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
@@ -72,7 +71,19 @@ async fn start_server(window: Window) -> Result<u16, String> {
.map_err(|err| err.to_string())
}
#[cfg(desktop)]
#[command]
async fn list_fonts() -> Result<Vec<String>, String> {
let font_collection = font_enumeration::Collection::new().unwrap();
let mut fonts = Vec::new();
for font in font_collection.all() {
fonts.push(font.family_name.clone());
}
Ok(fonts)
}
#[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct Payload {
args: Vec<String>,
cwd: String,
@@ -86,7 +97,9 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
start_server,
download_file,
upload_file
upload_file,
#[cfg(desktop)]
list_fonts
])
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
@@ -116,6 +129,9 @@ pub fn run() {
#[cfg(target_os = "macos")]
let builder = builder.plugin(traffic_light_plugin::init());
#[cfg(target_os = "ios")]
let builder = builder.plugin(tauri_plugin_sign_in_with_apple::init());
builder
.setup(|#[allow(unused_variables)] app| {
#[cfg(desktop)]
@@ -191,12 +207,26 @@ pub fn run() {
.title("");
#[cfg(all(not(target_os = "macos"), desktop))]
let win_builder = win_builder
.decorations(false)
.transparent(true)
.visible(false)
.shadow(true)
.title("Readest");
let win_builder = {
let mut win_builder = win_builder
.decorations(false)
.transparent(true)
.visible(false)
.title("Readest");
if cfg!(target_os = "windows") {
let version = tauri_plugin_os::version();
if version <= tauri_plugin_os::Version::from_string("10.0.19045") {
win_builder = win_builder.shadow(false);
} else {
win_builder = win_builder.shadow(true);
}
} else {
win_builder = win_builder.shadow(true);
}
win_builder
};
win_builder.build().unwrap();
// let win = win_builder.build().unwrap();
+9 -3
View File
@@ -14,11 +14,11 @@
"windows": [],
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com",
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com",
"font-src": "'self' data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
"frame-src": "'self' blob: asset: http://asset.localhost",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com"
},
@@ -56,6 +56,12 @@
"section": "text"
}
},
"android": {
"minSdkVersion": 26
},
"iOS": {
"developmentTeam": "J5W48D69VR"
},
"fileAssociations": [
{
"name": "epub",
+124 -26
View File
@@ -7,7 +7,6 @@ import { Auth } from '@supabase/auth-ui-react';
import { ThemeSupa } from '@supabase/auth-ui-shared';
import { FcGoogle } from 'react-icons/fc';
import { FaApple } from 'react-icons/fa';
import { VscAzure } from 'react-icons/vsc';
import { FaGithub } from 'react-icons/fa';
import { IoArrowBack } from 'react-icons/io5';
@@ -16,11 +15,13 @@ import { supabase } from '@/utils/supabase';
import { useTheme } from '@/hooks/useTheme';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { isTauriAppPlatform } from '@/services/environment';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth';
import { openUrl } from '@tauri-apps/plugin-opener';
import { handleAuthCallback } from '@/helpers/auth';
import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
@@ -36,6 +37,9 @@ interface ProviderLoginProp {
label: string;
}
const WEB_AUTH_CALLBACK = 'https://web.readest.com/auth/callback';
const DEEPLINK_CALLBACK = 'readest://auth/callback';
const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Icon, label }) => {
return (
<button
@@ -52,16 +56,45 @@ const ProviderLogin: React.FC<ProviderLoginProp> = ({ provider, handleSignIn, Ic
};
export default function AuthPage() {
const _ = useTranslation();
const router = useRouter();
const { login } = useAuth();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { isDarkMode } = useTheme();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [port, setPort] = useState<number | null>(null);
const isOAuthServerRunning = useRef(false);
const [isMounted, setIsMounted] = useState(false);
const signIn = async (provider: OAuthProvider) => {
const getTauriRedirectTo = () => {
return process.env.NODE_ENV === 'production' || appService?.isMobile
? appService?.isMobile
? WEB_AUTH_CALLBACK
: DEEPLINK_CALLBACK
: `http://localhost:${port}`; // only for development env on Desktop
};
const tauriSignInApple = async () => {
if (!supabase) {
throw new Error('No backend connected');
}
supabase.auth.signOut();
const request = {
scope: ['fullName', 'email'] as Scope[],
};
const appleAuthResponse = await getAppleIdAuth(request);
if (appleAuthResponse.identityToken) {
const { error } = await supabase.auth.signInWithIdToken({
provider: 'apple',
token: appleAuthResponse.identityToken,
});
if (error) {
console.error('Authentication error:', error);
}
}
};
const tauriSignIn = async (provider: OAuthProvider) => {
if (!supabase) {
throw new Error('No backend connected');
}
@@ -70,10 +103,7 @@ export default function AuthPage() {
provider,
options: {
skipBrowserRedirect: true,
redirectTo:
process.env.NODE_ENV === 'production'
? 'readest://auth/callback'
: `http://localhost:${port}`,
redirectTo: getTauriRedirectTo(),
},
});
@@ -81,7 +111,7 @@ export default function AuthPage() {
console.error('Authentication error:', error);
return;
}
openUrl(data.url);
await openUrl(data.url);
};
const handleOAuthUrl = async (url: string) => {
@@ -101,7 +131,7 @@ export default function AuthPage() {
const startTauriOAuth = async () => {
try {
if (process.env.NODE_ENV === 'production') {
if (process.env.NODE_ENV === 'production' || appService?.isMobile) {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
currentWindow.listen('single-instance', ({ event, payload }) => {
@@ -150,6 +180,68 @@ export default function AuthPage() {
router.back();
};
const getAuthLocalization = () => {
return {
variables: {
sign_in: {
email_label: _('Email address'),
password_label: _('Your Password'),
email_input_placeholder: _('Your email address'),
password_input_placeholder: _('Your password'),
button_label: _('Sign in'),
loading_button_label: _('Signing in...'),
social_provider_text: _('Sign in with {{provider}}'),
link_text: _('Already have an account? Sign in'),
},
sign_up: {
email_label: _('Email address'),
password_label: _('Create a Password'),
email_input_placeholder: _('Your email address'),
password_input_placeholder: _('Your password'),
button_label: _('Sign up'),
loading_button_label: _('Signing up...'),
social_provider_text: _('Sign in with {{provider}}'),
link_text: _('Dont have an account? Sign up'),
confirmation_text: _('Check your email for the confirmation link'),
},
magic_link: {
email_input_label: _('Email address'),
email_input_placeholder: _('Your email address'),
button_label: _('Sign in'),
loading_button_label: _('Signing in ...'),
link_text: _('Send a magic link email'),
confirmation_text: _('Check your email for the magic link'),
},
forgotten_password: {
email_label: _('Email address'),
password_label: _('Your Password'),
email_input_placeholder: _('Your email address'),
button_label: _('Send reset password instructions'),
loading_button_label: _('Sending reset instructions ...'),
link_text: _('Forgot your password?'),
confirmation_text: _('Check your email for the password reset link'),
},
update_password: {
password_label: _('New Password'),
password_input_placeholder: _('Your new password'),
button_label: _('Update password'),
loading_button_label: _('Updating password ...'),
confirmation_text: _('Your password has been updated'),
},
verify_otp: {
email_input_label: _('Email address'),
email_input_placeholder: _('Your email address'),
phone_input_label: _('Phone number'),
phone_input_placeholder: _('Your phone number'),
token_input_label: _('Token'),
token_input_placeholder: _('Your OTP token'),
button_label: _('Verify token'),
loading_button_label: _('Signing in ...'),
},
},
};
};
useEffect(() => {
if (!isTauriAppPlatform()) return;
if (isOAuthServerRunning.current) return;
@@ -190,37 +282,41 @@ export default function AuthPage() {
// For tauri app production, use deeplink to handle the OAuth callback
// For web app, use the built-in OAuth callback page /auth/callback
return isTauriAppPlatform() ? (
<div className='flex pt-11'>
<div
className={clsx(
'mt-6 flex',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
appService?.hasTrafficLight && 'pt-11',
)}
>
<button
onClick={handleGoBack}
className='btn btn-ghost fixed left-3 top-11 h-8 min-h-8 w-8 p-0'
className={clsx(
'btn btn-ghost fixed left-4 h-8 min-h-8 w-8 p-0',
appService?.hasSafeAreaInset && 'top-[calc(env(safe-area-inset-top)+16px)]',
appService?.hasTrafficLight && 'top-11',
)}
>
<IoArrowBack className='text-base-content' />
</button>
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
<ProviderLogin
provider='google'
handleSignIn={signIn}
handleSignIn={tauriSignIn}
Icon={FcGoogle}
label='Sign in with Google'
label={_('Sign in with Google')}
/>
<ProviderLogin
provider='apple'
handleSignIn={signIn}
handleSignIn={appService?.isIOSApp ? tauriSignInApple : tauriSignIn}
Icon={FaApple}
label='Sign in with Apple'
/>
<ProviderLogin
provider='azure'
handleSignIn={signIn}
Icon={VscAzure}
label='Sign in with Azure'
label={_('Sign in with Apple')}
/>
<ProviderLogin
provider='github'
handleSignIn={signIn}
handleSignIn={tauriSignIn}
Icon={FaGithub}
label='Sign in with GitHub'
label={_('Sign in with GitHub')}
/>
<hr className='my-3 mt-6 w-64 border-t border-gray-200' />
<Auth
@@ -229,7 +325,8 @@ export default function AuthPage() {
theme={isDarkMode ? 'dark' : 'light'}
magicLink={true}
providers={[]}
redirectTo={`http://localhost:${port}`}
redirectTo={getTauriRedirectTo()}
localization={getAuthLocalization()}
/>
</div>
</div>
@@ -237,7 +334,7 @@ export default function AuthPage() {
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem', paddingTop: '4rem' }}>
<button
onClick={handleGoBack}
className='btn btn-ghost fixed left-10 top-6 h-8 min-h-8 w-8 p-0'
className='btn btn-ghost fixed left-6 top-6 h-8 min-h-8 w-8 p-0'
>
<IoArrowBack className='text-base-content' />
</button>
@@ -246,8 +343,9 @@ export default function AuthPage() {
appearance={{ theme: ThemeSupa }}
theme={isDarkMode ? 'dark' : 'light'}
magicLink={true}
providers={['google', 'apple', 'azure', 'github']}
providers={['google', 'apple', 'github']}
redirectTo='/auth/callback'
localization={getAuthLocalization()}
/>
</div>
);
@@ -0,0 +1,34 @@
import { invoke } from '@tauri-apps/api/core';
export type Scope = 'fullName' | 'email';
export interface AppleIDAuthorizationRequest {
scope: Scope[];
nonce?: string;
state?: string;
}
export interface AppleIDAuthorizationResponse {
// usually not null
userIdentifier: string | null;
givenName: string | null;
familyName: string | null;
email: string | null;
authorizationCode: string;
identityToken: string | null;
state: string | null;
}
export async function getAppleIdAuth(
request: AppleIDAuthorizationRequest,
): Promise<AppleIDAuthorizationResponse> {
const result = await invoke<AppleIDAuthorizationResponse>(
'plugin:sign-in-with-apple|get_apple_id_credential',
{
payload: request,
},
);
return result;
}
@@ -0,0 +1,57 @@
import clsx from 'clsx';
import Image from 'next/image';
import { Book } from '@/types/book';
import { formatAuthors, formatTitle } from '@/utils/book';
interface BookCoverProps {
book: Book;
isPreview?: boolean;
}
const BookCover: React.FC<BookCoverProps> = ({ book, isPreview }) => {
return (
<div className='relative flex h-full w-full'>
<Image
src={book.coverImageUrl!}
alt={book.title}
fill={true}
className='object-cover'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
}}
/>
<div
className={clsx(
'invisible absolute inset-0 rounded-none p-2',
'text-neutral-content text-center font-serif font-medium',
isPreview && 'bg-base-200/50',
)}
>
<div className='flex h-1/2 items-center justify-center'>
<span
className={clsx(
isPreview ? 'line-clamp-2' : 'line-clamp-3',
isPreview ? 'text-[0.5em]' : 'text-lg',
)}
>
{formatTitle(book.title)}
</span>
</div>
<div className='h-1/6'></div>
<div className='flex h-1/3 items-center justify-center'>
<span
className={clsx(
'text-neutral-content/50 line-clamp-1',
isPreview ? 'text-[0.4em]' : 'text-base',
)}
>
{formatAuthors(book.author)}
</span>
</div>
</div>
</div>
);
};
export default BookCover;
@@ -1,84 +1,62 @@
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 { useEnv } from '@/context/EnvContext';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import ReadingProgress from './ReadingProgress';
import BookCover from './BookCover';
interface BookItemProps {
book: Book;
isSelectMode: boolean;
selectedBooks: string[];
clickedBookHash: string | null;
transferProgress: number | null;
handleBookClick: (book: Book) => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
showBookDetailsModal: (book: Book) => void;
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);
const { appService } = useEnv();
const stopEvent = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
};
return (
<div
className='book-item cursor-pointer'
onContextMenu={bookContextMenuHandler.bind(null, book)}
className={clsx(
'book-item flex h-full flex-col',
appService?.hasContextMenu ? 'cursor-pointer' : '',
)}
>
<div className='bg-base-100 shadow-md' onClick={() => handleBookClick(book)}>
<div className='relative aspect-[28/41]'>
<Image
src={book.coverImageUrl!}
alt={book.title}
fill={true}
className='object-cover'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
}}
/>
<div
className={clsx(
'invisible absolute inset-0 rounded-none p-2',
'text-neutral-content text-center font-serif font-medium',
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden shadow-md'>
<BookCover book={book} />
{selectedBooks.includes(book.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(book.hash) ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
)}
>
<div className='flex h-1/2 items-center justify-center'>
<span className='line-clamp-3 text-lg'>{book.title}</span>
</div>
<div className='h-1/6'></div>
<div className='flex h-1/3 items-center justify-center'>
<span className='text-neutral-content/50 line-clamp-1 text-base'>{book.author}</span>
</div>
</div>
{(selectedBooks.includes(book.hash) || clickedBookHash === book.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(book.hash) ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
)}
</div>
)}
</div>
)}
</div>
<div className={clsx('flex w-full flex-col p-0 pt-2')}>
<div className='min-w-0 flex-1'>
@@ -108,6 +86,11 @@ const BookItem: React.FC<BookItemProps> = ({
) : (
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onPointerDown={(e) => stopEvent(e)}
onPointerUp={(e) => stopEvent(e)}
onPointerMove={(e) => stopEvent(e)}
onPointerCancel={(e) => stopEvent(e)}
onPointerLeave={(e) => stopEvent(e)}
onClick={() => {
if (!book.uploadedAt) {
handleBookUpload(book);
@@ -124,6 +107,11 @@ const BookItem: React.FC<BookItemProps> = ({
)}
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onPointerDown={(e) => stopEvent(e)}
onPointerUp={(e) => stopEvent(e)}
onPointerMove={(e) => stopEvent(e)}
onPointerCancel={(e) => stopEvent(e)}
onPointerLeave={(e) => stopEvent(e)}
onClick={() => showBookDetailsModal(book)}
>
<CiCircleMore size={iconSize15} />
@@ -2,53 +2,20 @@ import clsx from 'clsx';
import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { MdDelete, MdOpenInNew } from 'react-icons/md';
import { MdDelete, MdOpenInNew, MdOutlineCancel } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { Book, BooksGroup } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToReader } from '@/utils/nav';
import { getOSPlatform } from '@/utils/misc';
import { getFilename } from '@/utils/book';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { isTauriAppPlatform } from '@/services/environment';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import BookDetailModal from '@/components/BookDetailModal';
import BookItem from './BookItem';
import GroupItem from './GroupItem';
type BookshelfItem = Book | BooksGroup;
const UNGROUPED_NAME = 'ungrouped';
const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
if (book.deletedAt) return acc;
book.group = book.group || UNGROUPED_NAME;
const groupIndex = acc.findIndex((group) => group.name === book.group);
const booksGroup = acc[acc.findIndex((group) => group.name === book.group)];
if (booksGroup) {
booksGroup.books.push(book);
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
} else {
acc.push({
name: book.group,
books: [book],
updatedAt: book.updatedAt,
});
}
return acc;
}, []);
const ungroupedBooks: Book[] = groups.find((group) => group.name === UNGROUPED_NAME)?.books || [];
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== UNGROUPED_NAME);
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
};
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import GroupingModal from './GroupingModal';
interface BookshelfProps {
libraryBooks: Book[];
@@ -57,6 +24,8 @@ interface BookshelfProps {
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDelete: (book: Book) => void;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
booksTransferProgress: { [key: string]: number | null };
}
@@ -67,50 +36,33 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleBookUpload,
handleBookDownload,
handleBookDelete,
handleSetSelectMode,
handleShowDetailsBook,
booksTransferProgress,
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
const { appService } = useEnv();
const [loading, setLoading] = useState(false);
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [clickedImage, setClickedImage] = useState<string | null>(null);
const [showGroupingModal, setShowGroupingModal] = useState(false);
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const isImportingBook = useRef(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const makeBookAvailable = async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
let available = false;
try {
await handleBookDownload(book);
updateBook(envConfig, book);
available = true;
} finally {
return available;
}
}
return true;
};
const showBookDetailsModal = async (book: Book) => {
if (await makeBookAvailable(book)) {
setShowDetailsBook(book);
}
};
const dismissBookDetailsModal = () => {
setShowDetailsBook(null);
};
const { setLibrary } = useLibraryStore();
const allBookshelfItems = generateBookshelfItems(libraryBooks);
useEffect(() => {
setSelectedBooks([]);
if (isSelectMode) {
setShowSelectModeActions(true);
} else {
setSelectedBooks([]);
setShowSelectModeActions(false);
}
}, [isSelectMode]);
useEffect(() => {
@@ -132,24 +84,27 @@ const Bookshelf: React.FC<BookshelfProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importBookUrl, appService]);
const bookshelfItems = generateBookshelfItems(libraryBooks);
const handleBookClick = async (book: Book) => {
if (!(await makeBookAvailable(book))) return;
if (isSelectMode) {
toggleSelection(book.hash);
useEffect(() => {
const group = searchParams?.get('group') || '';
if (group) {
const booksGroup = allBookshelfItems.find(
(item) => 'name' in item && item.id === group,
) as BooksGroup;
if (booksGroup) {
setNavBooksGroup(booksGroup);
} else {
navigateToLibrary(router);
}
} else {
setClickedImage(book.hash);
setTimeout(() => setClickedImage(null), 300);
setTimeout(() => setLoading(true), 200);
navigateToReader(router, [book.hash]);
setNavBooksGroup(null);
navigateToLibrary(router);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, libraryBooks, showGroupingModal]);
const toggleSelection = (id: string) => {
setSelectedBooks((prev) =>
prev.includes(id) ? prev.filter((bookId) => bookId !== id) : [...prev, id],
prev.includes(id) ? prev.filter((selectedId) => selectedId !== id) : [...prev, id],
);
};
@@ -159,98 +114,62 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const confirmDelete = async () => {
for (const selectedBook of selectedBooks) {
const book = libraryBooks.find((b) => b.hash === selectedBook);
if (book) {
await handleBookDelete(book);
selectedBooks.forEach((id) => {
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
if (book && !book.deletedAt) {
handleBookDelete(book);
}
}
}
});
setSelectedBooks([]);
setShowDeleteAlert(false);
setShowSelectModeActions(true);
};
const deleteSelectedBooks = () => {
setShowSelectModeActions(false);
setShowDeleteAlert(true);
};
const bookContextMenuHandler = async (book: Book, e: React.MouseEvent) => {
if (!isTauriAppPlatform()) return;
e.preventDefault();
e.stopPropagation();
const osPlatform = getOSPlatform();
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
const openBookMenuItem = await MenuItem.new({
text: isSelectMode ? _('Select Book') : _('Open Book'),
action: async () => {
handleBookClick(book);
},
});
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
revealItemInDir(folder);
},
});
const showBookDetailsMenuItem = await MenuItem.new({
text: _('Show Book Details'),
action: async () => {
showBookDetailsModal(book);
},
});
const uploadBookMenuItem = await MenuItem.new({
text: _('Upload Book'),
action: async () => {
handleBookUpload(book);
},
});
const deleteBookMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
await handleBookDelete(book);
},
});
const menu = await Menu.new();
menu.append(openBookMenuItem);
menu.append(showBookDetailsMenuItem);
menu.append(showBookInFinderMenuItem);
menu.append(uploadBookMenuItem);
menu.append(deleteBookMenuItem);
menu.popup();
const groupSelectedBooks = () => {
setShowSelectModeActions(false);
setShowGroupingModal(true);
};
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
return (
<div className='bookshelf'>
<div className='grid grid-cols-3 gap-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8'>
{bookshelfItems.map((item, index) => (
<div
<div
className={clsx(
'transform-wrapper grid flex-1 gap-x-4 sm:gap-x-0',
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8',
)}
>
{currentBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
className='hover:bg-base-300/50 group flex h-full flex-col p-4'
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
clickedBookHash={clickedImage}
handleBookClick={handleBookClick}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
bookContextMenuHandler={bookContextMenuHandler}
transferProgress={booksTransferProgress[item.hash] || null}
/>
) : (
<GroupItem group={item} />
)}
</div>
</div>
item={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
transferProgress={
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
}
/>
))}
{bookshelfItems.length > 0 && (
{!navBooksGroup && allBookshelfItems.length > 0 && (
<div
className='border-1 bg-base-100 hover:bg-base-300/50 m-4 flex aspect-[28/41] items-center justify-center'
className={clsx(
'border-1 bg-base-100 hover:bg-base-300/50 flex items-center justify-center',
'mx-0 my-4 aspect-[28/41] sm:mx-4',
)}
role='button'
onClick={handleImportBooks}
>
@@ -258,42 +177,94 @@ const Bookshelf: React.FC<BookshelfProps> = ({
</div>
)}
</div>
{selectedBooks.length > 0 && (
<div
className={clsx(
'text-base-content bg-base-300 fixed bottom-4 left-1/2 flex',
'-translate-x-1/2 transform space-x-4 rounded-lg p-4 shadow-lg',
)}
>
<button onClick={openSelectedBooks} className='flex items-center space-x-2'>
<MdOpenInNew />
<span>{_('Open')}</span>
</button>
<button onClick={deleteSelectedBooks} className='flex items-center space-x-2'>
<MdDelete className='fill-red-500' />
<span className='text-red-500'>{_('Delete')}</span>
</button>
</div>
)}
{loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)}
{showDeleteAlert && (
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the selected books?')}
onClickCancel={() => setShowDeleteAlert(false)}
onClickConfirm={confirmDelete}
/>
<div className='fixed bottom-0 left-0 right-0 z-40 pb-[calc(env(safe-area-inset-bottom)+16px)]'>
{isSelectMode && showSelectModeActions && (
<div
className={clsx(
'flex items-center justify-center shadow-lg',
'text-base-content bg-base-300 text-sm',
'mx-auto w-fit space-x-6 rounded-lg p-4',
)}
>
<button
onClick={openSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
(!selectedBooks.length || !selectedBooks.every((id) => isMd5(id))) &&
'btn-disabled opacity-50',
)}
>
<MdOpenInNew />
<div>{_('Open')}</div>
</button>
<button
onClick={groupSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
!selectedBooks.length && 'btn-disabled opacity-50',
)}
>
<LuFolderPlus />
<div>{_('Add to Group')}</div>
</button>
<button
onClick={deleteSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
!selectedBooks.length && 'btn-disabled opacity-50',
)}
>
<MdDelete className='fill-red-500' />
<div className='text-red-500'>{_('Delete')}</div>
</button>
<button
onClick={() => handleSetSelectMode(false)}
className={clsx('flex flex-col items-center justify-center')}
>
<MdOutlineCancel />
<div>{_('Cancel')}</div>
</button>
</div>
)}
</div>
{showGroupingModal && (
<div>
<GroupingModal
libraryBooks={libraryBooks}
selectedBooks={selectedBooks}
onCancel={() => {
setShowGroupingModal(false);
setShowSelectModeActions(true);
}}
onConfirm={() => {
setShowGroupingModal(false);
handleSetSelectMode(false);
}}
/>
</div>
)}
{showDetailsBook && (
<BookDetailModal
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={dismissBookDetailsModal}
/>
{showDeleteAlert && (
<div
className={clsx(
'fixed bottom-0 left-0 right-0 z-50 flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
>
<Alert
title={_('Confirm Deletion')}
message={_('Are you sure to delete the selected books?')}
onCancel={() => {
setShowDeleteAlert(false);
setShowSelectModeActions(true);
}}
onConfirm={confirmDelete}
/>
</div>
)}
</div>
);
@@ -0,0 +1,278 @@
import clsx from 'clsx';
import { useRouter } from 'next/navigation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLongPress } from '@/hooks/useLongPress';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getOSPlatform } from '@/utils/misc';
import { getFilename } from '@/utils/book';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BookGroupType, BooksGroup } from '@/types/book';
import BookItem from './BookItem';
import GroupItem from './GroupItem';
export type BookshelfItem = Book | BooksGroup;
export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] => {
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
if (book.deletedAt) return acc;
book.groupId = book.groupId || BOOK_UNGROUPED_ID;
book.groupName = book.groupName || BOOK_UNGROUPED_NAME;
const groupIndex = acc.findIndex((group) => group.id === book.groupId);
const booksGroup = acc[acc.findIndex((group) => group.id === book.groupId)];
if (booksGroup) {
booksGroup.books.push(book);
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
} else {
acc.push({
id: book.groupId,
name: book.groupName,
books: [book],
updatedAt: book.updatedAt,
});
}
return acc;
}, []);
groups.forEach((group) => {
group.books.sort((a, b) => b.updatedAt - a.updatedAt);
});
const ungroupedBooks: Book[] =
groups.find((group) => group.name === BOOK_UNGROUPED_NAME)?.books || [];
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== BOOK_UNGROUPED_NAME);
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
};
export const generateGroupsList = (items: Book[]): BookGroupType[] => {
return items
.sort((a, b) => b.updatedAt - a.updatedAt)
.reduce((acc: BookGroupType[], item: Book) => {
if (item.deletedAt) return acc;
if (
item.groupId &&
item.groupName &&
item.groupId !== BOOK_UNGROUPED_ID &&
item.groupName !== BOOK_UNGROUPED_NAME &&
!acc.find((group) => group.id === item.groupId)
) {
acc.push({ id: item.groupId, name: item.groupName });
}
return acc;
}, []) as BookGroupType[];
};
interface BookshelfItemProps {
item: BookshelfItem;
isSelectMode: boolean;
selectedBooks: string[];
transferProgress: number | null;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDelete: (book: Book) => void;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
}
const BookshelfItem: React.FC<BookshelfItemProps> = ({
item,
isSelectMode,
selectedBooks,
transferProgress,
setLoading,
toggleSelection,
handleBookUpload,
handleBookDownload,
handleBookDelete,
handleSetSelectMode,
handleShowDetailsBook,
}) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
const showBookDetailsModal = async (book: Book) => {
if (await makeBookAvailable(book)) {
handleShowDetailsBook(book);
}
};
const makeBookAvailable = async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
let available = false;
const loadingTimeout = setTimeout(() => setLoading(true), 200);
try {
await handleBookDownload(book);
updateBook(envConfig, book);
available = true;
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
return available;
}
}
return true;
};
const handleBookClick = async (book: Book) => {
if (isSelectMode) {
toggleSelection(book.hash);
} else {
if (!(await makeBookAvailable(book))) return;
navigateToReader(router, [book.hash]);
}
};
const handleGroupClick = (group: BooksGroup) => {
if (isSelectMode) {
toggleSelection(group.id);
} else {
navigateToLibrary(router, `group=${group.id}`);
}
};
const bookContextMenuHandler = async (book: Book) => {
if (!appService?.hasContextMenu) return;
const osPlatform = getOSPlatform();
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
const selectBookMenuItem = await MenuItem.new({
text: selectedBooks.includes(book.hash) ? _('Deselect Book') : _('Select Book'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(book.hash);
},
});
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
revealItemInDir(folder);
},
});
const showBookDetailsMenuItem = await MenuItem.new({
text: _('Show Book Details'),
action: async () => {
showBookDetailsModal(book);
},
});
const downloadBookMenuItem = await MenuItem.new({
text: _('Download Book'),
action: async () => {
handleBookDownload(book);
},
});
const uploadBookMenuItem = await MenuItem.new({
text: _('Upload Book'),
action: async () => {
handleBookUpload(book);
},
});
const deleteBookMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
await handleBookDelete(book);
},
});
const menu = await Menu.new();
menu.append(selectBookMenuItem);
menu.append(showBookDetailsMenuItem);
menu.append(showBookInFinderMenuItem);
if (book.uploadedAt && !book.downloadedAt) {
menu.append(downloadBookMenuItem);
}
if (!book.uploadedAt && book.downloadedAt) {
menu.append(uploadBookMenuItem);
}
menu.append(deleteBookMenuItem);
menu.popup();
};
const groupContextMenuHandler = async (group: BooksGroup) => {
if (!appService?.hasContextMenu) return;
const selectGroupMenuItem = await MenuItem.new({
text: selectedBooks.includes(group.id) ? _('Deselect Group') : _('Select Group'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(group.id);
},
});
const deleteGroupMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
for (const book of group.books) {
await handleBookDelete(book);
}
},
});
const menu = await Menu.new();
menu.append(selectGroupMenuItem);
menu.append(deleteGroupMenuItem);
menu.popup();
};
const { pressing, handlers } = useLongPress({
onLongPress: () => {
if (!isSelectMode) {
handleSetSelectMode(true);
}
if ('format' in item) {
toggleSelection((item as Book).hash);
} else {
toggleSelection((item as BooksGroup).id);
}
},
onTap: () => {
if ('format' in item) {
handleBookClick(item as Book);
} else {
handleGroupClick(item as BooksGroup);
}
},
onContextMenu: () => {
if ('format' in item) {
bookContextMenuHandler(item as Book);
} else {
groupContextMenuHandler(item as BooksGroup);
}
},
});
return (
<div
className={clsx(
'sm:hover:bg-base-300/50 group flex h-full flex-col px-0 py-4 sm:px-4',
pressing ? 'scale-95' : 'scale-100',
)}
style={{
transition: 'transform 0.2s',
}}
{...handlers}
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
transferProgress={transferProgress}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
)}
</div>
</div>
);
};
export default BookshelfItem;
@@ -1,33 +1,50 @@
import Image from 'next/image';
import clsx from 'clsx';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { BooksGroup } from '@/types/book';
import ReadingProgress from './ReadingProgress';
import BookCover from './BookCover';
interface GroupItemProps {
group: BooksGroup;
isSelectMode: boolean;
selectedBooks: string[];
}
const GroupItem: React.FC<GroupItemProps> = ({ group }) => {
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, selectedBooks }) => {
const { appService } = useEnv();
return (
<div>
{group.books.map((book) => (
<div key={book.hash} className='card bg-base-100 w-full shadow-md'>
<figure>
<Image
width={10}
height={10}
src={book.coverImageUrl!}
alt={book.title || ''}
className='h-48 w-full object-cover'
/>
</figure>
<div className='card-body p-4'>
<h3 className='card-title line-clamp-2 text-sm'>{book.title}</h3>
<p className='text-neutral-content line-clamp-1 text-xs'>{book.author}</p>
<ReadingProgress book={book} />
</div>
<div
className={clsx(
'group-item flex h-full flex-col',
appService?.hasContextMenu ? 'cursor-pointer' : '',
)}
>
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden p-2 shadow-md'>
<div className='grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden'>
{group.books.slice(0, 4).map((book) => (
<div key={book.hash} className='relative aspect-[28/41] h-full w-full'>
<BookCover book={book} isPreview />
</div>
))}
</div>
))}
<h2 className='mb-2 text-lg font-bold'>{group.name}</h2>
{selectedBooks.includes(group.id) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(group.id) ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
)}
</div>
)}
</div>
<div className='min-w-0 flex-1 pt-2'>
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-xs font-semibold'>
{group.name}
</h4>
</div>
</div>
);
};
@@ -0,0 +1,247 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { MdCheck } from 'react-icons/md';
import { HiOutlineFolder, HiOutlineFolderAdd, HiOutlineFolderRemove } from 'react-icons/hi';
import { Book, BookGroupType } from '@/types/book';
import { isMd5, md5Fingerprint } from '@/utils/md5';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { generateGroupsList } from './BookshelfItem';
interface GroupingModalProps {
libraryBooks: Book[];
selectedBooks: string[];
onCancel: () => void;
onConfirm: () => void;
}
const GroupingModal: React.FC<GroupingModalProps> = ({
libraryBooks,
selectedBooks,
onCancel,
onConfirm,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { setLibrary } = useLibraryStore();
const groupsList = generateGroupsList(libraryBooks);
const [showInput, setShowInput] = useState(false);
const [editGroupName, setEditGroupName] = useState(_('Untitled Group'));
const [selectedGroup, setSelectedGroup] = useState<BookGroupType | null>(null);
const [newGroups, setNewGroups] = useState<BookGroupType[]>([]);
const [allGroups, setAllGroups] = useState<BookGroupType[]>(groupsList);
const editorRef = useRef<HTMLInputElement>(null);
const iconSize = useResponsiveSize(16);
const isSelectedBooksHasGroup =
selectedBooks.some((hash) => !isMd5(hash)) ||
selectedBooks
.map((hash) => libraryBooks.find((book) => book.hash === hash)?.groupId)
.some((group) => group && group !== BOOK_UNGROUPED_NAME);
const handleCreateGroup = () => {
setShowInput(true);
};
const handleRemoveFromGroup = () => {
selectedBooks.forEach((id) => {
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
if (
book &&
book.groupId &&
book.groupName &&
book.groupId !== BOOK_UNGROUPED_ID &&
book.groupName !== BOOK_UNGROUPED_NAME
) {
book.groupId = undefined;
book.groupName = undefined;
book.updatedAt = Date.now();
}
}
});
setLibrary(libraryBooks);
appService?.saveLibraryBooks(libraryBooks);
onConfirm();
};
const handleConfirmCreateGroup = () => {
const groupName = editGroupName.trim();
if (groupName) {
const newGroup = { id: md5Fingerprint(groupName), name: groupName };
const existingGroupIndex = newGroups.findIndex((group) => group.name === groupName);
if (existingGroupIndex > -1) {
newGroups.splice(existingGroupIndex, 1);
}
newGroups.push(newGroup);
setSelectedGroup(newGroup);
setNewGroups(newGroups);
for (const newGroup of newGroups) {
const existingGroupIndex = groupsList.findIndex((group) => group.id === newGroup.id);
if (existingGroupIndex > -1) {
groupsList.splice(existingGroupIndex, 1);
}
groupsList.unshift(newGroup);
}
setAllGroups(groupsList);
const untitledGroupPattern = new RegExp(`^${_('Untitled Group')}\\s*(\\d+)?$`);
const untitledGroupNumbers = groupsList
.map((group) => {
const match = group.name.match(untitledGroupPattern);
return match ? parseInt(match[1] || '0', 10) : null;
})
.filter((num) => num !== null);
const nextNumber =
untitledGroupNumbers.length > 0 ? Math.max(...untitledGroupNumbers) + 1 : 1;
setEditGroupName(`${_('Untitled Group')} ${nextNumber}`);
setShowInput(false);
}
};
const handleToggleSelectGroup = (group: BookGroupType) => {
setSelectedGroup((prevGroup) => (prevGroup?.id === group.id ? null : group));
};
const handleConfirmGrouping = () => {
selectedBooks.forEach((id) => {
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
if (book && selectedGroup) {
book.groupId = selectedGroup.id;
book.groupName = selectedGroup.name;
book.updatedAt = Date.now();
}
}
});
setLibrary(libraryBooks);
appService?.saveLibraryBooks(libraryBooks);
onConfirm();
};
useEffect(() => {
if (editorRef.current) {
editorRef.current.select();
}
}, [showInput]);
useEffect(() => {
const groupIds = selectedBooks
.map((id) => libraryBooks.find((book) => book.hash === id || book.groupId === id)?.groupId)
.filter((groupId) => groupId);
if (Array.from(new Set(groupIds)).length === 1) {
setSelectedGroup(groupsList.find((group) => group.id === groupIds[0]) || null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBooks]);
return (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50'>
<div
className={clsx(
'modal-box bg-base-100 overflow-y-auto rounded-2xl shadow-xl',
'max-h-[85%] w-[95%] min-w-64 max-w-[440px] p-6 sm:w-[70%]',
)}
>
<h2 className='text-center text-lg font-bold'>{_('Group Books')}</h2>
<div className={clsx('mt-4 grid grid-cols-1 gap-2 text-base md:grid-cols-2')}>
{isSelectedBooksHasGroup && (
<div
onClick={handleRemoveFromGroup}
role='button'
className='flex items-center space-x-2 p-2 text-blue-500'
>
<HiOutlineFolderRemove size={iconSize} />
<span>{_('Remove From Group')}</span>
</div>
)}
<div
onClick={handleCreateGroup}
role='button'
className='flex items-center space-x-2 p-2 text-blue-500'
>
<HiOutlineFolderAdd size={iconSize} />
<span>{_('Create New Group')}</span>
</div>
</div>
{showInput && (
<div className='mt-4 flex items-center gap-2'>
<input
type='text'
autoFocus
ref={editorRef}
value={editGroupName}
onChange={(e) => setEditGroupName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmCreateGroup();
e.stopPropagation();
}}
className='input input-ghost w-full border-0 px-2 text-base !outline-none sm:text-sm'
/>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={() => handleConfirmCreateGroup()}
>
<div className='pr-1 align-bottom text-base text-blue-500 sm:text-sm'>
{_('Save')}
</div>
</button>
</div>
)}
<ul className='groups-list mt-4 grid grid-cols-2 gap-2'>
{allGroups.map((group, index) => (
<button
key={index}
className={clsx(
'hover:bg-base-300 text-base-content flex w-full',
'items-center justify-between rounded-md px-2 py-2',
)}
onClick={() => handleToggleSelectGroup(group)}
>
<div className='flex min-w-0 items-center'>
<span style={{ minWidth: `${iconSize}px` }}>
<HiOutlineFolder size={iconSize} />
</span>
<span
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm')}
style={{ minWidth: 0 }}
>
{group.name}
</span>
</div>
<span className='text-neutral-content flex shrink-0 text-sm'>
{selectedGroup && selectedGroup.id == group.id && (
<MdCheck className='fill-blue-500' size={iconSize} />
)}
</span>
</button>
))}
</ul>
<div className='mt-6 flex justify-end gap-x-8 p-2'>
<button onClick={onCancel} className='flex items-center'>
{_('Cancel')}
</button>
<button
onClick={handleConfirmGrouping}
className={clsx(
'flex items-center text-blue-500',
!selectedGroup && 'btn-disabled opacity-50',
)}
>
{_('Confirm')}
</button>
</div>
</div>
</div>
);
};
export default GroupingModal;
@@ -1,13 +1,16 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
import { PiSelectionAllDuotone } from 'react-icons/pi';
import { MdOutlineMenu } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { navigateToLibrary } from '@/utils/nav';
import useTrafficLight from '@/hooks/useTrafficLight';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
@@ -26,10 +29,13 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onToggleSelectMode,
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { isTrafficLightVisible } = useTrafficLight();
const headerRef = useRef<HTMLDivElement>(null);
const iconSize16 = useResponsiveSize(16);
const iconSize20 = useResponsiveSize(20);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -43,30 +49,48 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
};
}, [onToggleSelectMode]);
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const isInGroupView = !!searchParams?.get('group');
return (
<div
ref={headerRef}
className={clsx(
'titlebar z-10 h-11 w-full py-2 pr-6',
isTrafficLightVisible ? 'pl-16' : 'pl-2',
'titlebar z-10 flex h-11 w-full items-center py-2 pr-4 sm:pr-6',
appService?.hasSafeAreaInset && 'mt-[env(safe-area-inset-top)]',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
>
<div className='flex items-center justify-between space-x-12'>
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
<span className='absolute left-8 text-gray-500'>
<FaSearch className='h-4 w-4' />
</span>
<input
type='text'
placeholder={_('Search Books...')}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
'font-sans text-sm font-light',
'border-none focus:outline-none focus:ring-0',
)}
/>
<div className='absolute right-4 flex items-center space-x-4 text-gray-500'>
{isInGroupView && (
<button
onClick={() => {
navigateToLibrary(router);
}}
className='ml-[-6px] mr-4 flex h-7 min-h-7 w-7 items-center p-0'
>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Go Back')}>
<MdArrowBackIosNew size={iconSize20} />
</div>
</button>
)}
<div className='relative flex h-7 w-full items-center'>
<span className='absolute left-3 text-gray-500'>
<FaSearch className='h-4 w-4' />
</span>
<input
type='text'
placeholder={_('Search Books...')}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
'font-sans text-sm font-light',
'border-none focus:outline-none focus:ring-0',
)}
/>
</div>
<div className='absolute right-4 flex items-center space-x-2 text-gray-500 sm:space-x-4'>
<span className='mx-2 h-6 w-[1px] bg-gray-400'></span>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center'
@@ -96,7 +120,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</button>
</div>
</div>
<div className='flex h-full items-center gap-x-4'>
<div className='flex h-full items-center gap-x-2 sm:gap-x-4'>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
@@ -104,12 +128,12 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
>
<SettingsMenu />
</Dropdown>
{!isTrafficLightVisible && appService?.appPlatform !== 'web' && (
{appService?.hasWindowBar && (
<WindowButtons
headerRef={headerRef}
showMinimize={true}
showMaximize={true}
showClose={true}
showMinimize={windowButtonVisible}
showMaximize={windowButtonVisible}
showClose={windowButtonVisible}
/>
)}
</div>
@@ -31,6 +31,7 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
const [isAutoCheckUpdates, setIsAutoCheckUpdates] = useState(settings.autoCheckUpdates);
const [isScreenWakeLock, setIsScreenWakeLock] = useState(settings.screenWakeLock);
const showAboutReadest = () => {
setAboutDialogVisible(true);
@@ -77,6 +78,13 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
setIsAutoCheckUpdates(settings.autoCheckUpdates);
};
const toggleScreenWakeLock = () => {
settings.screenWakeLock = !settings.screenWakeLock;
setSettings(settings);
saveSettings(envConfig, settings);
setIsScreenWakeLock(settings.screenWakeLock);
};
useEffect(() => {
if (!user || !token) return;
const storagPlan = getStoragePlanData(token);
@@ -146,6 +154,11 @@ const SettingsMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleAutoCheckUpdates}
/>
)}
<MenuItem
label={_('Keep Screen Awake')}
icon={isScreenWakeLock ? <MdCheck className='text-base-content' /> : undefined}
onClick={toggleScreenWakeLock}
/>
<hr className='border-base-200 my-1' />
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
<hr className='border-base-200 my-1' />
@@ -6,7 +6,12 @@ import { useLibraryStore } from '@/store/libraryStore';
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
import { Book } from '@/types/book';
export const useBooksSync = () => {
export interface UseBooksSyncProps {
onSyncStart?: () => void;
onSyncEnd?: () => void;
}
export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
const { user } = useAuth();
const { appService } = useEnv();
const { library, setLibrary } = useLibraryStore();
@@ -69,6 +74,8 @@ export const useBooksSync = () => {
const updateLibrary = async () => {
if (!syncedBooks?.length) return;
// Process old books first so that when we update the library the order is preserved
syncedBooks.sort((a, b) => a.updatedAt - b.updatedAt);
const processOldBook = async (oldBook: Book) => {
const matchingBook = syncedBooks.find((newBook) => newBook.hash === oldBook.hash);
@@ -91,16 +98,23 @@ export const useBooksSync = () => {
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);
updatedLibrary.push(newBook);
setLibrary(updatedLibrary);
} catch {
console.error('Failed to download book:', newBook);
}
}
}
};
await Promise.all(syncedBooks.map(processNewBook));
onSyncStart?.();
const batchSize = 3;
for (let i = 0; i < syncedBooks.length; i += batchSize) {
const batch = syncedBooks.slice(i, i + batchSize);
await Promise.all(batch.map(processNewBook));
}
onSyncEnd?.();
setLibrary(updatedLibrary);
appService?.saveLibraryBooks(updatedLibrary);
};
+63 -23
View File
@@ -2,14 +2,16 @@
import clsx from 'clsx';
import * as React from 'react';
import { useState, useRef, useEffect, Suspense } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { navigateToReader } from '@/utils/nav';
import { navigateToLogin, navigateToReader } from '@/utils/nav';
import { getBaseFilename, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
@@ -30,8 +32,8 @@ import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import BookDetailModal from '@/components/BookDetailModal';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
const LibraryPage = () => {
const router = useRouter();
@@ -51,15 +53,20 @@ const LibraryPage = () => {
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
const [isSelectMode, setIsSelectMode] = useState(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [booksTransferProgress, setBooksTransferProgress] = useState<{
[key: string]: number | null;
}>({});
const demoBooks = useDemoBooks();
const containerRef = useRef<HTMLDivElement>(null);
const { pullLibrary, pushLibrary } = useBooksSync();
const { pullLibrary, pushLibrary } = useBooksSync({
onSyncStart: () => setLoading(true),
onSyncEnd: () => setLoading(false),
});
usePullToRefresh(containerRef, pullLibrary);
useScreenWakeLock(settings.screenWakeLock);
useEffect(() => {
updateAppTheme('base-200');
@@ -244,7 +251,19 @@ const LibraryPage = () => {
title: book.title,
}),
});
} catch {
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
navigateToLogin(router);
return;
} else if (err.message.includes('Insufficient storage quota')) {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Insufficient storage quota'),
});
return;
}
}
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to upload book: {{title}}', {
@@ -304,8 +323,7 @@ const LibraryPage = () => {
let files;
if (isTauriAppPlatform()) {
const { type } = await import('@tauri-apps/plugin-os');
if (['android', 'ios'].includes(type())) {
if (appService?.isMobile) {
files = (await selectFilesWeb()) as [File];
} else {
files = (await selectFilesTauri()) as [string];
@@ -320,6 +338,14 @@ const LibraryPage = () => {
setIsSelectMode(!isSelectMode);
};
const handleSetSelectMode = (selectMode: boolean) => {
setIsSelectMode(selectMode);
};
const handleShowDetailsBook = (book: Book) => {
setShowDetailsBook(book);
};
if (!appService) {
return null;
}
@@ -337,8 +363,9 @@ const LibraryPage = () => {
return (
<div
className={clsx(
'library-page bg-base-200 text-base-content flex h-dvh select-none flex-col overflow-hidden',
isTauriAppPlatform() && 'rounded-window',
'library-page bg-base-200 text-base-content flex select-none flex-col overflow-hidden',
appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<div className='fixed top-0 z-40 w-full'>
@@ -354,19 +381,25 @@ const LibraryPage = () => {
</div>
)}
{libraryLoaded &&
(libraryBooks.length > 0 ? (
<div ref={containerRef} className='mt-12 flex-grow overflow-auto px-2'>
<Suspense>
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
handleImportBooks={handleImportBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
booksTransferProgress={booksTransferProgress}
/>
</Suspense>
(libraryBooks.some((book) => !book.deletedAt) ? (
<div
ref={containerRef}
className={clsx(
'mt-12 flex-grow overflow-auto px-4 sm:px-2',
appService?.hasSafeAreaInset && 'mt-[calc(48px+env(safe-area-inset-top))]',
)}
>
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
handleImportBooks={handleImportBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
booksTransferProgress={booksTransferProgress}
/>
</div>
) : (
<div className='hero h-screen items-center justify-center'>
@@ -385,6 +418,13 @@ const LibraryPage = () => {
</div>
</div>
))}
{showDetailsBook && (
<BookDetailModal
isOpen={!!showDetailsBook}
book={showDetailsBook}
onClose={() => setShowDetailsBook(null)}
/>
)}
<AboutWindow />
<Toast />
</div>
@@ -1,10 +1,11 @@
import clsx from 'clsx';
import React, { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { isTauriAppPlatform } from '@/services/environment';
import FoliateViewer from './FoliateViewer';
import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
@@ -23,6 +24,7 @@ interface BooksGridProps {
}
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { appService } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
@@ -39,7 +41,10 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
return (
<div
className='grid h-full flex-grow'
className={clsx(
'grid h-full flex-grow',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
)}
style={{
gridTemplateColumns: gridTemplate.columns,
gridTemplateRows: gridTemplate.rows,
@@ -61,7 +66,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<div
id={`gridcell-${bookKey}`}
key={bookKey}
className={`${isTauriAppPlatform() ? 'rounded-window' : ''} relative h-full w-full overflow-hidden`}
className={`${appService?.hasRoundedWindow ? 'rounded-window' : ''} relative h-full w-full overflow-hidden`}
>
{isBookmarked && <Ribbon width={marginGap} />}
<HeaderBar
@@ -7,8 +7,10 @@ import { useParallelViewStore } from '@/store/parallelViewStore';
import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar';
import { getStyles, mountAdditionalFonts } from '@/utils/style';
import { getBookDirFromWritingMode } from '@/utils/book';
import { useTheme } from '@/hooks/useTheme';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import {
@@ -42,6 +44,7 @@ const FoliateViewer: React.FC<{
}, [toastMessage]);
useProgressSync(bookKey);
useProgressAutoSave(bookKey);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -86,7 +89,8 @@ const FoliateViewer: React.FC<{
if (renderer) {
if (renderer.start <= 0) {
viewRef.current?.prev(1);
} else if (renderer.end >= renderer.viewSize) {
// sometimes viewSize has subpixel value that the end never reaches
} else if (renderer.end + 1 >= renderer.viewSize) {
viewRef.current?.next(1);
}
}
@@ -140,6 +144,11 @@ const FoliateViewer: React.FC<{
const viewSettings = getViewSettings(bookKey)!;
view.renderer.setStyles?.(getStyles(viewSettings, themeCode));
const writingMode = viewSettings.writingMode;
if (writingMode) {
view.book.dir = getBookDirFromWritingMode(writingMode);
}
const isScrolled = viewSettings.scrolled!;
const marginPx = viewSettings.marginPx!;
const gapPercent = viewSettings.gapPercent!;
@@ -4,11 +4,11 @@ import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { FaHeadphones } from 'react-icons/fa6';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { isPWA, isTauriAppPlatform } from '@/services/environment';
import Button from '@/components/Button';
interface FooterBarProps {
@@ -19,6 +19,7 @@ interface FooterBarProps {
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const view = getView(bookKey);
@@ -62,9 +63,9 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
className={clsx(
'footer-bar absolute bottom-0 z-10 flex h-12 w-full items-center gap-x-4 px-4',
'shadow-xs bg-base-100 transition-opacity duration-300',
isPWA() ? 'pb-[env(safe-area-inset-bottom)]' : '',
isTauriAppPlatform() && 'rounded-window-bottom-right',
!isSideBarVisible && isTauriAppPlatform() && 'rounded-window-bottom-left',
appService?.hasSafeAreaInset && 'pb-[env(safe-area-inset-bottom)]',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
hoveredBookKey === bookKey ? `opacity-100` : `opacity-0`,
)}
@@ -4,8 +4,9 @@ import { BookDoc } from '@/libs/document';
import { useReaderStore } from '@/store/readerStore';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { getFootnoteStyles, getStyles } from '@/utils/style';
import { getPopupPosition, getPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { FoliateView } from '@/types/view';
import { FootnoteHandler } from 'foliate-js/footnotes.js';
import Popup from '@/components/Popup';
@@ -48,7 +49,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const backgroundColor = getComputedStyle(popupContainer).backgroundColor;
popupTheme.bg = backgroundColor;
}
renderer.setStyles?.(getStyles(viewSettings, popupTheme));
const mainStyles = getStyles(viewSettings, popupTheme);
const footnoteStyles = getFootnoteStyles();
renderer.setStyles?.(`${mainStyles}\n${footnoteStyles}`);
};
const handleRender = (e: Event) => {
@@ -104,10 +107,43 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
setShowPopup(false);
};
const handleFootnotePopupEvent = (event: CustomEvent) => {
const { element, footnote } = event.detail;
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
if (!gridFrame) return;
const rect = gridFrame.getBoundingClientRect();
const viewSettings = getViewSettings(bookKey)!;
const triangPos = getPosition(element, rect, viewSettings.vertical);
const popupPos = getPopupPosition(
triangPos,
rect,
viewSettings.vertical ? popupHeight : popupWidth,
viewSettings.vertical ? popupWidth : popupHeight,
popupPadding,
);
if (footnoteRef.current) {
const elem = document.createElement('p');
elem.textContent = footnote;
elem.setAttribute('style', `padding: 16px; hanging-punctuation: allow-end last;`);
footnoteRef.current.replaceChildren(elem);
setShowPopup(true);
setTrianglePosition(triangPos);
setPopupPosition(popupPos);
}
};
useFoliateEvents(view, {
onLinkClick: docLinkHandler,
});
useEffect(() => {
eventDispatcher.on('footnote-popup', handleFootnotePopupEvent);
return () => {
eventDispatcher.off('footnote-popup', handleFootnotePopupEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (footnoteViewRef.current) {
footnoteRef.current?.replaceChildren(footnoteViewRef.current);
@@ -5,7 +5,6 @@ import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { isTauriAppPlatform } from '@/services/environment';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import useTrafficLight from '@/hooks/useTrafficLight';
import WindowButtons from '@/components/WindowButtons';
@@ -53,8 +52,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
`header-bar absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
isTrafficLightVisible && isTopLeft && !isSideBarVisible ? 'pl-16' : 'pl-4',
`shadow-xs bg-base-100 transition-opacity duration-300`,
isTauriAppPlatform() && 'rounded-window-top-right',
!isSideBarVisible && isTauriAppPlatform() && 'rounded-window-top-left',
appService?.hasRoundedWindow && 'rounded-window-top-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-top-left',
isHoveredAnim && 'hover-bar-anim',
hoveredBookKey === bookKey || isDropdownOpen ? `visible` : `opacity-0`,
isDropdownOpen && 'header-bar-pinned',
@@ -8,18 +8,19 @@ import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { isTauriAppPlatform } from '@/services/environment';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { library, setLibrary } = useLibraryStore();
const isInitiating = useRef(false);
const { updateAppTheme } = useTheme();
useScreenWakeLock(settings.screenWakeLock);
useEffect(() => {
updateAppTheme('base-100');
@@ -42,7 +43,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<div
className={clsx(
`reader-page bg-base-100 text-base-content select-none`,
isTauriAppPlatform() && 'rounded-window',
appService?.hasRoundedWindow && 'rounded-window',
)}
>
<Suspense>
@@ -1,5 +1,6 @@
'use client';
import clsx from 'clsx';
import * as React from 'react';
import { useState, useRef, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
@@ -31,7 +32,7 @@ import TTSControl from './tts/TTSControl';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { bookKeys, dismissBook, getNextBookKey } = useBooksManager();
const { sideBarBookKey, setSideBarBookKey } = useSidebarStore();
const { saveSettings } = useSettingsStore();
@@ -148,7 +149,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
setTimeout(() => setLoading(true), 300);
return (
loading && (
<div className={'hero hero-content h-dvh'}>
<div className={clsx('hero hero-content', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
<Spinner loading={true} />
</div>
)
@@ -156,7 +157,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}
return (
<div className='flex h-dvh'>
<div className={clsx('flex', appService?.isIOSApp ? 'h-[100vh]' : 'h-dvh')}>
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
<TTSControl />
@@ -5,7 +5,12 @@ import { BiMoon, BiSun } from 'react-icons/bi';
import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import {
MAX_ZOOM_LEVEL,
MIN_ZOOM_LEVEL,
ONE_COLUMN_MAX_INLINE_SIZE,
ZOOM_STEP,
} from '@/services/constants';
import MenuItem from '@/components/MenuItem';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
@@ -32,8 +37,8 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
const [isInvertedColors, setInvertedColors] = useState(viewSettings!.invert);
const [zoomLevel, setZoomLevel] = useState(viewSettings!.zoomLevel!);
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + 1, MAX_ZOOM_LEVEL));
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - 1, MIN_ZOOM_LEVEL));
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
const resetZoom = () => setZoomLevel(100);
const toggleScrolledMode = () => setScrolledMode(!isScrolledMode);
const toggleInvertedColors = () => setInvertedColors(!isInvertedColors);
@@ -104,7 +109,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
)}
onClick={resetZoom}
>
{100 - (100 - zoomLevel) * 10}%
{zoomLevel}%
</button>
<button
onClick={zoomIn}
@@ -31,7 +31,7 @@ import DeepLPopup from './DeepLPopup';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
@@ -49,6 +49,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const isShowingPopup = useRef(false);
const isTextSelected = useRef(false);
const isUpToShowPopup = useRef(false);
const isTouchstarted = useRef(false);
const [selection, setSelection] = useState<TextSelection | null>();
const [showAnnotPopup, setShowAnnotPopup] = useState(false);
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
@@ -94,14 +95,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}
setSelection({ key: bookKey, text: sel.toString(), range, index });
};
const handlePointerup = () => {
// Available on iOS and Desktop, fired when release the long press
// Note that on Android, pointerup event is fired for any tap but not for long press
const sel = doc.getSelection();
if (isValidSelection(sel)) {
makeSelection(sel, true);
}
};
const handleSelectionchange = () => {
// Available on iOS, Android and Desktop, fired when the selection is changed
// Ideally the popup only shows when the selection is done,
@@ -109,7 +102,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// we make the popup show when the selection is changed
const sel = doc.getSelection();
if (isValidSelection(sel)) {
if (osPlatform === 'android') {
if (osPlatform === 'android' && isTouchstarted.current) {
makeSelection(sel, false);
}
} else if (!isUpToShowPopup.current) {
@@ -120,19 +113,37 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowDeepLPopup(false);
}
};
const handlePointerup = () => {
// Available on iOS and Desktop, fired when release the long press
// Note that on Android, pointerup event is fired after an additional touch event
const sel = doc.getSelection();
if (isValidSelection(sel)) {
makeSelection(sel, true);
}
};
const handleTouchstart = () => {
// Available on iOS and Android for the initial touch event
isTouchstarted.current = true;
};
const handleTouchmove = () => {
// Available on iOS
// Available on iOS, on Android not fired
// To make the popup not to follow the selection
setShowAnnotPopup(false);
};
const handleTouchend = () => {
// Available on iOS, on Android fired after an additional touch event
isTouchstarted.current = false;
};
if (bookData.book?.format !== 'PDF') {
detail.doc?.addEventListener('pointerup', handlePointerup);
detail.doc?.addEventListener('touchstart', handleTouchstart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchend);
detail.doc?.addEventListener('selectionchange', handleSelectionchange);
// Disable the default context menu on mobile devices,
// although it should but doesn't work on iOS
if (osPlatform === 'android' || osPlatform === 'ios') {
if (appService?.isMobile) {
detail.doc?.addEventListener('contextmenu', (event: Event) => {
event.preventDefault();
event.stopPropagation();
@@ -3,7 +3,9 @@ import React, { useEffect, useRef } from 'react';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { TextSelection } from '@/utils/sel';
import { md5Fingerprint } from '@/utils/md5';
import { BookNote } from '@/types/book';
import useShortcuts from '@/hooks/useShortcuts';
interface NoteEditorProps {
onSave: (selection: TextSelection, note: string) => void;
@@ -12,7 +14,14 @@ interface NoteEditorProps {
const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
const _ = useTranslation();
const { notebookNewAnnotation, notebookEditAnnotation } = useNotebookStore();
const {
notebookNewAnnotation,
notebookEditAnnotation,
setNotebookNewAnnotation,
setNotebookEditAnnotation,
saveNotebookAnnotationDraft,
getNotebookAnnotationDraft,
} = useNotebookStore();
const editorRef = useRef<HTMLTextAreaElement>(null);
const [note, setNote] = React.useState('');
@@ -20,13 +29,30 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
if (editorRef.current) {
editorRef.current.focus();
}
}, []);
}, [editorRef]);
useEffect(() => {
if (notebookEditAnnotation) {
setNote(notebookEditAnnotation.note);
if (editorRef.current) {
editorRef.current.value = notebookEditAnnotation.note;
editorRef.current.focus();
adjustHeight();
}
} else if (notebookNewAnnotation) {
const noteText = getAnnotationText();
if (noteText) {
const draftNote = getNotebookAnnotationDraft(md5Fingerprint(noteText)) || '';
setNote(draftNote);
if (editorRef.current) {
editorRef.current.value = draftNote;
editorRef.current.focus();
adjustHeight();
}
}
}
}, [notebookEditAnnotation]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notebookNewAnnotation, notebookEditAnnotation]);
const adjustHeight = () => {
if (editorRef.current) {
@@ -35,11 +61,24 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
}
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const getAnnotationText = () => {
return notebookEditAnnotation?.text || notebookNewAnnotation?.text || '';
};
const handleOnChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
adjustHeight();
setNote(e.currentTarget.value);
};
const handleOnBlur = () => {
if (editorRef.current && editorRef.current.value) {
const noteText = getAnnotationText();
if (noteText) {
saveNotebookAnnotationDraft(md5Fingerprint(noteText), editorRef.current.value);
}
}
};
const handleSaveNote = () => {
if (editorRef.current && notebookNewAnnotation) {
onSave(notebookNewAnnotation, editorRef.current.value);
@@ -49,41 +88,60 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
}
};
useShortcuts({
onSaveNote: () => {
if (editorRef.current && editorRef.current.value) {
handleSaveNote();
}
},
onCloseNote: () => {
if (notebookNewAnnotation) {
setNotebookNewAnnotation(null);
}
if (notebookEditAnnotation) {
setNotebookEditAnnotation(null);
}
},
});
return (
<div className='note-editor-container bg-base-100 mt-2 rounded-md p-2'>
<div className='content note-editor-container bg-base-100 mt-2 rounded-md p-2'>
<div className='flex w-full justify-between space-x-2'>
<div className='settings-content relative w-full'>
<div className='relative w-full'>
<textarea
className={clsx(
'note-editor textarea textarea-ghost min-h-[1em] resize-none !outline-none',
'inset-0 w-full rounded-none border-0 bg-transparent p-0 leading-normal',
'text-sm',
'inset-0 w-full rounded-none border-0 bg-transparent p-0',
'content font-size-sm',
)}
ref={editorRef}
value={note}
rows={1}
spellCheck={false}
onChange={handleChange}
onChange={handleOnChange}
onBlur={handleOnBlur}
placeholder={_('Add your notes here...')}
></textarea>
</div>
</div>
<div className='flex items-start pt-2'>
<div className='mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
<div className='content font-size-sm line-clamp-3 py-2'>
<span className='content font-size-xs inline text-gray-500'>{getAnnotationText()}</span>
</div>
</div>
<div className='flex justify-end p-2'>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'content btn btn-ghost font-size-sm hover:bg-transparent',
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={handleSaveNote}
>
<div className='pr-1 align-bottom text-xs text-blue-400'>{_('Save')}</div>
<div className='font-size-sm pr-1 align-bottom text-blue-500'>{_('Save')}</div>
</button>
</div>
<div className='flex items-start pt-2'>
<div className='mr-2 min-h-full self-stretch border-l-2 border-gray-300'></div>
<div className='note-citation settings-content line-clamp-3 text-sm'>
{notebookNewAnnotation?.text || notebookEditAnnotation?.text}
</div>
</div>
</div>
);
};
@@ -13,7 +13,6 @@ import { TextSelection } from '@/utils/sel';
import { BookNote } from '@/types/book';
import { uniqueId } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import useDragBar from '../../hooks/useDragBar';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
@@ -25,7 +24,7 @@ const MAX_NOTEBOOK_WIDTH = 0.45;
const Notebook: React.FC = ({}) => {
const _ = useTranslation();
const { updateAppTheme } = useTheme();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { notebookWidth, isNotebookVisible, isNotebookPinned } = useNotebookStore();
@@ -78,6 +77,8 @@ const Notebook: React.FC = ({}) => {
event.preventDefault();
event.stopPropagation();
setNotebookVisible(false);
setNotebookNewAnnotation(null);
setNotebookEditAnnotation(null);
};
const handleDragMove = (e: MouseEvent) => {
@@ -151,8 +152,11 @@ const Notebook: React.FC = ({}) => {
)}
<div
className={clsx(
'notebook-container bg-base-200 right-0 z-20 h-full min-w-60 select-none',
isTauriAppPlatform() && 'rounded-window-top-right rounded-window-bottom-right',
'notebook-container bg-base-200 right-0 z-20 min-w-60 select-none',
'font-sans text-base font-normal sm:text-sm',
appService?.isIOSApp ? 'h-[100vh]' : 'h-full',
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)]',
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
!isNotebookPinned && 'shadow-2xl',
)}
style={{
@@ -179,7 +183,11 @@ const Notebook: React.FC = ({}) => {
handleTogglePin={handleTogglePin}
/>
<div className='max-h-[calc(100vh-44px)] overflow-y-auto px-3'>
<div>{excerptNotes.length > 0 && <p className='pt-1 text-sm'>{_('Excerpts')}</p>}</div>
<div>
{excerptNotes.length > 0 && (
<p className='content font-size-base pt-1'>{_('Excerpts')}</p>
)}
</div>
<ul className=''>
{excerptNotes.map((item, index) => (
<li key={`${index}-${item.id}`} className='my-2'>
@@ -188,7 +196,7 @@ const Notebook: React.FC = ({}) => {
className='collapse-arrow border-base-300 bg-base-100 collapse border'
>
<div
className='collapse-title h-9 min-h-9 p-2 pr-8 text-sm font-medium'
className='collapse-title font-size-sm h-9 min-h-9 p-2 pr-8 font-medium'
style={
{
'--top-override': '1.2rem',
@@ -198,11 +206,11 @@ const Notebook: React.FC = ({}) => {
>
<p className='line-clamp-1'>{item.text || `Excerpt ${index + 1}`}</p>
</div>
<div className='collapse-content select-text px-3 pb-0 text-xs'>
<div className='collapse-content font-size-xs select-text px-3 pb-0'>
<p className='hyphens-auto text-justify'>{item.text}</p>
<div className='flex justify-end'>
<div
className='cursor-pointer align-bottom text-xs text-red-400'
className='font-size-xs cursor-pointer align-bottom text-red-500 hover:text-red-600'
onClick={handleEditNote.bind(null, item, true)}
>
{_('Delete')}
@@ -215,13 +223,13 @@ const Notebook: React.FC = ({}) => {
</ul>
<div>
{(notebookNewAnnotation || annotationNotes.length > 0) && (
<p className='pt-1 text-sm'>{_('Notes')}</p>
<p className='content font-size-base pt-1'>{_('Notes')}</p>
)}
</div>
{(notebookNewAnnotation || notebookEditAnnotation) && (
<NoteEditor onSave={handleSaveNote} onEdit={(item) => handleEditNote(item, false)} />
)}
<ul className=''>
<ul>
{annotationNotes.map((item, index) => (
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={sideBarBookKey} item={item} />
))}
@@ -21,7 +21,7 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
return (
<div
tabIndex={0}
className='dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 w-44 border shadow-2xl'
className='dropdown-content dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl'
>
<button
className='hover:bg-base-200 text-base-content flex w-full items-center justify-between rounded-md p-2'
@@ -37,7 +37,7 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
isFontLayoutSettingsGlobal ? _('Apply to All Books') : _('Apply to This Book')
}
>
<span className='ml-2'>{_('Global Settings')}</span>
<span className='ml-2 whitespace-nowrap'>{_('Global Settings')}</span>
</div>
</div>
</button>
@@ -8,8 +8,8 @@ import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize
interface DropdownProps {
family?: string;
selected: string;
options: string[];
moreOptions?: string[];
options: { option: string; label?: string }[];
moreOptions?: { option: string; label?: string }[];
onSelect: (option: string) => void;
onGetFontFamily: (option: string, family: string) => string;
}
@@ -25,26 +25,32 @@ const FontDropdown: React.FC<DropdownProps> = ({
const _ = useTranslation();
const iconSize16 = useResponsiveSize(16);
const defaultIconSize = useDefaultIconSize();
const allOptions = [...options, ...(moreOptions ?? [])];
const selectedOption = allOptions.find((option) => option.option === selected) ?? allOptions[0]!;
return (
<div className='dropdown dropdown-top'>
<button
tabIndex={0}
className='btn btn-sm flex items-center gap-1 px-[20px] font-normal normal-case'
>
<span style={{ fontFamily: onGetFontFamily(selected, family ?? '') }}>{selected}</span>
<span style={{ fontFamily: onGetFontFamily(selectedOption.option, family ?? '') }}>
{selectedOption.label}
</span>
<FiChevronUp size={iconSize16} />
</button>
<ul
tabIndex={0}
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) => (
{options.map(({ option, label }) => (
<li key={option} onClick={() => onSelect(option)}>
<div className='flex items-center px-0'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{selected === option && <MdCheck className='text-base-content' />}
</span>
<span style={{ fontFamily: onGetFontFamily(option, family ?? '') }}>{option}</span>
<span style={{ fontFamily: onGetFontFamily(option, family ?? '') }}>
{label || option}
</span>
</div>
</li>
))}
@@ -63,14 +69,14 @@ const FontDropdown: React.FC<DropdownProps> = ({
'!mr-5 mb-[-46px] inline max-h-80 w-[200px] overflow-y-scroll',
)}
>
{moreOptions.map((option) => (
<li key={option} onClick={() => onSelect(option)}>
{moreOptions.map((option, index) => (
<li key={`${index}-${option.option}`} onClick={() => onSelect(option.option)}>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{selected === option && <MdCheck className='text-base-content' />}
{selected === option.option && <MdCheck className='text-base-content' />}
</span>
<span style={{ fontFamily: onGetFontFamily(option, family ?? '') }}>
{option}
<span style={{ fontFamily: onGetFontFamily(option.option, family ?? '') }}>
{option.label || option.option}
</span>
</div>
</li>
@@ -19,6 +19,8 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { getOSPlatform } from '@/utils/misc';
import { FONT_ENUM_SUPPORTED_OS_PLATFORMS, getSysFontsList } from '@/utils/font';
import { isTauriAppPlatform } from '@/services/environment';
interface FontFaceProps {
className?: string;
@@ -30,8 +32,6 @@ interface FontFaceProps {
onSelect: (option: string) => void;
}
const fontFamilyOptions = ['Serif', 'Sans-serif'];
const handleFontFaceFont = (option: string, family: string) => {
return `'${option}', ${family}`;
};
@@ -49,8 +49,8 @@ const FontFace = ({
<span className=''>{label}</span>
<FontDropdown
family={family}
options={options}
moreOptions={moreOptions}
options={options.map((option) => ({ option, label: option }))}
moreOptions={moreOptions?.map((option) => ({ option, label: option })) ?? []}
selected={selected}
onSelect={onSelect}
onGetFontFamily={handleFontFaceFont}
@@ -66,27 +66,39 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
const fontFamilyOptions = [
{
option: 'Serif',
label: _('Serif Font'),
},
{
option: 'Sans-serif',
label: _('Sans-Serif Font'),
},
];
const osPlatform = getOSPlatform();
let moreFonts: string[] = [];
let defaultSysFonts: string[] = [];
switch (osPlatform) {
case 'macos':
moreFonts = MACOS_FONTS;
defaultSysFonts = MACOS_FONTS;
break;
case 'windows':
moreFonts = WINDOWS_FONTS;
defaultSysFonts = WINDOWS_FONTS;
break;
case 'linux':
moreFonts = LINUX_FONTS;
defaultSysFonts = LINUX_FONTS;
break;
case 'ios':
moreFonts = IOS_FONTS;
defaultSysFonts = IOS_FONTS;
break;
case 'android':
moreFonts = ANDROID_FONTS;
defaultSysFonts = ANDROID_FONTS;
break;
default:
break;
}
const [sysFonts, setSysFonts] = useState<string[]>(defaultSysFonts);
const [defaultFontSize, setDefaultFontSize] = useState(viewSettings.defaultFontSize!);
const [minFontSize, setMinFontSize] = useState(viewSettings.minimumFontSize!);
const [overrideFont, setOverrideFont] = useState(viewSettings.overrideFont!);
@@ -94,6 +106,16 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [serifFont, setSerifFont] = useState(viewSettings.serifFont!);
const [sansSerifFont, setSansSerifFont] = useState(viewSettings.sansSerifFont!);
const [monospaceFont, setMonospaceFont] = useState(viewSettings.monospaceFont!);
const [fontWeight, setFontWeight] = useState(viewSettings.fontWeight!);
useEffect(() => {
if (isTauriAppPlatform() && FONT_ENUM_SUPPORTED_OS_PLATFORMS.includes(osPlatform)) {
getSysFontsList().then((fonts) => {
setSysFonts(fonts);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
viewSettings.defaultFont = defaultFont;
@@ -128,6 +150,17 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minFontSize]);
useEffect(() => {
viewSettings.fontWeight = fontWeight;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.fontWeight = fontWeight;
setSettings(settings);
}
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fontWeight]);
useEffect(() => {
viewSettings.serifFont = serifFont;
setViewSettings(bookKey, viewSettings);
@@ -211,6 +244,23 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Font Weight')}</h2>
<div className='card border-base-200 border shadow'>
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label={_('Font Weight')}
value={fontWeight}
onChange={setFontWeight}
min={100}
max={900}
step={100}
/>
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Font Family')}</h2>
<div className='card border-base-200 border shadow'>
@@ -247,7 +297,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
family='serif'
label={_('Serif Font')}
options={SERIF_FONTS}
moreOptions={moreFonts}
moreOptions={sysFonts}
selected={serifFont}
onSelect={setSerifFont}
/>
@@ -255,7 +305,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
family='sans-serif'
label={_('Sans-Serif Font')}
options={SANS_SERIF_FONTS}
moreOptions={moreFonts}
moreOptions={sysFonts}
selected={sansSerifFont}
onSelect={setSansSerifFont}
/>
@@ -264,7 +314,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
family='monospace'
label={_('Monospace Font')}
options={MONOSPACE_FONTS}
moreOptions={moreFonts}
moreOptions={sysFonts}
selected={monospaceFont}
onSelect={setMonospaceFont}
/>

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