Compare commits

..

60 Commits

Author SHA1 Message Date
Huang Xin 443809f5af Release version 0.9.6 (#191) 2025-01-18 18:44:16 +01:00
Huang Xin c009edca61 Fix translation API (#189)
* Fix translation API

* Add missing translations
2025-01-17 19:10:27 +01:00
Huang Xin 58c907c7cc Various enhancements for mobile platforms (#188)
* Larger area to toggle header/footer bar

* Don't show rounded window on web platform

* Disable tooltips on mobile platforms as there is no mouse pointer

* Dedupe voices from web speech API
2025-01-17 15:42:16 +01:00
Huang Xin 5ca173818b Fix corner case of sync progress failure (#187) 2025-01-17 13:45:29 +01:00
Huang Xin f64b5606f1 Hide book title in header bar for small screens (#185) 2025-01-17 00:28:06 +01:00
Huang Xin 010568eeb1 Responsive layout for sidebar and settings dialog on mobile devices (#184) 2025-01-17 00:17:51 +01:00
Huang Xin d24f737428 Style tweaks: slightly lighter base-300 colors and padding around tab icons in sidebar (#183) 2025-01-16 19:07:23 +01:00
Huang Xin 1635958ae3 More precise translations for some options and add Greek translations (#182)
* More precise translations for some options, closes #165

* Add Greek translation, closes #181
2025-01-16 15:18:14 +01:00
Huang Xin c8f0c09426 Use dynamic viewport unit dvh to avoid page covered by toolbar and address bar in mobile browsers (#160) 2025-01-15 19:11:05 +01:00
Huang Xin 60a9eb5b57 Release version 0.9.5 (#159) 2025-01-15 17:55:50 +01:00
Egan Gumiwang Pratama Bisma 46fdea3522 feat: provide reading progress indicator for each books in the bookshelf (#153)
* feat: provide reading progress indicator for each books in the bookshelf

* refactor: simplify bookshelf component and remove unused hook; enhance reading progress calculation

- Removed the `useBookConfigLoader` hook as it was no longer needed.
- Simplified the bookshelf items generation by eliminating the use of `useMemo`.
- Improved the reading progress calculation in the `ReadingProgress` component to directly use the book's progress data.
- Updated the `Book` type to include a `progress` field for better tracking of reading status.

* fix formatting for readerStore
2025-01-15 17:17:07 +01:00
Huang Xin ba82203629 Set webpage title to book title (#155) 2025-01-14 17:02:23 +01:00
Huang Xin 8dbcf25331 Toggle TTS stop when it's already running (#154) 2025-01-14 15:32:47 +01:00
Huang Xin 7e9ddcd7cc Preload the next two paragraphs in advance for TTS playback (#151) 2025-01-14 01:45:54 +01:00
Huang Xin 211fc2cd22 Add more system fonts (#150) 2025-01-13 23:27:52 +01:00
Huang Xin 3578d7eba8 Handle publisher with language map in book metadata (#149) 2025-01-13 22:53:58 +01:00
Huang Xin d220adbfdf TTS UX enhancements (#148)
* Fix toast of PDF unsupported for TTS

* Use vertical scroll for large dropdown menus
2025-01-13 20:04:04 +01:00
Huang Xin 752f4449b4 Add fallback fonts for GB18030-2022 L3 charsets (#147)
* Add fallback fonts for GB18030-2022 L3 charsets

* Update README to credit fonts used in this application
2025-01-13 19:14:43 +01:00
Huang Xin 66f369b269 Fix multiple voices when setting rate and voices in Firefox (#146) 2025-01-13 00:19:49 +01:00
Huang Xin 2251d65de4 Release hotfix of occasionally speak not stop when setting rate and voice 2025-01-11 19:08:23 +01:00
Huang Xin 463ea39467 Fix occasionally current speak not stopped when changing voice or rate (#144)
* Fix occasionally current speak not stopped when changing voice or rate

* Inherit tts rate when changing tts engine
2025-01-11 18:11:14 +01:00
Huang Xin 87bc842525 Set default sidebar width to 15% of screen width (#143) 2025-01-11 11:34:43 +01:00
Huang Xin 2ad67a0503 Init TTSController async when showing TTS indicator and add more voices (#142) 2025-01-11 11:15:21 +01:00
Huang Xin 9f894e1d9e Use default voice in Edge TTS (#141) 2025-01-10 15:26:01 +01:00
Huang Xin cc97a1b36f Update README (#140) 2025-01-10 14:36:37 +01:00
Huang Xin 4e42ec4a44 Release 0.9.2 (#139) 2025-01-10 14:13:56 +01:00
Huang Xin 00003a9415 Preloading audio for Edge TTS and other fixes (#138)
* Blacklist some low-quality web tts voices

* Dropdown layout tweaks

* Handle no audio data received in Edge TTS

* Preloading audio for Edge TTS
2025-01-10 14:11:09 +01:00
Huang Xin 7402141237 Add Edge TTS backend, selecting different voices will change TTS backend dynamically (#137) 2025-01-10 02:35:23 +01:00
Huang Xin e0323bb0ed Fix access token sometimes invalid in browser context (#136)
* Update dependencies

* Fix access token sometimes invalid in browser context
2025-01-09 17:58:55 +01:00
Egan Gumiwang Pratama Bisma 112c5fa979 enforce numeric inputs on NumberInput and prevent NaN text (#135) 2025-01-09 12:51:06 +01:00
Egan Gumiwang Pratama Bisma 86dc32dab3 persist max block size setting in LayoutPanel component (#134)
https://github.com/readest/readest/issues/131
2025-01-09 12:23:11 +01:00
Huajin 33d4dcd740 Better animation for sidebar transitions (#133)
* Add transition for BooknoteItem

* Add Tab switch animations and tooltip 'Toc' translations.

* Use 'Table of Contents' instead of 'Toc' as key.
2025-01-09 12:16:07 +01:00
Huang Xin 36d6074468 Filter voices list and shutdown TTS when closing book (#130) 2025-01-09 03:33:52 +01:00
Huang Xin cc1396876e Add select voice button in the TTS panel (#129) 2025-01-09 01:34:30 +01:00
Huang Xin f26de3ab0d Add speech speed slider in the TTS panel (#128) 2025-01-08 21:31:12 +01:00
Huang Xin 814a0032d0 Add 'Speak' button in the footbar (#127) 2025-01-08 19:44:04 +01:00
Huang Xin c6defb97ee Fix TTS might stop at the end of the section (#126) 2025-01-08 17:50:36 +01:00
Huang Xin ddee347eb3 Fix text color in misc panel and adjust order of annotator tools (#125) 2025-01-08 16:50:57 +01:00
Huang Xin 83b10a1df8 Fix color of return button in auth page in dark mode (#124) 2025-01-08 15:44:14 +01:00
Huang Xin 3e43905321 Fix TTS paused after forward/backward (#123) 2025-01-08 15:18:58 +01:00
Huang Xin 89b2eba2af Refactor Toast to global component (#122)
* Refactor Toast to global component, use eventDispatcher to dispatch toast events

* Error handling for TTS
2025-01-08 14:12:38 +01:00
Huang Xin 15d8d61841 Support more than 2 columns in page layout, closes #119 (#121) 2025-01-07 22:32:07 +01:00
Huang Xin 07b04b82e6 Initial implementation of TTS with Web Speech API (#120)
* Initial implementation of TTS with Web Speech API

* Add translations for annotator tools
2025-01-07 21:57:55 +01:00
Huang Xin a59a3b633b Fix progress overridden by newly opened book (#117) 2025-01-06 19:19:02 +01:00
Huang Xin cccd4c2b16 Various fixes (#116)
* Fix missing translations in translator popup

* Disable annotator for PDFs for now, closes #115

* Fix triangle in shadow of popup making it darker
2025-01-06 01:33:35 +01:00
Huang Xin 990f61fb72 Fix layout issues on Windows (#113) 2025-01-05 20:11:23 +01:00
Huang Xin d51f5a632a Regenerate tauri signing key to solve InvalidSignature error when updating 2025-01-05 18:07:49 +01:00
Huang Xin 3e9b89de0e Swap font when downloading online fonts, closes #104 (#111) 2025-01-05 17:26:36 +01:00
Huang Xin f900608d6a Update report-bug template to collect OS and version info 2025-01-05 16:23:48 +01:00
Huang Xin ca56c5a73d Fix no rounded window in reader page on Linux, closes #107 (#110) 2025-01-05 15:50:12 +01:00
Huang Xin 57b72d6d57 Release exe portable files directly to workaround the stupid virus warning on Windows 2025-01-05 14:19:18 +01:00
Huang Xin 76c5f58547 Restore window position and size, closes #7 (#109) 2025-01-05 14:08:56 +01:00
Huang Xin e4a6ae01e0 Release portable binaries for Windows (#102) 2025-01-04 19:46:57 +01:00
Huang Xin 2763c18d6f Fix dropdown close overlay not working on Windows/Linux (#101) 2025-01-04 17:13:53 +01:00
Huang Xin 71d0735917 Various fixes (#99)
* Add shortcut to page turning with the space key on keyboard

* Properly handle auto refresh token with supabase

* Fix settings not stored in direct quit of app, closes #94
2025-01-04 14:18:57 +01:00
Huang Xin 3484c893df Fix deeplink on Windows (#95) 2025-01-04 11:20:31 +01:00
Huang Xin e885d22631 Add deeplink to parse oauth callback url in tauri apps (#92) 2025-01-03 23:24:30 +01:00
Huang Xin 3ad26d9d8f Add vertical/horizontal switch in Layout configs for CJK books (#91) 2025-01-03 21:24:15 +01:00
Huang Xin b699fc98c2 Fix title layout of book details modal and apply of custom css deletion (#90)
* Fix custom css cannot apply after deletion

* Fix title layout of book details modal, closes #89
2025-01-03 19:12:25 +01:00
Huang Xin 2c9fe8e4f4 Better Custom CSS editor (#88) 2025-01-03 14:52:31 +01:00
110 changed files with 4112 additions and 736 deletions
+9 -3
View File
@@ -1,7 +1,7 @@
---
name: Report a bug
about: Report a bug or a functional regression
title: 'Ex: In DarkMode, a blank square appears in bottom right corner while scrolling'
title: 'Example: In DarkMode, a blank square appears in bottom right corner while scrolling'
labels: ['type: bug']
assignees: ''
---
@@ -30,9 +30,15 @@ The blank square should be transparent (invisible)
## Technical inputs
Operating System:
Readest Version:
**Example:**
```
- We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
- Probably fixable with CSS
Operating System: macOS 14.3.1
Readest Version: 0.9.0
We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
Probably fixable with CSS
```
+34 -1
View File
@@ -12,7 +12,9 @@ jobs:
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.get-release.outputs.release_id }}
release_tag: ${{ steps.get-release.outputs.release_tag }}
release_note: ${{ steps.get-release-notes.outputs.release_note }}
release_version: ${{ steps.get-release-notes.outputs.release_version }}
steps:
- uses: actions/checkout@v4
@@ -30,6 +32,7 @@ jobs:
repo: context.repo.repo,
})
core.setOutput('release_id', data.id);
core.setOutput('release_tag', data.tag_name);
- name: get release notes
id: get-release-notes
uses: actions/github-script@v7
@@ -42,6 +45,7 @@ jobs:
const notes = releaseNotes.notes || [];
const releaseNote = notes.map((note, index) => `${index + 1}. ${note}`).join(' ');
console.log('Formatted release note:', releaseNote);
core.setOutput('release_version', version);
core.setOutput('release_note', releaseNote);
build-tauri:
@@ -138,6 +142,34 @@ jobs:
releaseBody: ${{ needs.get-release.outputs.release_note }}
args: ${{ matrix.config.args || '' }}
- name: upload portable binaries (Windows only)
if: matrix.config.os == 'windows-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
echo "Uploading Portable Binaries"
arch=${{ matrix.config.arch }}
version=${{ needs.get-release.outputs.release_version }}
if [ "$arch" = "x86_64" ]; then
bin_file="Readest_${version}_x64-portable.exe"
elif [ "$arch" = "aarch64" ]; then
bin_file="Readest_${version}_arm64-portable.exe"
else
echo "Unknown architecture: $arch"
exit 1
fi
exe_file="apps/readest-app/src-tauri/target/${{ matrix.config.rust_target }}/release/readest.exe"
# Browsers on Windows won't download zip files that contain exe files
# so upload the exe files instead. This is totally stupid.
# powershell.exe -Command "Compress-Archive -Path $exe_file -DestinationPath $bin_file -Force"
cp $exe_file $bin_file
echo "Uploading $bin_file to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
update-release:
permissions:
contents: write
@@ -153,11 +185,12 @@ jobs:
release_note: ${{ needs.get-release.outputs.release_note }}
with:
script: |
const body = `## Release Highlight\n${process.env.release_note}`;
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
body: process.env.release_note,
body: body,
draft: false,
prerelease: false
})
+6 -5
View File
@@ -33,7 +33,7 @@ To get started with Readest, follow these steps to clone and build the project.
#### 1. Clone the Repository
```bash
git clone https://github.com/chrox/readest.git
git clone https://github.com/readest/readest.git
cd readest
git submodule update --init --recursive
```
@@ -77,10 +77,11 @@ Now you're all setup and can start implementing your changes.
This project is a monorepo. The code for the `readest-app` is in the `app/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
| Command | Description |
| ---------------- | -------------------------------------------------- |
| `pnpm dev-web` | Starts the development server for the web app only |
| `pnpm build-web` | Builds the web app |
| Command | Description |
| ------------------------------------------ | -------------------------------------------------- |
| `pnpm dev-web` | Starts the development server for the web app only |
| `TAURI_DEV_HOST=192.168.1.22 pnpm dev-web` | Start the dev server on a LAN address |
| `pnpm build-web` | Builds the web app |
Recommended Visual Studio Code plugins for development:
+25 -15
View File
@@ -52,25 +52,25 @@
| **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. | ✅ |
## Planned Features
<div align="left">🛠 Building</div>
<div align="left">🔄 Planned</div>
| **Feature** | **Description** | **Priority** |
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
| **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. | 🔄 |
| **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. | 🔄 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
@@ -80,6 +80,8 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
![DeepL](./data/screenshots/deepl.png)
![TTS](./data/screenshots/tts_control.png)
![Footnote](./data/screenshots/footnote_popover.png)
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
@@ -172,7 +174,7 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
<a href="https://github.com/readest/readest/graphs/contributors">
<p align="left">
<img width="200" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
<img width="300" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
</p>
</a>
@@ -180,12 +182,20 @@ 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 JavaScript libraries are bundled in this software:
The following libraries and software 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.
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
- [react](https://github.com/facebook/react), which is MIT licensed.
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
- [tauri](https://github.com/tauri-apps/tauri), which is MIT licensed.
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
[Bitter](https://fonts.google.com/?query=Bitter), [Fira Code](https://fonts.google.com/?query=Fira+Code), [Literata](https://fonts.google.com/?query=Literata), [Merriweather](https://fonts.google.com/?query=Merriweather), [Noto Sans](https://fonts.google.com/?query=Noto+Sans), [Roboto](https://fonts.google.com/?query=Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
---
+8
View File
@@ -0,0 +1,8 @@
NEXT_PUBLIC_POSTHOG_KEY=YOUR_POSTHOG_KEY
NEXT_PUBLIC_POSTHOG_HOST=YOUR_POSTHOG_HOST
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
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
+17 -2
View File
@@ -3,12 +3,27 @@ module.exports = {
output: '.',
options: {
debug: false,
sort: true,
sort: false,
func: {
list: ['_'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
lngs: ['de', 'ja', 'es', 'fr', 'it', 'ko', 'pt', 'ru', 'tr', 'id', 'vi', 'zh-CN', 'zh-TW'],
lngs: [
'de',
'ja',
'es',
'fr',
'it',
'el',
'ko',
'pt',
'ru',
'tr',
'id',
'vi',
'zh-CN',
'zh-TW',
],
ns: ['translation'],
defaultNs: 'translation',
defaultValue: '__STRING_NOT_TRANSLATED__',
-8
View File
@@ -19,14 +19,6 @@ const nextConfig = {
// Configure assetPrefix or else the server won't properly resolve your assets.
assetPrefix: isProd ? '' : `http://${internalHost}:3000`,
reactStrictMode: true,
async rewrites() {
return [
{
source: '/api/deepl/:path*',
destination: 'https://api-free.deepl.com/v2/:path*',
},
];
},
};
export default nextConfig;
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.8.9",
"version": "0.9.6",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -35,6 +35,7 @@
"@supabase/supabase-js": "^2.47.7",
"@tauri-apps/api": "2.1.1",
"@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",
@@ -54,11 +55,11 @@
"i18next-http-backend": "^3.0.1",
"js-md5": "^0.8.3",
"next": "15.1.0",
"posthog-js": "^1.194.1",
"posthog-js": "^1.205.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.3.0",
"react-icons": "^5.4.0",
"tinycolor2": "^1.6.0",
"zustand": "5.0.1"
},
@@ -72,7 +73,7 @@
"@types/tinycolor2": "^1.4.6",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",
"daisyui": "^4.12.14",
"daisyui": "^4.12.23",
"dotenv-cli": "^7.4.4",
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
@@ -83,7 +84,7 @@
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.15",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}
@@ -1,7 +1,10 @@
{
"(detected)": "(erkannt)",
"About Readest": "Über Readest",
"Add your notes here...": "Fügen Sie hier Ihre Notizen hinzu...",
"Animation": "Animation",
"Annotate": "Annotieren",
"Apply": "Anwenden",
"Are you sure to delete the selected books?": "Möchten Sie die ausgewählten Bücher wirklich löschen?",
"Auto Mode": "Automatischer Modus",
"Behavior": "Verhalten",
@@ -14,18 +17,23 @@
"Color": "Farbe",
"Confirm": "Bestätigen",
"Confirm Deletion": "Löschen bestätigen",
"Copied to notebook": "In Notizbuch kopiert",
"Copy": "Kopieren",
"Custom CSS": "Benutzerdefiniertes CSS",
"Dark Mode": "Dunkelmodus",
"Default": "Standard",
"Default Font": "Standardschriftart",
"Default Font Size": "Standard-Schriftgröße",
"Delete": "Löschen",
"Delete Highlight": "Hervorhebung löschen",
"Dictionary": "Wörterbuch",
"Disable Click-to-Flip": "Klick zum Blättern deaktivieren",
"Download Readest": "Readest herunterladen",
"Edit": "Bearbeiten",
"Enter your custom CSS here...": "Geben Sie hier Ihr benutzerdefiniertes CSS ein...",
"Excerpts": "Auszüge",
"Failed to import book(s): {{filenames}}": "Fehler beim Importieren von Buch/Büchern: {{filenames}}",
"Fast": "Schnell",
"Font": "Schriftart",
"Font & Layout": "Schrift & Layout",
"Font Face": "Schriftschnitt",
@@ -33,7 +41,6 @@
"Font Size": "Schriftgröße",
"From Local File": "Aus lokaler Datei",
"Full Justification": "Blocksatz",
"Gaps (%)": "Abstände (%)",
"Global Settings": "Globale Einstellungen",
"Go Back": "Zurück",
"Go Forward": "Vorwärts",
@@ -42,6 +49,8 @@
"Grass": "Grasgrün",
"Gray": "Grau",
"Gruvbox": "Gruvbox",
"Highlight": "Hervorheben",
"Horizontal Direction": "Horizontale Richtung",
"Hyphenation": "Silbentrennung",
"Identifier:": "Kennung:",
"Import Books": "Bücher importieren",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Angemeldet",
"Logged in as {{userDisplayName}}": "Angemeldet als {{userDisplayName}}",
"Margins (px)": "Ränder (px)",
"Match Case": "Groß-/Kleinschreibung beachten",
"Match Diacritics": "Akzente beachten",
"Match Whole Words": "Ganze Wörter",
@@ -70,7 +78,7 @@
"Notes": "Notizen",
"Open": "Öffnen",
"Open Book": "Buch öffnen",
"Override Publisher Font": "Verleger-Schriftart überschreiben",
"Original Text": "Originaltext",
"Page": "Seite",
"Paging Animation": "Blätter-Animation",
"Paragraph": "Absatz",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "Serifenlose Schriftart",
"Save": "Speichern",
"Scrolled Mode": "Scroll-Modus",
"Search": "Suchen",
"Search books...": "Bücher suchen...",
"Search...": "Suchen...",
"Select Book": "Buch auswählen",
@@ -97,16 +106,32 @@
"Sign In": "Anmelden",
"Sign Out": "Abmelden",
"Sky": "Himmelblau",
"Slow": "Langsam",
"Solarized": "Solarisiert",
"Speak": "Sprechen",
"Subjects:": "Themen:",
"System Fonts": "System-Schriftarten",
"Table of Contents": "Inhaltsverzeichnis",
"TTS not supported in this device": "TTS wird auf diesem Gerät nicht unterstützt",
"Theme Color": "Themenfarbe",
"Theme Mode": "Themenmodus",
"Translate": "Übersetzen",
"Translated Text": "Übersetzter Text",
"Unknown": "Unbekannt",
"Untitled": "Ohne Titel",
"Updated:": "Aktualisiert:",
"User avatar": "Benutzer-Avatar",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Vertikale Richtung",
"Welcome to your library. You can import your books here and read them anytime.": "Willkommen in Ihrer Bibliothek. Sie können hier Ihre Bücher importieren und jederzeit lesen.",
"Your Library": "Ihre Bibliothek"
"Wikipedia": "Wikipedia",
"Writing Mode": "Schreibmodus",
"Your Library": "Ihre Bibliothek",
"TTS not supported for PDF": "TTS wird für PDF nicht unterstützt",
"Override Book Font": "Buch-Schriftart überschreiben",
"Vertical Margins (px)": "Vertikale Ränder (px)",
"Horizontal Margins (%)": "Horizontale Ränder (%)",
"Apply to all books": "Auf alle Bücher anwenden",
"Apply to this book": "Auf dieses Buch anwenden",
"Unable to fetch the translation. Try again later.": "Übersetzung konnte nicht abgerufen werden. Versuchen Sie es später erneut."
}
@@ -0,0 +1,137 @@
{
"(detected)": "(εντοπίστηκε)",
"About Readest": "Σχετικά με το Readest",
"Add your notes here...": "Προσθέστε τις σημειώσεις σας εδώ...",
"Animation": "Κινούμενα σχέδια",
"Annotate": "Σχολιασμός",
"Apply": "Εφαρμογή",
"Are you sure to delete the selected books?": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τα επιλεγμένα βιβλία;",
"Auto Mode": "Αυτόματη λειτουργία",
"Behavior": "Συμπεριφορά",
"Book": "Βιβλίο",
"Book Cover": "Εξώφυλλο βιβλίου",
"Bookmark": "Σελιδοδείκτης",
"Cancel": "Ακύρωση",
"Chapter": "Κεφάλαιο",
"Cherry": "Κερασί",
"Color": "Χρώμα",
"Confirm": "Επιβεβαίωση",
"Confirm Deletion": "Επιβεβαίωση διαγραφής",
"Copied to notebook": "Αντιγράφηκε στο σημειωματάριο",
"Copy": "Αντιγραφή",
"Custom CSS": "Προσαρμοσμένο CSS",
"Dark Mode": "Σκοτεινή λειτουργία",
"Default": "Προεπιλογή",
"Default Font": "Προεπιλεγμένη γραμματοσειρά",
"Default Font Size": "Προεπιλεγμένο μέγεθος γραμματοσειράς",
"Delete": "Διαγραφή",
"Delete Highlight": "Διαγραφή επισήμανσης",
"Dictionary": "Λεξικό",
"Disable Click-to-Flip": "Απενεργοποίηση κλικ για γύρισμα σελίδας",
"Download Readest": "Λήψη Readest",
"Edit": "Επεξεργασία",
"Enter your custom CSS here...": "Εισάγετε το προσαρμοσμένο CSS εδώ...",
"Excerpts": "Αποσπάσματα",
"Failed to import book(s): {{filenames}}": "Αποτυχία εισαγωγής βιβλίου(ων): {{filenames}}",
"Fast": "Γρήγορα",
"Font": "Γραμματοσειρά",
"Font & Layout": "Γραμματοσειρά & Διάταξη",
"Font Face": "Τύπος γραμματοσειράς",
"Font Family": "Οικογένεια γραμματοσειράς",
"Font Size": "Μέγεθος γραμματοσειράς",
"From Local File": "Από τοπικό αρχείο",
"Full Justification": "Πλήρης στοίχιση",
"Global Settings": "Γενικές ρυθμίσεις",
"Go Back": "Πίσω",
"Go Forward": "Μπροστά",
"Go Left": "Αριστερά",
"Go Right": "Δεξιά",
"Grass": "Γρασίδι",
"Gray": "Γκρι",
"Gruvbox": "Gruvbox",
"Highlight": "Επισήμανση",
"Horizontal Direction": "Οριζόντια κατεύθυνση",
"Hyphenation": "Συλλαβισμός",
"Identifier:": "Αναγνωριστικό:",
"Import Books": "Εισαγωγή βιβλίων",
"Invert Colors in Dark Mode": "Αντιστροφή χρωμάτων στη σκοτεινή λειτουργία",
"Language:": "Γλώσσα:",
"Layout": "Διάταξη",
"Light Mode": "Φωτεινή λειτουργία",
"Line Height": "Ύψος γραμμής",
"Loading...": "Φόρτωση...",
"Loc. {{currentPage}} / {{totalPage}}": "Θέση {{currentPage}} / {{totalPage}}",
"Logged in": "Συνδεδεμένος",
"Logged in as {{userDisplayName}}": "Συνδεδεμένος ως {{userDisplayName}}",
"Match Case": "Ταίριασμα πεζών-κεφαλαίων",
"Match Diacritics": "Ταίριασμα διακριτικών σημείων",
"Match Whole Words": "Ταίριασμα ολόκληρων λέξεων",
"Maximum Block Size": "Μέγιστο μέγεθος μπλοκ",
"Maximum Inline Size": "Μέγιστο μέγεθος γραμμής",
"Maximum Number of Columns": "Μέγιστος αριθμός στηλών",
"Minimum Font Size": "Ελάχιστο μέγεθος γραμματοσειράς",
"Misc": "Διάφορα",
"Monospace Font": "Γραμματοσειρά σταθερού πλάτους",
"More Info": "Περισσότερες πληροφορίες",
"Nord": "Nord",
"Notebook": "Σημειωματάριο",
"Notes": "Σημειώσεις",
"Open": "Άνοιγμα",
"Open Book": "Άνοιγμα βιβλίου",
"Original Text": "Αρχικό κείμενο",
"Page": "Σελίδα",
"Paging Animation": "Κινούμενα σχέδια σελιδοποίησης",
"Paragraph": "Παράγραφος",
"Parallel Read": "Παράλληλη ανάγνωση",
"Published:": "Δημοσιεύτηκε:",
"Publisher:": "Εκδότης:",
"Reading progress synced": "Ο συγχρονισμός προόδου ανάγνωσης ολοκληρώθηκε",
"Reload Page": "Επαναφόρτωση σελίδας",
"Reveal in File Explorer": "Εμφάνιση στον εξερευνητή αρχείων",
"Reveal in Finder": "Εμφάνιση στον Finder",
"Reveal in Folder": "Εμφάνιση στο φάκελο",
"Sans-Serif Font": "Γραμματοσειρά Sans-Serif",
"Save": "Αποθήκευση",
"Scrolled Mode": "Λειτουργία κύλισης",
"Search": "Αναζήτηση",
"Search books...": "Αναζήτηση βιβλίων...",
"Search...": "Αναζήτηση...",
"Select Book": "Επιλογή βιβλίου",
"Select books": "Επιλογή βιβλίων",
"Select multiple books": "Επιλογή πολλαπλών βιβλίων",
"Sepia": "Σέπια",
"Serif Font": "Γραμματοσειρά Serif",
"Show Book Details": "Εμφάνιση λεπτομερειών βιβλίου",
"Sidebar": "Πλευρική γραμμή",
"Sign In": "Σύνδεση",
"Sign Out": "Αποσύνδεση",
"Sky": "Ουρανός",
"Slow": "Αργά",
"Solarized": "Solarized",
"Speak": "Ομιλία",
"Subjects:": "Θέματα:",
"System Fonts": "Γραμματοσειρές συστήματος",
"Table of Contents": "Πίνακας περιεχομένων",
"TTS not supported in this device": "Η μετατροπή κειμένου σε ομιλία δεν υποστηρίζεται σε αυτή τη συσκευή",
"Theme Color": "Χρώμα θέματος",
"Theme Mode": "Λειτουργία θέματος",
"Translate": "Μετάφραση",
"Translated Text": "Μεταφρασμένο κείμενο",
"Unknown": "Άγνωστο",
"Untitled": "Χωρίς τίτλο",
"Updated:": "Ενημερώθηκε:",
"User avatar": "Εικόνα χρήστη",
"Version {{version}}": "Έκδοση {{version}}",
"Vertical Direction": "Κάθετη κατεύθυνση",
"Welcome to your library. You can import your books here and read them anytime.": "Καλώς ήρθατε στη βιβλιοθήκη σας. Μπορείτε να εισάγετε τα βιβλία σας εδώ και να τα διαβάσετε οποιαδήποτε στιγμή.",
"Wikipedia": "Βικιπαίδεια",
"Writing Mode": "Λειτουργία γραφής",
"Your Library": "Η βιβλιοθήκη σας",
"TTS not supported for PDF": "Η μετατροπή κειμένου σε ομιλία δεν υποστηρίζεται για αρχεία PDF",
"Override Book Font": "Παράκαμψη γραμματοσειράς βιβλίου",
"Vertical Margins (px)": "Κάθετα περιθώρια (px)",
"Horizontal Margins (%)": "Οριζόντια περιθώρια (%)",
"Apply to all books": "Εφαρμογή σε όλα τα βιβλία",
"Apply to this book": "Εφαρμογή σε αυτό το βιβλίο",
"Unable to fetch the translation. Try again later.": "Αδυναμία λήψης της μετάφρασης. Δοκιμάστε ξανά αργότερα."
}
@@ -1,7 +1,10 @@
{
"(detected)": "(detectado)",
"About Readest": "Acerca de Readest",
"Add your notes here...": "Añade tus notas aquí...",
"Animation": "Animación",
"Annotate": "Anotar",
"Apply": "Aplicar",
"Are you sure to delete the selected books?": "¿Estás seguro de eliminar los libros seleccionados?",
"Auto Mode": "Modo automático",
"Behavior": "Comportamiento",
@@ -14,18 +17,23 @@
"Color": "Color",
"Confirm": "Confirmar",
"Confirm Deletion": "Confirmar eliminación",
"Copied to notebook": "Copiado al cuaderno",
"Copy": "Copiar",
"Custom CSS": "CSS personalizado",
"Dark Mode": "Modo oscuro",
"Default": "Predeterminado",
"Default Font": "Fuente predeterminada",
"Default Font Size": "Tamaño de fuente predeterminado",
"Delete": "Eliminar",
"Delete Highlight": "Eliminar resaltado",
"Dictionary": "Diccionario",
"Disable Click-to-Flip": "Desactivar clic para voltear",
"Download Readest": "Descargar Readest",
"Edit": "Editar",
"Enter your custom CSS here...": "Introduce tu CSS personalizado aquí...",
"Excerpts": "Extractos",
"Failed to import book(s): {{filenames}}": "Error al importar libro(s): {{filenames}}",
"Fast": "Rápido",
"Font": "Fuente",
"Font & Layout": "Fuente y diseño",
"Font Face": "Tipo de fuente",
@@ -33,7 +41,6 @@
"Font Size": "Tamaño de fuente",
"From Local File": "Desde archivo local",
"Full Justification": "Justificación completa",
"Gaps (%)": "Espacios (%)",
"Global Settings": "Configuración global",
"Go Back": "Volver",
"Go Forward": "Avanzar",
@@ -42,6 +49,8 @@
"Grass": "Hierba",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Highlight": "Resaltar",
"Horizontal Direction": "Dirección horizontal",
"Hyphenation": "Guionización",
"Identifier:": "Identificador:",
"Import Books": "Importar libros",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Sesión iniciada",
"Logged in as {{userDisplayName}}": "Sesión iniciada como {{userDisplayName}}",
"Margins (px)": "Márgenes (px)",
"Match Case": "Coincidir mayúsculas y minúsculas",
"Match Diacritics": "Coincidir diacríticos",
"Match Whole Words": "Coincidir palabras completas",
@@ -70,7 +78,7 @@
"Notes": "Notas",
"Open": "Abrir",
"Open Book": "Abrir libro",
"Override Publisher Font": "Sobrescribir fuente del editor",
"Original Text": "Texto original",
"Page": "Página",
"Paging Animation": "Animación de paginación",
"Paragraph": "Párrafo",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "Fuente sans-serif",
"Save": "Guardar",
"Scrolled Mode": "Modo desplazamiento",
"Search": "Buscar",
"Search books...": "Buscar libros...",
"Search...": "Buscar...",
"Select Book": "Seleccionar libro",
@@ -97,16 +106,32 @@
"Sign In": "Iniciar sesión",
"Sign Out": "Cerrar sesión",
"Sky": "Cielo",
"Slow": "Lento",
"Solarized": "Solarizado",
"Speak": "Leer",
"Subjects:": "Temas:",
"System Fonts": "Fuentes del sistema",
"Table of Contents": "Tabla de contenidos",
"TTS not supported in this device": "TTS no es compatible con este dispositivo",
"Theme Color": "Color del tema",
"Theme Mode": "Modo del tema",
"Translate": "Traducir",
"Translated Text": "Texto traducido",
"Unknown": "Desconocido",
"Untitled": "Sin título",
"Updated:": "Actualizado:",
"User avatar": "Avatar del usuario",
"Version {{version}}": "Versión {{version}}",
"Vertical Direction": "Dirección vertical",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenido a tu biblioteca. Puedes importar tus libros aquí y leerlos en cualquier momento.",
"Your Library": "Tu biblioteca"
"Wikipedia": "Wikipedia",
"Writing Mode": "Modo de escritura",
"Your Library": "Tu biblioteca",
"TTS not supported for PDF": "TTS no es compatible con PDF",
"Override Book Font": "Sobrescribir fuente del libro",
"Vertical Margins (px)": "Márgenes verticales (px)",
"Horizontal Margins (%)": "Márgenes horizontales (%)",
"Apply to all books": "Aplicar a todos los libros",
"Apply to this book": "Aplicar a este libro",
"Unable to fetch the translation. Try again later.": "No se puede obtener la traducción. Inténtalo de nuevo más tarde."
}
@@ -1,7 +1,10 @@
{
"(detected)": "(détecté)",
"About Readest": "À propos de Readest",
"Add your notes here...": "Ajoutez vos notes ici...",
"Animation": "Animation",
"Annotate": "Annoter",
"Apply": "Appliquer",
"Are you sure to delete the selected books?": "Êtes-vous sûr de vouloir supprimer les livres sélectionnés ?",
"Auto Mode": "Mode automatique",
"Behavior": "Comportement",
@@ -14,18 +17,23 @@
"Color": "Couleur",
"Confirm": "Confirmer",
"Confirm Deletion": "Confirmer la suppression",
"Copied to notebook": "Copié dans le carnet de notes",
"Copy": "Copier",
"Custom CSS": "CSS personnalisé",
"Dark Mode": "Mode sombre",
"Default": "Par défaut",
"Default Font": "Police par défaut",
"Default Font Size": "Taille de police par défaut",
"Delete": "Supprimer",
"Delete Highlight": "Supprimer le surlignage",
"Dictionary": "Dictionnaire",
"Disable Click-to-Flip": "Désactiver le clic pour tourner",
"Download Readest": "Télécharger Readest",
"Edit": "Modifier",
"Enter your custom CSS here...": "Saisissez votre CSS personnalisé ici...",
"Excerpts": "Extraits",
"Failed to import book(s): {{filenames}}": "Échec de l'importation du/des livre(s) : {{filenames}}",
"Fast": "Rapide",
"Font": "Police",
"Font & Layout": "Police et mise en page",
"Font Face": "Style de police",
@@ -33,7 +41,6 @@
"Font Size": "Taille de police",
"From Local File": "Depuis un fichier local",
"Full Justification": "Justification complète",
"Gaps (%)": "Espaces (%)",
"Global Settings": "Paramètres globaux",
"Go Back": "Retour",
"Go Forward": "Avancer",
@@ -42,6 +49,8 @@
"Grass": "Herbe",
"Gray": "Gris",
"Gruvbox": "Gruvbox",
"Highlight": "Surligner",
"Horizontal Direction": "Direction horizontale",
"Hyphenation": "Césure",
"Identifier:": "Identifiant :",
"Import Books": "Importer des livres",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Connecté",
"Logged in as {{userDisplayName}}": "Connecté en tant que {{userDisplayName}}",
"Margins (px)": "Marges (px)",
"Match Case": "Respecter la casse",
"Match Diacritics": "Respecter les accents",
"Match Whole Words": "Mots entiers",
@@ -70,7 +78,7 @@
"Notes": "Notes",
"Open": "Ouvrir",
"Open Book": "Ouvrir le livre",
"Override Publisher Font": "Remplacer la police de l'éditeur",
"Original Text": "Texte original",
"Page": "Page",
"Paging Animation": "Animation de page",
"Paragraph": "Paragraphe",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "Police sans empattement",
"Save": "Enregistrer",
"Scrolled Mode": "Mode défilement",
"Search": "Rechercher",
"Search books...": "Rechercher des livres...",
"Search...": "Rechercher...",
"Select Book": "Sélectionner un livre",
@@ -97,16 +106,32 @@
"Sign In": "Se connecter",
"Sign Out": "Se déconnecter",
"Sky": "Ciel",
"Slow": "Lent",
"Solarized": "Solarisé",
"Speak": "Lire",
"Subjects:": "Sujets :",
"System Fonts": "Polices système",
"Table of Contents": "Table des matières",
"TTS not supported in this device": "La synthèse vocale n'est pas prise en charge sur cet appareil",
"Theme Color": "Couleur du thème",
"Theme Mode": "Mode du thème",
"Translate": "Traduire",
"Translated Text": "Texte traduit",
"Unknown": "Inconnu",
"Untitled": "Sans titre",
"Updated:": "Mis à jour :",
"User avatar": "Avatar de l'utilisateur",
"Version {{version}}": "Version {{version}}",
"Vertical Direction": "Direction verticale",
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenue dans votre bibliothèque. Vous pouvez importer vos livres ici et les lire à tout moment.",
"Your Library": "Votre bibliothèque"
"Wikipedia": "Wikipédia",
"Writing Mode": "Mode écriture",
"Your Library": "Votre bibliothèque",
"TTS not supported for PDF": "La synthèse vocale n'est pas prise en charge pour les fichiers PDF",
"Override Book Font": "Remplacer la police du livre",
"Vertical Margins (px)": "Marges verticales (px)",
"Horizontal Margins (%)": "Marges horizontales (%)",
"Apply to all books": "Appliquer à tous les livres",
"Apply to this book": "Appliquer à ce livre",
"Unable to fetch the translation. Try again later.": "Impossible de récupérer la traduction. Réessayez plus tard."
}
@@ -1,7 +1,10 @@
{
"(detected)": "(terdeteksi)",
"About Readest": "Tentang Readest",
"Add your notes here...": "Tambahkan catatan Anda di sini...",
"Animation": "Animasi",
"Annotate": "Anotasi",
"Apply": "Terapkan",
"Are you sure to delete the selected books?": "Apakah Anda yakin ingin menghapus buku yang dipilih?",
"Auto Mode": "Mode Otomatis",
"Behavior": "Perilaku",
@@ -14,18 +17,23 @@
"Color": "Warna",
"Confirm": "Konfirmasi",
"Confirm Deletion": "Konfirmasi Penghapusan",
"Copied to notebook": "Disalin ke buku catatan",
"Copy": "Salin",
"Custom CSS": "CSS Kustom",
"Dark Mode": "Mode Gelap",
"Default": "Default",
"Default Font": "Font Default",
"Default Font Size": "Ukuran Font Default",
"Delete": "Hapus",
"Delete Highlight": "Hapus Sorotan",
"Dictionary": "Kamus",
"Disable Click-to-Flip": "Nonaktifkan Klik untuk Membalik",
"Download Readest": "Unduh Readest",
"Edit": "Edit",
"Enter your custom CSS here...": "Masukkan CSS kustom Anda di sini...",
"Excerpts": "Kutipan",
"Failed to import book(s): {{filenames}}": "Gagal mengimpor buku: {{filenames}}",
"Fast": "Cepat",
"Font": "Font",
"Font & Layout": "Font & Tata Letak",
"Font Face": "Jenis Font",
@@ -33,7 +41,6 @@
"Font Size": "Ukuran Font",
"From Local File": "Dari File Lokal",
"Full Justification": "Rata Penuh",
"Gaps (%)": "Jarak (%)",
"Global Settings": "Pengaturan Global",
"Go Back": "Kembali",
"Go Forward": "Maju",
@@ -42,6 +49,8 @@
"Grass": "Rumput",
"Gray": "Abu-abu",
"Gruvbox": "Gruvbox",
"Highlight": "Sorot",
"Horizontal Direction": "Arah Horizontal",
"Hyphenation": "Pemenggalan Kata",
"Identifier:": "Pengenal:",
"Import Books": "Impor Buku",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "Lok. {{currentPage}} / {{totalPage}}",
"Logged in": "Sudah Masuk",
"Logged in as {{userDisplayName}}": "Masuk sebagai {{userDisplayName}}",
"Margins (px)": "Margin (px)",
"Match Case": "Cocokkan Huruf Besar/Kecil",
"Match Diacritics": "Cocokkan Diakritik",
"Match Whole Words": "Cocokkan Kata Utuh",
@@ -70,7 +78,7 @@
"Notes": "Catatan",
"Open": "Buka",
"Open Book": "Buka Buku",
"Override Publisher Font": "Ganti Font Penerbit",
"Original Text": "Teks Asli",
"Page": "Halaman",
"Paging Animation": "Animasi Halaman",
"Paragraph": "Paragraf",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "Font Sans-Serif",
"Save": "Simpan",
"Scrolled Mode": "Mode Gulir",
"Search": "Cari",
"Search books...": "Cari buku...",
"Search...": "Cari...",
"Select Book": "Pilih Buku",
@@ -97,16 +106,32 @@
"Sign In": "Masuk",
"Sign Out": "Keluar",
"Sky": "Langit",
"Slow": "Lambat",
"Solarized": "Solarized",
"Speak": "Bicara",
"Subjects:": "Subjek:",
"System Fonts": "Font Sistem",
"Table of Contents": "Daftar Isi",
"TTS not supported in this device": "TTS tidak didukung di perangkat ini",
"Theme Color": "Warna Tema",
"Theme Mode": "Mode Tema",
"Translate": "Terjemahkan",
"Translated Text": "Teks Terjemahan",
"Unknown": "Tidak Diketahui",
"Untitled": "Tanpa Judul",
"Updated:": "Diperbarui:",
"User avatar": "Avatar Pengguna",
"Version {{version}}": "Versi {{version}}",
"Vertical Direction": "Arah Vertikal",
"Welcome to your library. You can import your books here and read them anytime.": "Selamat datang di perpustakaan Anda. Anda dapat mengimpor buku-buku Anda di sini dan membacanya kapan saja.",
"Your Library": "Perpustakaan Anda"
"Wikipedia": "Wikipedia",
"Writing Mode": "Mode Menulis",
"Your Library": "Perpustakaan Anda",
"TTS not supported for PDF": "TTS tidak didukung untuk PDF",
"Override Book Font": "Ganti Font Buku",
"Vertical Margins (px)": "Margin Vertikal (px)",
"Horizontal Margins (%)": "Margin Horizontal (%)",
"Apply to all books": "Terapkan ke semua buku",
"Apply to this book": "Terapkan ke buku ini",
"Unable to fetch the translation. Try again later.": "Tidak dapat mengambil terjemahan. Coba lagi nanti."
}
@@ -1,7 +1,10 @@
{
"(detected)": "(rilevato)",
"About Readest": "Informazioni su Readest",
"Add your notes here...": "Aggiungi qui le tue note...",
"Animation": "Animazione",
"Annotate": "Annota",
"Apply": "Applica",
"Are you sure to delete the selected books?": "Sei sicuro di voler eliminare i libri selezionati?",
"Auto Mode": "Modalità automatica",
"Behavior": "Comportamento",
@@ -14,18 +17,23 @@
"Color": "Colore",
"Confirm": "Conferma",
"Confirm Deletion": "Conferma eliminazione",
"Copied to notebook": "Copiato nel quaderno",
"Copy": "Copia",
"Custom CSS": "CSS personalizzato",
"Dark Mode": "Modalità scura",
"Default": "Predefinito",
"Default Font": "Font predefinito",
"Default Font Size": "Dimensione font predefinita",
"Delete": "Elimina",
"Delete Highlight": "Elimina evidenziazione",
"Dictionary": "Dizionario",
"Disable Click-to-Flip": "Disattiva click per voltare pagina",
"Download Readest": "Scarica Readest",
"Edit": "Modifica",
"Enter your custom CSS here...": "Inserisci qui il tuo CSS personalizzato...",
"Excerpts": "Estratti",
"Failed to import book(s): {{filenames}}": "Impossibile importare il/i libro/i: {{filenames}}",
"Fast": "Veloce",
"Font": "Font",
"Font & Layout": "Font e Layout",
"Font Face": "Tipo di carattere",
@@ -33,7 +41,6 @@
"Font Size": "Dimensione carattere",
"From Local File": "Da file locale",
"Full Justification": "Giustificazione completa",
"Gaps (%)": "Spaziature (%)",
"Global Settings": "Impostazioni globali",
"Go Back": "Indietro",
"Go Forward": "Avanti",
@@ -42,6 +49,8 @@
"Grass": "Erba",
"Gray": "Grigio",
"Gruvbox": "Gruvbox",
"Highlight": "Evidenzia",
"Horizontal Direction": "Direzione orizzontale",
"Hyphenation": "Sillabazione",
"Identifier:": "Identificatore:",
"Import Books": "Importa libri",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "Pos. {{currentPage}} / {{totalPage}}",
"Logged in": "Accesso effettuato",
"Logged in as {{userDisplayName}}": "Accesso effettuato come {{userDisplayName}}",
"Margins (px)": "Margini (px)",
"Match Case": "Maiuscole/minuscole",
"Match Diacritics": "Corrispondenza diacritici",
"Match Whole Words": "Parole intere",
@@ -70,7 +78,7 @@
"Notes": "Note",
"Open": "Apri",
"Open Book": "Apri libro",
"Override Publisher Font": "Sostituisci font editore",
"Original Text": "Testo originale",
"Page": "Pagina",
"Paging Animation": "Animazione cambio pagina",
"Paragraph": "Paragrafo",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "Font sans-serif",
"Save": "Salva",
"Scrolled Mode": "Modalità scorrimento",
"Search": "Cerca",
"Search books...": "Cerca libri...",
"Search...": "Cerca...",
"Select Book": "Seleziona libro",
@@ -97,16 +106,32 @@
"Sign In": "Accedi",
"Sign Out": "Esci",
"Sky": "Cielo",
"Slow": "Lento",
"Solarized": "Solarizzato",
"Speak": "Leggi",
"Subjects:": "Argomenti:",
"System Fonts": "Font di sistema",
"Table of Contents": "Indice",
"TTS not supported in this device": "TTS non supportato su questo dispositivo",
"Theme Color": "Colore tema",
"Theme Mode": "Modalità tema",
"Translate": "Traduci",
"Translated Text": "Testo tradotto",
"Unknown": "Sconosciuto",
"Untitled": "Senza titolo",
"Updated:": "Aggiornato:",
"User avatar": "Avatar utente",
"Version {{version}}": "Versione {{version}}",
"Vertical Direction": "Direzione verticale",
"Welcome to your library. You can import your books here and read them anytime.": "Benvenuto nella tua biblioteca. Puoi importare i tuoi libri qui e leggerli in qualsiasi momento.",
"Your Library": "La tua biblioteca"
"Wikipedia": "Wikipedia",
"Writing Mode": "Modalità scrittura",
"Your Library": "La tua biblioteca",
"TTS not supported for PDF": "TTS non supportato per PDF",
"Override Book Font": "Sovrascrivi font libro",
"Vertical Margins (px)": "Margini verticali (px)",
"Horizontal Margins (%)": "Margini orizzontali (%)",
"Apply to all books": "Applica a tutti i libri",
"Apply to this book": "Applica a questo libro",
"Unable to fetch the translation. Try again later.": "Impossibile recuperare la traduzione. Riprova più tardi."
}
@@ -1,7 +1,10 @@
{
"(detected)": "(検出)",
"About Readest": "Readestについて",
"Add your notes here...": "ここにメモを追加...",
"Animation": "アニメーション",
"Annotate": "注釈",
"Apply": "適用",
"Are you sure to delete the selected books?": "選択した書籍を削除してもよろしいですか?",
"Auto Mode": "自動モード",
"Behavior": "動作",
@@ -14,18 +17,23 @@
"Color": "色",
"Confirm": "確認",
"Confirm Deletion": "削除の確認",
"Copied to notebook": "ノートブックにコピー",
"Copy": "コピー",
"Custom CSS": "カスタムCSS",
"Dark Mode": "ダークモード",
"Default": "デフォルト",
"Default Font": "デフォルトフォント",
"Default Font Size": "デフォルトフォントサイズ",
"Delete": "削除",
"Delete Highlight": "ハイライトを削除",
"Dictionary": "辞書",
"Disable Click-to-Flip": "クリックめくりを無効化",
"Download Readest": "Readestをダウンロード",
"Edit": "編集",
"Enter your custom CSS here...": "ここにカスタムCSSを入力...",
"Excerpts": "抜粋",
"Failed to import book(s): {{filenames}}": "書籍のインポートに失敗しました:{{filenames}}",
"Fast": "高速",
"Font": "フォント",
"Font & Layout": "フォントとレイアウト",
"Font Face": "書体",
@@ -33,7 +41,6 @@
"Font Size": "フォントサイズ",
"From Local File": "ローカルファイルから",
"Full Justification": "両端揃え",
"Gaps (%)": "間隔 (%)",
"Global Settings": "全体設定",
"Go Back": "戻る",
"Go Forward": "進む",
@@ -42,6 +49,8 @@
"Grass": "グリーン",
"Gray": "グレー",
"Gruvbox": "グルーブボックス",
"Highlight": "ハイライト",
"Horizontal Direction": "横書",
"Hyphenation": "ハイフネーション",
"Identifier:": "識別子:",
"Import Books": "書籍をインポート",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "ログイン済み",
"Logged in as {{userDisplayName}}": "{{userDisplayName}}としてログイン中",
"Margins (px)": "余白 (px)",
"Match Case": "大文字・小文字を区別",
"Match Diacritics": "アクセント記号を区別",
"Match Whole Words": "単語全体を一致",
@@ -70,7 +78,7 @@
"Notes": "メモ",
"Open": "開く",
"Open Book": "書籍を開く",
"Override Publisher Font": "出版社フォントを上書き",
"Original Text": "原文",
"Page": "ページ",
"Paging Animation": "ページめくりアニメーション",
"Paragraph": "段落",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "ゴシック体",
"Save": "保存",
"Scrolled Mode": "スクロールモード",
"Search": "検索",
"Search books...": "書籍を検索...",
"Search...": "検索...",
"Select Book": "書籍を選択",
@@ -97,16 +106,32 @@
"Sign In": "サインイン",
"Sign Out": "サインアウト",
"Sky": "スカイ",
"Slow": "低速",
"Solarized": "ソーラライズド",
"Speak": "読み上げ",
"Subjects:": "主題:",
"System Fonts": "システムフォント",
"Table of Contents": "目次",
"TTS not supported in this device": "このデバイスではTTSがサポートされていません",
"Theme Color": "テーマカラー",
"Theme Mode": "テーマモード",
"Translate": "翻訳",
"Translated Text": "翻訳されたテキスト",
"Unknown": "不明",
"Untitled": "無題",
"Updated:": "更新日:",
"User avatar": "ユーザーアバター",
"Version {{version}}": "バージョン {{version}}",
"Vertical Direction": "縦書",
"Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
"Your Library": "ライブラリー"
"Wikipedia": "ウィキペディア",
"Writing Mode": "書き込みモード",
"Your Library": "ライブラリー",
"TTS not supported for PDF": "PDFではTTSがサポートされていません",
"Override Book Font": "書籍フォントを上書き",
"Vertical Margins (px)": "縦マージン(px",
"Horizontal Margins (%)": "横マージン(%",
"Apply to all books": "すべての書籍に適用",
"Apply to this book": "この書籍に適用",
"Unable to fetch the translation. Try again later.": "翻訳を取得できません。後で再試行してください。"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(감지됨)",
"About Readest": "Readest 정보",
"Add your notes here...": "여기에 메모를 추가하세요...",
"Animation": "애니메이션",
"Annotate": "주석",
"Apply": "적용",
"Are you sure to delete the selected books?": "선택한 책을 삭제하시겠습니까?",
"Auto Mode": "자동 모드",
"Behavior": "동작",
@@ -14,18 +17,23 @@
"Color": "색상",
"Confirm": "확인",
"Confirm Deletion": "삭제 확인",
"Copied to notebook": "노트북에 복사됨",
"Copy": "복사",
"Custom CSS": "사용자 정의 CSS",
"Dark Mode": "다크 모드",
"Default": "기본값",
"Default Font": "기본 글꼴",
"Default Font Size": "기본 글꼴 크기",
"Delete": "삭제",
"Delete Highlight": "하이라이트 삭제",
"Dictionary": "사전",
"Disable Click-to-Flip": "클릭 넘기기 비활성화",
"Download Readest": "Readest 다운로드",
"Edit": "편집",
"Enter your custom CSS here...": "여기에 사용자 정의 CSS를 입력하세요...",
"Excerpts": "발췌",
"Failed to import book(s): {{filenames}}": "책 가져오기 실패: {{filenames}}",
"Fast": "빠름",
"Font": "글꼴",
"Font & Layout": "글꼴 및 레이아웃",
"Font Face": "글꼴 스타일",
@@ -33,7 +41,6 @@
"Font Size": "글꼴 크기",
"From Local File": "로컬 파일에서",
"Full Justification": "양쪽 정렬",
"Gaps (%)": "간격 (%)",
"Global Settings": "전역 설정",
"Go Back": "뒤로",
"Go Forward": "앞으로",
@@ -42,6 +49,8 @@
"Grass": "잔디색",
"Gray": "회색",
"Gruvbox": "그러브박스",
"Highlight": "하이라이트",
"Horizontal Direction": "수평 방향",
"Hyphenation": "하이픈 넣기",
"Identifier:": "식별자:",
"Import Books": "책 가져오기",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "위치 {{currentPage}} / {{totalPage}}",
"Logged in": "로그인됨",
"Logged in as {{userDisplayName}}": "{{userDisplayName}}(으)로 로그인됨",
"Margins (px)": "여백 (px)",
"Match Case": "대소문자 구분",
"Match Diacritics": "발음 구별 부호 구분",
"Match Whole Words": "전체 단어 일치",
@@ -70,7 +78,7 @@
"Notes": "메모",
"Open": "열기",
"Open Book": "책 열기",
"Override Publisher Font": "출판사 글꼴 재정의",
"Original Text": "원본 텍스트",
"Page": "페이지",
"Paging Animation": "페이지 넘김 애니메이션",
"Paragraph": "단락",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "산세리프체",
"Save": "저장",
"Scrolled Mode": "스크롤 모드",
"Search": "검색",
"Search books...": "책 검색...",
"Search...": "검색...",
"Select Book": "책 선택",
@@ -97,16 +106,32 @@
"Sign In": "로그인",
"Sign Out": "로그아웃",
"Sky": "하늘색",
"Slow": "느림",
"Solarized": "솔라라이즈드",
"Speak": "말하기",
"Subjects:": "주제:",
"System Fonts": "시스템 글꼴",
"Table of Contents": "목차",
"TTS not supported in this device": "이 장치에서 TTS가 지원되지 않음",
"Theme Color": "테마 색상",
"Theme Mode": "테마 모드",
"Translate": "번역",
"Translated Text": "번역된 텍스트",
"Unknown": "알 수 없음",
"Untitled": "제목 없음",
"Updated:": "업데이트일:",
"User avatar": "사용자 아바타",
"Version {{version}}": "버전 {{version}}",
"Vertical Direction": "수직 방향",
"Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
"Your Library": "내 서재"
"Wikipedia": "위키피디아",
"Writing Mode": "글쓰기 모드",
"Your Library": "내 서재",
"TTS not supported for PDF": "PDF에서 TTS가 지원되지 않음",
"Override Book Font": "책 글꼴 덮어쓰기",
"Vertical Margins (px)": "수직 여백 (px)",
"Horizontal Margins (%)": "수평 여백 (%)",
"Apply to all books": "모든 책에 적용",
"Apply to this book": "이 책에 적용",
"Unable to fetch the translation. Try again later.": "번역을 가져올 수 없습니다. 나중에 다시 시도하세요."
}
@@ -1,7 +1,10 @@
{
"(detected)": "(detectado)",
"About Readest": "Sobre o Readest",
"Add your notes here...": "Adicione suas notas aqui...",
"Animation": "Animação",
"Annotate": "Anotar",
"Apply": "Aplicar",
"Are you sure to delete the selected books?": "Tem certeza de que deseja excluir os livros selecionados?",
"Auto Mode": "Modo Automático",
"Behavior": "Comportamento",
@@ -14,18 +17,23 @@
"Color": "Cor",
"Confirm": "Confirmar",
"Confirm Deletion": "Confirmar Exclusão",
"Copied to notebook": "Copiado para o caderno",
"Copy": "Copiar",
"Custom CSS": "CSS Personalizado",
"Dark Mode": "Modo Escuro",
"Default": "Padrão",
"Default Font": "Fonte Padrão",
"Default Font Size": "Tamanho da Fonte Padrão",
"Delete": "Excluir",
"Delete Highlight": "Excluir Destaque",
"Dictionary": "Dicionário",
"Disable Click-to-Flip": "Desativar Clique para Virar",
"Download Readest": "Baixar Readest",
"Edit": "Editar",
"Enter your custom CSS here...": "Insira seu CSS personalizado aqui...",
"Excerpts": "Trechos",
"Failed to import book(s): {{filenames}}": "Falha ao importar livro(s): {{filenames}}",
"Fast": "Rápido",
"Font": "Fonte",
"Font & Layout": "Fonte e Layout",
"Font Face": "Estilo da Fonte",
@@ -33,7 +41,6 @@
"Font Size": "Tamanho da Fonte",
"From Local File": "Do Arquivo Local",
"Full Justification": "Justificação Completa",
"Gaps (%)": "Espaçamentos (%)",
"Global Settings": "Configurações Globais",
"Go Back": "Voltar",
"Go Forward": "Avançar",
@@ -42,6 +49,8 @@
"Grass": "Grama",
"Gray": "Cinza",
"Gruvbox": "Gruvbox",
"Highlight": "Destaque",
"Horizontal Direction": "Direção Horizontal",
"Hyphenation": "Hifenização",
"Identifier:": "Identificador:",
"Import Books": "Importar Livros",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "Loc. {{currentPage}} / {{totalPage}}",
"Logged in": "Conectado",
"Logged in as {{userDisplayName}}": "Conectado como {{userDisplayName}}",
"Margins (px)": "Margens (px)",
"Match Case": "Diferenciar Maiúsculas e Minúsculas",
"Match Diacritics": "Corresponder Acentos",
"Match Whole Words": "Corresponder Palavras Inteiras",
@@ -70,7 +78,7 @@
"Notes": "Notas",
"Open": "Abrir",
"Open Book": "Abrir Livro",
"Override Publisher Font": "Substituir Fonte do Editor",
"Original Text": "Texto Original",
"Page": "Página",
"Paging Animation": "Animação de Página",
"Paragraph": "Parágrafo",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "Fonte Sans-Serif",
"Save": "Salvar",
"Scrolled Mode": "Modo de Rolagem",
"Search": "Buscar",
"Search books...": "Procurar livros...",
"Search...": "Pesquisar...",
"Select Book": "Selecionar Livro",
@@ -97,16 +106,32 @@
"Sign In": "Entrar",
"Sign Out": "Sair",
"Sky": "Céu",
"Slow": "Lento",
"Solarized": "Solarizado",
"Speak": "Falar",
"Subjects:": "Assuntos:",
"System Fonts": "Fontes do Sistema",
"Table of Contents": "Índice",
"TTS not supported in this device": "TTS não suportado neste dispositivo",
"Theme Color": "Cor do Tema",
"Theme Mode": "Modo do Tema",
"Translate": "Traduzir",
"Translated Text": "Texto Traduzido",
"Unknown": "Desconhecido",
"Untitled": "Sem Título",
"Updated:": "Atualizado:",
"User avatar": "Avatar do usuário",
"Version {{version}}": "Versão {{version}}",
"Vertical Direction": "Direção Vertical",
"Welcome to your library. You can import your books here and read them anytime.": "Bem-vindo à sua biblioteca. Você pode importar seus livros aqui e lê-los a qualquer momento.",
"Your Library": "Sua Biblioteca"
"Wikipedia": "Wikipédia",
"Writing Mode": "Modo de Escrita",
"Your Library": "Sua Biblioteca",
"TTS not supported for PDF": "TTS não suportado para PDF",
"Override Book Font": "Sobrescrever Fonte do Livro",
"Vertical Margins (px)": "Margens Verticais (px)",
"Horizontal Margins (%)": "Margens Horizontais (%)",
"Apply to all books": "Aplicar a todos os livros",
"Apply to this book": "Aplicar a este livro",
"Unable to fetch the translation. Try again later.": "Não foi possível buscar a tradução. Tente novamente mais tarde."
}
@@ -1,7 +1,10 @@
{
"(detected)": "(обнаружено)",
"About Readest": "О Readest",
"Add your notes here...": "Добавьте свои заметки здесь...",
"Animation": "Анимация",
"Annotate": "Аннотация",
"Apply": "Применить",
"Are you sure to delete the selected books?": "Вы уверены, что хотите удалить выбранные книги?",
"Auto Mode": "Автоматический режим",
"Behavior": "Поведение",
@@ -14,18 +17,23 @@
"Color": "Цвет",
"Confirm": "Подтвердить",
"Confirm Deletion": "Подтвердить удаление",
"Copied to notebook": "Скопировано в блокнот",
"Copy": "Копировать",
"Custom CSS": "Пользовательский CSS",
"Dark Mode": "Тёмная тема",
"Default": "По умолчанию",
"Default Font": "Шрифт по умолчанию",
"Default Font Size": "Размер шрифта по умолчанию",
"Delete": "Удалить",
"Delete Highlight": "Удалить выделение",
"Dictionary": "Словарь",
"Disable Click-to-Flip": "Отключить переворот по клику",
"Download Readest": "Скачать Readest",
"Edit": "Редактировать",
"Enter your custom CSS here...": "Введите ваш пользовательский CSS здесь...",
"Excerpts": "Отрывки",
"Failed to import book(s): {{filenames}}": "Не удалось импортировать книгу(и): {{filenames}}",
"Fast": "Быстро",
"Font": "Шрифт",
"Font & Layout": "Шрифт и макет",
"Font Face": "Начертание шрифта",
@@ -33,7 +41,6 @@
"Font Size": "Размер шрифта",
"From Local File": "Из локального файла",
"Full Justification": "Полное выравнивание",
"Gaps (%)": "Промежутки (%)",
"Global Settings": "Общие настройки",
"Go Back": "Назад",
"Go Forward": "Вперёд",
@@ -42,6 +49,8 @@
"Grass": "Трава",
"Gray": "Серый",
"Gruvbox": "Gruvbox",
"Highlight": "Выделить",
"Horizontal Direction": "Горизонтальное направление",
"Hyphenation": "Перенос слов",
"Identifier:": "Идентификатор:",
"Import Books": "Импорт книг",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "Поз. {{currentPage}} / {{totalPage}}",
"Logged in": "Выполнен вход",
"Logged in as {{userDisplayName}}": "Вход выполнен как {{userDisplayName}}",
"Margins (px)": "Отступы (px)",
"Match Case": "Учитывать регистр",
"Match Diacritics": "Учитывать диакритические знаки",
"Match Whole Words": "Только целые слова",
@@ -70,7 +78,7 @@
"Notes": "Заметки",
"Open": "Открыть",
"Open Book": "Открыть книгу",
"Override Publisher Font": "Переопределить шрифт издателя",
"Original Text": "Оригинальный текст",
"Page": "Страница",
"Paging Animation": "Анимация перелистывания",
"Paragraph": "Абзац",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "Шрифт без засечек",
"Save": "Сохранить",
"Scrolled Mode": "Режим прокрутки",
"Search": "Поиск",
"Search books...": "Поиск книг...",
"Search...": "Поиск...",
"Select Book": "Выбрать книгу",
@@ -97,16 +106,32 @@
"Sign In": "Войти",
"Sign Out": "Выйти",
"Sky": "Небесный",
"Slow": "Медленно",
"Solarized": "Солнечный",
"Speak": "Произнести",
"Subjects:": "Темы:",
"System Fonts": "Системные шрифты",
"Table of Contents": "Содержание",
"TTS not supported in this device": "TTS не поддерживается на этом устройстве",
"Theme Color": "Цвет темы",
"Theme Mode": "Режим темы",
"Translate": "Перевести",
"Translated Text": "Переведённый текст",
"Unknown": "Неизвестно",
"Untitled": "Без названия",
"Updated:": "Обновлено:",
"User avatar": "Аватар пользователя",
"Version {{version}}": "Версия {{version}}",
"Vertical Direction": "Вертикальное направление",
"Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
"Your Library": "Ваша библиотека"
"Wikipedia": "Википедия",
"Writing Mode": "Режим записи",
"Your Library": "Ваша библиотека",
"TTS not supported for PDF": "TTS не поддерживается для PDF",
"Override Book Font": "Переопределить шрифт книги",
"Vertical Margins (px)": "Вертикальные поля (px)",
"Horizontal Margins (%)": "Горизонтальные поля (%)",
"Apply to all books": "Применить ко всем книгам",
"Apply to this book": "Применить к этой книге",
"Unable to fetch the translation. Try again later.": "Не удалось получить перевод. Попробуйте позже."
}
@@ -1,7 +1,10 @@
{
"(detected)": "(algılandı)",
"About Readest": "Readest Hakkında",
"Add your notes here...": "Notlarınızı buraya ekleyin...",
"Animation": "Animasyon",
"Annotate": "Not Ekle",
"Apply": "Uygula",
"Are you sure to delete the selected books?": "Seçili kitapları silmek istediğinizden emin misiniz?",
"Auto Mode": "Otomatik Mod",
"Behavior": "Davranış",
@@ -14,18 +17,23 @@
"Color": "Renk",
"Confirm": "Onayla",
"Confirm Deletion": "Silmeyi Onayla",
"Copied to notebook": "Not defterine kopyalandı",
"Copy": "Kopyala",
"Custom CSS": "Özel CSS",
"Dark Mode": "Karanlık Mod",
"Default": "Varsayılan",
"Default Font": "Varsayılan Yazı Tipi",
"Default Font Size": "Varsayılan Yazı Boyutu",
"Delete": "Sil",
"Delete Highlight": "Vurguyu Sil",
"Dictionary": "Sözlük",
"Disable Click-to-Flip": "Tıkla-Çevir'i Devre Dışı Bırak",
"Download Readest": "Readest'i İndir",
"Edit": "Düzenle",
"Enter your custom CSS here...": "Özel CSS'nizi buraya girin...",
"Excerpts": "Alıntılar",
"Failed to import book(s): {{filenames}}": "Kitap(lar) içe aktarılamadı: {{filenames}}",
"Fast": "Hızlı",
"Font": "Yazı Tipi",
"Font & Layout": "Yazı Tipi ve Düzen",
"Font Face": "Yazı Tipi Yüzü",
@@ -33,7 +41,6 @@
"Font Size": "Yazı Boyutu",
"From Local File": "Yerel Dosyadan",
"Full Justification": "Tam Hizalama",
"Gaps (%)": "Boşluklar (%)",
"Global Settings": "Genel Ayarlar",
"Go Back": "Geri Git",
"Go Forward": "İleri Git",
@@ -42,6 +49,8 @@
"Grass": "Çimen",
"Gray": "Gri",
"Gruvbox": "Gruvbox",
"Highlight": "Vurgula",
"Horizontal Direction": "Yatay Yön",
"Hyphenation": "Heceleme",
"Identifier:": "Tanımlayıcı:",
"Import Books": "Kitapları İçe Aktar",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "Konum {{currentPage}} / {{totalPage}}",
"Logged in": "Giriş Yapıldı",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} olarak giriş yapıldı",
"Margins (px)": "Kenar Boşlukları (px)",
"Match Case": "Büyük/Küçük Harf Eşleştir",
"Match Diacritics": "Aksan İşaretlerini Eşleştir",
"Match Whole Words": "Tam Kelimeleri Eşleştir",
@@ -70,7 +78,7 @@
"Notes": "Notlar",
"Open": "Aç",
"Open Book": "Kitabı Aç",
"Override Publisher Font": "Yayıncı Yazı Tipini Geçersiz Kıl",
"Original Text": "Orijinal Metin",
"Page": "Sayfa",
"Paging Animation": "Sayfa Çevirme Animasyonu",
"Paragraph": "Paragraf",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "Sans-Serif Yazı Tipi",
"Save": "Kaydet",
"Scrolled Mode": "Kaydırma Modu",
"Search": "Ara",
"Search books...": "Kitap ara...",
"Search...": "Ara...",
"Select Book": "Kitap Seç",
@@ -97,16 +106,32 @@
"Sign In": "Giriş Yap",
"Sign Out": "Çıkış Yap",
"Sky": "Gökyüzü",
"Slow": "Yavaş",
"Solarized": "Solarized",
"Speak": "Konuş",
"Subjects:": "Konular:",
"System Fonts": "Sistem Yazı Tipleri",
"Table of Contents": "İçindekiler",
"TTS not supported in this device": "Bu cihazda TTS desteklenmiyor",
"Theme Color": "Tema Rengi",
"Theme Mode": "Tema Modu",
"Translate": "Çevir",
"Translated Text": "Çevrilen Metin",
"Unknown": "Bilinmiyor",
"Untitled": "Başlıksız",
"Updated:": "Güncellendi:",
"User avatar": "Kullanıcı avatarı",
"Version {{version}}": "Sürüm {{version}}",
"Vertical Direction": "Dikey Yön",
"Welcome to your library. You can import your books here and read them anytime.": "Kütüphanenize hoş geldiniz. Buradan kitaplarınızı içe aktarabilir ve istediğiniz zaman okuyabilirsiniz.",
"Your Library": "Kütüphaneniz"
"Wikipedia": "Vikipedi",
"Writing Mode": "Yazma Modu",
"Your Library": "Kütüphaneniz",
"TTS not supported for PDF": "PDF için TTS desteklenmiyor",
"Override Book Font": "Kitap Yazı Tipini Geçersiz Kıl",
"Vertical Margins (px)": "Dikey Kenar Boşlukları (px)",
"Horizontal Margins (%)": "Yatay Kenar Boşlukları (%)",
"Apply to all books": "Tüm kitaplara uygula",
"Apply to this book": "Bu kitaba uygula",
"Unable to fetch the translation. Try again later.": "Çeviri alınamıyor. Daha sonra tekrar deneyin."
}
@@ -1,7 +1,10 @@
{
"(detected)": "(đã phát hiện)",
"About Readest": "Về Readest",
"Add your notes here...": "Thêm ghi chú của bạn vào đây...",
"Animation": "Hiệu ứng động",
"Annotate": "Chú thích",
"Apply": "Áp dụng",
"Are you sure to delete the selected books?": "Bạn có chắc chắn muốn xóa các sách đã chọn?",
"Auto Mode": "Chế độ tự động",
"Behavior": "Hành vi",
@@ -14,18 +17,23 @@
"Color": "Màu sắc",
"Confirm": "Xác nhận",
"Confirm Deletion": "Xác nhận xóa",
"Copied to notebook": "Đã sao chép vào sổ ghi chú",
"Copy": "Sao chép",
"Custom CSS": "CSS tùy chỉnh",
"Dark Mode": "Chế độ tối",
"Default": "Mặc định",
"Default Font": "Phông chữ mặc định",
"Default Font Size": "Cỡ chữ mặc định",
"Delete": "Xóa",
"Delete Highlight": "Xóa văn bản được đánh dấu",
"Dictionary": "Từ điển",
"Disable Click-to-Flip": "Tắt tính năng nhấp để lật trang",
"Download Readest": "Tải Readest",
"Edit": "Chỉnh sửa",
"Enter your custom CSS here...": "Nhập CSS tùy chỉnh của bạn vào đây...",
"Excerpts": "Trích dẫn",
"Failed to import book(s): {{filenames}}": "Không thể nhập sách: {{filenames}}",
"Fast": "Nhanh",
"Font": "Phông chữ",
"Font & Layout": "Phông chữ & Bố cục",
"Font Face": "Kiểu chữ",
@@ -33,7 +41,6 @@
"Font Size": "Cỡ chữ",
"From Local File": "Từ tệp cục bộ",
"Full Justification": "Căn đều hai bên",
"Gaps (%)": "Khoảng cách (%)",
"Global Settings": "Cài đặt chung",
"Go Back": "Quay lại",
"Go Forward": "Tiến tới",
@@ -42,6 +49,8 @@
"Grass": "Xanh cỏ",
"Gray": "Xám",
"Gruvbox": "Gruvbox",
"Highlight": "Đánh dấu",
"Horizontal Direction": "Hướng ngang",
"Hyphenation": "Gạch nối từ",
"Identifier:": "Định danh:",
"Import Books": "Nhập sách",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "Vị trí {{currentPage}} / {{totalPage}}",
"Logged in": "Đã đăng nhập",
"Logged in as {{userDisplayName}}": "Đăng nhập với tên {{userDisplayName}}",
"Margins (px)": "Lề (px)",
"Match Case": "Phân biệt chữ hoa/thường",
"Match Diacritics": "Phân biệt dấu",
"Match Whole Words": "Khớp toàn bộ từ",
@@ -70,7 +78,7 @@
"Notes": "Ghi chú",
"Open": "Mở",
"Open Book": "Mở sách",
"Override Publisher Font": "Ghi đè phông chữ của nhà xuất bản",
"Original Text": "Văn bản gốc",
"Page": "Trang",
"Paging Animation": "Hiệu ứng lật trang",
"Paragraph": "Đoạn văn",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "Phông chữ không chân",
"Save": "Lưu",
"Scrolled Mode": "Chế độ cuộn",
"Search": "Tìm kiếm",
"Search books...": "Tìm kiếm sách...",
"Search...": "Tìm kiếm...",
"Select Book": "Chọn sách",
@@ -97,16 +106,32 @@
"Sign In": "Đăng nhập",
"Sign Out": "Đăng xuất",
"Sky": "Xanh trời",
"Slow": "Chậm",
"Solarized": "Solarized",
"Speak": "Đọc",
"Subjects:": "Chủ đề:",
"System Fonts": "Phông chữ hệ thống",
"Table of Contents": "Mục lục",
"TTS not supported in this device": "TTS không được hỗ trợ trên thiết bị này",
"Theme Color": "Màu chủ đề",
"Theme Mode": "Chế độ chủ đề",
"Translate": "Dịch",
"Translated Text": "Văn bản dịch",
"Unknown": "Không xác định",
"Untitled": "Không có tiêu đề",
"Updated:": "Cập nhật:",
"User avatar": "Ảnh đại diện",
"Version {{version}}": "Phiên bản {{version}}",
"Vertical Direction": "Hướng dọc",
"Welcome to your library. You can import your books here and read them anytime.": "Chào mừng đến với thư viện của bạn. Bạn có thể nhập sách vào đây và đọc bất cứ lúc nào.",
"Your Library": "Thư viện của bạn"
"Wikipedia": "Wikipedia",
"Writing Mode": "Chế độ viết",
"Your Library": "Thư viện của bạn",
"TTS not supported for PDF": "TTS không được hỗ trợ cho PDF",
"Override Book Font": "Ghi đè phông chữ sách",
"Vertical Margins (px)": "Lề dọc (px)",
"Horizontal Margins (%)": "Lề ngang (%)",
"Apply to all books": "Áp dụng cho tất cả sách",
"Apply to this book": "Áp dụng cho sách này",
"Unable to fetch the translation. Try again later.": "Không thể tải dịch vụ. Vui lòng thử lại sau."
}
@@ -1,7 +1,10 @@
{
"(detected)": "(检测到)",
"About Readest": "关于 Readest",
"Add your notes here...": "在这里添加您的笔记...",
"Animation": "动画",
"Annotate": "笔记",
"Apply": "应用",
"Are you sure to delete the selected books?": "您确定要删除所选书籍吗?",
"Auto Mode": "自动主题",
"Behavior": "行为",
@@ -14,18 +17,23 @@
"Color": "颜色",
"Confirm": "确认",
"Confirm Deletion": "确认删除",
"Copied to notebook": "已复制到笔记本",
"Copy": "复制",
"Custom CSS": "自定义 CSS",
"Dark Mode": "深色主题",
"Default": "默认",
"Default Font": "默认字体",
"Default Font Size": "默认字号",
"Delete": "删除",
"Delete Highlight": "删除划线",
"Dictionary": "词典",
"Disable Click-to-Flip": "禁用点击翻页",
"Download Readest": "下载 Readest",
"Edit": "编辑",
"Enter your custom CSS here...": "在此处输入您的自定义 CSS...",
"Excerpts": "摘录",
"Failed to import book(s): {{filenames}}": "导入书籍失败:{{filenames}}",
"Fast": "快",
"Font": "字体",
"Font & Layout": "字体和布局",
"Font Face": "字型",
@@ -33,7 +41,6 @@
"Font Size": "字号",
"From Local File": "从本地文件导入",
"Full Justification": "两端对齐",
"Gaps (%)": "间距 (%)",
"Global Settings": "全局设置",
"Go Back": "返回",
"Go Forward": "前进",
@@ -42,6 +49,8 @@
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Highlight": "划线",
"Horizontal Direction": "横排",
"Hyphenation": "断字",
"Identifier:": "识别码",
"Import Books": "导入书籍",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登录",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登录",
"Margins (px)": "页边距 (px)",
"Match Case": "匹配大小写",
"Match Diacritics": "匹配重音符号",
"Match Whole Words": "匹配整个单词",
@@ -70,7 +78,7 @@
"Notes": "笔记",
"Open": "打开",
"Open Book": "打开书籍",
"Override Publisher Font": "覆盖内置字体",
"Original Text": "原文",
"Page": "页面",
"Paging Animation": "翻页动画",
"Paragraph": "段落",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "无衬线字体",
"Save": "保存",
"Scrolled Mode": "滚动模式",
"Search": "搜索",
"Search books...": "搜索书籍...",
"Search...": "搜索...",
"Select Book": "选择书籍",
@@ -97,16 +106,32 @@
"Sign In": "登录",
"Sign Out": "登出",
"Sky": "天青",
"Slow": "慢",
"Solarized": "日晖",
"Speak": "朗读",
"Subjects:": "主题",
"System Fonts": "系统字体",
"Table of Contents": "目录",
"TTS not supported in this device": "此设备不支持 TTS",
"Theme Color": "主题颜色",
"Theme Mode": "主题模式",
"Translate": "翻译",
"Translated Text": "译文",
"Unknown": "未知",
"Untitled": "无标题",
"Updated:": "更新日期",
"User avatar": "用户头像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "竖排",
"Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。",
"Your Library": "书库"
"Wikipedia": "维基百科",
"Writing Mode": "排版模式",
"Your Library": "书库",
"TTS not supported for PDF": "PDF 文档暂不支持 TTS",
"Override Book Font": "覆盖书籍字体",
"Vertical Margins (px)": "垂直边距(px",
"Horizontal Margins (%)": "水平边距(%",
"Apply to all books": "应用于所有书籍",
"Apply to this book": "应用于此书籍",
"Unable to fetch the translation. Try again later.": "无法获取翻译,请稍后重试。"
}
@@ -1,7 +1,10 @@
{
"(detected)": "(檢測到)",
"About Readest": "關於 Readest",
"Add your notes here...": "在這裡添加您的筆記...",
"Animation": "動畫",
"Annotate": "筆記",
"Apply": "應用",
"Are you sure to delete the selected books?": "您確定要刪除所選書籍嗎?",
"Auto Mode": "自動主題",
"Behavior": "行為",
@@ -14,18 +17,23 @@
"Color": "顏色",
"Confirm": "確認",
"Confirm Deletion": "確認刪除",
"Copied to notebook": "已複製到筆記本",
"Copy": "複製",
"Custom CSS": "自定義 CSS",
"Dark Mode": "深色主題",
"Default": "預設",
"Default Font": "預設字體",
"Default Font Size": "預設字號",
"Delete": "刪除",
"Delete Highlight": "刪除劃線",
"Dictionary": "詞典",
"Disable Click-to-Flip": "禁用點擊翻頁",
"Download Readest": "下載 Readest",
"Edit": "編輯",
"Enter your custom CSS here...": "在此處輸入您的自定義 CSS...",
"Excerpts": "摘錄",
"Failed to import book(s): {{filenames}}": "導入書籍失敗:{{filenames}}",
"Fast": "快",
"Font": "字體",
"Font & Layout": "字體和版面",
"Font Face": "字型",
@@ -33,7 +41,6 @@
"Font Size": "字號",
"From Local File": "從本地檔案導入",
"Full Justification": "兩端對齊",
"Gaps (%)": "間距 (%)",
"Global Settings": "全局設置",
"Go Back": "返回",
"Go Forward": "前進",
@@ -42,6 +49,8 @@
"Grass": "青草",
"Gray": "素雅",
"Gruvbox": "暖橘",
"Highlight": "劃線",
"Horizontal Direction": "橫排",
"Hyphenation": "斷字",
"Identifier:": "識別碼",
"Import Books": "導入書籍",
@@ -54,7 +63,6 @@
"Loc. {{currentPage}} / {{totalPage}}": "位置 {{currentPage}} / {{totalPage}}",
"Logged in": "已登入",
"Logged in as {{userDisplayName}}": "{{userDisplayName}} 已登入",
"Margins (px)": "頁邊距 (px)",
"Match Case": "匹配大小寫",
"Match Diacritics": "匹配重音符號",
"Match Whole Words": "匹配整個單詞",
@@ -70,7 +78,7 @@
"Notes": "筆記",
"Open": "打開",
"Open Book": "打開書籍",
"Override Publisher Font": "覆蓋內置字體",
"Original Text": "原文",
"Page": "頁面",
"Paging Animation": "翻頁動畫",
"Paragraph": "段落",
@@ -85,6 +93,7 @@
"Sans-Serif Font": "無襯線字體",
"Save": "保存",
"Scrolled Mode": "滾動模式",
"Search": "搜索",
"Search books...": "搜索書籍...",
"Search...": "搜索...",
"Select Book": "選擇書籍",
@@ -97,16 +106,32 @@
"Sign In": "登入",
"Sign Out": "登出",
"Sky": "天青",
"Slow": "慢",
"Solarized": "日暉",
"Speak": "朗讀",
"Subjects:": "主題",
"System Fonts": "系統字體",
"Table of Contents": "目錄",
"TTS not supported in this device": "此設備不支持 TTS",
"Theme Color": "主題顏色",
"Theme Mode": "主題模式",
"Translate": "翻譯",
"Translated Text": "譯文",
"Unknown": "未知",
"Untitled": "無標題",
"Updated:": "更新日期",
"User avatar": "用戶頭像",
"Version {{version}}": "版本 {{version}}",
"Vertical Direction": "豎排",
"Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
"Your Library": "書庫"
"Wikipedia": "維基百科",
"Writing Mode": "排版模式",
"Your Library": "書庫",
"TTS not supported for PDF": "PDF 文檔暫不支持 TTS",
"Override Book Font": "覆蓋書籍字體",
"Vertical Margins (px)": "垂直邊距(px",
"Horizontal Margins (%)": "水平邊距(%",
"Apply to all books": "應用於所有書籍",
"Apply to this book": "應用於此書籍",
"Unable to fetch the translation. Try again later.": "無法獲取翻譯,請稍後再試。"
}
+42
View File
@@ -1,5 +1,47 @@
{
"releases": {
"0.9.6": {
"date": "2025-01-18",
"notes": ["Various enhancements for mobile platforms", "Fix DeepL translation API"]
},
"0.9.5": {
"date": "2025-01-15",
"notes": [
"Various TTS related UX enhancements",
"Add more system fonts as custom fonts",
"Show reading progress in bookshelf"
]
},
"0.9.3": {
"date": "2025-01-11",
"notes": [
"Hotfix occasionally current speak not stopped when changing voice or rate",
"Inherit tts rate when changing tts engine"
]
},
"0.9.2": {
"date": "2025-01-10",
"notes": [
"Support Text-to-Speech with Edge TTS and Web Speech API.",
"Various fixes and enhancements."
]
},
"0.9.1": {
"date": "2025-01-06",
"notes": [
"Restore window position and size when reopening app.",
"Swap font when downloading online fonts.",
"Fix layout issues on Windows and Linux."
]
},
"0.9.0": {
"date": "2025-01-04",
"notes": [
"Better Custom CSS editor and OAuth process.",
"Add vertical/horizontal switch for CJK books.",
"Fix dropdown close overlay not working on Windows/Linux."
]
},
"0.8.9": {
"date": "2025-01-02",
"notes": [
+295 -126
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -6,7 +6,7 @@ authors = ["Bilingify LLC"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.71"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -34,6 +34,7 @@ tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.2"
tauri-plugin-deep-link = "2"
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
@@ -44,4 +45,6 @@ rand = "0.8"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2.2.0"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2.2.0"
@@ -51,7 +51,12 @@
"os:default",
"core:window:default",
"core:window:allow-close",
"core:window:allow-center",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-set-size",
"core:window:allow-set-focus",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"shell:default",
@@ -60,6 +65,7 @@
"process:allow-restart",
"oauth:allow-start",
"oauth:allow-cancel",
"opener:default"
"opener:default",
"deep-link:default"
]
}
+32 -4
View File
@@ -9,7 +9,7 @@ extern crate objc;
#[cfg(target_os = "macos")]
mod menu;
#[cfg(target_os = "macos")]
mod tauri_traffic_light_positioner_plugin;
mod traffic_light_plugin;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -70,6 +70,12 @@ async fn start_server(window: Window) -> Result<u16, String> {
.map_err(|err| err.to_string())
}
#[derive(Clone, serde::Serialize)]
struct Payload {
args: Vec<String>,
cwd: String,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
@@ -83,11 +89,26 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
let _ = app
.get_webview_window("main")
.expect("no main window")
.set_focus();
app.emit("single-instance", Payload { args: argv, cwd })
.unwrap();
}));
let builder = builder.plugin(tauri_plugin_deep_link::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
#[cfg(target_os = "macos")]
let builder = builder.plugin(tauri_traffic_light_positioner_plugin::init());
let builder = builder.plugin(traffic_light_plugin::init());
builder
.setup(|#[allow(unused_variables)] app| {
@@ -135,6 +156,12 @@ pub fn run() {
});
}
#[cfg(any(windows, target_os = "linux"))]
{
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().register_all()?;
}
#[cfg(desktop)]
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -149,8 +176,7 @@ pub fn run() {
#[cfg(desktop)]
let win_builder = win_builder
.inner_size(800.0, 600.0)
.resizable(true)
.maximized(true);
.resizable(true);
#[cfg(target_os = "macos")]
let win_builder = win_builder
@@ -162,6 +188,8 @@ pub fn run() {
let win_builder = win_builder
.decorations(false)
.transparent(true)
.visible(false)
.shadow(true)
.title("Readest");
win_builder.build().unwrap();
+11 -4
View File
@@ -14,10 +14,11 @@
"windows": [],
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://fonts.gstatic.com https://db.onlinewebfonts.com",
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com",
"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",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net",
"font-src": "'self' data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
"frame-src": "'self' blob: asset: http://asset.localhost",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com"
},
@@ -130,8 +131,14 @@
}
]
},
"deep-link": {
"mobile": [{ "host": "web.readest.com" }],
"desktop": {
"schemes": ["readest"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OTAxMURGQkUzQjFENTQKUldSVUhUdSszeEdRNUExdmFkWnlvYWNYNG5wamkxMmUxRk5SejlMOTJVd28yNXlVTDh6Wld4OC8K",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJFMEQ1QjE2OEU1NEIzNTEKUldSUnMxU09GbHNOdmpEaWFMT1crRFpEV2VORzQ2MklxaFc0M1R0ci9xY2c1bENXS0xhM1R1L2sK",
"endpoints": ["https://github.com/readest/readest/releases/latest/download/latest.json"]
}
}
+58 -26
View File
@@ -17,12 +17,18 @@ import { useTheme } from '@/hooks/useTheme';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
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';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
interface SingleInstancePayload {
args: string[];
cwd: string;
}
interface ProviderLoginProp {
provider: OAuthProvider;
handleSignIn: (provider: OAuthProvider) => void;
@@ -64,7 +70,10 @@ export default function AuthPage() {
provider,
options: {
skipBrowserRedirect: true,
redirectTo: `http://localhost:${port}`,
redirectTo:
process.env.NODE_ENV === 'production'
? 'readest://auth/callback'
: `http://localhost:${port}`,
},
});
@@ -75,34 +84,54 @@ export default function AuthPage() {
openUrl(data.url);
};
const startOAuthServer = async () => {
const handleOAuthUrl = async (url: string) => {
console.log('Received OAuth URL:', url);
const hashMatch = url.match(/#(.*)/);
if (hashMatch) {
const hash = hashMatch[1];
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const next = params.get('next') ?? '/';
if (accessToken) {
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
}
}
};
const startTauriOAuth = async () => {
try {
const port = await start();
setPort(port);
console.log(`OAuth server started on port ${port}`);
if (process.env.NODE_ENV === 'production') {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
currentWindow.listen('single-instance', ({ event, payload }) => {
console.log('Received deep link:', event, payload);
const { args } = payload as SingleInstancePayload;
if (args?.[1]) {
handleOAuthUrl(args[1]);
}
});
await onOpenUrl((urls) => {
urls.forEach((url) => {
handleOAuthUrl(url);
});
});
} else {
const port = await start();
setPort(port);
console.log(`OAuth server started on port ${port}`);
await onUrl((url) => {
console.log('Received OAuth URL:', url);
const hashMatch = url.match(/#(.*)/);
if (hashMatch) {
const hash = hashMatch[1];
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
const next = params.get('next') ?? '/';
handleAuthCallback({ accessToken, refreshToken, next, login, navigate: router.push });
}
});
await onInvalidUrl((url) => {
console.log('Received invalid OAuth URL:', url);
});
await onUrl(handleOAuthUrl);
await onInvalidUrl((url) => {
console.log('Received invalid OAuth URL:', url);
});
}
} catch (error) {
console.error('Error starting OAuth server:', error);
}
};
const stopOAuthServer = async () => {
const stopTauriOAuth = async () => {
try {
if (port) {
await cancel(port);
@@ -126,10 +155,10 @@ export default function AuthPage() {
if (isOAuthServerRunning.current) return;
isOAuthServerRunning.current = true;
startOAuthServer();
startTauriOAuth();
return () => {
isOAuthServerRunning.current = false;
stopOAuthServer();
stopTauriOAuth();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -156,13 +185,16 @@ export default function AuthPage() {
return null;
}
// For tauri app development, use a custom OAuth server to handle the OAuth callback
// 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'>
<button
onClick={handleGoBack}
className='btn btn-ghost fixed left-3 top-11 h-8 min-h-8 w-8 p-0'
>
<IoArrowBack size={20} />
<IoArrowBack size={20} className='text-base-content' />
</button>
<div style={{ maxWidth: '420px', margin: 'auto', padding: '2rem' }}>
<ProviderLogin
@@ -206,7 +238,7 @@ export default function AuthPage() {
onClick={handleGoBack}
className='btn btn-ghost fixed left-10 top-6 h-8 min-h-8 w-8 p-0'
>
<IoArrowBack size={20} />
<IoArrowBack size={20} className='text-base-content' />
</button>
<Auth
supabaseClient={supabase}
@@ -25,6 +25,7 @@ import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import BookDetailModal from '@/components/BookDetailModal';
import ReadingProgress from './ReadingProgress';
type BookshelfItem = Book | BooksGroup;
@@ -242,17 +243,50 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
)}
</div>
</div>
<div className='flex flex-row items-center justify-between p-0 pt-2'>
<h4 className='card-title line-clamp-1 text-[0.6em] text-xs font-semibold'>
{(item as Book).title}
</h4>
{isWebAppPlatform() && (
<div
className='show-detail-button self-start opacity-0 group-hover:opacity-100'
role='button'
onClick={showBookDetailsModal.bind(null, item as Book)}
>
<CiCircleMore size={15} />
<div
className={clsx(
'flex w-full p-0 pt-2',
isWebAppPlatform() ? 'flex-col' : 'flex-row justify-between',
)}
>
<div className='min-w-0 flex-1'>
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
{(item as Book).title}
</h4>
</div>
{item.progress && (
<div className={'flex items-center justify-between'}>
<ReadingProgress book={item as Book} />
{isWebAppPlatform() && (
<button
type='button'
className='show-detail-button opacity-0 group-hover:opacity-100'
onClick={showBookDetailsModal.bind(null, item as Book)}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
showBookDetailsModal(item as Book);
}
}}
>
<CiCircleMore size={15} />
</button>
)}
</div>
)}
{!item.progress && isWebAppPlatform() && (
<div className={'flex items-center justify-end'}>
<button
type='button'
className='show-detail-button opacity-0 group-hover:opacity-100'
onClick={showBookDetailsModal.bind(null, item as Book)}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
showBookDetailsModal(item as Book);
}
}}
>
<CiCircleMore size={15} />
</button>
</div>
)}
</div>
@@ -270,6 +304,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
className='h-48 w-full object-cover'
/>
</figure>
<div className='card-body p-4'>
<h3 className='card-title line-clamp-2 text-sm'>{book.title}</h3>
<p className='text-neutral-content line-clamp-1 text-xs'>{book.author}</p>
<ReadingProgress book={book} />
</div>
</div>
))}
<h2 className='mb-2 text-lg font-bold'>{(item as BooksGroup).name}</h2>
@@ -0,0 +1,43 @@
import type React from 'react';
import { memo, useMemo } from 'react';
import type { Book } from '@/types/book';
interface ReadingProgressProps {
book: Book;
}
const getProgressPercentage = (book: Book) => {
if (!book.progress || !book.progress[1]) {
return null;
}
if (book.progress && book.progress[1] === 1) {
return 100;
}
return Math.round((book.progress[0] / book.progress[1]) * 100);
};
const ReadingProgress: React.FC<ReadingProgressProps> = memo(
({ book }) => {
const progressPercentage = useMemo(() => getProgressPercentage(book), [book]);
if (!progressPercentage) {
return null;
}
return (
<div className='text-neutral-content/70 flex justify-between text-xs'>
<span>{progressPercentage}%</span>
</div>
);
},
(prevProps, nextProps) => {
return (
prevProps.book.hash === nextProps.book.hash &&
prevProps.book.updatedAt === nextProps.book.updatedAt
);
},
);
ReadingProgress.displayName = 'ReadingProgress';
export default ReadingProgress;
+23 -26
View File
@@ -1,5 +1,6 @@
'use client';
import clsx from 'clsx';
import * as React from 'react';
import { useState, useRef, useEffect, Suspense } from 'react';
import { useRouter } from 'next/navigation';
@@ -8,6 +9,7 @@ import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { navigateToReader } from '@/utils/nav';
import { getBaseFilename, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform, hasUpdater } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
@@ -21,11 +23,11 @@ import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDemoBooks } from './hooks/useDemoBooks';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import { AboutWindow } from '@/components/AboutWindow';
import Toast from '@/components/Toast';
const LibraryPage = () => {
const router = useRouter();
@@ -46,16 +48,6 @@ const LibraryPage = () => {
const [isSelectMode, setIsSelectMode] = useState(false);
const demoBooks = useDemoBooks();
const [toastMessage, setToastMessage] = useState('');
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
toastDismissTimeout.current = setTimeout(() => setToastMessage(''), 5000);
return () => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
};
}, [toastMessage]);
useEffect(() => {
const doAppUpdates = async () => {
if (hasUpdater()) {
@@ -69,9 +61,14 @@ const LibraryPage = () => {
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
const bookIds: string[] = [];
for (const file of openWithFiles) {
const book = await appService.importBook(file, libraryBooks);
if (book) {
bookIds.push(book.hash);
console.log('Open with book:', file);
try {
const book = await appService.importBook(file, libraryBooks);
if (book) {
bookIds.push(book.hash);
}
} catch (error) {
console.log('Failed to import book:', file, error);
}
}
setLibrary(libraryBooks);
@@ -170,11 +167,12 @@ const LibraryPage = () => {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getBaseFilename(filename);
failedFiles.push(baseFilename);
setToastMessage(
_('Failed to import book(s): {{filenames}}', {
eventDispatcher.dispatch('toast', {
message: _('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}),
);
type: 'error',
});
console.error('Failed to import book:', filename, error);
}
}
@@ -236,7 +234,12 @@ const LibraryPage = () => {
}
return (
<div className='library-page rounded-window bg-base-200/50 text-base-content flex h-full min-h-screen select-none flex-col overflow-hidden'>
<div
className={clsx(
'library-page bg-base-200/50 text-base-content flex h-dvh select-none flex-col overflow-hidden',
isTauriAppPlatform() && 'rounded-window',
)}
>
<div className='fixed top-0 z-40 w-full'>
<LibraryHeader
isSelectMode={isSelectMode}
@@ -278,13 +281,7 @@ const LibraryPage = () => {
</div>
))}
<AboutWindow />
{toastMessage && (
<Toast
message={toastMessage}
toastClass='toast-top toast-end pt-11'
alertClass='alert-error max-w-80'
/>
)}
<Toast />
</div>
);
};
@@ -1,7 +1,10 @@
import React from 'react';
import React, { useEffect } from 'react';
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';
@@ -11,7 +14,6 @@ import PageInfoView from './PageInfo';
import Ribbon from './Ribbon';
import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
import { useBookDataStore } from '@/store/bookDataStore';
import FootnotePopup from './FootnotePopup';
interface BooksGridProps {
@@ -22,9 +24,18 @@ interface BooksGridProps {
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { getConfig, getBookData } = useBookDataStore();
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight);
useEffect(() => {
if (!sideBarBookKey) return;
const bookData = getBookData(sideBarBookKey);
if (!bookData || !bookData.book) return;
document.title = bookData.book.title;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey]);
return (
<div
className='grid h-full flex-grow'
@@ -49,7 +60,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
<div
id={`gridcell-${bookKey}`}
key={bookKey}
className='relative h-full w-full overflow-hidden'
className={`${isTauriAppPlatform() ? 'rounded-window' : ''} relative h-full w-full overflow-hidden`}
>
{isBookmarked && <Ribbon width={marginGap} />}
<HeaderBar
@@ -18,7 +18,6 @@ import {
handleClick,
handleWheel,
} from '../utils/iframeEventHandlers';
import Toast from '@/components/Toast';
const FoliateViewer: React.FC<{
bookKey: string;
@@ -39,7 +38,7 @@ const FoliateViewer: React.FC<{
return () => clearTimeout(timer);
}, [toastMessage]);
useProgressSync(bookKey, setToastMessage);
useProgressSync(bookKey);
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -164,9 +163,6 @@ const FoliateViewer: React.FC<{
onClick={(event) => handleTurnPage(event)}
ref={containerRef}
/>
{toastMessage && (
<Toast message={toastMessage} toastClass='toast-top toast-end' alertClass='alert-success' />
)}
</>
);
};
@@ -2,10 +2,13 @@ import React from 'react';
import clsx from 'clsx';
import { RiArrowLeftWideLine, RiArrowRightWideLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { FaHeadphones } from 'react-icons/fa6';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import Button from '@/components/Button';
interface FooterBarProps {
@@ -16,9 +19,10 @@ interface FooterBarProps {
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
const _ = useTranslation();
const { hoveredBookKey, setHoveredBookKey, getView } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const view = getView(bookKey);
const progress = getProgress(bookKey);
const handleProgressChange = (event: React.ChangeEvent) => {
const newProgress = parseInt((event.target as HTMLInputElement).value, 10);
@@ -41,14 +45,25 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
view?.history.forward();
};
const handleSpeakText = async () => {
if (!view || !progress) return;
const { range } = progress;
if (eventDispatcher.dispatchSync('tts-is-speaking')) {
eventDispatcher.dispatch('tts-stop', { bookKey });
} else {
eventDispatcher.dispatch('tts-speak', { bookKey, range });
}
};
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
const progressFraction = pageinfoValid ? pageinfo.current / pageinfo.total : 0;
return (
<div
className={clsx(
'footer-bar absolute bottom-0 z-10 flex h-12 w-full items-center px-4',
'shadow-xs bg-base-100 rounded-window-bottom-right transition-opacity duration-300',
!isSideBarVisible && 'rounded-window-bottom-left',
'shadow-xs bg-base-100 transition-opacity duration-300',
isTauriAppPlatform() && 'rounded-window-bottom-right',
!isSideBarVisible && isTauriAppPlatform() && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
hoveredBookKey === bookKey ? `opacity-100` : `opacity-0`,
)}
@@ -83,6 +98,7 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
value={pageinfoValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<Button icon={<FaHeadphones size={20} />} onClick={handleSpeakText} tooltip={_('Speak')} />
<Button
icon={<RiArrowRightWideLine size={20} />}
onClick={handleGoNext}
@@ -28,6 +28,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const { getView, getViewSettings } = useReaderStore();
const { themeCode } = useTheme();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const footnoteHandler = new FootnoteHandler();
useEffect(() => {
@@ -73,7 +74,13 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const rect = gridFrame.getBoundingClientRect();
const viewSettings = getViewSettings(bookKey)!;
const triangPos = getPosition(detail.a, rect, viewSettings.vertical);
const popupPos = getPopupPosition(triangPos, rect, popupWidth, popupHeight, popupPadding);
const popupPos = getPopupPosition(
triangPos,
rect,
viewSettings.vertical ? popupHeight : popupWidth,
viewSettings.vertical ? popupWidth : popupHeight,
popupPadding,
);
setTrianglePosition(triangPos);
setPopupPosition(popupPos);
@@ -107,6 +114,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
}
}, [footnoteRef]);
const width = viewSettings.vertical ? popupHeight : popupWidth;
const height = viewSettings.vertical ? popupWidth : popupHeight;
return (
<div>
{showPopup && (
@@ -117,8 +127,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
/>
)}
<Popup
width={popupWidth}
height={popupHeight}
width={width}
height={height}
position={showPopup ? popupPosition! : undefined}
trianglePosition={showPopup ? trianglePosition! : undefined}
className='select-text overflow-y-auto'
@@ -127,8 +137,8 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
className=''
ref={footnoteRef}
style={{
width: `${popupWidth}px`,
height: `${popupHeight}px`,
width: `${width}px`,
height: `${height}px`,
}}
></div>
</Popup>
@@ -5,12 +5,14 @@ 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 useTrafficLight from '@/hooks/useTrafficLight';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SidebarToggler from './SidebarToggler';
import BookmarkToggler from './BookmarkToggler';
import NotebookToggler from './NotebookToggler';
import SettingsToggler from './SettingsToggler';
import ViewMenu from './ViewMenu';
interface HeaderBarProps {
@@ -48,8 +50,9 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
className={clsx(
`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 rounded-window-top-right transition-opacity duration-300`,
!isSideBarVisible && 'rounded-window-top-left',
`shadow-xs bg-base-100 transition-opacity duration-300`,
isTauriAppPlatform() && 'rounded-window-top-right',
!isSideBarVisible && isTauriAppPlatform() && 'rounded-window-top-left',
isHoveredAnim && 'hover-bar-anim',
hoveredBookKey === bookKey || isDropdownOpen ? `visible` : `opacity-0`,
isDropdownOpen && 'header-bar-pinned',
@@ -57,16 +60,17 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
onMouseEnter={() => setHoveredBookKey(bookKey)}
onMouseLeave={() => setHoveredBookKey('')}
>
<div className='sidebar-bookmark-toggler flex h-full items-center'>
<div className='sidebar-bookmark-toggler flex h-full items-center space-x-2 sm:space-x-4'>
<SidebarToggler bookKey={bookKey} />
<BookmarkToggler bookKey={bookKey} />
</div>
<div className='header-title flex flex-1 items-center justify-center'>
<div className='header-title pointer-events-none absolute inset-0 hidden items-center justify-center sm:flex'>
<h2 className='line-clamp-1 max-w-[80%] text-center text-xs font-semibold'>{bookTitle}</h2>
</div>
<div className='flex h-full items-center space-x-2'>
<div className='ml-auto flex h-full items-center space-x-2 sm:space-x-4'>
<SettingsToggler />
<NotebookToggler bookKey={bookKey} />
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
@@ -1,5 +1,5 @@
import React from 'react';
import { TbLayoutSidebarRight, TbLayoutSidebarRightFilled } from 'react-icons/tb';
import { LuNotebookPen } from 'react-icons/lu';
import { useSidebarStore } from '@/store/sidebarStore';
import { useNotebookStore } from '@/store/notebookStore';
@@ -26,9 +26,9 @@ const NotebookToggler: React.FC<NotebookTogglerProps> = ({ bookKey }) => {
<Button
icon={
sideBarBookKey == bookKey && isNotebookVisible ? (
<TbLayoutSidebarRightFilled size={20} className='text-base-content' />
<LuNotebookPen size={16} className='text-base-content' />
) : (
<TbLayoutSidebarRight size={20} className='text-base-content' />
<LuNotebookPen size={16} className='text-base-content' />
)
}
onClick={handleToggleSidebar}
@@ -1,14 +1,16 @@
'use client';
import clsx from 'clsx';
import * as React from 'react';
import { useEffect, Suspense, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import ReaderContent from './ReaderContent';
import { AboutWindow } from '@/components/AboutWindow';
import { useSettingsStore } from '@/store/settingsStore';
import { isTauriAppPlatform } from '@/services/environment';
import { AboutWindow } from '@/components/AboutWindow';
import { Toast } from '@/components/Toast';
import ReaderContent from './ReaderContent';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig } = useEnv();
@@ -33,10 +35,16 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
return (
library.length > 0 &&
settings.globalReadSettings && (
<div className='reader-page bg-base-100 text-base-content min-h-screen select-none'>
<div
className={clsx(
`reader-page bg-base-100 text-base-content select-none`,
isTauriAppPlatform() && 'rounded-window',
)}
>
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
<Toast />
</Suspense>
</div>
)
@@ -26,6 +26,7 @@ import Spinner from '@/components/Spinner';
import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid';
import TTSControl from './tts/TTSControl';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const router = useRouter();
@@ -88,6 +89,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { book } = getBookData(bookKey) || {};
const { isPrimary } = getViewState(bookKey) || {};
if (isPrimary && book && config) {
const settings = useSettingsStore.getState().settings;
await saveConfig(envConfig, bookKey, config, settings);
}
};
@@ -100,6 +102,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
} catch {
console.info('Error closing book', bookKey);
}
eventDispatcher.dispatch('tts-stop', { bookKey });
await saveBookConfig(bookKey);
clearViewState(bookKey);
};
@@ -110,6 +113,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
};
const handleCloseBooks = async () => {
const settings = useSettingsStore.getState().settings;
await Promise.all(bookKeys.map((key) => saveConfigAndCloseBook(key)));
await saveSettings(envConfig, settings);
};
@@ -141,7 +145,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
setTimeout(() => setLoading(true), 300);
return (
loading && (
<div className={'hero hero-content min-h-screen'}>
<div className={'hero hero-content h-dvh'}>
<Spinner loading={true} />
</div>
)
@@ -149,9 +153,10 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}
return (
<div className='flex h-screen'>
<div className='flex h-dvh'>
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
<TTSControl />
<Notebook />
{showDetailsBook && (
<BookDetailModal
@@ -0,0 +1,24 @@
import React from 'react';
import { RiFontSize } from 'react-icons/ri';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import Button from '@/components/Button';
const SettingsToggler = () => {
const _ = useTranslation();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const handleToggleSettings = () => {
setFontLayoutSettingsDialogOpen(!isFontLayoutSettingsDialogOpen);
};
return (
<Button
icon={<RiFontSize size={16} className='text-base-content' />}
onClick={handleToggleSettings}
tooltip={_('Font & Layout')}
tooltipDirection='bottom'
></Button>
);
};
export default SettingsToggler;
@@ -92,7 +92,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<button
onClick={zoomOut}
className={clsx(
'hover:bg-base-200 text-base-content rounded-full p-2',
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomLevel <= MIN_ZOOM_LEVEL && 'btn-disabled text-gray-400',
)}
>
@@ -100,7 +100,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
</button>
<button
className={clsx(
'hover:bg-base-200 text-base-content h-8 min-h-8 w-[50%] rounded-md p-1 text-center',
'hover:bg-base-300 text-base-content h-8 min-h-8 w-[50%] rounded-md p-1 text-center',
)}
onClick={resetZoom}
>
@@ -109,7 +109,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<button
onClick={zoomIn}
className={clsx(
'hover:bg-base-200 text-base-content rounded-full p-2',
'hover:bg-base-300 text-base-content rounded-full p-2',
zoomLevel >= MAX_ZOOM_LEVEL && 'btn-disabled text-gray-400',
)}
>
@@ -6,7 +6,8 @@ import { FaWikipediaW } from 'react-icons/fa';
import { BsPencilSquare } from 'react-icons/bs';
import { RiDeleteBinLine } from 'react-icons/ri';
import { BsTranslate } from 'react-icons/bs';
import { SiDeepl } from 'react-icons/si';
import { TbHexagonLetterD } from 'react-icons/tb';
import { FaHeadphones } from 'react-icons/fa6';
import * as CFI from 'foliate-js/epubcfi.js';
import { Overlayer } from 'foliate-js/overlayer.js';
@@ -17,17 +18,18 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { useNotesSync } from '../../hooks/useNotesSync';
import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import Toast from '@/components/Toast';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import DeepLPopup from './DeepLPopup';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
@@ -54,7 +56,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
const [translatorPopupPosition, setTranslatorPopupPosition] = useState<Position>();
const [toastMessage, setToastMessage] = useState('');
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(
@@ -82,7 +83,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelection({ key: bookKey, text: sel.toString(), range: sel.getRangeAt(0), index });
}
};
detail.doc?.addEventListener('pointerup', handlePointerup);
if (bookData.book?.format !== 'PDF') {
detail.doc?.addEventListener('pointerup', handlePointerup);
}
};
const onDrawAnnotation = (event: Event) => {
@@ -191,11 +194,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, bookKey]);
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
}, [toastMessage]);
useEffect(() => {
if (!progress) return;
const { location } = progress;
@@ -220,7 +218,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleCopy = () => {
if (!selection || !selection.text) return;
setToastMessage('Copied to notebook');
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Copied to notebook'),
className: 'whitespace-nowrap',
timeout: 2000,
});
const { booknotes: annotations = [] } = config;
if (selection) navigator.clipboard.writeText(selection.text);
@@ -332,19 +335,26 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowDeepLPopup(true);
};
const handleSpeakText = async () => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
};
const selectionAnnotated = selection?.annotated;
const buttons = [
{ tooltipText: 'Copy', Icon: FiCopy, onClick: handleCopy },
{ tooltipText: _('Copy'), Icon: FiCopy, onClick: handleCopy },
{
tooltipText: selectionAnnotated ? 'Delete Highlight' : 'Highlight',
tooltipText: selectionAnnotated ? _('Delete Highlight') : _('Highlight'),
Icon: selectionAnnotated ? RiDeleteBinLine : PiHighlighterFill,
onClick: handleHighlight,
},
{ tooltipText: 'Annotate', Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: 'Search', Icon: FiSearch, onClick: handleSearch },
{ tooltipText: 'Dictionary', Icon: BsTranslate, onClick: handleDictionary },
{ tooltipText: 'Translate', Icon: SiDeepl, onClick: handleTranslation },
{ tooltipText: 'Wikipedia', Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Annotate'), Icon: BsPencilSquare, onClick: handleAnnotate },
{ tooltipText: _('Search'), Icon: FiSearch, onClick: handleSearch },
{ tooltipText: _('Dictionary'), Icon: TbHexagonLetterD, onClick: handleDictionary },
{ tooltipText: _('Wikipedia'), Icon: FaWikipediaW, onClick: handleWikipedia },
{ tooltipText: _('Translate'), Icon: BsTranslate, onClick: handleTranslation },
{ tooltipText: _('Speak'), Icon: FaHeadphones, onClick: handleSpeakText },
];
return (
@@ -399,8 +409,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onHighlight={handleHighlight}
/>
)}
{toastMessage && <Toast message={toastMessage} alertClass='bg-neutual text-content' />}
</div>
);
};
@@ -1,8 +1,10 @@
import React, { useEffect, useState } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { getAPIBaseUrl } from '@/services/environment';
import { useSettingsStore } from '@/store/settingsStore';
import { isWebAppPlatform } from '@/services/environment';
import { useTranslation } from '@/hooks/useTranslation';
import { useAuth } from '@/context/AuthContext';
const LANGUAGES = {
AUTO: 'Auto Detect',
@@ -22,6 +24,8 @@ const LANGUAGES = {
'ZH-HANT': 'Chinese (Traditional)',
};
const DEEPL_API_ENDPOINT = getAPIBaseUrl() + '/deepl/translate';
interface DeepLPopupProps {
text: string;
position: Position;
@@ -37,6 +41,8 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
popupWidth,
popupHeight,
}) => {
const _ = useTranslation();
const { token } = useAuth();
const { settings, setSettings } = useSettingsStore();
const [sourceLang, setSourceLang] = useState('AUTO');
const [targetLang, setTargetLang] = useState(settings.globalReadSettings.translateTargetLang);
@@ -61,23 +67,12 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
setError(null);
setTranslation(null);
if (!process.env['NEXT_PUBLIC_DEEPL_API_KEY']) {
console.error('DeepL API key not found. Set NEXT_PUBLIC_DEEPL_API_KEY in .env.local');
}
const { fetch, url } = isWebAppPlatform()
? { fetch: window.fetch, url: '/api/deepl/translate' }
: {
fetch: (await import('@tauri-apps/plugin-http')).fetch,
url: 'https://api-free.deepl.com/v2/translate',
};
try {
const response = await fetch(url, {
const response = await fetch(DEEPL_API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `DeepL-Auth-Key ${process.env['NEXT_PUBLIC_DEEPL_API_KEY']}`,
Authorization: `Bearer ${token ?? ''}`,
},
body: JSON.stringify({
text: [text],
@@ -104,14 +99,15 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
setTranslation(translatedText);
} catch (err) {
console.error(err);
setError('Unable to fetch the translation. Try again later.');
setError(_('Unable to fetch the translation. Try again later.'));
} finally {
setLoading(false);
}
};
fetchTranslation();
}, [text, sourceLang, targetLang]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, token, sourceLang, targetLang]);
return (
<div>
@@ -124,7 +120,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
>
<div className='text-neutral-content relative h-[50%] overflow-y-auto border-b border-neutral-400/75 p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h1 className='text-base font-semibold'>Original Text</h1>
<h1 className='text-base font-semibold'>{_('Original Text')}</h1>
<select
value={sourceLang}
onChange={handleSourceLangChange}
@@ -134,7 +130,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
return (
<option key={code} value={code}>
{detectedSourceLang && sourceLang === 'AUTO' && code === 'AUTO'
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} (detected)`
? `${LANGUAGES[detectedSourceLang] || detectedSourceLang} ` + _('(detected)')
: name}
</option>
);
@@ -146,7 +142,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
<div className='text-neutral-content relative h-[50%] overflow-y-auto p-4 font-sans'>
<div className='mb-2 flex items-center justify-between'>
<h2 className='text-base font-semibold'>Translated Text</h2>
<h2 className='text-base font-semibold'>{_('Translated Text')}</h2>
<select
value={targetLang}
onChange={handleTargetLangChange}
@@ -163,7 +159,7 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
</div>
{loading ? (
<p className='text-base italic text-gray-500'>Loading...</p>
<p className='text-base italic text-gray-500'>{_('Loading...')}</p>
) : error ? (
<p className='text-base text-red-600'>{error}</p>
) : (
@@ -51,7 +51,8 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
>
<div
className={clsx(
'h-4 w-4 p-0 text-center leading-none',
'w-4 p-0 text-center leading-none',
style === 'highlight' ? 'h-4' : 'h-5',
style === 'highlight' &&
(selectedStyle === 'highlight' ? `bg-${selectedColor}-400` : `bg-gray-300`),
(style === 'underline' || style === 'squiggly') &&
@@ -15,7 +15,7 @@ const PopupButton: React.FC<PopupButtonProps> = ({ showTooltip, tooltipText, Ico
};
return (
<div
className='tooltip tooltip-bottom'
className='lg:tooltip lg:tooltip-bottom'
data-tip={!buttonClicked && showTooltip ? tooltipText : null}
>
<button
@@ -1,26 +1,44 @@
import clsx from 'clsx';
import React from 'react';
import { FiSearch } from 'react-icons/fi';
import { LuNotebookPen } from 'react-icons/lu';
import { MdOutlinePushPin, MdPushPin } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
const NotebookHeader: React.FC<{
isPinned: boolean;
handleClose: () => void;
handleTogglePin: () => void;
}> = ({ isPinned, handleTogglePin }) => {
}> = ({ isPinned, handleClose, handleTogglePin }) => {
const _ = useTranslation();
return (
<div className='notebook-header relative flex h-11 items-center px-3'>
<div className='absolute inset-0 flex items-center justify-center'>
<div className='absolute inset-0 flex items-center justify-center space-x-2'>
<LuNotebookPen size={14} />
<div className='notebook-title text-sm font-medium'>{_('Notebook')}</div>
</div>
<div className='z-10 flex items-center space-x-3'>
<div className='z-10 flex items-center gap-x-3'>
<button
onClick={handleTogglePin}
className={`${isPinned ? 'bg-base-300' : 'bg-base-300/65'} btn btn-ghost btn-circle h-6 min-h-6 w-6`}
className={clsx(
'btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 sm:flex',
isPinned ? 'bg-base-300' : 'bg-base-300/65',
)}
>
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
</button>
<button
onClick={handleClose}
className={'bg-base-300/65 btn btn-ghost btn-circle h-6 min-h-6 w-6 sm:hidden'}
>
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path
fill='currentColor'
d='M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z'
/>
</svg>
</button>
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
<FiSearch size={18} />
</button>
@@ -8,13 +8,15 @@ import { useSidebarStore } from '@/store/sidebarStore';
import { useNotebookStore } from '@/store/notebookStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useEnv } from '@/context/EnvContext';
import useDragBar from '../../hooks/useDragBar';
import NotebookHeader from './Header';
import NoteEditor from './NoteEditor';
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';
import NoteEditor from './NoteEditor';
const MIN_NOTEBOOK_WIDTH = 0.15;
const MAX_NOTEBOOK_WIDTH = 0.45;
@@ -31,10 +33,23 @@ const Notebook: React.FC = ({}) => {
const { setNotebookWidth, setNotebookVisible, toggleNotebookPin } = useNotebookStore();
const { setNotebookNewAnnotation, setNotebookEditAnnotation } = useNotebookStore();
const onNavigateEvent = async () => {
const pinButton = document.querySelector('.sidebar-pin-btn');
const isPinButtonHidden = !pinButton || window.getComputedStyle(pinButton).display === 'none';
if (isPinButtonHidden) {
setNotebookVisible(false);
}
};
useEffect(() => {
setNotebookWidth(settings.globalReadSettings.notebookWidth);
setNotebookPin(settings.globalReadSettings.isNotebookPinned);
setNotebookVisible(settings.globalReadSettings.isNotebookPinned);
eventDispatcher.on('navigate', onNavigateEvent);
return () => {
eventDispatcher.off('navigate', onNavigateEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -126,7 +141,7 @@ const Notebook: React.FC = ({}) => {
<div
className={clsx(
'notebook-container bg-base-200 right-0 z-20 h-full min-w-60 select-none',
'rounded-window-top-right rounded-window-bottom-right',
isTauriAppPlatform() && 'rounded-window-top-right rounded-window-bottom-right',
!isNotebookPinned && 'shadow-2xl',
)}
style={{
@@ -135,11 +150,23 @@ const Notebook: React.FC = ({}) => {
position: isNotebookPinned ? 'relative' : 'absolute',
}}
>
<style jsx>{`
@media (max-width: 640px) {
.notebook-container {
width: 100%;
min-width: 100%;
}
}
`}</style>
<div
className='drag-bar absolute left-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleMouseDown}
/>
<NotebookHeader isPinned={isNotebookPinned} handleTogglePin={handleTogglePin} />
<NotebookHeader
isPinned={isNotebookPinned}
handleClose={() => setNotebookVisible(false)}
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>
<ul className=''>
@@ -28,7 +28,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Theme Mode')}</h2>
<div className='flex gap-2'>
<div className='tooltip tooltip-bottom' data-tip={_('Auto Mode')}>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Auto Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('auto')}
@@ -37,7 +37,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</button>
</div>
<div className='tooltip tooltip-bottom' data-tip={_('Light Mode')}>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Light Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('light')}
@@ -46,7 +46,7 @@ const ColorPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</button>
</div>
<div className='tooltip tooltip-bottom' data-tip={_('Dark Mode')}>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Dark Mode')}>
<button
className={`btn btn-ghost btn-circle ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
onClick={() => updateThemeMode('dark')}
@@ -29,7 +29,12 @@ const DialogMenu: React.FC<DialogMenuProps> = ({ toggleDropdown }) => {
<span style={{ minWidth: '20px' }}>
{isFontLayoutSettingsGlobal && <MdCheck size={20} className='text-base-content' />}
</span>
<div className='tooltip' data-tip='Uncheck for current book settings'>
<div
className='lg:tooltip'
data-tip={
isFontLayoutSettingsGlobal ? _('Apply to all books') : _('Apply to this book')
}
>
<span className='ml-2'>{_('Global Settings')}</span>
</div>
</div>
@@ -57,12 +57,12 @@ const FontDropdown: React.FC<DropdownProps> = ({
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] overflow-y-scroll shadow',
'!mr-5 mb-[-46px] max-h-80 w-[292px]',
'!mr-5 mb-[-46px] inline max-h-80 w-[250px] overflow-y-scroll',
)}
>
{moreOptions.map((option) => (
<li key={option} onClick={() => onSelect(option)}>
<div className='flex items-center px-0'>
<div className='flex items-center px-2'>
<span style={{ minWidth: '20px' }}>
{selected === option && <MdCheck size={20} className='text-base-content' />}
</span>
@@ -218,7 +218,7 @@ const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
<div className='config-item config-item-bottom'>
<span className=''>{_('Override Publisher Font')}</span>
<span className=''>{_('Override Book Font')}</span>
<input
type='checkbox'
className='toggle'
@@ -1,17 +1,24 @@
import React, { useEffect, useState } from 'react';
import { MdOutlineAutoMode } from 'react-icons/md';
import { MdOutlineTextRotationDown, MdOutlineTextRotationNone } from 'react-icons/md';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import NumberInput from './NumberInput';
import { useTheme } from '@/hooks/useTheme';
import { getStyles } from '@/utils/style';
import { getBookLangCode } from '@/utils/book';
import NumberInput from './NumberInput';
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { settings, isFontLayoutSettingsGlobal, setSettings } = useSettingsStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const { themeCode } = useTheme();
@@ -23,6 +30,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [maxColumnCount, setMaxColumnCount] = useState(viewSettings.maxColumnCount!);
const [maxInlineSize, setMaxInlineSize] = useState(viewSettings.maxInlineSize!);
const [maxBlockSize, setMaxBlockSize] = useState(viewSettings.maxBlockSize!);
const [writingMode, setWritingMode] = useState(viewSettings.writingMode!);
useEffect(() => {
viewSettings.lineHeight = lineHeight;
@@ -111,8 +119,66 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxInlineSize]);
useEffect(() => {
viewSettings.maxBlockSize = maxBlockSize;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.maxBlockSize = maxBlockSize;
setSettings(settings);
}
view?.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxBlockSize]);
useEffect(() => {
// global settings are not supported for writing mode
viewSettings.writingMode = writingMode;
setViewSettings(bookKey, viewSettings);
view?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writingMode]);
const langCode = getBookLangCode(bookData.bookDoc?.metadata?.language);
const isCJKBook = langCode === 'zh' || langCode === 'ja' || langCode === 'ko';
return (
<div className='my-4 w-full space-y-6'>
{isCJKBook && (
<div className='w-full'>
<div className='flex items-center justify-between'>
<h2 className='font-medium'>{_('Writing Mode')}</h2>
<div className='flex gap-2'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Default')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('auto')}
>
<MdOutlineAutoMode size={20} />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Horizontal Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'horizontal-tb' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('horizontal-tb')}
>
<MdOutlineTextRotationNone size={20} />
</button>
</div>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Vertical Direction')}>
<button
className={`btn btn-ghost btn-circle ${writingMode === 'vertical-rl' ? 'btn-active bg-base-300' : ''}`}
onClick={() => setWritingMode('vertical-rl')}
>
<MdOutlineTextRotationDown size={20} />
</button>
</div>
</div>
</div>
</div>
)}
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Paragraph')}</h2>
<div className='card bg-base-100 border-base-200 border shadow'>
@@ -154,7 +220,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='divide-base-200 divide-y'>
<NumberInput
className='config-item-top'
label={_('Margins (px)')}
label={_('Vertical Margins (px)')}
value={marginPx}
onChange={setMarginPx}
min={0}
@@ -162,7 +228,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
step={4}
/>
<NumberInput
label={_('Gaps (%)')}
label={_('Horizontal Margins (%)')}
value={gapPercent}
onChange={setGapPercent}
min={0}
@@ -173,7 +239,7 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
value={maxColumnCount}
onChange={setMaxColumnCount}
min={1}
max={2}
max={4}
/>
<NumberInput
label={_('Maximum Inline Size')}
@@ -1,13 +1,12 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import cssbeautify from 'cssbeautify';
import { getStyles } from '@/utils/style';
import { useTheme } from '@/hooks/useTheme';
const cssRegex =
/((?:\s*)([\w#.@*,:\-.:>+~$$$$\"=(),*\s]+)\s*{(?:[\s]*)((?:[^\}]+[:][^\}]+;?)*)*\s*}(?:\s*))/gim;
import cssbeautify from 'cssbeautify';
import cssValidate from '@/utils/css';
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -18,44 +17,51 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [animated, setAnimated] = useState(viewSettings.animated!);
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [userStylesheet, setUserStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheet, setDraftStylesheet] = useState(viewSettings.userStylesheet!);
const [draftStylesheetSaved, setDraftStylesheetSaved] = useState(true);
const [error, setError] = useState<string | null>(null);
let cssInput = userStylesheet;
const validateCSS = (css: string) => {
return cssRegex.test(css);
};
const handleUserStylesheetChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
cssInput = e.target.value;
const cssInput = e.target.value;
setDraftStylesheet(cssInput);
setDraftStylesheetSaved(false);
try {
const formattedCSS = cssbeautify(cssInput, {
indent: ' ',
openbrace: 'end-of-line',
autosemicolon: true,
});
setUserStylesheet(formattedCSS);
if (cssInput && !validateCSS(cssInput)) {
throw new Error('Invalid CSS');
const { isValid, error } = cssValidate(cssInput);
if (cssInput && !isValid) {
throw new Error(error || 'Invalid CSS');
}
setError(null);
viewSettings.userStylesheet = formattedCSS;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Invalid CSS: Please check your input.');
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
} catch (err) {
setError('Invalid CSS: Please check your input.');
console.log('CSS Error:', err);
}
};
const applyStyles = () => {
const formattedCSS = cssbeautify(draftStylesheet, {
indent: ' ',
openbrace: 'end-of-line',
autosemicolon: true,
});
setDraftStylesheet(formattedCSS);
setDraftStylesheetSaved(true);
viewSettings.userStylesheet = formattedCSS;
setViewSettings(bookKey, viewSettings);
if (isFontLayoutSettingsGlobal) {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
}
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings, themeCode));
};
const handleInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
@@ -90,10 +96,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Animation')}</h2>
<div className='card bg-base-100 border shadow'>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-y'>
<div className='config-item config-item-top config-item-bottom'>
<span className='text-gray-700'>{_('Paging Animation')}</span>
<span className=''>{_('Paging Animation')}</span>
<input
type='checkbox'
className='toggle'
@@ -107,10 +113,10 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Behavior')}</h2>
<div className='card bg-base-100 border shadow'>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-y'>
<div className='config-item config-item-top config-item-bottom'>
<span className='text-gray-700'>{_('Disable Click-to-Flip')}</span>
<span className=''>{_('Disable Click-to-Flip')}</span>
<input
type='checkbox'
className='toggle'
@@ -124,22 +130,34 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Custom CSS')}</h2>
<div className={`card bg-base-100 border shadow ${error ? 'border-red-500' : ''}`}>
<div className='divide-y'>
<div className='css-text-area config-item-top config-item-bottom p-1'>
<textarea
className='textarea textarea-ghost h-48 w-full border-0 p-3 !outline-none'
placeholder={_('Enter your custom CSS here...')}
spellCheck='false'
value={cssInput}
onInput={handleInput}
onKeyDown={handleInput}
onKeyUp={handleInput}
onChange={handleUserStylesheetChange}
/>
</div>
<div
className={`card border-base-200 bg-base-100 border shadow ${error ? 'border-red-500' : ''}`}
>
<div className='relative p-1'>
<textarea
className='textarea textarea-ghost h-48 w-full border-0 p-3 !outline-none'
placeholder={_('Enter your custom CSS here...')}
spellCheck='false'
value={draftStylesheet}
onInput={handleInput}
onKeyDown={handleInput}
onKeyUp={handleInput}
onChange={handleUserStylesheetChange}
/>
<button
className={clsx(
'btn btn-ghost bg-base-200 absolute bottom-2 right-4 h-8 min-h-8 px-4 py-2',
draftStylesheetSaved ? 'hidden' : '',
error ? 'btn-disabled' : '',
)}
onClick={applyStyles}
disabled={!!error}
>
{_('Apply')}
</button>
</div>
</div>
{error && <p className='mt-1 text-sm text-red-500'>{error}</p>}
</div>
</div>
);
@@ -25,11 +25,17 @@ const NumberInput: React.FC<NumberInputProps> = ({
const numberStep = step || 1;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value === '' ? 0 : parseFloat(e.target.value);
setLocalValue(newValue);
if (!isNaN(newValue)) {
const roundedValue = Math.round(newValue * 10) / 10;
onChange(Math.max(min, Math.min(max, roundedValue)));
const value = e.target.value;
// Allow empty string or valid numbers without leading zeros
if (value === '' || /^[1-9]\d*\.?\d*$|^0?\.?\d*$/.test(value)) {
const newValue = value === '' ? 0 : parseFloat(value);
setLocalValue(newValue);
if (!isNaN(newValue)) {
const roundedValue = Math.round(newValue * 10) / 10;
onChange(Math.max(min, Math.min(max, roundedValue)));
}
}
};
@@ -59,6 +65,7 @@ const NumberInput: React.FC<NumberInputProps> = ({
<div className='text-base-content flex items-center gap-2'>
<input
type='text'
inputMode='decimal'
value={localValue}
onChange={handleChange}
onBlur={handleOnBlur}
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { BookConfig } from '@/types/book';
import { useSettingsStore } from '@/store/settingsStore';
@@ -18,6 +19,12 @@ import MiscPanel from './MiscPanel';
type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Misc';
type TabConfig = {
tab: SettingsPanelType;
icon: React.ElementType;
label: string;
};
const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ bookKey }) => {
const _ = useTranslation();
const [activePanel, setActivePanel] = useState<SettingsPanelType>('Font');
@@ -37,43 +44,51 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const tabConfig = [
{
tab: 'Font',
icon: RiFontSize,
label: _('Font'),
},
{
tab: 'Layout',
icon: RiDashboardLine,
label: _('Layout'),
},
{
tab: 'Color',
icon: VscSymbolColor,
label: _('Color'),
},
{
tab: 'Misc',
icon: IoAccessibilityOutline,
label: _('Misc'),
},
] as TabConfig[];
return (
<dialog className='modal modal-open min-w-90 w-full !bg-[rgba(0,0,0,0.2)]'>
<div className='modal-box settings-content flex h-[65%] w-1/2 min-w-[540px] max-w-full flex-col p-0'>
<dialog className='modal modal-open sm:min-w-90 h-full w-full !bg-[rgba(0,0,0,0.2)] sm:w-full'>
<div className='modal-box settings-content flex h-full max-h-full w-full max-w-full flex-col rounded-none p-0 sm:h-[65%] sm:w-1/2 sm:min-w-[480px] sm:max-w-full sm:rounded-2xl'>
<div className='dialog-header bg-base-100 sticky top-0 z-10 flex items-center justify-center px-4 pt-2'>
<div className='dialog-tabs flex h-10 max-w-[80%] flex-grow items-center justify-around'>
<button
className={`btn btn-ghost text-base-content h-8 min-h-8 px-4 ${activePanel === 'Font' ? 'btn-active' : ''}`}
onClick={() => setActivePanel('Font')}
>
<RiFontSize size={20} className='mr-0' />
{_('Font')}
</button>
<button
className={`btn btn-ghost text-base-content h-8 min-h-8 px-4 ${activePanel === 'Layout' ? 'btn-active' : ''}`}
onClick={() => setActivePanel('Layout')}
>
<RiDashboardLine size={20} className='mr-0' />
{_('Layout')}
</button>
<button
className={`btn btn-ghost text-base-content h-8 min-h-8 px-4 ${activePanel === 'Color' ? 'btn-active' : ''}`}
onClick={() => setActivePanel('Color')}
>
<VscSymbolColor size={20} className='mr-0' />
{_('Color')}
</button>
<button
className={`btn btn-ghost text-base-content h-8 min-h-8 px-4 ${activePanel === 'Misc' ? 'btn-active' : ''}`}
onClick={() => setActivePanel('Misc')}
>
<IoAccessibilityOutline size={20} className='mr-0' />
{_('Misc')}
</button>
<div className='dialog-tabs flex h-10 max-w-[75%] flex-grow items-center justify-around sm:max-w-[80%]'>
{tabConfig.map(({ tab, icon: Icon, label }) => (
<button
key={tab}
className={clsx(
'btn btn-ghost text-base-content h-8 min-h-8',
activePanel === tab ? 'btn-active' : '',
)}
onClick={() => setActivePanel(tab)}
>
<Icon size={20} className='mr-0' />
{window.innerWidth >= 500 ? label : ''}
</button>
))}
</div>
<div className='flex h-full items-center justify-end'>
<Dropdown
className='dropdown-bottom dropdown-end absolute right-[7%]'
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeVerticalBold size={16} />}
>
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import Image from 'next/image';
@@ -10,10 +11,11 @@ import { DOWNLOAD_READEST_URL } from '@/services/constants';
import useBooksManager from '../../hooks/useBooksManager';
interface BookMenuProps {
menuClassName?: string;
setIsDropdownOpen?: (isOpen: boolean) => void;
}
const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
const _ = useTranslation();
const { library } = useLibraryStore();
const { openParallelView } = useBooksManager();
@@ -39,7 +41,10 @@ const BookMenu: React.FC<BookMenuProps> = ({ setIsDropdownOpen }) => {
return (
<div
tabIndex={0}
className='book-menu dropdown-content dropdown-center border-base-100 z-20 mt-3 w-60 shadow-2xl'
className={clsx(
'book-menu dropdown-content border-base-100 z-20 w-60 shadow-2xl',
menuClassName,
)}
>
<MenuItem label={_('Parallel Read')} noIcon>
<ul className='max-h-60 overflow-y-auto'>
@@ -9,6 +9,7 @@ import { useNotebookStore } from '@/store/notebookStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import useScrollToItem from '../../hooks/useScrollToItem';
import { eventDispatcher } from '@/utils/event';
interface BooknoteItemProps {
bookKey: string;
@@ -29,6 +30,8 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const handleClickItem = (event: React.MouseEvent) => {
event.preventDefault();
eventDispatcher.dispatch('navigate', { bookKey, cfi });
getView(bookKey)?.goTo(cfi);
if (note) {
setNotebookVisible(true);
@@ -122,7 +125,16 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
)}
onClick={editNote.bind(null, item)}
>
<div className='align-bottom text-blue-400'>{_('Edit')}</div>
<div
className={clsx(
'align-bottom text-blue-400',
'transition duration-300 ease-in-out',
'opacity-0 group-hover:opacity-100',
'hover:text-blue-600',
)}
>
{_('Edit')}
</div>
</button>
)}
<button
@@ -132,7 +144,16 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
)}
onClick={deleteNote.bind(null, item)}
>
<div className='align-bottom text-red-400'>{_('Delete')}</div>
<div
className={clsx(
'align-bottom text-red-400',
'transition duration-300 ease-in-out',
'opacity-0 group-hover:opacity-100',
'hover:text-red-600',
)}
>
{_('Delete')}
</div>
</button>
</div>
</div>
@@ -15,6 +15,8 @@ const SidebarContent: React.FC<{
const { getConfig, setConfig } = useBookDataStore();
const config = getConfig(sideBarBookKey);
const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc');
const [fade, setFade] = useState(false);
const [targetTab, setTargetTab] = useState(activeTab);
useEffect(() => {
const container = scrollContainerRef.current;
@@ -49,10 +51,17 @@ const SidebarContent: React.FC<{
}, [sideBarBookKey]);
const handleTabChange = (tab: string) => {
setFade(true);
const timeout = setTimeout(() => {
setFade(false)
setTargetTab(tab);
setConfig(sideBarBookKey!, config);
clearTimeout(timeout);
}, 300);
setActiveTab(tab);
const config = getConfig(sideBarBookKey!)!;
config.viewSettings!.sideBarTab = tab;
setConfig(sideBarBookKey!, config);
};
return (
@@ -63,14 +72,20 @@ const SidebarContent: React.FC<{
'font-sans text-sm font-normal shadow-inner',
)}
>
<div ref={scrollContainerRef} className='scroll-container overflow-y-auto'>
{activeTab === 'toc' && bookDoc.toc && (
<div
ref={scrollContainerRef}
className={clsx(
'scroll-container overflow-y-auto transition-opacity duration-300 ease-in-out',
{ 'opacity-0': fade, 'opacity-100': !fade }
)}
>
{targetTab === 'toc' && bookDoc.toc && (
<TOCView toc={bookDoc.toc} bookKey={sideBarBookKey} />
)}
{activeTab === 'annotations' && (
{targetTab === 'annotations' && (
<BooknoteView type='annotation' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
{activeTab === 'bookmarks' && (
{targetTab === 'bookmarks' && (
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
)}
</div>
@@ -12,9 +12,10 @@ const SidebarHeader: React.FC<{
isPinned: boolean;
isSearchBarVisible: boolean;
onGoToLibrary: () => void;
onClose: () => void;
onTogglePin: () => void;
onToggleSearchBar: () => void;
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onTogglePin, onToggleSearchBar }) => {
}> = ({ isPinned, isSearchBarVisible, onGoToLibrary, onClose, onTogglePin, onToggleSearchBar }) => {
const { isTrafficLightVisible } = useTrafficLight();
return (
<div
@@ -39,7 +40,11 @@ const SidebarHeader: React.FC<{
<FiSearch size={18} className='text-base-content' />
</button>
<Dropdown
className='dropdown-bottom flex justify-center'
className={clsx(
window.innerWidth < 640 && 'dropdown-end',
'dropdown-bottom flex justify-center',
)}
menuClassName={window.innerWidth < 640 ? 'no-triangle mt-1' : 'dropdown-center mt-3'}
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu size={20} className='fill-base-content' />}
>
@@ -48,10 +53,24 @@ const SidebarHeader: React.FC<{
<div className='right-0 flex h-8 w-8 items-center justify-center'>
<button
onClick={onTogglePin}
className={`${isPinned ? 'bg-base-300' : 'bg-base-300/65'} btn btn-ghost btn-circle h-6 min-h-6 w-6`}
className={clsx(
'sidebar-pin-btn btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 sm:flex',
isPinned ? 'bg-base-300' : 'bg-base-300/65',
)}
>
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
</button>
<button
onClick={onClose}
className={'bg-base-300/65 btn btn-ghost btn-circle h-6 min-h-6 w-6 sm:hidden'}
>
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path
fill='currentColor'
d='M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z'
/>
</svg>
</button>
</div>
</div>
</div>
@@ -166,7 +166,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
className='w-full bg-transparent p-2 font-sans text-sm font-light focus:outline-none'
/>
<div className='flex h-8 w-8 items-center rounded-r-lg bg-gray-300'>
<div className='bg-base-300 flex h-8 w-8 items-center rounded-r-lg'>
<Dropdown
className='dropdown-bottom flex justify-center'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 rounded-none rounded-r-lg'
@@ -9,100 +9,68 @@ interface SearchOptionsProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
interface OptionProps {
label: string;
isActive: boolean;
onClick: () => void;
}
const Option: React.FC<OptionProps> = ({ label, isActive, onClick }) => (
<button
className='hover:bg-base-300 flex w-full items-center justify-between rounded-md p-2'
onClick={onClick}
>
<div className='flex items-center'>
<span style={{ minWidth: '20px' }}>
{isActive && <MdCheck size={20} className='text-base-content' />}
</span>
<span className='ml-2'>{label}</span>
</div>
</button>
);
const SearchOptions: React.FC<SearchOptionsProps> = ({
searchConfig,
onSearchConfigChanged,
setIsDropdownOpen,
}) => {
const _ = useTranslation();
const handleSetScope = () => {
onSearchConfigChanged({
...searchConfig,
scope: searchConfig.scope === 'book' ? 'section' : 'book',
});
setIsDropdownOpen?.(false);
};
const handleSetMatchCase = () => {
onSearchConfigChanged({ ...searchConfig, matchCase: !searchConfig.matchCase });
setIsDropdownOpen?.(false);
};
const handleSetMatchWholeWords = () => {
onSearchConfigChanged({ ...searchConfig, matchWholeWords: !searchConfig.matchWholeWords });
setIsDropdownOpen?.(false);
};
const handleSetMatchDiacritics = () => {
onSearchConfigChanged({ ...searchConfig, matchDiacritics: !searchConfig.matchDiacritics });
const updateConfig = (key: keyof BookSearchConfig, value: boolean | string) => {
onSearchConfigChanged({ ...searchConfig, [key]: value });
setIsDropdownOpen?.(false);
};
return (
<div
tabIndex={0}
className='book-menu dropdown-content dropdown-center z-20 mt-3 w-56 border bg-white shadow-2xl'
className='book-menu dropdown-content dropdown-center border-base-200 z-20 mt-3 w-56 border shadow-2xl'
>
<button
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
onClick={handleSetScope}
>
<div className='flex items-center'>
<span style={{ minWidth: '20px' }}>
{searchConfig.scope === 'book' && <MdCheck size={20} className='text-base-content' />}
</span>
<span className='ml-2'>{_('Book')}</span>
</div>
</button>
<button
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
onClick={handleSetScope}
>
<div className='flex items-center'>
<span style={{ minWidth: '20px' }}>
{searchConfig.scope === 'section' && (
<MdCheck size={20} className='text-base-content' />
)}
</span>
<span className='ml-2'>{_('Chapter')}</span>
</div>
</button>
<hr className='my-1' />
<button
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
onClick={handleSetMatchCase}
>
<div className='flex items-center'>
<span style={{ minWidth: '20px' }}>
{searchConfig.matchCase && <MdCheck size={20} className='text-base-content' />}
</span>
<span className='ml-2'>{_('Match Case')}</span>
</div>
</button>
<button
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
onClick={handleSetMatchWholeWords}
>
<div className='flex items-center'>
<span style={{ minWidth: '20px' }}>
{searchConfig.matchWholeWords && <MdCheck size={20} className='text-base-content' />}
</span>
<span className='ml-2'>{_('Match Whole Words')}</span>
</div>
</button>
<button
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
onClick={handleSetMatchDiacritics}
>
<div className='flex items-center'>
<span style={{ minWidth: '20px' }}>
{searchConfig.matchDiacritics && <MdCheck size={20} className='text-base-content' />}
</span>
<span className='ml-2'>{_('Match Diacritics')}</span>
</div>
</button>
<Option
label={_('Book')}
isActive={searchConfig.scope === 'book'}
onClick={() => updateConfig('scope', 'book')}
/>
<Option
label={_('Chapter')}
isActive={searchConfig.scope === 'section'}
onClick={() => updateConfig('scope', 'section')}
/>
<hr className='border-base-200 my-1' />
<Option
label={_('Match Case')}
isActive={searchConfig.matchCase}
onClick={() => updateConfig('matchCase', !searchConfig.matchCase)}
/>
<Option
label={_('Match Whole Words')}
isActive={searchConfig.matchWholeWords}
onClick={() => updateConfig('matchWholeWords', !searchConfig.matchWholeWords)}
/>
<Option
label={_('Match Diacritics')}
isActive={searchConfig.matchDiacritics}
onClick={() => updateConfig('matchDiacritics', !searchConfig.matchDiacritics)}
/>
</div>
);
};
@@ -7,6 +7,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { BookSearchResult } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import SidebarHeader from './Header';
import SidebarContent from './Content';
import BookCard from './BookCard';
@@ -38,7 +39,7 @@ const SideBar: React.FC<{
handleSideBarTogglePin,
} = useSidebar(
settings.globalReadSettings.sideBarWidth,
settings.globalReadSettings.isSideBarPinned,
window.innerWidth >= 640 ? settings.globalReadSettings.isSideBarPinned : false,
);
const onSearchEvent = async (event: CustomEvent) => {
@@ -48,10 +49,20 @@ const SideBar: React.FC<{
setSearchTerm(term);
};
const onNavigateEvent = async () => {
const pinButton = document.querySelector('.sidebar-pin-btn');
const isPinButtonHidden = !pinButton || window.getComputedStyle(pinButton).display === 'none';
if (isPinButtonHidden) {
setSideBarVisible(false);
}
};
useEffect(() => {
eventDispatcher.on('search', onSearchEvent);
eventDispatcher.on('navigate', onNavigateEvent);
return () => {
eventDispatcher.off('search', onSearchEvent);
eventDispatcher.off('navigate', onNavigateEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -79,6 +90,7 @@ const SideBar: React.FC<{
useShortcuts({ onToggleSearchBar: handleToggleSearchBar }, [sideBarBookKey]);
const handleSearchResultClick = (cfi: string) => {
onNavigateEvent();
getView(sideBarBookKey)?.goTo(cfi);
};
@@ -94,8 +106,8 @@ const SideBar: React.FC<{
<>
<div
className={clsx(
'sidebar-container bg-base-200 z-20 flex h-full min-w-60 flex-col',
'rounded-window-top-left rounded-window-bottom-left select-none',
'sidebar-container bg-base-200 z-20 flex h-full min-w-60 select-none flex-col',
isTauriAppPlatform() && 'rounded-window-top-left rounded-window-bottom-left',
!isSideBarPinned && 'shadow-2xl',
)}
style={{
@@ -104,11 +116,20 @@ const SideBar: React.FC<{
position: isSideBarPinned ? 'relative' : 'absolute',
}}
>
<style jsx>{`
@media (max-width: 640px) {
.sidebar-container {
width: 100%;
min-width: 100%;
}
}
`}</style>
<div className='flex-shrink-0'>
<SidebarHeader
isPinned={isSideBarPinned}
isSearchBarVisible={isSearchBarVisible}
onGoToLibrary={onGoToLibrary}
onClose={() => setSideBarVisible(false)}
onTogglePin={handleSideBarTogglePin}
onToggleSearchBar={handleToggleSearchBar}
/>
@@ -1,3 +1,4 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { TOCItem } from '@/libs/document';
@@ -5,7 +6,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { findParentPath } from '@/utils/toc';
import { getContentMd5 } from '@/utils/misc';
import clsx from 'clsx';
import { eventDispatcher } from '@/utils/event';
const createExpanderIcon = (isExpanded: boolean) => {
return (
@@ -43,6 +44,7 @@ const TOCItemView: React.FC<{
const handleClickItem = (event: React.MouseEvent) => {
event.preventDefault();
eventDispatcher.dispatch('navigate', { bookKey, href: item.href });
if (item.href) {
getView(bookKey)?.goTo(item.href);
}
@@ -126,7 +128,7 @@ const TOCView: React.FC<{
const rect = currentItem.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!isVisible) {
(currentItem as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
(currentItem as HTMLElement).scrollIntoView({ behavior: 'instant', block: 'center' });
}
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
}
@@ -1,23 +1,53 @@
import clsx from 'clsx';
import React from 'react';
import { MdToc, MdEditNote, MdBookmarkBorder } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
const TabNavigation: React.FC<{
activeTab: string;
onTabChange: (tab: string) => void;
}> = ({ activeTab, onTabChange }) => (
<div className='bottom-tab border-base-300/50 flex w-full border-t'>
{['toc', 'annotations', 'bookmarks'].map((tab) => (
<button
key={tab}
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === tab ? 'bg-base-300' : ''}`}
onClick={() => onTabChange(tab)}
>
{tab === 'toc' && <MdToc size={20} className='mx-auto' />}
{tab === 'annotations' && <MdEditNote size={20} className='mx-auto' />}
{tab === 'bookmarks' && <MdBookmarkBorder size={20} className='mx-auto' />}
</button>
))}
</div>
);
}> = ({ activeTab, onTabChange }) => {
const _ = useTranslation();
const tabs = ['toc', 'annotations', 'bookmarks'];
return (
<div className='bottom-tab border-base-300/50 relative flex w-full border-t'>
<div
className={clsx(
'bg-base-300 absolute bottom-1.5 left-1 -z-10 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'transform transition-transform duration-300',
activeTab === 'toc' && 'translate-x-0',
activeTab === 'annotations' && 'translate-x-[calc(100%+8px)]',
activeTab === 'bookmarks' && 'translate-x-[calc(200%+16px)]',
)}
/>
{tabs.map((tab) => (
<div
key={tab}
className='lg:tooltip lg:tooltip-top z-50 m-1.5 flex-1 cursor-pointer rounded-md p-2'
data-tip={
tab === 'toc'
? _('Table of Contents')
: tab === 'annotations'
? _('Annotate')
: _('Bookmark')
}
>
<div className={clsx('')} onClick={() => onTabChange(tab)}>
{tab === 'toc' ? (
<MdToc size={20} className='mx-auto' />
) : tab === 'annotations' ? (
<MdEditNote size={20} className='mx-auto' />
) : (
<MdBookmarkBorder size={20} className='mx-auto' />
)}
</div>
</div>
))}
</div>
);
};
export default TabNavigation;
@@ -0,0 +1,283 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { TTSController } from '@/services/tts/TTSController';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { throttle } from '@/utils/ui';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
const POPUP_WIDTH = 282;
const POPUP_HEIGHT = 160;
const POPUP_PADDING = 10;
const TTSControl = () => {
const _ = useTranslation();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const [bookKey, setBookKey] = useState<string>('');
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [showIndicator, setShowIndicator] = useState(false);
const [showPanel, setShowPanel] = useState(false);
const [panelPosition, setPanelPosition] = useState<Position>();
const [trianglePosition, setTrianglePosition] = useState<Position>();
const iconRef = useRef<HTMLDivElement>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
useEffect(() => {
return () => {
if (ttsControllerRef.current) {
ttsControllerRef.current.kill();
ttsControllerRef.current = null;
}
};
}, []);
useEffect(() => {
eventDispatcher.on('tts-speak', handleTTSSpeak);
eventDispatcher.on('tts-stop', handleTTSStop);
eventDispatcher.onSync('tts-is-speaking', handleQueryIsSpeaking);
return () => {
eventDispatcher.off('tts-speak', handleTTSSpeak);
eventDispatcher.off('tts-stop', handleTTSStop);
eventDispatcher.offSync('tts-is-speaking', handleQueryIsSpeaking);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey, range } = event.detail;
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (!view || !viewSettings || !bookData) return;
if (bookData.book?.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for PDF'),
type: 'warning',
});
return;
}
setBookKey(bookKey);
if (ttsControllerRef.current) {
ttsControllerRef.current.stop();
ttsControllerRef.current = null;
}
setShowIndicator(true);
try {
const ttsController = new TTSController(view);
await ttsController.init();
await ttsController.initViewTTS();
const ssml = view.tts?.from(range);
if (ssml) {
ttsController.setRate(viewSettings.ttsRate);
ttsController.setVoice(viewSettings.ttsVoice);
ttsController.speak(ssml);
ttsControllerRef.current = ttsController;
const lang = parseSSMLLang(ssml) || 'en';
setTtsLang(lang);
setIsPlaying(true);
}
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported in this device'),
type: 'error',
});
console.error(error);
}
};
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey: stopBookKey } = event.detail;
if (bookKey === stopBookKey) {
handleStop();
}
};
const handleQueryIsSpeaking = () => {
return !!ttsControllerRef.current;
};
const handleTogglePlay = async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
if (isPlaying) {
setIsPlaying(false);
setIsPaused(true);
await ttsController.pause();
} else if (isPaused) {
setIsPlaying(true);
setIsPaused(false);
// start for forward/backward/setvoice-paused
// set rate don't pause the tts
if (ttsController.state === 'paused') {
await ttsController.resume();
} else {
await ttsController.start();
}
}
};
const handleBackward = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.backward();
}
};
const handleForward = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.forward();
}
};
const handleStop = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.stop();
ttsControllerRef.current = null;
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
setShowIndicator(false);
}
};
// rate range: 0.5 - 3, 1.0 is normal speed
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSetRate = useCallback(
throttle(async (rate: number) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.stop();
await ttsController.setRate(rate);
await ttsController.start();
} else {
await ttsController.setRate(rate);
}
}
}, 3000),
[],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSetVoice = useCallback(
throttle(async (voice: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.stop();
await ttsController.setVoice(voice);
await ttsController.start();
} else {
await ttsController.setVoice(voice);
}
}
}, 3000),
[],
);
const handleGetVoices = async (lang: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoices(lang);
}
return [];
};
const handleGetVoiceId = () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoiceId();
}
return '';
};
const updatePanelPosition = () => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
const windowRect = document.documentElement.getBoundingClientRect();
const trianglePos = {
dir: 'up',
point: { x: rect.left + rect.width / 2, y: rect.top - 12 },
} as Position;
const popupPos = getPopupPosition(
trianglePos,
windowRect,
POPUP_WIDTH,
POPUP_HEIGHT,
POPUP_PADDING,
);
setPanelPosition(popupPos);
setTrianglePosition(trianglePos);
}
};
const togglePopup = () => {
updatePanelPosition();
setShowPanel((prev) => !prev);
};
const handleDismissPopup = () => {
setShowPanel(false);
};
return (
<div>
{showPanel && (
<div
className='fixed inset-0'
onClick={handleDismissPopup}
onContextMenu={handleDismissPopup}
/>
)}
{showIndicator && (
<div ref={iconRef} className='absolute bottom-12 right-6 h-12 w-12'>
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
</div>
)}
{showPanel && panelPosition && trianglePosition && (
<Popup
width={POPUP_WIDTH}
height={POPUP_HEIGHT}
position={panelPosition}
trianglePosition={trianglePosition}
className='bg-base-200 absolute flex shadow-lg'
>
<TTSPanel
bookKey={bookKey}
ttsLang={ttsLang}
isPlaying={isPlaying}
onTogglePlay={handleTogglePlay}
onBackward={handleBackward}
onForward={handleForward}
onStop={handleStop}
onSetRate={handleSetRate}
onGetVoices={handleGetVoices}
onSetVoice={handleSetVoice}
onGetVoiceId={handleGetVoiceId}
/>
</Popup>
)}
</div>
);
};
export default TTSControl;
@@ -0,0 +1,54 @@
import React from 'react';
type TTSIconProps = {
isPlaying: boolean;
onClick: () => void;
};
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, onClick }) => {
const bars = [1, 2, 3, 4];
return (
<div className='relative h-full w-full cursor-pointer' onClick={onClick}>
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
<div
className='absolute -inset-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
style={{
animation: isPlaying ? 'moveGradient 2s alternate infinite' : 'none',
}}
/>
</div>
<div className='absolute inset-0 flex items-center justify-center'>
<style>{`
@keyframes moveGradient {
0% { transform: translate(0, 0); }
100% { transform: translate(25%, 25%); }
}
@keyframes bounce {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.6); }
}
`}</style>
<div className='flex items-end space-x-1'>
{bars.map((bar) => (
<div
key={bar}
className='w-1 rounded-t bg-white'
style={{
height: '16px',
animationName: isPlaying ? 'bounce' : 'none',
animationDuration: isPlaying ? `${1 + bar * 0.1}s` : '0s',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite',
animationDelay: `${bar * 0.1}s`,
}}
/>
))}
</div>
</div>
</div>
);
};
export default TTSIcon;
@@ -0,0 +1,161 @@
import clsx from 'clsx';
import React, { useState, ChangeEvent, useEffect } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdStop } from 'react-icons/md';
import { RiVoiceAiFill } from 'react-icons/ri';
import { MdCheck } from 'react-icons/md';
import { TTSVoice } from '@/services/tts';
type TTSPanelProps = {
bookKey: string;
ttsLang: string;
isPlaying: boolean;
onTogglePlay: () => void;
onBackward: () => void;
onForward: () => void;
onStop: () => void;
onSetRate: (rate: number) => void;
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
onSetVoice: (voice: string) => void;
onGetVoiceId: () => string;
};
const TTSPanel = ({
bookKey,
ttsLang,
isPlaying,
onTogglePlay,
onBackward,
onForward,
onStop,
onSetRate,
onGetVoices,
onSetVoice,
onGetVoiceId,
}: TTSPanelProps) => {
const _ = useTranslation();
const { getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const [voices, setVoices] = useState<TTSVoice[]>([]);
const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0);
const [selectedVoice, setSelectedVoice] = useState(viewSettings?.ttsVoice ?? '');
const handleSetRate = (e: ChangeEvent<HTMLInputElement>) => {
let newRate = parseFloat(e.target.value);
newRate = Math.max(0.2, Math.min(3.0, newRate));
setRate(newRate);
onSetRate(newRate);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsRate = newRate;
setViewSettings(bookKey, viewSettings);
};
const handleSelectVoice = (voice: string) => {
onSetVoice(voice);
setSelectedVoice(voice);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsVoice = voice;
setViewSettings(bookKey, viewSettings);
};
useEffect(() => {
const voiceId = onGetVoiceId();
setSelectedVoice(voiceId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const fetchVoices = async () => {
const voices = await onGetVoices(ttsLang);
setVoices(voices);
};
fetchVoices();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsLang]);
return (
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl p-4'>
<div className='flex w-full flex-col items-center gap-0.5'>
<input
className='range'
type='range'
min={0.0}
max={3.0}
step='0.1'
value={rate}
onChange={handleSetRate}
/>
<div className='grid w-full grid-cols-7 text-xs'>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
</div>
<div className='grid w-full grid-cols-7 text-xs'>
<span className='text-center'>{_('Slow')}</span>
<span className='text-center'></span>
<span className='text-center'>1.0</span>
<span className='text-center'>1.5</span>
<span className='text-center'>2.0</span>
<span className='text-center'></span>
<span className='text-center'>{_('Fast')}</span>
</div>
</div>
<div className='flex items-center justify-between space-x-2'>
<button onClick={onBackward} className='hover:bg-base-200/75 rounded-full p-1'>
<MdFastRewind size={32} />
</button>
<button onClick={onTogglePlay} className='hover:bg-base-200/75 rounded-full p-1'>
{isPlaying ? (
<MdPauseCircle size={48} className='fill-primary' />
) : (
<MdPlayCircle size={48} className='fill-primary' />
)}
</button>
<button onClick={onForward} className='hover:bg-base-200/75 rounded-full p-1'>
<MdFastForward size={32} />
</button>
<button onClick={onStop} className='hover:bg-base-200/75 rounded-full p-1'>
<MdStop size={32} />
</button>
<div className='dropdown dropdown-top'>
<button tabIndex={0} className='hover:bg-base-200/75 rounded-full p-1'>
<RiVoiceAiFill size={32} />
</button>
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
'mt-4 inline max-h-96 w-[250px] overflow-y-scroll',
)}
>
{voices.map((voice, index) => (
<li
key={`${index}-${voice.id}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id)}
>
<div className='flex items-center px-2'>
<span style={{ minWidth: '20px' }}>
{selectedVoice === voice.id && (
<MdCheck size={20} className='text-base-content' />
)}
</span>
<span className={clsx('text-sm', voice.disabled && 'text-gray-400')}>
{voice.name}
</span>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
);
};
export default TTSPanel;
@@ -29,14 +29,7 @@ export const useClickEvent = (
if (!consumed) {
const centerStartX = rect.left + rect.width * 0.375;
const centerEndX = rect.left + rect.width * 0.625;
const centerStartY = rect.top + rect.height * 0.375;
const centerEndY = rect.top + rect.height * 0.625;
if (
screenX >= centerStartX &&
screenX <= centerEndX &&
screenY >= centerStartY &&
screenY <= centerEndY
) {
if (screenX >= centerStartX && screenX <= centerEndX) {
// toggle visibility of the header bar and the footer bar
setHoveredBookKey(hoveredBookKey ? '' : bookKey);
} else if (screenX >= rect.left + rect.width / 2) {
@@ -7,12 +7,10 @@ import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
export const useProgressSync = (
bookKey: string,
setToastMessage?: React.Dispatch<React.SetStateAction<string>>,
) => {
export const useProgressSync = (bookKey: string) => {
const _ = useTranslation();
const { getConfig, setConfig } = useBookDataStore();
const { getView } = useReaderStore();
@@ -21,6 +19,8 @@ export const useProgressSync = (
const { user } = useAuth();
const view = getView(bookKey);
const config = getConfig(bookKey);
// flag to prevent accidental sync without first pulling the config
const configSynced = useRef(false);
const pushConfig = (bookKey: string, config: BookConfig | null) => {
if (!config || !user) return;
@@ -29,16 +29,32 @@ export const useProgressSync = (
const compressedConfig = JSON.parse(
serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG),
);
delete compressedConfig.booknotes;
syncConfigs([compressedConfig], bookHash, 'push');
};
const pullConfig = (bookKey: string) => {
if (!user) return;
const bookHash = bookKey.split('-')[0]!;
syncConfigs([], bookHash, 'pull');
};
const syncConfig = () => {
if (!configSynced.current) {
pullConfig(bookKey);
} else {
if (config && config.progress && config.progress[0] > 0) {
pushConfig(bookKey, config);
}
}
};
useEffect(() => {
if (!user) return;
const bookHash = bookKey.split('-')[0]!;
syncConfigs([], bookHash, 'pull');
return () => {
pushConfig(bookKey, config);
};
pullConfig(bookKey);
// return () => {
// if (configSynced.current) {
// pushConfig(bookKey, config);
// }
// };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -46,18 +62,19 @@ export const useProgressSync = (
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!config?.location || !user) return;
const now = Date.now();
const timeSinceLastSync = now - lastProgressSyncTime.current;
if (configSynced.current && timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
if (timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
lastProgressSyncTime.current = now;
pushConfig(bookKey, config);
syncConfig();
} else {
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = setTimeout(
() => {
lastProgressSyncTime.current = Date.now();
pushConfig(bookKey, config);
syncTimeoutRef.current = null;
syncConfig();
},
SYNC_PROGRESS_INTERVAL_SEC * 1000 - timeSinceLastSync,
);
@@ -66,9 +83,9 @@ export const useProgressSync = (
}, [config]);
// sync progress once when the book is opened
const configSynced = useRef(false);
useEffect(() => {
if (!configSynced.current && syncedConfigs?.length > 0) {
if (!configSynced.current && syncedConfigs) {
configSynced.current = true;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const newConfig = deserializeConfig(
@@ -77,13 +94,15 @@ export const useProgressSync = (
DEFAULT_BOOK_SEARCH_CONFIG,
);
setConfig(bookKey, { ...config, ...newConfig });
configSynced.current = true;
if ((syncedConfig.progress?.[1] ?? 0) > 0 && (config?.progress?.[1] ?? 0) > 0) {
const syncedFraction = syncedConfig.progress![0] / syncedConfig.progress![1];
const configFraction = config!.progress![0] / config!.progress![1];
if (syncedFraction > configFraction) {
view?.goToFraction(syncedFraction);
setToastMessage?.(_('Reading progress synced'));
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Reading progress synced'),
});
}
}
}
@@ -3,13 +3,13 @@ import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { Book } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { formatDate, formatSubject } from '@/utils/book';
import { formatDate, formatLanguage, formatPublisher, formatSubject } from '@/utils/book';
import WindowButtons from '@/components/WindowButtons';
import Spinner from './Spinner';
import { BookDoc } from '@/libs/document';
interface BookDetailModalProps {
book: Book;
@@ -105,9 +105,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
</div>
</div>
<div className='title-author flex h-40 flex-col justify-between pr-4'>
<div className='title-author flex h-40 max-w-[60%] flex-col justify-between pr-4'>
<div>
<h2 className='text-base-content mb-2 line-clamp-2 text-2xl font-bold'>
<h2 className='text-base-content mb-2 line-clamp-2 break-all text-2xl font-bold'>
{book.title || _('Untitled')}
</h2>
<p className='text-neutral-content line-clamp-1'>{book.author || _('Unknown')}</p>
@@ -123,7 +123,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
<div className='overflow-hidden'>
<span className='font-bold'>{_('Publisher:')}</span>
<p className='text-neutral-content line-clamp-1 text-sm'>
{bookMeta.publisher || _('Unknown')}
{formatPublisher(bookMeta.publisher || '') || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
@@ -141,7 +141,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
<div className='grid grid-cols-3 gap-4'>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Language:')}</span>
<p className='text-neutral-content text-sm'>{bookMeta.language || _('Unknown')}</p>
<p className='text-neutral-content text-sm'>
{formatLanguage(bookMeta.language) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Identifier:')}</span>
+8 -4
View File
@@ -20,14 +20,18 @@ const Button: React.FC<ButtonProps> = ({
}) => {
return (
<div
className={clsx('tooltip z-50', tooltip && `tooltip-${tooltipDirection}`, {
'tooltip-hidden': !tooltip,
})}
className={clsx(
'lg:tooltip z-50 h-8 min-h-8 w-8',
tooltip && `lg:tooltip-${tooltipDirection}`,
{
'tooltip-hidden': !tooltip,
},
)}
data-tip={tooltip}
>
<button
className={clsx(
'btn btn-ghost mx-2 h-8 min-h-8 w-8 p-0',
'btn btn-ghost h-8 min-h-8 w-8 p-0',
disabled && 'btn-disabled !bg-transparent',
className,
)}
+7 -5
View File
@@ -3,14 +3,16 @@ import React, { useState, isValidElement, ReactElement } from 'react';
interface DropdownProps {
className?: string;
menuClassName?: string;
buttonClassName?: string;
toggleButton: React.ReactNode;
children: ReactElement<{ setIsDropdownOpen: (isOpen: boolean) => void }>;
children: ReactElement<{ setIsDropdownOpen: (isOpen: boolean) => void; menuClassName?: string }>;
onToggle?: (isOpen: boolean) => void;
}
const Dropdown: React.FC<DropdownProps> = ({
className,
menuClassName,
buttonClassName,
toggleButton,
children,
@@ -30,11 +32,11 @@ const Dropdown: React.FC<DropdownProps> = ({
};
const childrenWithToggle = isValidElement(children)
? React.cloneElement(children, { setIsDropdownOpen })
? React.cloneElement(children, { setIsDropdownOpen, menuClassName })
: children;
return (
<>
<div className='dropdown-container'>
{isOpen && (
<div className='fixed inset-0 bg-transparent' onClick={() => setIsDropdownOpen(false)} />
)}
@@ -42,13 +44,13 @@ const Dropdown: React.FC<DropdownProps> = ({
<div
tabIndex={-1}
onClick={toggleDropdown}
className={clsx('dropdown-toggle', buttonClassName, isOpen && 'bg-base-200')}
className={clsx('dropdown-toggle', buttonClassName, isOpen && 'bg-base-300/50')}
>
{toggleButton}
</div>
{isOpen && childrenWithToggle}
</div>
</>
</div>
);
};
+2 -2
View File
@@ -25,7 +25,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
const menuButton = (
<button
className={clsx(
'hover:bg-neutral text-base-content flex h-10 w-full items-center justify-between rounded-md p-2',
'hover:bg-base-300 text-base-content flex h-10 w-full items-center justify-between rounded-md p-2',
disabled && 'btn-disabled text-gray-400',
)}
onClick={onClick}
@@ -44,7 +44,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
<ul className='menu rounded-box m-0 p-0'>
<li>
<details>
<summary className='hover:bg-neutral p-0 pr-3'>{menuButton}</summary>
<summary className='hover:bg-base-300 p-0 pr-3'>{menuButton}</summary>
{children}
</details>
</li>
+2 -2
View File
@@ -21,7 +21,7 @@ const Popup = ({
}) => (
<div>
<div
className={`triangle text-base-200 absolute z-10 ${triangleClassName}`}
className={`triangle text-base-200 absolute z-40 ${triangleClassName}`}
style={{
left:
trianglePosition?.dir === 'left'
@@ -67,7 +67,7 @@ const Popup = ({
/>
<div
id='popup-container'
className={`bg-base-200 absolute rounded-lg font-sans shadow-xl ${className}`}
className={`bg-base-200 absolute z-30 rounded-lg font-sans shadow-xl ${className}`}
style={{
width: `${width}px`,
height: `${height}px`,
+62 -13
View File
@@ -1,16 +1,65 @@
import { eventDispatcher } from '@/utils/event';
import clsx from 'clsx';
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
const Toast: React.FC<{ message: string; toastClass?: string; alertClass?: string }> = ({
message,
toastClass,
alertClass,
}) => (
<div className={clsx('toast toast-center toast-middle', toastClass)}>
<div className={clsx('alert flex items-center justify-center border-0', alertClass)}>
<span className='whitespace-normal break-words'>{message}</span>
</div>
</div>
);
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export default Toast;
export const Toast = () => {
const [toastMessage, setToastMessage] = useState('');
const toastType = useRef<ToastType>('info');
const toastTimeout = useRef(5000);
const messageClass = useRef('');
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastClassMap = {
info: 'toast-info toast-center toast-middle',
success: 'toast-success toast-top toast-end pt-11',
warning: 'toast-warning toast-top toast-end pt-11',
error: 'toast-error toast-top toast-end pt-11',
};
const alertClassMap = {
info: 'alert-primary',
success: 'alert-success',
warning: 'alert-warning',
error: 'alert-error',
};
useEffect(() => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
toastDismissTimeout.current = setTimeout(() => setToastMessage(''), toastTimeout.current);
return () => {
if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current);
};
}, [toastMessage]);
const handleShowToast = async (event: CustomEvent) => {
const { message, type = 'info', timeout = 5000, className = '' } = event.detail;
setToastMessage(message);
toastType.current = type;
toastTimeout.current = timeout;
messageClass.current = className;
};
useEffect(() => {
eventDispatcher.on('toast', handleShowToast);
return () => {
eventDispatcher.off('toast', handleShowToast);
};
}, []);
return (
toastMessage && (
<div className={clsx('toast toast-center toast-middle', toastClassMap[toastType.current])}>
<div
className={clsx(
'alert flex max-w-80 items-center justify-center border-0',
alertClassMap[toastType.current],
)}
>
<span className={clsx('whitespace-normal break-words', messageClass.current)}>
{toastMessage}
</span>
</div>
</div>
)
);
};
@@ -53,6 +53,7 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
if (
target.closest('.btn') ||
target.closest('.window-button') ||
target.closest('.dropdown-container') ||
target.closest('.exclude-title-bar-mousedown')
) {
return;
+36 -16
View File
@@ -29,27 +29,43 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
});
useEffect(() => {
const fetchSession = async () => {
const {
data: { session },
} = await supabase.auth.getSession();
const syncSession = (
session: { access_token: string; refresh_token: string; user: User } | null,
) => {
if (session) {
const { access_token, user } = session;
console.log('Syncing session');
const { access_token, refresh_token, user } = session;
localStorage.setItem('token', access_token);
localStorage.setItem('refresh_token', refresh_token);
localStorage.setItem('user', JSON.stringify(user));
setToken(access_token);
setUser(user);
localStorage.setItem('token', access_token);
localStorage.setItem('user', JSON.stringify(user));
} else {
console.log('Clearing session');
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
}
};
const refreshSession = async () => {
try {
await supabase.auth.refreshSession();
} catch {
syncSession(null);
}
};
console.log('Fetching session');
fetchSession();
}, [token]);
const { data: subscription } = supabase.auth.onAuthStateChange((_, session) => {
syncSession(session);
});
refreshSession();
return () => {
subscription?.subscription.unsubscribe();
};
}, []);
const login = (newToken: string, newUser: User) => {
console.log('Logging in');
@@ -61,12 +77,16 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const logout = async () => {
console.log('Logging out');
await supabase.auth.refreshSession();
await supabase.auth.signOut();
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
try {
await supabase.auth.refreshSession();
} catch {
} finally {
await supabase.auth.signOut();
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
setUser(null);
}
};
return (
+1 -1
View File
@@ -28,7 +28,7 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
onReloadPage: ['shift+r'],
onQuitApp: ['ctrl+q', 'cmd+q'],
onGoLeft: ['ArrowLeft', 'PageUp', 'h'],
onGoRight: ['ArrowRight', 'PageDown', 'l'],
onGoRight: ['ArrowRight', 'PageDown', 'l', ' '],
onGoNext: ['ArrowDown', 'j'],
onGoPrev: ['ArrowUp', 'k'],
onGoBack: ['shift+ArrowLeft', 'shift+h'],
+28 -18
View File
@@ -30,6 +30,8 @@ const computeMaxTimestamp = (records: BookDataRecord[]): number => {
return maxTime;
};
const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000;
export function useSync(bookKey?: string) {
const { settings } = useSettingsStore();
const { getConfig, setConfig } = useBookDataStore();
@@ -40,25 +42,31 @@ export function useSync(bookKey?: string) {
const [syncingNotes, setSyncingNotes] = useState(false);
const [syncError, setSyncError] = useState<string | null>(null);
const lastSyncedBooksAt = settings.lastSyncedAtBooks ?? 0;
const lastSyncedConfigsAt = config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0;
const lastSyncedNotesAt = config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0;
const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState<number>(
settings.lastSyncedAtBooks ?? 0,
lastSyncedBooksAt > 0 ? lastSyncedBooksAt - SEVEN_DAYS_IN_MS : 0,
);
const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState<number>(
config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0,
lastSyncedConfigsAt > 0 ? lastSyncedConfigsAt - SEVEN_DAYS_IN_MS : 0,
);
const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState<number>(
config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0,
lastSyncedNotesAt > 0 ? lastSyncedNotesAt - SEVEN_DAYS_IN_MS : 0,
);
const [syncing, setSyncing] = useState(false);
// null means unsynced, empty array means synced no changes
const [syncResult, setSyncResult] = useState<SyncResult>({
books: [],
configs: [],
notes: [],
books: null,
configs: null,
notes: null,
});
const [syncedBooks, setSyncedBooks] = useState<Book[]>([]);
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[]>([]);
const [syncedNotes, setSyncedNotes] = useState<BookNote[]>([]);
const [syncedBooks, setSyncedBooks] = useState<Book[] | null>(null);
const [syncedConfigs, setSyncedConfigs] = useState<BookConfig[] | null>(null);
const [syncedNotes, setSyncedNotes] = useState<BookNote[] | null>(null);
const { syncClient } = useSyncContext();
@@ -76,11 +84,11 @@ export function useSync(bookKey?: string) {
try {
const result = await syncClient.pullChanges(since, type, bookId);
setSyncResult({ ...syncResult, [type]: result[type] });
const records = result[type];
if (!records.length) return;
const maxTime = computeMaxTimestamp(result[type]);
if (!records?.length) return;
const maxTime = computeMaxTimestamp(records);
setLastSyncedAt(maxTime);
setSyncResult(result);
switch (type) {
case 'books':
settings.lastSyncedAtBooks = maxTime;
@@ -169,16 +177,18 @@ export function useSync(bookKey?: string) {
useEffect(() => {
if (!syncing && syncResult) {
const { books: dbBooks, configs: dbBookConfigs, notes: dbBookNotes } = syncResult;
const books = dbBooks.map((dbBook) => transformsFromDB['books'](dbBook as unknown as DBBook));
const configs = dbBookConfigs.map((dbBookConfig) =>
const books = dbBooks?.map((dbBook) =>
transformsFromDB['books'](dbBook as unknown as DBBook),
);
const configs = dbBookConfigs?.map((dbBookConfig) =>
transformsFromDB['configs'](dbBookConfig as unknown as DBBookConfig),
);
const notes = dbBookNotes.map((dbBookNote) =>
const notes = dbBookNotes?.map((dbBookNote) =>
transformsFromDB['notes'](dbBookNote as unknown as DBBookNote),
);
if (books.length) setSyncedBooks(books);
if (configs.length) setSyncedConfigs(configs);
if (notes.length) setSyncedNotes(notes);
if (books) setSyncedBooks(books);
if (configs) setSyncedConfigs(configs);
if (notes) setSyncedNotes(notes);
}
}, [syncResult, syncing]);
+285
View File
@@ -0,0 +1,285 @@
import { md5 } from 'js-md5';
import { randomMd5 } from '@/utils/misc';
import { LRUCache } from '@/utils/lru';
const EDGE_SPEECH_URL =
'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
const EDGE_API_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
const EDGE_TTS_VOICES = {
'af-ZA': ['af-ZA-AdriNeural', 'af-ZA-WillemNeural'],
'am-ET': ['am-ET-AmehaNeural', 'am-ET-MekdesNeural'],
'ar-AE': ['ar-AE-FatimaNeural', 'ar-AE-HamdanNeural'],
'ar-BH': ['ar-BH-AliNeural', 'ar-BH-LailaNeural'],
'ar-DZ': ['ar-DZ-AminaNeural', 'ar-DZ-IsmaelNeural'],
'ar-EG': ['ar-EG-SalmaNeural', 'ar-EG-ShakirNeural'],
'ar-IQ': ['ar-IQ-BasselNeural', 'ar-IQ-RanaNeural'],
'ar-JO': ['ar-JO-SanaNeural', 'ar-JO-TaimNeural'],
'ar-KW': ['ar-KW-FahedNeural', 'ar-KW-NouraNeural'],
'ar-LB': ['ar-LB-LaylaNeural', 'ar-LB-RamiNeural'],
'ar-LY': ['ar-LY-ImanNeural', 'ar-LY-OmarNeural'],
'ar-MA': ['ar-MA-JamalNeural', 'ar-MA-MounaNeural'],
'ar-OM': ['ar-OM-AbdullahNeural', 'ar-OM-AyshaNeural'],
'ar-QA': ['ar-QA-AmalNeural', 'ar-QA-MoazNeural'],
'ar-SA': ['ar-SA-HamedNeural', 'ar-SA-ZariyahNeural'],
'ar-SY': ['ar-SY-AmanyNeural', 'ar-SY-LaithNeural'],
'ar-TN': ['ar-TN-HediNeural', 'ar-TN-ReemNeural'],
'ar-YE': ['ar-YE-MaryamNeural', 'ar-YE-SalehNeural'],
'az-AZ': ['az-AZ-BabekNeural', 'az-AZ-BanuNeural'],
'bg-BG': ['bg-BG-BorislavNeural', 'bg-BG-KalinaNeural'],
'bn-BD': ['bn-BD-NabanitaNeural', 'bn-BD-PradeepNeural'],
'bn-IN': ['bn-IN-BashkarNeural', 'bn-IN-TanishaaNeural'],
'bs-BA': ['bs-BA-GoranNeural', 'bs-BA-VesnaNeural'],
'ca-ES': ['ca-ES-EnricNeural', 'ca-ES-JoanaNeural'],
'cs-CZ': ['cs-CZ-AntoninNeural', 'cs-CZ-VlastaNeural'],
'cy-GB': ['cy-GB-AledNeural', 'cy-GB-NiaNeural'],
'da-DK': ['da-DK-ChristelNeural', 'da-DK-JeppeNeural'],
'de-AT': ['de-AT-IngridNeural', 'de-AT-JonasNeural'],
'de-CH': ['de-CH-JanNeural', 'de-CH-LeniNeural'],
'de-DE': [
'de-DE-AmalaNeural',
'de-DE-ConradNeural',
'de-DE-FlorianMultilingualNeural',
'de-DE-KatjaNeural',
'de-DE-KillianNeural',
'de-DE-SeraphinaMultilingualNeural',
],
'el-GR': ['el-GR-AthinaNeural', 'el-GR-NestorasNeural'],
'en-AU': ['en-AU-NatashaNeural', 'en-AU-WilliamNeural'],
'en-CA': ['en-CA-ClaraNeural', 'en-CA-LiamNeural'],
'en-GB': [
'en-GB-LibbyNeural',
'en-GB-MaisieNeural',
'en-GB-RyanNeural',
'en-GB-SoniaNeural',
'en-GB-ThomasNeural',
],
'en-HK': ['en-HK-SamNeural', 'en-HK-YanNeural'],
'en-IE': ['en-IE-ConnorNeural', 'en-IE-EmilyNeural'],
'en-IN': ['en-IN-NeerjaExpressiveNeural', 'en-IN-NeerjaNeural', 'en-IN-PrabhatNeural'],
'en-KE': ['en-KE-AsiliaNeural', 'en-KE-ChilembaNeural'],
'en-NG': ['en-NG-AbeoNeural', 'en-NG-EzinneNeural'],
'en-NZ': ['en-NZ-MitchellNeural', 'en-NZ-MollyNeural'],
'en-PH': ['en-PH-JamesNeural', 'en-PH-RosaNeural'],
'en-SG': ['en-SG-LunaNeural', 'en-SG-WayneNeural'],
'en-TZ': ['en-TZ-ElimuNeural', 'en-TZ-ImaniNeural'],
'en-US': [
'en-US-AnaNeural',
'en-US-AndrewMultilingualNeural',
'en-US-AndrewNeural',
'en-US-AriaNeural',
'en-US-AvaMultilingualNeural',
'en-US-AvaNeural',
'en-US-BrianMultilingualNeural',
'en-US-BrianNeural',
'en-US-ChristopherNeural',
'en-US-EmmaMultilingualNeural',
'en-US-EmmaNeural',
'en-US-EricNeural',
'en-US-GuyNeural',
'en-US-JennyNeural',
'en-US-MichelleNeural',
'en-US-RogerNeural',
'en-US-SteffanNeural',
],
'es-AR': ['es-AR-ElenaNeural', 'es-AR-TomasNeural'],
'es-BO': ['es-BO-MarceloNeural', 'es-BO-SofiaNeural'],
'es-CL': ['es-CL-CatalinaNeural', 'es-CL-LorenzoNeural'],
'es-CO': ['es-CO-GonzaloNeural', 'es-CO-SalomeNeural'],
'es-CR': ['es-CR-JuanNeural', 'es-CR-MariaNeural'],
'es-CU': ['es-CU-BelkysNeural', 'es-CU-ManuelNeural'],
'es-DO': ['es-DO-EmilioNeural', 'es-DO-RamonaNeural'],
'es-EC': ['es-EC-AndreaNeural', 'es-EC-LuisNeural'],
'es-ES': ['es-ES-AlvaroNeural', 'es-ES-ElviraNeural', 'es-ES-XimenaNeural'],
'es-US': ['es-US-AlonsoNeural', 'es-US-PalomaNeural'],
'fr-BE': ['fr-BE-CharlineNeural', 'fr-BE-GerardNeural'],
'fr-CA': ['fr-CA-AntoineNeural', 'fr-CA-JeanNeural', 'fr-CA-SylvieNeural', 'fr-CA-ThierryNeural'],
'fr-CH': ['fr-CH-ArianeNeural', 'fr-CH-FabriceNeural'],
'fr-FR': [
'fr-FR-DeniseNeural',
'fr-FR-EloiseNeural',
'fr-FR-HenriNeural',
'fr-FR-RemyMultilingualNeural',
'fr-FR-VivienneMultilingualNeural',
],
'ja-JP': ['ja-JP-KeitaNeural', 'ja-JP-NanamiNeural'],
'ko-KR': ['ko-KR-HyunsuMultilingualNeural', 'ko-KR-InJoonNeural', 'ko-KR-SunHiNeural'],
'pt-BR': ['pt-BR-AntonioNeural', 'pt-BR-FranciscaNeural', 'pt-BR-ThalitaMultilingualNeural'],
'pt-PT': ['pt-PT-DuarteNeural', 'pt-PT-RaquelNeural'],
'zh-CN': [
'zh-CN-XiaoxiaoNeural',
'zh-CN-XiaoyiNeural',
'zh-CN-YunjianNeural',
'zh-CN-YunxiNeural',
'zh-CN-YunxiaNeural',
'zh-CN-YunyangNeural',
'zh-CN-liaoning-XiaobeiNeural',
'zh-CN-shaanxi-XiaoniNeural',
],
'zh-HK': ['zh-HK-HiuGaaiNeural', 'zh-HK-HiuMaanNeural', 'zh-HK-WanLungNeural'],
'zh-TW': ['zh-TW-HsiaoChenNeural', 'zh-TW-HsiaoYuNeural', 'zh-TW-YunJheNeural'],
};
const genVoiceList = (voices: Record<string, string[]>) => {
return Object.entries(voices).flatMap(([lang, voices]) => {
return voices.map((id) => {
const name = id.replace(`${lang}-`, '').replace('Neural', '');
return { name, id, lang };
});
});
};
export interface EdgeTTSPayload {
lang: string;
text: string;
voice: string;
rate: number;
pitch: number;
}
const hashPayload = (payload: EdgeTTSPayload): string => {
const base = JSON.stringify(payload);
return md5(base);
};
export class EdgeSpeechTTS {
static voices = genVoiceList(EDGE_TTS_VOICES);
private static audioCache = new LRUCache<string, AudioBuffer>(200);
constructor() {}
async #fetchEdgeSpeechWs({ lang, text, voice, rate }: EdgeTTSPayload): Promise<Response> {
const connectId = randomMd5();
const url = `${EDGE_SPEECH_URL}?ConnectionId=${connectId}&TrustedClientToken=${EDGE_API_TOKEN}`;
const date = new Date().toString();
const configHeaders = {
'Content-Type': 'application/json; charset=utf-8',
Path: 'speech.config',
'X-Timestamp': date,
};
const contentHeaders = {
'Content-Type': 'application/ssml+xml',
Path: 'ssml',
'X-RequestId': connectId,
'X-Timestamp': date,
};
const configContent = JSON.stringify({
context: {
synthesis: {
audio: {
metadataoptions: { sentenceBoundaryEnabled: false, wordBoundaryEnabled: true },
outputFormat: 'audio-24khz-48kbitrate-mono-mp3',
},
},
},
});
const genSSML = (lang: string, text: string, voice: string, rate: number) => {
return `
<speak version="1.0" xml:lang="${lang}">
<voice name="${voice}">
<prosody rate="${rate}">
${text}
</prosody>
</voice>
</speak>
`;
};
const genSendContent = (headerObj: Record<string, string>, content: string) => {
let header = '';
for (const key of Object.keys(headerObj)) {
header += `${key}: ${headerObj[key]}\r\n`;
}
return `${header}\r\n${content}`;
};
const getHeadersAndData = (message: string) => {
const lines = message.split('\n');
const headers: Record<string, string> = {};
let body = '';
let lineIdx = 0;
for (lineIdx = 0; lineIdx < lines.length; lineIdx++) {
const line = lines[lineIdx]!.trim();
if (!line) break;
const separatorIndex = line.indexOf(':');
if (separatorIndex === -1) continue;
const key = line.slice(0, separatorIndex).trim();
const value = line.slice(separatorIndex + 1).trim();
headers[key] = value;
}
for (lineIdx = lineIdx + 1; lineIdx < lines.length; lineIdx++) {
body += lines[lineIdx] + '\n';
}
return { headers, body };
};
const ssml = genSSML(lang, text, voice, rate);
const content = genSendContent(contentHeaders, ssml);
const config = genSendContent(configHeaders, configContent);
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
let audioData = new ArrayBuffer(0);
ws.addEventListener('open', () => {
ws.send(config);
ws.send(content);
});
ws.addEventListener('message', (event: MessageEvent) => {
if (typeof event.data === 'string') {
const { headers } = getHeadersAndData(event.data);
if (headers['Path'] === 'turn.end') {
ws.close();
if (!audioData.byteLength) {
return reject(new Error('No audio data received.'));
}
const res = new Response(audioData);
resolve(res);
}
} else if (event.data instanceof ArrayBuffer) {
const dataView = new DataView(event.data);
const headerLength = dataView.getInt16(0);
if (event.data.byteLength > headerLength + 2) {
const newBody = event.data.slice(2 + headerLength);
const merged = new Uint8Array(audioData.byteLength + newBody.byteLength);
merged.set(new Uint8Array(audioData), 0);
merged.set(new Uint8Array(newBody), audioData.byteLength);
audioData = merged.buffer;
}
}
});
ws.addEventListener('error', () => {
ws.close();
reject(new Error('WebSocket error occurred.'));
});
});
}
async create(payload: EdgeTTSPayload): Promise<Response> {
return this.#fetchEdgeSpeechWs(payload);
}
async createAudio(payload: EdgeTTSPayload): Promise<AudioBuffer> {
const cacheKey = hashPayload(payload);
if (EdgeSpeechTTS.audioCache.has(cacheKey)) {
return EdgeSpeechTTS.audioCache.get(cacheKey)!;
}
try {
const res = await this.create(payload);
const arrayBuffer = await res.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer.slice(0));
EdgeSpeechTTS.audioCache.set(cacheKey, audioBuffer);
return audioBuffer;
} catch (error) {
throw error;
}
}
}
+11 -11
View File
@@ -1,14 +1,8 @@
import { supabase } from '@/utils/supabase';
import { Book, BookConfig, BookNote, BookDataRecord } from '@/types/book';
import { READEST_WEB_BASE_URL } from '@/services/constants';
import { isWebAppPlatform } from '@/services/environment';
import { getAPIBaseUrl, isWebAppPlatform } from '@/services/environment';
// Develop Sync API only in development mode and web platform
// with command `pnpm dev-web`
const SYNC_API_ENDPOINT =
process.env['NODE_ENV'] === 'development' && isWebAppPlatform()
? '/api/sync'
: `${READEST_WEB_BASE_URL}/api/sync`;
const SYNC_API_ENDPOINT = getAPIBaseUrl() + '/sync';
export type SyncType = 'books' | 'configs' | 'notes';
export type SyncOp = 'push' | 'pull' | 'both';
@@ -18,9 +12,9 @@ interface BookConfigRecord extends BookDataRecord, BookConfig {}
interface BookNoteRecord extends BookDataRecord, BookNote {}
export interface SyncResult {
books: BookRecord[];
notes: BookNoteRecord[];
configs: BookConfigRecord[];
books: BookRecord[] | null;
notes: BookNoteRecord[] | null;
configs: BookConfigRecord[] | null;
}
export interface SyncData {
@@ -79,6 +73,12 @@ export class SyncClient {
}
private async getAccessToken(): Promise<string | null> {
// In browser context there might be two instances of supabase one in the app route
// and the other in the pages route, and they might have different sessions
// making the access token invalid for API calls. In that case we should use localStorage.
if (isWebAppPlatform()) {
return localStorage.getItem('token') ?? null;
}
const { data } = await supabase.auth.getSession();
return data?.session?.access_token ?? null;
}
+12 -3
View File
@@ -1,4 +1,5 @@
import { AppProps } from 'next/app';
import Head from 'next/head';
import Providers from '@/components/Providers';
import '../styles/globals.css';
@@ -6,9 +7,17 @@ import '../styles/fonts.css';
function MyApp({ Component, pageProps }: AppProps) {
return (
<Providers>
<Component {...pageProps} />
</Providers>
<>
<Head>
<meta
name='viewport'
content='width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, user-scalable=no'
/>
</Head>
<Providers>
<Component {...pageProps} />
</Providers>
</>
);
}
@@ -0,0 +1,44 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { supabase } from '@/utils/supabase';
const DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
const DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
const getUserAndToken = async (authHeader: string | undefined) => {
if (!authHeader) return {};
const token = authHeader.replace('Bearer ', '');
const {
data: { user },
error,
} = await supabase.auth.getUser(token);
if (error || !user) return {};
return { user, token };
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { user, token } = await getUserAndToken(req.headers['authorization']);
const deeplApiUrl = user && token ? DEEPL_PRO_API : DEEPL_FREE_API;
const deeplAuthKey =
user && token ? process.env['DEEPL_PRO_API_KEY'] : process.env['DEEPL_FREE_API_KEY'];
try {
console.log('Proxying DeepL request:', deeplApiUrl, deeplAuthKey);
const response = await fetch(deeplApiUrl, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${deeplAuthKey}`,
'Content-Type': 'application/json',
},
body: req.method === 'POST' ? JSON.stringify(req.body) : undefined,
});
res.status(response.status);
res.json(await response.json());
} catch (error) {
console.error('Error proxying DeepL request:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
};
export default handler;
@@ -25,6 +25,7 @@ import {
DEFAULT_READSETTINGS,
SYSTEM_SETTINGS_VERSION,
DEFAULT_BOOK_SEARCH_CONFIG,
DEFAULT_TTS_CONFIG,
} from './constants';
import { isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
@@ -68,6 +69,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
...settings.globalViewSettings,
};
} catch {
@@ -84,6 +86,7 @@ export abstract class BaseAppService implements AppService {
...DEFAULT_BOOK_STYLE,
...DEFAULT_BOOK_FONT,
...DEFAULT_VIEW_CONFIG,
...DEFAULT_TTS_CONFIG,
},
};
+45 -2
View File
@@ -1,4 +1,11 @@
import { BookFont, BookLayout, BookSearchConfig, BookStyle, ViewConfig } from '@/types/book';
import {
BookFont,
BookLayout,
BookSearchConfig,
BookStyle,
TTSConfig,
ViewConfig,
} from '@/types/book';
import { ReadSettings } from '@/types/settings';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
@@ -8,7 +15,7 @@ export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'cbz',
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
export const DEFAULT_READSETTINGS: ReadSettings = {
sideBarWidth: '25%',
sideBarWidth: '15%',
isSideBarPinned: true,
notebookWidth: '25%',
isNotebookPinned: false,
@@ -42,6 +49,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
maxInlineSize: 720,
maxBlockSize: 1440,
animated: false,
writingMode: 'auto',
vertical: false,
};
@@ -60,6 +68,11 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
sideBarTab: 'toc',
};
export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsRate: 1.0,
ttsVoice: '',
};
export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
scope: 'book',
matchCase: false,
@@ -82,6 +95,8 @@ export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'Helvetica'
export const MONOSPACE_FONTS = ['Fira Code', 'Lucida Console', 'Consolas', 'Courier New'];
export const FALLBACK_FONTS = ['MiSans L3'];
export const WINDOWS_FONTS = [
'Arial',
'Arial Black',
@@ -96,6 +111,7 @@ export const WINDOWS_FONTS = [
'Corbel',
'Courier New',
'Ebrima',
'FangSong',
'Franklin Gothic Medium',
'Gabriola',
'Gadugi',
@@ -104,6 +120,7 @@ export const WINDOWS_FONTS = [
'Impact',
'Ink Free',
'Javanese Text',
'KaiTi',
'Leelawadee UI',
'Lucida Console',
'Lucida Sans Unicode',
@@ -117,13 +134,16 @@ export const WINDOWS_FONTS = [
'Microsoft Tai Le',
'Microsoft YaHei',
'Microsoft Yi Baiti',
'MingLiU',
'MingLiU-ExtB',
'Mongolian Baiti',
'MS Gothic',
'MV Boli',
'Myanmar Text',
'Nirmala UI',
'NSimSun',
'Palatino Linotype',
'PMingLiU',
'Segoe MDL2 Assets',
'Segoe Print',
'Segoe Script',
@@ -131,6 +151,7 @@ export const WINDOWS_FONTS = [
'Segoe UI Historic',
'Segoe UI Emoji',
'Segoe UI Symbol',
'SimHei',
'SimSun',
'Sitka',
'Sylfaen',
@@ -156,6 +177,7 @@ export const MACOS_FONTS = [
'Avenir Next',
'Avenir Next Condensed',
'Baskerville',
'BiauKai',
'Big Caslon',
'Bodoni 72',
'Bodoni 72 Oldstyle',
@@ -178,11 +200,15 @@ export const MACOS_FONTS = [
'Geneva',
'Georgia',
'Gill Sans',
'Heiti SC',
'Heiti TC',
'Helvetica',
'Helvetica Neue',
'Herculanum',
'Hoefler Text',
'Impact',
'Kaiti SC',
'Kaiti TC',
'Lucida Grande',
'Luminari',
'Marker Felt',
@@ -193,12 +219,21 @@ export const MACOS_FONTS = [
'Optima',
'Palatino',
'Papyrus',
'PingFang HK',
'PingFang SC',
'PingFang TC',
'Phosphate',
'Rockwell',
'Savoye LET',
'SignPainter',
'Skia',
'Snell Roundhand',
'Songti SC',
'Songti TC',
'STFangsong',
'STKaiti',
'STSong',
'STXihei',
'Tahoma',
'Times',
'Times New Roman',
@@ -228,14 +263,22 @@ export const LINUX_FONTS = [
'Liberation Serif',
'Noto Mono',
'Noto Sans',
'Noto Sans CJK SC',
'Noto Sans CJK TC',
'Noto Serif',
'Noto Serif CJK SC',
'Noto Serif CJK TC',
'Open Sans',
'Poppins',
'Source Han Sans',
'Source Han Serif',
'Symbola',
'Times New Roman',
'Ubuntu',
'Ubuntu Mono',
'Wingdings',
'WenQuanYi Micro Hei',
'WenQuanYi Zen Hei',
];
export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
@@ -1,4 +1,5 @@
import { AppService } from '@/types/system';
import { READEST_WEB_BASE_URL } from './constants';
declare global {
interface Window {
@@ -12,6 +13,14 @@ export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] ==
export const hasUpdater = () => window.__READEST_UPDATER_ACCESS === true;
export const hasCli = () => window.__READEST_CLI_ACCESS === true;
// Dev API only in development mode and web platform
// with command `pnpm dev-web`
// for production build or tauri app use the production Web API
export const getAPIBaseUrl = () =>
process.env['NODE_ENV'] === 'development' && isWebAppPlatform()
? '/api'
: `${READEST_WEB_BASE_URL}/api`;
export interface EnvConfigType {
getAppService: () => Promise<AppService>;
}
@@ -0,0 +1,223 @@
import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity } from '@/types/view';
export class EdgeTTSClient implements TTSClient {
#rate = 1.0;
#pitch = 1.0;
#voice: TTSVoice | null = null;
#voices: TTSVoice[] = [];
#edgeTTS: EdgeSpeechTTS;
#audioContext: AudioContext | null = null;
#sourceNode: AudioBufferSourceNode | null = null;
#isPlaying = false;
#pausedAt = 0;
#startedAt = 0;
#audioBuffer: AudioBuffer | null = null;
available = true;
constructor() {
this.#edgeTTS = new EdgeSpeechTTS();
}
async init() {
this.#voices = EdgeSpeechTTS.voices;
try {
await this.#edgeTTS.create({
lang: 'en',
text: 'test',
voice: 'en-US-AriaNeural',
rate: 1.0,
pitch: 1.0,
});
this.available = true;
} catch {
this.available = false;
}
return this.available;
}
getPayload = (lang: string, text: string, voiceId: string) => {
return { lang, text, voice: voiceId, rate: this.#rate, pitch: this.#pitch } as EdgeTTSPayload;
};
async *speak(
ssml: string,
signal: AbortSignal,
preload = false,
): AsyncGenerator<TTSMessageEvent> {
const { marks } = parseSSMLMarks(ssml);
const lang = parseSSMLLang(ssml) || 'en';
let voiceId = 'en-US-AriaNeural';
if (!this.#voice) {
const voices = await this.getVoices(lang);
this.#voice = voices[0] ? voices[0] : this.#voices.find((v) => v.id === voiceId) || null;
}
if (this.#voice) {
voiceId = this.#voice.id;
}
if (preload) {
for (const mark of marks) {
await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)).catch((err) => {
console.warn('Error preloading:', err);
});
}
yield {
code: 'end',
message: 'Preload finished',
};
return;
} else {
await this.stopInternal();
}
for (const mark of marks) {
if (signal.aborted) {
yield {
code: 'error',
message: 'Aborted',
};
break;
}
try {
this.#audioBuffer = await this.#edgeTTS.createAudio(
this.getPayload(lang, mark.text, voiceId),
);
this.#audioContext = new AudioContext();
this.#sourceNode = this.#audioContext.createBufferSource();
this.#sourceNode.buffer = this.#audioBuffer;
this.#sourceNode.connect(this.#audioContext.destination);
yield {
code: 'boundary',
message: `Start chunk: ${mark.name}`,
mark: mark.name,
};
const result = await new Promise<TTSMessageEvent>((resolve) => {
if (this.#audioContext === null || this.#sourceNode === null) {
throw new Error('Audio context or source node is null');
}
this.#sourceNode.onended = () => {
this.#isPlaying = false;
resolve({
code: 'end',
message: `Chunk finished: ${mark.name}`,
});
};
if (signal.aborted) {
resolve({
code: 'error',
message: 'Aborted',
});
return;
}
this.#sourceNode.start(0);
this.#isPlaying = true;
this.#startedAt = this.#audioContext.currentTime;
});
yield result;
} catch (error) {
if (error instanceof Error && error.message === 'No audio data received.') {
console.warn('No audio data received for:', mark.text);
yield {
code: 'end',
message: `Chunk finished: ${mark.name}`,
};
continue;
}
console.log('Error:', error);
yield {
code: 'error',
message: error instanceof Error ? error.message : String(error),
};
break;
}
await this.stopInternal();
}
}
async pause() {
if (!this.#isPlaying || !this.#audioContext || !this.#sourceNode) return;
this.#pausedAt = this.#audioContext.currentTime - this.#startedAt;
await this.#audioContext.suspend();
this.#isPlaying = false;
}
async resume() {
if (this.#isPlaying || !this.#audioContext || !this.#sourceNode) return;
await this.#audioContext.resume();
this.#isPlaying = true;
this.#startedAt = this.#audioContext.currentTime - this.#pausedAt;
}
async stop() {
await this.stopInternal();
}
private async stopInternal() {
this.#isPlaying = false;
this.#pausedAt = 0;
this.#startedAt = 0;
if (this.#sourceNode) {
try {
this.#sourceNode.stop();
} catch (err) {
if (!(err instanceof Error) || err.name !== 'InvalidStateError') {
console.log('Error stopping source node:', err);
}
}
this.#sourceNode.disconnect();
this.#sourceNode = null;
}
if (this.#audioContext) {
await this.#audioContext.close();
this.#audioContext = null;
}
this.#audioBuffer = null;
}
async setRate(rate: number) {
// The Edge TTS API uses rate in [0.5 .. 2.0].
this.#rate = rate;
}
async setPitch(pitch: number) {
// The Edge TTS API uses pitch in [0.5 .. 1.5].
this.#pitch = pitch;
}
async setVoice(voice: string) {
const selectedVoice = this.#voices.find((v) => v.id === voice);
if (selectedVoice) {
this.#voice = selectedVoice;
}
}
async getAllVoices(): Promise<TTSVoice[]> {
this.#voices.forEach((voice) => {
voice.disabled = !this.available;
});
return this.#voices;
}
async getVoices(lang: string): Promise<TTSVoice[]> {
const locale = lang === 'en' ? getUserLocale(lang) || lang : lang;
const voices = await this.getAllVoices();
return voices.filter((v) => v.lang.startsWith(locale));
}
getGranularities(): TTSGranularity[] {
return ['sentence'];
}
getVoiceId(): string {
return this.#voice?.id || '';
}
}
@@ -0,0 +1,31 @@
import { TTSGranularity } from '@/types/view';
export type TTSMessageCode = 'boundary' | 'error' | 'end';
export interface TTSMessageEvent {
code: TTSMessageCode;
message?: string;
mark?: string;
}
export interface TTSVoice {
id: string;
name: string;
lang: string;
disabled?: boolean;
}
export interface TTSClient {
init(): Promise<boolean>;
speak(ssml: string, signal: AbortSignal, preload?: boolean): AsyncIterable<TTSMessageEvent>;
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
setRate(rate: number): Promise<void>;
setPitch(pitch: number): Promise<void>;
setVoice(voice: string): Promise<void>;
getAllVoices(): Promise<TTSVoice[]>;
getVoices(lang: string): Promise<TTSVoice[]>;
getGranularities(): TTSGranularity[];
getVoiceId(): string;
}
@@ -0,0 +1,221 @@
import { FoliateView, TTSGranularity } from '@/types/view';
import { TTSClient, TTSMessageCode, TTSVoice } from './TTSClient';
import { WebSpeechClient } from './WebSpeechClient';
import { EdgeTTSClient } from './EdgeTTSClient';
type TTSState =
| 'stopped'
| 'playing'
| 'paused'
| 'backward-paused'
| 'forward-paused'
| 'setrate-paused'
| 'setvoice-paused';
export class TTSController extends EventTarget {
state: TTSState = 'stopped';
view: FoliateView;
#nossmlCnt: number = 0;
#currentSpeakAbortController: AbortController | null = null;
ttsRate: number = 1.0;
ttsClient: TTSClient;
ttsWebClient: TTSClient;
ttsEdgeClient: TTSClient;
ttsWebVoices: TTSVoice[] = [];
ttsEdgeVoices: TTSVoice[] = [];
constructor(view: FoliateView) {
super();
this.ttsWebClient = new WebSpeechClient();
this.ttsEdgeClient = new EdgeTTSClient();
this.ttsClient = this.ttsWebClient;
this.view = view;
}
async init() {
await this.ttsWebClient.init();
const success = await this.ttsEdgeClient.init();
if (success) {
this.ttsClient = this.ttsEdgeClient;
} else {
this.ttsClient = this.ttsWebClient;
}
this.ttsWebVoices = await this.ttsWebClient.getAllVoices();
this.ttsEdgeVoices = await this.ttsEdgeClient.getAllVoices();
}
async initViewTTS() {
let granularity: TTSGranularity = this.view.language.isCJK ? 'sentence' : 'word';
const supportedGranularities = this.ttsClient.getGranularities();
if (!supportedGranularities.includes(granularity)) {
granularity = supportedGranularities[0]!;
}
await this.view.initTTS(granularity);
}
async preloadSSML(ssml: string | undefined) {
if (!ssml) return;
const iter = await this.ttsClient.speak(ssml, new AbortController().signal, true);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of iter);
}
async preloadNextSSML(count: number) {
const tts = this.view.tts;
if (!tts) return;
let preloaded = 0;
for (let i = 0; i < count; i++) {
const ssml = tts.next();
this.preloadSSML(ssml);
if (ssml) preloaded++;
}
for (let i = 0; i < preloaded; i++) {
tts.prev();
}
}
async #speak(ssml: string | undefined | Promise<string>) {
await this.stop();
this.#currentSpeakAbortController = new AbortController();
const { signal } = this.#currentSpeakAbortController;
try {
console.log('TTS speak');
this.state = 'playing';
ssml = await ssml;
await this.preloadSSML(ssml);
if (!ssml) {
this.#nossmlCnt++;
// FIXME: in case we are at the end of the book, need a better way to handle this
if (this.#nossmlCnt < 10 && this.state === 'playing') {
await this.view.next(1);
await this.forward();
}
return;
} else {
this.#nossmlCnt = 0;
}
const iter = await this.ttsClient.speak(ssml, signal);
let lastCode: TTSMessageCode = 'boundary';
for await (const { code, mark } of iter) {
if (mark && this.state === 'playing') {
this.view.tts?.setMark(mark);
}
lastCode = code;
}
if (lastCode === 'end' && this.state === 'playing') {
await this.forward();
}
} finally {
this.#currentSpeakAbortController = null;
}
}
async speak(ssml: string | Promise<string>) {
await this.initViewTTS();
this.#speak(ssml).catch((e) => this.error(e));
this.preloadNextSSML(2);
}
play() {
if (this.state !== 'playing') {
this.start();
} else {
this.pause();
}
}
async start() {
await this.initViewTTS();
const ssml = this.state.includes('paused') ? this.view.tts?.resume() : this.view.tts?.start();
this.#speak(ssml);
this.preloadNextSSML(2);
}
async pause() {
this.state = 'paused';
await this.ttsClient.pause().catch((e) => this.error(e));
}
async resume() {
this.state = 'playing';
await this.ttsClient.resume().catch((e) => this.error(e));
}
async stop() {
this.state = 'stopped';
if (this.#currentSpeakAbortController) {
this.#currentSpeakAbortController.abort();
}
await this.ttsClient.stop().catch((e) => this.error(e));
}
// goto previous sentence
async backward() {
await this.initViewTTS();
if (this.state === 'playing') {
await this.stop();
this.#speak(this.view.tts?.prev());
} else {
this.state = 'backward-paused';
this.view.tts?.prev(true);
}
}
// goto next sentence
async forward() {
await this.initViewTTS();
if (this.state === 'playing') {
await this.stop();
this.#speak(this.view.tts?.next());
this.preloadNextSSML(2);
} else {
this.state = 'forward-paused';
this.view.tts?.next(true);
}
}
async setRate(rate: number) {
this.state = 'setrate-paused';
this.ttsRate = rate;
await this.ttsClient.setRate(this.ttsRate);
}
async getVoices(lang: string) {
const ttsWebVoices = await this.ttsWebClient.getVoices(lang);
const ttsEdgeVoices = await this.ttsEdgeClient.getVoices(lang);
return [...ttsEdgeVoices, ...ttsWebVoices];
}
async setVoice(voiceId: string) {
this.state = 'setvoice-paused';
const useEdgeTTS = !!this.ttsEdgeVoices.find(
(voice) => (voiceId === '' || voice.id === voiceId) && !voice.disabled,
);
if (useEdgeTTS) {
this.ttsClient = this.ttsEdgeClient;
await this.ttsClient.setRate(this.ttsRate);
} else {
this.ttsClient = this.ttsWebClient;
await this.ttsClient.setRate(this.ttsRate);
}
await this.ttsClient.setVoice(voiceId);
}
getVoiceId() {
return this.ttsClient.getVoiceId();
}
error(e: unknown) {
console.error(e);
this.state = 'stopped';
}
async kill() {
this.state = 'stopped';
await this.ttsClient.stop();
}
}
@@ -0,0 +1,332 @@
import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
import { AsyncQueue } from '@/utils/queue';
import { findSSMLMark, parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity } from '@/types/view';
const BLACKLISTED_VOICES = [
'Albert',
'Bad News',
'Bahh',
'Bells',
'Boing',
'Bubbles',
'Cellos',
'Eddy',
'Flo',
'Fred',
'Good News',
'Grandma',
'Grandpa',
'Jester',
'Junior',
'Kathy',
'Organ',
'Ralph',
'Reed',
'Rocko',
'Sandy',
'Shelley',
'Superstar',
'Trinoids',
'Whisper',
'Wobble',
'Zarvox',
];
interface TTSBoundaryEvent {
type: 'boundary' | 'end' | 'error';
speaking: boolean;
name?: string;
mark?: string;
charIndex?: number;
charLength?: number;
error?: string;
}
async function* speakWithBoundary(
ssml: string,
getRate: () => number,
getPitch: () => number,
getVoice: () => SpeechSynthesisVoice | null,
) {
const lang = parseSSMLLang(ssml);
const { plainText, marks } = parseSSMLMarks(ssml);
// console.log('ssml', ssml, marks);
// console.log('text', plainText);
const synth = window.speechSynthesis;
const utterance = new SpeechSynthesisUtterance(plainText);
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
if (lang) {
utterance.lang = lang;
}
const queue = new AsyncQueue<TTSBoundaryEvent>();
utterance.onboundary = (event: SpeechSynthesisEvent) => {
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
const mark = findSSMLMark(event.charIndex, marks);
// console.log('boundary', event.charIndex, mark);
queue.enqueue({
type: 'boundary',
speaking: true,
name: event.name,
mark: mark?.name ?? '',
charIndex: event.charIndex,
charLength: event.charLength,
});
};
utterance.onend = () => {
queue.enqueue({ type: 'end', speaking: false });
queue.finish();
};
utterance.onerror = (event) => {
queue.enqueue({ type: 'error', speaking: false, error: event.error });
queue.finish();
};
synth.speak(utterance);
while (true) {
const ev = await queue.dequeue();
if (ev === null) {
break;
}
yield ev;
}
}
async function* speakWithMarks(
ssml: string,
getRate: () => number,
getPitch: () => number,
getVoice: () => SpeechSynthesisVoice | null,
) {
const { plainText, marks } = parseSSMLMarks(ssml);
const lang = parseSSMLLang(ssml);
const isCJK = (lang: string | null) => {
const cjkLangs = ['zh', 'ja', 'kr'];
if (lang && cjkLangs.some((cjk) => lang.startsWith(cjk))) return true;
return /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(plainText);
};
if (!isCJK(lang)) {
yield* speakWithBoundary(ssml, getRate, getPitch, getVoice);
return;
}
const synth = window.speechSynthesis;
const utterance = new SpeechSynthesisUtterance();
for (const mark of marks) {
utterance.text = mark.text;
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
if (voice) {
utterance.voice = voice;
}
if (lang) {
utterance.lang = lang;
}
yield {
type: 'boundary',
speaking: true,
name: 'sentence',
mark: mark.name,
} as TTSBoundaryEvent;
const result = await new Promise<TTSBoundaryEvent>((resolve) => {
utterance.onend = () => resolve({ type: 'end', speaking: false });
utterance.onerror = (event) =>
resolve({
type: 'error',
speaking: false,
error: event.error,
});
synth.speak(utterance);
});
yield result;
if (result.type === 'error') {
break;
}
}
}
export class WebSpeechClient implements TTSClient {
#rate = 1.0;
#pitch = 1.0;
#voice: SpeechSynthesisVoice | null = null;
#voices: SpeechSynthesisVoice[] = [];
#synth = window.speechSynthesis;
available = true;
async init() {
if (!this.#synth) {
this.available = false;
return this.available;
}
await new Promise<void>((resolve) => {
const populateVoices = () => {
this.#voices = this.#synth.getVoices();
// console.log('Voices', this.#voices);
if (this.#voices.length > 0) {
resolve();
}
};
if (this.#synth.getVoices().length > 0) {
populateVoices();
} else if (this.#synth.onvoiceschanged !== undefined) {
this.#synth.onvoiceschanged = populateVoices;
} else {
console.warn('Voiceschanged event not supported.');
resolve();
}
});
return this.available;
}
async *speak(
ssml: string,
signal: AbortSignal,
preload = false,
): AsyncGenerator<TTSMessageEvent> {
// no need to preload for web speech
if (preload) return;
const lang = parseSSMLLang(ssml) || 'en';
if (!this.#voice) {
const voices = await this.getVoices(lang);
const voiceId = voices[0]?.id ?? '';
this.#voice = this.#voices.find((v) => v.voiceURI === voiceId) || null;
}
for await (const ev of speakWithMarks(
ssml,
() => this.#rate,
() => this.#pitch,
() => this.#voice,
)) {
if (signal.aborted) {
console.log('TTS aborted');
yield { code: 'error', message: 'Aborted' } as TTSMessageEvent;
return;
}
if (ev.type === 'boundary') {
yield {
code: 'boundary',
mark: ev.mark ?? '',
message: `${ev.name ?? 'Unknown'} ${ev.charIndex ?? 0}/${ev.charLength ?? 0}`,
} as TTSMessageEvent;
} else if (ev.type === 'error') {
yield { code: 'error', message: ev.error ?? 'Unknown error' } as TTSMessageEvent;
} else if (ev.type === 'end') {
yield { code: 'end', message: 'Speech finished' } as TTSMessageEvent;
}
}
}
async pause() {
this.#synth.pause();
}
async resume() {
this.#synth.resume();
}
async stop() {
this.#synth.cancel();
}
async setRate(rate: number) {
// The Web Speech API uses utterance.rate in [0.1 .. 10],
this.#rate = rate;
}
async setPitch(pitch: number) {
// The Web Speech API uses pitch in [0 .. 2].
this.#pitch = pitch;
}
async setVoice(voiceId: string) {
const selectedVoice = this.#voices.find((v) => v.voiceURI === voiceId);
if (selectedVoice) {
this.#voice = selectedVoice;
}
}
async getAllVoices(): Promise<TTSVoice[]> {
const voices = this.#voices.map((voice) => {
return {
id: voice.voiceURI,
name: voice.name,
lang: voice.lang,
disabled: !this.available,
} as TTSVoice;
});
return voices;
}
async getVoices(lang: string) {
const locale = lang === 'en' ? getUserLocale(lang) || lang : lang;
const isValidVoice = (id: string) => {
return !id.includes('com.apple') || id.includes('com.apple.voice.compact');
};
const isNotBlacklisted = (voice: SpeechSynthesisVoice) => {
return BLACKLISTED_VOICES.some((name) => voice.name.includes(name)) === false;
};
const filteredVoices = this.#voices
.filter((voice) => voice.lang.startsWith(locale))
.filter((voice) => isValidVoice(voice.voiceURI || ''))
.filter(isNotBlacklisted);
const seenIds = new Set<string>();
const voices = filteredVoices
.map(
(voice) =>
({
id: voice.voiceURI,
name: voice.name,
lang: voice.lang,
}) as TTSVoice,
)
.filter((voice) => {
if (seenIds.has(voice.id)) {
return false;
}
seenIds.add(voice.id);
return true;
});
voices.forEach((voice) => {
voice.disabled = !this.available;
});
return voices;
}
getGranularities(): TTSGranularity[] {
// currently only support sentence boundary and disable word boundary as changing voice
// in the middle of speech is not possible for different granularities
return ['sentence'];
}
getVoiceId(): string {
return this.#voice?.voiceURI ?? '';
}
}
@@ -0,0 +1,4 @@
export * from './TTSClient';
export * from './WebSpeechClient';
export * from './EdgeTTSClient';
export * from './TTSController';
+24 -3
View File
@@ -64,7 +64,10 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
getView: (key: string | null) => (key && get().viewStates[key]?.view) || null,
setView: (key: string, view) =>
set((state) => ({
viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, view } },
viewStates: {
...state.viewStates,
[key]: { ...state.viewStates[key]!, view },
},
})),
getViews: () => Object.values(get().viewStates).map((state) => state.view!),
getViewsById: (id: string) => {
@@ -213,13 +216,31 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const bookData = useBookDataStore.getState().booksData[id];
const viewState = state.viewStates[key];
if (!viewState || !bookData) return state;
const progress: [number, number] = [pageinfo.current, pageinfo.total];
// Update library book progress
const { library, setLibrary } = useLibraryStore.getState();
const bookIndex = library.findIndex((b) => b.hash === id);
if (bookIndex !== -1) {
const updatedLibrary = [...library];
const existingBook = updatedLibrary[bookIndex]!;
updatedLibrary[bookIndex] = {
...existingBook,
progress,
updatedAt: Date.now(),
};
setLibrary(updatedLibrary);
}
const oldConfig = bookData.config;
const newConfig = {
...bookData.config,
updatedAt: Date.now(),
progress: [pageinfo.current, pageinfo.total] as [number, number],
progress,
location,
};
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
@@ -229,6 +250,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
},
}));
return {
viewStates: {
...state.viewStates,
@@ -248,7 +270,6 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
};
}),
setBookmarkRibbonVisibility: (key: string, visible: boolean) =>
set((state) => ({
viewStates: {
+1
View File
@@ -6,6 +6,7 @@
--background: #ffffff;
--foreground: #171717;
border-radius: 10px;
scrollbar-gutter: auto !important;
}
@media (prefers-color-scheme: dark) {
+2 -2
View File
@@ -11,7 +11,7 @@ const generateLightPalette = ({ bg, fg, primary }: BaseColor) => {
return {
'base-100': bg, // Main background
'base-200': tinycolor(bg).darken(5).toHexString(), // Slightly darker
'base-300': tinycolor(bg).darken(15).toHexString(), // More darker
'base-300': tinycolor(bg).darken(12).toHexString(), // More darker
'base-content': fg, // Main text color
neutral: tinycolor(bg).darken(15).desaturate(20).toHexString(), // Muted neutral
'neutral-content': tinycolor(fg).lighten(20).desaturate(20).toHexString(), // Slightly lighter text
@@ -25,7 +25,7 @@ const generateDarkPalette = ({ bg, fg, primary }: BaseColor) => {
return {
'base-100': bg, // Main background
'base-200': tinycolor(bg).lighten(5).toHexString(), // Slightly lighter
'base-300': tinycolor(bg).lighten(15).toHexString(), // More lighter
'base-300': tinycolor(bg).lighten(12).toHexString(), // More lighter
'base-content': fg, // Main text color
neutral: tinycolor(bg).lighten(15).desaturate(20).toHexString(), // Muted neutral
'neutral-content': tinycolor(fg).darken(20).desaturate(20).toHexString(), // Darkened text
+8 -1
View File
@@ -19,6 +19,7 @@ export interface Book {
deletedAt?: number | null;
lastUpdated?: number; // deprecated in favor of updatedAt
progress?: [number, number]; // Add progress field: [current, total]
}
export interface PageInfo {
@@ -51,6 +52,7 @@ export interface BookLayout {
maxInlineSize: number;
maxBlockSize: number;
animated: boolean;
writingMode: string;
vertical: boolean;
}
@@ -79,7 +81,12 @@ export interface ViewConfig {
sideBarTab: string;
}
export interface ViewSettings extends BookLayout, BookStyle, BookFont, ViewConfig {}
export interface TTSConfig {
ttsRate: number;
ttsVoice: string;
}
export interface ViewSettings extends BookLayout, BookStyle, BookFont, ViewConfig, TTSConfig {}
export interface BookProgress {
location: string;
+9
View File
@@ -1,5 +1,8 @@
import { BookDoc } from '@/libs/document';
import { BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
import { TTS } from 'foliate-js/tts.js';
export type TTSGranularity = 'sentence' | 'word';
export interface FoliateView extends HTMLElement {
open: (book: BookDoc) => Promise<void>;
@@ -17,6 +20,12 @@ export interface FoliateView extends HTMLElement {
clearSearch: () => void;
select: (target: string | number | { fraction: number }) => void;
deselect: () => void;
initTTS: (granularity?: TTSGranularity) => Promise<void>;
tts: TTS | null;
language: {
locale?: string;
isCJK?: boolean;
};
history: {
canGoBack: boolean;
canGoForward: boolean;
+9 -1
View File
@@ -54,7 +54,7 @@ export const listFormater = (narrow = false, lang = userLang) => {
}
};
const getBookLangCode = (lang: string | string[] | undefined) => {
export const getBookLangCode = (lang: string | string[] | undefined) => {
try {
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
return bookLang ? bookLang.split('-')[0]! : 'en';
@@ -82,6 +82,14 @@ export const formatTitle = (title: string | LanguageMap) => {
return typeof title === 'string' ? title : formatLanguageMap(title);
};
export const formatPublisher = (publisher: string | LanguageMap) => {
return typeof publisher === 'string' ? publisher : formatLanguageMap(publisher);
};
export const formatLanguage = (lang: string | string[] | undefined) => {
return Array.isArray(lang) ? lang.join(', ') : lang;
};
export const formatDate = (date: string | number | Date | undefined) => {
if (!date) return;
try {
+77
View File
@@ -0,0 +1,77 @@
const cssValidate = (css: string) => {
// Remove comments and normalize whitespace
css = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
// CSS property pattern (validate both property name and value)
const propertyPattern = /^[\s\n]*[-\w]+\s*:\s*[^;]+;?$/;
// Check if empty
if (!css) return { isValid: false, error: 'Empty CSS' };
// Ensure balanced curly braces
const openBraces = (css.match(/{/g) || []).length;
const closeBraces = (css.match(/}/g) || []).length;
if (openBraces !== closeBraces) {
return { isValid: false, error: 'Unbalanced curly braces' };
}
// Split into rule blocks
const blocks = css
.split('}')
.map((block) => block.trim())
.filter(Boolean);
for (const block of blocks) {
// Ensure the block has a selector and declarations
const parts = block.split('{').map((part) => part.trim());
if (parts.length !== 2) {
return { isValid: false, error: 'Invalid CSS structure' };
}
const [selector, decls] = parts;
// Ensure selector is not empty
if (!selector) {
return { isValid: false, error: 'Missing selector' };
}
// Ensure declarations are not empty
if (!decls) {
return { isValid: false, error: `Missing declarations for selector: ${selector}` };
}
// Validate declarations
const props = decls
.split(';')
.map((prop) => prop.trim())
.filter(Boolean);
if (props.length === 0) {
return { isValid: false, error: `No valid properties for selector: ${selector}` };
}
for (const prop of props) {
// Check if property is missing a name or value
if (!prop.includes(':')) {
return { isValid: false, error: `Missing property or value: ${prop}` };
}
const [name, value] = prop.split(':').map((part) => part.trim());
if (!name) {
return { isValid: false, error: `Missing property name: ${prop}` };
}
if (!value) {
return { isValid: false, error: `Missing property value: ${prop}` };
}
// Validate full property format
if (!propertyPattern.test(prop.endsWith(';') ? prop : prop + ';')) {
return { isValid: false, error: `Invalid property: ${prop}` };
}
}
}
return { isValid: true, error: null };
};
export default cssValidate;

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