Compare commits

...

32 Commits

Author SHA1 Message Date
chrox 4f20ae4967 Add NEXT_PUBLIC_APP_PLATFORM env variable in github workflow 2024-12-06 22:49:36 +01:00
chrox bdf619f2e6 Update open graph metadata for Readest WebApp 2024-12-06 22:44:57 +01:00
chrox 53db2777af Update preview banner 2024-12-06 18:16:27 +01:00
chrox a10bde2254 Add Downloads section 2024-12-06 15:18:21 +01:00
chrox cf7390915b Toggle visibility of the header/footer bar when tap on the center 25% of the page 2024-12-06 14:14:29 +01:00
chrox b694203d26 Fix DeepL on Web Readest and avoid cli parsing for Web 2024-12-06 13:36:38 +01:00
chrox 242fbf6743 Use bookID in path in Web version 2024-12-06 11:36:24 +01:00
chrox f21f3a3fc0 Update README for roadmap 2024-12-05 19:45:34 +01:00
chrox aa16bc09f1 Support running Readest in modern browsers
Now we support Web platform
2024-12-05 18:57:16 +01:00
chrox 23eb2514bc Refactor readme 2024-12-05 17:03:30 +01:00
chrox 9eacf7e203 Don't auto hide scrollbar in platform other than macOS and iOS, closes #9 2024-12-05 12:02:52 +01:00
chrox 32ffaa0e05 Close window when document is closed with 'open with Readest' 2024-12-04 18:58:05 +01:00
chrox 999e4828db Fix open with Readest on Windows 2024-12-04 18:38:36 +01:00
chrox 626dd2fbd7 Fix headerbar start-dragging events 2024-12-04 13:20:08 +01:00
chrox ec1ec2265d Skip all buttons in headerbar when start dragging, closes #8 2024-12-04 12:00:32 +01:00
chrox 1429f111bc Add file associations to support system "open with Readest" function, closes #1
Support passing files as command line args to Readest
2024-12-03 22:16:51 +01:00
chrox 5ed74fe9c5 Add app auto updater and bump update of tauri 2024-12-03 17:41:10 +01:00
chrox abb3ace57e Fix import books button occasionally shown when refreshing library page 2024-12-03 17:10:47 +01:00
chrox f897067683 Refactoring WindowButtons handlers 2024-12-03 15:53:59 +01:00
chrox a189cd6d4f Hide max/min window buttons in reader page when there are more than one documents opened, closes #6 2024-12-03 15:43:48 +01:00
chrox 0076bab4e3 Flatten css fron pdfjs to support older browser below WebKit 17615.1 2024-12-03 15:24:38 +01:00
chrox 433f707455 Show bookshelf or import books button only when library is loaded 2024-12-03 15:20:49 +01:00
chrox dbb8347554 Fix max-inline-size not changed according to scroll mode 2024-12-02 22:06:30 +01:00
chrox f1a31b9590 Fix scrollbar auto hide on macOS 2024-12-02 21:37:00 +01:00
chrox 22fb45e54b Bump update of foliate-js 2024-12-02 17:37:46 +01:00
chrox 3d09eb761c Make sure settings are loaded before loading library, closes #2 2024-12-02 17:36:54 +01:00
chrox 0d50edbff1 Add wheel page turning in non-scrolled mode, closes #3 2024-12-02 12:41:45 +01:00
chrox 96fd2752ff Fix title bar hard to drag-and-move, closes #1 2024-12-02 11:56:04 +01:00
chrox 4c4f067857 Update readme 2024-12-01 23:19:39 +01:00
chrox d6bfbbc0b6 Fix fg color in darkmode and larger annotator popup 2024-12-01 18:24:07 +01:00
chrox e851582264 Update readme 2024-12-01 18:23:00 +01:00
chrox c02d63367a Update github workflow of release 2024-12-01 12:55:12 +01:00
57 changed files with 1899 additions and 475 deletions
+38
View File
@@ -0,0 +1,38 @@
---
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'
labels: ['type: bug']
assignees: ''
---
## Bug Description
A clear and concise description of what the current behavior is.
Please also add **screenshots** of the existing application.
**Example:**
```
In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
[screenshot]
```
## Expected behavior
A clear and concise description of what the expected behavior is.
**Example:**
```
The blank square should be transparent (invisible)
```
## Technical inputs
**Example:**
```
- We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
- Probably fixable with CSS
```
+138
View File
@@ -0,0 +1,138 @@
name: Release Readest
on:
workflow_dispatch:
release:
types: [published]
jobs:
create-release:
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create-release.outputs.result }}
steps:
- uses: actions/checkout@v3
- name: setup node
uses: actions/setup-node@v3
- name: get version
run: echo "PACKAGE_VERSION=$(node -p "require('./apps/readest-app/package.json').version")" >> $GITHUB_ENV
- name: create release
id: create-release
uses: actions/github-script@v6
with:
script: |
const { data } = await github.rest.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo,
})
return data.id
build-tauri:
needs: create-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
config:
- os: ubuntu-latest
arch: x86_64
rust_target: x86_64-unknown-linux-gnu
- os: macos-latest
arch: aarch64
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
- os: windows-latest
arch: x86_64
rust_target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.config.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
- name: initialize git submodules
run: git submodule update --init --recursive
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.14.4
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 22
cache: pnpm
- name: install dependencies
run: pnpm install
- name: copy pdfjs-dist to public directory
run: pnpm --filter @readest/readest-app setup-pdfjs
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.config.rust_target }}
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('apps/readest-app/src-tauri/Cargo.lock') }}
workspaces: apps/readest-app/src-tauri -> target
- name: Create .env.local file for Next.js
run: |
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
echo "NEXT_PUBLIC_DEEPL_API_KEY=${{ secrets.NEXT_PUBLIC_DEEPL_API_KEY }}" >> .env.local
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
- name: Copy .env.local to apps/readest-app
run: cp .env.local apps/readest-app/.env.local
- name: install dependencies (ubuntu only)
if: matrix.config.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
projectPath: apps/readest-app
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.config.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
publish-release:
permissions:
contents: write
runs-on: ubuntu-latest
needs: [create-release, build-tauri]
steps:
- name: publish release
id: publish-release
uses: actions/github-script@v6
env:
release_id: ${{ needs.create-release.outputs.release_id }}
with:
script: |
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
draft: false,
prerelease: false
})
+2 -2
View File
@@ -1,6 +1,6 @@
[submodule "packages/foliate-js"]
path = packages/foliate-js
url = git@github.com:johnfactotum/foliate-js.git
url = https://github.com/chrox/foliate-js.git
[submodule "packages/tauri"]
path = packages/tauri
url = git@github.com:chrox/tauri.git
url = https://github.com/chrox/tauri.git
+123 -12
View File
@@ -1,27 +1,109 @@
# Readest
<div align="center">
<a href="https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme" target="_blank">
<img src="https://github.com/chrox/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
</a>
<h1>Readest</h1>
<br>
Readest is an open-source ebook reading software designed for immersive and deep reading experiences. It supports EPUB and PDF document formats, and with Tauri v2, Readest is cross-platform, running seamlessly on macOS, Windows, and Linux.
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to offer a seamless cross-platform experience on macOS, Windows, Linux and Web, with support for mobile platforms coming soon.
[![Website][badge-website]][link-website]
[![AGPL Licence][badge-license]](LICENSE)
[![OS][badge-platforms]][link-website]
[![][badge-discord]][link-discord]
<br>
[![Latest release][badge-release]][link-gh-releases]
[![Last commit][badge-last-commit]][link-gh-commits]
[![Commits][badge-commit-activity]][link-gh-pulse]
</div>
<p align="center">
<a href="#features">Features</a> •
<a href="#planned-features">Planned Features</a> •
<a href="#screenshots">Screenshots</a> •
<a href="#downloads">Downloads</a> •
<a href="#getting-started">Getting Started</a> •
<a href="#contributing">Contributing</a> •
<a href="#license">License</a>
</p>
<div align="center">
<a href="https://readest.com" target="_blank">
<img src="./data/screenshots/readest_landing_preview.png" alt="Readest Banner" width="100%" />
</a>
</div>
## Features
- **EPUB and PDF Support**: Enjoy both EPUB and PDF formats, making Readest versatile for all your reading needs.
- **Cross-Platform Compatibility**: Runs on macOS, Windows, and Linux with Tauri v2.
- **Immersive Reading Experience**: Supports advanced reading features like note-taking, highlighting, and progress syncing.
- **Optimized Performance**: Readest is lightweight, ensuring smooth performance even with large files.
- **Customizable Interface**: Built with daisyUI for a modern and user-friendly UI.
<div align="left">✅ Implemented</div>
| **Feature** | **Description** | **Status** |
| --------------------------------------- | -------------------------------------------------------------------------------- | ---------- |
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
## 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. | 🛠 |
| **Sync Across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🔄 |
| **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. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
## Screenshots
![Annotations](./data/screenshots/annotations.jpeg)
![Wikipedia](./data/screenshots/wikipedia.jpeg)
![DeepL](./data/screenshots/deepl.jpeg)
![Dark Mode](./data/screenshots/dark_mode.jpeg)
---
## Downloads
The Readest app is available for download! 🥳 🚀
- macOS : Search for "Readest" on the [macOS App Store][link-macos-appstore].
- Windows / Linux: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
- Web: Visit [https://web.readest.com][link-web-readest].
- iOS / Android: coming soon 👀
## Requirements
- **Node.js** and **pnpm** for Next.js development
- **Rust and Cargo** for Tauri development
For the best experience, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
```bash
nvm install v22
nvm use v22
npm install -g pnpm
rustup update
```
@@ -40,7 +122,7 @@ git submodule update --init --recursive
### 2. Install Dependencies
```bash
npm install -g pnpm
# might need to rerun this when code is updated
pnpm install
# copy pdfjs-dist to Next.js public directory
pnpm --filter @readest/readest-app setup-pdfjs
@@ -58,7 +140,7 @@ This command will display information about the installed Tauri dependencies and
For Windows targets, “Build Tools for Visual Studio 2022” (or a higher edition of Visual Studio) and the “Desktop development with C++” workflow must be installed. For Windows ARM64 targets, the “VS 2022 C++ ARM64 build tools” and "C++ Clang Compiler for Windows" components must be installed. And make sure `clang` can be found in the path by adding `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\Llvm\x64\bin` for example in the environment variable `Path`.
### 4. Build the Development
### 4. Build for Development
```bash
pnpm tauri dev
@@ -74,10 +156,39 @@ pnpm tauri build
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please review our contributing guidelines before you start.
## Contributors
<!---
npx contributor-faces --exclude "*bot*" --limit 100"
--->
[//]: contributor-faces
<a href="https://github.com/chrox"><img src="https://avatars.githubusercontent.com/u/751535?v=4" title="chrox" width="50" height="50"></a>
[//]: contributor-faces
## License
This project is licensed under the AGPL V3 License. See the LICENSE file for details.
Readest is distributed under the AGPL-3.0 License. See the [LICENSE](<(LICENSE)>) file for details.
---
Happy reading with Readest!
<div align="center" style="color: gray;">Happy reading with Readest!</div>
[badge-website]: https://img.shields.io/badge/website-readest.com-orange
[badge-license]: https://img.shields.io/github/license/chrox/readest?color=teal
[badge-release]: https://img.shields.io/github/release/chrox/readest?color=green
[badge-platforms]: https://img.shields.io/badge/OS-macOS%2C%20Windows%2C%20Linux%2C%20Web-green
[badge-last-commit]: https://img.shields.io/github/last-commit/chrox/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/chrox/readest
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
[link-macos-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
[link-web-readest]: https://web.readest.com
[link-gh-releases]: https://github.com/chrox/readest/releases
[link-gh-commits]: https://github.com/chrox/readest/commits/main
[link-gh-pulse]: https://github.com/chrox/readest/pulse
[link-discord]: https://discord.gg/jb2nzDts
[link-parallel-read]: https://readest.com/#parallel-read
[link-koreader]: https://github.com/koreader/koreader
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_APP_PLATFORM=tauri
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_APP_PLATFORM=web
+9 -1
View File
@@ -7,7 +7,7 @@ const internalHost = process.env.TAURI_DEV_HOST || 'localhost';
const nextConfig = {
// Ensure Next.js uses SSG instead of SSR
// https://nextjs.org/docs/pages/building-your-application/deploying/static-exports
output: 'export',
output: process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web' ? undefined : 'output',
// Note: This feature is required to use the Next.js Image component in SSG mode.
// See https://nextjs.org/docs/messages/export-image-api for different workarounds.
images: {
@@ -19,6 +19,14 @@ 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;
+24 -15
View File
@@ -1,34 +1,41 @@
{
"name": "@readest/readest-app",
"version": "0.7.9",
"version": "0.8.2",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"dev": "dotenv -e .env.tauri -- next dev",
"build": "dotenv -e .env.tauri -- next build",
"start": "dotenv -e .env.tauri -- next start",
"dev-web": "dotenv -e .env.web -- next dev",
"build-web": "dotenv -e .env.web -- next build",
"start-web": "dotenv -e .env.web -- next start",
"lint": "next lint",
"tauri": "tauri",
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs",
"copy-pdfjs-js": "dotenv -- cross-var cpx \"%PDFJS_BUILD_PATH%/pdf*\" ./public/vendor/pdfjs",
"copy-pdfjs-fonts": "dotenv -- cross-var cpx \"%PDFJS_FONTS_PATH%/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
"copy-pdfjs-css": "dotenv -- cross-var cpx \"%PDFJS_STYLE_PATH%/{annotation_layer_builder,text_layer_builder}.css\" ./public/vendor/pdfjs",
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-fonts && pnpm copy-pdfjs-css",
"copy-flatten-pdfjs-annotation-layer-css": "dotenv -- cross-var npx postcss \"%PDFJS_STYLE_PATH%/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
"copy-flatten-pdfjs-text-layer-css": "dotenv -- cross-var npx postcss \"%PDFJS_STYLE_PATH%/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
"setup-pdfjs": "pnpm prepare-public-vendor && pnpm copy-pdfjs",
"build-win-x64": "tauri build --target i686-pc-windows-msvc --bundles nsis",
"build-win-arm64": "tauri build --target aarch64-pc-windows-msvc --bundles nsis",
"build-linux-x64": "tauri build --target x86_64-unknown-linux-gnu --bundles appimage",
"build-macos-universial": "dotenv -e .env.apple-nonstore.local -- tauri build -t universal-apple-darwin --bundles dmg",
"build-macos-universial-appstore": "dotenv -e .env.apple-appstore.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore.conf.json",
"build-macos-universial-appstore-dev": "dotenv -e .env.apple-appstore-dev.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore-dev.conf.json",
"release-macos-universial-appstore": "dotenv -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh"
"build-win-x64": "dotenv -e .env.tauri.local -- tauri build --target i686-pc-windows-msvc --bundles nsis",
"build-win-arm64": "dotenv -e .env.tauri.local -- tauri build --target aarch64-pc-windows-msvc --bundles nsis",
"build-linux-x64": "dotenv -e .env.tauri.local -- tauri build --target x86_64-unknown-linux-gnu --bundles appimage",
"build-macos-universial": "dotenv -e .env.tauri.local -e .env.apple-nonstore.local -- tauri build -t universal-apple-darwin --bundles dmg",
"build-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore.conf.json",
"build-macos-universial-appstore-dev": "dotenv -e .env.tauri.local -e .env.apple-appstore-dev.local -- tauri build -t universal-apple-darwin --bundles app --config src-tauri/tauri.appstore-dev.conf.json",
"release-macos-universial-appstore": "dotenv -e .env.tauri.local -e .env.apple-appstore.local -- bash scripts/release-mac-appstore.sh"
},
"dependencies": {
"@tauri-apps/api": "2.1.1",
"@tauri-apps/plugin-cli": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/plugin-fs": "^2.0.2",
"@tauri-apps/plugin-fs": "^2.0.3",
"@tauri-apps/plugin-http": "^2.0.1",
"@tauri-apps/plugin-log": "^2.0.0",
"@tauri-apps/plugin-log": "^2.0.1",
"@tauri-apps/plugin-os": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "~2.0.1",
"@tauri-apps/plugin-updater": "^2.0.0",
"@zip.js/zip.js": "^2.7.53",
@@ -61,6 +68,8 @@
"eslint-config-next": "15.0.3",
"mkdirp": "^3.0.1",
"postcss": "^8.4.49",
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.15",
"typescript": "^5.7.2"
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+262 -144
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -31,6 +31,7 @@ tauri-plugin-os = "2.0.1"
tauri-plugin-http = "2.0.3"
tauri-plugin-devtools = "2.0.0"
tauri-plugin-shell = "2"
tauri-plugin-process = "2"
[patch.crates-io]
tauri = { path = "../../../packages/tauri/crates/tauri" }
@@ -39,8 +40,6 @@ cocoa = "0.25"
objc = "0.2.7"
rand = "0.8"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-cli = "2"
tauri-plugin-updater = "2"
@@ -54,6 +54,10 @@
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"shell:default"
"shell:default",
"updater:default",
"process:default",
"process:allow-restart",
"cli:default"
]
}
+100 -38
View File
@@ -6,37 +6,62 @@ extern crate cocoa;
#[macro_use]
extern crate objc;
#[cfg(target_os = "macos")]
mod menu;
#[cfg(target_os = "macos")]
mod tauri_traffic_light_positioner_plugin;
#[cfg(target_os = "macos")]
use {
tauri::menu::{SubmenuBuilder, HELP_SUBMENU_ID},
tauri::TitleBarStyle,
tauri_plugin_shell::ShellExt,
};
use tauri::TitleBarStyle;
use std::path::PathBuf;
use tauri::{AppHandle, Emitter, Manager, Url};
use tauri::{WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_dialog;
use tauri_plugin_fs::FsExt;
fn handle_menu_event(app: &tauri::AppHandle, event: &tauri::menu::MenuEvent) {
if event.id() == "privacy_policy" {
if let Err(e) = app.shell().open("https://readest.com/privacy-policy", None) {
eprintln!("Failed to open privacy policy: {}", e);
#[cfg(desktop)]
use tauri::Listener;
#[cfg(desktop)]
fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
let fs_scope = app.fs_scope();
let asset_protocol_scope = app.asset_protocol_scope();
for file in &files {
if let Err(e) = fs_scope.allow_file(&file) {
eprintln!("Failed to allow file in fs_scope: {}", e);
} else {
println!("Allowed file in fs_scope: {:?}", file);
}
} else if event.id() == "report_issue" {
if let Err(e) = app.shell().open("mailto:support@bilingify.com", None) {
eprintln!("Failed to open mail client: {}", e);
}
} else if event.id() == "readest_help" {
if let Err(e) = app.shell().open("https://readest.com/support", None) {
eprintln!("Failed to open support page: {}", e);
if let Err(e) = asset_protocol_scope.allow_file(&file) {
eprintln!("Failed to allow file in asset_protocol_scope: {}", e);
} else {
println!("Allowed file in asset_protocol_scope: {:?}", file);
}
}
}
#[cfg(desktop)]
fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
let files = files
.into_iter()
.map(|f| {
let file = f.to_string_lossy().replace("\\", "\\\\");
format!("\"{file}\"",)
})
.collect::<Vec<_>>()
.join(",");
let window = app.get_webview_window("main").unwrap();
let script = format!("window.OPEN_WITH_FILES = [{}];", files);
if let Err(e) = window.eval(&script) {
eprintln!("Failed to set open files variable: {}", e);
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init())
@@ -47,10 +72,41 @@ pub fn run() {
let builder = builder.plugin(tauri_traffic_light_positioner_plugin::init());
builder
.setup(|app| {
.setup(|#[allow(unused_variables)] app| {
#[cfg(desktop)]
{
let mut files = Vec::new();
// NOTICE: `args` may include URL protocol (`your-app-protocol://`)
// or arguments (`--`) if your app supports them.
// files may aslo be passed as `file://path/to/file`
for maybe_file in std::env::args().skip(1) {
// skip flags like -f or --flag
if maybe_file.starts_with("-") {
continue;
}
// handle `file://` path urls and skip other urls
if let Ok(url) = Url::parse(&maybe_file) {
if let Ok(path) = url.to_file_path() {
files.push(path);
} else {
files.push(PathBuf::from(maybe_file))
}
} else {
files.push(PathBuf::from(maybe_file))
}
}
if !files.is_empty() {
let app_handle = app.handle().clone();
allow_file_in_scopes(&app_handle, files.clone());
app.listen("window-ready", move |_| {
println!("Window is ready, proceeding to handle files.");
set_window_open_with_files(&app_handle, files.clone());
});
}
}
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_cli::init())?;
#[cfg(desktop)]
app.handle()
.plugin(tauri_plugin_updater::Builder::new().build())?;
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
@@ -73,29 +129,35 @@ pub fn run() {
let win_builder = win_builder.decorations(false).transparent(true);
win_builder.build().unwrap();
// let win = win_builder.build().unwrap();
// win.open_devtools();
#[cfg(target_os = "macos")]
{
let global_menu = app.menu().unwrap();
if let Some(item) = global_menu.get(HELP_SUBMENU_ID) {
global_menu.remove(&item)?;
}
global_menu.append(
&SubmenuBuilder::new(app, "Help")
.text("privacy_policy", "Privacy Policy")
.separator()
.text("report_issue", "Report An Issue...")
.text("readest_help", "Readest Help")
.build()?,
)?;
menu::setup_macos_menu(&app.handle())?;
app.on_menu_event(move |app, event| {
handle_menu_event(app, &event);
});
}
app.handle().emit("window-ready", {}).unwrap();
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(
#[allow(unused_variables)]
|app_handle, event| {
#[cfg(any(target_os = "macos", target_os = "ios"))]
if let tauri::RunEvent::Opened { urls } = event {
let files = urls
.into_iter()
.filter_map(|url| url.to_file_path().ok())
.collect::<Vec<_>>();
let app_handler_clone = app_handle.clone();
allow_file_in_scopes(&app_handle, files.clone());
app_handle.listen("window-ready", move |_| {
println!("Window is ready, proceeding to handle files.");
set_window_open_with_files(&app_handler_clone, files.clone());
});
}
},
);
}
+43
View File
@@ -0,0 +1,43 @@
use tauri::menu::MenuEvent;
use tauri::menu::{SubmenuBuilder, HELP_SUBMENU_ID};
use tauri::AppHandle;
use tauri_plugin_shell::ShellExt;
pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
let global_menu = app.menu().unwrap();
if let Some(item) = global_menu.get(HELP_SUBMENU_ID) {
global_menu.remove(&item)?;
}
global_menu.append(
&SubmenuBuilder::new(app, "Help")
.text("privacy_policy", "Privacy Policy")
.separator()
.text("report_issue", "Report An Issue...")
.text("readest_help", "Readest Help")
.build()?,
)?;
app.on_menu_event(|app, event| {
handle_menu_event(app, &event);
});
Ok(())
}
pub fn handle_menu_event(app: &AppHandle, event: &MenuEvent) {
if event.id() == "privacy_policy" {
if let Err(e) = app.shell().open("https://readest.com/privacy-policy", None) {
eprintln!("Failed to open privacy policy: {}", e);
}
} else if event.id() == "report_issue" {
if let Err(e) = app.shell().open("mailto:support@bilingify.com", None) {
eprintln!("Failed to open mail client: {}", e);
}
} else if event.id() == "readest_help" {
if let Err(e) = app.shell().open("https://readest.com/support", None) {
eprintln!("Failed to open support page: {}", e);
}
}
}
+70 -1
View File
@@ -49,14 +49,83 @@
"macOS": {
"minimumSystemVersion": "12.0"
},
"fileAssociations": [
{
"name": "epub",
"ext": ["epub"],
"description": "EPUB file",
"mimeType": "application/epub+zip",
"role": "Viewer"
},
{
"name": "mobi",
"ext": ["mobi"],
"description": "MOBI file",
"mimeType": "application/x-mobipocket-ebook",
"role": "Viewer"
},
{
"name": "azw",
"ext": ["azw", "azw3"],
"description": "AZW file",
"mimeType": "application/vnd.amazon.ebook",
"role": "Viewer"
},
{
"name": "fb2",
"ext": ["fb2"],
"description": "FB2 file",
"mimeType": "application/x-fictionbook+xml",
"role": "Viewer"
},
{
"name": "cbz",
"ext": ["cbz"],
"description": "CBZ file",
"mimeType": "application/vnd.comicbook+zip",
"role": "Viewer"
},
{
"name": "pdf",
"ext": ["pdf"],
"description": "PDF file",
"mimeType": "application/pdf",
"role": "Viewer"
}
],
"createUpdaterArtifacts": true
},
"plugins": {
"fs": {
"requireLiteralLeadingDot": false
},
"cli": {
"description": "Readest CLI",
"args": [
{
"name": "file1",
"index": 1,
"takesValue": true
},
{
"name": "file2",
"index": 2,
"takesValue": true
},
{
"name": "file3",
"index": 3,
"takesValue": true
},
{
"name": "file4",
"index": 4,
"takesValue": true
}
]
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNDMDdERDIyMTQ1QzE5NUQKUldSZEdWd1VJdDBIUE50UXVDeHZtUXVyb1lnNUtVUFVKS0Fva2FDRVc0K0NubWpmajlxY2I1bCsK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0OTAxMURGQkUzQjFENTQKUldSVUhUdSszeEdRNUExdmFkWnlvYWNYNG5wamkxMmUxRk5SejlMOTJVd28yNXlVTDh6Wld4OC8K",
"endpoints": ["https://github.com/chrox/readest/releases/latest/download/latest.json"]
}
}
+21 -19
View File
@@ -1,34 +1,36 @@
'use client';
import * as React from 'react';
import { usePathname } from 'next/navigation';
import { AuthProvider } from '@/context/AuthContext';
import { EnvProvider } from '@/context/EnvContext';
import { CSPostHogProvider } from '@/context/PHContext';
import { useTheme } from '@/hooks/useTheme';
import '../styles/globals.css';
import '../styles/fonts.css';
const url = 'https://web.readest.com/';
const title = 'Readest — Where You Read, Digest and Get Insight';
const description = 'Readest brings your entire library to your fingertips.';
const previewImage = 'https://cdn.readest.com/images/open_graph_preview_read_now.png';
export default function RootLayout({ children }: { children: React.ReactNode }) {
useTheme();
const pathname = usePathname();
React.useEffect(() => {
document.documentElement.setAttribute('data-page', pathname.replace('/', '') || 'default');
}, [pathname]);
React.useEffect(() => {
// TODO: disabled for now
// if (process.env['NODE_ENV'] === 'production') {
// document.oncontextmenu = (event) => {
// event.preventDefault();
// };
// }
}, []);
return (
<html lang='en'>
<head>
<title>{title}</title>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1' />
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
<link rel='icon' href='/favicon.ico' />
<meta name='description' content={description} />
<meta property='og:url' content={url} />
<meta property='og:type' content='website' />
<meta property='og:title' content={title} />
<meta property='og:description' content={description} />
<meta property='og:image' content={previewImage} />
<meta name='twitter:card' content='summary_large_image' />
<meta property='twitter:domain' content='web.readest.com' />
<meta property='twitter:url' content={url} />
<meta name='twitter:title' content={title} />
<meta name='twitter:description' content={description} />
<meta name='twitter:image' content={previewImage} />
</head>
<CSPostHogProvider>
<body>
@@ -11,6 +11,7 @@ import Image from 'next/image';
import { Book, BooksGroup } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { navigateToReader } from '@/utils/nav';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
@@ -68,7 +69,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
setClickedImage(id);
setTimeout(() => setClickedImage(null), 300);
setTimeout(() => setLoading(true), 200);
router.push(`/reader?ids=${id}`);
navigateToReader(router, [id]);
}
};
@@ -80,7 +81,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
const openSelectedBooks = () => {
setTimeout(() => setLoading(true), 200);
router.push(`/reader?ids=${selectedBooks.join(',')}`);
navigateToReader(router, selectedBooks);
};
const confirmDelete = () => {
@@ -4,6 +4,7 @@ import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
import { PiSelectionAllDuotone } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import useTrafficLight from '@/hooks/useTrafficLight';
import WindowButtons from '@/components/WindowButtons';
@@ -18,6 +19,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onImportBooks,
onToggleSelectMode,
}) => {
const { appService } = useEnv();
const { isTrafficLightVisible } = useTrafficLight();
const headerRef = useRef<HTMLDivElement>(null);
@@ -33,21 +35,6 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
};
}, [onToggleSelectMode]);
const handleMinimize = async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
getCurrentWindow().minimize();
};
const handleToggleMaximize = async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
getCurrentWindow().toggleMaximize();
};
const handleClose = async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
getCurrentWindow().close();
};
return (
<div
ref={headerRef}
@@ -57,7 +44,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
)}
>
<div className='flex items-center justify-between space-x-6'>
<div className='sm:w relative flex w-full items-center pl-4'>
<div className='exclude-title-bar-mousedown sm:w relative flex w-full items-center pl-4'>
<span className='absolute left-8 text-gray-500'>
<FaSearch className='h-4 w-4' />
</span>
@@ -100,12 +87,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</div>
<WindowButtons
headerRef={headerRef}
showMinimize={!isTrafficLightVisible}
showMaximize={!isTrafficLightVisible}
showClose={!isTrafficLightVisible}
onMinimize={handleMinimize}
onToggleMaximize={handleToggleMaximize}
onClose={handleClose}
showMinimize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
showMaximize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
showClose={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
/>
</div>
</div>
+126 -33
View File
@@ -2,32 +2,109 @@
import * as React from 'react';
import { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { navigateToReader } from '@/utils/nav';
import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
import { useEnv } from '@/context/EnvContext';
import { useTheme } from '@/hooks/useTheme';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import Spinner from '@/components/Spinner';
import LibraryHeader from '@/app/library/components/LibraryHeader';
import Bookshelf from '@/app/library/components/Bookshelf';
const LibraryPage = () => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { library: libraryBooks, setLibrary } = useLibraryStore();
const {
library: libraryBooks,
setLibrary,
checkOpenWithBooks,
clearOpenWithBooks,
} = useLibraryStore();
useTheme();
const { setSettings } = useSettingsStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
const [isSelectMode, setIsSelectMode] = useState(false);
React.useEffect(() => {
const doAppUpdates = async () => {
if (isTauriAppPlatform()) {
await checkForAppUpdates();
}
};
doAppUpdates();
}, []);
const processOpenWithFiles = React.useCallback(
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);
}
}
setLibrary(libraryBooks);
appService.saveLibraryBooks(libraryBooks);
console.log('Opening books:', bookIds);
if (bookIds.length > 0) {
navigateToReader(router, bookIds);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
React.useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
const loadingTimeout = setTimeout(() => setLoading(true), 200);
envConfig.getAppService().then(async (appService) => {
console.log('Loading library books...');
setLibrary(await appService.loadLibraryBooks());
const loadingTimeout = setTimeout(() => setLoading(true), 300);
const initLibrary = async () => {
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
setSettings(settings);
const libraryBooks = await appService.loadLibraryBooks();
if (checkOpenWithBooks && isTauriAppPlatform()) {
await handleOpenWithBooks(appService, libraryBooks);
} else {
clearOpenWithBooks();
setLibrary(libraryBooks);
}
setLibraryLoaded(true);
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
});
};
const handleOpenWithBooks = async (appService: AppService, libraryBooks: Book[]) => {
const openWithFiles = (await parseOpenWithFiles()) || [];
if (openWithFiles.length > 0) {
await processOpenWithFiles(appService, openWithFiles, libraryBooks);
} else {
clearOpenWithBooks();
setLibrary(libraryBooks);
}
};
initLibrary();
return () => {
clearOpenWithBooks();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -42,14 +119,14 @@ const LibraryPage = () => {
};
const selectFilesTauri = async () => {
return appService?.selectFiles('Select Books', ['epub', 'pdf']);
return appService?.selectFiles('Select Books', SUPPORTED_FILE_EXTS);
};
const selectFilesWeb = () => {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.epub, .pdf';
fileInput.accept = FILE_ACCEPT_FORMATS;
fileInput.multiple = true;
fileInput.click();
@@ -61,12 +138,17 @@ const LibraryPage = () => {
const handleImportBooks = async () => {
console.log('Importing books...');
const { type } = await import('@tauri-apps/plugin-os');
let files;
if (['android', 'ios'].includes(type())) {
files = (await selectFilesWeb()) as [File];
if (isTauriAppPlatform()) {
const { type } = await import('@tauri-apps/plugin-os');
if (['android', 'ios'].includes(type())) {
files = (await selectFilesWeb()) as [File];
} else {
files = (await selectFilesTauri()) as [string];
}
} else {
files = (await selectFilesTauri()) as [string];
files = (await selectFilesWeb()) as [File];
}
importBooks(files);
};
@@ -79,6 +161,16 @@ const LibraryPage = () => {
return null;
}
if (checkOpenWithBooks) {
return (
loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)
);
}
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='fixed top-0 z-40 w-full'>
@@ -93,29 +185,30 @@ const LibraryPage = () => {
<Spinner loading />
</div>
)}
{libraryBooks.length > 0 ? (
<div className='mt-12 flex-grow overflow-auto px-2'>
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
onImportBooks={handleImportBooks}
/>
</div>
) : (
<div className='hero h-screen items-center justify-center'>
<div className='hero-content text-neutral-content text-center'>
<div className='max-w-md'>
<h1 className='mb-5 text-5xl font-bold'>Your Library</h1>
<p className='mb-5'>
Welcome to your library. You can import your books here and read them anytime.
</p>
<button className='btn btn-primary rounded-xl' onClick={handleImportBooks}>
Import Books
</button>
{libraryLoaded &&
(libraryBooks.length > 0 ? (
<div className='mt-12 flex-grow overflow-auto px-2'>
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
onImportBooks={handleImportBooks}
/>
</div>
) : (
<div className='hero h-screen items-center justify-center'>
<div className='hero-content text-neutral-content text-center'>
<div className='max-w-md'>
<h1 className='mb-5 text-5xl font-bold'>Your Library</h1>
<p className='mb-5'>
Welcome to your library. You can import your books here and read them anytime.
</p>
<button className='btn btn-primary rounded-xl' onClick={handleImportBooks}>
Import Books
</button>
</div>
</div>
</div>
</div>
)}
))}
</div>
);
};
+2 -1
View File
@@ -2,6 +2,7 @@
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { navigateToLibrary } from '@/utils/nav';
import Spinner from '@/components/Spinner';
@@ -9,7 +10,7 @@ const HomePage = () => {
const router = useRouter();
useEffect(() => {
router.push('/library');
navigateToLibrary(router);
}, [router]);
return <Spinner loading={true} />;
@@ -0,0 +1,6 @@
import Reader from '../components/Reader';
export default async function Page({ params }: { params: Promise<{ ids: string }> }) {
const ids = decodeURIComponent((await params).ids);
return <Reader ids={ids} />;
}
@@ -4,14 +4,16 @@ import { BookDoc } from '@/libs/document';
import { BookConfig, BookNote, BookSearchConfig, BookSearchResult } from '@/types/book';
import { useReaderStore } from '@/store/readerStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { getOSPlatform } from '@/utils/misc';
import { getStyles } from '@/utils/style';
import { useTheme } from '@/hooks/useTheme';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import {
handleKeydown,
handleMousedown,
handleClick,
handleMouseup,
handleClick,
handleWheel,
} from '../utils/iframeEventHandlers';
import { eventDispatcher } from '@/utils/event';
@@ -45,7 +47,7 @@ export interface FoliateView extends HTMLElement {
removeAttribute: (name: string) => void;
next: () => Promise<void>;
prev: () => Promise<void>;
scrollTo?: (offset: number, reason: string, smooth: boolean) => void;
goTo?: (params: { index: number; anchor: number }) => void;
setStyles?: (css: string) => void;
addEventListener: (type: string, listener: EventListener) => void;
removeEventListener: (type: string, listener: EventListener) => void;
@@ -73,71 +75,121 @@ const FoliateViewer: React.FC<{
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<FoliateView | null>(null);
const isViewCreated = useRef(false);
const isScrolling = useRef(false);
const { getView, setView: setFoliateView, setProgress, getViewSettings } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { themeCode } = useTheme();
const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform());
const progressRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
// console.log('relocate:', detail);
setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range);
};
const handleScrollbarAutoHide = (doc: Document) => {
if (doc && doc.defaultView && doc.defaultView.frameElement) {
const iframe = doc.defaultView.frameElement as HTMLIFrameElement;
const container = iframe.parentElement?.parentElement;
if (!container) return;
let hideScrollbarTimeout: ReturnType<typeof setTimeout>;
const showScrollbar = () => {
container.style.overflow = 'auto';
container.style.scrollbarWidth = 'thin';
};
const hideScrollbar = () => {
container.style.overflow = 'hidden';
container.style.scrollbarWidth = 'none';
requestAnimationFrame(() => {
container.style.overflow = 'auto';
});
};
container.addEventListener('scroll', () => {
showScrollbar();
clearTimeout(hideScrollbarTimeout);
hideScrollbarTimeout = setTimeout(hideScrollbar, 1000);
});
hideScrollbar();
}
};
const docLoadHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail.doc) {
const viewSettings = getViewSettings(bookKey)!;
if (viewSettings.scrolled && shouldAutoHideScrollbar) {
handleScrollbarAutoHide(detail.doc);
}
if (!detail.doc.isEventListenersAdded) {
detail.doc.isEventListenersAdded = true;
detail.doc.addEventListener('keydown', handleKeydown.bind(null, bookKey));
detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey));
detail.doc.addEventListener('mouseup', handleMouseup.bind(null, bookKey));
detail.doc.addEventListener('click', handleClick.bind(null, bookKey));
detail.doc.addEventListener('wheel', handleWheel.bind(null, bookKey));
}
}
};
const docScrollHandler = (event: Event) => {
setTimeout(() => {
isScrolling.current = false;
}, 300);
if (isScrolling.current) return;
isScrolling.current = true;
const docRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail.reason !== 'scroll') return;
if (!viewRef.current!.renderer.scrolled) return;
if (detail.reason !== 'scroll' && detail.reason !== 'page') return;
const parallelViews = getParallels(bookKey);
if (parallelViews && parallelViews.size > 0) {
parallelViews.forEach((key) => {
if (key !== bookKey) {
const target = getView(key)?.renderer;
if (target && target.scrolled && target.viewSize) {
target.scrollTo?.(detail.fraction * target.viewSize, 'follow-scroll', true);
if (target) {
target.goTo?.({ index: detail.index, anchor: detail.fraction });
}
}
});
}
};
const handleClickTurnPage = (
msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
const handleTurnPage = (msg: MessageEvent | React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (msg instanceof MessageEvent) {
if (msg.data && msg.data.type === 'iframe-single-click' && msg.data.bookKey === bookKey) {
const viewElement = containerRef.current;
if (viewElement) {
const rect = viewElement.getBoundingClientRect();
const { screenX } = msg.data;
const eventConsumed = eventDispatcher.dispatchSync('iframe-single-click', { screenX });
if (!eventConsumed) {
if (screenX >= rect.left + rect.width / 2) {
viewRef.current?.goRight();
} else if (screenX < rect.left + rect.width / 2) {
viewRef.current?.goLeft();
if (msg.data && msg.data.bookKey === bookKey) {
const viewSettings = getViewSettings(bookKey)!;
if (msg.data.type === 'iframe-single-click') {
const viewElement = containerRef.current;
if (viewElement) {
const rect = viewElement.getBoundingClientRect();
const { screenX, screenY } = msg.data;
const consumed = eventDispatcher.dispatchSync('iframe-single-click', {
screenX,
screenY,
});
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
) {
// toggle visibility of the header bar and the footer bar
setHoveredBookKey(hoveredBookKey ? '' : bookKey);
} else if (screenX >= rect.left + rect.width / 2) {
viewRef.current?.goRight();
} else if (screenX < rect.left + rect.width / 2) {
viewRef.current?.goLeft();
}
}
}
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
const { deltaY } = msg.data;
if (deltaY > 0) {
viewRef.current?.next(1);
} else if (deltaY < 0) {
viewRef.current?.prev(1);
}
}
}
} else {
@@ -153,10 +205,18 @@ const FoliateViewer: React.FC<{
}
};
useEffect(() => {
window.addEventListener('message', handleTurnPage);
return () => {
window.removeEventListener('message', handleTurnPage);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hoveredBookKey, viewRef]);
useFoliateEvents(viewRef.current, {
onLoad: docLoadHandler,
onRelocate: progressRelocateHandler,
onRendererRelocate: docScrollHandler,
onRendererRelocate: docRelocateHandler,
});
useEffect(() => {
@@ -213,8 +273,6 @@ const FoliateViewer: React.FC<{
} else {
await view.goToFraction(0);
}
window.addEventListener('message', handleClickTurnPage);
};
openBook();
@@ -224,7 +282,7 @@ const FoliateViewer: React.FC<{
return (
<div
className='foliate-viewer h-[100%] w-[100%]'
onClick={(event) => handleClickTurnPage(event)}
onClick={(event) => handleTurnPage(event)}
ref={containerRef}
/>
);
@@ -2,6 +2,7 @@ import clsx from 'clsx';
import React, { useRef, useState } from 'react';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import useTrafficLight from '@/hooks/useTrafficLight';
@@ -29,10 +30,11 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
onCloseBook,
onSetSettingsDialogOpen,
}) => {
const { appService } = useEnv();
const headerRef = useRef<HTMLDivElement>(null);
const { isTrafficLightVisible } = useTrafficLight();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { hoveredBookKey, setHoveredBookKey, bookKeys } = useReaderStore();
const { isSideBarVisible } = useSidebarStore();
const handleToggleDropdown = (isOpen: boolean) => {
@@ -67,7 +69,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<div className='flex h-full items-center space-x-2'>
<NotebookToggler bookKey={bookKey} />
<Dropdown
className='dropdown-bottom dropdown-end'
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeVerticalBold size={16} />}
onToggle={handleToggleDropdown}
@@ -78,8 +80,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<WindowButtons
className='window-buttons flex h-full items-center'
headerRef={headerRef}
showMinimize={false}
showMaximize={false}
showMinimize={bookKeys.length == 1 && appService?.appPlatform !== 'web'}
showMaximize={bookKeys.length == 1 && appService?.appPlatform !== 'web'}
onClose={() => onCloseBook(bookKey)}
/>
</div>
@@ -0,0 +1,46 @@
'use client';
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';
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { envConfig } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { library, setLibrary } = useLibraryStore();
const isInitiating = useRef(false);
useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
const initLibrary = async () => {
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
setSettings(settings);
setLibrary(await appService.loadLibraryBooks());
};
initLibrary();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
library.length > 0 &&
settings.globalReadSettings && (
<div className='reader-page bg-base-100 text-base-content min-h-screen select-none'>
<Suspense>
<ReaderContent ids={ids} settings={settings} />
<AboutWindow />
</Suspense>
</div>
)
);
};
export default Reader;
@@ -10,7 +10,11 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { SystemSettings } from '@/types/settings';
import { parseOpenWithFiles } from '@/helpers/cli';
import { tauriHandleClose } from '@/utils/window';
import { uniqueId } from '@/utils/misc';
import { navigateToLibrary } from '@/utils/nav';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
import useBooksManager from '../hooks/useBooksManager';
import useBookShortcuts from '../hooks/useBookShortcuts';
@@ -19,7 +23,7 @@ import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid';
const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) => {
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig } = useEnv();
@@ -38,7 +42,8 @@ const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) =>
if (isInitiating.current) return;
isInitiating.current = true;
const initialIds = (searchParams.get('ids') || '').split(',').filter(Boolean);
const bookIds = ids || searchParams.get('ids') || '';
const initialIds = bookIds.split(BOOK_IDS_SEPARATOR).filter(Boolean);
const initialBookKeys = initialIds.map((id) => `${id}-${uniqueId()}`);
setBookKeys(initialBookKeys);
const uniqueIds = new Set<string>();
@@ -70,7 +75,7 @@ const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) =>
const saveSettingsAndGoToLibrary = () => {
saveSettings(envConfig, settings);
router.replace('/library');
navigateToLibrary(router);
};
const handleCloseBooks = () => {
@@ -80,21 +85,26 @@ const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) =>
saveSettingsAndGoToLibrary();
};
const handleCloseBook = (bookKey: string) => {
const handleCloseBook = async (bookKey: string) => {
saveConfigAndCloseBook(bookKey);
if (sideBarBookKey === bookKey) {
setSideBarBookKey(getNextBookKey(sideBarBookKey));
}
dismissBook(bookKey);
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
saveSettingsAndGoToLibrary();
const openWithFiles = (await parseOpenWithFiles()) || [];
if (openWithFiles.length > 0) {
tauriHandleClose();
} else {
saveSettingsAndGoToLibrary();
}
}
};
if (!bookKeys || bookKeys.length === 0) return null;
const bookData = getBookData(bookKeys[0]!);
if (!bookData || !bookData.book || !bookData.bookDoc) {
setTimeout(() => setLoading(true), 200);
setTimeout(() => setLoading(true), 300);
return (
loading && (
<div className={'hero hero-content min-h-screen'}>
@@ -5,6 +5,7 @@ import { BiMoon, BiSun } from 'react-icons/bi';
import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import MenuItem from '@/components/MenuItem';
import { useReaderStore } from '@/store/readerStore';
import { useTheme, ThemeMode } from '@/hooks/useTheme';
@@ -22,7 +23,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
onSetSettingsDialogOpen,
}) => {
const { getView, getViews, getViewSettings, setViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const { themeMode, isDarkMode, themeCode, updateThemeMode } = useTheme();
const [isScrolledMode, setScrolledMode] = useState(viewSettings!.scrolled);
@@ -55,6 +56,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
useEffect(() => {
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute(
'max-inline-size',
`${viewSettings.maxColumnCount === 1 || isScrolledMode ? ONE_COLUMN_MAX_INLINE_SIZE : viewSettings.maxInlineSize}px`,
);
getView(bookKey)?.renderer.setStyles?.(getStyles(viewSettings!, themeCode));
viewSettings!.scrolled = isScrolledMode;
setViewSettings(bookKey, viewSettings!);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -80,7 +86,6 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
return (
<div
id='exclude-title-bar-mousedown'
tabIndex={0}
className='view-menu dropdown-content dropdown-right no-triangle border-base-100 z-20 mt-1 w-72 border shadow-2xl'
>
@@ -58,8 +58,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalReadSettings.highlightStyles[selectedStyle],
);
const dictPopupWidth = 400;
const dictPopupHeight = 300;
const dictPopupWidth = 480;
const dictPopupHeight = 360;
const annotPopupWidth = 280;
const annotPopupHeight = 44;
const popupPadding = 10;
@@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { fetch } from '@tauri-apps/plugin-http';
import { useSettingsStore } from '@/store/settingsStore';
import { isWebAppPlatform } from '@/services/environment';
const LANGUAGES = {
AUTO: 'Auto Detect',
@@ -61,8 +61,19 @@ 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('https://api-free.deepl.com/v2/translate', {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -78,7 +89,6 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
if (!response.ok) {
throw new Error('Failed to fetch translation');
}
const data = await response.json();
const translatedText = data.translations[0]?.text;
const detectedSource = data.translations[0]?.detected_source_language;
@@ -5,6 +5,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { uniqueId } from '@/utils/misc';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { navigateToReader } from '@/utils/nav';
const useBooksManager = () => {
const router = useRouter();
@@ -18,11 +19,9 @@ const useBooksManager = () => {
useEffect(() => {
if (shouldUpdateSearchParams) {
const ids = bookKeys.map((key) => key.split('-')[0]).join(',');
const ids = bookKeys.map((key) => key.split('-')[0]!);
if (ids) {
const params = new URLSearchParams(searchParams.toString());
params.set('ids', ids);
router.replace(`?${params.toString()}`, { scroll: false });
navigateToReader(router, ids, searchParams.toString(), { scroll: false });
}
setShouldUpdateSearchParams(false);
}
+14 -40
View File
@@ -1,47 +1,21 @@
'use client';
import * as React from 'react';
import { useEffect, Suspense, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import ReaderContent from './components/ReaderContent';
import { AboutWindow } from '@/components/AboutWindow';
import { useSettingsStore } from '@/store/settingsStore';
const ReaderPage = () => {
const { envConfig } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { library, setLibrary } = useLibraryStore();
const isInitiating = useRef(false);
import { useEffect } from 'react';
import { useTheme } from '@/hooks/useTheme';
import { isTauriAppPlatform } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import Reader from './components/Reader';
export default function Page() {
useTheme();
useEffect(() => {
if (isInitiating.current) return;
isInitiating.current = true;
const initLibrary = async () => {
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
setSettings(settings);
console.log('initializing library in reader');
setLibrary(await appService.loadLibraryBooks());
const doAppUpdates = async () => {
if (isTauriAppPlatform()) {
await checkForAppUpdates();
}
};
initLibrary();
// eslint-disable-next-line react-hooks/exhaustive-deps
doAppUpdates();
}, []);
return (
library.length > 0 &&
settings.globalReadSettings && (
<div className='reader-page bg-base-100 text-base-content min-h-screen select-none'>
<Suspense>
<ReaderContent settings={settings} />
<AboutWindow />
</Suspense>
</div>
)
);
};
export default ReaderPage;
return <Reader />;
}
@@ -57,6 +57,26 @@ export const handleMouseup = (bookKey: string, event: MouseEvent) => {
);
};
export const handleWheel = (bookKey: string, event: WheelEvent) => {
window.postMessage(
{
type: 'iframe-wheel',
bookKey,
deltaMode: event.deltaMode,
deltaX: event.deltaX,
deltaY: event.deltaY,
deltaZ: event.deltaZ,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
offsetX: event.offsetX,
offsetY: event.offsetY,
},
'*',
);
};
export const handleClick = (bookKey: string, event: MouseEvent) => {
const now = Date.now();
@@ -1,6 +1,8 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import { tauriHandleMinimize, tauriHandleToggleMaximize, tauriHandleClose } from '@/utils/window';
interface WindowButtonsProps {
className?: string;
headerRef?: React.RefObject<HTMLDivElement>;
@@ -12,6 +14,24 @@ interface WindowButtonsProps {
onClose?: () => void;
}
interface WindowButtonProps {
id: string;
onClick: () => void;
ariaLabel: string;
children: React.ReactNode;
}
const WindowButton: React.FC<WindowButtonProps> = ({ onClick, ariaLabel, id, children }) => (
<button
id={id}
onClick={onClick}
className='window-button text-base-content/85 hover:text-base-content'
aria-label={ariaLabel}
>
{children}
</button>
);
const WindowButtons: React.FC<WindowButtonsProps> = ({
className,
headerRef,
@@ -27,11 +47,11 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
const handleMouseDown = async (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target !== e.currentTarget) {
return;
}
if (target.closest('#exclude-title-bar-mousedown')) {
if (
target.closest('.btn') ||
target.closest('.window-button') ||
target.closest('.exclude-title-bar-mousedown')
) {
return;
}
@@ -58,18 +78,24 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
const handleMinimize = async () => {
if (onMinimize) {
onMinimize();
} else {
tauriHandleMinimize();
}
};
const handleMaximize = async () => {
if (onToggleMaximize) {
onToggleMaximize();
} else {
tauriHandleToggleMaximize();
}
};
const handleClose = async () => {
if (onClose) {
onClose();
} else {
tauriHandleClose();
}
};
@@ -83,45 +109,30 @@ const WindowButtons: React.FC<WindowButtonsProps> = ({
)}
>
{showMinimize && (
<button
onClick={handleMinimize}
className='window-button text-base-content'
aria-label='Minimize'
id='titlebar-minimize'
>
<WindowButton onClick={handleMinimize} ariaLabel='Minimize' id='titlebar-minimize'>
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path fill='currentColor' d='M20 14H4v-2h16' />
</svg>
</button>
</WindowButton>
)}
{showMaximize && (
<button
onClick={handleMaximize}
className='window-button text-base-content'
aria-label='Maximize/Restore'
id='titlebar-maximize'
>
<WindowButton onClick={handleMaximize} ariaLabel='Maximize/Restore' id='titlebar-maximize'>
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path fill='currentColor' d='M4 4h16v16H4zm2 4v10h12V8z' />
</svg>
</button>
</WindowButton>
)}
{showClose && (
<button
onClick={handleClose}
className='window-button text-base-content'
aria-label='Close'
id='titlebar-close'
>
<WindowButton onClick={handleClose} ariaLabel='Close' id='titlebar-close'>
<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>
</WindowButton>
)}
</div>
);
+43
View File
@@ -0,0 +1,43 @@
import { isWebAppPlatform } from '@/services/environment';
declare global {
interface Window {
OPEN_WITH_FILES?: string[];
}
}
interface CliArgument {
value: string;
occurrences: number;
}
const parseWindowOpenWithFiles = () => {
return window.OPEN_WITH_FILES;
};
const parseCLIOpenWithFiles = async () => {
const { getMatches } = await import('@tauri-apps/plugin-cli');
const matches = await getMatches();
const args = matches?.args;
const files: string[] = [];
if (args) {
for (const name of ['file1', 'file2', 'file3', 'file4']) {
const arg = args[name] as CliArgument;
if (arg && arg.occurrences > 0) {
files.push(arg.value);
}
}
}
return files;
};
export const parseOpenWithFiles = async () => {
if (isWebAppPlatform()) return [];
let files = parseWindowOpenWithFiles();
if (!files) {
files = await parseCLIOpenWithFiles();
}
return files;
};
+44
View File
@@ -0,0 +1,44 @@
import { check } from '@tauri-apps/plugin-updater';
import { ask } from '@tauri-apps/plugin-dialog';
import { relaunch } from '@tauri-apps/plugin-process';
export const checkForAppUpdates = async () => {
const update = await check();
if (update) {
const yes = await ask(
`
Update to ${update.version} is available!
Release notes: ${update.body}
`,
{
title: 'Update Now!',
kind: 'info',
okLabel: 'Update',
cancelLabel: 'Cancel',
},
);
if (yes) {
console.log(`found update ${update.version} from ${update.date} with notes ${update.body}`);
let downloaded = 0;
let contentLength = 0;
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength!;
console.log(`started downloading ${event.data.contentLength} bytes`);
break;
case 'Progress':
downloaded += event.data.chunkLength;
console.log(`downloaded ${downloaded} from ${contentLength}`);
break;
case 'Finished':
console.log('download finished');
break;
}
});
console.log('update installed');
await relaunch();
}
}
};
@@ -30,7 +30,10 @@ const useTrafficLight = () => {
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
handleSwitchFullScreen();
return () => {
unlistenEnterFullScreen?.();
unlistenExitFullScreen?.();
+32 -12
View File
@@ -1,4 +1,4 @@
import { AppService, ToastType } from '@/types/system';
import { AppPlatform, AppService, ToastType } from '@/types/system';
import { SystemSettings } from '@/types/settings';
import { FileSystem, BaseDir } from '@/types/system';
@@ -26,14 +26,15 @@ import {
export abstract class BaseAppService implements AppService {
localBooksDir: string = '';
abstract fs: FileSystem;
abstract appPlatform: AppPlatform;
abstract isAppDataSandbox: boolean;
abstract hasTrafficLight: boolean;
abstract fs: FileSystem;
abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string };
abstract getCoverImageUrl(book: Book): string;
abstract getCoverImageBlobUrl(book: Book): Promise<string>;
abstract getInitBooksDir(): Promise<string>;
abstract selectDirectory(title: string): Promise<string>;
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
abstract showMessage(
msg: string,
@@ -93,7 +94,7 @@ export abstract class BaseAppService implements AppService {
file: string | File,
books: Book[],
overwrite: boolean = false,
): Promise<Book[]> {
): Promise<Book | null> {
try {
let loadedBook: BookDoc;
let format: BookFormat;
@@ -133,8 +134,6 @@ export abstract class BaseAppService implements AppService {
author: loadedBook.metadata.author,
lastUpdated: Date.now(),
};
book.coverImageUrl = this.getCoverImageUrl(book);
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
}
@@ -156,11 +155,18 @@ export abstract class BaseAppService implements AppService {
await this.saveBookConfig(book, INIT_BOOK_CONFIG);
books.splice(0, 0, book);
}
if (this.appPlatform === 'web') {
book.coverImageUrl = await this.getCoverImageBlobUrl(book);
} else {
book.coverImageUrl = this.getCoverImageUrl(book);
}
return book;
} catch (error) {
throw error;
}
return books;
return null;
}
async deleteBook(book: Book): Promise<void> {
@@ -173,7 +179,13 @@ export abstract class BaseAppService implements AppService {
async loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent> {
const fp = getFilename(book);
const file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
let file: File;
if (this.appPlatform === 'web') {
const content = await this.fs.readFile(fp, 'Books', 'binary');
file = new File([content], fp);
} else {
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
}
return { book, file, config: await this.loadBookConfig(book, settings) };
}
@@ -210,6 +222,7 @@ export abstract class BaseAppService implements AppService {
}
async loadLibraryBooks(): Promise<Book[]> {
console.log('Loading library books...');
let books: Book[] = [];
const libraryFilename = getLibraryFilename();
@@ -221,9 +234,16 @@ export abstract class BaseAppService implements AppService {
await this.fs.writeFile(libraryFilename, 'Books', '[]');
}
books.forEach((book) => {
book.coverImageUrl = this.getCoverImageUrl(book);
});
await Promise.all(
books.map(async (book) => {
if (this.appPlatform === 'web') {
book.coverImageUrl = await this.getCoverImageBlobUrl(book);
} else {
book.coverImageUrl = this.getCoverImageUrl(book);
}
return book;
}),
);
return books;
}
@@ -4,6 +4,9 @@ import { ReadSettings } from '@/types/settings';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'pdf'];
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
export const DEFAULT_READSETTINGS: ReadSettings = {
sideBarWidth: '25%',
isSideBarPinned: true,
@@ -75,3 +78,5 @@ export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'Helvetica'
export const MONOSPACE_FONTS = ['Fira Code', 'Lucida Console', 'Consolas', 'Courier New'];
export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
export const BOOK_IDS_SEPARATOR = '+';
+26 -6
View File
@@ -1,19 +1,39 @@
import { AppService } from '@/types/system';
let appService: AppService | null = null;
export const isTauriAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri';
export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
export interface EnvConfigType {
getAppService: () => Promise<AppService>;
}
let nativeAppService: AppService | null = null;
const getNativeAppService = async () => {
if (!nativeAppService) {
const { NativeAppService } = await import('@/services/nativeAppService');
nativeAppService = new NativeAppService();
await nativeAppService.loadSettings();
}
return nativeAppService;
};
let webAppService: AppService | null = null;
const getWebAppService = async () => {
if (!webAppService) {
const { WebAppService } = await import('@/services/webAppService');
webAppService = new WebAppService();
await webAppService.loadSettings();
}
return webAppService;
};
const environmentConfig: EnvConfigType = {
getAppService: async () => {
if (!appService) {
const { NativeAppService } = await import('@/services/nativeAppService');
appService = new NativeAppService();
await appService.loadSettings();
if (isTauriAppPlatform()) {
return getNativeAppService();
} else {
return getWebAppService();
}
return appService;
},
};
@@ -16,7 +16,7 @@ import { join, appDataDir } from '@tauri-apps/api/path';
import { type as osType } from '@tauri-apps/plugin-os';
import { Book } from '@/types/book';
import { ToastType, FileSystem, BaseDir } from '@/types/system';
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getCoverFilename } from '@/utils/book';
import { BaseAppService } from './appService';
@@ -24,10 +24,7 @@ import { LOCAL_BOOKS_SUBDIR } from './constants';
export const isMobile = ['android', 'ios'].includes(osType());
export const resolvePath = (
fp: string,
base: BaseDir,
): { baseDir: number; base: BaseDir; fp: string } => {
const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => {
switch (base) {
case 'Settings':
return { baseDir: BaseDirectory.AppConfig, fp, base };
@@ -56,6 +53,10 @@ export const nativeFileSystem: FileSystem = {
getURL(path: string) {
return convertFileSrc(path);
},
async getBlobURL(path: string, base: BaseDir) {
const content = await this.readFile(path, base, 'binary');
return URL.createObjectURL(new Blob([content]));
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
const { fp, baseDir } = resolvePath(dstPath, base);
await copyFile(srcPath, fp, base && { toPathBaseDir: baseDir });
@@ -114,6 +115,7 @@ export const nativeFileSystem: FileSystem = {
export class NativeAppService extends BaseAppService {
fs = nativeFileSystem;
appPlatform = 'tauri' as AppPlatform;
isAppDataSandbox = isMobile;
hasTrafficLight = osType() === 'macos';
@@ -125,14 +127,6 @@ export class NativeAppService extends BaseAppService {
return join(await appDataDir(), LOCAL_BOOKS_SUBDIR);
}
async selectDirectory(title: string): Promise<string> {
const selected = await open({
title,
directory: true,
});
return selected as string;
}
async selectFiles(name: string, extensions: string[]): Promise<string[]> {
const selected = await open({
multiple: true,
@@ -153,4 +147,8 @@ export class NativeAppService extends BaseAppService {
getCoverImageUrl = (book: Book): string => {
return this.fs.getURL(`${this.localBooksDir}/${getCoverFilename(book)}`);
};
getCoverImageBlobUrl = async (book: Book): Promise<string> => {
return this.fs.getBlobURL(`${this.localBooksDir}/${getCoverFilename(book)}`, 'None');
};
}
@@ -0,0 +1,206 @@
import { Book } from '@/types/book';
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getCoverFilename } from '@/utils/book';
import { BaseAppService } from './appService';
import { LOCAL_BOOKS_SUBDIR } from './constants';
const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => {
switch (base) {
case 'Books':
return { baseDir: 0, fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`, base };
case 'None':
return { baseDir: 0, fp, base };
default:
return { baseDir: 0, fp: `${base}/${fp}`, base };
}
};
const dbName = 'AppFileSystem';
const dbVersion = 1;
async function openIndexedDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, dbVersion);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('files')) {
db.createObjectStore('files', { keyPath: 'path' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
const indexedDBFileSystem: FileSystem = {
getURL(path: string) {
return URL.createObjectURL(new Blob([path]));
},
async getBlobURL(path: string, base: BaseDir) {
try {
const content = await this.readFile(path, base, 'binary');
return URL.createObjectURL(new Blob([content]));
} catch {
return path;
}
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
const { fp } = resolvePath(dstPath, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
const getRequest = store.get(srcPath);
getRequest.onsuccess = () => {
const data = getRequest.result;
if (data) {
store.put({ path: fp, content: data.content });
resolve();
} else {
reject(new Error(`File not found: ${srcPath}`));
}
};
getRequest.onerror = () => reject(getRequest.error);
});
},
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
const { fp } = resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<string | ArrayBuffer>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.get(fp);
request.onsuccess = async () => {
if (request.result) {
const content = request.result.content;
if (mode === 'text') resolve(content);
else {
if (content instanceof Blob) {
const arrayBuffer = await content.arrayBuffer();
resolve(arrayBuffer);
} else if (content instanceof ArrayBuffer) {
resolve(content);
} else if (typeof content === 'string') {
resolve(new TextEncoder().encode(content).buffer as ArrayBuffer);
} else {
reject(new Error('Unsupported content type in IndexedDB'));
}
}
} else {
reject(new Error(`File not found: ${fp}`));
}
};
request.onerror = () => reject(request.error);
});
},
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer) {
const { fp } = resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
store.put({ path: fp, content });
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
},
async removeFile(path: string, base: BaseDir) {
const { fp } = resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
store.delete(fp);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
},
async createDir() {
// Directories are virtual in IndexedDB; no-op
},
async removeDir() {
// Directories are virtual in IndexedDB; no-op
},
async readDir(path: string) {
const db = await openIndexedDB();
return new Promise<{ path: string; isDir: boolean }[]>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.getAll();
request.onsuccess = () => {
const files = request.result as { path: string }[];
resolve(
files
.filter((file) => file.path.startsWith(path))
.map((file) => ({ path: file.path, isDir: false })),
);
};
request.onerror = () => reject(request.error);
});
},
async exists(path: string, base: BaseDir) {
const { fp } = resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<boolean>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.get(fp);
request.onsuccess = () => resolve(!!request.result);
request.onerror = () => reject(request.error);
});
},
};
export class WebAppService extends BaseAppService {
fs = indexedDBFileSystem;
appPlatform = 'web' as AppPlatform;
isAppDataSandbox = false;
hasTrafficLight = false;
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
}
async getInitBooksDir(): Promise<string> {
return LOCAL_BOOKS_SUBDIR;
}
async selectDirectory(): Promise<string> {
throw new Error('selectDirectory is not supported in browser');
}
async selectFiles(): Promise<string[]> {
throw new Error('selectFiles is not supported in browser');
}
async showMessage(msg: string, kind: ToastType = 'info'): Promise<void> {
alert(`${kind.toUpperCase()}: ${msg}`);
}
getCoverImageUrl = (book: Book): string => {
return this.fs.getURL(`${LOCAL_BOOKS_SUBDIR}/${getCoverFilename(book)}`);
};
getCoverImageBlobUrl = async (book: Book): Promise<string> => {
return this.fs.getBlobURL(`${LOCAL_BOOKS_SUBDIR}/${getCoverFilename(book)}`, 'None');
};
}
@@ -4,12 +4,16 @@ import { EnvConfigType } from '@/services/environment';
interface LibraryState {
library: Book[];
checkOpenWithBooks: boolean;
clearOpenWithBooks: () => void;
setLibrary: (books: Book[]) => void;
deleteBook: (envConfig: EnvConfigType, book: Book) => void;
}
export const useLibraryStore = create<LibraryState>((set, get) => ({
library: [],
checkOpenWithBooks: true,
clearOpenWithBooks: () => set({ checkOpenWithBooks: false }),
setLibrary: (books) => set({ library: books }),
deleteBook: async (envConfig: EnvConfigType, book: Book) => {
const appService = await envConfig.getAppService();
+5 -2
View File
@@ -1,11 +1,13 @@
import { SystemSettings } from './settings';
import { Book, BookConfig, BookContent } from './book';
export type AppPlatform = 'web' | 'tauri';
export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None';
export type ToastType = 'info' | 'warning' | 'error';
export interface FileSystem {
getURL(path: string): string;
getBlobURL(path: string, base: BaseDir): Promise<string>;
copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise<void>;
readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise<string | ArrayBuffer>;
writeFile(path: string, base: BaseDir, content: string | ArrayBuffer): Promise<void>;
@@ -18,16 +20,16 @@ export interface FileSystem {
export interface AppService {
fs: FileSystem;
appPlatform: AppPlatform;
hasTrafficLight: boolean;
isAppDataSandbox: boolean;
selectDirectory(title: string): Promise<string>;
selectFiles(name: string, extensions: string[]): Promise<string[]>;
showMessage(msg: string, kind?: ToastType, title?: string, okLabel?: string): Promise<void>;
loadSettings(): Promise<SystemSettings>;
saveSettings(settings: SystemSettings): Promise<void>;
importBook(file: string | File, books: Book[], overwrite?: boolean): Promise<Book[]>;
importBook(file: string | File, books: Book[], overwrite?: boolean): Promise<Book | null>;
deleteBook(book: Book): Promise<void>;
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
@@ -35,4 +37,5 @@ export interface AppService {
loadLibraryBooks(): Promise<Book[]>;
saveLibraryBooks(books: Book[]): Promise<void>;
getCoverImageUrl(book: Book): string;
getCoverImageBlobUrl(book: Book): Promise<string>;
}
+12
View File
@@ -22,3 +22,15 @@ export const makeSafeFilename = (filename: string, replacement = '_') => {
return safeName.trim();
};
export const getOSPlatform = () => {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('macintosh') || userAgent.includes('mac os x')) return 'macos';
if (userAgent.includes('windows nt')) return 'windows';
if (userAgent.includes('linux')) return 'linux';
if (/iphone|ipad|ipod/.test(userAgent)) return 'ios';
if (userAgent.includes('android')) return 'android';
return '';
};
+23
View File
@@ -0,0 +1,23 @@
import { useRouter } from 'next/navigation';
import { isWebAppPlatform } from '@/services/environment';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
export const navigateToReader = (
router: ReturnType<typeof useRouter>,
bookIds: string[],
queryParams?: string,
navOptions?: { scroll?: boolean },
) => {
const ids = bookIds.join(BOOK_IDS_SEPARATOR);
if (isWebAppPlatform()) {
router.push(`/reader/${ids}${queryParams ? `?${queryParams}` : ''}`, navOptions);
} else {
const params = new URLSearchParams(queryParams || '');
params.set('ids', ids);
router.push(`/reader?${params.toString()}`, navOptions);
}
};
export const navigateToLibrary = (router: ReturnType<typeof useRouter>) => {
router.push('/library');
};
+1
View File
@@ -75,6 +75,7 @@ const getLayoutStyles = (
tab-size: 2;
}
body {
color: ${fg};
zoom: ${zoomLevel}%;
background-color: ${bg} !important;
}
+14
View File
@@ -0,0 +1,14 @@
export const tauriHandleMinimize = async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
getCurrentWindow().minimize();
};
export const tauriHandleToggleMaximize = async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
getCurrentWindow().toggleMaximize();
};
export const tauriHandleClose = async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
getCurrentWindow().close();
};
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "@sindresorhus/tsconfig",
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

+1
View File
@@ -1,6 +1,7 @@
{
"name": "@readest/monorepo",
"private": true,
"repository": "chrox/readest",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"tauri": "pnpm --filter @readest/readest-app tauri"
+229 -26
View File
@@ -10,7 +10,7 @@ importers:
devDependencies:
'@sindresorhus/tsconfig':
specifier: ^6.0.0
version: 7.0.0
version: 6.0.0
cross-env:
specifier: ^7.0.3
version: 7.0.3
@@ -38,19 +38,25 @@ importers:
'@tauri-apps/api':
specifier: 2.1.1
version: 2.1.1
'@tauri-apps/plugin-cli':
specifier: ^2.0.0
version: 2.0.0
'@tauri-apps/plugin-dialog':
specifier: ^2.0.1
version: 2.0.1
'@tauri-apps/plugin-fs':
specifier: ^2.0.2
version: 2.0.2
specifier: ^2.0.3
version: 2.0.3
'@tauri-apps/plugin-http':
specifier: ^2.0.1
version: 2.0.1
'@tauri-apps/plugin-log':
specifier: ^2.0.1
version: 2.0.1
'@tauri-apps/plugin-os':
specifier: ^2.0.0
version: 2.0.0
'@tauri-apps/plugin-os':
'@tauri-apps/plugin-process':
specifier: ^2.0.0
version: 2.0.0
'@tauri-apps/plugin-shell':
@@ -144,6 +150,12 @@ importers:
postcss:
specifier: ^8.4.49
version: 8.4.49
postcss-cli:
specifier: ^11.0.0
version: 11.0.0(jiti@1.21.6)(postcss@8.4.49)
postcss-nested:
specifier: ^7.0.2
version: 7.0.2(postcss@8.4.49)
raw-loader:
specifier: ^4.0.2
version: 4.0.2(webpack@5.96.1)
@@ -610,8 +622,12 @@ packages:
'@rushstack/eslint-patch@1.10.4':
resolution: {integrity: sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==}
'@sindresorhus/tsconfig@7.0.0':
resolution: {integrity: sha512-i5K04hLAP44Af16zmDjG07E1NHuDgCM07SJAT4gY0LZSRrWYzwt4qkLem6TIbIVh0k51RkN2bF+lP+lM5eC9fw==}
'@sindresorhus/merge-streams@2.3.0':
resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==}
engines: {node: '>=18'}
'@sindresorhus/tsconfig@6.0.0':
resolution: {integrity: sha512-+fUdfuDd/7O2OZ9/UvJy76IEWn2Tpvm2l+rwUoS2Yz4jCUTSNOQQv2PLWrwekt8cPLwHmpHaBpay34bkBmVl2Q==}
engines: {node: '>=18'}
'@swc/counter@0.1.3':
@@ -692,21 +708,27 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-cli@2.0.0':
resolution: {integrity: sha512-glQmlL1IiCGEa1FHYa/PTPSeYhfu56omLRgHXWlJECDt6DbJyRuJWVgtkQfUxtqnVdYnnU+DGIGeiInoEqtjLw==}
'@tauri-apps/plugin-dialog@2.0.1':
resolution: {integrity: sha512-fnUrNr6EfvTqdls/ufusU7h6UbNFzLKvHk/zTuOiBq01R3dTODqwctZlzakdbfSp/7pNwTKvgKTAgl/NAP/Z0Q==}
'@tauri-apps/plugin-fs@2.0.2':
resolution: {integrity: sha512-4YZaX2j7ta81M5/DL8aN10kTnpUkEpkPo1FTYPT8Dd0ImHe3azM8i8MrtjrDGoyBYLPO3zFv7df/mSCYF8oA0Q==}
'@tauri-apps/plugin-fs@2.0.3':
resolution: {integrity: sha512-3Xv9cg5krj/2gSo1HFn9GbKUE0keh+DMx7O+hQeSzg1T4yGY5Fv/+yBISmrPa2j9thi3qcjgPj8PnMH4rbl/Dw==}
'@tauri-apps/plugin-http@2.0.1':
resolution: {integrity: sha512-j6IA3pVBybSCwPpsihpX4z8bs6PluuGtr06ahL/xy4D8HunNBTmRmadJrFOQi0gOAbaig4MkQ15nzNLBLy8R1A==}
'@tauri-apps/plugin-log@2.0.0':
resolution: {integrity: sha512-C+NII9vzswqnOQE8k7oRtnaw0z5TZsMmnirRhXkCKDEhQQH9841Us/PC1WHtGiAaJ8za1A1JB2xXndEq/47X/w==}
'@tauri-apps/plugin-log@2.0.1':
resolution: {integrity: sha512-zFT3S/Q5liMKIjPPdUZL8aNbjvhHvyfkhuqct3/MQ6/7rk4ODstcUPu320KJBjZrNNH/UM+kaQBGrioIwtmP/Q==}
'@tauri-apps/plugin-os@2.0.0':
resolution: {integrity: sha512-M7hG/nNyQYTJxVG/UhTKhp9mpXriwWzrs9mqDreB8mIgqA3ek5nHLdwRZJWhkKjZrnDT4v9CpA9BhYeplTlAiA==}
'@tauri-apps/plugin-process@2.0.0':
resolution: {integrity: sha512-OYzi0GnkrF4NAnsHZU7U3tjSoP0PbeAlO7T1Z+vJoBUH9sFQ1NSLqWYWQyf8hcb3gVWe7P1JggjiskO+LST1ug==}
'@tauri-apps/plugin-shell@2.0.1':
resolution: {integrity: sha512-akU1b77sw3qHiynrK0s930y8zKmcdrSD60htjH+mFZqv5WaakZA/XxHR3/sF1nNv9Mgmt/Shls37HwnOr00aSw==}
@@ -747,9 +769,6 @@ packages:
resolution: {integrity: sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==}
deprecated: This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed!
'@types/node@20.17.9':
resolution: {integrity: sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==}
'@types/node@22.10.1':
resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==}
@@ -1391,6 +1410,10 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1583,6 +1606,10 @@ packages:
resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==}
engines: {node: '>=0.10.0'}
dependency-graph@0.11.0:
resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==}
engines: {node: '>= 0.6.0'}
detect-indent@4.0.0:
resolution: {integrity: sha512-BDKtmHlOzwI7iRuEkhzsnPoi5ypEhWAJB5RvHWe1kMr06js3uK5B3734i3ui5Yd+wOJV1cpE4JnivPD283GU/A==}
engines: {node: '>=0.10.0'}
@@ -2005,10 +2032,18 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.2.4:
resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
engines: {node: '>= 0.4'}
get-stdin@9.0.0:
resolution: {integrity: sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==}
engines: {node: '>=12'}
get-symbol-description@1.0.2:
resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==}
engines: {node: '>= 0.4'}
@@ -2069,6 +2104,10 @@ packages:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
globby@14.0.2:
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
engines: {node: '>=18'}
gopd@1.1.0:
resolution: {integrity: sha512-FQoVQnqcdk4hVM4JN1eromaun4iuS34oStkdlLENLdpULsuQcTyXj8w7ayhuUfPwEYZ1ZOooOTT6fdA9Vmx/RA==}
engines: {node: '>= 0.4'}
@@ -2795,6 +2834,10 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-type@5.0.0:
resolution: {integrity: sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==}
engines: {node: '>=12'}
path-webpack@0.0.3:
resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==}
@@ -2829,6 +2872,13 @@ packages:
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
engines: {node: '>= 0.4'}
postcss-cli@11.0.0:
resolution: {integrity: sha512-xMITAI7M0u1yolVcXJ9XTZiO9aO49mcoKQy6pCDFdMh9kGqhzLVpWxeD/32M/QBmkhcGypZFFOLNLmIW4Pg4RA==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
postcss: ^8.0.0
postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
@@ -2853,16 +2903,47 @@ packages:
ts-node:
optional: true
postcss-load-config@5.1.0:
resolution: {integrity: sha512-G5AJ+IX0aD0dygOE0yFZQ/huFFMSNneyfp0e3/bT05a8OfPC5FUoZRPfGijUdGOJNMewJiwzcHJXFafFzeKFVA==}
engines: {node: '>= 18'}
peerDependencies:
jiti: '>=1.21.0'
postcss: '>=8.0.9'
tsx: ^4.8.1
peerDependenciesMeta:
jiti:
optional: true
postcss:
optional: true
tsx:
optional: true
postcss-nested@6.2.0:
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
engines: {node: '>=12.0'}
peerDependencies:
postcss: ^8.2.14
postcss-nested@7.0.2:
resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==}
engines: {node: '>=18.0'}
peerDependencies:
postcss: ^8.2.14
postcss-reporter@7.1.0:
resolution: {integrity: sha512-/eoEylGWyy6/DOiMP5lmFRdmDKThqgn7D6hP2dXKJI/0rJSO1ADFNngZfDzxL0YAxFvws+Rtpuji1YIHj4mySA==}
engines: {node: '>=10'}
peerDependencies:
postcss: ^8.1.0
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'}
postcss-selector-parser@7.0.0:
resolution: {integrity: sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==}
engines: {node: '>=4'}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@@ -2953,6 +3034,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
pretty-hrtime@1.0.3:
resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==}
engines: {node: '>= 0.8'}
private@0.1.8:
resolution: {integrity: sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==}
engines: {node: '>= 0.6'}
@@ -3078,6 +3163,10 @@ packages:
resolution: {integrity: sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==}
engines: {node: '>=0.10.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -3212,6 +3301,10 @@ packages:
resolution: {integrity: sha512-3TYDR7xWt4dIqV2JauJr+EJeW356RXijHeUlO+8djJ+uBXPn8/2dpzBc8yQhh583sVvc9CvFAeQVgijsH+PNNg==}
engines: {node: '>=0.10.0'}
slash@5.1.0:
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
engines: {node: '>=14.16'}
smob@1.5.0:
resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==}
@@ -3399,6 +3492,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
thenby@1.3.4:
resolution: {integrity: sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@@ -3496,12 +3592,13 @@ packages:
unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
unicorn-magic@0.1.0:
resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==}
engines: {node: '>=18'}
union-value@1.0.1:
resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==}
engines: {node: '>=0.10.0'}
@@ -3597,6 +3694,10 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@2.1.2:
resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==}
@@ -3605,6 +3706,14 @@ packages:
engines: {node: '>= 14'}
hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'}
@@ -3940,7 +4049,9 @@ snapshots:
'@rushstack/eslint-patch@1.10.4': {}
'@sindresorhus/tsconfig@7.0.0': {}
'@sindresorhus/merge-streams@2.3.0': {}
'@sindresorhus/tsconfig@6.0.0': {}
'@swc/counter@0.1.3': {}
@@ -3993,11 +4104,15 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.1.0
'@tauri-apps/cli-win32-x64-msvc': 2.1.0
'@tauri-apps/plugin-cli@2.0.0':
dependencies:
'@tauri-apps/api': 2.1.1
'@tauri-apps/plugin-dialog@2.0.1':
dependencies:
'@tauri-apps/api': 2.1.1
'@tauri-apps/plugin-fs@2.0.2':
'@tauri-apps/plugin-fs@2.0.3':
dependencies:
'@tauri-apps/api': 2.1.1
@@ -4005,7 +4120,7 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.1.1
'@tauri-apps/plugin-log@2.0.0':
'@tauri-apps/plugin-log@2.0.1':
dependencies:
'@tauri-apps/api': 2.1.1
@@ -4013,6 +4128,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.1.1
'@tauri-apps/plugin-process@2.0.0':
dependencies:
'@tauri-apps/api': 2.1.1
'@tauri-apps/plugin-shell@2.0.1':
dependencies:
'@tauri-apps/api': 2.1.1
@@ -4055,10 +4174,6 @@ snapshots:
dependencies:
localforage: 1.10.0
'@types/node@20.17.9':
dependencies:
undici-types: 6.19.8
'@types/node@22.10.1':
dependencies:
undici-types: 6.20.0
@@ -5100,6 +5215,12 @@ snapshots:
client-only@0.0.1: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
clsx@2.1.1: {}
collection-visit@1.0.0:
@@ -5299,6 +5420,8 @@ snapshots:
is-descriptor: 1.0.3
isobject: 3.0.1
dependency-graph@0.11.0: {}
detect-indent@4.0.0:
dependencies:
repeating: 2.0.1
@@ -5889,6 +6012,8 @@ snapshots:
functions-have-names@1.2.3: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.2.4:
dependencies:
es-errors: 1.3.0
@@ -5897,6 +6022,8 @@ snapshots:
has-symbols: 1.0.3
hasown: 2.0.2
get-stdin@9.0.0: {}
get-symbol-description@1.0.2:
dependencies:
call-bind: 1.0.7
@@ -5964,6 +6091,15 @@ snapshots:
define-properties: 1.2.1
gopd: 1.1.0
globby@14.0.2:
dependencies:
'@sindresorhus/merge-streams': 2.3.0
fast-glob: 3.3.2
ignore: 5.3.2
path-type: 5.0.0
slash: 5.1.0
unicorn-magic: 0.1.0
gopd@1.1.0:
dependencies:
get-intrinsic: 1.2.4
@@ -6255,7 +6391,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 20.17.9
'@types/node': 22.10.1
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -6672,6 +6808,8 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
path-type@5.0.0: {}
path-webpack@0.0.3: {}
path2d@0.2.1:
@@ -6694,6 +6832,25 @@ snapshots:
possible-typed-array-names@1.0.0: {}
postcss-cli@11.0.0(jiti@1.21.6)(postcss@8.4.49):
dependencies:
chokidar: 3.6.0
dependency-graph: 0.11.0
fs-extra: 11.2.0
get-stdin: 9.0.0
globby: 14.0.2
picocolors: 1.1.1
postcss: 8.4.49
postcss-load-config: 5.1.0(jiti@1.21.6)(postcss@8.4.49)
postcss-reporter: 7.1.0(postcss@8.4.49)
pretty-hrtime: 1.0.3
read-cache: 1.0.0
slash: 5.1.0
yargs: 17.7.2
transitivePeerDependencies:
- jiti
- tsx
postcss-import@15.1.0(postcss@8.4.49):
dependencies:
postcss: 8.4.49
@@ -6714,16 +6871,40 @@ snapshots:
postcss: 8.4.49
ts-node: 10.9.2(@types/node@22.10.1)(typescript@5.7.2)
postcss-load-config@5.1.0(jiti@1.21.6)(postcss@8.4.49):
dependencies:
lilconfig: 3.1.2
yaml: 2.6.1
optionalDependencies:
jiti: 1.21.6
postcss: 8.4.49
postcss-nested@6.2.0(postcss@8.4.49):
dependencies:
postcss: 8.4.49
postcss-selector-parser: 6.1.2
postcss-nested@7.0.2(postcss@8.4.49):
dependencies:
postcss: 8.4.49
postcss-selector-parser: 7.0.0
postcss-reporter@7.1.0(postcss@8.4.49):
dependencies:
picocolors: 1.1.1
postcss: 8.4.49
thenby: 1.3.4
postcss-selector-parser@6.1.2:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-selector-parser@7.0.0:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-value-parser@4.2.0: {}
postcss@8.4.31:
@@ -6773,6 +6954,8 @@ snapshots:
prettier@3.4.1: {}
pretty-hrtime@1.0.3: {}
private@0.1.8: {}
process-nextick-args@2.0.1: {}
@@ -6926,6 +7109,8 @@ snapshots:
dependencies:
is-finite: 1.1.0
require-directory@2.1.1: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -7114,6 +7299,8 @@ snapshots:
slash@1.0.0: {}
slash@5.1.0: {}
smob@1.5.0: {}
snapdragon-node@2.1.1:
@@ -7353,6 +7540,8 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
thenby@1.3.4: {}
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@@ -7474,10 +7663,10 @@ snapshots:
has-symbols: 1.0.3
which-boxed-primitive: 1.0.2
undici-types@6.19.8: {}
undici-types@6.20.0: {}
unicorn-magic@0.1.0: {}
union-value@1.0.1:
dependencies:
arr-union: 3.1.0
@@ -7613,10 +7802,24 @@ snapshots:
wrappy@1.0.2: {}
y18n@5.0.8: {}
yallist@2.1.2: {}
yaml@2.6.1: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yn@3.1.1:
optional: true